MCP API key support (hosted mode) (#438)

This commit is contained in:
Ben Senescu 2026-08-11 19:20:44 -04:00 committed by GitHub
parent 7a1748af4e
commit 16eb599270
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 8478 additions and 164 deletions

View File

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

View File

@ -0,0 +1,28 @@
CREATE TABLE "apikey" (
"id" text PRIMARY KEY NOT NULL,
"config_id" text DEFAULT 'default' NOT NULL,
"name" text,
"start" text,
"prefix" text,
"key" text NOT NULL,
"reference_id" text NOT NULL,
"refill_interval" integer,
"refill_amount" integer,
"last_refill_at" timestamp with time zone,
"enabled" boolean DEFAULT true,
"rate_limit_enabled" boolean DEFAULT true,
"rate_limit_time_window" integer DEFAULT 60000,
"rate_limit_max" integer DEFAULT 120,
"request_count" integer DEFAULT 0,
"remaining" integer,
"last_request" timestamp with time zone,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
"permissions" text,
"metadata" text
);
--> statement-breakpoint
CREATE INDEX "apikey_configId_idx" ON "apikey" USING btree ("config_id");--> statement-breakpoint
CREATE INDEX "apikey_referenceId_idx" ON "apikey" USING btree ("reference_id");--> statement-breakpoint
CREATE INDEX "apikey_key_idx" ON "apikey" USING btree ("key");

File diff suppressed because it is too large Load Diff

View File

@ -134,6 +134,13 @@
"when": 1786208325786,
"tag": "0018_drop_reddit_attributions",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1786209409297,
"tag": "0019_clammy_selene",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,28 @@
CREATE TABLE `apikey` (
`id` text PRIMARY KEY NOT NULL,
`config_id` text DEFAULT 'default' NOT NULL,
`name` text,
`start` text,
`prefix` text,
`key` text NOT NULL,
`reference_id` text NOT NULL,
`refill_interval` integer,
`refill_amount` integer,
`last_refill_at` integer,
`enabled` integer DEFAULT true,
`rate_limit_enabled` integer DEFAULT true,
`rate_limit_time_window` integer DEFAULT 60000,
`rate_limit_max` integer DEFAULT 120,
`request_count` integer DEFAULT 0,
`remaining` integer,
`last_request` integer,
`expires_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`permissions` text,
`metadata` text
);
--> statement-breakpoint
CREATE INDEX `apikey_configId_idx` ON `apikey` (`config_id`);--> statement-breakpoint
CREATE INDEX `apikey_referenceId_idx` ON `apikey` (`reference_id`);--> statement-breakpoint
CREATE INDEX `apikey_key_idx` ON `apikey` (`key`);

File diff suppressed because it is too large Load Diff

View File

@ -288,6 +288,13 @@
"when": 1786208324064,
"tag": "0040_drop_reddit_attributions",
"breakpoints": true
},
{
"idx": 41,
"version": "6",
"when": 1786209407629,
"tag": "0041_free_marvex",
"breakpoints": true
}
]
}

View File

@ -75,6 +75,7 @@
},
"dependencies": {
"@ai-sdk/react": "^3.0.211",
"@better-auth/api-key": "1.6.22",
"@cloudflare/ai-chat": "^0.10.1",
"@cloudflare/think": "0.15.1",
"@cloudflare/workers-oauth-provider": "^0.10.2",

19
pnpm-lock.yaml generated
View File

@ -27,6 +27,9 @@ importers:
'@ai-sdk/react':
specifier: ^3.0.211
version: 3.0.211(react@19.2.4)(zod@4.3.6)
'@better-auth/api-key':
specifier: 1.6.22
version: 1.6.22(8d706e213363f271470d19db6adbe48a)
'@cloudflare/ai-chat':
specifier: ^0.10.1
version: 0.10.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(agents@0.20.1(@ai-sdk/react@3.0.211(react@19.2.4)(zod@4.3.6))(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(@cloudflare/workers-types@4.20260702.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(@modelcontextprotocol/server@2.0.0)(ai@6.0.199(zod@4.3.6))(chat@4.32.0(ai@6.0.199(zod@4.3.6))(zod@4.3.6))(just-bash@3.0.2)(react@19.2.4)(rolldown@1.0.1)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(zod@4.3.6))(ai@6.0.199(zod@4.3.6))(react@19.2.4)(zod@4.3.6)
@ -567,6 +570,14 @@ packages:
resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==}
engines: {node: ^22.18.0 || >=24.11.0}
'@better-auth/api-key@1.6.22':
resolution: {integrity: sha512-HDiiLYF0ov0zqhKv4CMTyLwpjTZ3UWl2dug451uTw40VM9zGxWSNxwILDcMDZ6hS5evaTHmmk7R5gciskEk2nQ==}
peerDependencies:
'@better-auth/core': ^1.6.22
'@better-auth/utils': 0.4.2
better-auth: ^1.6.22
better-call: 1.3.7
'@better-auth/core@1.6.22':
resolution: {integrity: sha512-aFH/5nzmR501jAJPKjJfiVg4BrkcjVCqq9WS9JnhTruE/2PIWopv1QGMiRIRqxXaPbHczri7cqRdhep3Lg5PMw==}
peerDependencies:
@ -6571,6 +6582,14 @@ snapshots:
'@babel/helper-string-parser': 8.0.0
'@babel/helper-validator-identifier': 8.0.2
'@better-auth/api-key@1.6.22(8d706e213363f271470d19db6adbe48a)':
dependencies:
'@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)
'@better-auth/utils': 0.4.2
better-auth: 1.6.22(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.168.26(esbuild@0.28.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rolldown@1.0.1)(rollup@4.59.0)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)))(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260702.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(mongodb@7.2.0(@aws-sdk/credential-providers@3.1080.0)(@mongodb-js/zstd@7.0.0))(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))
better-call: 1.3.7(zod@4.3.6)
zod: 4.4.3
'@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260702.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.1.1)':
dependencies:
'@better-auth/utils': 0.4.2

View File

@ -305,6 +305,10 @@ async function buildInventory(db: Db, user: UserRow) {
schema.ga4Connections,
eq(schema.ga4Connections.connectedByUserId, user.id),
),
api_keys: await db.$count(
schema.apikey,
eq(schema.apikey.referenceId, user.id),
),
};
return {
@ -486,6 +490,11 @@ async function erasePostgres(db: Db, user: UserRow, organizationIds: string[]) {
await tx
.delete(schema.ga4Connections)
.where(eq(schema.ga4Connections.connectedByUserId, user.id));
// apikey.reference_id mirrors the plugin's polymorphic schema and has no
// user FK, so keys don't cascade with the user row.
await tx
.delete(schema.apikey)
.where(eq(schema.apikey.referenceId, user.id));
await tx
.update(schema.audits)
.set({ startedByUserId: "gdpr-deleted-user" })

View File

@ -0,0 +1,96 @@
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { MoreHorizontal } from "lucide-react";
/**
* Kebab actions menu rendered through a portal in fixed position, so it can't
* be clipped by overflow containers (scrollable tables, overflow-hidden
* cards). Opens below the trigger, right-aligned. Closes on outside click,
* Escape, scroll, and resize.
*/
export function PortalMenu({
ariaLabel,
triggerClassName = "btn btn-ghost btn-xs btn-square",
triggerContent = <MoreHorizontal className="size-3.5" />,
menuClassName = "w-40",
children,
}: {
ariaLabel: string;
triggerClassName?: string;
triggerContent?: ReactNode;
menuClassName?: string;
/** Menu <li> items; call `close` before running an item's action. */
children: (close: () => void) => ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLUListElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
useEffect(() => {
if (!isOpen) return;
const closeOnOutsideClick = (event: MouseEvent) => {
const target = event.target;
if (
target instanceof Node &&
(buttonRef.current?.contains(target) ||
menuRef.current?.contains(target))
) {
return;
}
setIsOpen(false);
};
const close = () => setIsOpen(false);
const closeOnScroll = (event: Event) => {
// Scrolling inside the menu itself shouldn't dismiss it.
const target = event.target;
if (target instanceof Node && menuRef.current?.contains(target)) return;
setIsOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsOpen(false);
};
document.addEventListener("mousedown", closeOnOutsideClick);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("scroll", closeOnScroll, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("mousedown", closeOnOutsideClick);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("scroll", closeOnScroll, true);
window.removeEventListener("resize", close);
};
}, [isOpen]);
return (
<>
<button
ref={buttonRef}
type="button"
className={triggerClassName}
aria-label={ariaLabel}
aria-expanded={isOpen}
onClick={() => {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) setPosition({ top: rect.bottom + 4, left: rect.right });
setIsOpen((open) => !open);
}}
>
{triggerContent}
</button>
{isOpen && typeof document !== "undefined"
? createPortal(
<ul
ref={menuRef}
className={`menu fixed z-[1000] -translate-x-full rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${menuClassName}`}
style={{ top: position.top, left: position.left }}
>
{children(() => setIsOpen(false))}
</ul>,
document.body,
)
: null}
</>
);
}

View File

@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { MoreHorizontal, ScanSearch, Trash2 } from "lucide-react";
import { ScanSearch, Trash2 } from "lucide-react";
import type { getAuditHistory } from "@/serverFunctions/audit";
import { PortalMenu } from "@/client/components/PortalMenu";
import { formatDate, StatusBadge } from "@/client/features/audit/shared";
export function AuditHistorySection({
@ -95,24 +96,13 @@ function HistoryActions({
>
View
</Link>
<div className="dropdown dropdown-end">
<div
tabIndex={0}
role="button"
className="btn btn-ghost btn-xs btn-square"
aria-label="Audit actions"
>
<MoreHorizontal className="size-3.5" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-40"
>
<PortalMenu ariaLabel="Audit actions">
{(close) => (
<li>
<button
className="text-error"
onClick={(event) => {
event.stopPropagation();
onClick={() => {
close();
onDelete(auditId);
}}
>
@ -120,8 +110,8 @@ function HistoryActions({
Delete audit
</button>
</li>
</ul>
</div>
)}
</PortalMenu>
</div>
);
}

View File

@ -7,6 +7,7 @@ import {
Sheet,
TriangleAlert,
} from "lucide-react";
import { PortalMenu } from "@/client/components/PortalMenu";
import type {
CategoryTab,
ExportPayload,
@ -191,23 +192,30 @@ function ExportMenu({
visibleIssues: LighthouseIssue[];
}) {
return (
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-sm gap-1">
<PortalMenu
ariaLabel="Export Lighthouse issues"
triggerClassName="btn btn-sm gap-1"
triggerContent={
<>
<Download className="size-4" />
Export
<ChevronDown className="size-3 opacity-60" />
</div>
<ul
tabIndex={0}
className="dropdown-content z-10 menu p-2 shadow-lg bg-base-100 border border-base-300 rounded-box w-72"
</>
}
menuClassName="w-72 max-h-[min(30rem,70vh)] flex-nowrap overflow-y-auto"
>
{(close) => (
<>
<li className="menu-title">
<span>Export to Sheets</span>
</li>
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportSheets(visibleIssues, "current")}
onClick={() => {
close();
onExportSheets(visibleIssues, "current");
}}
>
<Sheet className="size-4" />
Open in Sheets {categoryLabelLower}
@ -216,7 +224,10 @@ function ExportMenu({
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportSheets(allIssues, "all")}
onClick={() => {
close();
onExportSheets(allIssues, "all");
}}
>
<Sheet className="size-4" />
Open in Sheets all actionable
@ -228,12 +239,13 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() =>
onClick={() => {
close();
onCopy(
exportCurrentCategory,
`Copied ${categoryLabelLower} issues`,
)
}
);
}}
>
<Copy className="size-4" />
Copy {categoryLabelLower} issues
@ -242,9 +254,10 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "issues" }, "Copied all actionable issues")
}
onClick={() => {
close();
onCopy({ mode: "issues" }, "Copied all actionable issues");
}}
>
<Copy className="size-4" />
Copy all actionable issues
@ -253,9 +266,10 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() =>
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
}
onClick={() => {
close();
onCopy({ mode: "full" }, "Copied saved Lighthouse payload");
}}
>
<Copy className="size-4" />
Copy saved Lighthouse payload
@ -267,7 +281,10 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() => onExport(exportCurrentCategory)}
onClick={() => {
close();
onExport(exportCurrentCategory);
}}
>
Download {categoryLabelLower} issues
</button>
@ -275,13 +292,22 @@ function ExportMenu({
<li>
<button
disabled={isBusy}
onClick={() => onExport({ mode: "issues" })}
onClick={() => {
close();
onExport({ mode: "issues" });
}}
>
Download all actionable issues
</button>
</li>
<li>
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
<button
disabled={isBusy}
onClick={() => {
close();
onExport({ mode: "full" });
}}
>
Download saved Lighthouse payload
</button>
</li>
@ -291,7 +317,10 @@ function ExportMenu({
<li>
<button
disabled={!visibleIssues.length}
onClick={() => onExportCsv(visibleIssues, "current")}
onClick={() => {
close();
onExportCsv(visibleIssues, "current");
}}
>
Download {categoryLabelLower} issues
</button>
@ -299,13 +328,17 @@ function ExportMenu({
<li>
<button
disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")}
onClick={() => {
close();
onExportCsv(allIssues, "all");
}}
>
Download all actionable issues
</button>
</li>
</ul>
</div>
</>
)}
</PortalMenu>
);
}

View File

@ -0,0 +1,270 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { PortalMenu } from "@/client/components/PortalMenu";
import { CopyButton } from "@/client/features/ai-mcp/SetupControls";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
// Better Auth rejects longer names with INVALID_NAME_LENGTH.
const MAX_KEY_NAME_LENGTH = 32;
export function ApiKeySettings() {
const queryClient = useQueryClient();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [name, setName] = useState("");
const [createdKey, setCreatedKey] = useState<string | null>(null);
const mcpUrl =
typeof window === "undefined"
? "https://app.openseo.so/mcp"
: `${window.location.origin}/mcp`;
const apiKeysQuery = useQuery({
queryKey: ["apiKeys"],
queryFn: async () => {
const result = await authClient.apiKey.list();
if (result.error) {
throw new Error(result.error.message ?? "Failed to load API keys");
}
return result.data.apiKeys.map((key) => ({
id: key.id,
name: key.name,
start: key.start,
createdAt: new Date(key.createdAt),
lastRequest: key.lastRequest ? new Date(key.lastRequest) : null,
}));
},
});
const createMutation = useMutation({
mutationFn: async (keyName: string) => {
const result = await authClient.apiKey.create({ name: keyName });
if (result.error || !result.data?.key) {
throw new Error(result.error?.message ?? "Failed to create the key");
}
return result.data.key;
},
onSuccess: (key) => {
setCreatedKey(key);
setName("");
captureClientEvent("mcp:api_key_created");
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
onError: (error) => {
toast.error(getStandardErrorMessage(error));
},
});
const revokeMutation = useMutation({
mutationFn: async (keyId: string) => {
const result = await authClient.apiKey.delete({ keyId });
if (result.error) {
throw new Error(result.error.message ?? "Failed to revoke the key");
}
},
onSuccess: () => {
captureClientEvent("mcp:api_key_revoked");
toast.success("API key revoked");
void queryClient.invalidateQueries({ queryKey: ["apiKeys"] });
},
onError: (error) => {
toast.error(getStandardErrorMessage(error));
},
});
const apiKeys = apiKeysQuery.data ?? [];
const closeCreateModal = () => {
setIsCreateOpen(false);
setCreatedKey(null);
setName("");
};
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">API keys</h2>
<div className="flex items-start justify-between gap-6">
<div>
<p className="text-sm">
Authenticate MCP clients when OAuth doesn't work
</p>
<p className="mt-1 text-sm text-base-content/60">
Use this for remote agents like Hermes where the normal login flow
doesn't work.
</p>
<p className="mt-1 text-sm">
<a
className="link link-primary"
href="https://openseo.so/docs/mcp"
target="_blank"
rel="noreferrer"
>
Setup guide
</a>
</p>
</div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setIsCreateOpen(true)}
>
Create API key
</button>
</div>
{apiKeysQuery.isError ? (
<p className="text-sm text-error">We couldn't load your API keys.</p>
) : apiKeys.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th>Last used</th>
<th className="w-10"></th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="hover">
<td className="max-w-[220px] truncate font-medium">
{key.name || "Unnamed key"}
</td>
<td className="font-mono text-xs text-base-content/70">
{key.start || "oseo_"}
</td>
<td className="text-xs text-base-content/70">
{key.createdAt.toLocaleDateString()}
</td>
<td className="text-xs text-base-content/70">
{key.lastRequest
? key.lastRequest.toLocaleDateString()
: "Never"}
</td>
<td>
<PortalMenu
ariaLabel={`Actions for ${key.name || "API key"}`}
>
{(close) => (
<li>
<button
className="text-error"
disabled={
revokeMutation.isPending &&
revokeMutation.variables === key.id
}
onClick={() => {
close();
if (
window.confirm(
`Revoke "${key.name || "Unnamed key"}"? Clients using it will stop working.`,
)
) {
revokeMutation.mutate(key.id);
}
}}
>
<Trash2 className="size-3.5" />
Revoke key
</button>
</li>
)}
</PortalMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{isCreateOpen ? (
<div className="modal modal-open">
<div className="modal-box max-w-md">
{createdKey ? (
<>
<h3 className="text-lg font-bold">Copy your new API key</h3>
<p className="mt-2 text-sm text-base-content/60">
It won't be shown again. Send it as{" "}
<span className="font-mono text-xs">
Authorization: Bearer
</span>{" "}
to <span className="font-mono text-xs">{mcpUrl}</span>.
</p>
<div className="mt-4 flex items-center gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-base-200 px-2.5 py-2 font-mono text-xs">
{createdKey}
</code>
<CopyButton
value={createdKey}
successMessage="API key copied"
iconOnly
/>
</div>
<div className="modal-action">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={closeCreateModal}
>
Done
</button>
</div>
</>
) : (
<form
onSubmit={(event) => {
event.preventDefault();
if (name.trim()) createMutation.mutate(name.trim());
}}
>
<h3 className="text-lg font-bold">Create API key</h3>
<label className="form-control mt-4 w-full">
<span className="label-text pb-1 text-xs text-base-content/60">
Name
</span>
<input
className="input input-sm input-bordered w-full"
placeholder="Claude Code on laptop"
value={name}
maxLength={MAX_KEY_NAME_LENGTH}
onChange={(event) => setName(event.currentTarget.value)}
required
autoFocus
/>
</label>
<div className="modal-action">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={closeCreateModal}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary btn-sm"
disabled={createMutation.isPending || !name.trim()}
>
{createMutation.isPending ? "Creating…" : "Create"}
</button>
</div>
</form>
)}
</div>
{/* No backdrop close on the reveal step: the key is shown once. */}
{createdKey ? (
<div className="modal-backdrop" />
) : (
<div className="modal-backdrop" onClick={closeCreateModal} />
)}
</div>
) : null}
</section>
);
}

View File

@ -164,6 +164,41 @@ export const invitation = sqliteTable(
],
);
export const apikey = sqliteTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
referenceId: text("reference_id").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: integer("last_refill_at", { mode: "timestamp_ms" }),
enabled: integer("enabled", { mode: "boolean" }).default(true),
rateLimitEnabled: integer("rate_limit_enabled", {
mode: "boolean",
}).default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(60000),
rateLimitMax: integer("rate_limit_max").default(120),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: integer("last_request", { mode: "timestamp_ms" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),

View File

@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
@ -152,6 +153,39 @@ export const invitation = pgTable(
],
);
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
referenceId: text("reference_id").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestampColumn("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(60000),
rateLimitMax: integer("rate_limit_max").default(120),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestampColumn("last_request"),
expiresAt: timestampColumn("expires_at"),
createdAt: timestampColumn("created_at").notNull(),
updatedAt: timestampColumn("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),

View File

@ -84,6 +84,7 @@ export const {
user,
session,
account,
apikey,
verification,
organization,
member,

19
src/lib/auth-api-key.ts Normal file
View File

@ -0,0 +1,19 @@
import { apiKey } from "@better-auth/api-key";
// Also the routing discriminator on /mcp: a credential starting with this
// prefix is treated as an API key, anything else flows to the OAuth provider.
export const API_KEY_PREFIX = "oseo_";
export function createApiKeyPlugin() {
return apiKey({
defaultPrefix: API_KEY_PREFIX,
// Stored display prefix ("oseo_" + 4 key chars) shown in Settings so keys
// are tellable apart; the plugin default of 6 barely clears the prefix.
startingCharactersConfig: { shouldStore: true, charactersLength: 9 },
rateLimit: {
enabled: true,
timeWindow: 60 * 1000,
maxRequests: 500,
},
});
}

View File

@ -1,4 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { apiKeyClient } from "@better-auth/api-key/client";
import {
genericOAuthClient,
inferAdditionalFields,
@ -11,6 +12,7 @@ import { getSignInHrefForLocation } from "@/lib/auth-redirect";
export const authClient = createAuthClient({
baseURL: typeof window !== "undefined" ? window.location.origin : "",
plugins: [
apiKeyClient(),
organizationClient(),
genericOAuthClient(),
inferAdditionalFields({ user: userAdditionalFields }),

View File

@ -34,7 +34,24 @@ export function createBaseAuthConfig() {
// server-side at signup via `auth.api.createOrganization({ body: { userId }})`
// — that's a "system action" (no session + userId in body) which better-auth
// exempts from this flag, so the bootstrap keeps working.
organization({ allowUserToCreateOrganization: false }),
//
// invitationLimit: 0 closes the other path to multi-org membership.
// "One user, one workspace" is a billing invariant: MCP API keys bill the
// user's first org, sessions bill the active org — identical only while
// users can't be invited into a second workspace. Remove this when teams
// ship, in the same change that moves API-key requests to project-level
// authz (org derived per tool call from the project; keys stay
// user-scoped, no key→workspace binding).
//
// disableOrganizationDeletion closes the delete side of the same loop:
// POST /api/auth/organization/delete (owner-callable by default) would
// cascade-delete the workspace, and the next request auto-creates a fresh
// org id — a fresh Autumn customer with a fresh credit grant.
organization({
allowUserToCreateOrganization: false,
invitationLimit: 0,
disableOrganizationDeletion: true,
}),
genericOAuth({
config: [
{

View File

@ -12,6 +12,7 @@ import * as pgSchema from "@/db/pg/schema";
import { getDatabaseProvider } from "@/db/provider";
import { z } from "zod";
import { isHostedAuthMode } from "@/lib/auth-mode";
import { createApiKeyPlugin } from "@/lib/auth-api-key";
import { createBaseAuthConfig } from "@/lib/auth-config";
import {
getHostedTurnstileSecretKey,
@ -96,6 +97,7 @@ function createAuth() {
database,
plugins: [
...baseAuthConfig.plugins,
...(isHostedAuthMode(env.AUTH_MODE) ? [createApiKeyPlugin()] : []),
...(turnstileSecretKey
? [
captcha({

View File

@ -1,6 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ArrowUpRight, ShieldAlert } from "lucide-react";
import { getAuthMode } from "@/lib/auth-mode";
import { getAuthMode, isHostedClientAuthMode } from "@/lib/auth-mode";
import { captureClientEvent } from "@/client/lib/posthog";
import { ClaudeIcon, CodexIcon } from "@/client/features/ai-mcp/AgentIcons";
import { AvailableTools } from "@/client/features/ai-mcp/AvailableTools";
@ -94,6 +94,15 @@ function AiPage() {
instance you are using now, whether hosted, self-hosted, or local.
Sign in with OpenSEO when prompted.
</p>
{isHostedClientAuthMode() ? (
<p className="mt-2 text-xs text-base-content/55">
For headless or CI setups, use an API key from{" "}
<Link className="link link-primary" to="/settings">
Settings
</Link>{" "}
instead of the OAuth login.
</p>
) : null}
</section>
<section className="mt-10">

View File

@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { Monitor, Moon, Sun } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { ApiKeySettings } from "@/client/features/settings/ApiKeySettings";
import { type ThemePreference, useThemePreference } from "@/client/lib/theme";
import { authClient, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
@ -49,7 +50,7 @@ function SettingsPage() {
return (
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
<div className="mx-auto max-w-xl space-y-10">
<div className="mx-auto max-w-3xl space-y-10">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<section className="space-y-3">
@ -90,6 +91,9 @@ function SettingsPage() {
</section>
{isHosted ? (
<>
<ApiKeySettings />
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">
Analytics
@ -113,6 +117,7 @@ function SettingsPage() {
/>
</div>
</section>
</>
) : (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">About</h2>

View File

@ -1,8 +1,10 @@
import { ActivationRepository } from "@/server/features/activation/repositories/ActivationRepository";
// Orgs whose first external MCP tool call is already recorded (or in flight)
// in this isolate. Only *first* timestamps matter, so after one successful
// write the tool-call hot path never touches the DB again for that org.
// Orgs whose activation milestones are already recorded (or in flight) in
// this isolate. Only *first* timestamps matter, so after one successful write
// these hot paths (every API-key /mcp request, every external tool call)
// never touch the DB again for that org.
const recordedAuthorizedOrgs = new Set<string>();
const recordedToolCallOrgs = new Set<string>();
/**
@ -14,9 +16,13 @@ const recordedToolCallOrgs = new Set<string>();
export async function recordMcpAuthorized(
organizationId: string,
): Promise<void> {
if (recordedAuthorizedOrgs.has(organizationId)) return;
recordedAuthorizedOrgs.add(organizationId);
try {
await ActivationRepository.recordFirstMcpAuthorized(organizationId);
} catch (error) {
// Allow a retry on a later call rather than losing the milestone.
recordedAuthorizedOrgs.delete(organizationId);
console.error("activation: recordMcpAuthorized failed", error);
}
}

View File

@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
import type { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
const mocks = vi.hoisted(() => ({
verifyApiKey: vi.fn(),
getHostedUser: vi.fn(),
getOrCreateDefaultHostedOrganization: vi.fn(),
recordMcpAuthorized: vi.fn(),
handleAuthenticatedOpenSeoMcpRequest:
vi.fn<typeof handleAuthenticatedOpenSeoMcpRequest>(),
}));
vi.mock("@/lib/auth", () => ({
getAuth: () => ({
api: {
verifyApiKey: mocks.verifyApiKey,
createOrganization: vi.fn(),
},
}),
getHostedBaseUrl: () => "https://app.openseo.so",
}));
vi.mock("@/server/auth/repositories/AuthRepository", () => ({
AuthRepository: {
getHostedUser: mocks.getHostedUser,
},
}));
vi.mock("@/server/auth/default-hosted-organization", () => ({
getOrCreateDefaultHostedOrganization:
mocks.getOrCreateDefaultHostedOrganization,
}));
vi.mock("@/server/features/activation/mcpActivation", () => ({
recordMcpAuthorized: mocks.recordMcpAuthorized,
}));
vi.mock("@/server/mcp/transport", () => ({
handleAuthenticatedOpenSeoMcpRequest:
mocks.handleAuthenticatedOpenSeoMcpRequest,
}));
import { handleMcpApiKeyRequest } from "@/server/mcp/api-key-auth";
const env = {};
const ctx: ExecutionContext = {
waitUntil() {},
passThroughOnException() {},
props: {},
};
function request(headers?: HeadersInit, method = "POST") {
return new Request("https://app.openseo.so/mcp", { method, headers });
}
describe("handleMcpApiKeyRequest", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getHostedUser.mockResolvedValue({
id: "user-1",
email: "person@example.com",
name: "Person",
});
mocks.getOrCreateDefaultHostedOrganization.mockResolvedValue("org-1");
mocks.recordMcpAuthorized.mockResolvedValue(undefined);
mocks.handleAuthenticatedOpenSeoMcpRequest.mockResolvedValue(
new Response("mcp response"),
);
});
it("handles a valid key with the hosted user, organization, and MCP scopes", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: true,
error: null,
key: { referenceId: "user-1" },
});
const mcpRequest = request({ Authorization: "Bearer oseo_secret" });
const response = await handleMcpApiKeyRequest(mcpRequest, env, ctx);
expect(await response?.text()).toBe("mcp response");
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
body: { key: "oseo_secret" },
});
expect(mocks.getOrCreateDefaultHostedOrganization).toHaveBeenCalledWith(
"user-1",
expect.any(Function),
);
expect(mocks.recordMcpAuthorized).toHaveBeenCalledWith("org-1");
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).toHaveBeenCalledTimes(1);
const [passedRequest, props, passedEnv, passedCtx] =
mocks.handleAuthenticatedOpenSeoMcpRequest.mock.calls[0];
expect(passedRequest).toBe(mcpRequest);
expect(passedEnv).toBe(env);
expect(passedCtx).toBe(ctx);
expect(props).toMatchObject({
[MCP_AUTH_CONTEXT_PROP]: {
userId: "user-1",
userEmail: "person@example.com",
organizationId: "org-1",
scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key",
baseUrl: "https://app.openseo.so",
},
});
});
it("accepts the key via a case-insensitive bearer scheme", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: true,
error: null,
key: { referenceId: "user-1" },
});
await handleMcpApiKeyRequest(
request({ Authorization: "bearer oseo_secret" }),
env,
ctx,
);
expect(mocks.verifyApiKey).toHaveBeenCalledWith({
body: { key: "oseo_secret" },
});
});
it("returns 401 for an invalid key without invoking the transport", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: false,
error: { code: "INVALID_API_KEY" },
key: null,
});
const response = await handleMcpApiKeyRequest(
request({ "x-api-key": "oseo_revoked" }),
env,
ctx,
);
expect(response?.status).toBe(401);
expect(response?.headers.has("WWW-Authenticate")).toBe(false);
await expect(response?.json()).resolves.toMatchObject({
error: "invalid_api_key",
});
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
});
it("returns 429 with Retry-After when Better Auth rate-limits the key", async () => {
mocks.verifyApiKey.mockResolvedValue({
valid: false,
error: {
code: "RATE_LIMITED",
message: "Rate limit exceeded",
details: { tryAgainIn: 30500 },
},
key: null,
});
const response = await handleMcpApiKeyRequest(
request({ "x-api-key": "oseo_limited" }),
env,
ctx,
);
expect(response?.status).toBe(429);
expect(response?.headers.get("Retry-After")).toBe("31");
await expect(response?.json()).resolves.toMatchObject({
error: "rate_limited",
});
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
});
it("returns a JSON 500 when auth resolution throws", async () => {
mocks.verifyApiKey.mockRejectedValue(new Error("db down"));
const response = await handleMcpApiKeyRequest(
request({ Authorization: "Bearer oseo_secret" }),
env,
ctx,
);
expect(response?.status).toBe(500);
await expect(response?.json()).resolves.toMatchObject({
error: "internal_error",
});
expect(mocks.handleAuthenticatedOpenSeoMcpRequest).not.toHaveBeenCalled();
});
it("leaves non-OpenSEO bearer tokens for OAuth", async () => {
await expect(
handleMcpApiKeyRequest(
request({ Authorization: "Bearer oauth-access-token" }),
env,
ctx,
),
).resolves.toBeNull();
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
});
it("leaves non-OpenSEO x-api-key values for OAuth", async () => {
await expect(
handleMcpApiKeyRequest(
request({
"x-api-key": "some-foreign-key",
Authorization: "Bearer oauth-access-token",
}),
env,
ctx,
),
).resolves.toBeNull();
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
});
it("leaves requests without credentials for OAuth", async () => {
await expect(
handleMcpApiKeyRequest(request(), env, ctx),
).resolves.toBeNull();
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
});
it("leaves OPTIONS requests with API keys for the CORS handler", async () => {
await expect(
handleMcpApiKeyRequest(
request({ "x-api-key": "oseo_secret" }, "OPTIONS"),
env,
ctx,
),
).resolves.toBeNull();
expect(mocks.verifyApiKey).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,140 @@
import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { API_KEY_PREFIX } from "@/lib/auth-api-key";
import { MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
import { getOrCreateDefaultHostedOrganization } from "@/server/auth/default-hosted-organization";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { recordMcpAuthorized } from "@/server/features/activation/mcpActivation";
import { createWorkersOAuthMcpProps, MCP_ROUTE } from "@/server/mcp/context";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
function getApiKey(request: Request) {
// Both branches require the oseo_ prefix: anything else (a Cloudflare OAuth
// access token, a stray foreign x-api-key header) falls through to the
// OAuth provider instead of being consumed here.
const headerKey = request.headers.get("x-api-key");
if (headerKey?.startsWith(API_KEY_PREFIX)) return headerKey;
const bearerToken = request.headers
.get("Authorization")
?.replace(/^Bearer /i, "");
if (bearerToken?.startsWith(API_KEY_PREFIX)) return bearerToken;
return null;
}
function apiKeyErrorResponse(
error: { code?: string | null; message?: unknown; details?: unknown } | null,
) {
const headers = new Headers({ "Content-Type": "application/json" });
const isLimited =
error?.code === "RATE_LIMITED" || error?.code === "USAGE_EXCEEDED";
if (isLimited) {
// The plugin reports tryAgainIn (milliseconds) for RATE_LIMITED, but its
// published error type omits `details`, so narrow at runtime.
const details = error?.details;
const tryAgainInMs =
typeof details === "object" && details !== null && "tryAgainIn" in details
? details.tryAgainIn
: undefined;
if (typeof tryAgainInMs === "number" && Number.isFinite(tryAgainInMs)) {
headers.set(
"Retry-After",
String(Math.max(1, Math.ceil(tryAgainInMs / 1000))),
);
}
}
const code = isLimited
? error?.code === "RATE_LIMITED"
? "rate_limited"
: "usage_exceeded"
: "invalid_api_key";
const description = isLimited
? typeof error?.message === "string"
? error.message
: "API key request limit reached"
: "The provided API key is invalid, expired, or disabled";
const status = isLimited ? 429 : 401;
// Bad credentials are client-side noise, so 401 logs at debug (mirroring the
// OAuth path); a 429 means we actually cut a caller off, so warn.
const line = `[mcp-api-key] ${status} ${code} (${error?.code ?? "no_hosted_user"})`;
if (status === 429) {
console.warn(line);
} else {
console.debug(line);
}
return new Response(
JSON.stringify({ error: code, error_description: description }),
{ status, headers },
);
}
export async function handleMcpApiKeyRequest(
request: Request,
env: unknown,
ctx: ExecutionContext,
): Promise<Response | null> {
const url = new URL(request.url);
if (url.pathname !== MCP_ROUTE || request.method === "OPTIONS") return null;
const apiKey = getApiKey(request);
if (!apiKey) return null;
try {
// Keep API keys scoped to /mcp: verifyApiKey (rather than Better Auth's
// enableSessionForAPIKeys mock sessions) means a key never becomes a
// session that could reach account or organization endpoints.
const authApi = getAuth().api;
const result = await authApi.verifyApiKey({ body: { key: apiKey } });
if (!result.valid || !result.key) {
return apiKeyErrorResponse(result.error);
}
const userId = result.key.referenceId;
const user = await AuthRepository.getHostedUser(userId);
if (!user?.email) return apiKeyErrorResponse(null);
// API keys bill the user's default hosted workspace (their first org).
// The hosted product provisions exactly one org per user. Decided
// direction for multi-org: keys stay user-scoped and the org derives from
// the project each tool call names (project-level authz) — not key→org
// binding.
const organizationId = await getOrCreateDefaultHostedOrganization(
userId,
(body) => authApi.createOrganization({ body }),
);
// clientId "api_key" satisfies the hosted transport's fail-closed props
// schema and counts these calls as external MCP clients in telemetry.
const props = createWorkersOAuthMcpProps({
userId,
userEmail: user.email,
organizationId,
baseUrl: getHostedBaseUrl(),
scopes: [...MCP_OAUTH_SCOPES],
clientId: "api_key",
});
await recordMcpAuthorized(organizationId);
return await handleAuthenticatedOpenSeoMcpRequest(request, props, env, ctx);
} catch (error) {
// Without this, a throw here surfaces as a bare platform 500 with no log
// breadcrumb.
console.error("[mcp-api-key] 500 (internal):", error);
return new Response(
JSON.stringify({
error: "internal_error",
error_description: "API key authentication failed",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
}

View File

@ -15,6 +15,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("cloudflare:workers", () => ({
waitUntil: (promise: Promise<unknown>) => void promise,
// api-key-auth pulls the db client into this module graph; it reads env at
// module load.
env: {},
}));
// The whole module is doubled because the real one imports "cloudflare:workers"

View File

@ -27,6 +27,7 @@ import { normalizeClientRegistrationRequest } from "@/server/mcp/oauth-registrat
import { getPublicOrigin } from "@/server/mcp/public-origin";
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
import { handleMcpApiKeyRequest } from "@/server/mcp/api-key-auth";
const OAUTH_AUTHORIZE_PATH = "/api/auth/oauth2/authorize";
const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token";
@ -446,6 +447,9 @@ export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
async fetch(request: Request, env: OpenSeoOAuthEnv, ctx: ExecutionContext) {
const url = new URL(request.url);
const apiKeyResponse = await handleMcpApiKeyRequest(request, env, ctx);
if (apiKeyResponse) return apiKeyResponse;
if (url.pathname === OAUTH_REGISTER_PATH) {
return getProvider().fetch(
await normalizeClientRegistrationRequest(request),

View File

@ -20,6 +20,9 @@ vi.mock("cloudflare:workers", () => ({
// support; this suite uses object handlers, so a bare stand-in suffices.
// oxlint-disable-next-line typescript/no-extraneous-class
WorkerEntrypoint: class {},
// api-key-auth pulls the db client into this module graph; it reads env at
// module load.
env: {},
}));
vi.mock("@/lib/auth", () => ({

View File

@ -11,7 +11,7 @@ The hosted MCP server URL is:
https://app.openseo.so/mcp
```
The first connection sends you through OpenSEO login. After authorization, your MCP client can call OpenSEO tools with the project context and account scopes you approved.
The first connection sends you through OpenSEO login. After authorization, your MCP client can call OpenSEO tools with the project context and account scopes you approved. For headless environments and CI, [connect with an API key](#connect-with-an-api-key) instead.
For the most current setup UI and a copyable endpoint, open [AI & MCP in OpenSEO](https://app.openseo.so/ai).
@ -69,6 +69,42 @@ Approve the login when prompted.
3. Paste `https://app.openseo.so/mcp`.
4. Approve the OpenSEO login when prompted.
## Connect with an API key
Use an API key in headless environments, CI, or clients where OAuth is inconvenient. API keys are personal: anything an agent does with your key acts as you in your workspace.
In the [OpenSEO app](https://app.openseo.so/settings), open **Settings -> API keys**, create a key, and copy it when it appears. It won't be shown again.
For Claude Code, run:
```bash
claude mcp add --transport http --scope user openseo https://app.openseo.so/mcp --header "Authorization: Bearer oseo_YOUR_KEY"
```
For Cursor, add `headers` to the server entry in `mcp.json`:
```json
{
"mcpServers": {
"openseo": {
"url": "https://app.openseo.so/mcp",
"headers": {
"Authorization": "Bearer oseo_YOUR_KEY"
}
}
}
}
```
For Codex CLI, put the key in an environment variable and reference it:
```bash
export OPENSEO_API_KEY=oseo_YOUR_KEY
codex mcp add openseo --url https://app.openseo.so/mcp --bearer-token-env-var OPENSEO_API_KEY
```
Any other client that supports custom HTTP headers can send `Authorization: Bearer oseo_YOUR_KEY` or `x-api-key: oseo_YOUR_KEY`.
## Available tools
OpenSEO MCP exposes tools for SEO research workflows: