fix: add additional well known for claude.ai + Claude Desktop (#170)

* Simplify MCP discovery metadata and lazy auth flow

* Fix ci check for MCP discovery branch
This commit is contained in:
Ben Senescu 2026-05-08 15:59:43 -04:00 committed by GitHub
parent 36b60f240f
commit 103b6fe91c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 194 additions and 59 deletions

View File

@ -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",

View File

@ -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,
}

View File

@ -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),
},
},
});

View File

@ -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),
},
},
});

View File

@ -77,18 +77,36 @@ const transportOptionsSchema = z.object({
}),
});
function createMcpRequest(token: string) {
return new Request("https://open-seo.test/mcp", {
method: "POST",
headers: {
function createMcpRequest({
method = "tools/call",
token,
}: {
method?: string;
token?: string;
} = {}) {
const headers = new Headers({
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
});
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return new Request("https://open-seo.test/mcp", {
method: "POST",
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",
},

View File

@ -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,

View File

@ -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",
},
});
}