diff --git a/knip.jsonc b/knip.jsonc index 57533b7..20646d0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,6 +9,11 @@ "src/routes/**/*.tsx", // Drizzle config (plugin disabled due to cloudflare:workers import issues) "drizzle.config.ts", + // Package script entrypoints + "scripts/backlinks-cost-profile.ts", + "scripts/brand-lookup-cost-profile.ts", + "scripts/release-notes.mjs", + "scripts/seed-rank-tracking.ts", // DB schema — exports consumed via `import * as schema` / drizzle() "src/db/index.ts", "src/db/app.schema.ts", diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7a9e9d6..ca7c478 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppBillingRouteImport } from './routes/_app/billing' import { Route as AppAiRouteImport } from './routes/_app/ai' import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration' +import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known/oauth-protected-resource' import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server' import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -130,6 +131,12 @@ const DotwellKnownOpenidConfigurationRoute = path: '/.well-known/openid-configuration', getParentRoute: () => rootRouteImport, } as any) +const DotwellKnownOauthProtectedResourceRoute = + DotwellKnownOauthProtectedResourceRouteImport.update({ + id: '/.well-known/oauth-protected-resource', + path: '/.well-known/oauth-protected-resource', + getParentRoute: () => rootRouteImport, + } as any) const DotwellKnownOauthAuthorizationServerRoute = DotwellKnownOauthAuthorizationServerRouteImport.update({ id: '/.well-known/oauth-authorization-server', @@ -153,9 +160,9 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({ } as any) const DotwellKnownOauthProtectedResourceMcpRoute = DotwellKnownOauthProtectedResourceMcpRouteImport.update({ - id: '/.well-known/oauth-protected-resource/mcp', - path: '/.well-known/oauth-protected-resource/mcp', - getParentRoute: () => rootRouteImport, + id: '/mcp', + path: '/mcp', + getParentRoute: () => DotwellKnownOauthProtectedResourceRoute, } as any) const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({ id: '/p/$projectId', @@ -249,6 +256,7 @@ export interface FileRoutesByFullPath { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute @@ -284,6 +292,7 @@ export interface FileRoutesByTo { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/ai': typeof AppAiRoute '/billing': typeof AppBillingRoute @@ -320,6 +329,7 @@ export interface FileRoutesById { '/reset-password': typeof ResetPasswordRoute '/verify-email': typeof VerifyEmailRoute '/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + '/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren '/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute '/_app/ai': typeof AppAiRoute '/_app/billing': typeof AppBillingRoute @@ -358,6 +368,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' | '/ai' | '/billing' @@ -393,6 +404,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' | '/ai' | '/billing' @@ -428,6 +440,7 @@ export interface FileRouteTypes { | '/reset-password' | '/verify-email' | '/.well-known/oauth-authorization-server' + | '/.well-known/oauth-protected-resource' | '/.well-known/openid-configuration' | '/_app/ai' | '/_app/billing' @@ -468,8 +481,8 @@ export interface RootRouteChildren { ResetPasswordRoute: typeof ResetPasswordRoute VerifyEmailRoute: typeof VerifyEmailRoute DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren + DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute - DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute } @@ -595,6 +608,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport parentRoute: typeof rootRouteImport } + '/.well-known/oauth-protected-resource': { + id: '/.well-known/oauth-protected-resource' + path: '/.well-known/oauth-protected-resource' + fullPath: '/.well-known/oauth-protected-resource' + preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport + parentRoute: typeof rootRouteImport + } '/.well-known/oauth-authorization-server': { id: '/.well-known/oauth-authorization-server' path: '/.well-known/oauth-authorization-server' @@ -625,10 +645,10 @@ declare module '@tanstack/react-router' { } '/.well-known/oauth-protected-resource/mcp': { id: '/.well-known/oauth-protected-resource/mcp' - path: '/.well-known/oauth-protected-resource/mcp' + path: '/mcp' fullPath: '/.well-known/oauth-protected-resource/mcp' preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport - parentRoute: typeof rootRouteImport + parentRoute: typeof DotwellKnownOauthProtectedResourceRoute } '/_project/p/$projectId': { id: '/_project/p/$projectId' @@ -879,6 +899,21 @@ const DotwellKnownOauthAuthorizationServerRouteWithChildren = DotwellKnownOauthAuthorizationServerRouteChildren, ) +interface DotwellKnownOauthProtectedResourceRouteChildren { + DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute +} + +const DotwellKnownOauthProtectedResourceRouteChildren: DotwellKnownOauthProtectedResourceRouteChildren = + { + DotwellKnownOauthProtectedResourceMcpRoute: + DotwellKnownOauthProtectedResourceMcpRoute, + } + +const DotwellKnownOauthProtectedResourceRouteWithChildren = + DotwellKnownOauthProtectedResourceRoute._addFileChildren( + DotwellKnownOauthProtectedResourceRouteChildren, + ) + const rootRouteChildren: RootRouteChildren = { AppRouteRoute: AppRouteRouteWithChildren, ProjectRouteRoute: ProjectRouteRouteWithChildren, @@ -889,9 +924,9 @@ const rootRouteChildren: RootRouteChildren = { VerifyEmailRoute: VerifyEmailRoute, DotwellKnownOauthAuthorizationServerRoute: DotwellKnownOauthAuthorizationServerRouteWithChildren, + DotwellKnownOauthProtectedResourceRoute: + DotwellKnownOauthProtectedResourceRouteWithChildren, DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute, - DotwellKnownOauthProtectedResourceMcpRoute: - DotwellKnownOauthProtectedResourceMcpRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute, } diff --git a/src/routes/[.]well-known/oauth-protected-resource.ts b/src/routes/[.]well-known/oauth-protected-resource.ts new file mode 100644 index 0000000..8748123 --- /dev/null +++ b/src/routes/[.]well-known/oauth-protected-resource.ts @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { env } from "cloudflare:workers"; +import { mcpProtectedResourceMetadataResponse } from "@/server/mcp/protected-resource-metadata"; + +export const Route = createFileRoute("/.well-known/oauth-protected-resource")({ + server: { + handlers: { + GET: async () => mcpProtectedResourceMetadataResponse(env.AUTH_MODE), + }, + }, +}); diff --git a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts index 4979f71..d9b23e5 100644 --- a/src/routes/[.]well-known/oauth-protected-resource/mcp.ts +++ b/src/routes/[.]well-known/oauth-protected-resource/mcp.ts @@ -1,47 +1,13 @@ import { createFileRoute } from "@tanstack/react-router"; import { env } from "cloudflare:workers"; -import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; -import { isHostedAuthMode } from "@/lib/auth-mode"; -import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client"; -import { getMcpResource, MCP_OAUTH_SCOPES } from "@/lib/oauth-resource"; - -function unavailableMetadataResponse() { - if (!isHostedAuthMode(env.AUTH_MODE)) { - return new Response("Not found", { status: 404 }); - } - - return new Response("Missing Better Auth hosted configuration", { - status: 500, - }); -} +import { mcpProtectedResourceMetadataResponse } from "@/server/mcp/protected-resource-metadata"; export const Route = createFileRoute( "/.well-known/oauth-protected-resource/mcp", )({ server: { handlers: { - GET: async () => { - if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) { - return unavailableMetadataResponse(); - } - - const baseUrl = getHostedBaseUrl(); - const metadata = - await getOAuthProviderResourceActions().getProtectedResourceMetadata({ - resource: getMcpResource(baseUrl), - authorization_servers: [`${baseUrl}/api/auth`], - scopes_supported: [...MCP_OAUTH_SCOPES], - resource_name: "OpenSEO MCP", - }); - - return new Response(JSON.stringify(metadata), { - headers: { - "Cache-Control": - "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400", - "Content-Type": "application/json", - }, - }); - }, + GET: async () => mcpProtectedResourceMetadataResponse(env.AUTH_MODE), }, }, }); diff --git a/src/routes/api/auth/$.test.ts b/src/routes/api/auth/-$.test.ts similarity index 100% rename from src/routes/api/auth/$.test.ts rename to src/routes/api/auth/-$.test.ts diff --git a/src/server/mcp/handler.test.ts b/src/server/mcp/handler.test.ts index 03b49f9..66be24a 100644 --- a/src/server/mcp/handler.test.ts +++ b/src/server/mcp/handler.test.ts @@ -77,18 +77,36 @@ const transportOptionsSchema = z.object({ }), }); -function createMcpRequest(token: string) { +function createMcpRequest({ + method = "tools/call", + token, +}: { + method?: string; + token?: string; +} = {}) { + const headers = new Headers({ + Accept: "application/json, text/event-stream", + "Content-Type": "application/json", + }); + + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + return new Request("https://open-seo.test/mcp", { method: "POST", - headers: { - Accept: "application/json, text/event-stream", - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, + headers, body: JSON.stringify({ jsonrpc: "2.0", id: 1, - method: "tools/list", + method, + params: + method === "tools/call" + ? { + name: "whoami", + arguments: {}, + } + : undefined, }), }); } @@ -125,7 +143,7 @@ describe("handleMcpRequest", () => { const { handleMcpRequest } = await import("@/server/mcp/handler"); const response = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, @@ -166,14 +184,14 @@ describe("handleMcpRequest", () => { const { handleMcpRequest } = await import("@/server/mcp/handler"); const first = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, ctx, ); const second = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, @@ -207,6 +225,40 @@ describe("handleMcpRequest", () => { expect(body.options.authContext).toBeUndefined(); }); + it("lets unauthenticated initialize reach the MCP transport", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + + const response = await handleMcpRequest( + createMcpRequest({ method: "initialize" }), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled(); + expect(body.options.authContext).toBeUndefined(); + }); + + it("lets unauthenticated tools/list reach the MCP transport", async () => { + const { handleMcpRequest } = await import("@/server/mcp/handler"); + + const response = await handleMcpRequest( + createMcpRequest({ method: "tools/list" }), + { + AUTH_MODE: "hosted", + }, + ctx, + ); + const body = transportOptionsSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled(); + expect(body.options.authContext).toBeUndefined(); + }); + it("returns 401 when Better Auth rejects the access token", async () => { const { handleMcpRequest } = await import("@/server/mcp/handler"); verifyMocks.verifyJwsAccessToken.mockRejectedValue( @@ -214,7 +266,7 @@ describe("handleMcpRequest", () => { ); const response = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, @@ -236,7 +288,7 @@ describe("handleMcpRequest", () => { ); const response = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, @@ -251,7 +303,7 @@ describe("handleMcpRequest", () => { userEmailMocks.getMcpUserEmail.mockResolvedValue(null); const response = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, @@ -268,7 +320,7 @@ describe("handleMcpRequest", () => { ); const response = await handleMcpRequest( - createMcpRequest(jwtShapedToken), + createMcpRequest({ token: jwtShapedToken }), { AUTH_MODE: "hosted", }, diff --git a/src/server/mcp/handler.ts b/src/server/mcp/handler.ts index 4a6940b..460d6d8 100644 --- a/src/server/mcp/handler.ts +++ b/src/server/mcp/handler.ts @@ -33,6 +33,17 @@ type McpAccessTokenPayload = JWTPayload & { scope?: unknown; }; +type McpJsonRpcRequest = { + method?: unknown; +}; + +const PUBLIC_MCP_METHODS = new Set([ + "initialize", + "notifications/initialized", + "ping", + "tools/list", +]); + function getTokenScopes(payload: McpAccessTokenPayload) { return typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) @@ -57,6 +68,20 @@ function unauthorizedResponse(resource: string) { }); } +async function isPublicMcpRequest(request: Request) { + if (request.method === "OPTIONS") return true; + if (request.method !== "POST") return false; + + try { + const body: McpJsonRpcRequest = await request.clone().json(); + return ( + typeof body.method === "string" && PUBLIC_MCP_METHODS.has(body.method) + ); + } catch { + return false; + } +} + export async function handleMcpRequest( request: Request, env: { AUTH_MODE?: unknown }, @@ -82,7 +107,7 @@ export async function handleMcpRequest( const organizationIdClaim = getMcpOrganizationIdClaim(baseUrl); const server = createOpenSeoMcpServer(); - if (request.method === "OPTIONS") { + if (await isPublicMcpRequest(request)) { return createMcpHandler(server, { route: MCP_ROUTE, enableJsonResponse: true, diff --git a/src/server/mcp/protected-resource-metadata.ts b/src/server/mcp/protected-resource-metadata.ts new file mode 100644 index 0000000..0e52609 --- /dev/null +++ b/src/server/mcp/protected-resource-metadata.ts @@ -0,0 +1,41 @@ +import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth"; +import { isHostedAuthMode } from "@/lib/auth-mode"; +import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client"; +import { getMcpResource, MCP_OAUTH_SCOPES } from "@/lib/oauth-resource"; + +function unavailableMcpProtectedResourceMetadataResponse( + authMode: string | null | undefined, +) { + if (!isHostedAuthMode(authMode)) { + return new Response("Not found", { status: 404 }); + } + + return new Response("Missing Better Auth hosted configuration", { + status: 500, + }); +} + +export async function mcpProtectedResourceMetadataResponse( + authMode: string | null | undefined, +) { + if (!isHostedAuthMode(authMode) || !hasHostedAuthConfig()) { + return unavailableMcpProtectedResourceMetadataResponse(authMode); + } + + const baseUrl = getHostedBaseUrl(); + const metadata = + await getOAuthProviderResourceActions().getProtectedResourceMetadata({ + resource: getMcpResource(baseUrl), + authorization_servers: [`${baseUrl}/api/auth`], + scopes_supported: [...MCP_OAUTH_SCOPES], + resource_name: "OpenSEO MCP", + }); + + return new Response(JSON.stringify(metadata), { + headers: { + "Cache-Control": + "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400", + "Content-Type": "application/json", + }, + }); +}