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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7596-mcp-app-domains-refuses-in-the-same-sent → main 1 commit fork

  • Merged. scout/7596-mcp-app-domains-refuses-in-the-same-sent was merged into main as b83b9cf.

Review

Sign in to review this change.
2 files changed +49 −11
src/http/mcp/tools/app_domains.ts +30 −10
@@ -4,6 +4,13 @@ import {
44 listAppDomains,
55 removeAppDomain,
66 } from "../../../actions/domains.ts";
7 import {
8 NO_SUCH_DOMAIN,
9 domainAddedText,
10 domainRefusalText,
11 domainRemovedText,
12 domainSaidText,
13 } from "../../../actions/domain_words.ts";
714 import { type Answer, good, bad, type Tool, REPO_ARG, repoArg, isAnswer, str } from "../shared.ts";
815
916 export const tool: Tool = {
@@ -28,18 +35,31 @@ export const tool: Tool = {
2835 return bad("invalid", "action is list, add, check or remove");
2936 const host = str(args["host"]);
3037 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));
3742 if (action === "list") {
3843 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);
4047 }
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);
4464 },
4565 };
test/mcp-app-domains.test.ts +19 −1
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
66 import { mkdtemp, rm } from "node:fs/promises";
77 import { tmpdir } from "node:os";
88 import { join } from "node:path";
9 import { domainAddedText, domainRefusalText } from "../src/actions/domain_words.ts";
910 import { createApp, type App } from "../src/app.ts";
1011 import { newApp } from "../src/actions/apps.ts";
1112 import { AppsRuntime } from "../src/apps/runtime.ts";
@@ -102,15 +103,19 @@ describe("MCP app_domains", () => {
102103 expect(added.value.host).toBe("app.example.com");
103104 expect(added.value.verified).toBe(false);
104105 expect(added.value.records.length).toBe(2);
106 expect(added.value.says).toBe(domainAddedText(added.value));
105107
106108 const checked = await tool(admin, { repo, action: "check", host: "app.example.com" });
107109 expect(checked.value.verified).toBe(false);
108110 expect(checked.value.said).toContain("No TXT record");
111 expect(checked.value.says).toBe(checked.value.said);
109112
110113 const listed = await tool(admin, { repo, action: "list" });
111114 expect(listed.value.items.map((d: any) => d.host)).toEqual(["app.example.com"]);
112115
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 });
114119 expect((await tool(admin, { repo, action: "list" })).value.items).toEqual([]);
115120 expect((await tool(admin, { repo, action: "check", host: "app.example.com" })).error).toContain("no_such_domain");
116121 });
@@ -128,4 +133,17 @@ describe("MCP app_domains", () => {
128133 expect((await tool(admin, { repo, action: "add" })).error).toContain("host");
129134 expect((await tool(admin, { repo, action: "drop", host: "a.example.com" })).error).toContain("action");
130135 });
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 });
131149 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs