diff --git a/apps/web/__tests__/unit/mcp-auth.test.ts b/apps/web/__tests__/unit/mcp-auth.test.ts index 550e14d8f3..ba89ac047d 100644 --- a/apps/web/__tests__/unit/mcp-auth.test.ts +++ b/apps/web/__tests__/unit/mcp-auth.test.ts @@ -97,9 +97,10 @@ describe("MCP OAuth authorization", () => { true, ); expect(isMcpRedirectUri("http://127.0.0.1:49152/callback")).toBe(true); + expect(isMcpRedirectUri("http://localhost:49152/callback")).toBe(true); for (const uri of [ "http://chatgpt.com/connector/callback", - "http://localhost:49152/callback", + "http://localhost.evil.example:49152/callback", "http://127.0.0.1/callback", "https://chatgpt.com/connector/callback#fragment", "https://user:password@chatgpt.com/callback", diff --git a/apps/web/__tests__/unit/mcp-server.test.ts b/apps/web/__tests__/unit/mcp-server.test.ts index 1646bd6e3a..d396c2c4dc 100644 --- a/apps/web/__tests__/unit/mcp-server.test.ts +++ b/apps/web/__tests__/unit/mcp-server.test.ts @@ -83,30 +83,31 @@ describe("hosted MCP transport", () => { }); it("preserves browser preflight and OAuth challenge headers", async () => { - const origin = "https://chatgpt.com"; - const preflight = await OPTIONS( - new Request("https://cap.so/api/mcp", { - method: "OPTIONS", - headers: { Origin: origin }, - }), - ); - expect(preflight.status).toBe(204); - expect(preflight.headers.get("access-control-allow-origin")).toBe(origin); - expect(preflight.headers.get("cache-control")).toBe("no-store"); - const unauthorized = await POST( - new Request("https://cap.so/api/mcp", { - method: "POST", - headers: { Origin: origin, "Content-Type": "application/json" }, - body: "{}", - }), - ); - expect(unauthorized.status).toBe(401); - expect(unauthorized.headers.get("access-control-allow-origin")).toBe( - origin, - ); - expect(unauthorized.headers.get("www-authenticate")).toContain( - "resource_metadata", - ); + for (const origin of ["https://chatgpt.com", "https://grok.com"]) { + const preflight = await OPTIONS( + new Request("https://cap.so/api/mcp", { + method: "OPTIONS", + headers: { Origin: origin }, + }), + ); + expect(preflight.status).toBe(204); + expect(preflight.headers.get("access-control-allow-origin")).toBe(origin); + expect(preflight.headers.get("cache-control")).toBe("no-store"); + const unauthorized = await POST( + new Request("https://cap.so/api/mcp", { + method: "POST", + headers: { Origin: origin, "Content-Type": "application/json" }, + body: "{}", + }), + ); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("access-control-allow-origin")).toBe( + origin, + ); + expect(unauthorized.headers.get("www-authenticate")).toContain( + "resource_metadata", + ); + } }); it("keeps host, origin, media type, and body limits ahead of MCP dispatch", async () => { @@ -150,6 +151,32 @@ describe("hosted MCP transport", () => { expect(listBody).toContain("caps_list"); expect(listBody).toContain("caps_get"); expect(listBody).toContain("caps_context"); + expect(listBody).toContain('"openWorldHint":false'); + const data = listBody.split("\n").find((line) => line.startsWith("data: ")); + expect(data).toBeDefined(); + const tools = JSON.parse(data?.slice(6) ?? "").result.tools; + expect(tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "caps_list", + annotations: expect.objectContaining({ + title: "List Cap recordings", + }), + }), + expect.objectContaining({ + name: "caps_get", + annotations: expect.objectContaining({ + title: "Get a Cap recording", + }), + }), + expect.objectContaining({ + name: "caps_context", + annotations: expect.objectContaining({ + title: "Read a Cap recording", + }), + }), + ]), + ); expect(listBody).not.toContain("caps_delete"); const called = await POST( request({ diff --git a/apps/web/app/api/mcp/route.ts b/apps/web/app/api/mcp/route.ts index 32704c367b..e59700e3e9 100644 --- a/apps/web/app/api/mcp/route.ts +++ b/apps/web/app/api/mcp/route.ts @@ -24,6 +24,7 @@ const allowedOrigins = () => "https://chatgpt.com", "https://claude.ai", "https://muse.ai", + "https://grok.com", ]); const corsHeaders = (request: Request) => { diff --git a/apps/web/lib/mcp-auth.ts b/apps/web/lib/mcp-auth.ts index c6c14c680c..d61ea1fdaa 100644 --- a/apps/web/lib/mcp-auth.ts +++ b/apps/web/lib/mcp-auth.ts @@ -37,7 +37,9 @@ export const isMcpRedirectUri = (value: string) => { const url = new URL(value); const loopback = url.protocol === "http:" && - (url.hostname === "127.0.0.1" || url.hostname === "[::1]") && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]") && url.port.length > 0; return ( (url.protocol === "https:" || loopback) && diff --git a/apps/web/lib/mcp-server.ts b/apps/web/lib/mcp-server.ts index 9725b3d55e..aa96c9fba6 100644 --- a/apps/web/lib/mcp-server.ts +++ b/apps/web/lib/mcp-server.ts @@ -17,6 +17,7 @@ const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, + openWorldHint: false, }; const result = (value: Record) => ({ @@ -54,7 +55,7 @@ export const createCapMcpServer = (userId: User.UserId) => { }, additionalProperties: false, }), - annotations: readOnly, + annotations: { ...readOnly, title: "List Cap recordings" }, }, async (input) => { try { @@ -77,7 +78,7 @@ export const createCapMcpServer = (userId: User.UserId) => { required: ["id"], additionalProperties: false, }), - annotations: readOnly, + annotations: { ...readOnly, title: "Get a Cap recording" }, }, async ({ id }) => { try { @@ -105,7 +106,7 @@ export const createCapMcpServer = (userId: User.UserId) => { required: ["id"], additionalProperties: false, }), - annotations: readOnly, + annotations: { ...readOnly, title: "Read a Cap recording" }, _meta: { ui: { resourceUri: cardUri }, "openai/outputTemplate": cardUri }, }, async ({ id, query }) => {