Public: anyone can read this; the crew can change it.
MCP `app_domains` refuses in the same sentence as the app's settings page and the terminal, not `the domain is refused: invalid_host` #7606
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7596-mcp-app-domains-refuses-in-the-same-sent → main 1 commit fork
2 files
changed +49
−11
src/http/mcp/tools/app_domains.ts +30 −10
| @@ -4,6 +4,13 @@ import { | ||
| 4 | 4 | listAppDomains, |
| 5 | 5 | removeAppDomain, |
| 6 | 6 | } from "../../../actions/domains.ts"; |
| 7 | import { | |
| 8 | NO_SUCH_DOMAIN, | |
| 9 | domainAddedText, | |
| 10 | domainRefusalText, | |
| 11 | domainRemovedText, | |
| 12 | domainSaidText, | |
| 13 | } from "../../../actions/domain_words.ts"; | |
| 7 | 14 | import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts"; |
| 8 | 15 | |
| 9 | 16 | export const tool: Tool = { |
| @@ -28,18 +35,31 @@ export const tool: Tool = { | ||
| 28 | 35 | return bad("invalid", "action is list, add, check or remove"); |
| 29 | 36 | const host = str(args["host"]); |
| 30 | 37 | if (action !== "list" && host === "") return bad("invalid", "host is the domain name"); |
| 31 | const answer = <T>(result: { ok: true; value: T } | { ok: false; error: string }): Answer => | |
| 32 | result.ok | |
| 33 | ? good(result.value ?? {}) | |
| 34 | : result.error === "not_found" | |
| 35 | ? bad("not_found", "no such app or repository (owner/name)") | |
| 36 | : bad(result.error, `the domain is refused: ${result.error}`); | |
| 38 | const refused = (error: string, fallback: string): Answer => | |
| 39 | error === "not_found" | |
| 40 | ? bad("not_found", "no such app or repository (owner/name)") | |
| 41 | : bad(error, domainRefusalText(error, fallback)); | |
| 37 | 42 | if (action === "list") { |
| 38 | 43 | const listed = listAppDomains(ctx.app, user, repo); |
| 39 | return listed.ok ? good({ items: listed.value, next_cursor: null }) : answer(listed); | |
| 44 | return listed.ok | |
| 45 | ? good({ items: listed.value, next_cursor: null }) | |
| 46 | : refused(listed.error, NO_SUCH_DOMAIN); | |
| 40 | 47 | } |
| 41 | if (action === "add") return answer(addAppDomain(ctx.app, user, repo, host)); | |
| 42 | if (action === "check") return answer(await checkAppDomain(ctx.app, user, repo, host)); | |
| 43 | return answer(removeAppDomain(ctx.app, user, repo, host)); | |
| 48 | if (action === "add") { | |
| 49 | const added = addAppDomain(ctx.app, user, repo, host); | |
| 50 | return added.ok | |
| 51 | ? good({ ...added.value, says: domainAddedText(added.value) }) | |
| 52 | : refused(added.error, "That domain could not be added."); | |
| 53 | } | |
| 54 | if (action === "check") { | |
| 55 | const checked = await checkAppDomain(ctx.app, user, repo, host); | |
| 56 | return checked.ok | |
| 57 | ? good({ ...checked.value, says: domainSaidText(checked.value) }) | |
| 58 | : refused(checked.error, NO_SUCH_DOMAIN); | |
| 59 | } | |
| 60 | const removed = removeAppDomain(ctx.app, user, repo, host); | |
| 61 | return removed.ok | |
| 62 | ? good({ says: domainRemovedText(host) }) | |
| 63 | : refused(removed.error, NO_SUCH_DOMAIN); | |
| 44 | 64 | }, |
| 45 | 65 | }; |
test/mcp-app-domains.test.ts +19 −1
| @@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | ||
| 6 | 6 | import { mkdtemp, rm } from "node:fs/promises"; |
| 7 | 7 | import { tmpdir } from "node:os"; |
| 8 | 8 | import { join } from "node:path"; |
| 9 | import { domainAddedText, domainRefusalText } from "../src/actions/domain_words.ts"; | |
| 9 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 11 | import { newApp } from "../src/actions/apps.ts"; |
| 11 | 12 | import { AppsRuntime } from "../src/apps/runtime.ts"; |
| @@ -102,15 +103,19 @@ describe("MCP app_domains", () => { | ||
| 102 | 103 | expect(added.value.host).toBe("app.example.com"); |
| 103 | 104 | expect(added.value.verified).toBe(false); |
| 104 | 105 | expect(added.value.records.length).toBe(2); |
| 106 | expect(added.value.says).toBe(domainAddedText(added.value)); | |
| 105 | 107 | |
| 106 | 108 | const checked = await tool(admin, { repo, action: "check", host: "app.example.com" }); |
| 107 | 109 | expect(checked.value.verified).toBe(false); |
| 108 | 110 | expect(checked.value.said).toContain("No TXT record"); |
| 111 | expect(checked.value.says).toBe(checked.value.said); | |
| 109 | 112 | |
| 110 | 113 | const listed = await tool(admin, { repo, action: "list" }); |
| 111 | 114 | expect(listed.value.items.map((d: any) => d.host)).toEqual(["app.example.com"]); |
| 112 | 115 | |
| 113 | expect((await tool(admin, { repo, action: "remove", host: "app.example.com" })).value).toEqual({}); | |
| 116 | expect((await tool(admin, { repo, action: "remove", host: "app.example.com" })).value).toEqual({ | |
| 117 | says: "app.example.com is removed. The forge no longer answers there.", | |
| 118 | }); | |
| 114 | 119 | expect((await tool(admin, { repo, action: "list" })).value.items).toEqual([]); |
| 115 | 120 | expect((await tool(admin, { repo, action: "check", host: "app.example.com" })).error).toContain("no_such_domain"); |
| 116 | 121 | }); |
| @@ -128,4 +133,17 @@ describe("MCP app_domains", () => { | ||
| 128 | 133 | expect((await tool(admin, { repo, action: "add" })).error).toContain("host"); |
| 129 | 134 | expect((await tool(admin, { repo, action: "drop", host: "a.example.com" })).error).toContain("action"); |
| 130 | 135 | }); |
| 136 | ||
| 137 | test("a refusal says what the settings page says for the same code", async () => { | |
| 138 | for (const [host, code] of [["not a host", "invalid_host"], ["forge.test", "reserved_host"]] as const) { | |
| 139 | const said = domainRefusalText(code, "That domain could not be added."); | |
| 140 | const got = await tool(admin, { repo, action: "add", host }); | |
| 141 | expect(got.error).toContain(code); | |
| 142 | expect(got.error).toContain(said); | |
| 143 | } | |
| 144 | await tool(admin, { repo, action: "add", host: "dup.example.com" }); | |
| 145 | const again = await tool(admin, { repo, action: "add", host: "dup.example.com" }); | |
| 146 | expect(again.error).toContain(domainRefusalText("taken", "")); | |
| 147 | await tool(admin, { repo, action: "remove", host: "dup.example.com" }); | |
| 148 | }); | |
| 131 | 149 | }); |
new above
offline