Merge pull request #163 from bensenescu/feat/mcp

feature: implement mcp server
This commit is contained in:
Ben Senescu 2026-05-08 00:35:10 -04:00 committed by GitHub
commit 04497dd90e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 6456 additions and 44 deletions

View File

@ -22,8 +22,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@ -20,8 +20,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:

View File

@ -6,7 +6,7 @@ ENV PATH=$PNPM_HOME:$PATH
WORKDIR /app WORKDIR /app
RUN corepack enable RUN corepack enable && corepack prepare pnpm@10.30.1 --activate
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile

View File

@ -1,11 +1,12 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { baseAuthConfig } from "./src/lib/auth-config"; import { createBaseAuthConfig } from "./src/lib/auth-config";
const CLI_DEV_BASE_URL = "http://localhost:3000"; const CLI_DEV_BASE_URL = "http://localhost:3000";
const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
export const auth = betterAuth({ export const auth = betterAuth({
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL, baseURL: baseUrl,
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(), secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
...baseAuthConfig, ...createBaseAuthConfig(baseUrl),
}); });

View File

@ -0,0 +1,89 @@
CREATE TABLE `jwks` (
`id` text PRIMARY KEY NOT NULL,
`public_key` text NOT NULL,
`private_key` text NOT NULL,
`created_at` integer NOT NULL,
`expires_at` integer
);
--> statement-breakpoint
CREATE TABLE `oauth_access_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text,
`reference_id` text,
`refresh_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint
CREATE TABLE `oauth_client` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`client_secret` text,
`disabled` integer DEFAULT false,
`skip_consent` integer,
`enable_end_session` integer,
`subject_type` text,
`scopes` text,
`user_id` text,
`created_at` integer,
`updated_at` integer,
`name` text,
`uri` text,
`icon` text,
`contacts` text,
`tos` text,
`policy` text,
`software_id` text,
`software_version` text,
`software_statement` text,
`redirect_uris` text NOT NULL,
`post_logout_redirect_uris` text,
`token_endpoint_auth_method` text,
`grant_types` text,
`response_types` text,
`public` integer,
`type` text,
`require_pkce` integer,
`reference_id` text,
`metadata` text,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint
CREATE TABLE `oauth_consent` (
`id` text PRIMARY KEY NOT NULL,
`client_id` text NOT NULL,
`user_id` text,
`reference_id` text,
`scopes` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `oauth_refresh_token` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`client_id` text NOT NULL,
`session_id` text,
`user_id` text NOT NULL,
`reference_id` text,
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`revoked` integer,
`auth_time` integer,
`scopes` text NOT NULL,
FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);

File diff suppressed because it is too large Load Diff

View File

@ -85,6 +85,13 @@
"when": 1778031161783, "when": 1778031161783,
"tag": "0011_colorful_dark_beast", "tag": "0011_colorful_dark_beast",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1778113978173,
"tag": "0012_closed_impossible_man",
"breakpoints": true
} }
] ]
} }

View File

@ -11,6 +11,7 @@
"drizzle.config.ts", "drizzle.config.ts",
// DB schema exports consumed via `import * as schema` / drizzle() // DB schema exports consumed via `import * as schema` / drizzle()
"src/db/index.ts", "src/db/index.ts",
"src/db/app.schema.ts",
"src/db/better-auth-schema.ts", "src/db/better-auth-schema.ts",
], ],
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"], "project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],

View File

@ -4,8 +4,9 @@
"sideEffects": false, "sideEffects": false,
"version": "0.0.10", "version": "0.0.10",
"type": "module", "type": "module",
"packageManager": "pnpm@10.30.1",
"scripts": { "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": "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", "dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
"build": "vite build && tsc --noEmit", "build": "vite build && tsc --noEmit",
@ -49,7 +50,9 @@
} }
}, },
"dependencies": { "dependencies": {
"@better-auth/oauth-provider": "^1.5.5",
"@every-app/sdk": "^0.1.14", "@every-app/sdk": "^0.1.14",
"@modelcontextprotocol/sdk": "1.29.0",
"@tanstack/query-core": "^5.90.9", "@tanstack/query-core": "^5.90.9",
"@tanstack/react-form": "^1.25.0", "@tanstack/react-form": "^1.25.0",
"@tanstack/react-query": "^5.90.9", "@tanstack/react-query": "^5.90.9",
@ -57,6 +60,7 @@
"@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-start": "^1.167.16", "@tanstack/react-start": "^1.167.16",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"agents": "0.12.3",
"autumn-js": "^1.1.7", "autumn-js": "^1.1.7",
"better-auth": "^1.5.5", "better-auth": "^1.5.5",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",

1287
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -151,18 +151,116 @@ export const invitation = sqliteTable(
], ],
); );
export const jwks = sqliteTable("jwks", {
id: text("id").primaryKey(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
});
export const oauthClient = sqliteTable("oauth_client", {
id: text("id").primaryKey(),
clientId: text("client_id").notNull().unique(),
clientSecret: text("client_secret"),
disabled: integer("disabled", { mode: "boolean" }).default(false),
skipConsent: integer("skip_consent", { mode: "boolean" }),
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
subjectType: text("subject_type"),
scopes: text("scopes", { mode: "json" }),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }),
name: text("name"),
uri: text("uri"),
icon: text("icon"),
contacts: text("contacts", { mode: "json" }),
tos: text("tos"),
policy: text("policy"),
softwareId: text("software_id"),
softwareVersion: text("software_version"),
softwareStatement: text("software_statement"),
redirectUris: text("redirect_uris", { mode: "json" }).notNull(),
postLogoutRedirectUris: text("post_logout_redirect_uris", { mode: "json" }),
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
grantTypes: text("grant_types", { mode: "json" }),
responseTypes: text("response_types", { mode: "json" }),
public: integer("public", { mode: "boolean" }),
type: text("type"),
requirePKCE: integer("require_pkce", { mode: "boolean" }),
referenceId: text("reference_id"),
metadata: text("metadata", { mode: "json" }),
});
export const oauthRefreshToken = sqliteTable("oauth_refresh_token", {
id: text("id").primaryKey(),
token: text("token").notNull(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: text("session_id").references(() => session.id, {
onDelete: "set null",
}),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
revoked: integer("revoked", { mode: "timestamp_ms" }),
authTime: integer("auth_time", { mode: "timestamp_ms" }),
scopes: text("scopes", { mode: "json" }).notNull(),
});
export const oauthAccessToken = sqliteTable("oauth_access_token", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: text("session_id").references(() => session.id, {
onDelete: "set null",
}),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
refreshId: text("refresh_id").references(() => oauthRefreshToken.id, {
onDelete: "cascade",
}),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
scopes: text("scopes", { mode: "json" }).notNull(),
});
export const oauthConsent = sqliteTable("oauth_consent", {
id: text("id").primaryKey(),
clientId: text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: text("reference_id"),
scopes: text("scopes", { mode: "json" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
sessions: many(session), sessions: many(session),
accounts: many(account), accounts: many(account),
members: many(member), members: many(member),
invitations: many(invitation), invitations: many(invitation),
oauthClients: many(oauthClient),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
oauthConsents: many(oauthConsent),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({ one, many }) => ({
user: one(user, { user: one(user, {
fields: [session.userId], fields: [session.userId],
references: [user.id], references: [user.id],
}), }),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({ one }) => ({
@ -198,3 +296,65 @@ export const invitationRelations = relations(invitation, ({ one }) => ({
references: [user.id], references: [user.id],
}), }),
})); }));
export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({
user: one(user, {
fields: [oauthClient.userId],
references: [user.id],
}),
oauthRefreshTokens: many(oauthRefreshToken),
oauthAccessTokens: many(oauthAccessToken),
oauthConsents: many(oauthConsent),
}));
export const oauthRefreshTokenRelations = relations(
oauthRefreshToken,
({ one, many }) => ({
oauthClient: one(oauthClient, {
fields: [oauthRefreshToken.clientId],
references: [oauthClient.clientId],
}),
session: one(session, {
fields: [oauthRefreshToken.sessionId],
references: [session.id],
}),
user: one(user, {
fields: [oauthRefreshToken.userId],
references: [user.id],
}),
oauthAccessTokens: many(oauthAccessToken),
}),
);
export const oauthAccessTokenRelations = relations(
oauthAccessToken,
({ one }) => ({
oauthClient: one(oauthClient, {
fields: [oauthAccessToken.clientId],
references: [oauthClient.clientId],
}),
session: one(session, {
fields: [oauthAccessToken.sessionId],
references: [session.id],
}),
user: one(user, {
fields: [oauthAccessToken.userId],
references: [user.id],
}),
oauthRefreshToken: one(oauthRefreshToken, {
fields: [oauthAccessToken.refreshId],
references: [oauthRefreshToken.id],
}),
}),
);
export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({
oauthClient: one(oauthClient, {
fields: [oauthConsent.clientId],
references: [oauthClient.clientId],
}),
user: one(user, {
fields: [oauthConsent.userId],
references: [user.id],
}),
}));

