feat(mcp): migrate to MCP SDK v2 stateless handler (#464)

This commit is contained in:
Ben Senescu 2026-08-08 12:44:48 -04:00 committed by GitHub
parent 45403aa06f
commit cfc8456767
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 2019 additions and 1170 deletions

View File

@ -74,11 +74,13 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^3.0.211", "@ai-sdk/react": "^3.0.211",
"@cloudflare/ai-chat": "^0.8.4", "@cloudflare/ai-chat": "^0.10.1",
"@cloudflare/think": "0.12.1", "@cloudflare/think": "0.15.1",
"@cloudflare/workers-oauth-provider": "^0.4.0", "@cloudflare/workers-oauth-provider": "^0.10.2",
"@every-app/sdk": "^0.1.14", "@every-app/sdk": "^0.1.14",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/sdk": "1.30.0",
"@modelcontextprotocol/server": "2.0.0",
"@openrouter/ai-sdk-provider": "^2.9.0", "@openrouter/ai-sdk-provider": "^2.9.0",
"@tanstack/query-core": "^5.101.2", "@tanstack/query-core": "^5.101.2",
"@tanstack/react-form": "^1.33.0", "@tanstack/react-form": "^1.33.0",
@ -87,7 +89,7 @@
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-start": "^1.168.26", "@tanstack/react-start": "^1.168.26",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"agents": "0.17.3", "agents": "0.20.1",
"ai": "^6.0.199", "ai": "^6.0.199",
"autumn-js": "^1.2.33", "autumn-js": "^1.2.33",
"better-auth": "^1.6.22", "better-auth": "^1.6.22",
@ -117,7 +119,7 @@
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.42.3", "@cloudflare/vite-plugin": "^1.42.3",
"@cloudflare/workers-types": "^4.20260611.1", "@cloudflare/workers-types": "^4.20260702.1",
"@distilled.cloud/cloudflare": "0.28.2", "@distilled.cloud/cloudflare": "0.28.2",
"@effect/platform-node": "4.0.0-beta.93", "@effect/platform-node": "4.0.0-beta.93",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",

447
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,15 @@
minimumReleaseAge: 11520 minimumReleaseAge: 11520
minimumReleaseAgeExclude: minimumReleaseAgeExclude:
- "@every-app/*" - "@every-app/*"
# TEMPORARY (remove after 2026-08-15): workers-oauth-provider 0.10.2 was
# published 2026-08-07; needed for the MCP SDK v2 migration plus the
# ChatGPT DCR client-auth-negotiation fix.
- "@cloudflare/workers-oauth-provider"
# TEMPORARY (remove after 2026-08-09): security releases published
# 2026-07-30/31; required to clear current high-severity advisories.
- "brace-expansion"
- "fast-uri"
- "js-yaml"
# TEMPORARY (remove after 2026-07-16): alchemy 2.0.0-beta.61 and its # TEMPORARY (remove after 2026-07-16): alchemy 2.0.0-beta.61 and its
# matching @distilled.cloud/*@0.28.2 published 2026-07-08 — 2 days shy of # matching @distilled.cloud/*@0.28.2 published 2026-07-08 — 2 days shy of
# the window. Exact-pinned in package.json. # the window. Exact-pinned in package.json.
@ -27,23 +36,29 @@ auditConfig:
overrides: overrides:
# GHSA-4x5r-pxfx-6jf8 — waiting on @tanstack/devtools-vite # GHSA-4x5r-pxfx-6jf8 — waiting on @tanstack/devtools-vite
"@babel/core": "^7.29.6" "@babel/core": "^7.29.6"
# GHSA-3jxr-9vmj-r5cp, GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895 — waiting on just-bash
brace-expansion: "^5.0.9"
# GHSA-737v-mqg7-c878 — waiting on better-auth # GHSA-737v-mqg7-c878 — waiting on better-auth
defu: "^6.1.5" defu: "^6.1.5"
# 13 advisories incl. GHSA-x4vx-rjvf-j5p4, GHSA-gvmj-g25r-r7wr — waiting on posthog-js # 13 advisories incl. GHSA-x4vx-rjvf-j5p4, GHSA-gvmj-g25r-r7wr — waiting on posthog-js
dompurify: "^3.4.11" dompurify: "^3.4.11"
# GHSA-hmw2-7cc7-3qxx — waiting on cloudflare # GHSA-v2hh-gcrm-f6hx, GHSA-7p8r-x3mc-p8w7, GHSA-4c8g-83qw-93j6 — waiting on @modelcontextprotocol/sdk
form-data: "^4.0.6" fast-uri: "^3.1.5"
# 9 advisories incl. GHSA-88fw-hqm2-52qc (CORS) — waiting on @modelcontextprotocol/sdk # 9 advisories incl. GHSA-88fw-hqm2-52qc (CORS) — waiting on @modelcontextprotocol/sdk
hono: "^4.12.25" hono: "^4.12.25"
# GHSA-mwp4-54f8-5fhr — waiting on @modelcontextprotocol/sdk
ip-address: "^10.3.1"
# GHSA-5p4m-2wfm-xmqj — waiting on @tanstack/start-plugin-core
js-yaml: "^4.3.1"
# GHSA-v6wh-96g9-6wx3 — waiting on @tanstack/devtools-vite # GHSA-v6wh-96g9-6wx3 — waiting on @tanstack/devtools-vite
launch-editor: "^2.14.1" launch-editor: "^2.14.1"
# GHSA-qx2v-qp2m-jg93 — waiting on vite # GHSA-qx2v-qp2m-jg93, GHSA-r28c-9q8g-f849 — waiting on vite
postcss: "^8.5.10" postcss: "^8.5.18"
# GHSA-q8mj-m7cp-5q26 — waiting on @modelcontextprotocol/sdk # GHSA-q8mj-m7cp-5q26 — waiting on @modelcontextprotocol/sdk
qs: "^6.15.2" qs: "^6.15.2"
# GHSA-w7jw-789q-3m8p — waiting on @tanstack/devtools-vite # GHSA-w7jw-789q-3m8p — waiting on @tanstack/devtools-vite
shell-quote: "^1.8.4" shell-quote: "^1.8.4"
# GHSA-v3rj-xjv7-4jmq — waiting on knip # GHSA-v3rj-xjv7-4jmq — waiting on knip
smol-toml: "^1.6.1" smol-toml: "^1.6.1"
# 7 advisories incl. GHSA-vmh5-mc38-953g — waiting on cheerio + miniflare # 8 advisories incl. GHSA-vmh5-mc38-953g, GHSA-4cwx-7wf7-3272 — waiting on cheerio + miniflare
undici: "^7.28.0" undici: "^7.29.0"

View File

@ -181,13 +181,32 @@ export { SamChatAgent } from "./server/features/sam/SamChatAgent";
// Durable Object class for the per-audit crawl scratchpad. // Durable Object class for the per-audit crawl scratchpad.
export { AuditScratchpad } from "./server/features/audit/AuditScratchpad"; export { AuditScratchpad } from "./server/features/audit/AuditScratchpad";
// Daily OAuth KV garbage collection; must match a trigger in wrangler.jsonc.
const MCP_OAUTH_PURGE_CRON = "17 3 * * *";
export default { export default {
fetch, fetch,
async scheduled( async scheduled(
_controller: ScheduledController, controller: ScheduledController,
env: Env, env: Env,
_ctx: ExecutionContext, _ctx: ExecutionContext,
) { ) {
if (controller.cron === MCP_OAUTH_PURGE_CRON) {
// Only hosted mode runs the OAuth provider (and has OAUTH_KV bound).
if (isHostedAuthMode(getAuthMode(env.AUTH_MODE))) {
const result = await openSeoOAuthProvider.purgeExpiredData(
env as OpenSeoOAuthEnv,
);
console.log("[mcp-oauth] purged expired OAuth data", result);
if (!result.done) {
// The sweep only advances past live records via deletions; a
// persistent incomplete scan means the keyspace outgrew the batch.
console.warn("[mcp-oauth] purge did not cover the full keyspace");
}
}
return;
}
// Watchdog first: reconcile audits stuck in "running" whose workflow died // Watchdog first: reconcile audits stuck in "running" whose workflow died
// without reaching mark-failed (OOM/CPU kills, expired instances). Runs // without reaching mark-failed (OOM/CPU kills, expired instances). Runs
// before the rank loop so a slow tick can't delay or starve it. Its // before the rank loop so a slow tick can't delay or starve it. Its

View File

@ -29,7 +29,7 @@ import {
} from "@/server/billing/subscription"; } from "@/server/billing/subscription";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_SCOPE } from "@/lib/oauth-resource"; import { MCP_SCOPE } from "@/lib/oauth-resource";
import { buildFirstPartyMcpAuthContext } from "@/server/mcp/context"; import type { ToolAuthContext } from "@/server/mcp/context";
// SAM's writable context blocks, backed by sam_project_memory rows shared by // SAM's writable context blocks, backed by sam_project_memory rows shared by
// every chat session in the project. // every chat session in the project.
@ -255,13 +255,14 @@ export class SamChatAgent extends Think {
const baseUrl = const baseUrl =
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ?? (await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
"https://app.openseo.so"; "https://app.openseo.so";
const authContext = buildFirstPartyMcpAuthContext({ const authContext: ToolAuthContext = {
userId: ctx.row.userId, userId: ctx.row.userId,
userEmail: ctx.userEmail, userEmail: ctx.userEmail,
organizationId, organizationId,
baseUrl, baseUrl,
clientId: null,
scopes: [MCP_SCOPE], scopes: [MCP_SCOPE],
}); };
return { return {
tools: buildSamMcpTools(authContext, { tools: buildSamMcpTools(authContext, {

View File

@ -1,12 +1,9 @@
import { tool, type Tool, type ToolSet } from "ai"; import { tool, type Tool, type ToolSet } from "ai";
import { z, type ZodRawShape } from "zod"; import { z, type ZodRawShape } from "zod";
import { withPgClient } from "@/db"; import { withPgClient } from "@/db";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context";
createWorkersOAuthMcpProps, import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile"; import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions"; import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
@ -41,10 +38,11 @@ const SAM_MAX_MAPPED_URLS = 60;
// the exact same definitions the MCP server registers, so the in-app agent and // the exact same definitions the MCP server registers, so the in-app agent and
// the MCP server can never drift in what a tool does or how it bills. // the MCP server can never drift in what a tool does or how it bills.
type McpToolDefinition<Shape extends ZodRawShape> = { type McpToolDefinition<Shape extends ZodRawShape> = {
name: string;
config: { description: string; inputSchema: Shape }; config: { description: string; inputSchema: Shape };
handler: ( handler: (
args: z.infer<z.ZodObject<Shape>>, args: z.infer<z.ZodObject<Shape>>,
extra: ToolExtra, context: ToolContext,
) => Promise<CallToolResult>; ) => Promise<CallToolResult>;
}; };
@ -62,9 +60,11 @@ function toModelOutput(result: CallToolResult): unknown {
: { summary }; : { summary };
} }
// Adapt one MCP tool into an AI SDK tool. The MCP handler reads auth from `extra` // Adapt one OpenSEO tool into an AI SDK tool. The shared handler receives the
// (via requireMcpToolAuthContext) and self-gates project access against the org, // same explicit auth context as the MCP transport, and runs through the same
// so SAM gets identical scoping and metering for free. // instrumentation wrapper, so project scoping, credit metering, and the
// mcp:tool_call telemetry (source "in_app_agent", null clientId) all match the
// external MCP path.
// //
// SAM always runs inside one project (the session row), so we bind that project // SAM always runs inside one project (the session row), so we bind that project
// server-side: any tool with a `projectId` input has it stripped from the schema // server-side: any tool with a `projectId` input has it stripped from the schema
@ -72,11 +72,12 @@ function toModelOutput(result: CallToolResult): unknown {
// the id, can't target another project, and can't hallucinate a wrong one. // the id, can't target another project, and can't hallucinate a wrong one.
function adaptMcpTool<Shape extends ZodRawShape>( function adaptMcpTool<Shape extends ZodRawShape>(
def: McpToolDefinition<Shape>, def: McpToolDefinition<Shape>,
extra: ToolExtra, context: ToolContext,
projectId: string, projectId: string,
): Tool { ): Tool {
const { projectId: _projectIdSchema, ...modelShape } = def.config.inputSchema; const { projectId: _projectIdSchema, ...modelShape } = def.config.inputSchema;
const bindsProject = "projectId" in def.config.inputSchema; const bindsProject = "projectId" in def.config.inputSchema;
const handler = instrumentMcpToolHandler(def.name, undefined, def.handler);
return tool({ return tool({
description: def.config.description, description: def.config.description,
@ -93,7 +94,7 @@ function adaptMcpTool<Shape extends ZodRawShape>(
// request scope, so each execution scopes its own Postgres client // request scope, so each execution scopes its own Postgres client
// (no-op in D1 mode) — same rule as the DO's other DB-touching seams. // (no-op in D1 mode) — same rule as the DO's other DB-touching seams.
return toModelOutput( return toModelOutput(
await withPgClient(() => def.handler(fullArgs, extra)), await withPgClient(() => handler(fullArgs, context)),
); );
} catch (error) { } catch (error) {
// Surface the failure to the model so it can recover or report it, // Surface the failure to the model so it can recover or report it,
@ -174,32 +175,19 @@ function scrapeTools(projectDomain: string | null): ToolSet {
/** /**
* Builds SAM's tool surface as an AI SDK ToolSet: the full MCP toolset plus the * Builds SAM's tool surface as an AI SDK ToolSet: the full MCP toolset plus the
* free site-reading tools. Every tool the OpenSEO MCP server exposes is * free site-reading tools. Every tool the OpenSEO MCP server exposes is
* available; auth/billing context is carried on a synthetic `ToolExtra` the * available. Auth and billing context are passed directly to the shared tool
* handlers read exactly as they would on the real MCP route. DataForSEO spend * handlers. DataForSEO spend is metered inside the shared client, so tool calls
* is metered inside the shared client, so tool calls draw down the org's * draw down the org's credits automatically.
* credits automatically.
*/ */
export function buildSamMcpTools( export function buildSamMcpTools(
authContext: McpToolAuthContext, authContext: ToolAuthContext,
project: { id: string; domain: string | null }, project: { id: string; domain: string | null },
): ToolSet { ): ToolSet {
const projectId = project.id; const projectId = project.id;
const extra: ToolExtra = { const toolContext: ToolContext = { auth: authContext };
// Placeholder to satisfy ToolExtra — no tool handler or the DataForSEO const adaptTool = <Shape extends ZodRawShape>(
// client reads this signal (true on the real MCP route too), so aborting a definition: McpToolDefinition<Shape>,
// turn does not cancel in-flight tool requests. ) => adaptMcpTool(definition, toolContext, projectId);
signal: new AbortController().signal,
requestId: 0,
authInfo: {
token: "sam-session",
clientId: authContext.clientId ?? "sam",
scopes: authContext.scopes,
extra: createWorkersOAuthMcpProps(authContext),
},
sendNotification: () => Promise.resolve(),
sendRequest: () =>
Promise.reject(new Error("sendRequest is unsupported in the SAM agent")),
};
// Note: no `list_projects`. SAM is bound to the session's project, so // Note: no `list_projects`. SAM is bound to the session's project, so
// discovering other projects isn't part of its job — every project-scoped tool // discovering other projects isn't part of its job — every project-scoped tool
@ -214,55 +202,23 @@ export function buildSamMcpTools(
execute: () => Promise.resolve({ factSheet: openSeoFactSheet }), execute: () => Promise.resolve({ factSheet: openSeoFactSheet }),
}), }),
...scrapeTools(project.domain), ...scrapeTools(project.domain),
whoami: adaptMcpTool(whoamiTool, extra, projectId), whoami: adaptTool(whoamiTool),
list_saved_keywords: adaptMcpTool(listSavedKeywordsTool, extra, projectId), list_saved_keywords: adaptTool(listSavedKeywordsTool),
research_keywords: adaptMcpTool(researchKeywordsTool, extra, projectId), research_keywords: adaptTool(researchKeywordsTool),
save_keywords: adaptMcpTool(saveKeywordsTool, extra, projectId), save_keywords: adaptTool(saveKeywordsTool),
get_domain_overview: adaptMcpTool(getDomainOverviewTool, extra, projectId), get_domain_overview: adaptTool(getDomainOverviewTool),
get_domain_keyword_suggestions: adaptMcpTool( get_domain_keyword_suggestions: adaptTool(getDomainKeywordSuggestionsTool),
getDomainKeywordSuggestionsTool, get_backlinks_overview: adaptTool(getBacklinksOverviewTool),
extra, get_backlinks_profile: adaptTool(getBacklinksProfileTool),
projectId, get_serp_results: adaptTool(getSerpResultsTool),
), get_rank_tracker: adaptTool(getRankTrackerTool),
get_backlinks_overview: adaptMcpTool( get_ranked_keywords: adaptTool(getRankedKeywordsTool),
getBacklinksOverviewTool, find_serp_competitors: adaptTool(findSerpCompetitorsTool),
extra, search_local_businesses: adaptTool(searchLocalBusinessesTool),
projectId, get_local_serp_results: adaptTool(getLocalSerpResultsTool),
), get_google_business_questions: adaptTool(getGoogleBusinessQuestionsTool),
get_backlinks_profile: adaptMcpTool( get_keyword_metrics: adaptTool(getKeywordMetricsTool),
getBacklinksProfileTool, get_search_console_performance: adaptTool(getSearchConsolePerformanceTool),
extra, inspect_urls: adaptTool(inspectUrlsTool),
projectId,
),
get_serp_results: adaptMcpTool(getSerpResultsTool, extra, projectId),
get_rank_tracker: adaptMcpTool(getRankTrackerTool, extra, projectId),
get_ranked_keywords: adaptMcpTool(getRankedKeywordsTool, extra, projectId),
find_serp_competitors: adaptMcpTool(
findSerpCompetitorsTool,
extra,
projectId,
),
search_local_businesses: adaptMcpTool(
searchLocalBusinessesTool,
extra,
projectId,
),
get_local_serp_results: adaptMcpTool(
getLocalSerpResultsTool,
extra,
projectId,
),
get_google_business_questions: adaptMcpTool(
getGoogleBusinessQuestionsTool,
extra,
projectId,
),
get_keyword_metrics: adaptMcpTool(getKeywordMetricsTool, extra, projectId),
get_search_console_performance: adaptMcpTool(
getSearchConsolePerformanceTool,
extra,
projectId,
),
inspect_urls: adaptMcpTool(inspectUrlsTool, extra, projectId),
}; };
} }

View File

@ -1,43 +1,75 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
props: {} as Record<string, unknown>,
}));
vi.mock("agents/mcp/server", () => ({
getMcpAuthContext: () => ({ props: mocks.props }),
}));
import { import {
createMcpToolContext,
createWorkersOAuthMcpProps, createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP, MCP_AUTH_CONTEXT_PROP,
withWorkersOAuthMcpScopes,
workersOAuthMcpPropsSchema, workersOAuthMcpPropsSchema,
} from "@/server/mcp/context"; } from "@/server/mcp/context";
const mcpContext = { const applicationContext = {
userId: "user_123", userId: "user_123",
userEmail: "alice@example.com", userEmail: "alice@example.com",
organizationId: "org_123", organizationId: "org_123",
clientId: "client_123",
scopes: ["offline_access", "mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test", baseUrl: "https://open-seo.test",
}; };
describe("withWorkersOAuthMcpScopes", () => { describe("OpenSEO tool auth context", () => {
it("stores the OpenSEO MCP context in Workers OAuth props", () => { it("stores only application-specific identity in Workers OAuth props", () => {
const props = createWorkersOAuthMcpProps(mcpContext); const props = createWorkersOAuthMcpProps(applicationContext);
expect(workersOAuthMcpPropsSchema.parse(props)).toEqual({ expect(workersOAuthMcpPropsSchema.parse(props)).toEqual({
[MCP_AUTH_CONTEXT_PROP]: mcpContext, [MCP_AUTH_CONTEXT_PROP]: applicationContext,
}); });
}); });
it("updates access-token props with downscoped token scopes", () => { it("rejects unrecognized provider props", () => {
const props = createWorkersOAuthMcpProps(mcpContext); expect(workersOAuthMcpPropsSchema.safeParse({}).success).toBe(false);
});
expect(withWorkersOAuthMcpScopes(props, ["mcp"])).toEqual({ it("prefers standard OAuth client metadata over the props fallback", () => {
[MCP_AUTH_CONTEXT_PROP]: { mocks.props = createWorkersOAuthMcpProps({
...mcpContext, ...applicationContext,
clientId: "stale-client",
scopes: ["offline_access"],
});
expect(
createMcpToolContext({
http: {
authInfo: {
token: "access-token",
clientId: "client-1",
scopes: ["mcp"], scopes: ["mcp"],
}, },
},
}).auth,
).toMatchObject({
...applicationContext,
clientId: "client-1",
scopes: ["mcp"],
}); });
}); });
it("leaves unrecognized provider props alone", () => { it("falls back to encrypted props with workers-oauth-provider 0.10", () => {
expect(withWorkersOAuthMcpScopes({}, ["mcp"])).toBeUndefined(); mocks.props = createWorkersOAuthMcpProps({
...applicationContext,
clientId: "client-1",
scopes: ["mcp"],
});
expect(createMcpToolContext({}).auth).toMatchObject({
...applicationContext,
clientId: "client-1",
scopes: ["mcp"],
});
}); });
}); });

View File

@ -1,123 +1,92 @@
import type { import type { ServerContext } from "@modelcontextprotocol/server";
ServerNotification, import { getMcpAuthContext } from "agents/mcp/server";
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { AsyncLocalStorage } from "node:async_hooks";
import { z } from "zod"; import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { getMcpResource } from "@/lib/oauth-resource";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
type McpAuth = { export type ToolAuthContext = {
userId: string; userId: string;
userEmail: string; userEmail: string;
organizationId: string; organizationId: string;
scopes: string[]; scopes: string[];
clientId: string | null; clientId: string | null;
audience: string; baseUrl: string;
subject: string; };
export type ToolContext = {
auth: ToolAuthContext;
}; };
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth"; export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
export const MCP_ROUTE = "/mcp"; export const MCP_ROUTE = "/mcp";
const mcpToolAuthContextSchema = z.object({ const applicationAuthContextSchema = z.object({
userId: z.string().min(1), userId: z.string().min(1),
userEmail: z.string().min(1), userEmail: z.string().min(1),
organizationId: z.string().min(1), organizationId: z.string().min(1),
clientId: z.string().nullable(),
scopes: z.array(z.string()),
audience: z.string().min(1),
subject: z.string().min(1),
baseUrl: z.string().url(), baseUrl: z.string().url(),
// Compatibility fallback until workers-oauth-provider supplies the verified
// context marker consumed by Agents SDK 0.20.x (the
// cloudflare.workers-oauth-provider.verified-context.v1 symbol, which mints
// context.http.authInfo — watch the provider changelog). Once it ships,
// delete these two fields and the fallback in createMcpToolContext, and read
// clientId/scopes in transport.ts from authInfo instead of props.
clientId: z.string().min(1).nullable().optional(),
scopes: z.array(z.string()).optional(),
}); });
export type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>; type ApplicationAuthContext = z.infer<typeof applicationAuthContextSchema>;
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
export const workersOAuthMcpPropsSchema = z.object({ export const workersOAuthMcpPropsSchema = z.object({
[MCP_AUTH_CONTEXT_PROP]: mcpToolAuthContextSchema, [MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema,
}); });
const mcpToolAuthContextStorage = new AsyncLocalStorage<McpToolAuthContext>(); // The hosted /mcp route only ever sees provider-minted tokens, whose props
// always carry the OAuth client identity — require it so scope enforcement
/** // fails closed instead of silently degrading to first-party.
* Auth context for first-party (non-OAuth) callers the self-hosted MCP export const hostedWorkersOAuthMcpPropsSchema = z.object({
* transport and the SAM agent. Centralizes the invariants both sites relied [MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema.extend({
* on by convention: `subject` is the user id, `clientId` is null, and the clientId: z.string().min(1),
* audience derives from the base URL. scopes: z.array(z.string()),
*/ }),
export function buildFirstPartyMcpAuthContext(input: { });
userId: string;
userEmail: string;
organizationId: string;
baseUrl: string;
scopes?: string[];
}): McpToolAuthContext {
return {
userId: input.userId,
userEmail: input.userEmail,
organizationId: input.organizationId,
clientId: null,
scopes: input.scopes ?? [],
audience: getMcpResource(input.baseUrl),
subject: input.userId,
baseUrl: input.baseUrl,
};
}
export function createWorkersOAuthMcpProps( export function createWorkersOAuthMcpProps(
context: McpToolAuthContext, context: ApplicationAuthContext,
): Record<string, McpToolAuthContext> { ): Record<string, ApplicationAuthContext> {
return { return {
[MCP_AUTH_CONTEXT_PROP]: context, [MCP_AUTH_CONTEXT_PROP]: context,
}; };
} }
export function withWorkersOAuthMcpScopes( export function createMcpToolContext(
props: unknown, context: Pick<ServerContext, "http">,
scopes: string[], ): ToolContext {
): Record<string, McpToolAuthContext> | undefined { const result = workersOAuthMcpPropsSchema.safeParse(
const result = workersOAuthMcpPropsSchema.safeParse(props); getMcpAuthContext()?.props,
if (!result.success) return undefined; );
return createWorkersOAuthMcpProps({
...result.data[MCP_AUTH_CONTEXT_PROP],
scopes,
});
}
export function runWithMcpToolAuthContext<T>(
context: McpToolAuthContext,
callback: () => T,
) {
return mcpToolAuthContextStorage.run(context, callback);
}
export function requireMcpToolAuthContext(
extra: ToolExtra,
): McpToolAuthContext {
const rawContext =
mcpToolAuthContextStorage.getStore() ??
extra.authInfo?.extra?.[MCP_AUTH_CONTEXT_PROP];
const result = mcpToolAuthContextSchema.safeParse(rawContext);
if (!result.success) { if (!result.success) {
throw new Error(`MCP auth context missing: ${result.error.message}`); throw new Error(`MCP auth context missing: ${result.error.message}`);
} }
return result.data; // Scope enforcement happens once, at the hosted transport boundary
} // (handleAuthenticatedOpenSeoMcpRequest); this only assembles identity.
const applicationAuth = result.data[MCP_AUTH_CONTEXT_PROP];
const authInfo = context.http?.authInfo;
const clientId = authInfo?.clientId ?? applicationAuth.clientId ?? null;
const scopes = authInfo?.scopes ?? applicationAuth.scopes ?? [];
export function getAuth(extra: ToolExtra): McpAuth { return {
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext(extra); auth: {
return auth; ...applicationAuth,
clientId,
scopes,
},
};
} }
export function buildBillingCustomer( export function buildBillingCustomer(
auth: McpAuth, auth: Pick<ToolAuthContext, "userId" | "userEmail" | "organizationId">,
projectId: string, projectId: string,
): BillingCustomerContext { ): BillingCustomerContext {
return { return {
@ -129,7 +98,10 @@ export function buildBillingCustomer(
} }
export function buildProjectMeta( export function buildProjectMeta(
context: { auth: Pick<McpAuth, "organizationId">; baseUrl: string }, context: {
auth: Pick<ToolAuthContext, "organizationId">;
baseUrl: string;
},
projectId: string, projectId: string,
path?: string, path?: string,
params?: Record<string, string | number | undefined>, params?: Record<string, string | number | undefined>,

View File

@ -1,4 +1,4 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/server";
type McpResponseMeta = { type McpResponseMeta = {
url?: string; url?: string;
@ -9,6 +9,22 @@ type McpResponseMeta = {
creditsRemaining?: number; creditsRemaining?: number;
}; };
// The generic overload preserves each tool's concrete structuredContent shape
// so tests (and callers) can access fields without casting. The type parameter
// appears in exactly one position — a required `structuredContent: T` — which
// is what makes inference work; a `T` shared between an optional field and an
// intersection member collapses to `{}`.
export function mcpResponse<T extends Record<string, unknown>>(opts: {
text: string;
meta?: McpResponseMeta;
structuredContent: T;
}): CallToolResult & {
structuredContent: T & { meta?: Record<string, unknown> };
};
export function mcpResponse(opts: {
text: string;
meta?: McpResponseMeta;
}): CallToolResult;
export function mcpResponse(opts: { export function mcpResponse(opts: {
text: string; text: string;
meta?: McpResponseMeta; meta?: McpResponseMeta;

View File

@ -1,12 +1,8 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { instrumentMcpToolHandler } from "./instrumentation"; import { instrumentMcpToolHandler } from "./instrumentation";
import { import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context";
runWithMcpToolAuthContext,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@ -36,39 +32,39 @@ vi.mock("@/server/lib/self-host-telemetry", () => ({
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount, incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
})); }));
const toolExtra: ToolExtra = { const outputSchema = z.object({
signal: new AbortController().signal, items: z.array(z.object({}).passthrough()),
requestId: 1, });
sendNotification: vi.fn(),
sendRequest: vi.fn(),
};
const outputSchema = { items: z.array(z.object({}).passthrough()) };
function okResult(structuredContent: Record<string, unknown>): CallToolResult { function okResult(structuredContent: Record<string, unknown>): CallToolResult {
return { content: [{ type: "text", text: "ok" }], structuredContent }; return { content: [{ type: "text", text: "ok" }], structuredContent };
} }
const authContext: McpToolAuthContext = { const authContext: ToolAuthContext = {
userId: "user-1", userId: "user-1",
userEmail: "user@example.com", userEmail: "user@example.com",
organizationId: "org-1", organizationId: "org-1",
clientId: "client-1", clientId: "client-1",
scopes: ["mcp"], scopes: ["mcp"],
audience: "https://app.openseo.so/mcp",
subject: "user-1",
baseUrl: "https://app.openseo.so", baseUrl: "https://app.openseo.so",
}; };
const toolContext: ToolContext = { auth: authContext };
describe("instrumentMcpToolHandler", () => { describe("instrumentMcpToolHandler", () => {
beforeEach(() => {}); beforeEach(() => {
mocks.captureServerError.mockReset();
mocks.captureServerEvent.mockReset();
mocks.recordExternalMcpToolCall.mockReset();
mocks.incrementSelfHostMcpToolCallCount.mockReset();
});
it("passes a valid result through without reporting", async () => { it("passes a valid result through without reporting", async () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [{ domain: "example.com" }] }), okResult({ items: [{ domain: "example.com" }] }),
); );
const result = await wrapped({}, toolExtra); const result = await wrapped({}, toolContext);
expect(result.structuredContent).toEqual({ expect(result.structuredContent).toEqual({
items: [{ domain: "example.com" }], items: [{ domain: "example.com" }],
@ -81,7 +77,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: "not-an-array" }), okResult({ items: "not-an-array" }),
); );
await wrapped({}, toolExtra); await wrapped({}, toolContext);
expect(mocks.captureServerError).toHaveBeenCalledTimes(1); expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
expect(mocks.captureServerError.mock.calls[0][1]).toMatchObject({ expect(mocks.captureServerError.mock.calls[0][1]).toMatchObject({
@ -96,7 +92,7 @@ describe("instrumentMcpToolHandler", () => {
throw boom; throw boom;
}); });
await expect(wrapped({}, toolExtra)).rejects.toThrow("upstream exploded"); await expect(wrapped({}, toolContext)).rejects.toThrow("upstream exploded");
expect(mocks.captureServerError).toHaveBeenCalledTimes(1); expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
expect(mocks.captureServerError.mock.calls[0][0]).toBe(boom); expect(mocks.captureServerError.mock.calls[0][0]).toBe(boom);
}); });
@ -106,16 +102,16 @@ describe("instrumentMcpToolHandler", () => {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
}); });
await expect(wrapped({}, toolExtra)).rejects.toThrow("NOT_FOUND"); await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND");
expect(mocks.captureServerError).not.toHaveBeenCalled(); expect(mocks.captureServerError).not.toHaveBeenCalled();
}); });
it("captures a usage event when auth context is present", async () => { it("captures a usage event for every call", async () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }), okResult({ items: [] }),
); );
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); await wrapped({}, toolContext);
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1); expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1); expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1);
@ -137,7 +133,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: "not-an-array" }), okResult({ items: "not-an-array" }),
); );
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call", event: "mcp:tool_call",
@ -154,7 +150,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ status: "error", error: { code: "ga4_not_connected" } }), okResult({ status: "error", error: { code: "ga4_not_connected" } }),
); );
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call", event: "mcp:tool_call",
@ -172,7 +168,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ ok: false, reason: "audit_not_ready" }), okResult({ ok: false, reason: "audit_not_ready" }),
); );
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call", event: "mcp:tool_call",
@ -186,9 +182,7 @@ describe("instrumentMcpToolHandler", () => {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
}); });
await expect( await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND");
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
).rejects.toThrow("NOT_FOUND");
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({ expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call", event: "mcp:tool_call",
@ -196,23 +190,12 @@ describe("instrumentMcpToolHandler", () => {
}); });
}); });
it("skips the usage event when auth context is missing", async () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
await wrapped({}, toolExtra);
expect(mocks.captureServerEvent).not.toHaveBeenCalled();
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
});
it("records the activation milestone for a successful external call", async () => { it("records the activation milestone for a successful external call", async () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () => const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }), okResult({ items: [] }),
); );
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)); await wrapped({}, toolContext);
expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith( expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith(
"org-1", "org-1",
@ -224,9 +207,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: [] }), okResult({ items: [] }),
); );
await runWithMcpToolAuthContext({ ...authContext, clientId: null }, () => await wrapped({}, { auth: { ...authContext, clientId: null } });
wrapped({}, toolExtra),
);
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled(); expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
}); });
@ -236,9 +217,7 @@ describe("instrumentMcpToolHandler", () => {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
}); });
await expect( await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND");
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
).rejects.toThrow("NOT_FOUND");
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled(); expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
}); });

View File

@ -1,38 +1,38 @@
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { import type { z } from "zod";
getParseErrorMessage,
normalizeObjectSchema,
safeParseAsync,
type AnySchema,
type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { asAppError } from "@/server/lib/errors"; import { asAppError } from "@/server/lib/errors";
import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation"; import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation";
import { captureServerError, captureServerEvent } from "@/server/lib/posthog"; import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
import { shouldCaptureAppErrorCode } from "@/shared/error-codes"; import { shouldCaptureAppErrorCode } from "@/shared/error-codes";
import { getAuth, type ToolExtra } from "@/server/mcp/context"; import { type ToolContext } from "@/server/mcp/context";
import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry"; import { incrementSelfHostMcpToolCallCount } from "@/server/lib/self-host-telemetry";
type ToolHandler<TArgs> = ( type ToolHandler<TArgs> = (
args: TArgs, args: TArgs,
extra: ToolExtra, context: ToolContext,
) => CallToolResult | Promise<CallToolResult>; ) => CallToolResult | Promise<CallToolResult>;
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
function formatValidationIssues(error: z.ZodError) {
return error.issues
.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`)
.join("; ")
.slice(0, 500);
}
/** /**
* Usage analytics for every MCP tool invocation. `clientId` distinguishes * Usage analytics for every MCP tool invocation. `clientId` distinguishes
* external MCP clients (OAuth) from the in-app agent (first-party auth, null * external MCP clients (OAuth) from the in-app agent (first-party auth, null
* clientId); self-hosted installs never report because captureServerEvent is * clientId); self-hosted installs never report because captureServerEvent is
* gated to hosted mode. Analytics must never affect the tool call, so a * gated to hosted mode.
* missing auth context (e.g. in tests) is swallowed.
*/ */
function captureMcpToolCall( function captureMcpToolCall(
toolName: string, toolName: string,
extra: ToolExtra, context: ToolContext,
outcome: { outcome: {
success: boolean; success: boolean;
errorCode?: string; errorCode?: string;
@ -44,8 +44,7 @@ function captureMcpToolCall(
) { ) {
waitUntil(incrementSelfHostMcpToolCallCount()); waitUntil(incrementSelfHostMcpToolCallCount());
try { const auth = context.auth;
const auth = getAuth(extra);
waitUntil( waitUntil(
captureServerEvent({ captureServerEvent({
distinctId: auth.userId, distinctId: auth.userId,
@ -64,9 +63,6 @@ function captureMcpToolCall(
}, },
}), }),
); );
} catch {
// no auth context — skip analytics
}
} }
/** /**
@ -80,51 +76,43 @@ function captureMcpToolCall(
* - Output-schema validation failures. The SDK validates structuredContent * - Output-schema validation failures. The SDK validates structuredContent
* against the output schema *after* the handler returns and converts a * against the output schema *after* the handler returns and converts a
* failure into a -32602 JSON-RPC error it never rethrows, so we re-run the * failure into a -32602 JSON-RPC error it never rethrows, so we re-run the
* same validation (via the SDK's own helpers) to surface the mismatch * same validation to surface the mismatch instead of shipping it silently.
* instead of shipping it silently.
*/ */
export function instrumentMcpToolHandler<TArgs>( export function instrumentMcpToolHandler<TArgs>(
toolName: string, toolName: string,
outputSchema: AnySchema | ZodRawShapeCompat | undefined, outputSchema: z.ZodType | undefined,
handler: ToolHandler<TArgs>, handler: ToolHandler<TArgs>,
): (args: TArgs, extra: ToolExtra) => Promise<CallToolResult> { ): (args: TArgs, context: ToolContext) => Promise<CallToolResult> {
const normalizedOutputSchema = normalizeObjectSchema(outputSchema); return async (args, context) => {
return async (args, extra) => {
const startedAt = performance.now(); const startedAt = performance.now();
try { try {
const result = await handler(args, extra); const result = await handler(args, context);
// The SDK converts an output-schema mismatch into a client-visible // The SDK converts an output-schema mismatch into a client-visible
// JSON-RPC error, so count it as a failed call, not a success. // JSON-RPC error, so count it as a failed call, not a success.
let outputValidationFailed = false; let outputValidationFailed = false;
if ( if (outputSchema && !result.isError && result.structuredContent) {
normalizedOutputSchema && const validation = await outputSchema.safeParseAsync(
!result.isError &&
result.structuredContent
) {
const validation = await safeParseAsync(
normalizedOutputSchema,
result.structuredContent, result.structuredContent,
); );
if (!validation.success) { if (!validation.success) {
outputValidationFailed = true; outputValidationFailed = true;
// getParseErrorMessage reports type-level mismatches (expected vs // Keep this type-level and privacy-safe: output schemas must not gain
// received *types*), so it carries no row data. Keep it that way: // value-echoing refinements that would surface response data here.
// output schemas must not gain value-echoing refinements (enums on
// user data, etc.) that would surface response values in PostHog.
waitUntil( waitUntil(
captureServerError( captureServerError(
new Error(`MCP output validation failed for ${toolName}`), new Error(`MCP output validation failed for ${toolName}`),
{ {
errorCode: "MCP_OUTPUT_VALIDATION", errorCode: "MCP_OUTPUT_VALIDATION",
tool: toolName, tool: toolName,
issues: getParseErrorMessage(validation.error).slice(0, 500), issues: formatValidationIssues(validation.error),
}, },
), ),
); );
} }
} }
const structured = result.structuredContent; const structured = isRecord(result.structuredContent)
? result.structuredContent
: undefined;
const returnedFailure = const returnedFailure =
structured?.status === "error" || structured?.ok === false; structured?.status === "error" || structured?.ok === false;
const returnedError = const returnedError =
@ -148,7 +136,7 @@ export function instrumentMcpToolHandler<TArgs>(
!result.isError && !outputValidationFailed && !returnedFailure; !result.isError && !outputValidationFailed && !returnedFailure;
captureMcpToolCall( captureMcpToolCall(
toolName, toolName,
extra, context,
outputValidationFailed outputValidationFailed
? { ? {
success: false, success: false,
@ -179,19 +167,15 @@ export function instrumentMcpToolHandler<TArgs>(
// the request's DB scope; a per-isolate memo keeps this off the hot // the request's DB scope; a per-isolate memo keeps this off the hot
// path after the first call. // path after the first call.
if (succeeded) { if (succeeded) {
try { const auth = context.auth;
const auth = getAuth(extra);
if (auth.clientId) { if (auth.clientId) {
await recordExternalMcpToolCall(auth.organizationId); await recordExternalMcpToolCall(auth.organizationId);
} }
} catch {
// no auth context — skip milestone tracking
}
} }
return result; return result;
} catch (error) { } catch (error) {
const appError = asAppError(error); const appError = asAppError(error);
captureMcpToolCall(toolName, extra, { captureMcpToolCall(toolName, context, {
success: false, success: false,
errorCode: appError?.code ?? "INTERNAL_ERROR", errorCode: appError?.code ?? "INTERNAL_ERROR",
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),

View File

@ -0,0 +1,351 @@
import {
GrantType,
type OAuthProviderOptions,
type TokenExchangeCallbackOptions,
} from "@cloudflare/workers-oauth-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { createOpenSeoOAuthProvider } from "./oauth-provider";
const mocks = vi.hoisted(() => ({
options: [] as OAuthProviderOptions<unknown>[],
requests: [] as Request[],
purges: [] as unknown[],
}));
vi.mock("cloudflare:workers", () => ({
waitUntil: (promise: Promise<unknown>) => void promise,
}));
// The whole module is doubled because the real one imports "cloudflare:workers"
// at top level, which vitest's node environment cannot load even via
// importOriginal. The error-class doubles must stay signature-compatible with
// the real AuthorizationError/OAuthError.
vi.mock("@cloudflare/workers-oauth-provider", () => {
class AuthorizationError extends Error {
readonly description: string;
readonly redirectUri?: string;
readonly state?: string;
readonly issuer?: string;
constructor(
readonly code: string,
options: {
description: string;
redirectUri?: string;
state?: string;
issuer?: string;
},
) {
super(options.description);
this.description = options.description;
this.redirectUri = options.redirectUri;
this.state = options.state;
this.issuer = options.issuer;
}
}
class OAuthError extends Error {
constructor(
readonly code: string,
options?: { description?: string },
) {
super(options?.description);
}
}
return {
AuthorizationError,
GrantType: { REFRESH_TOKEN: "refresh_token" },
OAuthError,
OAuthProvider: class {
constructor(options: OAuthProviderOptions<unknown>) {
mocks.options.push(options);
}
fetch(request: Request) {
mocks.requests.push(request);
return Promise.resolve(new Response(null, { status: 204 }));
}
purgeExpiredData(env: unknown, options?: { batchSize?: number }) {
mocks.purges.push({ env, options });
return Promise.resolve({
grantsChecked: 0,
grantsPurged: 0,
tokensChecked: 0,
tokensPurged: 0,
done: true,
});
}
},
};
});
vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://app.openseo.so",
}));
vi.mock("@/middleware/ensure-user/hosted", () => ({
resolveHostedContext: vi.fn(),
}));
vi.mock("@/server/features/activation/mcpActivation", () => ({
recordMcpAuthorized: vi.fn(),
}));
vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: vi.fn(),
}));
vi.mock("@/server/mcp/transport", () => ({
handleAuthenticatedOpenSeoMcpRequest: vi.fn(),
}));
const executionContext = {
props: {},
waitUntil() {},
passThroughOnException() {},
} as ExecutionContext;
async function dispatch(
provider: ReturnType<typeof createOpenSeoOAuthProvider>,
request: Request,
) {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- mocked provider does not read its KV-backed environment in these configuration tests
return provider.fetch(request, {} as never, executionContext);
}
function tokenExchangeOptions(
requestedScope: string[],
): TokenExchangeCallbackOptions {
return {
grantType: GrantType.REFRESH_TOKEN,
clientId: "client-1",
userId: "user-1",
grantId: "grant-1",
scope: ["offline_access", "mcp"],
requestedScope,
props: {
openSeoAuth: {
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://app.openseo.so",
clientId: "client-1",
scopes: ["offline_access", "mcp"],
},
},
};
}
async function invokeDefaultHandler(
request: Request,
env: Record<string, unknown>,
) {
const defaultHandler = mocks.options[0]?.defaultHandler;
if (
!defaultHandler ||
typeof defaultHandler !== "object" ||
!("fetch" in defaultHandler) ||
typeof defaultHandler.fetch !== "function"
) {
throw new Error("Missing default handler");
}
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Workers' handler type requires incoming-only CF metadata; this test Request never inspects request.cf
const workerRequest = request as Request<
unknown,
IncomingRequestCfProperties
>;
const rawResponse: unknown = await defaultHandler.fetch(
workerRequest,
env,
executionContext,
);
if (!(rawResponse instanceof Response)) {
throw new Error("Default handler did not return a response");
}
return rawResponse;
}
describe("OpenSEO OAuth provider configuration", () => {
beforeEach(() => {
mocks.options.length = 0;
mocks.requests.length = 0;
mocks.purges.length = 0;
});
it("binds tokens and protected-resource metadata to the canonical MCP URL", async () => {
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(provider, new Request("https://app.openseo.so/health"));
expect(mocks.options).toHaveLength(1);
expect(mocks.options[0]?.resourceMetadata).toEqual({
resource: "https://app.openseo.so/mcp",
scopes_supported: ["mcp"],
resource_name: "OpenSEO MCP",
});
expect(mocks.options[0]?.scopesSupported).toEqual([
"offline_access",
"mcp",
]);
expect(mocks.options[0]?.clientRegistrationTTL).toBe(60 * 60 * 24 * 365);
});
it("purges OAuth KV data without needing a prior request", async () => {
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- mocked provider does not read its KV-backed environment
const result = await provider.purgeExpiredData({} as never);
expect(result.done).toBe(true);
expect(mocks.purges).toHaveLength(1);
// The lazily built provider still pins the hosted resource.
expect(mocks.options[0]?.resourceMetadata).toMatchObject({
resource: "https://app.openseo.so/mcp",
});
});
it("rejects token exchanges that drop the required MCP scope", async () => {
const { OAuthError } = await import("@cloudflare/workers-oauth-provider");
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(provider, new Request("https://app.openseo.so/health"));
const callback = mocks.options[0]?.tokenExchangeCallback;
if (!callback) throw new Error("Missing token exchange callback");
expect(() =>
callback(tokenExchangeOptions(["offline_access"])),
).toThrowError(OAuthError);
expect(callback(tokenExchangeOptions(["mcp"]))).toEqual({
accessTokenProps: {
openSeoAuth: {
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://app.openseo.so",
clientId: "client-1",
scopes: ["mcp"],
},
},
});
});
it("lets the provider issue Perplexity a real client secret", async () => {
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(
provider,
new Request("https://app.openseo.so/api/auth/oauth2/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "Perplexity",
redirect_uris: ["https://www.perplexity.ai/api/mcp/oauth/callback"],
}),
}),
);
await expect(mocks.requests[0]?.json()).resolves.toMatchObject({
token_endpoint_auth_method: "client_secret_post",
});
});
it("includes the authorization-server issuer when consent is denied", async () => {
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(provider, new Request("https://app.openseo.so/health"));
const response = await invokeDefaultHandler(
new Request("https://app.openseo.so/api/oauth/consent", {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: "https://app.openseo.so",
},
body: JSON.stringify({ accept: false, query: "state=state-1" }),
}),
{
OAUTH_PROVIDER: {
parseAuthRequest: () =>
Promise.resolve({
clientId: "client-1",
redirectUri: "https://client.example/callback",
scope: ["mcp"],
state: "state-1",
issuer: "https://app.openseo.so",
}),
},
},
);
const body = z
.object({ redirectTo: z.string().url() })
.parse(await response.json());
expect(
Object.fromEntries(new URL(body.redirectTo).searchParams),
).toMatchObject({
error: "access_denied",
state: "state-1",
iss: "https://app.openseo.so",
});
});
it("redirects safe authorization errors with state and issuer", async () => {
const { AuthorizationError } =
await import("@cloudflare/workers-oauth-provider");
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(provider, new Request("https://app.openseo.so/health"));
const response = await invokeDefaultHandler(
new Request("https://app.openseo.so/api/auth/oauth2/authorize"),
{
OAUTH_PROVIDER: {
parseAuthRequest: () =>
Promise.reject(
new AuthorizationError("invalid_scope", {
description: "Unsupported scope",
redirectUri: "https://client.example/callback",
state: "state-1",
issuer: "https://app.openseo.so",
}),
),
},
},
);
expect(response.status).toBe(302);
const location = response.headers.get("Location");
if (!location) throw new Error("Missing OAuth redirect");
expect(Object.fromEntries(new URL(location).searchParams)).toMatchObject({
error: "invalid_scope",
error_description: "Unsupported scope",
state: "state-1",
iss: "https://app.openseo.so",
});
});
it("does not expose unexpected authorization failures as client errors", async () => {
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
const provider = createOpenSeoOAuthProvider(() => new Response("app"));
await dispatch(provider, new Request("https://app.openseo.so/health"));
await expect(
invokeDefaultHandler(
new Request("https://app.openseo.so/api/auth/oauth2/authorize"),
{
OAUTH_PROVIDER: {
parseAuthRequest: () =>
Promise.reject(new Error("internal storage detail")),
},
},
),
).rejects.toThrow("internal storage detail");
});
});

View File

@ -1,5 +1,7 @@
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { import {
AuthorizationError,
OAuthError,
OAuthProvider, OAuthProvider,
type AuthRequest, type AuthRequest,
type OAuthHelpers, type OAuthHelpers,
@ -17,13 +19,11 @@ import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation"
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { import {
createWorkersOAuthMcpProps, createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE, MCP_ROUTE,
withWorkersOAuthMcpScopes, workersOAuthMcpPropsSchema,
} from "@/server/mcp/context"; } from "@/server/mcp/context";
import { import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration";
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport"; import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted"; import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
@ -33,7 +33,6 @@ const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token";
const OAUTH_REGISTER_PATH = "/api/auth/oauth2/register"; const OAUTH_REGISTER_PATH = "/api/auth/oauth2/register";
const OAUTH_CONSENT_RESPONSE_PATH = "/api/oauth/consent"; const OAUTH_CONSENT_RESPONSE_PATH = "/api/oauth/consent";
const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate";
const OAUTH_AUTHORIZATION_PARAM_NAMES = [ const OAUTH_AUTHORIZATION_PARAM_NAMES = [
"response_type", "response_type",
"client_id", "client_id",
@ -48,6 +47,11 @@ const OAUTH_AUTHORIZATION_PARAM_NAMES = [
// preserve MCP sessions across normal usage. // preserve MCP sessions across normal usage.
const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24; const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24;
const MCP_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; const MCP_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
// DCR client records expire on a fixed clock from registration (the provider
// defaults to 90 days), and an actively refreshing client breaks with
// invalid_client the moment its record lapses. A year keeps that cliff rare;
// rolling 30-day refresh tokens already reap inactive clients' sessions.
const MCP_CLIENT_REGISTRATION_TTL_SECONDS = 60 * 60 * 24 * 365;
export type OpenSeoOAuthEnv = Env & { export type OpenSeoOAuthEnv = Env & {
OAUTH_KV: KVNamespace; OAUTH_KV: KVNamespace;
@ -56,10 +60,6 @@ export type OpenSeoOAuthEnv = Env & {
type AppFetch = (request: Request) => Response | Promise<Response>; type AppFetch = (request: Request) => Response | Promise<Response>;
type OAuthExecutionContext = ExecutionContext & {
props?: unknown;
};
type ExportedHandlerWithFetch<Env> = ExportedHandler<Env> & { type ExportedHandlerWithFetch<Env> = ExportedHandler<Env> & {
fetch: NonNullable<ExportedHandler<Env>["fetch"]>; fetch: NonNullable<ExportedHandler<Env>["fetch"]>;
}; };
@ -77,10 +77,6 @@ function getOAuthHelpers(env: OpenSeoOAuthEnv) {
return env.OAUTH_PROVIDER; return env.OAUTH_PROVIDER;
} }
function getMcpResourceForRequest(request: Request) {
return getMcpResource(getPublicOrigin(request));
}
function getRelativeRequestTarget(request: Request) { function getRelativeRequestTarget(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
return `${url.pathname}${url.search}`; return `${url.pathname}${url.search}`;
@ -92,10 +88,35 @@ function redirectToSignIn(request: Request) {
return Response.redirect(signInUrl.toString(), 302); return Response.redirect(signInUrl.toString(), 302);
} }
function invalidOAuthRequestResponse(error: unknown) { function oauthErrorRedirect(input: {
return new Response( redirectUri: string;
error instanceof Error ? error.message : "Invalid OAuth request", code: string;
{ status: 400 }, description: string;
state?: string;
issuer?: string;
}) {
const redirectUrl = new URL(input.redirectUri);
redirectUrl.searchParams.set("error", input.code);
redirectUrl.searchParams.set("error_description", input.description);
if (input.state) redirectUrl.searchParams.set("state", input.state);
if (input.issuer) redirectUrl.searchParams.set("iss", input.issuer);
return redirectUrl.toString();
}
function authorizationErrorResponse(error: AuthorizationError) {
if (!error.redirectUri) {
return new Response(error.description, { status: 400 });
}
return Response.redirect(
oauthErrorRedirect({
redirectUri: error.redirectUri,
code: error.code,
description: error.description,
state: error.state,
issuer: error.issuer,
}),
302,
); );
} }
@ -109,19 +130,17 @@ function jsonResponse(body: unknown, init?: ResponseInit) {
}); });
} }
function oauthErrorResponse(error: { function logOAuthError(error: {
code: string; code: string;
description: string; description: string;
status: number; status: number;
headers: Record<string, string>;
}) { }) {
// 401s here are the standard OAuth discovery handshake, not failures: an // 401s here are the standard OAuth discovery handshake, not failures: an
// unauthenticated /mcp hit returns `invalid_token` (which triggers the // unauthenticated /mcp hit returns `invalid_token` (which triggers the
// client's .well-known discovery), and clients registered as confidential // client's .well-known discovery), and stale client registrations draw
// before the public-client DCR fix still draw `invalid_client` until they // `invalid_client` until the client re-registers. Log those at debug so
// retry with the secret or re-register. Log those at debug so they stop // they stop masquerading as errors; keep 5xx at error and everything else
// masquerading as errors; keep 5xx at error and everything else (bad client // (bad client metadata, etc.) at warn.
// metadata, etc.) at warn.
const line = `[oauth] ${error.status} ${error.code}: ${error.description}`; const line = `[oauth] ${error.status} ${error.code}: ${error.description}`;
if (error.status === 401) { if (error.status === 401) {
console.debug(line); console.debug(line);
@ -131,22 +150,8 @@ function oauthErrorResponse(error: {
console.warn(line); console.warn(line);
} }
const headers = new Headers(error.headers); // Returning void delegates the standards-compliant body, bearer challenge,
headers.set("Content-Type", "application/json"); // and CORS headers to workers-oauth-provider.
if (headers.has(WWW_AUTHENTICATE_HEADER)) {
headers.set("Access-Control-Expose-Headers", WWW_AUTHENTICATE_HEADER);
}
return new Response(
JSON.stringify({
error: error.code,
error_description: error.description,
}),
{
status: error.status,
headers,
},
);
} }
function csrfProtected(request: Request) { function csrfProtected(request: Request) {
@ -217,29 +222,6 @@ function buildAuthorizeRequestFromConsentQuery(
}); });
} }
function withDefaultMcpResource(authRequest: AuthRequest, request: Request) {
const mcpResource = getMcpResourceForRequest(request);
if (!authRequest.resource) {
return {
...authRequest,
resource: mcpResource,
};
}
const requestedResources = Array.isArray(authRequest.resource)
? authRequest.resource
: [authRequest.resource];
if (requestedResources.some((resource) => resource !== mcpResource)) {
throw new Error(`OAuth resource must be ${mcpResource}`);
}
return {
...authRequest,
resource: mcpResource,
};
}
function getGrantedMcpScopes(requestedScopes: string[]) { function getGrantedMcpScopes(requestedScopes: string[]) {
if (requestedScopes.length === 0) { if (requestedScopes.length === 0) {
return [...MCP_OAUTH_SCOPES]; return [...MCP_OAUTH_SCOPES];
@ -256,14 +238,13 @@ function getGrantedMcpScopes(requestedScopes: string[]) {
} }
function deniedRedirect(authRequest: AuthRequest) { function deniedRedirect(authRequest: AuthRequest) {
const redirectUrl = new URL(authRequest.redirectUri); return oauthErrorRedirect({
redirectUrl.searchParams.set("error", "access_denied"); redirectUri: authRequest.redirectUri,
redirectUrl.searchParams.set("error_description", "The user denied access"); code: "access_denied",
if (authRequest.state) { description: "The user denied access",
redirectUrl.searchParams.set("state", authRequest.state); state: authRequest.state,
} issuer: authRequest.issuer,
});
return redirectUrl.toString();
} }
async function handleOAuthAuthorizeRequest( async function handleOAuthAuthorizeRequest(
@ -275,7 +256,10 @@ async function handleOAuthAuthorizeRequest(
try { try {
await oauth.parseAuthRequest(request); await oauth.parseAuthRequest(request);
} catch (error) { } catch (error) {
return invalidOAuthRequestResponse(error); if (error instanceof AuthorizationError) {
return authorizationErrorResponse(error);
}
throw error;
} }
const sessionBlocker = await getAuthorizeSessionBlocker(request); const sessionBlocker = await getAuthorizeSessionBlocker(request);
@ -317,11 +301,11 @@ async function handleOAuthConsentResponse(
let authRequest: AuthRequest; let authRequest: AuthRequest;
try { try {
authRequest = await oauth.parseAuthRequest(authorizeRequest); authRequest = await oauth.parseAuthRequest(authorizeRequest);
authRequest = withDefaultMcpResource(authRequest, request);
} catch (error) { } catch (error) {
if (!(error instanceof AuthorizationError)) throw error;
return jsonResponse( return jsonResponse(
{ {
error: error instanceof Error ? error.message : "Invalid OAuth request", error: error.description,
}, },
{ status: 400 }, { status: 400 },
); );
@ -348,16 +332,13 @@ async function handleOAuthConsentResponse(
); );
} }
const audience = getMcpResourceForRequest(request);
const props = createWorkersOAuthMcpProps({ const props = createWorkersOAuthMcpProps({
userId: context.userId, userId: context.userId,
userEmail: context.userEmail, userEmail: context.userEmail,
organizationId: context.organizationId, organizationId: context.organizationId,
baseUrl: getHostedBaseUrl(),
clientId: authRequest.clientId, clientId: authRequest.clientId,
scopes, scopes,
audience,
subject: context.userId,
baseUrl: getHostedBaseUrl(),
}); });
const { redirectTo } = await oauth.completeAuthorization({ const { redirectTo } = await oauth.completeAuthorization({
@ -410,16 +391,11 @@ function createDefaultHandler(
const mcpApiHandler: ExportedHandlerWithFetch<OpenSeoOAuthEnv> = { const mcpApiHandler: ExportedHandlerWithFetch<OpenSeoOAuthEnv> = {
async fetch(request, env, ctx) { async fetch(request, env, ctx) {
return handleAuthenticatedOpenSeoMcpRequest( return handleAuthenticatedOpenSeoMcpRequest(request, ctx.props, env, ctx);
request,
(ctx as OAuthExecutionContext).props,
env,
ctx,
);
}, },
}; };
export function createOpenSeoOAuthProvider(appFetch: AppFetch) { function createProvider(appFetch: AppFetch, resource: string) {
const options: OAuthProviderOptions<OpenSeoOAuthEnv> = { const options: OAuthProviderOptions<OpenSeoOAuthEnv> = {
apiRoute: MCP_ROUTE, apiRoute: MCP_ROUTE,
apiHandler: mcpApiHandler, apiHandler: mcpApiHandler,
@ -430,39 +406,64 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
scopesSupported: [...MCP_OAUTH_SCOPES], scopesSupported: [...MCP_OAUTH_SCOPES],
accessTokenTTL: MCP_ACCESS_TOKEN_TTL_SECONDS, accessTokenTTL: MCP_ACCESS_TOKEN_TTL_SECONDS,
refreshTokenTTL: MCP_REFRESH_TOKEN_TTL_SECONDS, refreshTokenTTL: MCP_REFRESH_TOKEN_TTL_SECONDS,
clientRegistrationTTL: MCP_CLIENT_REGISTRATION_TTL_SECONDS,
resourceMetadata: { resourceMetadata: {
scopes_supported: [...MCP_OAUTH_SCOPES], resource,
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP", resource_name: "OpenSEO MCP",
}, },
tokenExchangeCallback: ({ props, requestedScope }) => { tokenExchangeCallback: ({ props, requestedScope }) => {
const accessTokenProps = withWorkersOAuthMcpScopes(props, requestedScope); if (!requestedScope.includes(MCP_SCOPE)) {
throw new OAuthError("invalid_scope", {
description: "The mcp scope is required",
});
}
return accessTokenProps ? { accessTokenProps } : undefined; const authContext =
workersOAuthMcpPropsSchema.parse(props)[MCP_AUTH_CONTEXT_PROP];
return {
accessTokenProps: createWorkersOAuthMcpProps({
...authContext,
scopes: requestedScope,
}),
};
}, },
onError: oauthErrorResponse, onError: logOAuthError,
}; };
const provider = new OAuthProvider(options); return new OAuthProvider(options);
}
export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
// Built lazily because the canonical resource comes from BETTER_AUTH_URL,
// which is not readable at module-init time. It is the same base URL the
// consent handler stamps into grant props.
let provider: OAuthProvider<OpenSeoOAuthEnv> | undefined;
const getProvider = () =>
(provider ??= createProvider(appFetch, getMcpResource(getHostedBaseUrl())));
return { return {
async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) { async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === OAUTH_REGISTER_PATH) { if (url.pathname === OAUTH_REGISTER_PATH) {
// Register secretless MCP clients as true public clients so refresh return getProvider().fetch(
// grants never require client authentication, then dress the DCR
// response as confidential for clients (Perplexity) that reject
// responses without a client_secret. Cloudflare still owns client
// creation, secret hashing, and token storage.
const response = await provider.fetch(
await normalizeClientRegistrationRequest(request), await normalizeClientRegistrationRequest(request),
env, env,
ctx, ctx,
); );
return withCompatibilityClientSecret(response);
} }
return provider.fetch(request, env, ctx); return getProvider().fetch(request, env, ctx);
},
// Cron GC for OAUTH_KV: sweeps orphaned grants/tokens (e.g. from expired
// client registrations) that KV TTLs alone don't reclaim. The sweep only
// advances past live records by deleting dead ones, so give it a batch
// large enough to cover the whole keyspace in one pass while staying
// within the invocation's subrequest budget.
purgeExpiredData(env: OpenSeoOAuthEnv) {
return getProvider().purgeExpiredData(env, { batchSize: 200 });
}, },
}; };
} }

View File

@ -0,0 +1,465 @@
import { createHash, randomBytes } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { createOpenSeoOAuthProvider } from "./oauth-provider";
// End-to-end OAuth lifecycle against the REAL @cloudflare/workers-oauth-provider
// (only the Workers runtime shims and app session resolution are mocked),
// shaped after how Codex actually behaves: DCR with no token_endpoint_auth_method,
// PKCE S256, form-encoded token requests, and refresh with client_id only —
// no client secret, no scope parameter. Refresh breakage has bitten real
// clients before (PR #420); these tests pin the full register → authorize →
// consent → token → use → refresh → rotate chain.
const BASE = "https://app.openseo.so";
const MCP_RESOURCE = `${BASE}/mcp`;
vi.mock("cloudflare:workers", () => ({
waitUntil: (promise: Promise<unknown>) => void promise,
// The provider imports WorkerEntrypoint for its class-based apiHandler
// support; this suite uses object handlers, so a bare stand-in suffices.
// oxlint-disable-next-line typescript/no-extraneous-class
WorkerEntrypoint: class {},
}));
vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://app.openseo.so",
}));
vi.mock("@/middleware/ensure-user/hosted", () => ({
resolveHostedContext: () =>
Promise.resolve({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
}),
}));
vi.mock("@/server/features/activation/mcpActivation", () => ({
recordMcpAuthorized: () => Promise.resolve(),
}));
vi.mock("@/server/lib/posthog", () => ({
captureServerEvent: () => Promise.resolve(),
}));
// The API side of the provider: echo the decrypted grant props back so tests
// can assert what a tool call would actually see after each token exchange.
vi.mock("@/server/mcp/transport", () => ({
handleAuthenticatedOpenSeoMcpRequest: (
_request: Request,
props: unknown,
): Promise<Response> => Promise.resolve(Response.json({ props })),
}));
// Minimal in-memory KVNamespace honoring expirationTtl against Date.now(), so
// vi.setSystemTime drives token/grant expiry.
function createKvFake() {
const store = new Map<
string,
{ value: string; metadata?: unknown; expiresAt?: number }
>();
const live = (key: string) => {
const entry = store.get(key);
if (!entry) return undefined;
if (entry.expiresAt !== undefined && Date.now() >= entry.expiresAt) {
store.delete(key);
return undefined;
}
return entry;
};
return {
get(key: string, type?: unknown) {
const entry = live(key);
if (!entry) return Promise.resolve(null);
const wantsJson =
type === "json" ||
(typeof type === "object" &&
type !== null &&
(type as { type?: string }).type === "json");
return Promise.resolve(wantsJson ? JSON.parse(entry.value) : entry.value);
},
put(
key: string,
value: string,
options?: { expirationTtl?: number; metadata?: unknown },
) {
store.set(key, {
value,
metadata: options?.metadata,
expiresAt:
options?.expirationTtl !== undefined
? Date.now() + options.expirationTtl * 1000
: undefined,
});
return Promise.resolve();
},
delete(key: string) {
store.delete(key);
return Promise.resolve();
},
list(options?: { prefix?: string; cursor?: string; limit?: number }) {
const keys = [...store.keys()]
.filter((key) => live(key))
.filter((key) => !options?.prefix || key.startsWith(options.prefix))
.slice(0, options?.limit ?? 1000)
.map((name) => {
const entry = store.get(name);
return {
name,
metadata: entry?.metadata,
expiration: entry?.expiresAt
? Math.floor(entry.expiresAt / 1000)
: undefined,
};
});
return Promise.resolve({ keys, list_complete: true, cursor: "" });
},
};
}
const ctx: ExecutionContext = {
waitUntil() {},
passThroughOnException() {},
props: {},
};
type Provider = ReturnType<typeof createOpenSeoOAuthProvider>;
type Env = Parameters<Provider["fetch"]>[1];
let provider: Provider;
let env: Env;
beforeEach(async () => {
vi.useRealTimers();
const { createOpenSeoOAuthProvider } = await import("./oauth-provider");
provider = createOpenSeoOAuthProvider(() => new Response("app"));
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the provider touches only OAUTH_KV
env = { OAUTH_KV: createKvFake() } as unknown as Env;
});
const registrationSchema = z.looseObject({
client_id: z.string(),
token_endpoint_auth_method: z.string(),
client_secret: z.string().optional(),
});
const tokenResponseSchema = z.looseObject({
access_token: z.string(),
refresh_token: z.string(),
token_type: z.string(),
});
const tokenErrorSchema = z.looseObject({
error: z.string(),
});
const propsEchoSchema = z.object({
props: z.object({
openSeoAuth: z.looseObject({
userId: z.string(),
organizationId: z.string(),
clientId: z.string(),
scopes: z.array(z.string()),
}),
}),
});
function dispatch(request: Request) {
return provider.fetch(request, env, ctx);
}
// Codex's DCR registration omits token_endpoint_auth_method entirely.
async function registerCodexStyleClient(
overrides: Record<string, unknown> = {},
) {
const response = await dispatch(
new Request(`${BASE}/api/auth/oauth2/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "Codex",
redirect_uris: ["http://localhost:1455/auth/callback"],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
...overrides,
}),
}),
);
expect(response.status).toBe(201);
return registrationSchema.parse(await response.json());
}
async function authorizeAndGetCode(clientId: string, codeVerifier: string) {
const challenge = createHash("sha256")
.update(codeVerifier)
.digest("base64url");
const query = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: "http://localhost:1455/auth/callback",
scope: "offline_access mcp",
state: "state-1",
code_challenge: challenge,
code_challenge_method: "S256",
resource: MCP_RESOURCE,
});
// The authorize route validates the request and bounces to the consent UI.
const authorizeResponse = await dispatch(
new Request(`${BASE}/api/auth/oauth2/authorize?${query.toString()}`),
);
expect(authorizeResponse.status).toBe(302);
expect(authorizeResponse.headers.get("Location")).toContain("/oauth-consent");
// The consent UI posts acceptance back with the original authorize query.
const consentResponse = await dispatch(
new Request(`${BASE}/api/oauth/consent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Origin: BASE,
},
body: JSON.stringify({ accept: true, query: query.toString() }),
}),
);
expect(consentResponse.status).toBe(200);
const { redirectTo } = z
.object({ redirectTo: z.string() })
.parse(await consentResponse.json());
const code = new URL(redirectTo).searchParams.get("code");
if (!code) throw new Error("authorization code missing from redirect");
return code;
}
function tokenRequest(params: Record<string, string>) {
return dispatch(
new Request(`${BASE}/api/auth/oauth2/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params).toString(),
}),
);
}
async function exchangeCode(
clientId: string,
code: string,
codeVerifier: string,
) {
const response = await tokenRequest({
grant_type: "authorization_code",
code,
redirect_uri: "http://localhost:1455/auth/callback",
client_id: clientId,
code_verifier: codeVerifier,
resource: MCP_RESOURCE,
});
expect(response.status).toBe(200);
return tokenResponseSchema.parse(await response.json());
}
// Codex-shaped refresh: client_id only — no secret, no scope, no resource.
function refresh(clientId: string, refreshToken: string) {
return tokenRequest({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: clientId,
});
}
async function callMcp(accessToken: string) {
const response = await dispatch(
new Request(MCP_RESOURCE, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
}),
);
expect(response.status).toBe(200);
return propsEchoSchema.parse(await response.json()).props.openSeoAuth;
}
async function setupSession() {
const client = await registerCodexStyleClient();
const codeVerifier = randomBytes(32).toString("base64url");
const code = await authorizeAndGetCode(client.client_id, codeVerifier);
const tokens = await exchangeCode(client.client_id, code, codeVerifier);
return { client, tokens };
}
describe("Codex-style OAuth token refresh (real workers-oauth-provider)", () => {
it("registers a secretless client as public and completes the code exchange", async () => {
const { client, tokens } = await setupSession();
expect(client.token_endpoint_auth_method).toBe("none");
const auth = await callMcp(tokens.access_token);
expect(auth).toMatchObject({
userId: "user-1",
organizationId: "org-1",
clientId: client.client_id,
});
expect(auth.scopes).toContain("mcp");
});
it("refreshes with client_id only and the new token still carries the mcp scope", async () => {
const { client, tokens } = await setupSession();
const response = await refresh(client.client_id, tokens.refresh_token);
expect(response.status).toBe(200);
const refreshed = tokenResponseSchema.parse(await response.json());
expect(refreshed.access_token).not.toBe(tokens.access_token);
const auth = await callMcp(refreshed.access_token);
expect(auth.scopes).toContain("mcp");
expect(auth.clientId).toBe(client.client_id);
// And the rotated refresh token keeps working for the next cycle.
const second = await refresh(client.client_id, refreshed.refresh_token);
expect(second.status).toBe(200);
});
it("honors the previous refresh token until the rotated one is used", async () => {
// Codex can lose a token response (crash, retry): the provider keeps the
// pre-rotation token valid until the new one is first used.
const { client, tokens } = await setupSession();
const first = tokenResponseSchema.parse(
await (await refresh(client.client_id, tokens.refresh_token)).json(),
);
// Retry with the ORIGINAL token before ever using the rotated one.
const retry = await refresh(client.client_id, tokens.refresh_token);
expect(retry.status).toBe(200);
const retried = tokenResponseSchema.parse(await retry.json());
// Once a newer refresh token is used, the stale original is rejected.
const afterUse = await refresh(client.client_id, retried.refresh_token);
expect(afterUse.status).toBe(200);
const stale = await refresh(client.client_id, tokens.refresh_token);
expect(stale.status).toBe(400);
expect(tokenErrorSchema.parse(await stale.json()).error).toBe(
"invalid_grant",
);
void first;
});
it("accepts a refresh carrying the canonical RFC 8707 resource and rejects a mismatched one", async () => {
const { client, tokens } = await setupSession();
const exact = await tokenRequest({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
client_id: client.client_id,
resource: MCP_RESOURCE,
});
expect(exact.status).toBe(200);
const rotated = tokenResponseSchema.parse(await exact.json());
const mismatched = await tokenRequest({
grant_type: "refresh_token",
refresh_token: rotated.refresh_token,
client_id: client.client_id,
resource: `${BASE}/other`,
});
expect(mismatched.status).toBeGreaterThanOrEqual(400);
expect(mismatched.status).toBeLessThan(500);
});
it("rejects an RFC 3986-equivalent resource variant (upstream #282 — flips when fixed)", async () => {
// workers-oauth-provider compares resources with strict string equality,
// so a trailing-slash variant of the canonical resource fails refresh with
// invalid_target (cloudflare/workers-oauth-provider#282, open). Codex
// omits the param entirely and never hits this; Cloudflare's MCP Server
// Portal normalizes URLs this way and does. When upstream ships equivalence
// matching, this test fails — flip the assertion to 200 and drop the note.
const { client, tokens } = await setupSession();
const slashVariant = await tokenRequest({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
client_id: client.client_id,
resource: `${MCP_RESOURCE}/`,
});
expect(slashVariant.status).toBe(400);
expect(tokenErrorSchema.parse(await slashVariant.json()).error).toBe(
"invalid_target",
);
});
it("rejects a refresh that drops the mcp scope with a structured error", async () => {
const { client, tokens } = await setupSession();
const response = await tokenRequest({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
client_id: client.client_id,
scope: "offline_access",
});
expect(response.status).toBe(400);
expect(tokenErrorSchema.parse(await response.json()).error).toBe(
"invalid_scope",
);
});
it("returns a structured error, never a 500, for a refresh at the end of the grant's life", async () => {
// workers-oauth-provider < 0.8.1 crashed (uncaught 500) refreshing a grant
// in its final minute because KV rejects sub-60s TTLs — a client that
// retries a 500 and gives up looks exactly like "refresh is broken".
const { client, tokens } = await setupSession();
vi.useFakeTimers({ toFake: ["Date"] });
try {
// 30-day refresh TTL (MCP_REFRESH_TOKEN_TTL_SECONDS), 30s before the end.
vi.setSystemTime(Date.now() + 30 * 24 * 60 * 60 * 1000 - 30 * 1000);
const response = await refresh(client.client_id, tokens.refresh_token);
expect(response.status).toBeLessThan(500);
if (response.status !== 200) {
expect(
tokenErrorSchema.parse(await response.json()).error,
).toBeTruthy();
}
} finally {
vi.useRealTimers();
}
});
it("still rejects a secretless refresh from a client registered as confidential", async () => {
// The pre-PR-#420 failure mode: a client stored with client_secret_basic
// that never sends its secret fails refresh until it re-registers.
const client = await registerCodexStyleClient({
token_endpoint_auth_method: "client_secret_basic",
});
expect(client.client_secret).toBeTruthy();
const codeVerifier = randomBytes(32).toString("base64url");
const code = await authorizeAndGetCode(client.client_id, codeVerifier);
// Confidential clients must authenticate even at the code exchange — and
// since 0.9.0 the registered method is enforced: client_secret_basic means
// the Authorization header, not a client_secret form field.
const basicAuth = Buffer.from(
`${client.client_id}:${client.client_secret ?? ""}`,
).toString("base64");
const exchange = await dispatch(
new Request(`${BASE}/api/auth/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "http://localhost:1455/auth/callback",
code_verifier: codeVerifier,
}).toString(),
}),
);
expect(exchange.status).toBe(200);
const tokens = tokenResponseSchema.parse(await exchange.json());
const secretless = await refresh(client.client_id, tokens.refresh_token);
expect(secretless.status).toBe(401);
expect(tokenErrorSchema.parse(await secretless.json()).error).toBe(
"invalid_client",
);
});
});

View File

@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { z } from "zod"; import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration";
import {
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
const registrationBodySchema = z.looseObject({ client_secret: z.string() });
describe("normalizeClientRegistrationRequest", () => { describe("normalizeClientRegistrationRequest", () => {
it("keeps explicit public registrations public", async () => { it("keeps explicit public registrations public", async () => {
@ -29,7 +23,7 @@ describe("normalizeClientRegistrationRequest", () => {
}); });
}); });
it("defaults omitted token auth methods to public clients", async () => { it("registers Perplexity as a real confidential client", async () => {
const request = new Request( const request = new Request(
"https://app.openseo.so/api/auth/oauth2/register", "https://app.openseo.so/api/auth/oauth2/register",
{ {
@ -44,6 +38,26 @@ describe("normalizeClientRegistrationRequest", () => {
const normalized = await normalizeClientRegistrationRequest(request); const normalized = await normalizeClientRegistrationRequest(request);
await expect(normalized.json()).resolves.toMatchObject({
token_endpoint_auth_method: "client_secret_post",
});
});
it("defaults other omitted token auth methods to public clients", async () => {
const request = new Request(
"https://app.openseo.so/api/auth/oauth2/register",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
redirect_uris: ["http://localhost:1455/auth/callback"],
client_name: "Codex",
}),
},
);
const normalized = await normalizeClientRegistrationRequest(request);
await expect(normalized.json()).resolves.toMatchObject({ await expect(normalized.json()).resolves.toMatchObject({
token_endpoint_auth_method: "none", token_endpoint_auth_method: "none",
}); });
@ -92,55 +106,3 @@ describe("normalizeClientRegistrationRequest", () => {
); );
}); });
}); });
function registrationResponse(body: Record<string, unknown>, status = 201) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("withCompatibilityClientSecret", () => {
it("adds a placeholder secret to public client registrations", async () => {
const response = await withCompatibilityClientSecret(
registrationResponse({
client_id: "abc123",
token_endpoint_auth_method: "none",
client_id_issued_at: 1753228800,
}),
);
const body = registrationBodySchema.parse(await response.json());
expect(body).toMatchObject({
client_id: "abc123",
token_endpoint_auth_method: "client_secret_post",
client_secret_expires_at: 0,
client_secret_issued_at: 1753228800,
});
expect(body.client_secret).toMatch(/^[0-9a-f]{32}$/);
});
it("leaves confidential registrations untouched", async () => {
const original = registrationResponse({
client_id: "abc123",
token_endpoint_auth_method: "client_secret_post",
client_secret: "real-secret",
});
const response = await withCompatibilityClientSecret(original);
expect(response).toBe(original);
});
it("leaves registration errors untouched", async () => {
const original = registrationResponse(
{ error: "invalid_client_metadata" },
400,
);
const response = await withCompatibilityClientSecret(original);
expect(response).toBe(original);
});
});

View File

@ -1,18 +1,14 @@
import { z } from "zod"; import { z } from "zod";
const PUBLIC_CLIENT_AUTH_METHOD = "none"; const PUBLIC_CLIENT_AUTH_METHOD = "none";
const COMPAT_CLIENT_AUTH_METHOD = "client_secret_post"; const CONFIDENTIAL_CLIENT_AUTH_METHOD = "client_secret_post";
const PERPLEXITY_CALLBACK = "https://www.perplexity.ai/api/mcp/oauth/callback";
const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024; const MAX_CLIENT_REGISTRATION_BODY_BYTES = 1024 * 1024;
// Loose so every field the provider cares about survives the round trip; these // Loose so every field the provider cares about survives the round trip.
// shims only read the auth method and secret.
const clientMetadataSchema = z.looseObject({ const clientMetadataSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(), token_endpoint_auth_method: z.string().optional(),
}); redirect_uris: z.array(z.string()).optional(),
const clientRegistrationSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(),
client_secret: z.string().optional(),
}); });
export async function normalizeClientRegistrationRequest(request: Request) { export async function normalizeClientRegistrationRequest(request: Request) {
@ -49,15 +45,15 @@ export async function normalizeClientRegistrationRequest(request: Request) {
return request; return request;
} }
// The provider defaults an omitted auth method to client_secret_basic, // Perplexity requires a client secret, so let the provider create and store a
// which requires client authentication on every grant — including refresh. // real one. Other MCP clients that omit the method are public clients: some
// MCP clients that discard the secret (Codex did) then lose their session // discard DCR secrets and would otherwise fail their first token refresh.
// at first token expiry with "invalid_client: missing client_secret". const isPerplexity = parsed.data.redirect_uris?.includes(PERPLEXITY_CALLBACK);
// Register them as public clients instead; PKCE plus the provider's
// grant-to-client binding secure the public-client flow.
const metadata = { const metadata = {
...parsed.data, ...parsed.data,
token_endpoint_auth_method: PUBLIC_CLIENT_AUTH_METHOD, token_endpoint_auth_method: isPerplexity
? CONFIDENTIAL_CLIENT_AUTH_METHOD
: PUBLIC_CLIENT_AUTH_METHOD,
}; };
const headers = new Headers(request.headers); const headers = new Headers(request.headers);
@ -70,47 +66,3 @@ export async function normalizeClientRegistrationRequest(request: Request) {
body: JSON.stringify(metadata), body: JSON.stringify(metadata),
}); });
} }
export async function withCompatibilityClientSecret(response: Response) {
if (response.status !== 201) {
return response;
}
let rawBody: unknown;
try {
rawBody = await response.clone().json();
} catch {
return response;
}
const parsed = clientRegistrationSchema.safeParse(rawBody);
if (
!parsed.success ||
parsed.data.token_endpoint_auth_method !== PUBLIC_CLIENT_AUTH_METHOD ||
parsed.data.client_secret !== undefined
) {
return response;
}
// Perplexity registers as a public client but rejects DCR responses without
// a client_secret (its validator accepts client_secret_post but not
// client_secret_basic). Dress the response as confidential while the stored
// client stays public: the token endpoint skips secret validation for public
// clients, so clients that send this placeholder and clients that never
// store it both keep working — including refresh grants.
const compatBody = {
...parsed.data,
token_endpoint_auth_method: COMPAT_CLIENT_AUTH_METHOD,
client_secret: crypto.randomUUID().replaceAll("-", ""),
client_secret_expires_at: 0,
client_secret_issued_at: parsed.data.client_id_issued_at,
};
const headers = new Headers(response.headers);
headers.delete("Content-Length");
return new Response(JSON.stringify(compatBody), {
status: response.status,
headers,
});
}

View File

@ -1,5 +1,18 @@
import { z } from "zod"; import { z } from "zod";
// Tools author schemas as either a raw Zod shape (most tools) or a full
// z.object (the GA4 tools). The SDK accepts both; this normalization exists so
// TS overload resolution stays simple and instrumentation receives a real
// ZodType to validate with.
export function objectSchema(schema: z.ZodType | z.ZodRawShape): z.ZodType;
export function objectSchema(
schema: z.ZodType | z.ZodRawShape | undefined,
): z.ZodType | undefined;
export function objectSchema(schema: z.ZodType | z.ZodRawShape | undefined) {
if (!schema) return undefined;
return schema instanceof z.ZodType ? schema : z.object(schema);
}
const mcpMetaOutputSchema = z const mcpMetaOutputSchema = z
.object({ .object({
url: z.string().optional(), url: z.string().optional(),

View File

@ -1,7 +1,5 @@
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import type { ToolExtra } from "@/server/mcp/context";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import { makeToolContext } from "@/server/mcp/tools/tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
@ -13,30 +11,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const authContext = { const toolContext = makeToolContext();
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
authInfo: {
token: "token",
clientId: "client_123",
scopes: ["mcp"],
resource: new URL("https://open-seo.test/mcp"),
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
} satisfies AuthInfo,
};
describe("withMcpProjectAuth", () => { describe("withMcpProjectAuth", () => {
beforeEach(() => { beforeEach(() => {
@ -57,7 +32,7 @@ describe("withMcpProjectAuth", () => {
const wrapped = withMcpProjectAuth(handler); const wrapped = withMcpProjectAuth(handler);
await expect( await expect(
wrapped({ projectId: "project_123" }, toolExtra), wrapped({ projectId: "project_123" }, toolContext),
).resolves.toBe("ok"); ).resolves.toBe("ok");
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith( expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
@ -71,7 +46,7 @@ describe("withMcpProjectAuth", () => {
const handler = vi.fn().mockReturnValue("ok"); const handler = vi.fn().mockReturnValue("ok");
const wrapped = withMcpProjectAuth(handler); const wrapped = withMcpProjectAuth(handler);
await wrapped({ projectId: "project_123" }, toolExtra); await wrapped({ projectId: "project_123" }, toolContext);
expect(handler).toHaveBeenCalledWith( expect(handler).toHaveBeenCalledWith(
{ projectId: "project_123" }, { projectId: "project_123" },
@ -82,8 +57,6 @@ describe("withMcpProjectAuth", () => {
organizationId: "org_123", organizationId: "org_123",
clientId: "client_123", clientId: "client_123",
scopes: ["mcp"], scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
}, },
baseUrl: "https://open-seo.test", baseUrl: "https://open-seo.test",
billing: { billing: {
@ -109,9 +82,9 @@ describe("withMcpProjectAuth", () => {
const handler = vi.fn(); const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler); const wrapped = withMcpProjectAuth(handler);
await expect(wrapped({ projectId: "project_123" }, toolExtra)).rejects.toBe( await expect(
error, wrapped({ projectId: "project_123" }, toolContext),
); ).rejects.toBe(error);
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
@ -126,7 +99,7 @@ describe("withMcpProjectAuth", () => {
const wrapped = withMcpProjectAuth(handler); const wrapped = withMcpProjectAuth(handler);
await expect( await expect(
wrapped({ projectId: "someone-elses-project" }, toolExtra), wrapped({ projectId: "someone-elses-project" }, toolContext),
).rejects.toThrow(); ).rejects.toThrow();
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();

View File

@ -1,17 +1,16 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
buildBillingCustomer,
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
type ProjectScopedArgs = { type ProjectScopedArgs = {
projectId: string; projectId: string;
}; };
async function requireProjectAccess(extra: ToolExtra, projectId: string) { async function requireProjectAccess(
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); toolContext: ToolContext,
projectId: string,
) {
const { baseUrl, ...auth } = toolContext.auth;
// Authorize the caller-supplied projectId against the token's organization. // Authorize the caller-supplied projectId against the token's organization.
// Assert on the result instead of relying on the lookup throwing, so this // Assert on the result instead of relying on the lookup throwing, so this
@ -42,8 +41,8 @@ export function withMcpProjectAuth<TArgs extends ProjectScopedArgs, TResult>(
context: McpProjectAuthContext, context: McpProjectAuthContext,
) => Promise<TResult> | TResult, ) => Promise<TResult> | TResult,
) { ) {
return async (args: TArgs, extra: ToolExtra) => { return async (args: TArgs, toolContext: ToolContext) => {
const context = await requireProjectAccess(extra, args.projectId); const context = await requireProjectAccess(toolContext, args.projectId);
return handler(args, context); return handler(args, context);
}; };
} }

View File

@ -1,12 +1,11 @@
import type { import {
type CallToolResult,
McpServer, McpServer,
ToolCallback, type ToolAnnotations,
} from "@modelcontextprotocol/sdk/server/mcp.js"; } from "@modelcontextprotocol/server";
import type { import type { z } from "zod";
AnySchema, import { createMcpToolContext, type ToolContext } from "@/server/mcp/context";
ZodRawShapeCompat, import { objectSchema } from "@/server/mcp/output-schemas";
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation"; import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview"; import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile"; import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
@ -19,9 +18,6 @@ import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
import { removeRankTrackingKeywordsTool } from "@/server/mcp/tools/remove-rank-tracking-keywords"; import { removeRankTrackingKeywordsTool } from "@/server/mcp/tools/remove-rank-tracking-keywords";
import { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker"; import { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results"; import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import { createProjectTool } from "@/server/mcp/tools/create-project";
import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import { import {
getGoogleAnalyticsAudienceBreakdownTool, getGoogleAnalyticsAudienceBreakdownTool,
getGoogleAnalyticsEcommercePerformanceTool, getGoogleAnalyticsEcommercePerformanceTool,
@ -34,6 +30,9 @@ import {
getGoogleAnalyticsTrafficAcquisitionTool, getGoogleAnalyticsTrafficAcquisitionTool,
getSearchOpportunitiesTool, getSearchOpportunitiesTool,
} from "@/server/mcp/tools/google-analytics-tools"; } from "@/server/mcp/tools/google-analytics-tools";
import { createProjectTool } from "@/server/mcp/tools/create-project";
import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import { import {
findSerpCompetitorsTool, findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool, getGoogleBusinessQuestionsTool,
@ -56,75 +55,118 @@ import {
} from "@/server/mcp/tools/site-audit-tools"; } from "@/server/mcp/tools/site-audit-tools";
import { whoamiTool } from "@/server/mcp/tools/whoami"; import { whoamiTool } from "@/server/mcp/tools/whoami";
// Each handler is wrapped so failures reach PostHog because the MCP route has type ToolSchema = z.ZodType | z.ZodRawShape;
// no error middleware of its own.
function registerInstrumentedTool< // Tools declare inputSchema as either a raw Zod shape (most tools) or a full
In extends ZodRawShapeCompat | AnySchema, // z.object (the GA4 tools); both normalize to one object schema at
Out extends ZodRawShapeCompat | AnySchema, // registration.
>( type ToolArgs<Input extends ToolSchema> = Input extends z.ZodType
server: McpServer, ? z.infer<Input>
tool: { : Input extends z.ZodRawShape
? z.infer<z.ZodObject<Input>>
: never;
type OpenSeoToolDefinition<Input extends ToolSchema> = {
name: string; name: string;
config: { config: {
inputSchema?: In;
outputSchema?: Out;
title?: string; title?: string;
description?: string; description?: string;
inputSchema: Input;
outputSchema?: ToolSchema;
annotations?: ToolAnnotations; annotations?: ToolAnnotations;
}; };
handler: ToolCallback<In>; handler: (
}, args: ToolArgs<Input>,
context: ToolContext,
) => CallToolResult | Promise<CallToolResult>;
};
function registerOpenSeoTool<Input extends ToolSchema>(
server: McpServer,
tool: OpenSeoToolDefinition<Input>,
) { ) {
const outputSchema = objectSchema(tool.config.outputSchema);
const handler = instrumentMcpToolHandler(
tool.name,
outputSchema,
tool.handler,
);
server.registerTool( server.registerTool(
tool.name, tool.name,
tool.config, {
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- instrumentation preserves the callback arguments validated by ToolCallback<In> ...tool.config,
instrumentMcpToolHandler( inputSchema: objectSchema(tool.config.inputSchema),
tool.name, outputSchema,
tool.config.outputSchema, },
tool.handler, (args, context) =>
) as ToolCallback<In>, // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- args were validated against the tool's own inputSchema just above
handler(args as ToolArgs<Input>, createMcpToolContext(context)),
); );
} }
export function registerOpenSeoMcpTools(server: McpServer) { export function createOpenSeoMcpServer() {
registerInstrumentedTool(server, whoamiTool); const server = new McpServer(
registerInstrumentedTool(server, listProjectsTool); {
registerInstrumentedTool(server, createProjectTool); name: "OpenSEO MCP",
registerInstrumentedTool(server, listSavedKeywordsTool); title: "OpenSEO",
registerInstrumentedTool(server, researchKeywordsTool); version: "0.0.11",
registerInstrumentedTool(server, saveKeywordsTool); description:
registerInstrumentedTool(server, getDomainOverviewTool); "SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.",
registerInstrumentedTool(server, getDomainKeywordSuggestionsTool); websiteUrl: "https://openseo.so",
registerInstrumentedTool(server, getBacklinksOverviewTool); icons: [
registerInstrumentedTool(server, getBacklinksProfileTool); {
registerInstrumentedTool(server, getSerpResultsTool); src: "https://openseo.so/android-chrome-512x512.png",
registerInstrumentedTool(server, createRankTrackerTool); mimeType: "image/png",
registerInstrumentedTool(server, getRankTrackerTool); sizes: ["512x512"],
registerInstrumentedTool(server, addRankTrackingKeywordsTool); },
registerInstrumentedTool(server, removeRankTrackingKeywordsTool); ],
registerInstrumentedTool(server, estimateRankTrackerCostTool); },
registerInstrumentedTool(server, runRankTrackerTool); {
registerInstrumentedTool(server, getRankedKeywordsTool); instructions:
registerInstrumentedTool(server, findSerpCompetitorsTool); "OpenSEO research tools use credits. Proceed with normal focused research, but ask the user for confirmation before planned batches over 2,000 credits.",
registerInstrumentedTool(server, searchLocalBusinessesTool); },
registerInstrumentedTool(server, getLocalSerpResultsTool); );
registerInstrumentedTool(server, getGoogleBusinessQuestionsTool);
registerInstrumentedTool(server, getKeywordMetricsTool); registerOpenSeoTool(server, whoamiTool);
registerInstrumentedTool(server, getSearchConsolePerformanceTool); registerOpenSeoTool(server, listProjectsTool);
registerInstrumentedTool(server, inspectUrlsTool); registerOpenSeoTool(server, createProjectTool);
registerInstrumentedTool(server, getGoogleAnalyticsOrganicLandingPagesTool); registerOpenSeoTool(server, listSavedKeywordsTool);
registerInstrumentedTool(server, getGoogleAnalyticsPagePerformanceTool); registerOpenSeoTool(server, researchKeywordsTool);
registerInstrumentedTool(server, getGoogleAnalyticsKeyEventsTool); registerOpenSeoTool(server, saveKeywordsTool);
registerInstrumentedTool(server, getSearchOpportunitiesTool); registerOpenSeoTool(server, getDomainOverviewTool);
registerInstrumentedTool(server, getGoogleAnalyticsOrganicOverviewTool); registerOpenSeoTool(server, getDomainKeywordSuggestionsTool);
registerInstrumentedTool(server, getGoogleAnalyticsTrafficAcquisitionTool); registerOpenSeoTool(server, getBacklinksOverviewTool);
registerInstrumentedTool(server, getGoogleAnalyticsMeasurementHealthTool); registerOpenSeoTool(server, getBacklinksProfileTool);
registerInstrumentedTool(server, getGoogleAnalyticsEcommercePerformanceTool); registerOpenSeoTool(server, getSerpResultsTool);
registerInstrumentedTool(server, getGoogleAnalyticsSiteSearchTool); registerOpenSeoTool(server, createRankTrackerTool);
registerInstrumentedTool(server, getGoogleAnalyticsAudienceBreakdownTool); registerOpenSeoTool(server, getRankTrackerTool);
registerInstrumentedTool(server, runSiteAuditTool); registerOpenSeoTool(server, addRankTrackingKeywordsTool);
registerInstrumentedTool(server, getAuditStatusTool); registerOpenSeoTool(server, removeRankTrackingKeywordsTool);
registerInstrumentedTool(server, getAuditIssuesTool); registerOpenSeoTool(server, estimateRankTrackerCostTool);
registerInstrumentedTool(server, getAuditPagesTool); registerOpenSeoTool(server, runRankTrackerTool);
registerOpenSeoTool(server, getRankedKeywordsTool);
registerOpenSeoTool(server, findSerpCompetitorsTool);
registerOpenSeoTool(server, searchLocalBusinessesTool);
registerOpenSeoTool(server, getLocalSerpResultsTool);
registerOpenSeoTool(server, getGoogleBusinessQuestionsTool);
registerOpenSeoTool(server, getKeywordMetricsTool);
registerOpenSeoTool(server, getSearchConsolePerformanceTool);
registerOpenSeoTool(server, inspectUrlsTool);
registerOpenSeoTool(server, getGoogleAnalyticsOrganicLandingPagesTool);
registerOpenSeoTool(server, getGoogleAnalyticsPagePerformanceTool);
registerOpenSeoTool(server, getGoogleAnalyticsKeyEventsTool);
registerOpenSeoTool(server, getSearchOpportunitiesTool);
registerOpenSeoTool(server, getGoogleAnalyticsOrganicOverviewTool);
registerOpenSeoTool(server, getGoogleAnalyticsTrafficAcquisitionTool);
registerOpenSeoTool(server, getGoogleAnalyticsMeasurementHealthTool);
registerOpenSeoTool(server, getGoogleAnalyticsEcommercePerformanceTool);
registerOpenSeoTool(server, getGoogleAnalyticsSiteSearchTool);
registerOpenSeoTool(server, getGoogleAnalyticsAudienceBreakdownTool);
registerOpenSeoTool(server, runSiteAuditTool);
registerOpenSeoTool(server, getAuditStatusTool);
registerOpenSeoTool(server, getAuditIssuesTool);
registerOpenSeoTool(server, getAuditPagesTool);
return server;
} }

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createProjectTool } from "./create-project"; import { createProjectTool } from "./create-project";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createProject: vi.fn(), createProject: vi.fn(),
@ -12,7 +12,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
describe("create_project MCP tool", () => { describe("create_project MCP tool", () => {
beforeEach(() => {}); beforeEach(() => {});
@ -28,7 +28,7 @@ describe("create_project MCP tool", () => {
const result = await createProjectTool.handler( const result = await createProjectTool.handler(
{ name: "Acme", domain: "acme.com", locationCode: 2840 }, { name: "Acme", domain: "acme.com", locationCode: 2840 },
toolExtra, toolContext,
); );
// The schema does not derive languageCode; the service resolves it from // The schema does not derive languageCode; the service resolves it from
@ -62,7 +62,7 @@ describe("create_project MCP tool", () => {
languageCode: "en", languageCode: "en",
}); });
await createProjectTool.handler({ name: "Just a name" }, toolExtra); await createProjectTool.handler({ name: "Just a name" }, toolContext);
expect(mocks.createProject).toHaveBeenCalledWith("org_123", { expect(mocks.createProject).toHaveBeenCalledWith("org_123", {
name: "Just a name", name: "Just a name",
@ -73,7 +73,7 @@ describe("create_project MCP tool", () => {
await expect( await expect(
createProjectTool.handler( createProjectTool.handler(
{ name: "Bad market", languageCode: "en" }, { name: "Bad market", languageCode: "en" },
toolExtra, toolContext,
), ),
).rejects.toThrow(); ).rejects.toThrow();
expect(mocks.createProject).not.toHaveBeenCalled(); expect(mocks.createProject).not.toHaveBeenCalled();

View File

@ -1,9 +1,6 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { import { type ToolContext } from "@/server/mcp/context";
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
import { languageCodeSchema, locationCodeSchema } from "@/server/mcp/schemas"; import { languageCodeSchema, locationCodeSchema } from "@/server/mcp/schemas";
@ -65,8 +62,8 @@ export const createProjectTool = {
destructiveHint: false, destructiveHint: false,
}, },
}, },
handler: async (args: Args, extra: ToolExtra) => { handler: async (args: Args, context: ToolContext) => {
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); const { baseUrl, ...auth } = context.auth;
// Reuse the app's create schema so the market pair rule (a languageCode // Reuse the app's create schema so the market pair rule (a languageCode
// requires a locationCode) is enforced identically to the dashboard, and // requires a locationCode) is enforced identically to the dashboard, and
// the domain is normalized the same way. // the domain is normalized the same way.

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics"; import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
import { getKeywordMetricsTool } from "./dataforseo-research-tools"; import { getKeywordMetricsTool } from "./dataforseo-research-tools";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(), createDataforseoClient: vi.fn(),
@ -31,7 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
describe("get_keyword_metrics for Google-Ads-only locations", () => { describe("get_keyword_metrics for Google-Ads-only locations", () => {
beforeEach(() => { beforeEach(() => {
@ -68,7 +68,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
locationCode: 2352, locationCode: 2352,
languageCode: "is", languageCode: "is",
}, },
toolExtra, toolContext,
); );
expect(keywordOverview).not.toHaveBeenCalled(); expect(keywordOverview).not.toHaveBeenCalled();
@ -120,7 +120,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
keywords: ["seo tools"], keywords: ["seo tools"],
includeClickstreamData: true, includeClickstreamData: true,
}, },
toolExtra, toolContext,
); );
expect(keywordOverview).toHaveBeenCalledWith( expect(keywordOverview).toHaveBeenCalledWith(

View File

@ -3,7 +3,7 @@ import {
findSerpCompetitorsTool, findSerpCompetitorsTool,
getRankedKeywordsTool, getRankedKeywordsTool,
} from "./dataforseo-research-tools"; } from "./dataforseo-research-tools";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
// Market resolution for get_ranked_keywords: the explicit country selector and // Market resolution for get_ranked_keywords: the explicit country selector and
// the project's default-market fallback (projects.locationCode/languageCode). // the project's default-market fallback (projects.locationCode/languageCode).
@ -27,7 +27,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
function setProject(market: { locationCode: number; languageCode: string }) { function setProject(market: { locationCode: number; languageCode: string }) {
mocks.getProjectForOrganization.mockResolvedValue({ mocks.getProjectForOrganization.mockResolvedValue({
@ -55,7 +55,7 @@ async function runRankedKeywords(args: MarketArgs) {
}); });
await getRankedKeywordsTool.handler( await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", ...args }, { projectId: "project_1", target: "acmeexample.com", ...args },
toolExtra, toolContext,
); );
return rankedKeywords; return rankedKeywords;
} }
@ -67,7 +67,7 @@ async function runSerpCompetitors(args: MarketArgs) {
}); });
await findSerpCompetitorsTool.handler( await findSerpCompetitorsTool.handler(
{ projectId: "project_1", keywords: ["seo"], ...args }, { projectId: "project_1", keywords: ["seo"], ...args },
toolExtra, toolContext,
); );
return serpCompetitors; return serpCompetitors;
} }

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics"; import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
import * as researchTools from "./dataforseo-research-tools"; import * as researchTools from "./dataforseo-research-tools";
import { makeToolExtra, textContent } from "./tool-test-support"; import { makeToolContext, textContent } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
createDataforseoClient: vi.fn(), createDataforseoClient: vi.fn(),
@ -31,7 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
const usProjectRow = { const usProjectRow = {
id: "project_1", id: "project_1",
@ -70,7 +70,7 @@ describe("DataForSEO research MCP tools", () => {
}, },
categories: ["cafe"], categories: ["cafe"],
}, },
toolExtra, toolContext,
); );
expect(businessListings).toHaveBeenCalledWith( expect(businessListings).toHaveBeenCalledWith(
@ -116,7 +116,7 @@ describe("DataForSEO research MCP tools", () => {
zoom: 14, zoom: 14,
}, },
}, },
toolExtra, toolContext,
); );
expect(local).toHaveBeenCalledWith( expect(local).toHaveBeenCalledWith(
@ -164,7 +164,7 @@ describe("DataForSEO research MCP tools", () => {
radiusKm: 5, radiusKm: 5,
}, },
}, },
toolExtra, toolContext,
); );
expect(questionsAnswers).toHaveBeenCalledWith( expect(questionsAnswers).toHaveBeenCalledWith(
@ -201,7 +201,7 @@ describe("DataForSEO research MCP tools", () => {
target: "acmeexample.com", target: "acmeexample.com",
excludeBrandTerms: ["acme"], excludeBrandTerms: ["acme"],
}, },
toolExtra, toolContext,
); );
expect(rankedKeywords).toHaveBeenCalledWith( expect(rankedKeywords).toHaveBeenCalledWith(
@ -228,7 +228,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["coffee"], keywords: ["coffee"],
excludeDomains: ["directory.example"], excludeDomains: ["directory.example"],
}, },
toolExtra, toolContext,
); );
const content = z const content = z
@ -285,7 +285,7 @@ describe("DataForSEO research MCP tools", () => {
const result = await getKeywordMetricsTool.handler( const result = await getKeywordMetricsTool.handler(
{ projectId: "project_1", keywords: ["seo automation"] }, { projectId: "project_1", keywords: ["seo automation"] },
toolExtra, toolContext,
); );
expect(keywordOverview).toHaveBeenCalledWith( expect(keywordOverview).toHaveBeenCalledWith(
@ -340,7 +340,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["low", "high", "medium"], keywords: ["low", "high", "medium"],
sortBy: "search_volume", sortBy: "search_volume",
}, },
toolExtra, toolContext,
); );
const rows = z const rows = z
@ -372,7 +372,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["seo"], keywords: ["seo"],
includeMonthlyTrends: false, includeMonthlyTrends: false,
}, },
toolExtra, toolContext,
); );
const rows = z const rows = z

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures"; import { makeGa4ReportResult } from "@/server/features/ga4/services/ga4-test-fixtures";
import { Ga4ReportError } from "@/server/lib/ga4Errors"; import { Ga4ReportError } from "@/server/lib/ga4Errors";
import * as tools from "./google-analytics-tools"; import * as tools from "./google-analytics-tools";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
runReport: vi.fn(), runReport: vi.fn(),
@ -37,7 +37,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
const reportResult = makeGa4ReportResult({ const reportResult = makeGa4ReportResult({
rowCount: 1, rowCount: 1,
totalRowCount: 1, totalRowCount: 1,
@ -70,7 +70,7 @@ describe("Google Analytics MCP tools", () => {
const { getGoogleAnalyticsOrganicLandingPagesTool } = tools; const { getGoogleAnalyticsOrganicLandingPagesTool } = tools;
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler( const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 10, offset: 0 }, { projectId: "project_1", limit: 10, offset: 0 },
toolExtra, toolContext,
); );
expect(mocks.runReport).toHaveBeenCalledWith({ expect(mocks.runReport).toHaveBeenCalledWith({
projectId: "project_1", projectId: "project_1",
@ -99,7 +99,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
); );
await getGoogleAnalyticsKeyEventsTool.handler( await getGoogleAnalyticsKeyEventsTool.handler(
{ {
@ -110,7 +110,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
); );
expect(mocks.runReport).toHaveBeenNthCalledWith( expect(mocks.runReport).toHaveBeenNthCalledWith(
1, 1,
@ -146,7 +146,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
status: "error", status: "error",
@ -168,7 +168,7 @@ describe("Google Analytics MCP tools", () => {
const { getSearchOpportunitiesTool } = tools; const { getSearchOpportunitiesTool } = tools;
const result = await getSearchOpportunitiesTool.handler( const result = await getSearchOpportunitiesTool.handler(
{ projectId: "project_1", limit: 25 }, { projectId: "project_1", limit: 25 },
toolExtra, toolContext,
); );
expect(mocks.getOpportunities).toHaveBeenCalledWith({ expect(mocks.getOpportunities).toHaveBeenCalledWith({
projectId: "project_1", projectId: "project_1",
@ -193,7 +193,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
), ),
], ],
[ [
@ -208,7 +208,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
), ),
], ],
[ [
@ -216,7 +216,7 @@ describe("Google Analytics MCP tools", () => {
() => () =>
tools.getGoogleAnalyticsSiteSearchTool.handler( tools.getGoogleAnalyticsSiteSearchTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 }, { projectId: "project_1", limit: 100, offset: 0 },
toolExtra, toolContext,
), ),
], ],
[ [
@ -231,7 +231,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100, limit: 100,
offset: 0, offset: 0,
}, },
toolExtra, toolContext,
), ),
], ],
] as const)( ] as const)(
@ -265,11 +265,11 @@ describe("Google Analytics MCP tools", () => {
} = tools; } = tools;
const overview = await getGoogleAnalyticsOrganicOverviewTool.handler( const overview = await getGoogleAnalyticsOrganicOverviewTool.handler(
{ projectId: "project_1", trend: "weekly" }, { projectId: "project_1", trend: "weekly" },
toolExtra, toolContext,
); );
const health = await getGoogleAnalyticsMeasurementHealthTool.handler( const health = await getGoogleAnalyticsMeasurementHealthTool.handler(
{ projectId: "project_1" }, { projectId: "project_1" },
toolExtra, toolContext,
); );
expect(mocks.getOrganicOverview).toHaveBeenCalledWith({ expect(mocks.getOrganicOverview).toHaveBeenCalledWith({
projectId: "project_1", projectId: "project_1",

View File

@ -1,5 +1,5 @@
/* eslint-disable max-lines -- all GA4 MCP tools are intentionally kept in one module */ /* eslint-disable max-lines -- all GA4 MCP tools are intentionally kept in one module */
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CallToolResult } from "@modelcontextprotocol/server";
import { z } from "zod"; import { z } from "zod";
import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService"; import { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService";
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService"; import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";

View File

@ -1,9 +1,6 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { import { type ToolContext } from "@/server/mcp/context";
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls"; import { buildDashboardUrl } from "@/server/mcp/urls";
import { z } from "zod"; import { z } from "zod";
@ -36,8 +33,8 @@ export const listProjectsTool = {
destructiveHint: false, destructiveHint: false,
}, },
}, },
handler: async (_args: Record<string, never>, extra: ToolExtra) => { handler: async (_args: Record<string, never>, context: ToolContext) => {
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra); const { baseUrl, ...auth } = context.auth;
const projects = await ProjectService.listProjects(auth.organizationId); const projects = await ProjectService.listProjects(auth.organizationId);
const lines = const lines =
projects.length === 0 projects.length === 0

View File

@ -1,12 +1,9 @@
import {
normalizeObjectSchema,
safeParseAsync,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { objectSchema } from "@/server/mcp/output-schemas";
import * as researchTools from "./dataforseo-research-tools"; import * as researchTools from "./dataforseo-research-tools";
import { getBacklinksProfileTool } from "./get-backlinks-profile"; import { getBacklinksProfileTool } from "./get-backlinks-profile";
import { makeMcpAuthContext, makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
@ -42,12 +39,10 @@ class ProviderRow {
) {} ) {}
} }
const authExtra = makeToolExtra( const toolContext = makeToolContext({
makeMcpAuthContext({
userEmail: "team@example.com", userEmail: "team@example.com",
baseUrl: "https://app.example.com", baseUrl: "https://app.example.com",
}), });
);
const backlinkPage = { const backlinkPage = {
rows: [ rows: [
@ -100,13 +95,12 @@ describe("DataForSEO research tool output schemas", () => {
const tool = Object.values(tools).find((t) => t.name === toolName); const tool = Object.values(tools).find((t) => t.name === toolName);
if (!tool) throw new Error(`tool ${toolName} not found`); if (!tool) throw new Error(`tool ${toolName} not found`);
const schema = normalizeObjectSchema(tool.config.outputSchema); const schema = objectSchema(tool.config.outputSchema);
if (!schema) throw new Error("output schema did not normalize");
// Mirror the MCP server: validate structuredContent against the tool's // Mirror the MCP server: validate structuredContent against the tool's
// own output schema. Extra keys (e.g. get_ranked_keywords' totalCount) // own output schema. Extra keys (e.g. get_ranked_keywords' totalCount)
// are allowed by the passthrough schemas, so one payload covers all. // are allowed by the passthrough schemas, so one payload covers all.
const result = await safeParseAsync(schema, { const result = await schema.safeParseAsync({
[field]: [new ProviderRow("example.com", 1)], [field]: [new ProviderRow("example.com", 1)],
totalCount: 1, totalCount: 1,
}); });
@ -116,12 +110,9 @@ describe("DataForSEO research tool output schemas", () => {
); );
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => { it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
const schema = normalizeObjectSchema( const schema = objectSchema(getBacklinksProfileTool.config.outputSchema);
getBacklinksProfileTool.config.outputSchema,
);
if (!schema) throw new Error("output schema did not normalize");
const result = await safeParseAsync(schema, { const result = await schema.safeParseAsync({
backlinks: backlinkPage, backlinks: backlinkPage,
meta: { meta: {
organizationId: "org_123", organizationId: "org_123",
@ -155,7 +146,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "as_is", mode: "as_is",
hideSpam: false, hideSpam: false,
}, },
authExtra, toolContext,
); );
expect(mocks.profileBacklinksPage).toHaveBeenCalledWith( expect(mocks.profileBacklinksPage).toHaveBeenCalledWith(
@ -208,7 +199,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "one_per_domain", mode: "one_per_domain",
hideSpam: true, hideSpam: true,
}, },
authExtra, toolContext,
); );
expect(result.structuredContent?.backlinks).toMatchObject({ expect(result.structuredContent?.backlinks).toMatchObject({
@ -240,7 +231,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "one_per_domain", mode: "one_per_domain",
hideSpam: true, hideSpam: true,
}, },
authExtra, toolContext,
), ),
).rejects.toMatchObject({ ).rejects.toMatchObject({
code: "BACKLINKS_BILLING_ISSUE", code: "BACKLINKS_BILLING_ISSUE",

View File

@ -5,7 +5,7 @@ import { createRankTrackerTool } from "./create-rank-tracker";
import { estimateRankTrackerCostTool } from "./estimate-rank-tracker-cost"; import { estimateRankTrackerCostTool } from "./estimate-rank-tracker-cost";
import { removeRankTrackingKeywordsTool } from "./remove-rank-tracking-keywords"; import { removeRankTrackingKeywordsTool } from "./remove-rank-tracking-keywords";
import { runRankTrackerTool } from "./run-rank-tracker"; import { runRankTrackerTool } from "./run-rank-tracker";
import { makeToolExtra, textContent } from "./tool-test-support"; import { makeToolContext, textContent } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
@ -46,7 +46,7 @@ const projectId = "11111111-1111-4111-8111-111111111111";
const trackerId = "22222222-2222-4222-8222-222222222222"; const trackerId = "22222222-2222-4222-8222-222222222222";
const keywordId = "33333333-3333-4333-8333-333333333333"; const keywordId = "33333333-3333-4333-8333-333333333333";
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
const createdConfig = { const createdConfig = {
id: trackerId, id: trackerId,
@ -78,7 +78,7 @@ describe("rank tracking management MCP tools", () => {
const parsed = z.object(createRankTrackerTool.config.inputSchema).parse({ const parsed = z.object(createRankTrackerTool.config.inputSchema).parse({
projectId, projectId,
}); });
const result = await createRankTrackerTool.handler(parsed, toolExtra); const result = await createRankTrackerTool.handler(parsed, toolContext);
expect(mocks.createConfig).toHaveBeenCalledWith({ expect(mocks.createConfig).toHaveBeenCalledWith({
projectId, projectId,
@ -130,7 +130,7 @@ describe("rank tracking management MCP tools", () => {
}); });
await expect( await expect(
createRankTrackerTool.handler({ projectId }, toolExtra), createRankTrackerTool.handler({ projectId }, toolContext),
).rejects.toMatchObject({ code: "VALIDATION_ERROR" }); ).rejects.toMatchObject({ code: "VALIDATION_ERROR" });
expect(mocks.createConfig).not.toHaveBeenCalled(); expect(mocks.createConfig).not.toHaveBeenCalled();
}); });
@ -153,7 +153,7 @@ describe("rank tracking management MCP tools", () => {
const added = await addRankTrackingKeywordsTool.handler( const added = await addRankTrackingKeywordsTool.handler(
{ projectId, trackerId, keywords: ["seo", "SEO", "existing"] }, { projectId, trackerId, keywords: ["seo", "SEO", "existing"] },
toolExtra, toolContext,
); );
expect(textContent(added)).toContain("Added 1 of 3 requested"); expect(textContent(added)).toContain("Added 1 of 3 requested");
expect(added.structuredContent).toMatchObject({ requested: 3, added: 1 }); expect(added.structuredContent).toMatchObject({ requested: 3, added: 1 });
@ -169,7 +169,7 @@ describe("rank tracking management MCP tools", () => {
const removed = await removeRankTrackingKeywordsTool.handler( const removed = await removeRankTrackingKeywordsTool.handler(
{ projectId, trackerId, keywordIds: [keywordId, keywordId] }, { projectId, trackerId, keywordIds: [keywordId, keywordId] },
toolExtra, toolContext,
); );
expect(textContent(removed)).toContain("Removed 1 of 2 requested"); expect(textContent(removed)).toContain("Removed 1 of 2 requested");
expect(removed.structuredContent).toMatchObject({ expect(removed.structuredContent).toMatchObject({
@ -200,7 +200,7 @@ describe("rank tracking management MCP tools", () => {
}); });
const result = await estimateRankTrackerCostTool.handler( const result = await estimateRankTrackerCostTool.handler(
{ projectId, trackerId, additionalKeywordCount: 3 }, { projectId, trackerId, additionalKeywordCount: 3 },
toolExtra, toolContext,
); );
expect(textContent(result)).toContain( expect(textContent(result)).toContain(
@ -224,7 +224,7 @@ describe("rank tracking management MCP tools", () => {
}); });
const result = await runRankTrackerTool.handler( const result = await runRankTrackerTool.handler(
{ projectId, trackerId, maxCostCredits: 13 }, { projectId, trackerId, maxCostCredits: 13 },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -256,7 +256,7 @@ describe("rank tracking management MCP tools", () => {
}); });
const result = await runRankTrackerTool.handler( const result = await runRankTrackerTool.handler(
{ projectId, trackerId, maxCostCredits: 13 }, { projectId, trackerId, maxCostCredits: 13 },
toolExtra, toolContext,
); );
expect(textContent(result)).toContain("no additional check was charged"); expect(textContent(result)).toContain("no additional check was charged");
@ -277,7 +277,7 @@ describe("rank tracking management MCP tools", () => {
await expect( await expect(
runRankTrackerTool.handler( runRankTrackerTool.handler(
{ projectId, trackerId, maxCostCredits: 13 }, { projectId, trackerId, maxCostCredits: 13 },
toolExtra, toolContext,
), ),
).resolves.toMatchObject({ ).resolves.toMatchObject({
structuredContent: { started: true, runId: "run_1" }, structuredContent: { started: true, runId: "run_1" },

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { listSavedKeywordsTool } from "./list-saved-keywords"; import { listSavedKeywordsTool } from "./list-saved-keywords";
import { saveKeywordsTool } from "./save-keywords"; import { saveKeywordsTool } from "./save-keywords";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
@ -22,7 +22,7 @@ vi.mock("@/server/features/keywords/services/KeywordResearchService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
describe("saved keyword MCP tools", () => { describe("saved keyword MCP tools", () => {
beforeEach(() => { beforeEach(() => {
@ -45,7 +45,7 @@ describe("saved keyword MCP tools", () => {
keywords: ["technical seo"], keywords: ["technical seo"],
tags: ["Content"], tags: ["Content"],
}, },
toolExtra, toolContext,
); );
expect(mocks.saveKeywords).toHaveBeenCalledWith({ expect(mocks.saveKeywords).toHaveBeenCalledWith({
@ -76,7 +76,7 @@ describe("saved keyword MCP tools", () => {
tags: ["cluster: affordable semrush alternatives"], tags: ["cluster: affordable semrush alternatives"],
tagMode: "replace", tagMode: "replace",
}, },
toolExtra, toolContext,
); );
expect(mocks.saveKeywords).toHaveBeenCalledWith({ expect(mocks.saveKeywords).toHaveBeenCalledWith({
@ -102,7 +102,7 @@ describe("saved keyword MCP tools", () => {
keywords: ["semrush alternative"], keywords: ["semrush alternative"],
tagMode: "replace", tagMode: "replace",
}, },
toolExtra, toolContext,
), ),
).rejects.toThrow("Replacement tags are required"); ).rejects.toThrow("Replacement tags are required");
expect(mocks.saveKeywords).not.toHaveBeenCalled(); expect(mocks.saveKeywords).not.toHaveBeenCalled();
@ -138,7 +138,7 @@ describe("saved keyword MCP tools", () => {
tags: ["Content"], tags: ["Content"],
limit: 50, limit: 50,
}, },
toolExtra, toolContext,
); );
expect(mocks.getSavedKeywords).toHaveBeenCalledWith({ expect(mocks.getSavedKeywords).toHaveBeenCalledWith({

View File

@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { GscApiError, GscNotConnectedError } from "@/server/lib/gscErrors"; import { GscApiError, GscNotConnectedError } from "@/server/lib/gscErrors";
import * as searchConsoleTools from "./search-console-tools"; import * as searchConsoleTools from "./search-console-tools";
import { makeToolExtra } from "./tool-test-support"; import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(), getProjectForOrganization: vi.fn(),
@ -28,7 +28,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
vi.mock("@/server/features/gsc/services/GscService", () => ({ vi.mock("@/server/features/gsc/services/GscService", () => ({
GscService: mocks.GscService, GscService: mocks.GscService,
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
describe("search console MCP tools", () => { describe("search console MCP tools", () => {
beforeEach(() => { beforeEach(() => {
@ -75,7 +75,7 @@ describe("search console MCP tools", () => {
}, },
], ],
}, },
toolExtra, toolContext,
); );
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith( expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
@ -111,7 +111,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" }, { projectId: "project_1" },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -133,7 +133,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" }, { projectId: "project_1" },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -151,7 +151,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1", dimensions: ["query", "searchAppearance"] }, { projectId: "project_1", dimensions: ["query", "searchAppearance"] },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -165,7 +165,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1", startDate: "2026-01-01" }, { projectId: "project_1", startDate: "2026-01-01" },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -181,7 +181,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" }, { projectId: "project_1" },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -208,7 +208,7 @@ describe("search console MCP tools", () => {
const result = await getSearchConsolePerformanceTool.handler( const result = await getSearchConsolePerformanceTool.handler(
{ projectId: "project_1" }, { projectId: "project_1" },
toolExtra, toolContext,
); );
expect(mocks.GscService.getPerformance).toHaveBeenCalledWith( expect(mocks.GscService.getPerformance).toHaveBeenCalledWith(
@ -242,7 +242,7 @@ describe("search console MCP tools", () => {
projectId: "project_1", projectId: "project_1",
urls: ["https://example.com/a", "https://example.com/bad"], urls: ["https://example.com/a", "https://example.com/bad"],
}, },
toolExtra, toolContext,
); );
expect(mocks.GscService.inspectUrls).toHaveBeenCalledWith( expect(mocks.GscService.inspectUrls).toHaveBeenCalledWith(
@ -268,7 +268,7 @@ describe("search console MCP tools", () => {
const result = await inspectUrlsTool.handler( const result = await inspectUrlsTool.handler(
{ projectId: "project_1", urls: ["https://example.com/a"] }, { projectId: "project_1", urls: ["https://example.com/a"] },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({
@ -284,7 +284,7 @@ describe("search console MCP tools", () => {
const result = await inspectUrlsTool.handler( const result = await inspectUrlsTool.handler(
{ projectId: "project_1", urls: ["https://example.com/a"] }, { projectId: "project_1", urls: ["https://example.com/a"] },
toolExtra, toolContext,
); );
expect(result.structuredContent).toMatchObject({ expect(result.structuredContent).toMatchObject({

View File

@ -1,44 +1,18 @@
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import type { ToolAuthContext, ToolContext } from "@/server/mcp/context";
import { vi } from "vitest";
import {
MCP_AUTH_CONTEXT_PROP,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
export function makeMcpAuthContext( export function makeToolContext(
overrides: Partial<McpToolAuthContext> = {}, overrides: Partial<ToolAuthContext> = {},
): McpToolAuthContext { ): ToolContext {
const baseUrl = overrides.baseUrl ?? "https://open-seo.test";
return { return {
auth: {
userId: "user_123", userId: "user_123",
userEmail: "alice@example.com", userEmail: "alice@example.com",
organizationId: "org_123", organizationId: "org_123",
clientId: "client_123", clientId: "client_123",
scopes: ["mcp"], scopes: ["mcp"],
audience: `${baseUrl}/mcp`, baseUrl: "https://open-seo.test",
subject: "user_123",
baseUrl,
...overrides, ...overrides,
}; },
}
export function makeToolExtra(
authContext: McpToolAuthContext = makeMcpAuthContext(),
requestId: ToolExtra["requestId"] = 1,
): ToolExtra {
return {
signal: new AbortController().signal,
requestId,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
authInfo: {
token: "token",
clientId: authContext.clientId ?? "client_123",
scopes: authContext.scopes,
resource: new URL(`${authContext.baseUrl}/mcp`),
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
} satisfies AuthInfo,
}; };
} }

View File

@ -6,7 +6,7 @@ import { getDomainKeywordSuggestionsTool } from "./get-domain-keyword-suggestion
import { getRankTrackerTool } from "./get-rank-tracker"; import { getRankTrackerTool } from "./get-rank-tracker";
import { getSerpResultsTool } from "./get-serp-results"; import { getSerpResultsTool } from "./get-serp-results";
import { researchKeywordsTool } from "./research-keywords"; import { researchKeywordsTool } from "./research-keywords";
import { makeToolExtra, textContent } from "./tool-test-support"; import { makeToolContext, textContent } from "./tool-test-support";
// Verifies that each tool renders its actual row data into the text content // Verifies that each tool renders its actual row data into the text content
// block (not just a count), across the tools whose data comes from OpenSEO // block (not just a count), across the tools whose data comes from OpenSEO
@ -69,7 +69,7 @@ vi.mock("@/server/features/rank-tracking/services/RankTrackingService", () => ({
}, },
})); }));
const toolExtra = makeToolExtra(); const toolContext = makeToolContext();
describe("MCP tool text output (service-backed tools)", () => { describe("MCP tool text output (service-backed tools)", () => {
beforeEach(() => { beforeEach(() => {
@ -108,7 +108,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await researchKeywordsTool.handler( const result = await researchKeywordsTool.handler(
{ projectId: "project_1", seeds: [{ seed: "seo tools" }] }, { projectId: "project_1", seeds: [{ seed: "seo tools" }] },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -129,7 +129,7 @@ describe("MCP tool text output (service-backed tools)", () => {
]); ]);
const result = await getDomainKeywordSuggestionsTool.handler( const result = await getDomainKeywordSuggestionsTool.handler(
{ projectId: "project_1", domain: "example.com" }, { projectId: "project_1", domain: "example.com" },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -160,7 +160,7 @@ describe("MCP tool text output (service-backed tools)", () => {
}); });
const result = await getBacklinksOverviewTool.handler( const result = await getBacklinksOverviewTool.handler(
{ projectId: "project_1", target: "example.com" }, { projectId: "project_1", target: "example.com" },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -201,7 +201,7 @@ describe("MCP tool text output (service-backed tools)", () => {
filters: {}, filters: {},
mode: "one_per_domain", mode: "one_per_domain",
}, },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -236,7 +236,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankTrackerTool.handler( const result = await getRankTrackerTool.handler(
{ projectId: "project_1", trackerId: "tracker_1" }, { projectId: "project_1", trackerId: "tracker_1" },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -268,7 +268,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankTrackerTool.handler( const result = await getRankTrackerTool.handler(
{ projectId: "project_1", trackerId: "tracker_1" }, { projectId: "project_1", trackerId: "tracker_1" },
toolExtra, toolContext,
); );
expect(textContent(result)).toContain( expect(textContent(result)).toContain(
@ -310,7 +310,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankedKeywordsTool.handler( const result = await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "example.com" }, { projectId: "project_1", target: "example.com" },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);
@ -335,7 +335,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getSerpResultsTool.handler( const result = await getSerpResultsTool.handler(
{ projectId: "project_1", queries: [{ keyword: "seo tools" }] }, { projectId: "project_1", queries: [{ keyword: "seo tools" }] },
toolExtra, toolContext,
); );
const out = textContent(result); const out = textContent(result);

View File

@ -4,7 +4,7 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID, AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing"; } from "@/shared/billing";
import { mcpResponse } from "@/server/mcp/formatters"; import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, type ToolExtra } from "@/server/mcp/context"; import { type ToolContext } from "@/server/mcp/context";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas"; import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { z } from "zod"; import { z } from "zod";
@ -40,8 +40,8 @@ export const whoamiTool = {
destructiveHint: false, destructiveHint: false,
}, },
}, },
handler: async (_args: Record<string, never>, extra: ToolExtra) => { handler: async (_args: Record<string, never>, context: ToolContext) => {
const auth = getAuth(extra); const auth = context.auth;
const isHosted = await isHostedServerAuthMode(); const isHosted = await isHostedServerAuthMode();
let creditsRemaining: number | null = null; let creditsRemaining: number | null = null;
if (isHosted) { if (isHosted) {

View File

@ -0,0 +1,100 @@
import { createMcpHandler, getMcpAuthContext } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { describe, expect, it } from "vitest";
import { z } from "zod";
const ctx: ExecutionContext = {
waitUntil() {},
passThroughOnException() {},
props: {},
};
function request(method: string, body?: unknown) {
return new Request("https://open-seo.test/mcp", {
method,
headers: {
Host: "open-seo.test",
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe("Agents SDK v2 MCP transport", () => {
it("rejects a standalone GET without constructing a server", async () => {
let serverCount = 0;
const handler = createMcpHandler(
() => {
serverCount += 1;
const server = new McpServer({ name: "test", version: "1.0.0" });
server.registerTool("ping", {}, () => ({
content: [{ type: "text", text: "pong" }],
}));
return server;
},
{ route: "/mcp" },
);
const response = await handler(request("GET"), {}, ctx);
expect(response.status).toBe(405);
expect(serverCount).toBe(0);
});
it("passes verified provider identity and application props to tools", async () => {
const props = { openSeoAuth: { organizationId: "org-1" } };
const oauthContext = {
...ctx,
props,
[Symbol.for("cloudflare.workers-oauth-provider.verified-context.v1")]: {
version: 1,
token: "access-token",
clientId: "client-1",
scopes: ["mcp"],
resource: "https://open-seo.test/mcp",
props,
},
} as ExecutionContext;
const handler = createMcpHandler(
() => {
const server = new McpServer({ name: "test", version: "1.0.0" });
server.registerTool(
"auth",
{ inputSchema: z.object({}) },
(_args, context) => ({
content: [
{
type: "text",
text: JSON.stringify({
clientId: context.http?.authInfo?.clientId,
scopes: context.http?.authInfo?.scopes,
props: getMcpAuthContext()?.props,
}),
},
],
}),
);
return server;
},
{ route: "/mcp" },
);
const response = await handler(
request("POST", {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "auth", arguments: {} },
}),
{},
oauthContext,
);
expect(response.status).toBe(200);
const responseText = await response.text();
expect(responseText).toContain('\\"clientId\\":\\"client-1\\"');
expect(responseText).toContain('\\"scopes\\":[\\"mcp\\"]');
expect(responseText).toContain('\\"organizationId\\":\\"org-1\\"');
});
});

View File

@ -1,22 +1,17 @@
import type { CreateMcpHandlerOptions } from "agents/mcp"; import type { CreateMcpHandlerOptions } from "agents/mcp/server";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/server";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod"; import { z } from "zod";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context"; import {
createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
} from "@/server/mcp/context";
const selfHostedAuthMocks = vi.hoisted(() => ({ const selfHostedAuthMocks = vi.hoisted(() => ({
resolveCloudflareAccessContext: vi.fn(), resolveCloudflareAccessContext: vi.fn(),
resolveLocalNoAuthContext: vi.fn(), resolveLocalNoAuthContext: vi.fn(),
})); }));
const serverMocks = vi.hoisted(() => ({
nextServerId: 0,
serverIds: new WeakMap<McpServer, number>(),
lastServer: undefined as McpServer | undefined,
}));
vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({ vi.mock("@/middleware/ensure-user/cloudflareAccess", () => ({
resolveCloudflareAccessContext: resolveCloudflareAccessContext:
selfHostedAuthMocks.resolveCloudflareAccessContext, selfHostedAuthMocks.resolveCloudflareAccessContext,
@ -26,20 +21,38 @@ vi.mock("@/middleware/ensure-user/delegated", () => ({
resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext, resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext,
})); }));
vi.mock("@/server/mcp/server", () => ({ vi.mock("@/lib/auth", () => ({
registerOpenSeoMcpTools: vi.fn(), getHostedBaseUrl: () => "https://open-seo.test",
})); }));
vi.mock("agents/mcp", () => ({ vi.mock("@/server/mcp/server", () => ({
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => { createOpenSeoMcpServer: () =>
serverMocks.nextServerId += 1; new McpServer({
serverMocks.serverIds.set(_server, serverMocks.nextServerId); name: "OpenSEO MCP",
serverMocks.lastServer = _server; title: "OpenSEO",
version: "0.0.11",
description: "SEO research tools for AI agents",
websiteUrl: "https://openseo.so",
icons: [
{
src: "https://openseo.so/android-chrome-512x512.png",
mimeType: "image/png",
sizes: ["512x512"],
},
],
}),
}));
return async () => vi.mock("agents/mcp/server", () => ({
new Response( createMcpHandler: (
createServer: () => McpServer,
options: CreateMcpHandlerOptions,
) => {
return async (request: Request) => {
if (request.method !== "OPTIONS") createServer();
return new Response(
JSON.stringify({ JSON.stringify({
serverId: serverMocks.serverIds.get(_server),
options, options,
}), }),
{ {
@ -47,6 +60,7 @@ vi.mock("agents/mcp", () => ({
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}, },
); );
};
}, },
})); }));
@ -57,10 +71,9 @@ const ctx: ExecutionContext = {
}; };
const transportOptionsSchema = z.object({ const transportOptionsSchema = z.object({
serverId: z.number().optional(),
options: z.object({ options: z.object({
route: z.string().optional(), route: z.string().optional(),
enableJsonResponse: z.boolean().optional(), allowedOriginHostnames: z.array(z.string()).optional(),
authContext: z authContext: z
.object({ .object({
props: z.record(z.string(), z.unknown()), props: z.record(z.string(), z.unknown()),
@ -87,9 +100,6 @@ function createMcpRequest() {
describe("handleSelfHostedOpenSeoMcpRequest", () => { describe("handleSelfHostedOpenSeoMcpRequest", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
serverMocks.nextServerId = 0;
serverMocks.serverIds = new WeakMap<McpServer, number>();
serverMocks.lastServer = undefined;
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({ selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
userId: "local-admin", userId: "local-admin",
userEmail: "admin@localhost", userEmail: "admin@localhost",
@ -122,12 +132,11 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
userId: "local-admin", userId: "local-admin",
userEmail: "admin@localhost", userEmail: "admin@localhost",
organizationId: "delegated-local-admin", organizationId: "delegated-local-admin",
clientId: null,
scopes: [],
audience: "https://open-seo.test/mcp",
subject: "local-admin",
baseUrl: "https://open-seo.test", baseUrl: "https://open-seo.test",
}); });
// Self-hosted must not pin Origins to the request's own Host — the
// handler's localhost-class default is the rebinding-safe choice.
expect(body.options.allowedOriginHostnames).toBeUndefined();
}); });
it("accepts Cloudflare Access MCP requests through the existing Access resolver", async () => { it("accepts Cloudflare Access MCP requests through the existing Access resolver", async () => {
@ -152,36 +161,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
userId: "cloudflare-user", userId: "cloudflare-user",
userEmail: "person@example.com", userEmail: "person@example.com",
organizationId: "delegated-cloudflare-user", organizationId: "delegated-cloudflare-user",
clientId: null,
scopes: [],
audience: "https://open-seo.test/mcp",
subject: "cloudflare-user",
baseUrl: "https://open-seo.test", baseUrl: "https://open-seo.test",
}); });
}); });
// The OOM came from the GET SSE stream pinning a per-request McpServer, so
// GET must 405 without ever building one.
it("returns 405 for the standalone GET SSE stream without building a server", async () => {
const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
const response = await handleSelfHostedOpenSeoMcpRequest(
new Request("https://open-seo.test/mcp", {
method: "GET",
headers: { Accept: "text/event-stream" },
}),
"local_noauth",
{},
ctx,
);
expect(response.status).toBe(405);
expect(response.headers.get("Allow")).toContain("POST");
// nextServerId only advances when a server is built — a GET must not.
expect(serverMocks.nextServerId).toBe(0);
});
it("lets the MCP transport handle OPTIONS without auth context", async () => { it("lets the MCP transport handle OPTIONS without auth context", async () => {
const { handleSelfHostedOpenSeoMcpRequest } = const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport"); await import("@/server/mcp/transport");
@ -200,46 +183,74 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
).not.toHaveBeenCalled(); ).not.toHaveBeenCalled();
expect(body.options.authContext).toBeUndefined(); expect(body.options.authContext).toBeUndefined();
}); });
});
// Directory scanners (e.g. Smithery) read server metadata from initialize. describe("handleAuthenticatedOpenSeoMcpRequest", () => {
it("serves directory metadata in the initialize response", async () => { it("accepts the provider's encrypted identity and MCP scope fallback", async () => {
const { handleSelfHostedOpenSeoMcpRequest } = const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport"); await import("@/server/mcp/transport");
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["mcp"],
});
await handleSelfHostedOpenSeoMcpRequest( const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(), createMcpRequest(),
"local_noauth", props,
{}, {},
ctx, { ...ctx, props },
);
const server = serverMocks.lastServer;
if (!server) throw new Error("MCP server was not created");
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "0.0.0" });
await Promise.all([
client.connect(clientTransport),
server.connect(serverTransport),
]);
const serverInfo = client.getServerVersion();
expect(serverInfo).toMatchObject({
name: "OpenSEO MCP",
title: "OpenSEO",
websiteUrl: "https://openseo.so",
icons: [
{
src: "https://openseo.so/android-chrome-512x512.png",
mimeType: "image/png",
sizes: ["512x512"],
},
],
});
expect(serverInfo?.description).toContain(
"SEO research tools for AI agents",
); );
await client.close(); expect(response.status).toBe(200);
const body = transportOptionsSchema.parse(await response.json());
expect(body.options.allowedOriginHostnames).toEqual(["open-seo.test"]);
});
it("rejects provider props missing the OAuth client identity", async () => {
const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
// Hosted tokens always carry clientId/scopes; a token without them must
// fail closed rather than skip scope enforcement.
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
});
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(403);
});
it("rejects an OAuth client without the MCP scope", async () => {
const { handleAuthenticatedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
const props = createWorkersOAuthMcpProps({
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
baseUrl: "https://open-seo.test",
clientId: "client-1",
scopes: ["offline_access"],
});
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),
props,
{},
{ ...ctx, props },
);
expect(response.status).toBe(403);
}); });
}); });

View File

@ -1,44 +1,33 @@
import { createMcpHandler } from "agents/mcp"; import { createMcpHandler } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getHostedBaseUrl } from "@/lib/auth";
import { MCP_SCOPE } from "@/lib/oauth-resource"; import { MCP_SCOPE } from "@/lib/oauth-resource";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess"; import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated"; import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import { import {
buildFirstPartyMcpAuthContext,
createWorkersOAuthMcpProps, createWorkersOAuthMcpProps,
hostedWorkersOAuthMcpPropsSchema,
MCP_AUTH_CONTEXT_PROP, MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE, MCP_ROUTE,
runWithMcpToolAuthContext,
workersOAuthMcpPropsSchema,
} from "@/server/mcp/context"; } from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin"; import { getPublicOrigin } from "@/server/mcp/public-origin";
import { registerOpenSeoMcpTools } from "@/server/mcp/server"; import { createOpenSeoMcpServer } from "@/server/mcp/server";
function createOpenSeoMcpServer() { type McpProps = ReturnType<typeof createWorkersOAuthMcpProps>;
const server = new McpServer(
{
name: "OpenSEO MCP",
title: "OpenSEO",
version: "0.0.11",
description:
"SEO research tools for AI agents: keyword research and metrics, SERP and local SERP results, domain and backlink analysis, rank tracking, and Google Search Console performance.",
websiteUrl: "https://openseo.so",
icons: [
{
src: "https://openseo.so/android-chrome-512x512.png",
mimeType: "image/png",
sizes: ["512x512"],
},
],
},
{
instructions:
"OpenSEO research tools use credits. Proceed with normal focused research, but ask the user for confirmation before planned batches over 2,000 credits.",
},
);
registerOpenSeoMcpTools(server);
return server; // Hosted pins browser Origins to the configured base URL. Self-hosted leaves
// the option unset so the handler's localhost-class default applies — an
// allowlist derived from the request's own Host would accept a DNS-rebinding
// page trivially. Non-browser MCP clients send no Origin and are unaffected
// either way.
function createRequestHandler(
props: McpProps | undefined,
allowedOriginHostnames?: string[],
) {
return createMcpHandler(createOpenSeoMcpServer, {
route: MCP_ROUTE,
allowedOriginHostnames,
authContext: props ? { props } : undefined,
});
} }
export async function handleAuthenticatedOpenSeoMcpRequest( export async function handleAuthenticatedOpenSeoMcpRequest(
@ -47,16 +36,20 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
env: unknown, env: unknown,
ctx: ExecutionContext, ctx: ExecutionContext,
): Promise<Response> { ): Promise<Response> {
const result = workersOAuthMcpPropsSchema.safeParse(props); const result = hostedWorkersOAuthMcpPropsSchema.safeParse(props);
const scopes = result.success if (!result.success) {
? result.data[MCP_AUTH_CONTEXT_PROP].scopes
: [];
if (!result.success || !scopes.includes(MCP_SCOPE)) {
return new Response("MCP auth context required", { status: 403 }); return new Response("MCP auth context required", { status: 403 });
} }
if (!result.data[MCP_AUTH_CONTEXT_PROP].scopes.includes(MCP_SCOPE)) {
return new Response("MCP scope required", { status: 403 });
}
return handleOpenSeoMcpRequest(request, result.data, env, ctx); // The handler would fall back to the provider-populated ctx.props on its
// own; passing authContext explicitly hands it the schema-validated copy and
// keeps this path symmetrical with self-hosted, which has no ctx.props.
return createRequestHandler(result.data, [
new URL(getHostedBaseUrl()).hostname,
])(request, env, ctx);
} }
export async function handleSelfHostedOpenSeoMcpRequest( export async function handleSelfHostedOpenSeoMcpRequest(
@ -65,70 +58,21 @@ export async function handleSelfHostedOpenSeoMcpRequest(
env: unknown, env: unknown,
ctx: ExecutionContext, ctx: ExecutionContext,
): Promise<Response> { ): Promise<Response> {
// Self-hosted auth mirrors the app: local_noauth uses the local admin // Preflight does not carry an authenticated application context.
// workspace, while cloudflare_access trusts Cloudflare's Access JWT.
// CORS/preflight still needs to reach the MCP transport before auth context
// exists, so OPTIONS intentionally bypasses context creation.
if (request.method === "OPTIONS") { if (request.method === "OPTIONS") {
return handleOpenSeoMcpRequest(request, undefined, env, ctx); return createRequestHandler(undefined)(request, env, ctx);
} }
const baseUrl = getPublicOrigin(request); const identity =
const context =
authMode === "local_noauth" authMode === "local_noauth"
? await resolveLocalNoAuthContext() ? await resolveLocalNoAuthContext()
: await resolveCloudflareAccessContext(request.headers); : await resolveCloudflareAccessContext(request.headers);
const props = createWorkersOAuthMcpProps( const props = createWorkersOAuthMcpProps({
buildFirstPartyMcpAuthContext({ userId: identity.userId,
userId: context.userId, userEmail: identity.userEmail,
userEmail: context.userEmail, organizationId: identity.organizationId,
organizationId: context.organizationId, baseUrl: getPublicOrigin(request),
baseUrl,
}),
);
return handleOpenSeoMcpRequest(request, props, env, ctx);
}
function handleOpenSeoMcpRequest(
request: Request,
props: ReturnType<typeof createWorkersOAuthMcpProps> | undefined,
env: unknown,
ctx: ExecutionContext,
): Promise<Response> {
// Decline the optional standalone GET SSE stream: this server is stateless
// (POST returns JSON) and pushes no server-initiated messages, so the stream
// does nothing but leak memory — each GET is held open by a keepalive and
// pins a per-request McpServer (~5MB), so a few dozen concurrent clients OOM
// the 128MB isolate. 405 is the spec's "no stream" response; returning it
// before building the server means a GET allocates nothing.
if (request.method === "GET") {
return Promise.resolve(
new Response("Method Not Allowed", {
status: 405,
headers: {
Allow: "POST, DELETE, OPTIONS",
"Access-Control-Allow-Origin": "*",
},
}),
);
}
const server = createOpenSeoMcpServer();
const handler = createMcpHandler(server, {
route: MCP_ROUTE,
enableJsonResponse: true,
authContext: props ? { props } : undefined,
corsOptions: {
headers:
"Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id",
exposeHeaders: "mcp-protocol-version, mcp-session-id",
},
}); });
if (!props) return handler(request, env, ctx); return createRequestHandler(props)(request, env, ctx);
return runWithMcpToolAuthContext(props[MCP_AUTH_CONTEXT_PROP], () =>
handler(request, env, ctx),
);
} }

View File

@ -8,5 +8,13 @@ export default defineConfig({
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts"],
restoreMocks: true, restoreMocks: true,
clearMocks: true, clearMocks: true,
server: {
deps: {
// Processed by vitest (instead of loaded natively by node) so the
// oauth-refresh e2e test's cloudflare:workers mock reaches the real
// provider module.
inline: ["@cloudflare/workers-oauth-provider"],
},
},
}, },
}); });

View File

@ -72,7 +72,9 @@
}, },
], ],
"triggers": { "triggers": {
"crons": ["*/5 * * * *"], // Every 5 min: rank checks + stale-audit reconcile. Daily: OAuth KV GC.
// Dispatched on controller.cron in src/server.ts `scheduled`.
"crons": ["*/5 * * * *", "17 3 * * *"],
}, },
// This config serves local dev and Docker self-host only. All Cloudflare // This config serves local dev and Docker self-host only. All Cloudflare
// deployments previews, prod, self-host go through Alchemy // deployments previews, prod, self-host go through Alchemy