feat(mcp): migrate to MCP SDK v2 stateless handler (#464)
This commit is contained in:
parent
45403aa06f
commit
cfc8456767
14
package.json
14
package.json
@ -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
447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
351
src/server/mcp/oauth-provider.test.ts
Normal file
351
src/server/mcp/oauth-provider.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
465
src/server/mcp/oauth-refresh.e2e.test.ts
Normal file
465
src/server/mcp/oauth-refresh.e2e.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
100
src/server/mcp/transport-v2.test.ts
Normal file
100
src/server/mcp/transport-v2.test.ts
Normal 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\\"');
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
|
||||
await handleSelfHostedOpenSeoMcpRequest(
|
||||
createMcpRequest(),
|
||||
"local_noauth",
|
||||
{},
|
||||
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"],
|
||||
},
|
||||
],
|
||||
const props = createWorkersOAuthMcpProps({
|
||||
userId: "user-1",
|
||||
userEmail: "user@example.com",
|
||||
organizationId: "org-1",
|
||||
baseUrl: "https://open-seo.test",
|
||||
clientId: "client-1",
|
||||
scopes: ["mcp"],
|
||||
});
|
||||
expect(serverInfo?.description).toContain(
|
||||
"SEO research tools for AI agents",
|
||||
|
||||
const response = await handleAuthenticatedOpenSeoMcpRequest(
|
||||
createMcpRequest(),
|
||||
props,
|
||||
{},
|
||||
{ ...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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user