View File

@ -3,6 +3,7 @@ import {
inferAdditionalFields, inferAdditionalFields,
organizationClient, organizationClient,
} from "better-auth/client/plugins"; } from "better-auth/client/plugins";
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog"; import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
import { userAdditionalFields } from "@/lib/auth-options"; import { userAdditionalFields } from "@/lib/auth-options";
import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { getSignInHrefForLocation } from "@/lib/auth-redirect";
@ -11,6 +12,7 @@ export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "", baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [ plugins: [
organizationClient(), organizationClient(),
oauthProviderClient(),
inferAdditionalFields({ user: userAdditionalFields }), inferAdditionalFields({ user: userAdditionalFields }),
], ],
}); });

View File

@ -1,7 +1,77 @@
import { organization } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider";
import { jwt, organization } from "better-auth/plugins";
import { baseAuthOptions } from "@/lib/auth-options"; import { baseAuthOptions } from "@/lib/auth-options";
import { getActiveOrganizationId } from "@/lib/auth-session";
import {
getMcpOrganizationIdClaim,
getMcpResource,
MCP_SCOPE,
} from "@/lib/oauth-resource";
export const baseAuthConfig = { const MCP_OAUTH_SCOPES = ["offline_access", MCP_SCOPE];
...baseAuthOptions,
plugins: [organization()], 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,
plugins: [
organization(),
jwt(),
oauthProvider({
loginPage: "/sign-in",
consentPage: "/oauth-consent",
signup: {
page: "/sign-up",
},
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,
// 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

@ -4,7 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { tanstackStartCookies } from "better-auth/tanstack-start"; import { tanstackStartCookies } from "better-auth/tanstack-start";
import { db } from "@/db"; import { db } from "@/db";
import { z } from "zod"; import { z } from "zod";
import { baseAuthConfig } from "@/lib/auth-config"; import { createBaseAuthConfig } from "@/lib/auth-config";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization"; import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { import {
sendHostedPasswordResetEmail, sendHostedPasswordResetEmail,
@ -25,10 +25,14 @@ const hostedBaseUrlSchema = z
function createAuth() { function createAuth() {
const baseUrl = getHostedBaseUrl(); const baseUrl = getHostedBaseUrl();
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true"; const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
const baseAuthConfig = createBaseAuthConfig(baseUrl);
const auth = betterAuth({ const auth = betterAuth({
baseURL: baseUrl, baseURL: baseUrl,
secret: getHostedSecret(), 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, ...baseAuthConfig,
emailAndPassword: { emailAndPassword: {
...baseAuthConfig.emailAndPassword, ...baseAuthConfig.emailAndPassword,
@ -102,7 +106,7 @@ function getTrustedOrigins(baseUrl: string) {
return trustedOrigins; return trustedOrigins;
} }
function getHostedBaseUrl() { export function getHostedBaseUrl() {
const baseUrl = env.BETTER_AUTH_URL?.trim(); const baseUrl = env.BETTER_AUTH_URL?.trim();
if (!baseUrl) { if (!baseUrl) {

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

22
src/lib/oauth-resource.ts Normal file
View File

@ -0,0 +1,22 @@
const MCP_RESOURCE_PATH = "/mcp";
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

@ -18,14 +18,18 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
import { Route as AppRouteRouteImport } from './routes/_app/route' import { Route as AppRouteRouteImport } from './routes/_app/route'
import { Route as AppIndexRouteImport } from './routes/_app/index' import { Route as AppIndexRouteImport } from './routes/_app/index'
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe' import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up' import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in' import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
import { Route as AppSupportRouteImport } from './routes/_app/support' import { Route as AppSupportRouteImport } from './routes/_app/support'
import { Route as AppSettingsRouteImport } from './routes/_app/settings' import { Route as AppSettingsRouteImport } from './routes/_app/settings'
import { Route as AppBillingRouteImport } from './routes/_app/billing' 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 ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key' import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route' import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index' import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved' import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
@ -37,6 +41,7 @@ import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_proj
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks' import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit' import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai' 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 ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index' import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId' import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
@ -83,6 +88,12 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
path: '/subscribe', path: '/subscribe',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedOauthConsentRoute =
AuthenticatedOauthConsentRouteImport.update({
id: '/oauth-consent',
path: '/oauth-consent',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthSignUpRoute = AuthSignUpRouteImport.update({ const AuthSignUpRoute = AuthSignUpRouteImport.update({
id: '/sign-up', id: '/sign-up',
path: '/sign-up', path: '/sign-up',
@ -108,6 +119,18 @@ const AppBillingRoute = AppBillingRouteImport.update({
path: '/billing', path: '/billing',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } 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',
path: '/.well-known/oauth-authorization-server',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({ const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
id: '/api/autumn/$', id: '/api/autumn/$',
path: '/api/autumn/$', path: '/api/autumn/$',
@ -123,6 +146,12 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
path: '/help/dataforseo-api-key', path: '/help/dataforseo-api-key',
getParentRoute: () => AppRouteRoute, getParentRoute: () => AppRouteRoute,
} as any) } as any)
const DotwellKnownOauthProtectedResourceMcpRoute =
DotwellKnownOauthProtectedResourceMcpRouteImport.update({
id: '/.well-known/oauth-protected-resource/mcp',
path: '/.well-known/oauth-protected-resource/mcp',
getParentRoute: () => rootRouteImport,
} as any)
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({ const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
id: '/p/$projectId', id: '/p/$projectId',
path: '/p/$projectId', path: '/p/$projectId',
@ -183,6 +212,12 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({
path: '/ai', path: '/ai',
getParentRoute: () => ProjectPProjectIdRouteRoute, getParentRoute: () => ProjectPProjectIdRouteRoute,
} as any) } as any)
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
id: '/api/auth',
path: '/api/auth',
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
} as any)
const ProjectPProjectIdRankTrackingIndexRoute = const ProjectPProjectIdRankTrackingIndexRoute =
ProjectPProjectIdRankTrackingIndexRouteImport.update({ ProjectPProjectIdRankTrackingIndexRouteImport.update({
id: '/', id: '/',
@ -213,16 +248,21 @@ export interface FileRoutesByFullPath {
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute '/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -243,15 +283,20 @@ export interface FileRoutesByTo {
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute '/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/billing': typeof AppBillingRoute '/billing': typeof AppBillingRoute
'/settings': typeof AppSettingsRoute '/settings': typeof AppSettingsRoute
'/support': typeof AppSupportRoute '/support': typeof AppSupportRoute
'/sign-in': typeof AuthSignInRoute '/sign-in': typeof AuthSignInRoute
'/sign-up': typeof AuthSignUpRoute '/sign-up': typeof AuthSignUpRoute
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/subscribe': typeof AuthenticatedSubscribeRoute '/subscribe': typeof AuthenticatedSubscribeRoute
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute '/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
@ -274,17 +319,22 @@ export interface FileRoutesById {
'/forgot-password': typeof ForgotPasswordRoute '/forgot-password': typeof ForgotPasswordRoute
'/reset-password': typeof ResetPasswordRoute '/reset-password': typeof ResetPasswordRoute
'/verify-email': typeof VerifyEmailRoute '/verify-email': typeof VerifyEmailRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
'/_app/billing': typeof AppBillingRoute '/_app/billing': typeof AppBillingRoute
'/_app/settings': typeof AppSettingsRoute '/_app/settings': typeof AppSettingsRoute
'/_app/support': typeof AppSupportRoute '/_app/support': typeof AppSupportRoute
'/_auth/sign-in': typeof AuthSignInRoute '/_auth/sign-in': typeof AuthSignInRoute
'/_auth/sign-up': typeof AuthSignUpRoute '/_auth/sign-up': typeof AuthSignUpRoute
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute '/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren '/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute '/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/api/auth/$': typeof ApiAuthSplatRoute '/api/auth/$': typeof ApiAuthSplatRoute
'/api/autumn/$': typeof ApiAutumnSplatRoute '/api/autumn/$': typeof ApiAutumnSplatRoute
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute '/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren '/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute '/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
@ -307,16 +357,21 @@ export interface FileRouteTypes {
| '/forgot-password' | '/forgot-password'
| '/reset-password' | '/reset-password'
| '/verify-email' | '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/.well-known/openid-configuration'
| '/billing' | '/billing'
| '/settings' | '/settings'
| '/support' | '/support'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent'
| '/subscribe' | '/subscribe'
| '/p/$projectId' | '/p/$projectId'
| '/.well-known/oauth-protected-resource/mcp'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/.well-known/oauth-authorization-server/api/auth'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/audit' | '/p/$projectId/audit'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
@ -337,15 +392,20 @@ export interface FileRouteTypes {
| '/forgot-password' | '/forgot-password'
| '/reset-password' | '/reset-password'
| '/verify-email' | '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/.well-known/openid-configuration'
| '/billing' | '/billing'
| '/settings' | '/settings'
| '/support' | '/support'
| '/sign-in' | '/sign-in'
| '/sign-up' | '/sign-up'
| '/oauth-consent'
| '/subscribe' | '/subscribe'
| '/.well-known/oauth-protected-resource/mcp'
| '/help/dataforseo-api-key' | '/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/.well-known/oauth-authorization-server/api/auth'
| '/p/$projectId/ai' | '/p/$projectId/ai'
| '/p/$projectId/backlinks' | '/p/$projectId/backlinks'
| '/p/$projectId/brand-lookup' | '/p/$projectId/brand-lookup'
@ -367,17 +427,22 @@ export interface FileRouteTypes {
| '/forgot-password' | '/forgot-password'
| '/reset-password' | '/reset-password'
| '/verify-email' | '/verify-email'
| '/.well-known/oauth-authorization-server'
| '/.well-known/openid-configuration'
| '/_app/billing' | '/_app/billing'
| '/_app/settings' | '/_app/settings'
| '/_app/support' | '/_app/support'
| '/_auth/sign-in' | '/_auth/sign-in'
| '/_auth/sign-up' | '/_auth/sign-up'
| '/_authenticated/oauth-consent'
| '/_authenticated/subscribe' | '/_authenticated/subscribe'
| '/_app/' | '/_app/'
| '/_project/p/$projectId' | '/_project/p/$projectId'
| '/.well-known/oauth-protected-resource/mcp'
| '/_app/help/dataforseo-api-key' | '/_app/help/dataforseo-api-key'
| '/api/auth/$' | '/api/auth/$'
| '/api/autumn/$' | '/api/autumn/$'
| '/.well-known/oauth-authorization-server/api/auth'
| '/_project/p/$projectId/ai' | '/_project/p/$projectId/ai'
| '/_project/p/$projectId/audit' | '/_project/p/$projectId/audit'
| '/_project/p/$projectId/backlinks' | '/_project/p/$projectId/backlinks'
@ -402,6 +467,9 @@ export interface RootRouteChildren {
ForgotPasswordRoute: typeof ForgotPasswordRoute ForgotPasswordRoute: typeof ForgotPasswordRoute
ResetPasswordRoute: typeof ResetPasswordRoute ResetPasswordRoute: typeof ResetPasswordRoute
VerifyEmailRoute: typeof VerifyEmailRoute VerifyEmailRoute: typeof VerifyEmailRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
} }
@ -471,6 +539,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/oauth-consent': {
id: '/_authenticated/oauth-consent'
path: '/oauth-consent'
fullPath: '/oauth-consent'
preLoaderRoute: typeof AuthenticatedOauthConsentRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_auth/sign-up': { '/_auth/sign-up': {
id: '/_auth/sign-up' id: '/_auth/sign-up'
path: '/sign-up' path: '/sign-up'
@ -506,6 +581,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppBillingRouteImport preLoaderRoute: typeof AppBillingRouteImport
parentRoute: typeof AppRouteRoute 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'
fullPath: '/.well-known/oauth-authorization-server'
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
parentRoute: typeof rootRouteImport
}
'/api/autumn/$': { '/api/autumn/$': {
id: '/api/autumn/$' id: '/api/autumn/$'
path: '/api/autumn/$' path: '/api/autumn/$'
@ -527,6 +616,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
parentRoute: typeof AppRouteRoute parentRoute: typeof AppRouteRoute
} }
'/.well-known/oauth-protected-resource/mcp': {
id: '/.well-known/oauth-protected-resource/mcp'
path: '/.well-known/oauth-protected-resource/mcp'
fullPath: '/.well-known/oauth-protected-resource/mcp'
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport
parentRoute: typeof rootRouteImport
}
'/_project/p/$projectId': { '/_project/p/$projectId': {
id: '/_project/p/$projectId' id: '/_project/p/$projectId'
path: '/p/$projectId' path: '/p/$projectId'
@ -604,6 +700,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
parentRoute: typeof ProjectPProjectIdRouteRoute 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/': { '/_project/p/$projectId/rank-tracking/': {
id: '/_project/p/$projectId/rank-tracking/' id: '/_project/p/$projectId/rank-tracking/'
path: '/' path: '/'
@ -748,10 +851,12 @@ const AuthRouteChildren: AuthRouteChildren = {
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
interface AuthenticatedRouteChildren { interface AuthenticatedRouteChildren {
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
} }
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = { const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute, AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
} }
@ -759,6 +864,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren, AuthenticatedRouteChildren,
) )
interface DotwellKnownOauthAuthorizationServerRouteChildren {
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
}
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
{
DotwellKnownOauthAuthorizationServerApiAuthRoute:
DotwellKnownOauthAuthorizationServerApiAuthRoute,
}
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
DotwellKnownOauthAuthorizationServerRouteChildren,
)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AppRouteRoute: AppRouteRouteWithChildren, AppRouteRoute: AppRouteRouteWithChildren,
ProjectRouteRoute: ProjectRouteRouteWithChildren, ProjectRouteRoute: ProjectRouteRouteWithChildren,
@ -767,6 +887,11 @@ const rootRouteChildren: RootRouteChildren = {
ForgotPasswordRoute: ForgotPasswordRoute, ForgotPasswordRoute: ForgotPasswordRoute,
ResetPasswordRoute: ResetPasswordRoute, ResetPasswordRoute: ResetPasswordRoute,
VerifyEmailRoute: VerifyEmailRoute, VerifyEmailRoute: VerifyEmailRoute,
DotwellKnownOauthAuthorizationServerRoute:
DotwellKnownOauthAuthorizationServerRouteWithChildren,
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
DotwellKnownOauthProtectedResourceMcpRoute:
DotwellKnownOauthProtectedResourceMcpRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute, ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiAutumnSplatRoute: ApiAutumnSplatRoute, ApiAutumnSplatRoute: ApiAutumnSplatRoute,
} }

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")(
{
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
return unavailableMetadataResponse();
}
return oauthProviderAuthServerMetadata(getAuth())(request);
},
},
},
},
);

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

@ -0,0 +1,47 @@
import { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers";
import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
import { isHostedAuthMode } from "@/lib/auth-mode";
import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client";
import { getMcpResource, MCP_SCOPE } from "@/lib/oauth-resource";
function unavailableMetadataResponse() {
if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", { status: 404 });
}
return new Response("Missing Better Auth hosted configuration", {
status: 500,
});
}
export const Route = createFileRoute(
"/.well-known/oauth-protected-resource/mcp",
)({
server: {
handlers: {
GET: async () => {
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
return unavailableMetadataResponse();
}
const baseUrl = getHostedBaseUrl();
const metadata =
await getOAuthProviderResourceActions().getProtectedResourceMetadata({
resource: getMcpResource(baseUrl),
authorization_servers: [`${baseUrl}/api/auth`],
scopes_supported: [MCP_SCOPE],
resource_name: "OpenSEO MCP",
});
return new Response(JSON.stringify(metadata), {
headers: {
"Cache-Control":
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
"Content-Type": "application/json",
},
});
},
},
},
});

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

@ -0,0 +1,169 @@
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Check, Database, KeyRound, User } from "lucide-react";
import { useState } from "react";
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);
const { data, error: consentError } = await authClient.oauth2.consent({
accept,
});
if (consentError) {
setError(consentError.message ?? "Unable to complete authorization.");
setIsSubmitting(false);
return;
}
if (data?.redirect && data.url) {
window.location.assign(data.url);
return;
}
setError("Authorization response did not include a redirect URL.");
setIsSubmitting(false);
}
return (
<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>
{!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 ? (
<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)}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary flex-1"
disabled={isSubmitting}
onClick={() => void respond(true)}
>
{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

@ -18,7 +18,9 @@ function AuthenticatedShellLayout() {
if (!session?.user?.id) { if (!session?.user?.id) {
void navigate({ void navigate({
to: "/sign-in", to: "/sign-in",
search: { redirect: window.location.pathname }, search: {
redirect: `${window.location.pathname}${window.location.search}`,
},
}); });
} }
}, [isPending, isHostedMode, session?.user?.id, navigate]); }, [isPending, isHostedMode, session?.user?.id, navigate]);

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 { createFileRoute } from "@tanstack/react-router";
import { env } from "cloudflare:workers"; 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 { 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)) { if (!isHostedAuthMode(env.AUTH_MODE)) {
return new Response("Not found", { return new Response("Not found", {
status: 404, status: 404,
@ -17,7 +56,7 @@ function handleAuthRequest(request: Request) {
} }
const auth = getAuth(); const auth = getAuth();
return auth.handler(request); return auth.handler(await maybeInjectMcpResource(request));
} }
export const Route = createFileRoute("/api/auth/$")({ 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 { customerHasPaidPlan } from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { computeNextCheckAt } from "@/shared/rank-tracking"; 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 Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow"; export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";

76
src/server/mcp/context.ts Normal file
View File

@ -0,0 +1,76 @@
import { getMcpAuthContext } from "agents/mcp";
import { z } from "zod";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { buildDashboardUrl } from "@/server/mcp/urls";
type McpAuth = {
userId: string;
userEmail: string;
organizationId: string;
scopes: string[];
clientId: string | null;
audience: string;
subject: string;
};
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
const mcpToolAuthContextSchema = z.object({
userId: z.string().min(1),
userEmail: 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),
baseUrl: z.string().url(),
});
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
export type ToolExtra = unknown;
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;
}
export function getAuth(_extra?: ToolExtra): McpAuth {
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext();
return auth;
}
export function getBaseUrl(_extra?: ToolExtra): string {
return requireMcpToolAuthContext().baseUrl;
}
export function buildBillingCustomer(
auth: McpAuth,
projectId: string,
): BillingCustomerContext {
return {
userId: auth.userId,
userEmail: auth.userEmail,
organizationId: auth.organizationId,
projectId,
};
}
export function buildProjectMeta(
context: { auth: Pick<McpAuth, "organizationId">; baseUrl: string },
projectId: string,
path?: string,
params?: Record<string, string | number | undefined>,
) {
return {
organizationId: context.auth.organizationId,
projectId,
url: path ? buildDashboardUrl(context.baseUrl, path, params) : undefined,
};
}

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { mcpResponse } from "./formatters";
describe("mcpResponse", () => {
it("returns content as a text block", () => {
const result = mcpResponse({ text: "hi" });
expect(result.content).toEqual([{ type: "text", text: "hi" }]);
});
it("includes _meta only when meta is provided", () => {
const bare = mcpResponse({ text: "hi" });
expect(bare._meta).toBeUndefined();
const withMeta = mcpResponse({
text: "hi",
meta: { url: "https://app.openseo.so/p/1", projectId: "1" },
});
expect(withMeta._meta).toEqual({
url: "https://app.openseo.so/p/1",
projectId: "1",
});
});
it("drops undefined meta keys", () => {
const result = mcpResponse({
text: "hi",
meta: {
url: "https://app.openseo.so",
organizationId: undefined,
creditsCharged: 0,
},
});
expect(result._meta).toEqual({
url: "https://app.openseo.so",
creditsCharged: 0,
});
expect(result._meta).not.toHaveProperty("organizationId");
});
it("attaches structuredContent when provided", () => {
const result = mcpResponse({
text: "hi",
structuredContent: { foo: "bar" },
});
expect(result.structuredContent).toEqual({ foo: "bar" });
});
});

View File

@ -0,0 +1,34 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
type McpResponseMeta = {
url?: string;
organizationId?: string;
projectId?: string;
runId?: string;
creditsCharged?: number;
creditsRemaining?: number;
};
export function mcpResponse(opts: {
text: string;
meta?: McpResponseMeta;
structuredContent?: Record<string, unknown>;
}): CallToolResult {
const result: CallToolResult = {
content: [{ type: "text", text: opts.text }],
};
if (opts.structuredContent) {
result.structuredContent = opts.structuredContent;
}
if (opts.meta) {
// Drop undefined keys so the wire payload stays clean.
const meta: Record<string, unknown> = {};
for (const [key, value] of Object.entries(opts.meta)) {
if (value !== undefined) meta[key] = value;
}
if (Object.keys(meta).length > 0) {
result._meta = meta;
}
}
return result;
}

View File

@ -0,0 +1,280 @@
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 userEmailMocks = vi.hoisted(() => ({
getMcpUserEmail: 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("@/server/mcp/user-email", () => ({
getMcpUserEmail: userEmailMocks.getMcpUserEmail,
}));
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(),
);
userEmailMocks.getMcpUserEmail.mockResolvedValue("alice@example.com");
});
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",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["offline_access", "mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
});
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 403 when the verified user is not found", async () => {
const { handleMcpRequest } = await import("@/server/mcp/handler");
userEmailMocks.getMcpUserEmail.mockResolvedValue(null);
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);
});
});

163
src/server/mcp/handler.ts Normal file
View File

@ -0,0 +1,163 @@
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";
import { getMcpUserEmail } from "@/server/mcp/user-email";
// 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",
},
},
);
}
const userEmail = await getMcpUserEmail(userId);
if (!userEmail) {
return new Response("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,
userEmail,
organizationId,
clientId,
scopes,
audience: mcpResource,
subject: userId,
baseUrl,
},
},
},
})(request, env, ctx);
}

