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..abfedcf 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,262 @@ type TokenRow = { revoked: boolean; }; -export function TokensClient({ tokens }: { tokens: TokenRow[] }) { +function CodeBlock({ + copyKey, + label, + value, + copiedKey, + onCopy, +}: { + copyKey: string; + 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 +302,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 +343,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/tests/mcp-oauth.test.ts b/tests/mcp-oauth.test.ts index 8bb3273..dd8bd4d 100644 --- a/tests/mcp-oauth.test.ts +++ b/tests/mcp-oauth.test.ts @@ -163,6 +163,39 @@ describe("MCP dual-auth", () => { }); }); +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(); diff --git a/tests/token-guides.test.ts b/tests/token-guides.test.ts new file mode 100644 index 0000000..6e2073d --- /dev/null +++ b/tests/token-guides.test.ts @@ -0,0 +1,50 @@ +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); + }); + + 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), + ); + }); +});