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:
parent
36b60f240f
commit
103b6fe91c
@ -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",
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
11
src/routes/[.]well-known/oauth-protected-resource.ts
Normal file
11
src/routes/[.]well-known/oauth-protected-resource.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
41
src/server/mcp/protected-resource-metadata.ts
Normal file
41
src/server/mcp/protected-resource-metadata.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user