View File

@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
const mocks = vi.hoisted(() => ({
getMcpAuthContext: vi.fn(),
getProjectForOrganization: vi.fn(),
}));
vi.mock("agents/mcp", () => ({
getMcpAuthContext: mocks.getMcpAuthContext,
}));
vi.mock("@/server/features/projects/services/ProjectService", () => ({
ProjectService: {
getProjectForOrganization: mocks.getProjectForOrganization,
},
}));
const authContext = {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
baseUrl: "https://open-seo.test",
};
describe("withMcpProjectAuth", () => {
beforeEach(() => {
vi.resetModules();
mocks.getMcpAuthContext.mockReset();
mocks.getProjectForOrganization.mockReset();
mocks.getMcpAuthContext.mockReturnValue({
props: { [MCP_AUTH_CONTEXT_PROP]: authContext },
});
});
it("checks project access for the authenticated organization", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockResolvedValue("ok");
const wrapped = withMcpProjectAuth(handler);
await expect(
wrapped({ projectId: "project_123" }, undefined),
).resolves.toBe("ok");
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
"org_123",
"project_123",
);
});
it("passes auth, baseUrl, and billing context to the wrapped handler", async () => {
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn().mockReturnValue("ok");
const wrapped = withMcpProjectAuth(handler);
await wrapped({ projectId: "project_123" }, undefined);
expect(handler).toHaveBeenCalledWith(
{ projectId: "project_123" },
{
auth: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
clientId: "client_123",
scopes: ["mcp"],
audience: "https://open-seo.test/mcp",
subject: "user_123",
},
baseUrl: "https://open-seo.test",
billing: {
userId: "user_123",
userEmail: "alice@example.com",
organizationId: "org_123",
projectId: "project_123",
},
},
);
});
it("propagates project access failures without calling the wrapped handler", async () => {
const error = new Error("project not found");
mocks.getProjectForOrganization.mockRejectedValue(error);
const { withMcpProjectAuth } = await import("@/server/mcp/project-auth");
const handler = vi.fn();
const wrapped = withMcpProjectAuth(handler);
await expect(wrapped({ projectId: "project_123" }, undefined)).rejects.toBe(
error,
);
expect(handler).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,40 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import {
buildBillingCustomer,
requireMcpToolAuthContext,
type ToolExtra,
} from "@/server/mcp/context";
type ProjectScopedArgs = {
projectId: string;
};
async function requireProjectAccess(_extra: ToolExtra, projectId: string) {
const { baseUrl, ...auth } = requireMcpToolAuthContext();
// This lookup enforces that the project belongs to the authenticated org.
await ProjectService.getProjectForOrganization(
auth.organizationId,
projectId,
);
return {
auth,
baseUrl,
billing: buildBillingCustomer(auth, projectId),
};
}
type McpProjectAuthContext = Awaited<ReturnType<typeof requireProjectAccess>>;
export function withMcpProjectAuth<TArgs extends ProjectScopedArgs, TResult>(
handler: (
args: TArgs,
context: McpProjectAuthContext,
) => Promise<TResult> | TResult,
) {
return async (args: TArgs, extra: ToolExtra) => {
const context = await requireProjectAccess(extra, args.projectId);
return handler(args, context);
};
}

24
src/server/mcp/schemas.ts Normal file
View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const DEFAULT_LOCATION_CODE = 2840;
export const DEFAULT_LANGUAGE_CODE = "en";
export const projectIdSchema = z
.string()
.min(1)
.describe(
"Required. The OpenSEO project ID to scope this call to. Get one from list_projects.",
);
export const locationCodeSchema = z
.number()
.int()
.positive()
.describe(
"DataForSEO location code. Defaults to 2840 (United States). See dataforseo.com/help-center/locations.",
);
export const languageCodeSchema = z
.string()
.min(2)
.describe("Language code (e.g. 'en', 'es', 'fr'). Defaults to 'en'.");

67
src/server/mcp/server.ts Normal file
View File

@ -0,0 +1,67 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
import { getRankTrackerTool } from "@/server/mcp/tools/get-rank-tracker";
import { getSerpResultsTool } from "@/server/mcp/tools/get-serp-results";
import { listProjectsTool } from "@/server/mcp/tools/list-projects";
import { listSavedKeywordsTool } from "@/server/mcp/tools/list-saved-keywords";
import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
import { whoamiTool } from "@/server/mcp/tools/whoami";
export function createOpenSeoMcpServer() {
const server = new McpServer({
name: "OpenSEO MCP",
version: "0.0.10",
});
server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler);
server.registerTool(
listProjectsTool.name,
listProjectsTool.config,
listProjectsTool.handler,
);
server.registerTool(
listSavedKeywordsTool.name,
listSavedKeywordsTool.config,
listSavedKeywordsTool.handler,
);
server.registerTool(
researchKeywordsTool.name,
researchKeywordsTool.config,
researchKeywordsTool.handler,
);
server.registerTool(
saveKeywordsTool.name,
saveKeywordsTool.config,
saveKeywordsTool.handler,
);
server.registerTool(
getDomainOverviewTool.name,
getDomainOverviewTool.config,
getDomainOverviewTool.handler,
);
server.registerTool(
getDomainKeywordSuggestionsTool.name,
getDomainKeywordSuggestionsTool.config,
getDomainKeywordSuggestionsTool.handler,
);
server.registerTool(
getBacklinksOverviewTool.name,
getBacklinksOverviewTool.config,
getBacklinksOverviewTool.handler,
);
server.registerTool(
getSerpResultsTool.name,
getSerpResultsTool.config,
getSerpResultsTool.handler,
);
server.registerTool(
getRankTrackerTool.name,
getRankTrackerTool.config,
getRankTrackerTool.handler,
);
return server;
}

View File

@ -0,0 +1,81 @@
import { z } from "zod";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
target: z
.string()
.min(1)
.describe(
"Domain or URL to analyze (e.g. 'example.com' or 'https://example.com/blog').",
),
scope: z
.enum(["domain", "page"])
.optional()
.describe(
"'domain' analyzes the whole domain; 'page' analyzes a specific URL. Defaults to 'domain'.",
),
hideSpam: z
.boolean()
.optional()
.describe("Filter out spammy referring domains. Defaults to true."),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
function formatMetric(value: unknown) {
return typeof value === "number" || typeof value === "string" ? value : "?";
}
export const getBacklinksOverviewTool = {
name: "get_backlinks_overview",
config: {
title: "Get backlinks overview",
description:
"Returns a backlinks profile summary (total backlinks, referring domains, top referring domains). Charges credits (~200-500 typical). Requires that the user's DataForSEO account has Backlinks enabled.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const lookup = { target: args.target, scope: args.scope };
const spamOptions = { hideSpam: args.hideSpam ?? true };
const [overview, refDomains] = await Promise.all([
BacklinksService.profileOverview(lookup, context.billing, spamOptions),
BacklinksService.profileReferringDomains(
lookup,
context.billing,
spamOptions,
),
]);
const topDomains = refDomains.rows ?? [];
const overviewRecord =
overview && typeof overview === "object"
? (overview as Record<string, unknown>)
: {};
const text = [
`Backlinks profile for ${args.target} (${args.scope ?? "domain"}):`,
`- backlinks: ${formatMetric(overviewRecord.backlinks)}`,
`- referring domains: ${formatMetric(overviewRecord.referring_domains)}`,
`- referring pages: ${formatMetric(overviewRecord.referring_pages)}`,
`- rank: ${formatMetric(overviewRecord.rank)}`,
"",
`Top referring domains (${Math.min(topDomains.length, 10)} shown):`,
...topDomains
.slice(0, 10)
.map((d) => `- ${d.domain ?? "?"} backlinks:${d.backlinks ?? "?"}`),
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/backlinks`,
{ target: args.target },
),
structuredContent: { overview, referringDomains: refDomains },
});
}),
};

View File

@ -0,0 +1,67 @@
import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
domain: z
.string()
.min(1)
.describe("Competitor or reference domain to extract keywords from."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getDomainKeywordSuggestionsTool = {
name: "get_domain_keyword_suggestions",
config: {
title: "Get domain keyword opportunities",
description:
"Returns the organic keywords a domain ranks for, including position and available metrics. Use after get_domain_overview when you want the detailed keyword opportunity list for a competitor or reference domain. Charges credits (~100-300 typical). Cached for 12 hours.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const keywords = await DomainService.getSuggestedKeywords(
{
domain: args.domain,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
organizationId: context.auth.organizationId,
projectId: args.projectId,
},
context.billing,
);
const text = [
`Top keywords for ${args.domain} (${keywords.length}):`,
...keywords
.slice(0, 25)
.map(
(kw) =>
`- "${kw.keyword}" #${kw.position ?? "?"} vol:${kw.searchVolume ?? "?"} kd:${kw.keywordDifficulty ?? "?"}`,
),
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
{
domain: args.domain,
},
),
structuredContent: { keywords },
});
}),
};

