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": {
"@ai-sdk/react": "^3.0.211",
"@cloudflare/ai-chat": "^0.8.4",
"@cloudflare/think": "0.12.1",
"@cloudflare/workers-oauth-provider": "^0.4.0",
"@cloudflare/ai-chat": "^0.10.1",
"@cloudflare/think": "0.15.1",
"@cloudflare/workers-oauth-provider": "^0.10.2",
"@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",
"@tanstack/query-core": "^5.101.2",
"@tanstack/react-form": "^1.33.0",
@ -87,7 +89,7 @@
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-start": "^1.168.26",
"@tanstack/react-table": "^8.21.3",
"agents": "0.17.3",
"agents": "0.20.1",
"ai": "^6.0.199",
"autumn-js": "^1.2.33",
"better-auth": "^1.6.22",
@ -117,7 +119,7 @@
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.42.3",
"@cloudflare/workers-types": "^4.20260611.1",
"@cloudflare/workers-types": "^4.20260702.1",
"@distilled.cloud/cloudflare": "0.28.2",
"@effect/platform-node": "4.0.0-beta.93",
"@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
minimumReleaseAgeExclude:
- "@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
# matching @distilled.cloud/*@0.28.2 published 2026-07-08 — 2 days shy of
# the window. Exact-pinned in package.json.
@ -27,23 +36,29 @@ auditConfig:
overrides:
# GHSA-4x5r-pxfx-6jf8 — waiting on @tanstack/devtools-vite
"@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
defu: "^6.1.5"
# 13 advisories incl. GHSA-x4vx-rjvf-j5p4, GHSA-gvmj-g25r-r7wr — waiting on posthog-js
dompurify: "^3.4.11"
# GHSA-hmw2-7cc7-3qxx — waiting on cloudflare
form-data: "^4.0.6"
# GHSA-v2hh-gcrm-f6hx, GHSA-7p8r-x3mc-p8w7, GHSA-4c8g-83qw-93j6 — waiting on @modelcontextprotocol/sdk
fast-uri: "^3.1.5"
# 9 advisories incl. GHSA-88fw-hqm2-52qc (CORS) — waiting on @modelcontextprotocol/sdk
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
launch-editor: "^2.14.1"
# GHSA-qx2v-qp2m-jg93 — waiting on vite
postcss: "^8.5.10"
# GHSA-qx2v-qp2m-jg93, GHSA-r28c-9q8g-f849 — waiting on vite
postcss: "^8.5.18"
# GHSA-q8mj-m7cp-5q26 — waiting on @modelcontextprotocol/sdk
qs: "^6.15.2"
# GHSA-w7jw-789q-3m8p — waiting on @tanstack/devtools-vite
shell-quote: "^1.8.4"
# GHSA-v3rj-xjv7-4jmq — waiting on knip
smol-toml: "^1.6.1"
# 7 advisories incl. GHSA-vmh5-mc38-953g — waiting on cheerio + miniflare
undici: "^7.28.0"
# 8 advisories incl. GHSA-vmh5-mc38-953g, GHSA-4cwx-7wf7-3272 — waiting on cheerio + miniflare
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.
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 {
fetch,
async scheduled(
_controller: ScheduledController,
controller: ScheduledController,
env: Env,
_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
// 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

View File

@ -29,7 +29,7 @@ import {
} from "@/server/billing/subscription";
import { getPublicOrigin } from "@/server/mcp/public-origin";
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
// every chat session in the project.
@ -255,13 +255,14 @@ export class SamChatAgent extends Think {
const baseUrl =
(await this.ctx.storage.get<string>(PUBLIC_ORIGIN_KEY)) ??
"https://app.openseo.so";
const authContext = buildFirstPartyMcpAuthContext({
const authContext: ToolAuthContext = {
userId: ctx.row.userId,
userEmail: ctx.userEmail,
organizationId,
baseUrl,
clientId: null,
scopes: [MCP_SCOPE],
});
};
return {
tools: buildSamMcpTools(authContext, {

View File

@ -1,12 +1,9 @@
import { tool, type Tool, type ToolSet } from "ai";
import { z, type ZodRawShape } from "zod";
import { withPgClient } from "@/db";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
createWorkersOAuthMcpProps,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context";
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getBacklinksProfileTool } from "@/server/mcp/tools/get-backlinks-profile";
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 MCP server can never drift in what a tool does or how it bills.
type McpToolDefinition<Shape extends ZodRawShape> = {
name: string;
config: { description: string; inputSchema: Shape };
handler: (
args: z.infer<z.ZodObject<Shape>>,
extra: ToolExtra,
context: ToolContext,
) => Promise<CallToolResult>;
};
@ -62,9 +60,11 @@ function toModelOutput(result: CallToolResult): unknown {
: { summary };
}
// Adapt one MCP tool into an AI SDK tool. The MCP handler reads auth from `extra`
// (via requireMcpToolAuthContext) and self-gates project access against the org,
// so SAM gets identical scoping and metering for free.
// Adapt one OpenSEO tool into an AI SDK tool. The shared handler receives the
// same explicit auth context as the MCP transport, and runs through the same
// 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
// 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.
function adaptMcpTool<Shape extends ZodRawShape>(
def: McpToolDefinition<Shape>,
extra: ToolExtra,
context: ToolContext,
projectId: string,
): Tool {
const { projectId: _projectIdSchema, ...modelShape } = def.config.inputSchema;
const bindsProject = "projectId" in def.config.inputSchema;
const handler = instrumentMcpToolHandler(def.name, undefined, def.handler);
return tool({
description: def.config.description,
@ -93,7 +94,7 @@ function adaptMcpTool<Shape extends ZodRawShape>(
// 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.
return toModelOutput(
await withPgClient(() => def.handler(fullArgs, extra)),
await withPgClient(() => handler(fullArgs, context)),
);
} catch (error) {
// 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
* free site-reading tools. Every tool the OpenSEO MCP server exposes is
* available; auth/billing context is carried on a synthetic `ToolExtra` the
* handlers read exactly as they would on the real MCP route. DataForSEO spend
* is metered inside the shared client, so tool calls draw down the org's
* credits automatically.
* available. Auth and billing context are passed directly to the shared tool
* handlers. DataForSEO spend is metered inside the shared client, so tool calls
* draw down the org's credits automatically.
*/
export function buildSamMcpTools(
authContext: McpToolAuthContext,
authContext: ToolAuthContext,
project: { id: string; domain: string | null },
): ToolSet {
const projectId = project.id;
const extra: ToolExtra = {
// Placeholder to satisfy ToolExtra — no tool handler or the DataForSEO
// client reads this signal (true on the real MCP route too), so aborting a
// turn does not cancel in-flight tool requests.
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")),
};
const toolContext: ToolContext = { auth: authContext };
const adaptTool = <Shape extends ZodRawShape>(
definition: McpToolDefinition<Shape>,
) => adaptMcpTool(definition, toolContext, projectId);
// 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
@ -214,55 +202,23 @@ export function buildSamMcpTools(
execute: () => Promise.resolve({ factSheet: openSeoFactSheet }),
}),
...scrapeTools(project.domain),
whoami: adaptMcpTool(whoamiTool, extra, projectId),
list_saved_keywords: adaptMcpTool(listSavedKeywordsTool, extra, projectId),
research_keywords: adaptMcpTool(researchKeywordsTool, extra, projectId),
save_keywords: adaptMcpTool(saveKeywordsTool, extra, projectId),
get_domain_overview: adaptMcpTool(getDomainOverviewTool, extra, projectId),
get_domain_keyword_suggestions: adaptMcpTool(
getDomainKeywordSuggestionsTool,
extra,
projectId,
),
get_backlinks_overview: adaptMcpTool(
getBacklinksOverviewTool,
extra,
projectId,
),
get_backlinks_profile: adaptMcpTool(
getBacklinksProfileTool,
extra,
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),
whoami: adaptTool(whoamiTool),
list_saved_keywords: adaptTool(listSavedKeywordsTool),
research_keywords: adaptTool(researchKeywordsTool),
save_keywords: adaptTool(saveKeywordsTool),
get_domain_overview: adaptTool(getDomainOverviewTool),
get_domain_keyword_suggestions: adaptTool(getDomainKeywordSuggestionsTool),
get_backlinks_overview: adaptTool(getBacklinksOverviewTool),
get_backlinks_profile: adaptTool(getBacklinksProfileTool),
get_serp_results: adaptTool(getSerpResultsTool),
get_rank_tracker: adaptTool(getRankTrackerTool),
get_ranked_keywords: adaptTool(getRankedKeywordsTool),
find_serp_competitors: adaptTool(findSerpCompetitorsTool),
search_local_businesses: adaptTool(searchLocalBusinessesTool),
get_local_serp_results: adaptTool(getLocalSerpResultsTool),
get_google_business_questions: adaptTool(getGoogleBusinessQuestionsTool),
get_keyword_metrics: adaptTool(getKeywordMetricsTool),
get_search_console_performance: adaptTool(getSearchConsolePerformanceTool),
inspect_urls: adaptTool(inspectUrlsTool),
};
}

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 {
createMcpToolContext,
createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
withWorkersOAuthMcpScopes,
workersOAuthMcpPropsSchema,
} from "@/server/mcp/context";
const mcpContext = {
const applicationContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["offline_access", "mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
describe("withWorkersOAuthMcpScopes", () => {
it("stores the OpenSEO MCP context in Workers OAuth props", () => {
const props = createWorkersOAuthMcpProps(mcpContext);
describe("OpenSEO tool auth context", () => {
it("stores only application-specific identity in Workers OAuth props", () => {
const props = createWorkersOAuthMcpProps(applicationContext);
expect(workersOAuthMcpPropsSchema.parse(props)).toEqual({
[MCP_AUTH_CONTEXT_PROP]: mcpContext,
[MCP_AUTH_CONTEXT_PROP]: applicationContext,
});
});
it("updates access-token props with downscoped token scopes", () => {
const props = createWorkersOAuthMcpProps(mcpContext);
it("rejects unrecognized provider props", () => {
expect(workersOAuthMcpPropsSchema.safeParse({}).success).toBe(false);
});
expect(withWorkersOAuthMcpScopes(props, ["mcp"])).toEqual({
[MCP_AUTH_CONTEXT_PROP]: {
...mcpContext,
it("prefers standard OAuth client metadata over the props fallback", () => {
mocks.props = createWorkersOAuthMcpProps({
...applicationContext,
clientId: "stale-client",
scopes: ["offline_access"],
});
expect(
createMcpToolContext({
http: {
authInfo: {
token: "access-token",
clientId: "client-1",
scopes: ["mcp"],
},
},
}).auth,
).toMatchObject({
...applicationContext,
clientId: "client-1",
scopes: ["mcp"],
});
});
it("leaves unrecognized provider props alone", () => {
expect(withWorkersOAuthMcpScopes({}, ["mcp"])).toBeUndefined();
it("falls back to encrypted props with workers-oauth-provider 0.10", () => {
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 {
ServerNotification,
ServerRequest,
} from "@modelcontextprotocol/sdk/types.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { AsyncLocalStorage } from "node:async_hooks";
import type { ServerContext } from "@modelcontextprotocol/server";
import { getMcpAuthContext } from "agents/mcp/server";
import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { getMcpResource } from "@/lib/oauth-resource";
import { buildDashboardUrl } from "@/server/mcp/urls";
type McpAuth = {
export type ToolAuthContext = {
userId: string;
userEmail: string;
organizationId: string;
scopes: string[];
clientId: string | null;
audience: string;
subject: string;
baseUrl: string;
};
export type ToolContext = {
auth: ToolAuthContext;
};
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
export const MCP_ROUTE = "/mcp";
const mcpToolAuthContextSchema = z.object({
const applicationAuthContextSchema = z.object({
userId: z.string().min(1),
userEmail: 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(),
// 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>;
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
type ApplicationAuthContext = z.infer<typeof applicationAuthContextSchema>;
export const workersOAuthMcpPropsSchema = z.object({
[MCP_AUTH_CONTEXT_PROP]: mcpToolAuthContextSchema,
[MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema,
});
const mcpToolAuthContextStorage = new AsyncLocalStorage<McpToolAuthContext>();
/**
* Auth context for first-party (non-OAuth) callers the self-hosted MCP
* transport and the SAM agent. Centralizes the invariants both sites relied
* on by convention: `subject` is the user id, `clientId` is null, and the
* audience derives from the base URL.
*/
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,
};
}
// 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.
export const hostedWorkersOAuthMcpPropsSchema = z.object({
[MCP_AUTH_CONTEXT_PROP]: applicationAuthContextSchema.extend({
clientId: z.string().min(1),
scopes: z.array(z.string()),
}),
});
export function createWorkersOAuthMcpProps(
context: McpToolAuthContext,
): Record<string, McpToolAuthContext> {
context: ApplicationAuthContext,
): Record<string, ApplicationAuthContext> {
return {
[MCP_AUTH_CONTEXT_PROP]: context,
};
}
export function withWorkersOAuthMcpScopes(
props: unknown,
scopes: string[],
): Record<string, McpToolAuthContext> | undefined {
const result = workersOAuthMcpPropsSchema.safeParse(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);
export function createMcpToolContext(
context: Pick<ServerContext, "http">,
): ToolContext {
const result = workersOAuthMcpPropsSchema.safeParse(
getMcpAuthContext()?.props,
);
if (!result.success) {
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 {
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext(extra);
return auth;
return {
auth: {
...applicationAuth,
clientId,
scopes,
},
};
}
export function buildBillingCustomer(
auth: McpAuth,
auth: Pick<ToolAuthContext, "userId" | "userEmail" | "organizationId">,
projectId: string,
): BillingCustomerContext {
return {
@ -129,7 +98,10 @@ export function buildBillingCustomer(
}
export function buildProjectMeta(
context: { auth: Pick<McpAuth, "organizationId">; baseUrl: string },
context: {
auth: Pick<ToolAuthContext, "organizationId">;
baseUrl: string;
},
projectId: string,
path?: string,
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 = {
url?: string;
@ -9,6 +9,22 @@ type McpResponseMeta = {
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: {
text: string;
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 { z } from "zod";
import { instrumentMcpToolHandler } from "./instrumentation";
import {
runWithMcpToolAuthContext,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { type ToolAuthContext, type ToolContext } from "@/server/mcp/context";
import { AppError } from "@/server/lib/errors";
const mocks = vi.hoisted(() => ({
@ -36,39 +32,39 @@ vi.mock("@/server/lib/self-host-telemetry", () => ({
incrementSelfHostMcpToolCallCount: mocks.incrementSelfHostMcpToolCallCount,
}));
const toolExtra: ToolExtra = {
signal: new AbortController().signal,
requestId: 1,
sendNotification: vi.fn(),
sendRequest: vi.fn(),
};
const outputSchema = { items: z.array(z.object({}).passthrough()) };
const outputSchema = z.object({
items: z.array(z.object({}).passthrough()),
});
function okResult(structuredContent: Record<string, unknown>): CallToolResult {
return { content: [{ type: "text", text: "ok" }], structuredContent };
}
const authContext: McpToolAuthContext = {
const authContext: ToolAuthContext = {
userId: "user-1",
userEmail: "user@example.com",
organizationId: "org-1",
clientId: "client-1",
scopes: ["mcp"],
audience: "https://app.openseo.so/mcp",
subject: "user-1",
baseUrl: "https://app.openseo.so",
};
const toolContext: ToolContext = { auth: authContext };
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 () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [{ domain: "example.com" }] }),
);
const result = await wrapped({}, toolExtra);
const result = await wrapped({}, toolContext);
expect(result.structuredContent).toEqual({
items: [{ domain: "example.com" }],
@ -81,7 +77,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: "not-an-array" }),
);
await wrapped({}, toolExtra);
await wrapped({}, toolContext);
expect(mocks.captureServerError).toHaveBeenCalledTimes(1);
expect(mocks.captureServerError.mock.calls[0][1]).toMatchObject({
@ -96,7 +92,7 @@ describe("instrumentMcpToolHandler", () => {
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.mock.calls[0][0]).toBe(boom);
});
@ -106,16 +102,16 @@ describe("instrumentMcpToolHandler", () => {
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();
});
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 () =>
okResult({ items: [] }),
);
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
await wrapped({}, toolContext);
expect(mocks.captureServerEvent).toHaveBeenCalledTimes(1);
expect(mocks.incrementSelfHostMcpToolCallCount).toHaveBeenCalledTimes(1);
@ -137,7 +133,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: "not-an-array" }),
);
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call",
@ -154,7 +150,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ status: "error", error: { code: "ga4_not_connected" } }),
);
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call",
@ -172,7 +168,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ ok: false, reason: "audit_not_ready" }),
);
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
await wrapped({}, toolContext);
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
event: "mcp:tool_call",
@ -186,9 +182,7 @@ describe("instrumentMcpToolHandler", () => {
throw new AppError("NOT_FOUND");
});
await expect(
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
).rejects.toThrow("NOT_FOUND");
await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND");
expect(mocks.captureServerEvent.mock.calls[0][0]).toMatchObject({
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 () => {
const wrapped = instrumentMcpToolHandler("demo", outputSchema, async () =>
okResult({ items: [] }),
);
await runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra));
await wrapped({}, toolContext);
expect(mocks.recordExternalMcpToolCall).toHaveBeenCalledExactlyOnceWith(
"org-1",
@ -224,9 +207,7 @@ describe("instrumentMcpToolHandler", () => {
okResult({ items: [] }),
);
await runWithMcpToolAuthContext({ ...authContext, clientId: null }, () =>
wrapped({}, toolExtra),
);
await wrapped({}, { auth: { ...authContext, clientId: null } });
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
});
@ -236,9 +217,7 @@ describe("instrumentMcpToolHandler", () => {
throw new AppError("NOT_FOUND");
});
await expect(
runWithMcpToolAuthContext(authContext, () => wrapped({}, toolExtra)),
).rejects.toThrow("NOT_FOUND");
await expect(wrapped({}, toolContext)).rejects.toThrow("NOT_FOUND");
expect(mocks.recordExternalMcpToolCall).not.toHaveBeenCalled();
});

View File

@ -1,38 +1,38 @@
import { waitUntil } from "cloudflare:workers";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import {
getParseErrorMessage,
normalizeObjectSchema,
safeParseAsync,
type AnySchema,
type ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
import type { z } from "zod";
import { asAppError } from "@/server/lib/errors";
import { recordExternalMcpToolCall } from "@/server/features/activation/mcpActivation";
import { captureServerError, captureServerEvent } from "@/server/lib/posthog";
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";
type ToolHandler<TArgs> = (
args: TArgs,
extra: ToolExtra,
context: ToolContext,
) => CallToolResult | Promise<CallToolResult>;
function isRecord(value: unknown): value is Record<string, unknown> {
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
* external MCP clients (OAuth) from the in-app agent (first-party auth, null
* clientId); self-hosted installs never report because captureServerEvent is
* gated to hosted mode. Analytics must never affect the tool call, so a
* missing auth context (e.g. in tests) is swallowed.
* gated to hosted mode.
*/
function captureMcpToolCall(
toolName: string,
extra: ToolExtra,
context: ToolContext,
outcome: {
success: boolean;
errorCode?: string;
@ -44,8 +44,7 @@ function captureMcpToolCall(
) {
waitUntil(incrementSelfHostMcpToolCallCount());
try {
const auth = getAuth(extra);
const auth = context.auth;
waitUntil(
captureServerEvent({
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
* 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
* same validation (via the SDK's own helpers) to surface the mismatch
* instead of shipping it silently.
* same validation to surface the mismatch instead of shipping it silently.
*/
export function instrumentMcpToolHandler<TArgs>(
toolName: string,
outputSchema: AnySchema | ZodRawShapeCompat | undefined,
outputSchema: z.ZodType | undefined,
handler: ToolHandler<TArgs>,
): (args: TArgs, extra: ToolExtra) => Promise<CallToolResult> {
const normalizedOutputSchema = normalizeObjectSchema(outputSchema);
return async (args, extra) => {
): (args: TArgs, context: ToolContext) => Promise<CallToolResult> {
return async (args, context) => {
const startedAt = performance.now();
try {
const result = await handler(args, extra);
const result = await handler(args, context);
// The SDK converts an output-schema mismatch into a client-visible
// JSON-RPC error, so count it as a failed call, not a success.
let outputValidationFailed = false;
if (
normalizedOutputSchema &&
!result.isError &&
result.structuredContent
) {
const validation = await safeParseAsync(
normalizedOutputSchema,
if (outputSchema && !result.isError && result.structuredContent) {
const validation = await outputSchema.safeParseAsync(
result.structuredContent,
);
if (!validation.success) {
outputValidationFailed = true;
// getParseErrorMessage reports type-level mismatches (expected vs
// received *types*), so it carries no row data. Keep it that way:
// output schemas must not gain value-echoing refinements (enums on
// user data, etc.) that would surface response values in PostHog.
// Keep this type-level and privacy-safe: output schemas must not gain
// value-echoing refinements that would surface response data here.
waitUntil(
captureServerError(
new Error(`MCP output validation failed for ${toolName}`),
{
errorCode: "MCP_OUTPUT_VALIDATION",
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 =
structured?.status === "error" || structured?.ok === false;
const returnedError =
@ -148,7 +136,7 @@ export function instrumentMcpToolHandler<TArgs>(
!result.isError && !outputValidationFailed && !returnedFailure;
captureMcpToolCall(
toolName,
extra,
context,
outputValidationFailed
? {
success: false,
@ -179,19 +167,15 @@ export function instrumentMcpToolHandler<TArgs>(
// the request's DB scope; a per-isolate memo keeps this off the hot
// path after the first call.
if (succeeded) {
try {
const auth = getAuth(extra);
const auth = context.auth;
if (auth.clientId) {
await recordExternalMcpToolCall(auth.organizationId);
}
} catch {
// no auth context — skip milestone tracking
}
}
return result;
} catch (error) {
const appError = asAppError(error);
captureMcpToolCall(toolName, extra, {
captureMcpToolCall(toolName, context, {
success: false,
errorCode: appError?.code ?? "INTERNAL_ERROR",
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 {
AuthorizationError,
OAuthError,
OAuthProvider,
type AuthRequest,
type OAuthHelpers,
@ -17,13 +19,11 @@ import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation"
import { captureServerEvent } from "@/server/lib/posthog";
import {
createWorkersOAuthMcpProps,
MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE,
withWorkersOAuthMcpScopes,
workersOAuthMcpPropsSchema,
} from "@/server/mcp/context";
import {
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
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_CONSENT_RESPONSE_PATH = "/api/oauth/consent";
const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate";
const OAUTH_AUTHORIZATION_PARAM_NAMES = [
"response_type",
"client_id",
@ -48,6 +47,11 @@ const OAUTH_AUTHORIZATION_PARAM_NAMES = [
// preserve MCP sessions across normal usage.
const MCP_ACCESS_TOKEN_TTL_SECONDS = 60 * 60 * 24;
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 & {
OAUTH_KV: KVNamespace;
@ -56,10 +60,6 @@ export type OpenSeoOAuthEnv = Env & {
type AppFetch = (request: Request) => Response | Promise<Response>;
type OAuthExecutionContext = ExecutionContext & {
props?: unknown;
};
type ExportedHandlerWithFetch<Env> = ExportedHandler<Env> & {
fetch: NonNullable<ExportedHandler<Env>["fetch"]>;
};
@ -77,10 +77,6 @@ function getOAuthHelpers(env: OpenSeoOAuthEnv) {
return env.OAUTH_PROVIDER;
}
function getMcpResourceForRequest(request: Request) {
return getMcpResource(getPublicOrigin(request));
}
function getRelativeRequestTarget(request: Request) {
const url = new URL(request.url);
return `${url.pathname}${url.search}`;
@ -92,10 +88,35 @@ function redirectToSignIn(request: Request) {
return Response.redirect(signInUrl.toString(), 302);
}
function invalidOAuthRequestResponse(error: unknown) {
return new Response(
error instanceof Error ? error.message : "Invalid OAuth request",
{ status: 400 },
function oauthErrorRedirect(input: {
redirectUri: string;
code: string;
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;
description: string;
status: number;
headers: Record<string, string>;
}) {
// 401s here are the standard OAuth discovery handshake, not failures: an
// unauthenticated /mcp hit returns `invalid_token` (which triggers the
// client's .well-known discovery), and clients registered as confidential
// before the public-client DCR fix still draw `invalid_client` until they
// retry with the secret or re-register. Log those at debug so they stop
// masquerading as errors; keep 5xx at error and everything else (bad client
// metadata, etc.) at warn.
// client's .well-known discovery), and stale client registrations draw
// `invalid_client` until the client re-registers. Log those at debug so
// they stop masquerading as errors; keep 5xx at error and everything else
// (bad client metadata, etc.) at warn.
const line = `[oauth] ${error.status} ${error.code}: ${error.description}`;
if (error.status === 401) {
console.debug(line);
@ -131,22 +150,8 @@ function oauthErrorResponse(error: {
console.warn(line);
}
const headers = new Headers(error.headers);
headers.set("Content-Type", "application/json");
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,
},
);
// Returning void delegates the standards-compliant body, bearer challenge,
// and CORS headers to workers-oauth-provider.
}
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[]) {
if (requestedScopes.length === 0) {
return [...MCP_OAUTH_SCOPES];
@ -256,14 +238,13 @@ function getGrantedMcpScopes(requestedScopes: string[]) {
}
function deniedRedirect(authRequest: AuthRequest) {
const redirectUrl = new URL(authRequest.redirectUri);
redirectUrl.searchParams.set("error", "access_denied");
redirectUrl.searchParams.set("error_description", "The user denied access");
if (authRequest.state) {
redirectUrl.searchParams.set("state", authRequest.state);
}
return redirectUrl.toString();
return oauthErrorRedirect({
redirectUri: authRequest.redirectUri,
code: "access_denied",
description: "The user denied access",
state: authRequest.state,
issuer: authRequest.issuer,
});
}
async function handleOAuthAuthorizeRequest(
@ -275,7 +256,10 @@ async function handleOAuthAuthorizeRequest(
try {
await oauth.parseAuthRequest(request);
} catch (error) {
return invalidOAuthRequestResponse(error);
if (error instanceof AuthorizationError) {
return authorizationErrorResponse(error);
}
throw error;
}
const sessionBlocker = await getAuthorizeSessionBlocker(request);
@ -317,11 +301,11 @@ async function handleOAuthConsentResponse(
let authRequest: AuthRequest;
try {
authRequest = await oauth.parseAuthRequest(authorizeRequest);
authRequest = withDefaultMcpResource(authRequest, request);
} catch (error) {
if (!(error instanceof AuthorizationError)) throw error;
return jsonResponse(
{
error: error instanceof Error ? error.message : "Invalid OAuth request",
error: error.description,
},
{ status: 400 },
);
@ -348,16 +332,13 @@ async function handleOAuthConsentResponse(
);
}
const audience = getMcpResourceForRequest(request);
const props = createWorkersOAuthMcpProps({
userId: context.userId,
userEmail: context.userEmail,
organizationId: context.organizationId,
baseUrl: getHostedBaseUrl(),
clientId: authRequest.clientId,
scopes,
audience,
subject: context.userId,
baseUrl: getHostedBaseUrl(),
});
const { redirectTo } = await oauth.completeAuthorization({
@ -410,16 +391,11 @@ function createDefaultHandler(
const mcpApiHandler: ExportedHandlerWithFetch<OpenSeoOAuthEnv> = {
async fetch(request, env, ctx) {
return handleAuthenticatedOpenSeoMcpRequest(
request,
(ctx as OAuthExecutionContext).props,
env,
ctx,
);
return handleAuthenticatedOpenSeoMcpRequest(request, ctx.props, env, ctx);
},
};
export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
function createProvider(appFetch: AppFetch, resource: string) {
const options: OAuthProviderOptions<OpenSeoOAuthEnv> = {
apiRoute: MCP_ROUTE,
apiHandler: mcpApiHandler,
@ -430,39 +406,64 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
scopesSupported: [...MCP_OAUTH_SCOPES],
accessTokenTTL: MCP_ACCESS_TOKEN_TTL_SECONDS,
refreshTokenTTL: MCP_REFRESH_TOKEN_TTL_SECONDS,
clientRegistrationTTL: MCP_CLIENT_REGISTRATION_TTL_SECONDS,
resourceMetadata: {
scopes_supported: [...MCP_OAUTH_SCOPES],
resource,
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP",
},
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 {
async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) {
const url = new URL(request.url);
if (url.pathname === OAUTH_REGISTER_PATH) {
// Register secretless MCP clients as true public clients so refresh
// 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(
return getProvider().fetch(
await normalizeClientRegistrationRequest(request),
env,
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 { z } from "zod";
import {
normalizeClientRegistrationRequest,
withCompatibilityClientSecret,
} from "@/server/mcp/oauth-registration";
const registrationBodySchema = z.looseObject({ client_secret: z.string() });
import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registration";
describe("normalizeClientRegistrationRequest", () => {
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(
"https://app.openseo.so/api/auth/oauth2/register",
{
@ -44,6 +38,26 @@ describe("normalizeClientRegistrationRequest", () => {
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({
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";
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;
// Loose so every field the provider cares about survives the round trip; these
// shims only read the auth method and secret.
// Loose so every field the provider cares about survives the round trip.
const clientMetadataSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(),
});
const clientRegistrationSchema = z.looseObject({
token_endpoint_auth_method: z.string().optional(),
client_secret: z.string().optional(),
redirect_uris: z.array(z.string()).optional(),
});
export async function normalizeClientRegistrationRequest(request: Request) {
@ -49,15 +45,15 @@ export async function normalizeClientRegistrationRequest(request: Request) {
return request;
}
// The provider defaults an omitted auth method to client_secret_basic,
// which requires client authentication on every grant — including refresh.
// MCP clients that discard the secret (Codex did) then lose their session
// at first token expiry with "invalid_client: missing client_secret".
// Register them as public clients instead; PKCE plus the provider's
// grant-to-client binding secure the public-client flow.
// Perplexity requires a client secret, so let the provider create and store a
// real one. Other MCP clients that omit the method are public clients: some
// discard DCR secrets and would otherwise fail their first token refresh.
const isPerplexity = parsed.data.redirect_uris?.includes(PERPLEXITY_CALLBACK);
const metadata = {
...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);
@ -70,47 +66,3 @@ export async function normalizeClientRegistrationRequest(request: Request) {
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";
// 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
.object({
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 { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import { makeToolContext } from "@/server/mcp/tools/tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
@ -13,30 +11,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
const authContext = {
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,
};
const toolContext = makeToolContext();
describe("withMcpProjectAuth", () => {
beforeEach(() => {
@ -57,7 +32,7 @@ describe("withMcpProjectAuth", () => {
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "project_123" }, toolExtra),
wrapped({ projectId: "project_123" }, toolContext),
).resolves.toBe("ok");
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
@ -71,7 +46,7 @@ describe("withMcpProjectAuth", () => {
const handler = vi.fn().mockReturnValue("ok");
const wrapped = withMcpProjectAuth(handler);
await wrapped({ projectId: "project_123" }, toolExtra);
await wrapped({ projectId: "project_123" }, toolContext);
expect(handler).toHaveBeenCalledWith(
{ projectId: "project_123" },
@ -82,8 +57,6 @@ describe("withMcpProjectAuth", () => {
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
},
baseUrl: "https://open-seo.test",
billing: {
@ -109,9 +82,9 @@ describe("withMcpProjectAuth", () => {
const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler);
await expect(wrapped({ projectId: "project_123" }, toolExtra)).rejects.toBe(
error,
);
await expect(
wrapped({ projectId: "project_123" }, toolContext),
).rejects.toBe(error);
expect(handler).not.toHaveBeenCalled();
});
@ -126,7 +99,7 @@ describe("withMcpProjectAuth", () => {
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "someone-elses-project" }, toolExtra),
wrapped({ projectId: "someone-elses-project" }, toolContext),
).rejects.toThrow();
expect(handler).not.toHaveBeenCalled();

View File

@ -1,17 +1,16 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { AppError } from "@/server/lib/errors";
import {
buildBillingCustomer,
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { buildBillingCustomer, type ToolContext } from "@/server/mcp/context";
type ProjectScopedArgs = {
projectId: string;
};
async function requireProjectAccess(extra: ToolExtra, projectId: string) {
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
async function requireProjectAccess(
toolContext: ToolContext,
projectId: string,
) {
const { baseUrl, ...auth } = toolContext.auth;
// Authorize the caller-supplied projectId against the token's organization.
// 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,
) => Promise<TResult> | TResult,
) {
return async (args: TArgs, extra: ToolExtra) => {
const context = await requireProjectAccess(extra, args.projectId);
return async (args: TArgs, toolContext: ToolContext) => {
const context = await requireProjectAccess(toolContext, args.projectId);
return handler(args, context);
};
}

View File

@ -1,12 +1,11 @@
import type {
import {
type CallToolResult,
McpServer,
ToolCallback,
} from "@modelcontextprotocol/sdk/server/mcp.js";
import type {
AnySchema,
ZodRawShapeCompat,
} from "@modelcontextprotocol/sdk/server/zod-compat.js";
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
type ToolAnnotations,
} from "@modelcontextprotocol/server";
import type { z } from "zod";
import { createMcpToolContext, type ToolContext } from "@/server/mcp/context";
import { objectSchema } from "@/server/mcp/output-schemas";
import { instrumentMcpToolHandler } from "@/server/mcp/instrumentation";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
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 { runRankTrackerTool } from "@/server/mcp/tools/run-rank-tracker";
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 {
getGoogleAnalyticsAudienceBreakdownTool,
getGoogleAnalyticsEcommercePerformanceTool,
@ -34,6 +30,9 @@ import {
getGoogleAnalyticsTrafficAcquisitionTool,
getSearchOpportunitiesTool,
} 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 {
findSerpCompetitorsTool,
getGoogleBusinessQuestionsTool,
@ -56,75 +55,118 @@ import {
} from "@/server/mcp/tools/site-audit-tools";
import { whoamiTool } from "@/server/mcp/tools/whoami";
// Each handler is wrapped so failures reach PostHog because the MCP route has
// no error middleware of its own.
function registerInstrumentedTool<
In extends ZodRawShapeCompat | AnySchema,
Out extends ZodRawShapeCompat | AnySchema,
>(
server: McpServer,
tool: {
type ToolSchema = z.ZodType | z.ZodRawShape;
// Tools declare inputSchema as either a raw Zod shape (most tools) or a full
// z.object (the GA4 tools); both normalize to one object schema at
// registration.
type ToolArgs<Input extends ToolSchema> = Input extends z.ZodType
? z.infer<Input>
: Input extends z.ZodRawShape
? z.infer<z.ZodObject<Input>>
: never;
type OpenSeoToolDefinition<Input extends ToolSchema> = {
name: string;
config: {
inputSchema?: In;
outputSchema?: Out;
title?: string;
description?: string;
inputSchema: Input;
outputSchema?: ToolSchema;
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(
tool.name,
tool.config,
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- instrumentation preserves the callback arguments validated by ToolCallback<In>
instrumentMcpToolHandler(
tool.name,
tool.config.outputSchema,
tool.handler,
) as ToolCallback<In>,
{
...tool.config,
inputSchema: objectSchema(tool.config.inputSchema),
outputSchema,
},
(args, context) =>
// 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) {
registerInstrumentedTool(server, whoamiTool);
registerInstrumentedTool(server, listProjectsTool);
registerInstrumentedTool(server, createProjectTool);
registerInstrumentedTool(server, listSavedKeywordsTool);
registerInstrumentedTool(server, researchKeywordsTool);
registerInstrumentedTool(server, saveKeywordsTool);
registerInstrumentedTool(server, getDomainOverviewTool);
registerInstrumentedTool(server, getDomainKeywordSuggestionsTool);
registerInstrumentedTool(server, getBacklinksOverviewTool);
registerInstrumentedTool(server, getBacklinksProfileTool);
registerInstrumentedTool(server, getSerpResultsTool);
registerInstrumentedTool(server, createRankTrackerTool);
registerInstrumentedTool(server, getRankTrackerTool);
registerInstrumentedTool(server, addRankTrackingKeywordsTool);
registerInstrumentedTool(server, removeRankTrackingKeywordsTool);
registerInstrumentedTool(server, estimateRankTrackerCostTool);
registerInstrumentedTool(server, runRankTrackerTool);
registerInstrumentedTool(server, getRankedKeywordsTool);
registerInstrumentedTool(server, findSerpCompetitorsTool);
registerInstrumentedTool(server, searchLocalBusinessesTool);
registerInstrumentedTool(server, getLocalSerpResultsTool);
registerInstrumentedTool(server, getGoogleBusinessQuestionsTool);
registerInstrumentedTool(server, getKeywordMetricsTool);
registerInstrumentedTool(server, getSearchConsolePerformanceTool);
registerInstrumentedTool(server, inspectUrlsTool);
registerInstrumentedTool(server, getGoogleAnalyticsOrganicLandingPagesTool);
registerInstrumentedTool(server, getGoogleAnalyticsPagePerformanceTool);
registerInstrumentedTool(server, getGoogleAnalyticsKeyEventsTool);
registerInstrumentedTool(server, getSearchOpportunitiesTool);
registerInstrumentedTool(server, getGoogleAnalyticsOrganicOverviewTool);
registerInstrumentedTool(server, getGoogleAnalyticsTrafficAcquisitionTool);
registerInstrumentedTool(server, getGoogleAnalyticsMeasurementHealthTool);
registerInstrumentedTool(server, getGoogleAnalyticsEcommercePerformanceTool);
registerInstrumentedTool(server, getGoogleAnalyticsSiteSearchTool);
registerInstrumentedTool(server, getGoogleAnalyticsAudienceBreakdownTool);
registerInstrumentedTool(server, runSiteAuditTool);
registerInstrumentedTool(server, getAuditStatusTool);
registerInstrumentedTool(server, getAuditIssuesTool);
registerInstrumentedTool(server, getAuditPagesTool);
export function createOpenSeoMcpServer() {
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.",
},
);
registerOpenSeoTool(server, whoamiTool);
registerOpenSeoTool(server, listProjectsTool);
registerOpenSeoTool(server, createProjectTool);
registerOpenSeoTool(server, listSavedKeywordsTool);
registerOpenSeoTool(server, researchKeywordsTool);
registerOpenSeoTool(server, saveKeywordsTool);
registerOpenSeoTool(server, getDomainOverviewTool);
registerOpenSeoTool(server, getDomainKeywordSuggestionsTool);
registerOpenSeoTool(server, getBacklinksOverviewTool);
registerOpenSeoTool(server, getBacklinksProfileTool);
registerOpenSeoTool(server, getSerpResultsTool);
registerOpenSeoTool(server, createRankTrackerTool);
registerOpenSeoTool(server, getRankTrackerTool);
registerOpenSeoTool(server, addRankTrackingKeywordsTool);
registerOpenSeoTool(server, removeRankTrackingKeywordsTool);
registerOpenSeoTool(server, estimateRankTrackerCostTool);
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 { createProjectTool } from "./create-project";
import { makeToolExtra } from "./tool-test-support";
import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
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", () => {
beforeEach(() => {});
@ -28,7 +28,7 @@ describe("create_project MCP tool", () => {
const result = await createProjectTool.handler(
{ name: "Acme", domain: "acme.com", locationCode: 2840 },
toolExtra,
toolContext,
);
// The schema does not derive languageCode; the service resolves it from
@ -62,7 +62,7 @@ describe("create_project MCP tool", () => {
languageCode: "en",
});
await createProjectTool.handler({ name: "Just a name" }, toolExtra);
await createProjectTool.handler({ name: "Just a name" }, toolContext);
expect(mocks.createProject).toHaveBeenCalledWith("org_123", {
name: "Just a name",
@ -73,7 +73,7 @@ describe("create_project MCP tool", () => {
await expect(
createProjectTool.handler(
{ name: "Bad market", languageCode: "en" },
toolExtra,
toolContext,
),
).rejects.toThrow();
expect(mocks.createProject).not.toHaveBeenCalled();

View File

@ -1,9 +1,6 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters";
import {
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { type ToolContext } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls";
import { languageCodeSchema, locationCodeSchema } from "@/server/mcp/schemas";
@ -65,8 +62,8 @@ export const createProjectTool = {
destructiveHint: false,
},
},
handler: async (args: Args, extra: ToolExtra) => {
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
handler: async (args: Args, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth;
// Reuse the app's create schema so the market pair rule (a languageCode
// requires a locationCode) is enforced identically to the dashboard, and
// 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 type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
import { getKeywordMetricsTool } from "./dataforseo-research-tools";
import { makeToolExtra } from "./tool-test-support";
import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
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", () => {
beforeEach(() => {
@ -68,7 +68,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
locationCode: 2352,
languageCode: "is",
},
toolExtra,
toolContext,
);
expect(keywordOverview).not.toHaveBeenCalled();
@ -120,7 +120,7 @@ describe("get_keyword_metrics for Google-Ads-only locations", () => {
keywords: ["seo tools"],
includeClickstreamData: true,
},
toolExtra,
toolContext,
);
expect(keywordOverview).toHaveBeenCalledWith(

View File

@ -3,7 +3,7 @@ import {
findSerpCompetitorsTool,
getRankedKeywordsTool,
} 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
// 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 }) {
mocks.getProjectForOrganization.mockResolvedValue({
@ -55,7 +55,7 @@ async function runRankedKeywords(args: MarketArgs) {
});
await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "acmeexample.com", ...args },
toolExtra,
toolContext,
);
return rankedKeywords;
}
@ -67,7 +67,7 @@ async function runSerpCompetitors(args: MarketArgs) {
});
await findSerpCompetitorsTool.handler(
{ projectId: "project_1", keywords: ["seo"], ...args },
toolExtra,
toolContext,
);
return serpCompetitors;
}

View File

@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { fetchKeywordMetricsForList as FetchKeywordMetricsForList } from "@/server/lib/dataforseo/keyword-metrics";
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(() => ({
createDataforseoClient: vi.fn(),
@ -31,7 +31,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
const toolExtra = makeToolExtra();
const toolContext = makeToolContext();
const usProjectRow = {
id: "project_1",
@ -70,7 +70,7 @@ describe("DataForSEO research MCP tools", () => {
},
categories: ["cafe"],
},
toolExtra,
toolContext,
);
expect(businessListings).toHaveBeenCalledWith(
@ -116,7 +116,7 @@ describe("DataForSEO research MCP tools", () => {
zoom: 14,
},
},
toolExtra,
toolContext,
);
expect(local).toHaveBeenCalledWith(
@ -164,7 +164,7 @@ describe("DataForSEO research MCP tools", () => {
radiusKm: 5,
},
},
toolExtra,
toolContext,
);
expect(questionsAnswers).toHaveBeenCalledWith(
@ -201,7 +201,7 @@ describe("DataForSEO research MCP tools", () => {
target: "acmeexample.com",
excludeBrandTerms: ["acme"],
},
toolExtra,
toolContext,
);
expect(rankedKeywords).toHaveBeenCalledWith(
@ -228,7 +228,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["coffee"],
excludeDomains: ["directory.example"],
},
toolExtra,
toolContext,
);
const content = z
@ -285,7 +285,7 @@ describe("DataForSEO research MCP tools", () => {
const result = await getKeywordMetricsTool.handler(
{ projectId: "project_1", keywords: ["seo automation"] },
toolExtra,
toolContext,
);
expect(keywordOverview).toHaveBeenCalledWith(
@ -340,7 +340,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["low", "high", "medium"],
sortBy: "search_volume",
},
toolExtra,
toolContext,
);
const rows = z
@ -372,7 +372,7 @@ describe("DataForSEO research MCP tools", () => {
keywords: ["seo"],
includeMonthlyTrends: false,
},
toolExtra,
toolContext,
);
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 { Ga4ReportError } from "@/server/lib/ga4Errors";
import * as tools from "./google-analytics-tools";
import { makeToolExtra } from "./tool-test-support";
import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
runReport: vi.fn(),
@ -37,7 +37,7 @@ vi.mock("@/server/features/projects/services/ProjectService", () => ({
},
}));
const toolExtra = makeToolExtra();
const toolContext = makeToolContext();
const reportResult = makeGa4ReportResult({
rowCount: 1,
totalRowCount: 1,
@ -70,7 +70,7 @@ describe("Google Analytics MCP tools", () => {
const { getGoogleAnalyticsOrganicLandingPagesTool } = tools;
const result = await getGoogleAnalyticsOrganicLandingPagesTool.handler(
{ projectId: "project_1", limit: 10, offset: 0 },
toolExtra,
toolContext,
);
expect(mocks.runReport).toHaveBeenCalledWith({
projectId: "project_1",
@ -99,7 +99,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
);
await getGoogleAnalyticsKeyEventsTool.handler(
{
@ -110,7 +110,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
);
expect(mocks.runReport).toHaveBeenNthCalledWith(
1,
@ -146,7 +146,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
);
expect(result.structuredContent).toMatchObject({
status: "error",
@ -168,7 +168,7 @@ describe("Google Analytics MCP tools", () => {
const { getSearchOpportunitiesTool } = tools;
const result = await getSearchOpportunitiesTool.handler(
{ projectId: "project_1", limit: 25 },
toolExtra,
toolContext,
);
expect(mocks.getOpportunities).toHaveBeenCalledWith({
projectId: "project_1",
@ -193,7 +193,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
),
],
[
@ -208,7 +208,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
),
],
[
@ -216,7 +216,7 @@ describe("Google Analytics MCP tools", () => {
() =>
tools.getGoogleAnalyticsSiteSearchTool.handler(
{ projectId: "project_1", limit: 100, offset: 0 },
toolExtra,
toolContext,
),
],
[
@ -231,7 +231,7 @@ describe("Google Analytics MCP tools", () => {
limit: 100,
offset: 0,
},
toolExtra,
toolContext,
),
],
] as const)(
@ -265,11 +265,11 @@ describe("Google Analytics MCP tools", () => {
} = tools;
const overview = await getGoogleAnalyticsOrganicOverviewTool.handler(
{ projectId: "project_1", trend: "weekly" },
toolExtra,
toolContext,
);
const health = await getGoogleAnalyticsMeasurementHealthTool.handler(
{ projectId: "project_1" },
toolExtra,
toolContext,
);
expect(mocks.getOrganicOverview).toHaveBeenCalledWith({
projectId: "project_1",

View File

@ -1,5 +1,5 @@
/* 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 { Ga4MeasurementHealthService } from "@/server/features/ga4/services/Ga4MeasurementHealthService";
import { Ga4OrganicOverviewService } from "@/server/features/ga4/services/Ga4OrganicOverviewService";

View File

@ -1,9 +1,6 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters";
import {
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import { type ToolContext } from "@/server/mcp/context";
import { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { buildDashboardUrl } from "@/server/mcp/urls";
import { z } from "zod";
@ -36,8 +33,8 @@ export const listProjectsTool = {
destructiveHint: false,
},
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const { baseUrl, ...auth } = requireMcpToolAuthContext(extra);
handler: async (_args: Record<string, never>, context: ToolContext) => {
const { baseUrl, ...auth } = context.auth;
const projects = await ProjectService.listProjects(auth.organizationId);
const lines =
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 { AppError } from "@/server/lib/errors";
import { objectSchema } from "@/server/mcp/output-schemas";
import * as researchTools from "./dataforseo-research-tools";
import { getBacklinksProfileTool } from "./get-backlinks-profile";
import { makeMcpAuthContext, makeToolExtra } from "./tool-test-support";
import { makeToolContext } from "./tool-test-support";
const mocks = vi.hoisted(() => ({
getProjectForOrganization: vi.fn(),
@ -42,12 +39,10 @@ class ProviderRow {
) {}
}
const authExtra = makeToolExtra(
makeMcpAuthContext({
const toolContext = makeToolContext({
userEmail: "team@example.com",
baseUrl: "https://app.example.com",
}),
);
});
const backlinkPage = {
rows: [
@ -100,13 +95,12 @@ describe("DataForSEO research tool output schemas", () => {
const tool = Object.values(tools).find((t) => t.name === toolName);
if (!tool) throw new Error(`tool ${toolName} not found`);
const schema = normalizeObjectSchema(tool.config.outputSchema);
if (!schema) throw new Error("output schema did not normalize");
const schema = objectSchema(tool.config.outputSchema);
// Mirror the MCP server: validate structuredContent against the tool's
// own output schema. Extra keys (e.g. get_ranked_keywords' totalCount)
// 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)],
totalCount: 1,
});
@ -116,12 +110,9 @@ describe("DataForSEO research tool output schemas", () => {
);
it("get_backlinks_profile accepts a paginated backlinks profile payload", async () => {
const schema = normalizeObjectSchema(
getBacklinksProfileTool.config.outputSchema,
);
if (!schema) throw new Error("output schema did not normalize");
const schema = objectSchema(getBacklinksProfileTool.config.outputSchema);
const result = await safeParseAsync(schema, {
const result = await schema.safeParseAsync({
backlinks: backlinkPage,
meta: {
organizationId: "org_123",
@ -155,7 +146,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "as_is",
hideSpam: false,
},
authExtra,
toolContext,
);
expect(mocks.profileBacklinksPage).toHaveBeenCalledWith(
@ -208,7 +199,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "one_per_domain",
hideSpam: true,
},
authExtra,
toolContext,
);
expect(result.structuredContent?.backlinks).toMatchObject({
@ -240,7 +231,7 @@ describe("get_backlinks_profile MCP tool", () => {
mode: "one_per_domain",
hideSpam: true,
},
authExtra,
toolContext,
),
).rejects.toMatchObject({
code: "BACKLINKS_BILLING_ISSUE",

View File

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

View File

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

View File

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

View File

@ -1,44 +1,18 @@
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
import { vi } from "vitest";
import {
MCP_AUTH_CONTEXT_PROP,
type McpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
import type { ToolAuthContext, ToolContext } from "@/server/mcp/context";
export function makeMcpAuthContext(
overrides: Partial<McpToolAuthContext> = {},
): McpToolAuthContext {
const baseUrl = overrides.baseUrl ?? "https://open-seo.test";
export function makeToolContext(
overrides: Partial<ToolAuthContext> = {},
): ToolContext {
return {
auth: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: `${baseUrl}/mcp`,
subject: "user_123",
baseUrl,
baseUrl: "https://open-seo.test",
...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 { getSerpResultsTool } from "./get-serp-results";
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
// 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)", () => {
beforeEach(() => {
@ -108,7 +108,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await researchKeywordsTool.handler(
{ projectId: "project_1", seeds: [{ seed: "seo tools" }] },
toolExtra,
toolContext,
);
const out = textContent(result);
@ -129,7 +129,7 @@ describe("MCP tool text output (service-backed tools)", () => {
]);
const result = await getDomainKeywordSuggestionsTool.handler(
{ projectId: "project_1", domain: "example.com" },
toolExtra,
toolContext,
);
const out = textContent(result);
@ -160,7 +160,7 @@ describe("MCP tool text output (service-backed tools)", () => {
});
const result = await getBacklinksOverviewTool.handler(
{ projectId: "project_1", target: "example.com" },
toolExtra,
toolContext,
);
const out = textContent(result);
@ -201,7 +201,7 @@ describe("MCP tool text output (service-backed tools)", () => {
filters: {},
mode: "one_per_domain",
},
toolExtra,
toolContext,
);
const out = textContent(result);
@ -236,7 +236,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankTrackerTool.handler(
{ projectId: "project_1", trackerId: "tracker_1" },
toolExtra,
toolContext,
);
const out = textContent(result);
@ -268,7 +268,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankTrackerTool.handler(
{ projectId: "project_1", trackerId: "tracker_1" },
toolExtra,
toolContext,
);
expect(textContent(result)).toContain(
@ -310,7 +310,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getRankedKeywordsTool.handler(
{ projectId: "project_1", target: "example.com" },
toolExtra,
toolContext,
);
const out = textContent(result);
@ -335,7 +335,7 @@ describe("MCP tool text output (service-backed tools)", () => {
const result = await getSerpResultsTool.handler(
{ projectId: "project_1", queries: [{ keyword: "seo tools" }] },
toolExtra,
toolContext,
);
const out = textContent(result);

View File

@ -4,7 +4,7 @@ import {
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
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 { optionalMetaOutputSchema } from "@/server/mcp/output-schemas";
import { z } from "zod";
@ -40,8 +40,8 @@ export const whoamiTool = {
destructiveHint: false,
},
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);
handler: async (_args: Record<string, never>, context: ToolContext) => {
const auth = context.auth;
const isHosted = await isHostedServerAuthMode();
let creditsRemaining: number | null = null;
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import type { CreateMcpHandlerOptions } from "agents/mcp/server";
import { McpServer } from "@modelcontextprotocol/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
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(() => ({
resolveCloudflareAccessContext: 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", () => ({
resolveCloudflareAccessContext:
selfHostedAuthMocks.resolveCloudflareAccessContext,
@ -26,20 +21,38 @@ vi.mock("@/middleware/ensure-user/delegated", () => ({
resolveLocalNoAuthContext: selfHostedAuthMocks.resolveLocalNoAuthContext,
}));
vi.mock("@/server/mcp/server", () => ({
registerOpenSeoMcpTools: vi.fn(),
vi.mock("@/lib/auth", () => ({
getHostedBaseUrl: () => "https://open-seo.test",
}));
vi.mock("agents/mcp", () => ({
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
serverMocks.nextServerId += 1;
serverMocks.serverIds.set(_server, serverMocks.nextServerId);
serverMocks.lastServer = _server;
vi.mock("@/server/mcp/server", () => ({
createOpenSeoMcpServer: () =>
new McpServer({
name: "OpenSEO MCP",
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 () =>
new Response(
vi.mock("agents/mcp/server", () => ({
createMcpHandler: (
createServer: () => McpServer,
options: CreateMcpHandlerOptions,
) => {
return async (request: Request) => {
if (request.method !== "OPTIONS") createServer();
return new Response(
JSON.stringify({
serverId: serverMocks.serverIds.get(_server),
options,
}),
{
@ -47,6 +60,7 @@ vi.mock("agents/mcp", () => ({
headers: { "Content-Type": "application/json" },
},
);
};
},
}));
@ -57,10 +71,9 @@ const ctx: ExecutionContext = {
};
const transportOptionsSchema = z.object({
serverId: z.number().optional(),
options: z.object({
route: z.string().optional(),
enableJsonResponse: z.boolean().optional(),
allowedOriginHostnames: z.array(z.string()).optional(),
authContext: z
.object({
props: z.record(z.string(), z.unknown()),
@ -87,9 +100,6 @@ function createMcpRequest() {
describe("handleSelfHostedOpenSeoMcpRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
serverMocks.nextServerId = 0;
serverMocks.serverIds = new WeakMap<McpServer, number>();
serverMocks.lastServer = undefined;
selfHostedAuthMocks.resolveLocalNoAuthContext.mockResolvedValue({
userId: "local-admin",
userEmail: "admin@localhost",
@ -122,12 +132,11 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
userId: "local-admin",
userEmail: "admin@localhost",
organizationId: "delegated-local-admin",
clientId: null,
scopes: [],
audience: "https://open-seo.test/mcp",
subject: "local-admin",
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 () => {
@ -152,36 +161,10 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
userId: "cloudflare-user",
userEmail: "person@example.com",
organizationId: "delegated-cloudflare-user",
clientId: null,
scopes: [],
audience: "https://open-seo.test/mcp",
subject: "cloudflare-user",
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 () => {
const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
@ -200,46 +183,74 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
).not.toHaveBeenCalled();
expect(body.options.authContext).toBeUndefined();
});
});
// Directory scanners (e.g. Smithery) read server metadata from initialize.
it("serves directory metadata in the initialize response", async () => {
const { handleSelfHostedOpenSeoMcpRequest } =
describe("handleAuthenticatedOpenSeoMcpRequest", () => {
it("accepts the provider's encrypted identity and MCP scope fallback", 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: ["mcp"],
});
await handleSelfHostedOpenSeoMcpRequest(
const response = await handleAuthenticatedOpenSeoMcpRequest(
createMcpRequest(),
"local_noauth",
props,
{},
ctx,
);
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",
{ ...ctx, props },
);
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler } from "agents/mcp/server";
import { getHostedBaseUrl } from "@/lib/auth";
import { MCP_SCOPE } from "@/lib/oauth-resource";
import { resolveCloudflareAccessContext } from "@/middleware/ensure-user/cloudflareAccess";
import { resolveLocalNoAuthContext } from "@/middleware/ensure-user/delegated";
import {
buildFirstPartyMcpAuthContext,
createWorkersOAuthMcpProps,
hostedWorkersOAuthMcpPropsSchema,
MCP_AUTH_CONTEXT_PROP,
MCP_ROUTE,
runWithMcpToolAuthContext,
workersOAuthMcpPropsSchema,
} from "@/server/mcp/context";
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { registerOpenSeoMcpTools } from "@/server/mcp/server";
import { createOpenSeoMcpServer } from "@/server/mcp/server";
function createOpenSeoMcpServer() {
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);
type McpProps = ReturnType<typeof createWorkersOAuthMcpProps>;
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(
@ -47,16 +36,20 @@ export async function handleAuthenticatedOpenSeoMcpRequest(
env: unknown,
ctx: ExecutionContext,
): Promise<Response> {
const result = workersOAuthMcpPropsSchema.safeParse(props);
const scopes = result.success
? result.data[MCP_AUTH_CONTEXT_PROP].scopes
: [];
if (!result.success || !scopes.includes(MCP_SCOPE)) {
const result = hostedWorkersOAuthMcpPropsSchema.safeParse(props);
if (!result.success) {
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(
@ -65,70 +58,21 @@ export async function handleSelfHostedOpenSeoMcpRequest(
env: unknown,
ctx: ExecutionContext,
): Promise<Response> {
// Self-hosted auth mirrors the app: local_noauth uses the local admin
// 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.
// Preflight does not carry an authenticated application context.
if (request.method === "OPTIONS") {
return handleOpenSeoMcpRequest(request, undefined, env, ctx);
return createRequestHandler(undefined)(request, env, ctx);
}
const baseUrl = getPublicOrigin(request);
const context =
const identity =
authMode === "local_noauth"
? await resolveLocalNoAuthContext()
: await resolveCloudflareAccessContext(request.headers);
const props = createWorkersOAuthMcpProps(
buildFirstPartyMcpAuthContext({
userId: context.userId,
userEmail: context.userEmail,
organizationId: context.organizationId,
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",
},
const props = createWorkersOAuthMcpProps({
userId: identity.userId,
userEmail: identity.userEmail,
organizationId: identity.organizationId,
baseUrl: getPublicOrigin(request),
});
if (!props) return handler(request, env, ctx);
return runWithMcpToolAuthContext(props[MCP_AUTH_CONTEXT_PROP], () =>
handler(request, env, ctx),
);
return createRequestHandler(props)(request, env, ctx);
}

View File

@ -8,5 +8,13 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
restoreMocks: 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": {
"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
// deployments previews, prod, self-host go through Alchemy