From 69941afedaf5e04f29e361890839d458e8af7e26 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Fri, 15 May 2026 10:02:06 +0100 Subject: [PATCH 1/2] fix(mcp): complete discovery flow and setup guides Co-authored-by: Codex --- .../settings/tokens/connection-guides.ts | 75 +++++ app/(app)/settings/tokens/page.tsx | 8 +- app/(app)/settings/tokens/tokens-client.tsx | 316 ++++++++++++++++-- .../api/auth/route.ts | 4 + .../oauth-protected-resource/route.ts | 2 +- app/api/mcp/route.ts | 13 +- lib/mcp/auth.ts | 8 +- tests/mcp-oauth.test.ts | 89 ++++- tests/token-guides.test.ts | 43 +++ 9 files changed, 516 insertions(+), 42 deletions(-) create mode 100644 app/(app)/settings/tokens/connection-guides.ts create mode 100644 app/.well-known/oauth-authorization-server/api/auth/route.ts create mode 100644 tests/token-guides.test.ts diff --git a/app/(app)/settings/tokens/connection-guides.ts b/app/(app)/settings/tokens/connection-guides.ts new file mode 100644 index 0000000..9a9f2a6 --- /dev/null +++ b/app/(app)/settings/tokens/connection-guides.ts @@ -0,0 +1,75 @@ +export const SERVICE_TOKEN_ENV_VAR = "ARIN_MCP_TOKEN"; +export const SERVICE_TOKEN_PLACEHOLDER = "arin_your_token_here"; + +function bearerValue(token?: string): string { + return `Bearer ${token?.trim() || SERVICE_TOKEN_PLACEHOLDER}`; +} + +export function mcpServerUrl(appUrl: string): string { + return `${appUrl}/api/mcp`; +} + +export function claudeWebFields(appUrl: string) { + return { + name: "Arin", + remoteMcpServerUrl: mcpServerUrl(appUrl), + oauthClientId: "Leave blank", + oauthClientSecret: "Leave blank", + }; +} + +export function claudeWebBlock(appUrl: string): string { + const fields = claudeWebFields(appUrl); + return [ + `Name: ${fields.name}`, + `Remote MCP server URL: ${fields.remoteMcpServerUrl}`, + `OAuth Client ID: ${fields.oauthClientId}`, + `OAuth Client Secret: ${fields.oauthClientSecret}`, + ].join("\n"); +} + +export function claudeCodeMacCommand(appUrl: string, token?: string): string { + return [ + "claude mcp add --transport http arin \\", + ` ${mcpServerUrl(appUrl)} \\`, + ` --header "Authorization: ${bearerValue(token)}"`, + ].join("\n"); +} + +export function claudeCodeWindowsCommand(appUrl: string, token?: string): string { + return [ + "claude mcp add --transport http arin `", + ` ${mcpServerUrl(appUrl)} \``, + ` --header 'Authorization: ${bearerValue(token)}'`, + ].join("\n"); +} + +export function codexMacCommand(appUrl: string, token?: string): string { + return [ + `export ${SERVICE_TOKEN_ENV_VAR}="${token?.trim() || SERVICE_TOKEN_PLACEHOLDER}"`, + `codex mcp add arin --url ${mcpServerUrl(appUrl)} --bearer-token-env-var ${SERVICE_TOKEN_ENV_VAR}`, + "codex mcp list", + ].join("\n"); +} + +export function codexWindowsCommand(appUrl: string, token?: string): string { + return [ + `$env:${SERVICE_TOKEN_ENV_VAR}="${token?.trim() || SERVICE_TOKEN_PLACEHOLDER}"`, + `codex mcp add arin --url ${mcpServerUrl(appUrl)} --bearer-token-env-var ${SERVICE_TOKEN_ENV_VAR}`, + "codex mcp list", + ].join("\n"); +} + +export function genericHttpConfig(appUrl: string, token?: string): string { + return `{ + "mcpServers": { + "arin": { + "type": "http", + "url": "${mcpServerUrl(appUrl)}", + "headers": { + "Authorization": "${bearerValue(token)}" + } + } + } +}`; +} diff --git a/app/(app)/settings/tokens/page.tsx b/app/(app)/settings/tokens/page.tsx index a81d3b3..a60fabe 100644 --- a/app/(app)/settings/tokens/page.tsx +++ b/app/(app)/settings/tokens/page.tsx @@ -1,6 +1,7 @@ import { desc, eq } from "drizzle-orm"; import { db } from "@/db/client"; import { serviceTokens } from "@/db/schema/settings"; +import { env } from "@/lib/env"; import { relativeTime } from "@/lib/format"; import { requireOrgSession } from "@/lib/session"; import { TokensClient } from "./tokens-client"; @@ -20,7 +21,7 @@ export default async function ServiceTokensSettingsPage() { .orderBy(desc(serviceTokens.createdAt)); return ( -
+

- Service tokens authenticate Claude (over MCP) to your workspace. Each token is - shown once at creation — store it in your MCP client config. + Connect Claude, Codex, and other MCP clients to your workspace. Claude Web + uses OAuth; service-token clients show the token only once at creation.

({ id: r.id, name: r.name, diff --git a/app/(app)/settings/tokens/tokens-client.tsx b/app/(app)/settings/tokens/tokens-client.tsx index bad09f1..696dd64 100644 --- a/app/(app)/settings/tokens/tokens-client.tsx +++ b/app/(app)/settings/tokens/tokens-client.tsx @@ -5,6 +5,18 @@ import { Check, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + SERVICE_TOKEN_PLACEHOLDER, + claudeCodeMacCommand, + claudeCodeWindowsCommand, + claudeWebBlock, + claudeWebFields, + codexMacCommand, + codexWindowsCommand, + genericHttpConfig, + mcpServerUrl, +} from "./connection-guides"; import { issueServiceTokenAction, revokeServiceTokenAction } from "./actions"; type TokenRow = { @@ -15,10 +27,254 @@ type TokenRow = { revoked: boolean; }; -export function TokensClient({ tokens }: { tokens: TokenRow[] }) { +function CodeBlock({ + label, + value, + copiedKey, + onCopy, +}: { + label: string; + value: string; + copiedKey: string | null; + onCopy: (key: string, value: string) => Promise; +}) { + return ( +
+
+
+ {label} +
+ +
+
+        {value}
+      
+
+ ); +} + +function ConnectionGuide({ + appUrl, + token, + copiedKey, + onCopy, +}: { + appUrl: string; + token?: string; + copiedKey: string | null; + onCopy: (key: string, value: string) => Promise; +}) { + const fields = claudeWebFields(appUrl); + const tokenReady = Boolean(token?.trim()); + const mcpUrl = mcpServerUrl(appUrl); + + return ( +
+
+
+
+

Connect a client

+

+ Claude Web signs in with OAuth. Claude Code, Codex, and most HTTP MCP + clients use a service token in the `Authorization` header. +

+
+ +
+
+ + {mcpUrl} + + + {tokenReady + ? "The snippets below already use the token you just created." + : `Generate a token first for the service-token tabs below. Existing token values are not shown again.`} + +
+
+ + + + + Claude Web + + + Claude Code + + + Codex + + + Generic MCP + + + + +
+
+ Add a custom connector in Claude, then paste the server URL below. Leave + both OAuth client fields blank so Claude uses your server's discovery flow. +
+
+
+ {[ + ["claude-web-name", "Name", fields.name], + ["claude-web-url", "Remote MCP server URL", fields.remoteMcpServerUrl], + ["claude-web-client-id", "OAuth Client ID", fields.oauthClientId], + ["claude-web-client-secret", "OAuth Client Secret", fields.oauthClientSecret], + ].map(([key, label, value]) => ( +
+
+
+ {label} +
+
+ {value} +
+
+ +
+ ))} +
+ +
+ + +
+
+ Use Claude Code's HTTP MCP transport. These commands add Arin directly + with the bearer token header already attached. +
+
+
+ + +
+
+ + +
+
+ Codex CLI and the IDE extension share the same MCP configuration. Set the + token in an environment variable, then register Arin once with `codex mcp add`. +
+
+
+ + +
+
+ + +
+
+ Use this for any remote HTTP MCP client that accepts a URL plus custom + headers. Replace the placeholder token if you haven't just generated one. +
+
+ + {!tokenReady ? ( +

+ Placeholder shown: `{SERVICE_TOKEN_PLACEHOLDER}`. Generate a new token + above to get a real value you can copy once. +

+ ) : null} +
+
+
+ ); +} + +export function TokensClient({ + appUrl, + tokens, +}: { + appUrl: string; + tokens: TokenRow[]; +}) { const [name, setName] = useState(""); const [pending, setPending] = useState(false); const [issued, setIssued] = useState<{ token: string; copied: boolean } | null>(null); + const [copiedKey, setCopiedKey] = useState(null); const [error, setError] = useState(null); async function onSubmit(e: FormEvent) { @@ -38,11 +294,25 @@ export function TokensClient({ tokens }: { tokens: TokenRow[] }) { } } - async function copy() { + async function copyIssuedToken() { if (!issued) return; - await navigator.clipboard.writeText(issued.token); - setIssued({ ...issued, copied: true }); - setTimeout(() => setIssued((cur) => (cur ? { ...cur, copied: false } : cur)), 1500); + try { + await navigator.clipboard.writeText(issued.token); + setIssued({ ...issued, copied: true }); + setTimeout(() => setIssued((cur) => (cur ? { ...cur, copied: false } : cur)), 1500); + } catch { + setError("Couldn't copy to clipboard. Copy the token manually."); + } + } + + async function copyValue(key: string, value: string) { + try { + await navigator.clipboard.writeText(value); + setCopiedKey(key); + setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1500); + } catch { + setError("Couldn't copy to clipboard. Copy the value manually."); + } } return ( @@ -65,7 +335,7 @@ export function TokensClient({ tokens }: { tokens: TokenRow[] }) { {issued.token} -
-
- - Wire into Claude Code (HTTP MCP) - -
-{`{
-  "mcpServers": {
-    "arin": {
-      "type": "http",
-      "url": "${typeof window !== "undefined" ? window.location.origin : ""}/api/mcp",
-      "headers": {
-        "Authorization": "Bearer ${issued.token}"
-      }
-    }
-  }
-}`}
-            
-
+

+ The setup snippets below now use this token automatically for Claude Code, + Codex, and generic HTTP MCP clients. +

) : null} -
+ + +

Issue a new token

Name it for the client that will use it (e.g. "Claude Code laptop"). diff --git a/app/.well-known/oauth-authorization-server/api/auth/route.ts b/app/.well-known/oauth-authorization-server/api/auth/route.ts new file mode 100644 index 0000000..446ab8a --- /dev/null +++ b/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -0,0 +1,4 @@ +import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider"; +import { auth } from "@/lib/auth"; + +export const GET = oauthProviderAuthServerMetadata(auth); diff --git a/app/.well-known/oauth-protected-resource/route.ts b/app/.well-known/oauth-protected-resource/route.ts index d42d480..905ba6c 100644 --- a/app/.well-known/oauth-protected-resource/route.ts +++ b/app/.well-known/oauth-protected-resource/route.ts @@ -3,7 +3,7 @@ import { env } from "@/lib/env"; export function GET() { return Response.json({ resource: `${env.APP_URL}/api/mcp`, - authorization_servers: [env.APP_URL], + authorization_servers: [`${env.APP_URL}/api/auth`], bearer_methods_supported: ["header"], scopes_supported: ["mcp"], }); diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index f95f1da..9e9046c 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -32,8 +32,11 @@ export async function POST(request: Request): Promise { } } -// Stateless server doesn't push notifications and has no sessions to terminate, -// so refuse the GET SSE stream and DELETE — both would only allocate without value -// and GET in particular leaks McpServer instances by holding them open indefinitely. -export const GET = methodNotAllowed; -export const DELETE = methodNotAllowed; +async function challengeOrNotAllowed(request: Request): Promise { + const ctx = await authenticate(request); + if (!ctx) return unauthorizedResponse(); + return methodNotAllowed(); +} + +export const GET = challengeOrNotAllowed; +export const DELETE = challengeOrNotAllowed; diff --git a/lib/mcp/auth.ts b/lib/mcp/auth.ts index eb61cdf..cdd36a5 100644 --- a/lib/mcp/auth.ts +++ b/lib/mcp/auth.ts @@ -2,12 +2,14 @@ import { eq } from "drizzle-orm"; import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey } from "jose"; import { db } from "@/db/client"; import { user } from "@/db/schema/auth"; +import { MCP_AUDIENCE } from "@/lib/auth"; import type { Actor } from "@/lib/audit"; import { env } from "@/lib/env"; import { resolveServiceToken } from "@/lib/service-tokens"; const defaultJwks = createRemoteJWKSet(new URL(`${env.APP_URL}/api/auth/jwks`)); -export const MCP_RESOURCE = `${env.APP_URL}/api/mcp`; +export const MCP_RESOURCE = MCP_AUDIENCE; +export const MCP_ISSUER = `${env.APP_URL}/api/auth`; export type McpAuthContext = { organizationId: string; actor: Actor }; export type AuthenticateOptions = { jwks?: JWTVerifyGetKey }; @@ -49,8 +51,8 @@ export async function authenticate( try { const { payload } = await jwtVerify(token, opts.jwks ?? defaultJwks, { - audience: MCP_RESOURCE, - issuer: env.APP_URL, + audience: MCP_AUDIENCE, + issuer: MCP_ISSUER, }); const orgId = payload.org_id; if (typeof orgId !== "string" || orgId.length === 0) return null; diff --git a/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index 0a641d3..d7989ae 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -2,7 +2,12 @@ import { beforeEach, describe, expect, test } from "bun:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createDb } from "@/db/client"; import { organization } from "@/db/schema/auth"; -import { authenticate, MCP_RESOURCE, unauthorizedResponse } from "@/lib/mcp/auth"; +import { + authenticate, + MCP_ISSUER, + MCP_RESOURCE, + unauthorizedResponse, +} from "@/lib/mcp/auth"; import { issueServiceToken } from "@/lib/service-tokens"; import { resetDb } from "./setup"; @@ -23,7 +28,7 @@ async function mintJwt(opts: { opts.payload ?? { org_id: "org_jwt", sub: "user_1", client_id: "test_client" }, ) .setProtectedHeader({ alg: ALG, kid: opts.kid }) - .setIssuer(opts.issuer ?? APP_URL) + .setIssuer(opts.issuer ?? MCP_ISSUER) .setAudience(opts.audience ?? MCP_RESOURCE) .setIssuedAt() .setExpirationTime(exp) @@ -157,3 +162,83 @@ describe("MCP dual-auth", () => { expect(await authenticate(bearer(token), { jwks })).toBeNull(); }); }); + +describe("MCP discovery", () => { + test("protected resource metadata points Claude at the auth server base path", async () => { + const { GET } = await import("@/app/.well-known/oauth-protected-resource/route"); + const res = await GET(); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + resource: `${APP_URL}/api/mcp`, + authorization_servers: [`${APP_URL}/api/auth`], + bearer_methods_supported: ["header"], + scopes_supported: ["mcp"], + }); + }); + + test("path-aware auth server metadata route is served under /.well-known/.../api/auth", async () => { + const { GET } = await import( + "@/app/.well-known/oauth-authorization-server/api/auth/route" + ); + const res = await GET( + new Request("http://localhost/.well-known/oauth-authorization-server/api/auth", { + method: "GET", + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.issuer).toBe(`${APP_URL}/api/auth`); + expect(body.jwks_uri).toBe(`${APP_URL}/api/auth/jwks`); + expect(body.registration_endpoint).toBe(`${APP_URL}/api/auth/oauth2/register`); + expect(body.authorization_endpoint).toBe( + `${APP_URL}/api/auth/oauth2/authorize`, + ); + expect(body.token_endpoint).toBe(`${APP_URL}/api/auth/oauth2/token`); + }); +}); + +describe("MCP route methods", () => { + beforeEach(async () => { + await resetDb(); + }); + + function expectOAuthChallenge(res: Response): void { + expect(res.status).toBe(401); + const wa = res.headers.get("WWW-Authenticate") ?? ""; + expect(wa).toContain("Bearer"); + expect(wa).toContain( + `resource_metadata="${APP_URL}/.well-known/oauth-protected-resource"`, + ); + } + + test("GET /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { method: "GET" }), + ); + expectOAuthChallenge(res); + }); + + test("DELETE /api/mcp without a token returns 401 with WWW-Authenticate", async () => { + const { DELETE } = await import("@/app/api/mcp/route"); + const res = await DELETE( + new Request("http://localhost/api/mcp", { method: "DELETE" }), + ); + expectOAuthChallenge(res); + }); + + test("GET /api/mcp with a valid service token returns 405", async () => { + const orgId = "org_route"; + await db.insert(organization).values({ id: orgId, name: "Route", slug: "route" }); + const issued = await issueServiceToken(db, orgId, "ci-bot"); + const { GET } = await import("@/app/api/mcp/route"); + const res = await GET( + new Request("http://localhost/api/mcp", { + method: "GET", + headers: { authorization: `Bearer ${issued.token}` }, + }), + ); + expect(res.status).toBe(405); + expect(res.headers.get("Allow")).toBe("POST"); + }); +}); diff --git a/tests/token-guides.test.ts b/tests/token-guides.test.ts new file mode 100644 index 0000000..593f2e6 --- /dev/null +++ b/tests/token-guides.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { + SERVICE_TOKEN_PLACEHOLDER, + claudeCodeMacCommand, + claudeCodeWindowsCommand, + claudeWebBlock, + codexMacCommand, + codexWindowsCommand, + genericHttpConfig, + mcpServerUrl, +} from "@/app/(app)/settings/tokens/connection-guides"; + +const APP_URL = "https://arin.usedocsyde.com"; +const TOKEN = "arin_real_token"; + +describe("token connection guides", () => { + test("builds the canonical MCP URL from APP_URL", () => { + expect(mcpServerUrl(APP_URL)).toBe("https://arin.usedocsyde.com/api/mcp"); + }); + + test("Claude Web block points to the remote MCP URL and leaves OAuth client fields blank", () => { + expect(claudeWebBlock(APP_URL)).toContain("Name: Arin"); + expect(claudeWebBlock(APP_URL)).toContain( + "Remote MCP server URL: https://arin.usedocsyde.com/api/mcp", + ); + expect(claudeWebBlock(APP_URL)).toContain("OAuth Client ID: Leave blank"); + expect(claudeWebBlock(APP_URL)).toContain("OAuth Client Secret: Leave blank"); + }); + + test("service-token client snippets embed the issued token when present", () => { + expect(claudeCodeMacCommand(APP_URL, TOKEN)).toContain(`Bearer ${TOKEN}`); + expect(claudeCodeWindowsCommand(APP_URL, TOKEN)).toContain(`Bearer ${TOKEN}`); + expect(codexMacCommand(APP_URL, TOKEN)).toContain(TOKEN); + expect(codexWindowsCommand(APP_URL, TOKEN)).toContain(TOKEN); + expect(genericHttpConfig(APP_URL, TOKEN)).toContain(`"Authorization": "Bearer ${TOKEN}"`); + }); + + test("service-token client snippets fall back to a placeholder when no token is available", () => { + expect(claudeCodeMacCommand(APP_URL)).toContain(SERVICE_TOKEN_PLACEHOLDER); + expect(codexMacCommand(APP_URL)).toContain(SERVICE_TOKEN_PLACEHOLDER); + expect(genericHttpConfig(APP_URL)).toContain(SERVICE_TOKEN_PLACEHOLDER); + }); +}); From 4949e51b779ac35685f0e1ece47c229e2d3bbac8 Mon Sep 17 00:00:00 2001 From: Timmyy3000 Date: Fri, 15 May 2026 10:42:10 +0100 Subject: [PATCH 2/2] fix(tokens): isolate copied state per snippet Co-authored-by: Codex --- app/(app)/settings/tokens/tokens-client.tsx | 12 ++++++++++-- tests/token-guides.test.ts | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/(app)/settings/tokens/tokens-client.tsx b/app/(app)/settings/tokens/tokens-client.tsx index 696dd64..abfedcf 100644 --- a/app/(app)/settings/tokens/tokens-client.tsx +++ b/app/(app)/settings/tokens/tokens-client.tsx @@ -28,11 +28,13 @@ type TokenRow = { }; function CodeBlock({ + copyKey, label, value, copiedKey, onCopy, }: { + copyKey: string; label: string; value: string; copiedKey: string | null; @@ -48,9 +50,9 @@ function CodeBlock({ variant="outline" size="xs" type="button" - onClick={() => onCopy(label, value)} + onClick={() => onCopy(copyKey, value)} > - {copiedKey === label ? ( + {copiedKey === copyKey ? ( <> Copied @@ -186,6 +188,7 @@ function ConnectionGuide({ ))}

{ expect(codexMacCommand(APP_URL)).toContain(SERVICE_TOKEN_PLACEHOLDER); expect(genericHttpConfig(APP_URL)).toContain(SERVICE_TOKEN_PLACEHOLDER); }); + + test("Claude Code and Codex snippets remain distinct even when platform labels match", () => { + expect(claudeCodeMacCommand(APP_URL, TOKEN)).not.toBe(codexMacCommand(APP_URL, TOKEN)); + expect(claudeCodeWindowsCommand(APP_URL, TOKEN)).not.toBe( + codexWindowsCommand(APP_URL, TOKEN), + ); + }); });