View File

@ -0,0 +1,63 @@
import { z } from "zod";
import { DomainService } from "@/server/features/domain/services/DomainService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
domain: z.string().min(1).describe("Domain to analyze (e.g. 'example.com')."),
includeSubdomains: z.boolean().optional().default(false),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getDomainOverviewTool = {
name: "get_domain_overview",
config: {
title: "Get domain overview",
description:
"Returns a high-level view of a domain's organic footprint: estimated organic traffic, organic keyword count, backlinks, and referring domains. Use this first for domain research; for the detailed ranked-keyword list, call get_domain_keyword_suggestions next. Charges credits (~100-300 typical). Cached for 12 hours per domain.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const result = await DomainService.getOverview(
{
projectId: args.projectId,
domain: args.domain,
includeSubdomains: args.includeSubdomains,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
},
context.billing,
);
const text = [
`Domain: ${result.domain}`,
`Organic traffic: ${result.organicTraffic ?? "?"}`,
`Organic keywords: ${result.organicKeywords ?? "?"}`,
`Backlinks: ${result.backlinks ?? "?"}`,
`Referring domains: ${result.referringDomains ?? "?"}`,
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/domain`,
{
domain: args.domain,
},
),
structuredContent: result,
});
}),
};

View File

@ -0,0 +1,88 @@
import { z } from "zod";
import { RankTrackingRepository } from "@/server/features/rank-tracking/repositories/RankTrackingRepository";
import { getLatestResults } from "@/server/features/rank-tracking/services/rankTrackingResults";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
trackerId: z
.string()
.optional()
.describe(
"Rank tracker config ID. If omitted, lists all rank trackers in the project.",
),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getRankTrackerTool = {
name: "get_rank_tracker",
config: {
title: "Get rank tracker",
description:
"Read-only access to rank tracker configs and their latest results. With `trackerId`, returns config + latest snapshot per keyword. Without it, lists all trackers in the project. Free — reads from OpenSEO state, no DataForSEO call. To trigger a new check, use the dashboard.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
if (!args.trackerId) {
const configs = await RankTrackingRepository.getConfigsForProject(
args.projectId,
);
const text =
configs.length === 0
? "No rank trackers configured for this project."
: `Rank trackers (${configs.length}):\n` +
configs
.map(
(c) =>
`- ${c.id} ${c.domain} loc:${c.locationCode} schedule:${c.scheduleInterval}`,
)
.join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/rank-tracking`,
),
structuredContent: { configs },
});
}
const config = await RankTrackingRepository.getConfigById({
configId: args.trackerId,
projectId: args.projectId,
});
if (!config) {
return mcpResponse({
text: `Rank tracker ${args.trackerId} not found in project ${args.projectId}.`,
meta: buildProjectMeta(context, args.projectId),
});
}
const results = await getLatestResults(args.trackerId, args.projectId);
const text = [
`Tracker ${config.id} (${config.domain}):`,
`Schedule: ${config.scheduleInterval}, devices: ${config.devices}, depth: ${config.serpDepth}`,
`Latest run: ${results.run?.lastCheckedAt ?? "never"}`,
`Keywords (${results.rows.length}):`,
...results.rows
.slice(0, 25)
.map(
(r) =>
`- "${r.keyword}" desktop:#${r.desktop.position ?? "-"} (was ${r.desktop.previousPosition ?? "-"}) mobile:#${r.mobile.position ?? "-"}`,
),
].join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/rank-tracking/${args.trackerId}`,
),
structuredContent: { config, results },
});
}),
};

View File

@ -0,0 +1,100 @@
import { z } from "zod";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const querySchema = z.object({
keyword: z.string().min(1),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
});
const inputSchema = {
projectId: projectIdSchema,
queries: z
.array(querySchema)
.min(1)
.max(10)
.describe(
"1-10 queries. Bulk-friendly — prefer this over multiple single-query calls.",
),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const getSerpResultsTool = {
name: "get_serp_results",
config: {
title: "Get Google SERP results",
description:
"Fetch live Google organic search results for 1-10 keywords. Use this to inspect who ranks for a query, verify competitors, compare SERPs across keywords, or gather source URLs before content planning. Charges credits per keyword (~30-60 each). Does not save results to OpenSEO. Per-keyword errors don't fail the batch.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const client = createDataforseoClient(context.billing);
const results = await Promise.all(
args.queries.map(async (q) => {
try {
const items = await client.serp.live({
keyword: q.keyword,
locationCode: q.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: q.languageCode ?? DEFAULT_LANGUAGE_CODE,
});
// Trim noise — return only essentials per item.
const trimmed = items.slice(0, 20).map((item) => ({
type: item.type,
rank: item.rank_absolute ?? item.rank_group ?? null,
title: item.title ?? null,
url: item.url ?? null,
domain: item.domain ?? null,
description: item.description ?? null,
}));
return { keyword: q.keyword, ok: true as const, items: trimmed };
} catch (error) {
return {
keyword: q.keyword,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
}
}),
);
const okCount = results.filter((r) => r.ok).length;
const text =
results
.map((r) => {
if (r.ok) {
const top = r.items.slice(0, 3);
return `"${r.keyword}" (${r.items.length} results):\n${top
.map(
(it) =>
` #${it.rank ?? "?"} ${it.domain ?? "?"}${it.title ?? "?"}`,
)
.join("\n")}`;
}
return `"${r.keyword}": FAILED — ${r.error}`;
})
.join("\n\n") +
`\n\n${okCount} of ${results.length} queries succeeded.`;
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { results },
});
}),
};

View File

@ -0,0 +1,40 @@
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, getBaseUrl, type ToolExtra } from "@/server/mcp/context";
import { buildDashboardUrl } from "@/server/mcp/urls";
export const listProjectsTool = {
name: "list_projects",
config: {
title: "List projects",
description:
"Lists all projects in the user's organization. Free — does not call DataForSEO. Use this whenever you need a `projectId` for another OpenSEO tool. Returns an array of {id, name, domain}; pass the `id` value as `projectId`.",
inputSchema: {} as Record<string, never>,
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);
const baseUrl = getBaseUrl(extra);
const projects = await ProjectService.listProjects(auth.organizationId);
const lines =
projects.length === 0
? ["No projects yet. Create one in the dashboard."]
: projects.map(
(p) => `- ${p.id} ${p.name}${p.domain ? ` (${p.domain})` : ""}`,
);
return mcpResponse({
text: `Projects (${projects.length}):\n${lines.join("\n")}`,
meta: {
organizationId: auth.organizationId,
url: buildDashboardUrl(baseUrl, "/"),
},
structuredContent: {
projects: projects.map((p) => ({
id: p.id,
name: p.name,
domain: p.domain,
url: buildDashboardUrl(baseUrl, `/p/${p.id}`),
})),
},
});
},
};

View File

@ -0,0 +1,46 @@
import type { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import { projectIdSchema } from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
} as const;
export const listSavedKeywordsTool = {
name: "list_saved_keywords",
config: {
title: "List saved keywords",
description:
"Lists keywords saved to a project (with cached metrics like search volume, difficulty, CPC if available). Free — reads from OpenSEO's database, no DataForSEO call.",
inputSchema,
},
handler: withMcpProjectAuth(
async (args: z.infer<z.ZodObject<typeof inputSchema>>, context) => {
const { rows } = await KeywordResearchService.getSavedKeywords({
projectId: args.projectId,
});
const text =
rows.length === 0
? "No saved keywords yet."
: `Saved keywords (${rows.length}):\n` +
rows
.map(
(r) =>
`- ${r.keyword} vol:${r.searchVolume ?? "?"} kd:${r.keywordDifficulty ?? "?"} cpc:${r.cpc != null ? `$${r.cpc.toFixed(2)}` : "?"}`,
)
.join("\n");
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/saved`,
),
structuredContent: { rows },
});
},
),
};

