Merge pull request #163 from bensenescu/feat/mcp
feature: implement mcp server
This commit is contained in:
commit
04497dd90e
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -22,8 +22,6 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
2
.github/workflows/sourcemaps.yml
vendored
2
.github/workflows/sourcemaps.yml
vendored
@ -20,8 +20,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
|
||||
@ -6,7 +6,7 @@ ENV PATH=$PNPM_HOME:$PATH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
RUN corepack enable && corepack prepare pnpm@10.30.1 --activate
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
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 baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
|
||||
|
||||
export const auth = betterAuth({
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL,
|
||||
baseURL: baseUrl,
|
||||
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
||||
...baseAuthConfig,
|
||||
...createBaseAuthConfig(baseUrl),
|
||||
});
|
||||
|
||||
89
drizzle/0012_closed_impossible_man.sql
Normal file
89
drizzle/0012_closed_impossible_man.sql
Normal 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
|
||||
);
|
||||
2634
drizzle/meta/0012_snapshot.json
Normal file
2634
drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,13 @@
|
||||
"when": 1778031161783,
|
||||
"tag": "0011_colorful_dark_beast",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1778113978173,
|
||||
"tag": "0012_closed_impossible_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -11,6 +11,7 @@
|
||||
"drizzle.config.ts",
|
||||
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||
"src/db/index.ts",
|
||||
"src/db/app.schema.ts",
|
||||
"src/db/better-auth-schema.ts",
|
||||
],
|
||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||
|
||||
@ -4,8 +4,9 @@
|
||||
"sideEffects": false,
|
||||
"version": "0.0.10",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"scripts": {
|
||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
||||
"dev": "vite dev",
|
||||
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
@ -49,7 +50,9 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/oauth-provider": "^1.5.5",
|
||||
"@every-app/sdk": "^0.1.14",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@tanstack/query-core": "^5.90.9",
|
||||
"@tanstack/react-form": "^1.25.0",
|
||||
"@tanstack/react-query": "^5.90.9",
|
||||
@ -57,6 +60,7 @@
|
||||
"@tanstack/react-router-devtools": "^1.166.11",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"agents": "0.12.3",
|
||||
"autumn-js": "^1.1.7",
|
||||
"better-auth": "^1.5.5",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
1287
pnpm-lock.yaml
generated
1287
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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 }) => ({
|
||||
sessions: many(session),
|
||||
accounts: many(account),
|
||||
members: many(member),
|
||||
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, {
|
||||
fields: [session.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
oauthRefreshTokens: many(oauthRefreshToken),
|
||||
oauthAccessTokens: many(oauthAccessToken),
|
||||
}));
|
||||
|
||||
export const accountRelations = relations(account, ({ one }) => ({
|
||||
@ -198,3 +296,65 @@ export const invitationRelations = relations(invitation, ({ one }) => ({
|
||||
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],
|
||||
}),
|
||||
}));
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
inferAdditionalFields,
|
||||
organizationClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||
import { userAdditionalFields } from "@/lib/auth-options";
|
||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||
@ -11,6 +12,7 @@ export const authClient = createAuthClient({
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||
plugins: [
|
||||
organizationClient(),
|
||||
oauthProviderClient(),
|
||||
inferAdditionalFields({ user: userAdditionalFields }),
|
||||
],
|
||||
});
|
||||
|
||||
@ -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 { 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];
|
||||
|
||||
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()],
|
||||
};
|
||||
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 } : {};
|
||||
},
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { db } from "@/db";
|
||||
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 {
|
||||
sendHostedPasswordResetEmail,
|
||||
@ -25,10 +25,14 @@ const hostedBaseUrlSchema = z
|
||||
function createAuth() {
|
||||
const baseUrl = getHostedBaseUrl();
|
||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||
const baseAuthConfig = createBaseAuthConfig(baseUrl);
|
||||
|
||||
const auth = betterAuth({
|
||||
baseURL: baseUrl,
|
||||
secret: getHostedSecret(),
|
||||
// Disable Better Auth's generic /token endpoint so OAuth access tokens only
|
||||
// flow through /oauth2/token, where the MCP resource shim can run.
|
||||
disabledPaths: ["/token"],
|
||||
...baseAuthConfig,
|
||||
emailAndPassword: {
|
||||
...baseAuthConfig.emailAndPassword,
|
||||
@ -102,7 +106,7 @@ function getTrustedOrigins(baseUrl: string) {
|
||||
return trustedOrigins;
|
||||
}
|
||||
|
||||
function getHostedBaseUrl() {
|
||||
export function getHostedBaseUrl() {
|
||||
const baseUrl = env.BETTER_AUTH_URL?.trim();
|
||||
|
||||
if (!baseUrl) {
|
||||
|
||||
14
src/lib/oauth-provider-resource-client.ts
Normal file
14
src/lib/oauth-provider-resource-client.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { getAuth } from "@/lib/auth";
|
||||
|
||||
type ResourceClientAuth = Parameters<typeof oauthProviderResourceClient>[0];
|
||||
|
||||
export function getOAuthProviderResourceActions() {
|
||||
// Better Auth documents passing the server auth instance here, but the
|
||||
// resource-client package currently types the generic too narrowly for the
|
||||
// concrete `betterAuth(...)` return type.
|
||||
return oauthProviderResourceClient(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
getAuth() as unknown as ResourceClientAuth,
|
||||
).getActions();
|
||||
}
|
||||
22
src/lib/oauth-resource.ts
Normal file
22
src/lib/oauth-resource.ts
Normal 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}`;
|
||||
}
|
||||
@ -18,14 +18,18 @@ import { Route as ProjectRouteRouteImport } from './routes/_project/route'
|
||||
import { Route as AppRouteRouteImport } from './routes/_app/route'
|
||||
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
||||
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 AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
|
||||
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
|
||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||
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 ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||
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 ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
||||
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.]well-known/oauth-authorization-server/api/auth'
|
||||
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
||||
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
||||
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
||||
@ -83,6 +88,12 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
||||
path: '/subscribe',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthenticatedOauthConsentRoute =
|
||||
AuthenticatedOauthConsentRouteImport.update({
|
||||
id: '/oauth-consent',
|
||||
path: '/oauth-consent',
|
||||
getParentRoute: () => AuthenticatedRoute,
|
||||
} as any)
|
||||
const AuthSignUpRoute = AuthSignUpRouteImport.update({
|
||||
id: '/sign-up',
|
||||
path: '/sign-up',
|
||||
@ -108,6 +119,18 @@ const AppBillingRoute = AppBillingRouteImport.update({
|
||||
path: '/billing',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const DotwellKnownOpenidConfigurationRoute =
|
||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||
id: '/.well-known/openid-configuration',
|
||||
path: '/.well-known/openid-configuration',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerRoute =
|
||||
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
||||
id: '/.well-known/oauth-authorization-server',
|
||||
path: '/.well-known/oauth-authorization-server',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||
id: '/api/autumn/$',
|
||||
path: '/api/autumn/$',
|
||||
@ -123,6 +146,12 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
||||
path: '/help/dataforseo-api-key',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} 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({
|
||||
id: '/p/$projectId',
|
||||
path: '/p/$projectId',
|
||||
@ -183,6 +212,12 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({
|
||||
path: '/ai',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
|
||||
id: '/api/auth',
|
||||
path: '/api/auth',
|
||||
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdRankTrackingIndexRoute =
|
||||
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
||||
id: '/',
|
||||
@ -213,16 +248,21 @@ export interface FileRoutesByFullPath {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -243,15 +283,20 @@ export interface FileRoutesByTo {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
'/sign-up': typeof AuthSignUpRoute
|
||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
@ -274,17 +319,22 @@ export interface FileRoutesById {
|
||||
'/forgot-password': typeof ForgotPasswordRoute
|
||||
'/reset-password': typeof ResetPasswordRoute
|
||||
'/verify-email': typeof VerifyEmailRoute
|
||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||
'/_app/billing': typeof AppBillingRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/support': typeof AppSupportRoute
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||
'/_app/': typeof AppIndexRoute
|
||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
@ -307,16 +357,21 @@ export interface FileRouteTypes {
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/subscribe'
|
||||
| '/p/$projectId'
|
||||
| '/.well-known/oauth-protected-resource/mcp'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/audit'
|
||||
| '/p/$projectId/backlinks'
|
||||
@ -337,15 +392,20 @@ export interface FileRouteTypes {
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/billing'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/oauth-consent'
|
||||
| '/subscribe'
|
||||
| '/.well-known/oauth-protected-resource/mcp'
|
||||
| '/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/p/$projectId/ai'
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
@ -367,17 +427,22 @@ export interface FileRouteTypes {
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/verify-email'
|
||||
| '/.well-known/oauth-authorization-server'
|
||||
| '/.well-known/openid-configuration'
|
||||
| '/_app/billing'
|
||||
| '/_app/settings'
|
||||
| '/_app/support'
|
||||
| '/_auth/sign-in'
|
||||
| '/_auth/sign-up'
|
||||
| '/_authenticated/oauth-consent'
|
||||
| '/_authenticated/subscribe'
|
||||
| '/_app/'
|
||||
| '/_project/p/$projectId'
|
||||
| '/.well-known/oauth-protected-resource/mcp'
|
||||
| '/_app/help/dataforseo-api-key'
|
||||
| '/api/auth/$'
|
||||
| '/api/autumn/$'
|
||||
| '/.well-known/oauth-authorization-server/api/auth'
|
||||
| '/_project/p/$projectId/ai'
|
||||
| '/_project/p/$projectId/audit'
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
@ -402,6 +467,9 @@ export interface RootRouteChildren {
|
||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||
}
|
||||
@ -471,6 +539,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
|
||||
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': {
|
||||
id: '/_auth/sign-up'
|
||||
path: '/sign-up'
|
||||
@ -506,6 +581,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppBillingRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/.well-known/openid-configuration': {
|
||||
id: '/.well-known/openid-configuration'
|
||||
path: '/.well-known/openid-configuration'
|
||||
fullPath: '/.well-known/openid-configuration'
|
||||
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/.well-known/oauth-authorization-server': {
|
||||
id: '/.well-known/oauth-authorization-server'
|
||||
path: '/.well-known/oauth-authorization-server'
|
||||
fullPath: '/.well-known/oauth-authorization-server'
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/autumn/$': {
|
||||
id: '/api/autumn/$'
|
||||
path: '/api/autumn/$'
|
||||
@ -527,6 +616,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
|
||||
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': {
|
||||
id: '/_project/p/$projectId'
|
||||
path: '/p/$projectId'
|
||||
@ -604,6 +700,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/.well-known/oauth-authorization-server/api/auth': {
|
||||
id: '/.well-known/oauth-authorization-server/api/auth'
|
||||
path: '/api/auth'
|
||||
fullPath: '/.well-known/oauth-authorization-server/api/auth'
|
||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
|
||||
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
||||
}
|
||||
'/_project/p/$projectId/rank-tracking/': {
|
||||
id: '/_project/p/$projectId/rank-tracking/'
|
||||
path: '/'
|
||||
@ -748,10 +851,12 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||
|
||||
interface AuthenticatedRouteChildren {
|
||||
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
|
||||
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
|
||||
}
|
||||
|
||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
|
||||
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
|
||||
}
|
||||
|
||||
@ -759,6 +864,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||
AuthenticatedRouteChildren,
|
||||
)
|
||||
|
||||
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
||||
{
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute:
|
||||
DotwellKnownOauthAuthorizationServerApiAuthRoute,
|
||||
}
|
||||
|
||||
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
||||
DotwellKnownOauthAuthorizationServerRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
AppRouteRoute: AppRouteRouteWithChildren,
|
||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||
@ -767,6 +887,11 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
VerifyEmailRoute: VerifyEmailRoute,
|
||||
DotwellKnownOauthAuthorizationServerRoute:
|
||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
||||
DotwellKnownOauthProtectedResourceMcpRoute:
|
||||
DotwellKnownOauthProtectedResourceMcpRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||
}
|
||||
|
||||
31
src/routes/[.]well-known/oauth-authorization-server.ts
Normal file
31
src/routes/[.]well-known/oauth-authorization-server.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
@ -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);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
47
src/routes/[.]well-known/oauth-protected-resource/mcp.ts
Normal file
47
src/routes/[.]well-known/oauth-protected-resource/mcp.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
29
src/routes/[.]well-known/openid-configuration.ts
Normal file
29
src/routes/[.]well-known/openid-configuration.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
|
||||
function unavailableMetadataResponse() {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response("Missing Better Auth hosted configuration", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
||||
return unavailableMetadataResponse();
|
||||
}
|
||||
|
||||
return oauthProviderOpenIdConfigMetadata(getAuth())(request);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
169
src/routes/_authenticated.oauth-consent.tsx
Normal file
169
src/routes/_authenticated.oauth-consent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -18,7 +18,9 @@ function AuthenticatedShellLayout() {
|
||||
if (!session?.user?.id) {
|
||||
void navigate({
|
||||
to: "/sign-in",
|
||||
search: { redirect: window.location.pathname },
|
||||
search: {
|
||||
redirect: `${window.location.pathname}${window.location.search}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [isPending, isHostedMode, session?.user?.id, navigate]);
|
||||
|
||||
83
src/routes/api/auth/$.test.ts
Normal file
83
src/routes/api/auth/$.test.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("cloudflare:workers", () => ({
|
||||
env: {
|
||||
AUTH_MODE: "hosted",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (routeConfig: unknown) => routeConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth", () => ({
|
||||
getAuth: () => ({ handler: vi.fn() }),
|
||||
getHostedBaseUrl: () => "https://open-seo.test",
|
||||
hasHostedAuthConfig: () => true,
|
||||
}));
|
||||
|
||||
describe("maybeInjectMcpResource", () => {
|
||||
it("injects the MCP resource into form token requests when missing", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: "code_123",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await maybeInjectMcpResource(request);
|
||||
const params = new URLSearchParams(await result.text());
|
||||
|
||||
expect(params.get("resource")).toBe("https://open-seo.test/mcp");
|
||||
expect(params.get("grant_type")).toBe("authorization_code");
|
||||
expect(params.get("code")).toBe("code_123");
|
||||
});
|
||||
|
||||
it("leaves token requests alone when a resource is already present", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
resource: "https://other-resource.test/mcp",
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
||||
});
|
||||
|
||||
it("skips requests that are not matching form POST token requests", async () => {
|
||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
||||
const requests = [
|
||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "GET",
|
||||
}),
|
||||
new Request("https://open-seo.test/api/auth/oauth2/authorize", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: "authorization_code" }),
|
||||
}),
|
||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ grant_type: "authorization_code" }),
|
||||
}),
|
||||
];
|
||||
|
||||
for (const request of requests) {
|
||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,48 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||
import { getMcpResource } from "@/lib/oauth-resource";
|
||||
|
||||
function handleAuthRequest(request: Request) {
|
||||
const TOKEN_PATH = "/api/auth/oauth2/token";
|
||||
|
||||
// Inject RFC 8707 `resource` into /oauth2/token requests when the client
|
||||
// omitted it. Some MCP clients (notably codex as of 2026-05) skip the
|
||||
// resource indicator, which makes better-auth issue an opaque access token
|
||||
// (see `checkResource` in @better-auth/oauth-provider — audience comes from
|
||||
// `ctx.body.resource` at token-issuance time, not from the stored authorize
|
||||
// query). Without an audience to bind, no `aud` claim → opaque token → no
|
||||
// local JWT verify on the resource side.
|
||||
//
|
||||
// We only have one valid audience (`validAudiences: [mcpResource]` in
|
||||
// auth-config.ts), so it is safe to default missing resources to it. Remove
|
||||
// this shim once MCP clients reliably pass `resource` per spec.
|
||||
export async function maybeInjectMcpResource(
|
||||
request: Request,
|
||||
): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname !== TOKEN_PATH) return request;
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/x-www-form-urlencoded"))
|
||||
return request;
|
||||
|
||||
const body = await request.clone().text();
|
||||
const params = new URLSearchParams(body);
|
||||
if (params.has("resource")) return request;
|
||||
|
||||
params.set("resource", getMcpResource(getHostedBaseUrl()));
|
||||
|
||||
return new Request(request.url, {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: params.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAuthRequest(request: Request) {
|
||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||
return new Response("Not found", {
|
||||
status: 404,
|
||||
@ -17,7 +56,7 @@ function handleAuthRequest(request: Request) {
|
||||
}
|
||||
|
||||
const auth = getAuth();
|
||||
return auth.handler(request);
|
||||
return auth.handler(await maybeInjectMcpResource(request));
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/api/auth/$")({
|
||||
|
||||
@ -7,8 +7,20 @@ import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rank
|
||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||
import { handleMcpRequest, MCP_ROUTE } from "@/server/mcp/handler";
|
||||
|
||||
const fetch = createStartHandler(defaultStreamHandler);
|
||||
const appFetch = createStartHandler(defaultStreamHandler);
|
||||
const fetch = (
|
||||
request: Request,
|
||||
env: Env,
|
||||
ctx: ExecutionContext,
|
||||
): Response | Promise<Response> => {
|
||||
if (new URL(request.url).pathname === MCP_ROUTE) {
|
||||
return handleMcpRequest(request, env, ctx);
|
||||
}
|
||||
|
||||
return appFetch(request);
|
||||
};
|
||||
|
||||
// Export Workflow classes as named exports
|
||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||
|
||||
76
src/server/mcp/context.ts
Normal file
76
src/server/mcp/context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
47
src/server/mcp/formatters.test.ts
Normal file
47
src/server/mcp/formatters.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
34
src/server/mcp/formatters.ts
Normal file
34
src/server/mcp/formatters.ts
Normal 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;
|
||||
}
|
||||
280
src/server/mcp/handler.test.ts
Normal file
280
src/server/mcp/handler.test.ts
Normal 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
163
src/server/mcp/handler.ts
Normal 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);
|
||||
}
|
||||
98
src/server/mcp/project-auth.test.ts
Normal file
98
src/server/mcp/project-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
40
src/server/mcp/project-auth.ts
Normal file
40
src/server/mcp/project-auth.ts
Normal 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
24
src/server/mcp/schemas.ts
Normal 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
67
src/server/mcp/server.ts
Normal 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;
|
||||
}
|
||||
81
src/server/mcp/tools/get-backlinks-overview.ts
Normal file
81
src/server/mcp/tools/get-backlinks-overview.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
67
src/server/mcp/tools/get-domain-keyword-suggestions.ts
Normal file
67
src/server/mcp/tools/get-domain-keyword-suggestions.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
63
src/server/mcp/tools/get-domain-overview.ts
Normal file
63
src/server/mcp/tools/get-domain-overview.ts
Normal 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,
|
||||
});
|
||||
}),
|
||||
};
|
||||
88
src/server/mcp/tools/get-rank-tracker.ts
Normal file
88
src/server/mcp/tools/get-rank-tracker.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
100
src/server/mcp/tools/get-serp-results.ts
Normal file
100
src/server/mcp/tools/get-serp-results.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
40
src/server/mcp/tools/list-projects.ts
Normal file
40
src/server/mcp/tools/list-projects.ts
Normal 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}`),
|
||||
})),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
46
src/server/mcp/tools/list-saved-keywords.ts
Normal file
46
src/server/mcp/tools/list-saved-keywords.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
),
|
||||
};
|
||||
101
src/server/mcp/tools/research-keywords.ts
Normal file
101
src/server/mcp/tools/research-keywords.ts
Normal 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 },
|
||||
});
|
||||
}),
|
||||
};
|
||||
51
src/server/mcp/tools/save-keywords.ts
Normal file
51
src/server/mcp/tools/save-keywords.ts
Normal 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`,
|
||||
),
|
||||
});
|
||||
}),
|
||||
};
|
||||
68
src/server/mcp/tools/whoami.ts
Normal file
68
src/server/mcp/tools/whoami.ts
Normal 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
18
src/server/mcp/urls.ts
Normal 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();
|
||||
}
|
||||
12
src/server/mcp/user-email.ts
Normal file
12
src/server/mcp/user-email.ts
Normal 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;
|
||||
}
|
||||
33
src/serverFunctions/oauth.ts
Normal file
33
src/serverFunctions/oauth.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { oauthClient } from "@/db/better-auth-schema";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
const getOAuthClientInfoSchema = z.object({
|
||||
clientId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const getOAuthClientInfo = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => getOAuthClientInfoSchema.parse(data))
|
||||
.handler(async ({ data }) => {
|
||||
const row = await db
|
||||
.select({
|
||||
name: oauthClient.name,
|
||||
icon: oauthClient.icon,
|
||||
uri: oauthClient.uri,
|
||||
})
|
||||
.from(oauthClient)
|
||||
.where(eq(oauthClient.clientId, data.clientId))
|
||||
.get();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
name: row.name ?? null,
|
||||
icon: row.icon ?? null,
|
||||
uri: row.uri ?? null,
|
||||
};
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user