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
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
2
.github/workflows/sourcemaps.yml
vendored
2
.github/workflows/sourcemaps.yml
vendored
@ -20,8 +20,6 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ ENV PATH=$PNPM_HOME:$PATH
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable && corepack prepare pnpm@10.30.1 --activate
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { baseAuthConfig } from "./src/lib/auth-config";
|
import { createBaseAuthConfig } from "./src/lib/auth-config";
|
||||||
|
|
||||||
const CLI_DEV_BASE_URL = "http://localhost:3000";
|
const CLI_DEV_BASE_URL = "http://localhost:3000";
|
||||||
|
const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL,
|
baseURL: baseUrl,
|
||||||
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
||||||
...baseAuthConfig,
|
...createBaseAuthConfig(baseUrl),
|
||||||
});
|
});
|
||||||
|
|||||||
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,
|
"when": 1778031161783,
|
||||||
"tag": "0011_colorful_dark_beast",
|
"tag": "0011_colorful_dark_beast",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1778113978173,
|
||||||
|
"tag": "0012_closed_impossible_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -11,6 +11,7 @@
|
|||||||
"drizzle.config.ts",
|
"drizzle.config.ts",
|
||||||
// DB schema — exports consumed via `import * as schema` / drizzle()
|
// DB schema — exports consumed via `import * as schema` / drizzle()
|
||||||
"src/db/index.ts",
|
"src/db/index.ts",
|
||||||
|
"src/db/app.schema.ts",
|
||||||
"src/db/better-auth-schema.ts",
|
"src/db/better-auth-schema.ts",
|
||||||
],
|
],
|
||||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||||
|
|||||||
@ -4,8 +4,9 @@
|
|||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.30.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "AUTH_MODE=local_noauth vite dev",
|
"dev": "vite dev",
|
||||||
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
"dev:agents": "mkdir -p .logs && portless run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||||
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
"dev:agents:force": "mkdir -p .logs && portless --force run vite dev 2>&1 | tee .logs/dev-server.log",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
@ -49,7 +50,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@better-auth/oauth-provider": "^1.5.5",
|
||||||
"@every-app/sdk": "^0.1.14",
|
"@every-app/sdk": "^0.1.14",
|
||||||
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@tanstack/query-core": "^5.90.9",
|
"@tanstack/query-core": "^5.90.9",
|
||||||
"@tanstack/react-form": "^1.25.0",
|
"@tanstack/react-form": "^1.25.0",
|
||||||
"@tanstack/react-query": "^5.90.9",
|
"@tanstack/react-query": "^5.90.9",
|
||||||
@ -57,6 +60,7 @@
|
|||||||
"@tanstack/react-router-devtools": "^1.166.11",
|
"@tanstack/react-router-devtools": "^1.166.11",
|
||||||
"@tanstack/react-start": "^1.167.16",
|
"@tanstack/react-start": "^1.167.16",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"agents": "0.12.3",
|
||||||
"autumn-js": "^1.1.7",
|
"autumn-js": "^1.1.7",
|
||||||
"better-auth": "^1.5.5",
|
"better-auth": "^1.5.5",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
|
|||||||
1287
pnpm-lock.yaml
generated
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 }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
members: many(member),
|
members: many(member),
|
||||||
invitations: many(invitation),
|
invitations: many(invitation),
|
||||||
|
oauthClients: many(oauthClient),
|
||||||
|
oauthRefreshTokens: many(oauthRefreshToken),
|
||||||
|
oauthAccessTokens: many(oauthAccessToken),
|
||||||
|
oauthConsents: many(oauthConsent),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
export const sessionRelations = relations(session, ({ one, many }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
|
oauthRefreshTokens: many(oauthRefreshToken),
|
||||||
|
oauthAccessTokens: many(oauthAccessToken),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
@ -198,3 +296,65 @@ export const invitationRelations = relations(invitation, ({ one }) => ({
|
|||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const oauthClientRelations = relations(oauthClient, ({ one, many }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [oauthClient.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
oauthRefreshTokens: many(oauthRefreshToken),
|
||||||
|
oauthAccessTokens: many(oauthAccessToken),
|
||||||
|
oauthConsents: many(oauthConsent),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const oauthRefreshTokenRelations = relations(
|
||||||
|
oauthRefreshToken,
|
||||||
|
({ one, many }) => ({
|
||||||
|
oauthClient: one(oauthClient, {
|
||||||
|
fields: [oauthRefreshToken.clientId],
|
||||||
|
references: [oauthClient.clientId],
|
||||||
|
}),
|
||||||
|
session: one(session, {
|
||||||
|
fields: [oauthRefreshToken.sessionId],
|
||||||
|
references: [session.id],
|
||||||
|
}),
|
||||||
|
user: one(user, {
|
||||||
|
fields: [oauthRefreshToken.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
oauthAccessTokens: many(oauthAccessToken),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const oauthAccessTokenRelations = relations(
|
||||||
|
oauthAccessToken,
|
||||||
|
({ one }) => ({
|
||||||
|
oauthClient: one(oauthClient, {
|
||||||
|
fields: [oauthAccessToken.clientId],
|
||||||
|
references: [oauthClient.clientId],
|
||||||
|
}),
|
||||||
|
session: one(session, {
|
||||||
|
fields: [oauthAccessToken.sessionId],
|
||||||
|
references: [session.id],
|
||||||
|
}),
|
||||||
|
user: one(user, {
|
||||||
|
fields: [oauthAccessToken.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
oauthRefreshToken: one(oauthRefreshToken, {
|
||||||
|
fields: [oauthAccessToken.refreshId],
|
||||||
|
references: [oauthRefreshToken.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const oauthConsentRelations = relations(oauthConsent, ({ one }) => ({
|
||||||
|
oauthClient: one(oauthClient, {
|
||||||
|
fields: [oauthConsent.clientId],
|
||||||
|
references: [oauthClient.clientId],
|
||||||
|
}),
|
||||||
|
user: one(user, {
|
||||||
|
fields: [oauthConsent.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
inferAdditionalFields,
|
inferAdditionalFields,
|
||||||
organizationClient,
|
organizationClient,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
|
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||||
import { userAdditionalFields } from "@/lib/auth-options";
|
import { userAdditionalFields } from "@/lib/auth-options";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
@ -11,6 +12,7 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
plugins: [
|
plugins: [
|
||||||
organizationClient(),
|
organizationClient(),
|
||||||
|
oauthProviderClient(),
|
||||||
inferAdditionalFields({ user: userAdditionalFields }),
|
inferAdditionalFields({ user: userAdditionalFields }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,77 @@
|
|||||||
import { organization } from "better-auth/plugins";
|
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||||
|
import { jwt, organization } from "better-auth/plugins";
|
||||||
import { baseAuthOptions } from "@/lib/auth-options";
|
import { baseAuthOptions } from "@/lib/auth-options";
|
||||||
|
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||||
|
import {
|
||||||
|
getMcpOrganizationIdClaim,
|
||||||
|
getMcpResource,
|
||||||
|
MCP_SCOPE,
|
||||||
|
} from "@/lib/oauth-resource";
|
||||||
|
|
||||||
export const baseAuthConfig = {
|
const MCP_OAUTH_SCOPES = ["offline_access", MCP_SCOPE];
|
||||||
...baseAuthOptions,
|
|
||||||
plugins: [organization()],
|
function assertSingleMcpAudience(audiences: string[]) {
|
||||||
};
|
if (audiences.length !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"MCP OAuth resource injection requires exactly one valid audience",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBaseAuthConfig(baseUrl: string) {
|
||||||
|
const mcpResource = getMcpResource(baseUrl);
|
||||||
|
const mcpOrganizationIdClaim = getMcpOrganizationIdClaim(baseUrl);
|
||||||
|
const validAudiences = [mcpResource];
|
||||||
|
|
||||||
|
assertSingleMcpAudience(validAudiences);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseAuthOptions,
|
||||||
|
plugins: [
|
||||||
|
organization(),
|
||||||
|
jwt(),
|
||||||
|
oauthProvider({
|
||||||
|
loginPage: "/sign-in",
|
||||||
|
consentPage: "/oauth-consent",
|
||||||
|
signup: {
|
||||||
|
page: "/sign-up",
|
||||||
|
},
|
||||||
|
scopes: MCP_OAUTH_SCOPES,
|
||||||
|
// We publish /.well-known/oauth-authorization-server/api/auth via
|
||||||
|
// TanStack routes, so silence Better Auth's metadata reminder.
|
||||||
|
silenceWarnings: {
|
||||||
|
oauthAuthServerConfig: true,
|
||||||
|
},
|
||||||
|
allowDynamicClientRegistration: true,
|
||||||
|
clientRegistrationDefaultScopes: MCP_OAUTH_SCOPES,
|
||||||
|
clientRegistrationAllowedScopes: MCP_OAUTH_SCOPES,
|
||||||
|
// TODO: drop once the MCP spec settles on a replacement for
|
||||||
|
// unauthenticated DCR — better-auth has flagged this option for removal.
|
||||||
|
allowUnauthenticatedClientRegistration: true,
|
||||||
|
// Single allowed audience — see `routes/api/auth/$.ts`, which defaults
|
||||||
|
// missing `resource` on /oauth2/token to this value. Adding a second
|
||||||
|
// audience here would make that injection unsafe (we'd no longer know
|
||||||
|
// which to pick) and require scope-conditional logic in the route.
|
||||||
|
validAudiences,
|
||||||
|
postLogin: {
|
||||||
|
page: "/oauth-consent",
|
||||||
|
shouldRedirect: () => false,
|
||||||
|
consentReferenceId: ({ session, scopes }) => {
|
||||||
|
if (!scopes.includes(MCP_SCOPE)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getActiveOrganizationId({ session }) ?? undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customAccessTokenClaims: ({ referenceId, scopes }) => {
|
||||||
|
if (!scopes.includes(MCP_SCOPE)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return referenceId ? { [mcpOrganizationIdClaim]: referenceId } : {};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { baseAuthConfig } from "@/lib/auth-config";
|
import { createBaseAuthConfig } from "@/lib/auth-config";
|
||||||
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
|
||||||
import {
|
import {
|
||||||
sendHostedPasswordResetEmail,
|
sendHostedPasswordResetEmail,
|
||||||
@ -25,10 +25,14 @@ const hostedBaseUrlSchema = z
|
|||||||
function createAuth() {
|
function createAuth() {
|
||||||
const baseUrl = getHostedBaseUrl();
|
const baseUrl = getHostedBaseUrl();
|
||||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
|
const baseAuthConfig = createBaseAuthConfig(baseUrl);
|
||||||
|
|
||||||
const auth = betterAuth({
|
const auth = betterAuth({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
secret: getHostedSecret(),
|
secret: getHostedSecret(),
|
||||||
|
// Disable Better Auth's generic /token endpoint so OAuth access tokens only
|
||||||
|
// flow through /oauth2/token, where the MCP resource shim can run.
|
||||||
|
disabledPaths: ["/token"],
|
||||||
...baseAuthConfig,
|
...baseAuthConfig,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
...baseAuthConfig.emailAndPassword,
|
...baseAuthConfig.emailAndPassword,
|
||||||
@ -102,7 +106,7 @@ function getTrustedOrigins(baseUrl: string) {
|
|||||||
return trustedOrigins;
|
return trustedOrigins;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHostedBaseUrl() {
|
export function getHostedBaseUrl() {
|
||||||
const baseUrl = env.BETTER_AUTH_URL?.trim();
|
const baseUrl = env.BETTER_AUTH_URL?.trim();
|
||||||
|
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
|
|||||||
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 AppRouteRouteImport } from './routes/_app/route'
|
||||||
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
import { Route as AppIndexRouteImport } from './routes/_app/index'
|
||||||
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
|
import { Route as AuthenticatedSubscribeRouteImport } from './routes/_authenticated.subscribe'
|
||||||
|
import { Route as AuthenticatedOauthConsentRouteImport } from './routes/_authenticated.oauth-consent'
|
||||||
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||||
|
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
|
||||||
|
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
|
||||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||||
|
import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp'
|
||||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||||
@ -37,6 +41,7 @@ import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_proj
|
|||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||||
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
import { Route as ProjectPProjectIdAiRouteImport } from './routes/_project/p/$projectId/ai'
|
||||||
|
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.]well-known/oauth-authorization-server/api/auth'
|
||||||
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
||||||
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
||||||
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
||||||
@ -83,6 +88,12 @@ const AuthenticatedSubscribeRoute = AuthenticatedSubscribeRouteImport.update({
|
|||||||
path: '/subscribe',
|
path: '/subscribe',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedOauthConsentRoute =
|
||||||
|
AuthenticatedOauthConsentRouteImport.update({
|
||||||
|
id: '/oauth-consent',
|
||||||
|
path: '/oauth-consent',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthSignUpRoute = AuthSignUpRouteImport.update({
|
const AuthSignUpRoute = AuthSignUpRouteImport.update({
|
||||||
id: '/sign-up',
|
id: '/sign-up',
|
||||||
path: '/sign-up',
|
path: '/sign-up',
|
||||||
@ -108,6 +119,18 @@ const AppBillingRoute = AppBillingRouteImport.update({
|
|||||||
path: '/billing',
|
path: '/billing',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DotwellKnownOpenidConfigurationRoute =
|
||||||
|
DotwellKnownOpenidConfigurationRouteImport.update({
|
||||||
|
id: '/.well-known/openid-configuration',
|
||||||
|
path: '/.well-known/openid-configuration',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DotwellKnownOauthAuthorizationServerRoute =
|
||||||
|
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
||||||
|
id: '/.well-known/oauth-authorization-server',
|
||||||
|
path: '/.well-known/oauth-authorization-server',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||||
id: '/api/autumn/$',
|
id: '/api/autumn/$',
|
||||||
path: '/api/autumn/$',
|
path: '/api/autumn/$',
|
||||||
@ -123,6 +146,12 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
|||||||
path: '/help/dataforseo-api-key',
|
path: '/help/dataforseo-api-key',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DotwellKnownOauthProtectedResourceMcpRoute =
|
||||||
|
DotwellKnownOauthProtectedResourceMcpRouteImport.update({
|
||||||
|
id: '/.well-known/oauth-protected-resource/mcp',
|
||||||
|
path: '/.well-known/oauth-protected-resource/mcp',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
|
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
|
||||||
id: '/p/$projectId',
|
id: '/p/$projectId',
|
||||||
path: '/p/$projectId',
|
path: '/p/$projectId',
|
||||||
@ -183,6 +212,12 @@ const ProjectPProjectIdAiRoute = ProjectPProjectIdAiRouteImport.update({
|
|||||||
path: '/ai',
|
path: '/ai',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
||||||
|
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
|
||||||
|
id: '/api/auth',
|
||||||
|
path: '/api/auth',
|
||||||
|
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
||||||
|
} as any)
|
||||||
const ProjectPProjectIdRankTrackingIndexRoute =
|
const ProjectPProjectIdRankTrackingIndexRoute =
|
||||||
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@ -213,16 +248,21 @@ export interface FileRoutesByFullPath {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
|
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
@ -243,15 +283,20 @@ export interface FileRoutesByTo {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
'/support': typeof AppSupportRoute
|
'/support': typeof AppSupportRoute
|
||||||
'/sign-in': typeof AuthSignInRoute
|
'/sign-in': typeof AuthSignInRoute
|
||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
|
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -274,17 +319,22 @@ export interface FileRoutesById {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
|
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
|
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
'/_app/settings': typeof AppSettingsRoute
|
'/_app/settings': typeof AppSettingsRoute
|
||||||
'/_app/support': typeof AppSupportRoute
|
'/_app/support': typeof AppSupportRoute
|
||||||
'/_auth/sign-in': typeof AuthSignInRoute
|
'/_auth/sign-in': typeof AuthSignInRoute
|
||||||
'/_auth/sign-up': typeof AuthSignUpRoute
|
'/_auth/sign-up': typeof AuthSignUpRoute
|
||||||
|
'/_authenticated/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
'/_app/': typeof AppIndexRoute
|
'/_app/': typeof AppIndexRoute
|
||||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
|
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
|
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
'/_project/p/$projectId/ai': typeof ProjectPProjectIdAiRoute
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
@ -307,16 +357,21 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
|
| '/.well-known/oauth-authorization-server'
|
||||||
|
| '/.well-known/openid-configuration'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
|
| '/.well-known/oauth-protected-resource/mcp'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
| '/p/$projectId/ai'
|
| '/p/$projectId/ai'
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
@ -337,15 +392,20 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
|
| '/.well-known/oauth-authorization-server'
|
||||||
|
| '/.well-known/openid-configuration'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/support'
|
| '/support'
|
||||||
| '/sign-in'
|
| '/sign-in'
|
||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
|
| '/.well-known/oauth-protected-resource/mcp'
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
| '/p/$projectId/ai'
|
| '/p/$projectId/ai'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
@ -367,17 +427,22 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
|
| '/.well-known/oauth-authorization-server'
|
||||||
|
| '/.well-known/openid-configuration'
|
||||||
| '/_app/billing'
|
| '/_app/billing'
|
||||||
| '/_app/settings'
|
| '/_app/settings'
|
||||||
| '/_app/support'
|
| '/_app/support'
|
||||||
| '/_auth/sign-in'
|
| '/_auth/sign-in'
|
||||||
| '/_auth/sign-up'
|
| '/_auth/sign-up'
|
||||||
|
| '/_authenticated/oauth-consent'
|
||||||
| '/_authenticated/subscribe'
|
| '/_authenticated/subscribe'
|
||||||
| '/_app/'
|
| '/_app/'
|
||||||
| '/_project/p/$projectId'
|
| '/_project/p/$projectId'
|
||||||
|
| '/.well-known/oauth-protected-resource/mcp'
|
||||||
| '/_app/help/dataforseo-api-key'
|
| '/_app/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
|
| '/.well-known/oauth-authorization-server/api/auth'
|
||||||
| '/_project/p/$projectId/ai'
|
| '/_project/p/$projectId/ai'
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
@ -402,6 +467,9 @@ export interface RootRouteChildren {
|
|||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
|
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
||||||
|
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
||||||
|
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
}
|
}
|
||||||
@ -471,6 +539,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
|
preLoaderRoute: typeof AuthenticatedSubscribeRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/oauth-consent': {
|
||||||
|
id: '/_authenticated/oauth-consent'
|
||||||
|
path: '/oauth-consent'
|
||||||
|
fullPath: '/oauth-consent'
|
||||||
|
preLoaderRoute: typeof AuthenticatedOauthConsentRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_auth/sign-up': {
|
'/_auth/sign-up': {
|
||||||
id: '/_auth/sign-up'
|
id: '/_auth/sign-up'
|
||||||
path: '/sign-up'
|
path: '/sign-up'
|
||||||
@ -506,6 +581,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppBillingRouteImport
|
preLoaderRoute: typeof AppBillingRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/.well-known/openid-configuration': {
|
||||||
|
id: '/.well-known/openid-configuration'
|
||||||
|
path: '/.well-known/openid-configuration'
|
||||||
|
fullPath: '/.well-known/openid-configuration'
|
||||||
|
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/.well-known/oauth-authorization-server': {
|
||||||
|
id: '/.well-known/oauth-authorization-server'
|
||||||
|
path: '/.well-known/oauth-authorization-server'
|
||||||
|
fullPath: '/.well-known/oauth-authorization-server'
|
||||||
|
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/autumn/$': {
|
'/api/autumn/$': {
|
||||||
id: '/api/autumn/$'
|
id: '/api/autumn/$'
|
||||||
path: '/api/autumn/$'
|
path: '/api/autumn/$'
|
||||||
@ -527,6 +616,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
|
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
|
'/.well-known/oauth-protected-resource/mcp': {
|
||||||
|
id: '/.well-known/oauth-protected-resource/mcp'
|
||||||
|
path: '/.well-known/oauth-protected-resource/mcp'
|
||||||
|
fullPath: '/.well-known/oauth-protected-resource/mcp'
|
||||||
|
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_project/p/$projectId': {
|
'/_project/p/$projectId': {
|
||||||
id: '/_project/p/$projectId'
|
id: '/_project/p/$projectId'
|
||||||
path: '/p/$projectId'
|
path: '/p/$projectId'
|
||||||
@ -604,6 +700,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdAiRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
|
'/.well-known/oauth-authorization-server/api/auth': {
|
||||||
|
id: '/.well-known/oauth-authorization-server/api/auth'
|
||||||
|
path: '/api/auth'
|
||||||
|
fullPath: '/.well-known/oauth-authorization-server/api/auth'
|
||||||
|
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
|
||||||
|
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
||||||
|
}
|
||||||
'/_project/p/$projectId/rank-tracking/': {
|
'/_project/p/$projectId/rank-tracking/': {
|
||||||
id: '/_project/p/$projectId/rank-tracking/'
|
id: '/_project/p/$projectId/rank-tracking/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@ -748,10 +851,12 @@ const AuthRouteChildren: AuthRouteChildren = {
|
|||||||
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedOauthConsentRoute: typeof AuthenticatedOauthConsentRoute
|
||||||
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
|
AuthenticatedSubscribeRoute: typeof AuthenticatedSubscribeRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedOauthConsentRoute: AuthenticatedOauthConsentRoute,
|
||||||
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
|
AuthenticatedSubscribeRoute: AuthenticatedSubscribeRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -759,6 +864,21 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
|||||||
AuthenticatedRouteChildren,
|
AuthenticatedRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
||||||
|
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
||||||
|
{
|
||||||
|
DotwellKnownOauthAuthorizationServerApiAuthRoute:
|
||||||
|
DotwellKnownOauthAuthorizationServerApiAuthRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
||||||
|
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
||||||
|
DotwellKnownOauthAuthorizationServerRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
AppRouteRoute: AppRouteRouteWithChildren,
|
AppRouteRoute: AppRouteRouteWithChildren,
|
||||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||||
@ -767,6 +887,11 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
|
DotwellKnownOauthAuthorizationServerRoute:
|
||||||
|
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
||||||
|
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
||||||
|
DotwellKnownOauthProtectedResourceMcpRoute:
|
||||||
|
DotwellKnownOauthProtectedResourceMcpRoute,
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
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) {
|
if (!session?.user?.id) {
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/sign-in",
|
to: "/sign-in",
|
||||||
search: { redirect: window.location.pathname },
|
search: {
|
||||||
|
redirect: `${window.location.pathname}${window.location.search}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isPending, isHostedMode, session?.user?.id, navigate]);
|
}, [isPending, isHostedMode, session?.user?.id, navigate]);
|
||||||
|
|||||||
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 { createFileRoute } from "@tanstack/react-router";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
|
import { getMcpResource } from "@/lib/oauth-resource";
|
||||||
|
|
||||||
function handleAuthRequest(request: Request) {
|
const TOKEN_PATH = "/api/auth/oauth2/token";
|
||||||
|
|
||||||
|
// Inject RFC 8707 `resource` into /oauth2/token requests when the client
|
||||||
|
// omitted it. Some MCP clients (notably codex as of 2026-05) skip the
|
||||||
|
// resource indicator, which makes better-auth issue an opaque access token
|
||||||
|
// (see `checkResource` in @better-auth/oauth-provider — audience comes from
|
||||||
|
// `ctx.body.resource` at token-issuance time, not from the stored authorize
|
||||||
|
// query). Without an audience to bind, no `aud` claim → opaque token → no
|
||||||
|
// local JWT verify on the resource side.
|
||||||
|
//
|
||||||
|
// We only have one valid audience (`validAudiences: [mcpResource]` in
|
||||||
|
// auth-config.ts), so it is safe to default missing resources to it. Remove
|
||||||
|
// this shim once MCP clients reliably pass `resource` per spec.
|
||||||
|
export async function maybeInjectMcpResource(
|
||||||
|
request: Request,
|
||||||
|
): Promise<Request> {
|
||||||
|
if (request.method !== "POST") return request;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname !== TOKEN_PATH) return request;
|
||||||
|
|
||||||
|
const contentType = request.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("application/x-www-form-urlencoded"))
|
||||||
|
return request;
|
||||||
|
|
||||||
|
const body = await request.clone().text();
|
||||||
|
const params = new URLSearchParams(body);
|
||||||
|
if (params.has("resource")) return request;
|
||||||
|
|
||||||
|
params.set("resource", getMcpResource(getHostedBaseUrl()));
|
||||||
|
|
||||||
|
return new Request(request.url, {
|
||||||
|
method: request.method,
|
||||||
|
headers: request.headers,
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuthRequest(request: Request) {
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
return new Response("Not found", {
|
return new Response("Not found", {
|
||||||
status: 404,
|
status: 404,
|
||||||
@ -17,7 +56,7 @@ function handleAuthRequest(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
return auth.handler(request);
|
return auth.handler(await maybeInjectMcpResource(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/auth/$")({
|
export const Route = createFileRoute("/api/auth/$")({
|
||||||
|
|||||||
@ -7,8 +7,20 @@ import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rank
|
|||||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||||
|
import { handleMcpRequest, MCP_ROUTE } from "@/server/mcp/handler";
|
||||||
|
|
||||||
const fetch = createStartHandler(defaultStreamHandler);
|
const appFetch = createStartHandler(defaultStreamHandler);
|
||||||
|
const fetch = (
|
||||||
|
request: Request,
|
||||||
|
env: Env,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): Response | Promise<Response> => {
|
||||||
|
if (new URL(request.url).pathname === MCP_ROUTE) {
|
||||||
|
return handleMcpRequest(request, env, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return appFetch(request);
|
||||||
|
};
|
||||||
|
|
||||||
// Export Workflow classes as named exports
|
// Export Workflow classes as named exports
|
||||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||||
|
|||||||
76
src/server/mcp/context.ts
Normal file
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