View File

@ -0,0 +1,101 @@
import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const seedSchema = z.object({
seed: z.string().min(1).describe("Seed keyword to research."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
});
const inputSchema = {
projectId: projectIdSchema,
seeds: z
.array(seedSchema)
.min(1)
.max(5)
.describe(
"1-5 seed keywords. Each seed is researched independently and returns related keywords with volume/difficulty/CPC. Bulk-friendly — prefer this over multiple single-seed calls.",
),
resultLimit: z
.union([z.literal(150), z.literal(300), z.literal(500)])
.optional()
.describe("Max keywords returned per seed. Defaults to 150."),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const researchKeywordsTool = {
name: "research_keywords",
config: {
title: "Research keywords (bulk)",
description:
"Research keyword data (search volume, difficulty, CPC, related ideas) for 1-5 seed keywords in one call. Charges credits per seed (~50-200 credits each, varies by source). Returns per-seed results — a single bad seed won't fail the batch.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
const results = await Promise.all(
args.seeds.map(async (item) => {
try {
const data = await KeywordResearchService.research(
{
projectId: args.projectId,
keywords: [item.seed],
locationCode: item.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: item.languageCode ?? DEFAULT_LANGUAGE_CODE,
resultLimit: args.resultLimit ?? 150,
mode: "auto",
},
context.billing,
);
return {
seed: item.seed,
ok: true as const,
rowCount: data.rows.length,
source: data.source,
usedFallback: data.usedFallback,
topRows: data.rows.slice(0, 20),
};
} catch (error) {
return {
seed: item.seed,
ok: false as const,
error: error instanceof Error ? error.message : String(error),
};
}
}),
);
const okCount = results.filter((r) => r.ok).length;
const failCount = results.length - okCount;
const text =
results
.map((r) => {
if (r.ok) {
return `- "${r.seed}": ${r.rowCount} keywords (source: ${r.source})`;
}
return `- "${r.seed}": FAILED — ${r.error}`;
})
.join("\n") +
`\n\nResearched ${okCount} of ${results.length} seeds${failCount > 0 ? ` (${failCount} failed)` : ""}.`;
return mcpResponse({
text,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/keywords`,
),
structuredContent: { results },
});
}),
};

View File

@ -0,0 +1,51 @@
import { z } from "zod";
import { KeywordResearchService } from "@/server/features/keywords/services/KeywordResearchService";
import { mcpResponse } from "@/server/mcp/formatters";
import { buildProjectMeta } from "@/server/mcp/context";
import { withMcpProjectAuth } from "@/server/mcp/project-auth";
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCATION_CODE,
languageCodeSchema,
locationCodeSchema,
projectIdSchema,
} from "@/server/mcp/schemas";
const inputSchema = {
projectId: projectIdSchema,
keywords: z
.array(z.string().min(1))
.min(1)
.max(100)
.describe("Keywords to save (1-100)."),
locationCode: locationCodeSchema.optional(),
languageCode: languageCodeSchema.optional(),
} as const;
type Args = z.infer<z.ZodObject<typeof inputSchema>>;
export const saveKeywordsTool = {
name: "save_keywords",
config: {
title: "Save keywords",
description:
"Save keywords to a project's saved-keywords list. Free — does not call DataForSEO. Idempotent: re-saving an existing keyword is a no-op.",
inputSchema,
},
handler: withMcpProjectAuth(async (args: Args, context) => {
await KeywordResearchService.saveKeywords({
projectId: args.projectId,
keywords: args.keywords,
locationCode: args.locationCode ?? DEFAULT_LOCATION_CODE,
languageCode: args.languageCode ?? DEFAULT_LANGUAGE_CODE,
});
return mcpResponse({
text: `Saved ${args.keywords.length} keyword(s) to project ${args.projectId}.`,
meta: buildProjectMeta(
context,
args.projectId,
`/p/${args.projectId}/saved`,
),
});
}),
};

View File

@ -0,0 +1,68 @@
import { autumn } from "@/server/billing/autumn";
import {
AUTUMN_SEO_DATA_BALANCE_FEATURE_ID,
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
} from "@/shared/billing";
import { mcpResponse } from "@/server/mcp/formatters";
import { getAuth, type ToolExtra } from "@/server/mcp/context";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
async function checkBalance(featureId: string, customerId: string) {
try {
const result = await autumn.check({ customerId, featureId });
return result.balance?.remaining ?? null;
} catch {
return null;
}
}
export const whoamiTool = {
name: "whoami",
config: {
title: "Who am I",
description:
"Returns the authenticated user, organization, server mode, token scopes, and current credit balance. Free — does not call DataForSEO. Use this first to confirm connection context before choosing a project or running paid tools.",
inputSchema: {} as Record<string, never>,
},
handler: async (_args: Record<string, never>, extra: ToolExtra) => {
const auth = getAuth(extra);
const isHosted = await isHostedServerAuthMode();
let creditsRemaining: number | null = null;
if (isHosted) {
const [base, topup] = await Promise.all([
checkBalance(AUTUMN_SEO_DATA_BALANCE_FEATURE_ID, auth.organizationId),
checkBalance(
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
auth.organizationId,
),
]);
creditsRemaining = (base ?? 0) + (topup ?? 0);
}
const lines = [
`User: ${auth.userId} (${auth.userEmail})`,
`Organization: ${auth.organizationId}`,
`Mode: ${isHosted ? "hosted" : "self-hosted"}`,
`Scopes: ${auth.scopes.length > 0 ? auth.scopes.join(", ") : "none"}`,
];
if (isHosted) {
lines.push(
`Credits remaining: ${creditsRemaining != null ? creditsRemaining.toLocaleString() : "unknown"}`,
);
}
return mcpResponse({
text: lines.join("\n"),
meta: {
organizationId: auth.organizationId,
creditsRemaining: creditsRemaining ?? undefined,
},
structuredContent: {
userId: auth.userId,
userEmail: auth.userEmail,
organizationId: auth.organizationId,
scopes: auth.scopes,
mode: isHosted ? "hosted" : "self-hosted",
creditsRemaining,
},
});
},
};

18
src/server/mcp/urls.ts Normal file
View File

@ -0,0 +1,18 @@
// Dashboard URL builder. The base URL is derived per-request from the incoming
// MCP request's origin so it works correctly across hosted, self-hosted, and
// dev environments without needing an env var.
export function buildDashboardUrl(
baseUrl: string,
path: string,
params?: Record<string, string | number | undefined>,
): string {
const url = new URL(path.startsWith("/") ? path : `/${path}`, baseUrl);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value == null) continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}

View File

@ -0,0 +1,12 @@
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { user } from "@/db/schema";
export async function getMcpUserEmail(userId: string) {
const authUser = await db.query.user.findFirst({
columns: { email: true },
where: eq(user.id, userId),
});
return authUser?.email ?? null;
}

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