Add stateless MCP server (#162)
This commit is contained in:
parent
6231424e88
commit
0ff7bc96b2
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -22,8 +22,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/sourcemaps.yml
vendored
2
.github/workflows/sourcemaps.yml
vendored
@ -20,8 +20,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@ -6,7 +6,7 @@ ENV PATH=$PNPM_HOME:$PATH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.1 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
"sideEffects": false,
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"scripts": {
|
||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
||||
"dev": "vite dev",
|
||||
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
@ -51,6 +52,7 @@
|
||||
"dependencies": {
|
||||
"@better-auth/oauth-provider": "^1.5.5",
|
||||
"@every-app/sdk": "^0.1.14",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@tanstack/query-core": "^5.90.9",
|
||||
"@tanstack/react-form": "^1.25.0",
|
||||
"@tanstack/react-query": "^5.90.9",
|
||||
@ -58,6 +60,7 @@
|
||||
"@tanstack/react-router-devtools": "^1.166.11",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"agents": "0.12.3",
|
||||
"autumn-js": "^1.1.7",
|
||||
"better-auth": "^1.5.5",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
1271
pnpm-lock.yaml
generated
1271
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,10 +1,29 @@
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { jwt, organization } from "better-auth/plugins";
|
||||
import { baseAuthOptions } from "@/lib/auth-options";
|
||||
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
|
||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||
import {
|
||||
getMcpOrganizationIdClaim,
|
||||
getMcpResource,
|
||||
MCP_SCOPE,
|
||||
} from "@/lib/oauth-resource";
|
||||
|
||||
const MCP_OAUTH_SCOPES = ["offline_access", MCP_SCOPE];
|
||||
|
||||
function assertSingleMcpAudience(audiences: string[]) {
|
||||
if (audiences.length !== 1) {
|
||||
throw new Error(
|
||||
"MCP OAuth resource injection requires exactly one valid audience",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBaseAuthConfig(baseUrl: string) {
|
||||
const mcpResource = getMcpResource(baseUrl);
|
||||
const mcpOrganizationIdClaim = getMcpOrganizationIdClaim(baseUrl);
|
||||
const validAudiences = [mcpResource];
|
||||
|
||||
assertSingleMcpAudience(validAudiences);
|
||||
|
||||
return {
|
||||
...baseAuthOptions,
|
||||
@ -17,12 +36,41 @@ export function createBaseAuthConfig(baseUrl: string) {
|
||||
signup: {
|
||||
page: "/sign-up",
|
||||
},
|
||||
scopes: ["offline_access", MCP_SCOPE],
|
||||
scopes: MCP_OAUTH_SCOPES,
|
||||
// We publish /.well-known/oauth-authorization-server/api/auth via
|
||||
// TanStack routes, so silence Better Auth's metadata reminder.
|
||||
silenceWarnings: {
|
||||
oauthAuthServerConfig: true,
|
||||
},
|
||||
allowDynamicClientRegistration: true,
|
||||
clientRegistrationDefaultScopes: MCP_OAUTH_SCOPES,
|
||||
clientRegistrationAllowedScopes: MCP_OAUTH_SCOPES,
|
||||
// TODO: drop once the MCP spec settles on a replacement for
|
||||
// unauthenticated DCR — better-auth has flagged this option for removal.
|
||||
allowUnauthenticatedClientRegistration: true,
|
||||
validAudiences: [mcpResource],
|
||||
// Single allowed audience — see `routes/api/auth/$.ts`, which defaults
|
||||
// missing `resource` on /oauth2/token to this value. Adding a second
|
||||
// audience here would make that injection unsafe (we'd no longer know
|
||||
// which to pick) and require scope-conditional logic in the route.
|
||||
validAudiences,
|
||||
postLogin: {
|
||||
page: "/oauth-consent",
|
||||
shouldRedirect: () => false,
|
||||
consentReferenceId: ({ session, scopes }) => {
|
||||
if (!scopes.includes(MCP_SCOPE)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return getActiveOrganizationId({ session }) ?? undefined;
|
||||
},
|
||||
},
|
||||
customAccessTokenClaims: ({ referenceId, scopes }) => {
|
||||
if (!scopes.includes(MCP_SCOPE)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return referenceId ? { [mcpOrganizationIdClaim]: referenceId } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@ -30,6 +30,9 @@ function createAuth() {
|
||||
const auth = betterAuth({
|
||||
baseURL: baseUrl,
|
||||
secret: getHostedSecret(),
|
||||
// Disable Better Auth's generic /token endpoint so OAuth access tokens only
|
||||
// flow through /oauth2/token, where the MCP resource shim can run.
|
||||
disabledPaths: ["/token"],
|
||||
...baseAuthConfig,
|
||||
emailAndPassword: {
|
||||
...baseAuthConfig.emailAndPassword,
|
||||
|
||||
14
src/lib/oauth-provider-resource-client.ts
Normal file
14
src/lib/oauth-provider-resource-client.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
|
||||
type ResourceClientAuth = Parameters<typeof oauthProviderResourceClient>[0];
|
||||
|
||||
export function getOAuthProviderResourceActions() {
|
||||
// Better Auth documents passing the server auth instance here, but the
|
||||
// resource-client package currently types the generic too narrowly for the
|
||||
// concrete `betterAuth(...)` return type.
|
||||
return oauthProviderResourceClient(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
getAuth() as unknown as ResourceClientAuth,
|
||||
).getActions();
|
||||
}
|
||||
@ -4,3 +4,19 @@ export const MCP_SCOPE = "mcp";
|
||||
export function getMcpResource(baseUrl: string) {
|
||||
return new URL(MCP_RESOURCE_PATH, baseUrl).toString();
|
||||
}
|
||||
|
||||
export function getMcpOrganizationIdClaim(baseUrl: string) {
|
||||
return new URL(
|
||||
`${MCP_RESOURCE_PATH}/claims/organization-id`,
|
||||
baseUrl,
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getMcpProtectedResourceMetadataUrl(resource: string) {
|
||||
const url = new URL(resource);
|
||||
const pathname = url.pathname.endsWith("/")
|
||||
? url.pathname.slice(0, -1)
|
||||
: url.pathname;
|
||||
|
||||
return `${url.origin}/.well-known/oauth-protected-resource${pathname}`;
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
|
||||
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/$'
|
||||
@ -40,6 +41,7 @@ import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_proj
|
||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
||||
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.]well-known/oauth-authorization-server/api/auth'
|
||||
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
||||
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
||||
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
||||
@ -117,6 +119,12 @@ const AppBillingRoute = AppBillingRouteImport.update({
|
||||
path: '/billing',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const DotwellKnownOpenidConfigurationRoute =
|
||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||
id: '/.well-known/openid-configuration',
|
||||
path: '/.well-known/openid-configuration',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerRoute =
|
||||
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
||||
id: '/.well-known/oauth-authorization-server',
|
||||
@ -204,6 +212,12 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({
|
||||
path: '/ai',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
|
||||
id: '/api/auth',
|
||||
path: '/api/auth',
|
||||
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdRankTrackingIndexRoute =
|
||||
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
||||
id: '/',
|
||||
@ -234,7 +248,8 @@ export interface FileRoutesByFullPath {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
@ -247,6 +262,7 @@ export interface FileRoutesByFullPath {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -267,7 +283,8 @@ export interface FileRoutesByTo {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
@ -279,6 +296,7 @@ export interface FileRoutesByTo {
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -301,7 +319,8 @@ export interface FileRoutesById {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/_app/billing': typeof AppBillingRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/support': typeof AppSupportRoute
|
||||
@ -315,6 +334,7 @@ export interface FileRoutesById {
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -338,6 +358,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
@ -350,6 +371,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
@ -371,6 +393,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
@ -382,6 +405,7 @@ export interface FileRouteTypes {
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
@ -404,6 +428,7 @@ export interface FileRouteTypes {
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/_app/billing'
|
||||
| '/_app/settings'
|
||||
| '/_app/support'
|
||||
@ -417,6 +442,7 @@ export interface FileRouteTypes {
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/_project/p/$projectId/ai'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
@ -441,7 +467,8 @@ export interface RootRouteChildren {
|
||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||
@ -554,6 +581,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppBillingRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/.well-known/openid-configuration': {
|
||||
id: '/.well-known/openid-configuration'
|
||||
path: '/.well-known/openid-configuration'
|
||||
fullPath: '/.well-known/openid-configuration'
|
||||
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/.well-known/oauth-authorization-server': {
|
||||
id: '/.well-known/oauth-authorization-server'
|
||||
path: '/.well-known/oauth-authorization-server'
|
||||
@ -666,6 +700,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/.well-known/oauth-authorization-server/api/auth': {
|
||||
id: '/.well-known/oauth-authorization-server/api/auth'
|
||||
path: '/api/auth'
|
||||
fullPath: '/.well-known/oauth-authorization-server/api/auth'
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
|
||||
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
}
|
||||
'/_project/p/$projectId/rank-tracking/': {
|
||||
id: '/_project/p/$projectId/rank-tracking/'
|
||||
path: '/'
|
||||
@ -823,6 +864,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
AuthenticatedRouteChildren,
|
||||
)
|
||||
|
||||
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
||||
{
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute:
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute,
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
||||
DotwellKnownOauthAuthorizationServerRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AppRouteRoute: AppRouteRouteWithChildren,
|
||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||
@ -832,7 +888,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
VerifyEmailRoute: VerifyEmailRoute,
|
||||
DotwellKnownOauthAuthorizationServerRoute:
|
||||
DotwellKnownOauthAuthorizationServerRoute,
|
||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
||||
DotwellKnownOauthProtectedResourceMcpRoute:
|
||||
DotwellKnownOauthProtectedResourceMcpRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
function unavailableMetadataResponse() {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response("Missing Better Auth hosted configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/.well-known/oauth-authorization-server/api/auth",
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
||||
return unavailableMetadataResponse();
|
||||
}
|
||||
|
||||
return oauthProviderAuthServerMetadata(getAuth())(request);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client";
|
||||
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
|
||||
|
||||
function unavailableMetadataResponse() {
|
||||
@ -25,13 +26,13 @@ export const Route = createFileRoute(
|
||||
}
|
||||
|
||||
const baseUrl = getHostedBaseUrl();
|
||||
const authServerMetadata = await getAuth().api.getOAuthServerConfig();
|
||||
const metadata = {
|
||||
const metadata =
|
||||
await getOAuthProviderResourceActions().getProtectedResourceMetadata({
|
||||
resource: getMcpResource(baseUrl),
|
||||
authorization_servers: [authServerMetadata.issuer],
|
||||
authorization_servers: [`${baseUrl}/api/auth`],
|
||||
scopes_supported: [MCP_SCOPE],
|
||||
resource_name: "OpenSEO MCP",
|
||||
};
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(metadata), {
|
||||
headers: {
|
||||
|
||||
29
src/routes/[.]well-known/openid-configuration.ts
Normal file
29
src/routes/[.]well-known/openid-configuration.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
function unavailableMetadataResponse() {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response("Missing Better Auth hosted configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
||||
return unavailableMetadataResponse();
|
||||
}
|
||||
|
||||
return oauthProviderOpenIdConfigMetadata(getAuth())(request);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,16 +1,52 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Check, Database, KeyRound, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { getOAuthClientInfo } from "@/serverFunctions/oauth";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/oauth-consent")({
|
||||
component: OAuthConsentPage,
|
||||
});
|
||||
|
||||
const SCOPES = [
|
||||
{
|
||||
icon: Database,
|
||||
label: "Read your OpenSEO data",
|
||||
description: "Projects, keyword reports, and audit results.",
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
label: "Act on your behalf via MCP",
|
||||
description: "Run tools and write results back to your workspace.",
|
||||
},
|
||||
];
|
||||
|
||||
function OAuthConsentPage() {
|
||||
const { data: session } = useSession();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const clientId =
|
||||
typeof window !== "undefined"
|
||||
? new URLSearchParams(window.location.search).get("client_id")
|
||||
: null;
|
||||
|
||||
const clientInfoQuery = useQuery({
|
||||
queryKey: ["oauth-client-info", clientId],
|
||||
queryFn: () =>
|
||||
clientId
|
||||
? getOAuthClientInfo({ data: { clientId } })
|
||||
: Promise.resolve(null),
|
||||
enabled: Boolean(clientId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const clientName = clientInfoQuery.data?.name ?? null;
|
||||
const userEmail = session?.user?.email ?? null;
|
||||
const isLoadingClient = clientInfoQuery.isLoading;
|
||||
const named = Boolean(clientName);
|
||||
|
||||
async function respond(accept: boolean) {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
@ -35,29 +71,85 @@ function OAuthConsentPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm space-y-5">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="mx-auto flex size-12 items-center justify-center rounded-lg bg-base-200">
|
||||
<ShieldCheck className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Authorize MCP access</h1>
|
||||
<div className="w-full max-w-md rounded-2xl border border-base-300 bg-base-100 p-8 shadow-sm">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<img
|
||||
src="/transparent-logo.png"
|
||||
alt="OpenSEO"
|
||||
className="size-10 rounded-lg"
|
||||
/>
|
||||
{isLoadingClient ? (
|
||||
<div className="mt-5 h-7 w-48 animate-pulse rounded-md bg-base-200" />
|
||||
) : (
|
||||
<h1 className="mt-5 text-xl font-semibold">
|
||||
{named ? (
|
||||
<>
|
||||
Authorize <span className="text-primary">{clientName}</span>
|
||||
</>
|
||||
) : (
|
||||
"Authorize MCP access"
|
||||
)}
|
||||
</h1>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-base-content/70">
|
||||
Allow this MCP client to access your OpenSEO workspace.
|
||||
{named
|
||||
? `${clientName} is requesting access to your OpenSEO workspace.`
|
||||
: "An MCP client is requesting access to your OpenSEO workspace."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!named && !isLoadingClient ? (
|
||||
<div className="mt-5 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning-content/90">
|
||||
This client did not provide a name during registration. Only continue
|
||||
if you started this connection yourself.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{userEmail ? (
|
||||
<div className="mt-6 flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/50 px-3 py-2 text-sm">
|
||||
<div className="flex size-7 items-center justify-center rounded-full bg-base-300">
|
||||
<User className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-base-content/60">Signed in as</div>
|
||||
<div className="font-medium">{userEmail}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-base-content/60">
|
||||
{named ? `This will allow ${clientName} to` : "This will allow it to"}
|
||||
</div>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{SCOPES.map((scope) => (
|
||||
<li key={scope.label} className="flex gap-3">
|
||||
<Check className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{scope.label}</div>
|
||||
<div className="text-xs text-base-content/60">
|
||||
{scope.description}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-error">{error}</p> : null}
|
||||
{error ? (
|
||||
<div className="mt-6 rounded-lg border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="mt-8 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost flex-1"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => void respond(false)}
|
||||
>
|
||||
Deny
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@ -68,6 +160,10 @@ function OAuthConsentPage() {
|
||||
{isSubmitting ? "Authorizing..." : "Authorize"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-base-content/50">
|
||||
You can revoke access at any time in Settings.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
83
src/routes/api/auth/$.test.ts
Normal file
83
src/routes/api/auth/$.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
env: {
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (routeConfig: unknown) => routeConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuth: () => ({ handler: vi.fn() }),
|
||||
getHostedBaseUrl: () => "https://open-seo.test",
|
||||
hasHostedAuthConfig: () => true,
|
||||
}));
|
||||
|
||||
describe("maybeInjectMcpResource", () => {
|
||||
it("injects the MCP resource into form token requests when missing", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: "code_123",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await maybeInjectMcpResource(request);
|
||||
const params = new URLSearchParams(await result.text());
|
||||
|
||||
expect(params.get("resource")).toBe("https://open-seo.test/mcp");
|
||||
expect(params.get("grant_type")).toBe("authorization_code");
|
||||
expect(params.get("code")).toBe("code_123");
|
||||
});
|
||||
|
||||
it("leaves token requests alone when a resource is already present", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
resource: "https://other-resource.test/mcp",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
||||
});
|
||||
|
||||
it("skips requests that are not matching form POST token requests", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const requests = [
|
||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "GET",
|
||||
}),
|
||||
new Request("https://open-seo.test/api/auth/oauth2/authorize", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: "authorization_code" }),
|
||||
}),
|
||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ grant_type: "authorization_code" }),
|
||||
}),
|
||||
];
|
||||
|
||||
for (const request of requests) {
|
||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,48 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { getMcpResource } from "@/lib/oauth-resource";
|
||||
|
||||
function handleAuthRequest(request: Request) {
|
||||
const TOKEN_PATH = "/api/auth/oauth2/token";
|
||||
|
||||
// Inject RFC 8707 `resource` into /oauth2/token requests when the client
|
||||
// omitted it. Some MCP clients (notably codex as of 2026-05) skip the
|
||||
// resource indicator, which makes better-auth issue an opaque access token
|
||||
// (see `checkResource` in @better-auth/oauth-provider — audience comes from
|
||||
// `ctx.body.resource` at token-issuance time, not from the stored authorize
|
||||
// query). Without an audience to bind, no `aud` claim → opaque token → no
|
||||
// local JWT verify on the resource side.
|
||||
//
|
||||
// We only have one valid audience (`validAudiences: [mcpResource]` in
|
||||
// auth-config.ts), so it is safe to default missing resources to it. Remove
|
||||
// this shim once MCP clients reliably pass `resource` per spec.
|
||||
export async function maybeInjectMcpResource(
|
||||
request: Request,
|
||||
): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== TOKEN_PATH) return request;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/x-www-form-urlencoded"))
|
||||
return request;
|
||||
|
||||
const body = await request.clone().text();
|
||||
const params = new URLSearchParams(body);
|
||||
if (params.has("resource")) return request;
|
||||
|
||||
params.set("resource", getMcpResource(getHostedBaseUrl()));
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthRequest(request: Request) {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
@ -17,7 +56,7 @@ function handleAuthRequest(request: Request) {
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
return auth.handler(request);
|
||||
return auth.handler(await maybeInjectMcpResource(request));
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
|
||||
@ -7,8 +7,20 @@ import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rank
|
||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||
import { handleMcpRequest, MCP_ROUTE } from "@/server/mcp/handler";
|
||||
|
||||
const fetch = createStartHandler(defaultStreamHandler);
|
||||
const appFetch = createStartHandler(defaultStreamHandler);
|
||||
const fetch = (
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
): Response | Promise<Response> => {
|
||||
if (new URL(request.url).pathname === MCP_ROUTE) {
|
||||
return handleMcpRequest(request, env, ctx);
|
||||
}
|
||||
|
||||
return appFetch(request);
|
||||
};
|
||||
|
||||
// Export Workflow classes as named exports
|
||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||
|
||||
26
src/server/mcp/context.ts
Normal file
26
src/server/mcp/context.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { getMcpAuthContext } from "agents/mcp";
|
||||
import { z } from "zod";
|
||||
|
||||
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
|
||||
|
||||
const mcpToolAuthContextSchema = z.object({
|
||||
userId: 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),
|
||||
});
|
||||
|
||||
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
|
||||
|
||||
export function requireMcpToolAuthContext(): McpToolAuthContext {
|
||||
const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP];
|
||||
const result = mcpToolAuthContextSchema.safeParse(rawContext);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(`MCP auth context missing: ${result.error.message}`);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
252
src/server/mcp/handler.test.ts
Normal file
252
src/server/mcp/handler.test.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import type { CreateMcpHandlerOptions } from "agents/mcp";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
|
||||
const verifyMocks = vi.hoisted(() => ({
|
||||
verifyJwsAccessToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const serverMocks = vi.hoisted(() => ({
|
||||
nextServerId: 0,
|
||||
createdServerIds: [] as number[],
|
||||
serverIds: new WeakMap<McpServer, number>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuth: () => ({ api: { getJwks: vi.fn() } }),
|
||||
getHostedBaseUrl: () => "https://open-seo.test",
|
||||
hasHostedAuthConfig: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("better-auth/oauth2", () => ({
|
||||
verifyJwsAccessToken: verifyMocks.verifyJwsAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/mcp/server", () => ({
|
||||
createOpenSeoMcpServer: () => {
|
||||
serverMocks.nextServerId += 1;
|
||||
const server = new McpServer({ name: "Test MCP", version: "0.0.0" });
|
||||
serverMocks.createdServerIds.push(serverMocks.nextServerId);
|
||||
serverMocks.serverIds.set(server, serverMocks.nextServerId);
|
||||
return server;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("agents/mcp", () => ({
|
||||
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
|
||||
return async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
serverId: serverMocks.serverIds.get(_server),
|
||||
options,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
const ctx: ExecutionContext = {
|
||||
waitUntil() {},
|
||||
passThroughOnException() {},
|
||||
props: {},
|
||||
};
|
||||
|
||||
const transportOptionsSchema = z.object({
|
||||
serverId: z.number().optional(),
|
||||
options: z.object({
|
||||
route: z.string().optional(),
|
||||
enableJsonResponse: z.boolean().optional(),
|
||||
authContext: z
|
||||
.object({
|
||||
props: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
function createMcpRequest(token: string) {
|
||||
return new Request("https://open-seo.test/mcp", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/list",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const jwtShapedToken = "header.payload.signature";
|
||||
const organizationIdClaim = "https://open-seo.test/mcp/claims/organization-id";
|
||||
|
||||
function createAccessTokenPayload(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sub: "user_123",
|
||||
azp: "client_123",
|
||||
scope: "offline_access mcp",
|
||||
aud: "https://open-seo.test/mcp",
|
||||
[organizationIdClaim]: "org_123",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleMcpRequest", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
serverMocks.nextServerId = 0;
|
||||
serverMocks.createdServerIds = [];
|
||||
serverMocks.serverIds = new WeakMap<McpServer, number>();
|
||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
||||
createAccessTokenPayload(),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts access tokens verified by Better Auth", async () => {
|
||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const body = transportOptionsSchema.parse(await response.json());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP],
|
||||
).toMatchObject({
|
||||
userId: "user_123",
|
||||
organizationId: "org_123",
|
||||
clientId: "client_123",
|
||||
scopes: ["offline_access", "mcp"],
|
||||
});
|
||||
expect(body.options.route).toBe("/mcp");
|
||||
expect(body.options.enableJsonResponse).toBe(true);
|
||||
|
||||
const functionMatcher: unknown = expect.any(Function);
|
||||
expect(verifyMocks.verifyJwsAccessToken).toHaveBeenCalledWith(
|
||||
jwtShapedToken,
|
||||
expect.objectContaining({
|
||||
verifyOptions: {
|
||||
audience: "https://open-seo.test/mcp",
|
||||
issuer: "https://open-seo.test/api/auth",
|
||||
},
|
||||
jwksFetch: functionMatcher,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a fresh server for each request without persisted transport state", async () => {
|
||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
||||
|
||||
const first = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const second = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
const firstBody = transportOptionsSchema.parse(await first.json());
|
||||
const secondBody = transportOptionsSchema.parse(await second.json());
|
||||
|
||||
expect(serverMocks.createdServerIds).toEqual([1, 2]);
|
||||
expect(firstBody.serverId).toBe(1);
|
||||
expect(secondBody.serverId).toBe(2);
|
||||
expect(firstBody.options).not.toHaveProperty("sessionIdGenerator");
|
||||
expect(firstBody.options).not.toHaveProperty("storage");
|
||||
expect(firstBody.options).not.toHaveProperty("transport");
|
||||
});
|
||||
|
||||
it("lets the MCP transport handle OPTIONS without token verification", async () => {
|
||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
new Request("https://open-seo.test/mcp", { method: "OPTIONS" }),
|
||||
{
|
||||
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(
|
||||
new Error("invalid audience"),
|
||||
);
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.headers.get("WWW-Authenticate")).toBe(
|
||||
'Bearer resource_metadata="https://open-seo.test/.well-known/oauth-protected-resource/mcp"',
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 403 when the verified token is missing MCP organization context", async () => {
|
||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
||||
createAccessTokenPayload({
|
||||
[organizationIdClaim]: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 401 when the token is missing the required mcp scope", async () => {
|
||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
||||
createAccessTokenPayload({ scope: "offline_access" }),
|
||||
);
|
||||
|
||||
const response = await handleMcpRequest(
|
||||
createMcpRequest(jwtShapedToken),
|
||||
{
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
150
src/server/mcp/handler.ts
Normal file
150
src/server/mcp/handler.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { createMcpHandler } from "agents/mcp";
|
||||
import { verifyJwsAccessToken } from "better-auth/oauth2";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import {
|
||||
getMcpOrganizationIdClaim,
|
||||
getMcpProtectedResourceMetadataUrl,
|
||||
getMcpResource,
|
||||
MCP_SCOPE,
|
||||
} from "@/lib/oauth-resource";
|
||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||
import { createOpenSeoMcpServer } from "@/server/mcp/server";
|
||||
|
||||
// MCP request flow:
|
||||
// 1. Resource (`resource=<mcp>`) is injected into /oauth2/token requests by
|
||||
// `routes/api/auth/$.ts` so Better Auth always issues audience-bound JWTs
|
||||
// (some MCP clients skip RFC 8707; without it tokens would be opaque).
|
||||
// 2. Here we verify the JWT in-process via `verifyJwsAccessToken`, reading
|
||||
// the JWKS through `auth.api.getJwks()` rather than HTTP self-fetching
|
||||
// `/api/auth/jwks` (which 500s under workerd dev's self-routing and is
|
||||
// pointless in prod since the auth server and resource server are the
|
||||
// same Worker).
|
||||
// 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`)
|
||||
// and `aud = mcpResource`, both confirmed against the published
|
||||
// /.well-known/oauth-authorization-server metadata.
|
||||
|
||||
export const MCP_ROUTE = "/mcp";
|
||||
|
||||
type McpAccessTokenPayload = JWTPayload & {
|
||||
azp?: unknown;
|
||||
client_id?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
|
||||
function getTokenScopes(payload: McpAccessTokenPayload) {
|
||||
return typeof payload.scope === "string"
|
||||
? payload.scope.split(/\s+/).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
function getStringClaim(payload: Record<string, unknown>, claim: string) {
|
||||
const value = payload[claim];
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function unauthorizedResponse(resource: string) {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${getMcpProtectedResourceMetadataUrl(
|
||||
resource,
|
||||
)}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleMcpRequest(
|
||||
request: Request,
|
||||
env: { AUTH_MODE?: unknown },
|
||||
ctx: ExecutionContext,
|
||||
) {
|
||||
const authMode =
|
||||
typeof env.AUTH_MODE === "string" ? env.AUTH_MODE : undefined;
|
||||
|
||||
if (!isHostedAuthMode(authMode)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (!hasHostedAuthConfig()) {
|
||||
return new Response("Missing Better Auth hosted configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = getHostedBaseUrl();
|
||||
const auth = getAuth();
|
||||
const mcpResource = getMcpResource(baseUrl);
|
||||
const issuer = `${baseUrl}/api/auth`;
|
||||
const organizationIdClaim = getMcpOrganizationIdClaim(baseUrl);
|
||||
const server = createOpenSeoMcpServer();
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return createMcpHandler(server, {
|
||||
route: MCP_ROUTE,
|
||||
enableJsonResponse: true,
|
||||
})(request, env, ctx);
|
||||
}
|
||||
|
||||
const accessToken =
|
||||
request.headers
|
||||
.get("Authorization")
|
||||
?.replace(/^Bearer\s+/i, "")
|
||||
.trim() || undefined;
|
||||
|
||||
let payload: McpAccessTokenPayload;
|
||||
try {
|
||||
if (!accessToken) throw new Error("missing access token");
|
||||
payload = await verifyJwsAccessToken(accessToken, {
|
||||
jwksFetch: () => auth.api.getJwks(),
|
||||
verifyOptions: { audience: mcpResource, issuer },
|
||||
});
|
||||
} catch {
|
||||
return unauthorizedResponse(mcpResource);
|
||||
}
|
||||
|
||||
const scopes = getTokenScopes(payload);
|
||||
if (!scopes.includes(MCP_SCOPE)) {
|
||||
return unauthorizedResponse(mcpResource);
|
||||
}
|
||||
|
||||
const userId = getStringClaim(payload, "sub");
|
||||
const organizationId = getStringClaim(payload, organizationIdClaim);
|
||||
const clientId =
|
||||
getStringClaim(payload, "azp") ?? getStringClaim(payload, "client_id");
|
||||
|
||||
if (!userId || !organizationId) {
|
||||
return new Response(
|
||||
userId
|
||||
? "MCP organization context required"
|
||||
: "MCP user context required",
|
||||
{
|
||||
status: 403,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return createMcpHandler(server, {
|
||||
route: MCP_ROUTE,
|
||||
enableJsonResponse: true,
|
||||
authContext: {
|
||||
props: {
|
||||
[MCP_AUTH_CONTEXT_PROP]: {
|
||||
userId,
|
||||
organizationId,
|
||||
clientId,
|
||||
scopes,
|
||||
audience: mcpResource,
|
||||
subject: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
})(request, env, ctx);
|
||||
}
|
||||
63
src/server/mcp/server.ts
Normal file
63
src/server/mcp/server.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||
import { requireMcpToolAuthContext } from "@/server/mcp/context";
|
||||
|
||||
function jsonToolResult(data: Record<string, unknown>) {
|
||||
return {
|
||||
structuredContent: data,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenSeoMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: "OpenSEO MCP",
|
||||
version: "0.0.10",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"whoami",
|
||||
{
|
||||
title: "Who am I",
|
||||
description: "Return the verified OpenSEO user and organization context.",
|
||||
},
|
||||
async () => {
|
||||
const auth = requireMcpToolAuthContext();
|
||||
|
||||
return jsonToolResult({
|
||||
userId: auth.userId,
|
||||
activeOrganizationId: auth.organizationId,
|
||||
account: {
|
||||
clientId: auth.clientId,
|
||||
scopes: auth.scopes,
|
||||
audience: auth.audience,
|
||||
subject: auth.subject,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_projects",
|
||||
{
|
||||
title: "List projects",
|
||||
description: "List projects in the verified OpenSEO organization.",
|
||||
},
|
||||
async () => {
|
||||
const auth = requireMcpToolAuthContext();
|
||||
const projects = await ProjectService.listProjects(auth.organizationId);
|
||||
|
||||
return jsonToolResult({
|
||||
activeOrganizationId: auth.organizationId,
|
||||
projects,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
33
src/serverFunctions/oauth.ts
Normal file
33
src/serverFunctions/oauth.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { oauthClient } from "@/db/better-auth-schema";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
const getOAuthClientInfoSchema = z.object({
|
||||
clientId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const getOAuthClientInfo = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => getOAuthClientInfoSchema.parse(data))
|
||||
.handler(async ({ data }) => {
|
||||
const row = await db
|
||||
.select({
|
||||
name: oauthClient.name,
|
||||
icon: oauthClient.icon,
|
||||
uri: oauthClient.uri,
|
||||
})
|
||||
.from(oauthClient)
|
||||
.where(eq(oauthClient.clientId, data.clientId))
|
||||
.get();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
name: row.name ?? null,
|
||||
icon: row.icon ?? null,
|
||||
uri: row.uri ?? null,
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user