Add stateless MCP server (#162)

This commit is contained in:
Ben Senescu 2026-05-07 23:38:00 -04:00 committed by GitHub
parent 6231424e88
commit 0ff7bc96b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 2240 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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();
}

View File

@ -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}`;
}

View File

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

View File

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

View File

@ -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 = {
resource: getMcpResource(baseUrl),
authorization_servers: [authServerMetadata.issuer],
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP",
};
const metadata =
await getOAuthProviderResourceActions().getProtectedResourceMetadata({
resource: getMcpResource(baseUrl),
authorization_servers: [`${baseUrl}/api/auth`],
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP",
});
return new Response(JSON.stringify(metadata), {
headers: {

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

View File

@ -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>
<p className="mt-2 text-sm text-base-content/70">
Allow this MCP client to access your OpenSEO workspace.
</p>
</div>
<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">
{named
? `${clientName} is requesting access to your OpenSEO workspace.`
: "An MCP client is requesting access to your OpenSEO workspace."}
</p>
</div>
{error ? <p className="text-sm text-error">{error}</p> : null}
{!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}
<div className="flex gap-2">
{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 ? (
<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="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>
);
}

View 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);
}
});
});

View File

@ -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/$")({

View File

@ -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
View 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;
}

View 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
View 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
View 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;
}

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