- Introduce optional MCP API key signing secret in the .env.example file. - Update README.md to clarify MCP anonymous authentication and API key usage. - Refactor McpSetupCard component to utilize new MCP API key logic and improve session handling. - Implement getMcpApiKey query to manage MCP API key issuance for anonymous accounts. - Enhance error handling in MCP request processing to ensure proper API key validation and workspace access. - Add tests to verify the correct behavior of account resolution and API key preferences.
Unified · Split
| @@ -23,6 +23,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 22 unmodified lines | ||
| 23 | 23 | # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" |
| 24 | 24 | # Optional overrides: |
| 25 | 25 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 |
| 26 | +# MCP API key signing secret (optional; falls back to ANONYMOUS_AUTH_PRIVATE_KEY_PEM when unset) | |
| 27 | +# MCP_API_KEY_SECRET=change-me | |
| 26 | 28 | |
| 27 | 29 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 28 | 30 | # STRIPE_SECRET_KEY=sk_test_... |
| @@ -137,7 +137,8 @@ Convex HTTP routes (`packages/convex/http.ts`) expose: · 136 unmodified lines | ||
| 137 | 137 | - `/internal/runs/:runId/tool-call` |
| 138 | 138 | |
| 139 | 139 | MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured. |
| 140 | −Anonymous bearer tokens can be minted from `/auth/anonymous/token` (and verified by `/.well-known/jwks.json`). | |
| 140 | +`/mcp/anonymous` uses API-key auth (pass as `Authorization: Bearer <API_KEY>` or `x-api-key`). | |
| 141 | +Anonymous bearer tokens can still be minted from `/auth/anonymous/token` for Convex anonymous auth flows. | |
| 141 | 142 | |
| 142 | 143 | ## Configuration Reference |
| 143 | 144 | |
| @@ -222,6 +223,6 @@ executor/ · 78 unmodified lines | ||
| 222 | 223 | ## Troubleshooting |
| 223 | 224 | |
| 224 | 225 | - `401` on `/mcp`: verify your bearer token issuer matches `MCP_AUTHORIZATION_SERVER`. |
| 225 | −- `401` on `/mcp/anonymous`: pass `Authorization: Bearer <anonymous token>` from `/auth/anonymous/token`. | |
| 226 | +- `401` on `/mcp/anonymous`: pass `API_KEY` from the setup card as `Authorization: Bearer <API_KEY>` (or `x-api-key`). | |
| 226 | 227 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. |
| 227 | 228 | - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets. |
| @@ -63,7 +63,6 @@ export function DashboardView() { · 62 unmodified lines | ||
| 63 | 63 | <McpSetupCard |
| 64 | 64 | workspaceId={context?.workspaceId} |
| 65 | 65 | sessionId={context?.sessionId} |
| 66 | − accountId={context?.accountId} | |
| 67 | 66 | /> |
| 68 | 67 | </CardContent> |
| 69 | 68 | </Card> |
| @@ -1,308 +1,29 @@ | ||
| 1 | −export type McpProviderConfig = { | |
| 2 | − id: string; | |
| 3 | − name: string; | |
| 4 | − icon: string; | |
| 5 | − getConfig: ( | |
| 6 | − mcpUrl: string, | |
| 7 | − serverName: string, | |
| 8 | − ) => { | |
| 9 | − type: "command" | "json" | "toml"; | |
| 10 | − description: string; | |
| 11 | − content: string; | |
| 12 | − }; | |
| 1 | +export type McpInstallConfig = { | |
| 2 | + type: "command"; | |
| 3 | + description: string; | |
| 4 | + content: string; | |
| 13 | 5 | }; |
| 14 | 6 | |
| 15 | −export const MCP_PROVIDERS: McpProviderConfig[] = [ | |
| 16 | − { | |
| 17 | − id: "claude-code", | |
| 18 | − name: "Claude Code", | |
| 19 | − icon: "https://claude.ai/favicon.ico", | |
| 20 | − getConfig: (mcpUrl, serverName) => ({ | |
| 21 | − type: "command", | |
| 22 | − description: "Run this command in your terminal:", | |
| 23 | − content: `claude mcp add --transport http ${serverName} ${mcpUrl}`, | |
| 24 | − }), | |
| 25 | − }, | |
| 26 | − { | |
| 27 | − id: "cursor", | |
| 28 | − name: "Cursor", | |
| 29 | − icon: "https://cursor.com/favicon.ico", | |
| 30 | − getConfig: (mcpUrl, serverName) => ({ | |
| 31 | − type: "json", | |
| 32 | − description: "Add to ~/.cursor/mcp.json:", | |
| 33 | − content: JSON.stringify( | |
| 34 | − { | |
| 35 | − mcpServers: { | |
| 36 | − [serverName]: { | |
| 37 | − url: mcpUrl, | |
| 38 | − }, | |
| 39 | − }, | |
| 40 | − }, | |
| 41 | − null, | |
| 42 | − 2, | |
| 43 | − ), | |
| 44 | − }), | |
| 45 | − }, | |
| 46 | − { | |
| 47 | − id: "vscode", | |
| 48 | − name: "VS Code", | |
| 49 | − icon: "https://code.visualstudio.com/assets/favicon.ico", | |
| 50 | − getConfig: (mcpUrl, serverName) => ({ | |
| 51 | − type: "json", | |
| 52 | − description: "Add to VS Code settings (MCP extension):", | |
| 53 | − content: JSON.stringify( | |
| 54 | − { | |
| 55 | − mcp: { | |
| 56 | − servers: { | |
| 57 | − [serverName]: { | |
| 58 | − type: "http", | |
| 59 | − url: mcpUrl, | |
| 60 | − }, | |
| 61 | − }, | |
| 62 | − }, | |
| 63 | − }, | |
| 64 | − null, | |
| 65 | − 2, | |
| 66 | − ), | |
| 67 | − }), | |
| 68 | − }, | |
| 69 | − { | |
| 70 | − id: "windsurf", | |
| 71 | − name: "Windsurf", | |
| 72 | − icon: "https://windsurf.com/favicon.png", | |
| 73 | − getConfig: (mcpUrl, serverName) => ({ | |
| 74 | − type: "json", | |
| 75 | − description: "Add to your Windsurf MCP config file:", | |
| 76 | − content: JSON.stringify( | |
| 77 | − { | |
| 78 | − mcpServers: { | |
| 79 | − [serverName]: { | |
| 80 | − serverUrl: mcpUrl, | |
| 81 | − }, | |
| 82 | − }, | |
| 83 | − }, | |
| 84 | − null, | |
| 85 | − 2, | |
| 86 | − ), | |
| 87 | − }), | |
| 88 | − }, | |
| 89 | − { | |
| 90 | − id: "claude-desktop", | |
| 91 | − name: "Claude Desktop", | |
| 92 | − icon: "https://claude.ai/favicon.ico", | |
| 93 | − getConfig: (mcpUrl, serverName) => ({ | |
| 94 | − type: "json", | |
| 95 | − description: | |
| 96 | − "Open Claude Desktop Settings > Connectors > Add Custom Connector, or add to ~/Library/Application Support/Claude/claude_desktop_config.json:", | |
| 97 | − content: JSON.stringify( | |
| 98 | − { | |
| 99 | − mcpServers: { | |
| 100 | − [serverName]: { | |
| 101 | − command: "npx", | |
| 102 | − args: ["-y", "mcp-remote", mcpUrl], | |
| 103 | − }, | |
| 104 | − }, | |
| 105 | − }, | |
| 106 | − null, | |
| 107 | − 2, | |
| 108 | − ), | |
| 109 | − }), | |
| 110 | − }, | |
| 111 | − { | |
| 112 | − id: "opencode", | |
| 113 | − name: "OpenCode", | |
| 114 | − icon: "https://opencode.ai/favicon-96x96.png", | |
| 115 | − getConfig: (mcpUrl, serverName) => ({ | |
| 116 | − type: "json", | |
| 117 | − description: "Add to your OpenCode config (opencode.json):", | |
| 118 | − content: JSON.stringify( | |
| 119 | − { | |
| 120 | − mcp: { | |
| 121 | − [serverName]: { | |
| 122 | − type: "remote", | |
| 123 | − url: mcpUrl, | |
| 124 | − enabled: true, | |
| 125 | − }, | |
| 126 | − }, | |
| 127 | − }, | |
| 128 | − null, | |
| 129 | − 2, | |
| 130 | − ), | |
| 131 | − }), | |
| 132 | − }, | |
| 133 | − { | |
| 134 | − id: "cline", | |
| 135 | − name: "Cline", | |
| 136 | − icon: "https://saoudrizwan.gallerycdn.vsassets.io/extensions/saoudrizwan/claude-dev/3.46.1/1766441409033/Microsoft.VisualStudio.Services.Icons.Default", | |
| 137 | − getConfig: (mcpUrl, serverName) => ({ | |
| 138 | − type: "json", | |
| 139 | − description: | |
| 140 | − "Open Cline > MCP Servers > Remote Servers > Edit Configuration:", | |
| 141 | − content: JSON.stringify( | |
| 142 | − { | |
| 143 | − mcpServers: { | |
| 144 | − [serverName]: { | |
| 145 | − url: mcpUrl, | |
| 146 | − type: "streamableHttp", | |
| 147 | − }, | |
| 148 | − }, | |
| 149 | − }, | |
| 150 | − null, | |
| 151 | − 2, | |
| 152 | − ), | |
| 153 | − }), | |
| 154 | − }, | |
| 155 | − { | |
| 156 | − id: "amp", | |
| 157 | − name: "Amp", | |
| 158 | − icon: "https://ampcode.com/app-icon.png", | |
| 159 | − getConfig: (mcpUrl, serverName) => ({ | |
| 160 | − type: "command", | |
| 161 | − description: "Run this command in your terminal:", | |
| 162 | − content: `amp mcp add ${serverName} ${mcpUrl}`, | |
| 163 | − }), | |
| 164 | − }, | |
| 165 | − { | |
| 166 | − id: "zed", | |
| 167 | − name: "Zed", | |
| 168 | − icon: "https://zed.dev/favicon_black_32.png", | |
| 169 | − getConfig: (mcpUrl, serverName) => ({ | |
| 170 | − type: "json", | |
| 171 | − description: "Add to your Zed settings.json:", | |
| 172 | − content: JSON.stringify( | |
| 173 | − { | |
| 174 | − context_servers: { | |
| 175 | − [serverName]: { | |
| 176 | − source: "custom", | |
| 177 | − command: "npx", | |
| 178 | − args: ["-y", "mcp-remote", mcpUrl], | |
| 179 | − }, | |
| 180 | − }, | |
| 181 | − }, | |
| 182 | − null, | |
| 183 | − 2, | |
| 184 | − ), | |
| 185 | − }), | |
| 186 | − }, | |
| 187 | − { | |
| 188 | − id: "roo-code", | |
| 189 | − name: "Roo Code", | |
| 190 | − icon: "https://rooveterinaryinc.gallerycdn.vsassets.io/extensions/rooveterinaryinc/roo-cline/3.37.1/1766531704474/Microsoft.VisualStudio.Services.Icons.Default", | |
| 191 | − getConfig: (mcpUrl, serverName) => ({ | |
| 192 | − type: "json", | |
| 193 | − description: "Add to your Roo Code MCP configuration file:", | |
| 194 | − content: JSON.stringify( | |
| 195 | − { | |
| 196 | − mcpServers: { | |
| 197 | − [serverName]: { | |
| 198 | − type: "streamable-http", | |
| 199 | − url: mcpUrl, | |
| 200 | − }, | |
| 201 | − }, | |
| 202 | − }, | |
| 203 | − null, | |
| 204 | − 2, | |
| 205 | − ), | |
| 206 | − }), | |
| 207 | − }, | |
| 208 | − { | |
| 209 | − id: "gemini-cli", | |
| 210 | − name: "Gemini CLI", | |
| 211 | − icon: "https://www.gstatic.com/devrel-devsite/prod/ve08add287a6b4bdf8961ab8a1be50bf551be3816cdd70b7cc934114ff3ad5f10/googledevai/images/favicon-new.png", | |
| 212 | − getConfig: (mcpUrl, serverName) => ({ | |
| 213 | − type: "json", | |
| 214 | − description: "Add to ~/.gemini/settings.json:", | |
| 215 | − content: JSON.stringify( | |
| 216 | − { | |
| 217 | − mcpServers: { | |
| 218 | − [serverName]: { | |
| 219 | − httpUrl: mcpUrl, | |
| 220 | − headers: { | |
| 221 | − Accept: "application/json, text/event-stream", | |
| 222 | − }, | |
| 223 | − }, | |
| 224 | − }, | |
| 225 | − }, | |
| 226 | − null, | |
| 227 | − 2, | |
| 228 | − ), | |
| 229 | − }), | |
| 230 | − }, | |
| 231 | − { | |
| 232 | − id: "copilot-coding-agent", | |
| 233 | − name: "Copilot Coding Agent", | |
| 234 | − icon: "https://github.githubassets.com/favicons/favicon.svg", | |
| 235 | − getConfig: (mcpUrl, serverName) => ({ | |
| 236 | − type: "json", | |
| 237 | − description: | |
| 238 | − "Add to Repository > Settings > Copilot > Coding agent > MCP configuration:", | |
| 239 | − content: JSON.stringify( | |
| 240 | − { | |
| 241 | − mcpServers: { | |
| 242 | − [serverName]: { | |
| 243 | − type: "http", | |
| 244 | − url: mcpUrl, | |
| 245 | − }, | |
| 246 | − }, | |
| 247 | − }, | |
| 248 | − null, | |
| 249 | − 2, | |
| 250 | − ), | |
| 251 | − }), | |
| 252 | − }, | |
| 253 | − { | |
| 254 | − id: "jetbrains", | |
| 255 | − name: "JetBrains AI Assistant", | |
| 256 | − icon: "https://www.jetbrains.com/favicon.ico", | |
| 257 | − getConfig: (mcpUrl, serverName) => ({ | |
| 258 | − type: "json", | |
| 259 | − description: | |
| 260 | − "Go to Settings > Tools > AI Assistant > Model Context Protocol (MCP), click + Add, select 'As JSON':", | |
| 261 | − content: JSON.stringify( | |
| 262 | − { | |
| 263 | − mcpServers: { | |
| 264 | − [serverName]: { | |
| 265 | − command: "npx", | |
| 266 | − args: ["-y", "mcp-remote", mcpUrl], | |
| 267 | − }, | |
| 268 | − }, | |
| 269 | − }, | |
| 270 | − null, | |
| 271 | − 2, | |
| 272 | − ), | |
| 273 | − }), | |
| 274 | − }, | |
| 275 | − { | |
| 276 | − id: "openai-codex", | |
| 277 | − name: "OpenAI Codex", | |
| 278 | − icon: "https://openai.com/favicon.ico", | |
| 279 | − getConfig: (mcpUrl, serverName) => ({ | |
| 280 | − type: "toml", | |
| 281 | − description: "Add to your OpenAI Codex MCP configuration:", | |
| 282 | − content: `[mcp_servers.${serverName}]\nurl = "${mcpUrl}"`, | |
| 283 | − }), | |
| 284 | − }, | |
| 285 | − { | |
| 286 | − id: "kilo-code", | |
| 287 | − name: "Kilo Code", | |
| 288 | − icon: "https://kilo.ai/favicon.ico", | |
| 289 | − getConfig: (mcpUrl, serverName) => ({ | |
| 290 | − type: "json", | |
| 291 | − description: | |
| 292 | − "Open Settings > MCP Servers > Add Server, or add to .kilocode/mcp.json:", | |
| 293 | − content: JSON.stringify( | |
| 294 | − { | |
| 295 | − mcpServers: { | |
| 296 | − [serverName]: { | |
| 297 | − type: "streamable-http", | |
| 298 | − url: mcpUrl, | |
| 299 | − disabled: false, | |
| 300 | − }, | |
| 301 | − }, | |
| 302 | − }, | |
| 303 | − null, | |
| 304 | − 2, | |
| 305 | − ), | |
| 306 | − }), | |
| 307 | − }, | |
| 308 | −]; | |
| 7 | +export function getAddMcpInstallConfig( | |
| 8 | + mcpUrl: string, | |
| 9 | + serverName: string, | |
| 10 | + auth?: { | |
| 11 | + apiKeyEnvName?: string; | |
| 12 | + }, | |
| 13 | +): McpInstallConfig { | |
| 14 | + const apiKeyEnvName = auth?.apiKeyEnvName?.trim() || "API_KEY"; | |
| 15 | + const headerArg = auth | |
| 16 | + ? ` --header "x-api-key: $${apiKeyEnvName}"` | |
| 17 | + : ""; | |
| 18 | + const exportLine = auth | |
| 19 | + ? `${apiKeyEnvName}="<paste-api-key-from-above>"` | |
| 20 | + : ""; | |
| 21 | + | |
| 22 | + return { | |
| 23 | + type: "command", | |
| 24 | + description: "Run once to install for all supported clients (via add-mcp):", | |
| 25 | + content: auth | |
| 26 | + ? `${exportLine}\nnpx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}` | |
| 27 | + : `npx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}`, | |
| 28 | + }; | |
| 29 | +} | |
| @@ -1,27 +1,13 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useMemo, useState } from "react"; |
| 4 | −import { Check, ChevronRight, Copy } from "lucide-react"; | |
| 5 | −import Image from "next/image"; | |
| 6 | −import { useQuery as useTanstackQuery } from "@tanstack/react-query"; | |
| 4 | +import { useQuery } from "convex/react"; | |
| 5 | +import { Check, Copy } from "lucide-react"; | |
| 7 | 6 | import { Button } from "@/components/ui/button"; |
| 8 | −import { | |
| 9 | − Collapsible, | |
| 10 | − CollapsibleContent, | |
| 11 | − CollapsibleTrigger, | |
| 12 | −} from "@/components/ui/collapsible"; | |
| 13 | 7 | import { Input } from "@/components/ui/input"; |
| 14 | 8 | import { Label } from "@/components/ui/label"; |
| 15 | −import { | |
| 16 | − Select, | |
| 17 | − SelectContent, | |
| 18 | − SelectItem, | |
| 19 | − SelectTrigger, | |
| 20 | − SelectValue, | |
| 21 | −} from "@/components/ui/select"; | |
| 22 | −import { MCP_PROVIDERS } from "@/components/tools/install-configs"; | |
| 23 | −import { getAnonymousAuthToken } from "@/lib/anonymous-auth"; | |
| 24 | −import { cn } from "@/lib/utils"; | |
| 9 | +import { getAddMcpInstallConfig } from "@/components/tools/install-configs"; | |
| 10 | +import { convexApi } from "@/lib/convex-api"; | |
| 25 | 11 | |
| 26 | 12 | function inferServerName(workspaceId?: string): string { |
| 27 | 13 | if (!workspaceId) return "executor"; |
| @@ -33,6 +19,11 @@ function isAnonymousSessionId(sessionId?: string): boolean { · 5 unmodified lines | ||
| 33 | 19 | return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_"); |
| 34 | 20 | } |
| 35 | 21 | |
| 22 | +function isWorkosSessionId(sessionId?: string): boolean { | |
| 23 | + if (!sessionId) return false; | |
| 24 | + return sessionId.startsWith("workos_"); | |
| 25 | +} | |
| 26 | + | |
| 36 | 27 | function resolveMcpOrigin(windowOrigin: string): string { |
| 37 | 28 | const explicit = process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL ?? process.env.NEXT_PUBLIC_CONVEX_SITE_URL; |
| 38 | 29 | if (explicit && explicit.trim().length > 0) { |
| @@ -58,15 +49,11 @@ function resolveMcpOrigin(windowOrigin: string): string { · 19 unmodified lines | ||
| 58 | 49 | export function McpSetupCard({ |
| 59 | 50 | workspaceId, |
| 60 | 51 | sessionId, |
| 61 | − accountId, | |
| 62 | 52 | }: { |
| 63 | 53 | workspaceId?: string; |
| 64 | 54 | sessionId?: string; |
| 65 | − accountId?: string; | |
| 66 | 55 | }) { |
| 67 | − const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); | |
| 68 | 56 | const [copiedKey, setCopiedKey] = useState<string | null>(null); |
| 69 | − const [headersOpen, setHeadersOpen] = useState(false); | |
| 70 | 57 | |
| 71 | 58 | const origin = useMemo(() => { |
| 72 | 59 | if (typeof window === "undefined") return ""; |
| @@ -78,7 +65,9 @@ export function McpSetupCard({ · 5 unmodified lines | ||
| 78 | 65 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; |
| 79 | 66 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); |
| 80 | 67 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); |
| 81 | − if (!isAnonymousSession && sessionId) base.searchParams.set("sessionId", sessionId); | |
| 68 | + if (!isAnonymousSession && sessionId && !isWorkosSessionId(sessionId)) { | |
| 69 | + base.searchParams.set("sessionId", sessionId); | |
| 70 | + } | |
| 82 | 71 | if (!origin) { |
| 83 | 72 | return `${base.pathname}${base.search}`; |
| 84 | 73 | } |
| @@ -87,40 +76,39 @@ export function McpSetupCard({ · 2 unmodified lines | ||
| 87 | 76 | |
| 88 | 77 | const isAnonymousSession = isAnonymousSessionId(sessionId); |
| 89 | 78 | |
| 90 | − const anonymousTokenQuery = useTanstackQuery<{ accessToken: string; accountId: string; expiresAtMs: number } | null>({ | |
| 91 | − queryKey: ["mcp-anonymous-token", sessionId, accountId], | |
| 92 | − queryFn: async () => { | |
| 93 | − const token = await getAnonymousAuthToken(false, accountId); | |
| 94 | − return token; | |
| 95 | − }, | |
| 96 | − enabled: isAnonymousSession, | |
| 97 | − retry: false, | |
| 98 | − }); | |
| 99 | − | |
| 100 | − const anonymousAccessToken = anonymousTokenQuery.data?.accessToken ?? null; | |
| 101 | − const anonymousTokenError = anonymousTokenQuery.isError | |
| 102 | − ? anonymousTokenQuery.error instanceof Error | |
| 103 | − ? anonymousTokenQuery.error.message | |
| 104 | − : "Failed to get anonymous token" | |
| 79 | + const anonymousMcpApiKey = useQuery( | |
| 80 | + convexApi.workspace.getMcpApiKey, | |
| 81 | + isAnonymousSession && workspaceId | |
| 82 | + ? { | |
| 83 | + workspaceId, | |
| 84 | + sessionId, | |
| 85 | + } | |
| 86 | + : "skip", | |
| 87 | + ); | |
| 88 | + | |
| 89 | + const apiKeyEnvName = anonymousMcpApiKey?.envVar ?? "API_KEY"; | |
| 90 | + const apiKeyValue = anonymousMcpApiKey?.enabled | |
| 91 | + ? anonymousMcpApiKey.apiKey | |
| 92 | + : null; | |
| 93 | + const apiKeyError = isAnonymousSession | |
| 94 | + ? anonymousMcpApiKey?.enabled === false | |
| 95 | + ? anonymousMcpApiKey.error | |
| 96 | + : workspaceId | |
| 97 | + ? null | |
| 98 | + : "Workspace is required to issue an MCP API key" | |
| 105 | 99 | : null; |
| 106 | 100 | |
| 107 | − const headerLines = useMemo(() => { | |
| 108 | − return [ | |
| 109 | − `Authorization: Bearer ${anonymousAccessToken ?? "<loading anonymous token...>"}`, | |
| 110 | − "Accept: application/json, text/event-stream", | |
| 111 | − ]; | |
| 112 | − }, [anonymousAccessToken]); | |
| 113 | − | |
| 114 | − const provider = MCP_PROVIDERS.find((item) => item.id === selectedProviderId) ?? MCP_PROVIDERS[0]; | |
| 115 | − if (!provider) { | |
| 116 | − return ( | |
| 117 | − <div className="rounded-md border border-border bg-card/50 p-3 text-[11px] text-muted-foreground"> | |
| 118 | − MCP provider presets are unavailable. | |
| 119 | − </div> | |
| 120 | − ); | |
| 121 | − } | |
| 122 | 101 | const serverName = inferServerName(workspaceId); |
| 123 | − const providerConfig = provider.getConfig(mcpUrl, serverName); | |
| 102 | + const installConfig = getAddMcpInstallConfig( | |
| 103 | + mcpUrl, | |
| 104 | + serverName, | |
| 105 | + isAnonymousSession | |
| 106 | + ? { | |
| 107 | + apiKeyEnvName, | |
| 108 | + apiKey: apiKeyValue ?? "your-api-key-here", | |
| 109 | + } | |
| 110 | + : undefined, | |
| 111 | + ); | |
| 124 | 112 | |
| 125 | 113 | const copyText = async (key: string, text: string) => { |
| 126 | 114 | await navigator.clipboard.writeText(text); |
| @@ -128,9 +116,9 @@ export function McpSetupCard({ · 1 unmodified line | ||
| 128 | 116 | setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1500); |
| 129 | 117 | }; |
| 130 | 118 | |
| 131 | − const codeLanguage = providerConfig.type === "command" | |
| 119 | + const codeLanguage = installConfig.type === "command" | |
| 132 | 120 | ? "bash" |
| 133 | − : providerConfig.type; | |
| 121 | + : installConfig.type; | |
| 134 | 122 | |
| 135 | 123 | return ( |
| 136 | 124 | <div className="space-y-5"> |
| @@ -150,81 +138,44 @@ export function McpSetupCard({ · 13 unmodified lines | ||
| 150 | 138 | </div> |
| 151 | 139 | |
| 152 | 140 | {isAnonymousSession && ( |
| 153 | − <Collapsible open={headersOpen} onOpenChange={setHeadersOpen}> | |
| 154 | − <div className="mt-2"> | |
| 155 | − <CollapsibleTrigger asChild> | |
| 156 | − <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | |
| 157 | − <ChevronRight | |
| 158 | − className={cn("mr-1 h-3 w-3 transition-transform", headersOpen && "rotate-90")} | |
| 159 | − /> | |
| 160 | − {headersOpen ? "Hide headers" : "Set these headers also"} | |
| 161 | − </Button> | |
| 162 | − </CollapsibleTrigger> | |
| 141 | + <div className="mt-2 space-y-1.5"> | |
| 142 | + <Label className="text-xs text-muted-foreground">API Key ({apiKeyEnvName})</Label> | |
| 143 | + <div className="flex items-center gap-2"> | |
| 144 | + <Input | |
| 145 | + value={apiKeyValue ?? "Loading API key..."} | |
| 146 | + readOnly | |
| 147 | + className="h-8 text-xs font-mono bg-background" | |
| 148 | + /> | |
| 149 | + <Button | |
| 150 | + type="button" | |
| 151 | + size="icon" | |
| 152 | + variant="outline" | |
| 153 | + className="h-8 w-8" | |
| 154 | + disabled={!apiKeyValue} | |
| 155 | + onClick={() => apiKeyValue ? void copyText("api-key", apiKeyValue) : undefined} | |
| 156 | + > | |
| 157 | + {copiedKey === "api-key" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} | |
| 158 | + </Button> | |
| 163 | 159 | </div> |
| 164 | − | |
| 165 | − <CollapsibleContent className="mt-1.5"> | |
| 166 | − <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2 space-y-2"> | |
| 167 | − <div className="flex items-center justify-end"> | |
| 168 | − <Button | |
| 169 | − type="button" | |
| 170 | − variant="outline" | |
| 171 | − size="sm" | |
| 172 | − className="h-6 text-[10px]" | |
| 173 | − onClick={() => void copyText("headers", headerLines.join("\n"))} | |
| 174 | − > | |
| 175 | − {copiedKey === "headers" ? "Copied" : "Copy headers"} | |
| 176 | − </Button> | |
| 177 | − </div> | |
| 178 | − <pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-foreground"> | |
| 179 | − <code>{headerLines.join("\n")}</code> | |
| 180 | − </pre> | |
| 181 | − {anonymousTokenError && <p className="text-[10px] text-destructive">{anonymousTokenError}</p>} | |
| 182 | − </div> | |
| 183 | − </CollapsibleContent> | |
| 184 | − </Collapsible> | |
| 160 | + {apiKeyError && <p className="text-[10px] text-destructive">{apiKeyError}</p>} | |
| 161 | + </div> | |
| 185 | 162 | )} |
| 186 | 163 | </div> |
| 187 | 164 | |
| 188 | − <div className="space-y-1.5"> | |
| 189 | − <Label className="text-xs text-muted-foreground">Install For</Label> | |
| 190 | − <Select value={selectedProviderId} onValueChange={setSelectedProviderId}> | |
| 191 | − <SelectTrigger className="h-8 text-xs bg-background"> | |
| 192 | − <SelectValue /> | |
| 193 | − </SelectTrigger> | |
| 194 | − <SelectContent> | |
| 195 | − {MCP_PROVIDERS.map((item) => ( | |
| 196 | − <SelectItem key={item.id} value={item.id} className="text-xs"> | |
| 197 | − <div className="flex items-center gap-2"> | |
| 198 | − <Image | |
| 199 | − src={item.icon} | |
| 200 | − alt="" | |
| 201 | − width={14} | |
| 202 | − height={14} | |
| 203 | − className="h-3.5 w-3.5 rounded-sm" | |
| 204 | − unoptimized | |
| 205 | − /> | |
| 206 | − <span>{item.name}</span> | |
| 207 | − </div> | |
| 208 | − </SelectItem> | |
| 209 | − ))} | |
| 210 | − </SelectContent> | |
| 211 | − </Select> | |
| 212 | − </div> | |
| 213 | − | |
| 214 | 165 | <div className="space-y-2"> |
| 215 | − <p className="text-[11px] text-muted-foreground">{providerConfig.description}</p> | |
| 166 | + <p className="text-[11px] text-muted-foreground">{installConfig.description}</p> | |
| 216 | 167 | <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2"> |
| 217 | 168 | <pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-foreground"> |
| 218 | − <code className={`language-${codeLanguage}`}>{providerConfig.content}</code> | |
| 169 | + <code className={`language-${codeLanguage}`}>{installConfig.content}</code> | |
| 219 | 170 | </pre> |
| 220 | 171 | <Button |
| 221 | 172 | type="button" |
| 222 | 173 | variant="outline" |
| 223 | 174 | size="sm" |
| 224 | 175 | className="h-7 text-[11px]" |
| 225 | − onClick={() => void copyText("provider", providerConfig.content)} | |
| 176 | + onClick={() => void copyText("install", installConfig.content)} | |
| 226 | 177 | > |
| 227 | − {copiedKey === "provider" ? "Copied" : "Copy snippet"} | |
| 178 | + {copiedKey === "install" ? "Copied" : "Copy command"} | |
| 228 | 179 | </Button> |
| 229 | 180 | </div> |
| 230 | 181 | </div> |
| @@ -24,10 +24,66 @@ const convexClient = new ConvexReactClient(convexUrl, { · 23 unmodified lines | ||
| 24 | 24 | unsavedChangesWarning: false, |
| 25 | 25 | }); |
| 26 | 26 | |
| 27 | +type WorkosAuthProfile = { | |
| 28 | + name?: string; | |
| 29 | + email?: string; | |
| 30 | + avatarUrl?: string | null; | |
| 31 | +}; | |
| 32 | + | |
| 33 | +function readNonEmptyString(value: unknown): string | undefined { | |
| 34 | + if (typeof value !== "string") { | |
| 35 | + return undefined; | |
| 36 | + } | |
| 37 | + | |
| 38 | + const trimmed = value.trim(); | |
| 39 | + return trimmed.length > 0 ? trimmed : undefined; | |
| 40 | +} | |
| 41 | + | |
| 42 | +function getRecordString(record: Record<string, unknown>, keys: string[]): string | undefined { | |
| 43 | + for (const key of keys) { | |
| 44 | + const value = readNonEmptyString(record[key]); | |
| 45 | + if (value) { | |
| 46 | + return value; | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + return undefined; | |
| 51 | +} | |
| 52 | + | |
| 53 | +function buildWorkosAuthProfile(user: unknown): WorkosAuthProfile | null { | |
| 54 | + if (!user || typeof user !== "object") { | |
| 55 | + return null; | |
| 56 | + } | |
| 57 | + | |
| 58 | + const userRecord = user as Record<string, unknown>; | |
| 59 | + const firstName = getRecordString(userRecord, ["firstName", "first_name", "givenName", "given_name"]); | |
| 60 | + const lastName = getRecordString(userRecord, ["lastName", "last_name", "familyName", "family_name"]); | |
| 61 | + const combinedName = [firstName, lastName].filter(Boolean).join(" ").trim(); | |
| 62 | + const name = | |
| 63 | + getRecordString(userRecord, ["name", "fullName", "full_name", "displayName", "display_name"]) | |
| 64 | + ?? (combinedName.length > 0 ? combinedName : undefined); | |
| 65 | + | |
| 66 | + return { | |
| 67 | + name, | |
| 68 | + email: getRecordString(userRecord, ["email", "emailAddress", "email_address"]), | |
| 69 | + avatarUrl: | |
| 70 | + getRecordString(userRecord, [ | |
| 71 | + "profilePictureUrl", | |
| 72 | + "profile_picture_url", | |
| 73 | + "avatarUrl", | |
| 74 | + "avatar_url", | |
| 75 | + "pictureUrl", | |
| 76 | + "picture_url", | |
| 77 | + "picture", | |
| 78 | + ]) ?? null, | |
| 79 | + }; | |
| 80 | +} | |
| 81 | + | |
| 27 | 82 | /** Exposes whether the WorkOS auth token is still being resolved. */ |
| 28 | 83 | const WorkosAuthContext = createContext({ |
| 29 | 84 | loading: false, |
| 30 | 85 | authenticated: false, |
| 86 | + profile: null as WorkosAuthProfile | null, | |
| 31 | 87 | }); |
| 32 | 88 | |
| 33 | 89 | export function useWorkosAuthState() { |
| @@ -110,9 +166,10 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 76 unmodified lines | ||
| 110 | 166 | const { loading: tokenLoading } = useAccessToken(); |
| 111 | 167 | const authenticated = Boolean(user); |
| 112 | 168 | const loading = authLoading || (authenticated && tokenLoading); |
| 169 | + const profile = buildWorkosAuthProfile(user); | |
| 113 | 170 | |
| 114 | 171 | return ( |
| 115 | − <WorkosAuthContext.Provider value={{ loading, authenticated }}> | |
| 172 | + <WorkosAuthContext.Provider value={{ loading, authenticated, profile }}> | |
| 116 | 173 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}> |
| 117 | 174 | {children} |
| 118 | 175 | </ConvexProviderWithAuth> |
| @@ -130,7 +187,7 @@ export function AppConvexProvider({ children }: { children: ReactNode }) { · 11 unmodified lines | ||
| 130 | 187 | } |
| 131 | 188 | |
| 132 | 189 | return ( |
| 133 | − <WorkosAuthContext.Provider value={{ loading: false, authenticated: false }}> | |
| 190 | + <WorkosAuthContext.Provider value={{ loading: false, authenticated: false, profile: null }}> | |
| 134 | 191 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromAnonymous}> |
| 135 | 192 | {children} |
| 136 | 193 | </ConvexProviderWithAuth> |
| @@ -34,6 +34,14 @@ import { clearAnonymousAuth } from "@/lib/anonymous-auth"; · 33 unmodified lines | ||
| 34 | 34 | import type { AnonymousContext } from "./types"; |
| 35 | 35 | import type { Id } from "@executor/database/convex/_generated/dataModel"; |
| 36 | 36 | |
| 37 | +function isGeneratedWorkosLabel(name: string | undefined): boolean { | |
| 38 | + if (!name) { | |
| 39 | + return false; | |
| 40 | + } | |
| 41 | + | |
| 42 | + return /^User [A-Za-z0-9]{6}$/.test(name.trim()); | |
| 43 | +} | |
| 44 | + | |
| 37 | 45 | interface SessionState { |
| 38 | 46 | context: AnonymousContext | null; |
| 39 | 47 | loading: boolean; |
| @@ -103,7 +111,11 @@ const SessionContext = createContext<SessionState>({ · 63 unmodified lines | ||
| 103 | 111 | }); |
| 104 | 112 | |
| 105 | 113 | export function SessionProvider({ children }: { children: ReactNode }) { |
| 106 | − const { loading: workosAuthLoading, authenticated: workosAuthenticated } = useWorkosAuthState(); | |
| 114 | + const { | |
| 115 | + loading: workosAuthLoading, | |
| 116 | + authenticated: workosAuthenticated, | |
| 117 | + profile: workosAuthProfile, | |
| 118 | + } = useWorkosAuthState(); | |
| 107 | 119 | const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession); |
| 108 | 120 | const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId); |
| 109 | 121 | const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>( |
| @@ -319,6 +331,25 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 209 unmodified lines | ||
| 319 | 331 | const workspaceOptions = useMemo(() => { |
| 320 | 332 | return buildWorkspaceOptions(mode, workspaces, guestContext); |
| 321 | 333 | }, [mode, workspaces, guestContext]); |
| 334 | + const resolvedWorkosProfile = useMemo(() => { | |
| 335 | + if (!account || account.provider !== "workos") { | |
| 336 | + return null; | |
| 337 | + } | |
| 338 | + | |
| 339 | + const accountName = account.name?.trim(); | |
| 340 | + const authName = workosAuthProfile?.name?.trim(); | |
| 341 | + const name = | |
| 342 | + (accountName && !isGeneratedWorkosLabel(accountName) ? accountName : undefined) | |
| 343 | + ?? authName | |
| 344 | + ?? accountName | |
| 345 | + ?? "User"; | |
| 346 | + | |
| 347 | + return { | |
| 348 | + name, | |
| 349 | + email: account.email ?? workosAuthProfile?.email, | |
| 350 | + avatarUrl: account.avatarUrl ?? workosAuthProfile?.avatarUrl ?? null, | |
| 351 | + }; | |
| 352 | + }, [account, workosAuthProfile]); | |
| 322 | 353 | |
| 323 | 354 | return ( |
| 324 | 355 | <SessionContext.Provider |
| @@ -337,14 +368,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines | ||
| 337 | 368 | creatingAnonymousOrganization, |
| 338 | 369 | createAnonymousOrganization, |
| 339 | 370 | isSignedInToWorkos: Boolean(account && account.provider === "workos"), |
| 340 | − workosProfile: | |
| 341 | − account && account.provider === "workos" | |
| 342 | − ? { | |
| 343 | − name: account.name, | |
| 344 | − email: account.email, | |
| 345 | − avatarUrl: account.avatarUrl ?? null, | |
| 346 | − } | |
| 347 | − : null, | |
| 371 | + workosProfile: resolvedWorkosProfile, | |
| 348 | 372 | resetWorkspace, |
| 349 | 373 | }} |
| 350 | 374 | > |
| @@ -26,6 +26,18 @@ export async function resolveAccountForRequest( · 25 unmodified lines | ||
| 26 | 26 | sessionId?: string, |
| 27 | 27 | ): Promise<Doc<"accounts"> | null> { |
| 28 | 28 | const normalizedSessionId = sessionId?.trim() || ""; |
| 29 | + const identity = await ctx.auth.getUserIdentity(); | |
| 30 | + const hasNonAnonymousIdentity = Boolean(identity && !isAnonymousIdentity(identity)); | |
| 31 | + | |
| 32 | + if (identity && hasNonAnonymousIdentity) { | |
| 33 | + const workosAccount = await ctx.db | |
| 34 | + .query("accounts") | |
| 35 | + .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject)) | |
| 36 | + .unique(); | |
| 37 | + if (workosAccount) { | |
| 38 | + return workosAccount; | |
| 39 | + } | |
| 40 | + } | |
| 29 | 41 | |
| 30 | 42 | if (normalizedSessionId.startsWith("anon_session_") || normalizedSessionId.startsWith("mcp_")) { |
| 31 | 43 | const anonymousSession = await ctx.db |
| @@ -40,9 +52,8 @@ export async function resolveAccountForRequest( · 8 unmodified lines | ||
| 40 | 52 | } |
| 41 | 53 | } |
| 42 | 54 | |
| 43 | − const identity = await ctx.auth.getUserIdentity(); | |
| 44 | 55 | if (identity) { |
| 45 | − if (isAnonymousIdentity(identity)) { | |
| 56 | + if (!hasNonAnonymousIdentity) { | |
| 46 | 57 | const anonymousById = await ctx.db.get(identity.subject as Id<"accounts">); |
| 47 | 58 | if (anonymousById?.provider === "anonymous") { |
| 48 | 59 | return anonymousById; |
| @@ -56,14 +67,6 @@ export async function resolveAccountForRequest( · 7 unmodified lines | ||
| 56 | 67 | return anonymousAccount; |
| 57 | 68 | } |
| 58 | 69 | } |
| 59 | − | |
| 60 | − const workosAccount = await ctx.db | |
| 61 | − .query("accounts") | |
| 62 | − .withIndex("by_provider", (q) => q.eq("provider", "workos").eq("providerAccountId", identity.subject)) | |
| 63 | − .unique(); | |
| 64 | − if (workosAccount) { | |
| 65 | − return workosAccount; | |
| 66 | − } | |
| 67 | 70 | } |
| 68 | 71 | |
| 69 | 72 | return null; |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | ||
| 7 | 7 | * Specs are fetched live so these tests require network access and are slower (~5-60s each). |
| 8 | 8 | */ |
| 9 | 9 | import { test, expect, describe } from "bun:test"; |
| 10 | +import SwaggerParser from "@apidevtools/swagger-parser"; | |
| 10 | 11 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources"; |
| 11 | 12 | |
| 12 | 13 | interface SpecFixture { |
| @@ -18,6 +19,408 @@ interface SpecFixture { · 5 unmodified lines | ||
| 18 | 19 | expectDts: boolean; |
| 19 | 20 | } |
| 20 | 21 | |
| 22 | +type PrimitiveParamType = "string" | "number" | "boolean"; | |
| 23 | + | |
| 24 | +const HTTP_METHODS = ["get", "post", "put", "delete", "patch", "head", "options"] as const; | |
| 25 | +const JSON_CONTENT_TYPES = ["application/json", "application/*+json", "text/json"] as const; | |
| 26 | + | |
| 27 | +interface OperationTypeExpectations { | |
| 28 | + expectedInputFields: Array<{ name: string; type: PrimitiveParamType }>; | |
| 29 | + expectedOutputFields: Array<{ name: string; type: PrimitiveParamType }>; | |
| 30 | + expectsInput: boolean; | |
| 31 | + expectsKnownOutput: boolean; | |
| 32 | + expectsVoidOutput: boolean; | |
| 33 | +} | |
| 34 | + | |
| 35 | +function toRecord(value: unknown): Record<string, unknown> { | |
| 36 | + return value && typeof value === "object" && !Array.isArray(value) | |
| 37 | + ? value as Record<string, unknown> | |
| 38 | + : {}; | |
| 39 | +} | |
| 40 | + | |
| 41 | +function resolveSchemaPrimitiveType( | |
| 42 | + schemaValue: unknown, | |
| 43 | + componentSchemas: Record<string, unknown>, | |
| 44 | + seenRefs: Set<string> = new Set(), | |
| 45 | +): PrimitiveParamType | null { | |
| 46 | + const schema = toRecord(schemaValue); | |
| 47 | + const type = typeof schema.type === "string" ? schema.type : ""; | |
| 48 | + if (type === "integer" || type === "number") return "number"; | |
| 49 | + if (type === "string" || type === "boolean") return type; | |
| 50 | + | |
| 51 | + const ref = typeof schema.$ref === "string" ? schema.$ref : ""; | |
| 52 | + const schemaRefPrefix = "#/components/schemas/"; | |
| 53 | + if (ref.startsWith(schemaRefPrefix) && !seenRefs.has(ref)) { | |
| 54 | + const key = ref.slice(schemaRefPrefix.length); | |
| 55 | + const nextSeen = new Set(seenRefs); | |
| 56 | + nextSeen.add(ref); | |
| 57 | + return resolveSchemaPrimitiveType(componentSchemas[key], componentSchemas, nextSeen); | |
| 58 | + } | |
| 59 | + | |
| 60 | + return null; | |
| 61 | +} | |
| 62 | + | |
| 63 | +function resolveParameterEntry( | |
| 64 | + entry: unknown, | |
| 65 | + componentParameters: Record<string, unknown>, | |
| 66 | +): Record<string, unknown> { | |
| 67 | + const record = toRecord(entry); | |
| 68 | + const ref = typeof record.$ref === "string" ? record.$ref : ""; | |
| 69 | + const prefix = "#/components/parameters/"; | |
| 70 | + if (!ref.startsWith(prefix)) return record; | |
| 71 | + | |
| 72 | + const key = ref.slice(prefix.length); | |
| 73 | + const resolved = toRecord(componentParameters[key]); | |
| 74 | + return Object.keys(resolved).length > 0 ? resolved : record; | |
| 75 | +} | |
| 76 | + | |
| 77 | +function resolveRequestBodyEntry( | |
| 78 | + entry: unknown, | |
| 79 | + componentRequestBodies: Record<string, unknown>, | |
| 80 | +): Record<string, unknown> { | |
| 81 | + const record = toRecord(entry); | |
| 82 | + const ref = typeof record.$ref === "string" ? record.$ref : ""; | |
| 83 | + const prefix = "#/components/requestBodies/"; | |
| 84 | + if (!ref.startsWith(prefix)) return record; | |
| 85 | + | |
| 86 | + const key = ref.slice(prefix.length); | |
| 87 | + const resolved = toRecord(componentRequestBodies[key]); | |
| 88 | + return Object.keys(resolved).length > 0 ? resolved : record; | |
| 89 | +} | |
| 90 | + | |
| 91 | +function resolveResponseEntry( | |
| 92 | + entry: unknown, | |
| 93 | + componentResponses: Record<string, unknown>, | |
| 94 | +): Record<string, unknown> { | |
| 95 | + const record = toRecord(entry); | |
| 96 | + const ref = typeof record.$ref === "string" ? record.$ref : ""; | |
| 97 | + const prefix = "#/components/responses/"; | |
| 98 | + if (!ref.startsWith(prefix)) return record; | |
| 99 | + | |
| 100 | + const key = ref.slice(prefix.length); | |
| 101 | + const resolved = toRecord(componentResponses[key]); | |
| 102 | + return Object.keys(resolved).length > 0 ? resolved : record; | |
| 103 | +} | |
| 104 | + | |
| 105 | +function preferredContentSchema(contentValue: unknown): Record<string, unknown> { | |
| 106 | + const content = toRecord(contentValue); | |
| 107 | + for (const mediaType of JSON_CONTENT_TYPES) { | |
| 108 | + const candidate = toRecord(content[mediaType]); | |
| 109 | + const schema = toRecord(candidate.schema); | |
| 110 | + if (Object.keys(schema).length > 0) return schema; | |
| 111 | + } | |
| 112 | + | |
| 113 | + for (const value of Object.values(content)) { | |
| 114 | + const schema = toRecord(toRecord(value).schema); | |
| 115 | + if (Object.keys(schema).length > 0) return schema; | |
| 116 | + } | |
| 117 | + | |
| 118 | + return {}; | |
| 119 | +} | |
| 120 | + | |
| 121 | +function collectOperationTypeExpectations( | |
| 122 | + spec: Record<string, unknown>, | |
| 123 | +): Map<string, OperationTypeExpectations> { | |
| 124 | + const components = toRecord(spec.components); | |
| 125 | + const componentParameters = toRecord(components.parameters); | |
| 126 | + const componentSchemas = toRecord(components.schemas); | |
| 127 | + const componentResponses = toRecord(components.responses); | |
| 128 | + const componentRequestBodies = toRecord(components.requestBodies); | |
| 129 | + const paths = toRecord(spec.paths); | |
| 130 | + | |
| 131 | + const byOperationId = new Map<string, OperationTypeExpectations>(); | |
| 132 | + | |
| 133 | + for (const [pathTemplate, pathValue] of Object.entries(paths)) { | |
| 134 | + const pathObject = toRecord(pathValue); | |
| 135 | + const sharedParameters = Array.isArray(pathObject.parameters) ? pathObject.parameters : []; | |
| 136 | + | |
| 137 | + for (const method of HTTP_METHODS) { | |
| 138 | + const operation = toRecord(pathObject[method]); | |
| 139 | + if (Object.keys(operation).length === 0) continue; | |
| 140 | + const operationId = typeof operation.operationId === "string" && operation.operationId.length > 0 | |
| 141 | + ? operation.operationId | |
| 142 | + : `${method}_${pathTemplate}`; | |
| 143 | + | |
| 144 | + const operationParameters = Array.isArray(operation.parameters) ? operation.parameters : []; | |
| 145 | + const paramEntries = [...sharedParameters, ...operationParameters]; | |
| 146 | + const expectedByName = new Map<string, PrimitiveParamType>(); | |
| 147 | + const conflictedNames = new Set<string>(); | |
| 148 | + | |
| 149 | + const upsertExpectedField = (name: string, fieldType: PrimitiveParamType) => { | |
| 150 | + if (conflictedNames.has(name)) return; | |
| 151 | + const existing = expectedByName.get(name); | |
| 152 | + if (!existing) { | |
| 153 | + expectedByName.set(name, fieldType); | |
| 154 | + return; | |
| 155 | + } | |
| 156 | + if (existing !== fieldType) { | |
| 157 | + expectedByName.delete(name); | |
| 158 | + conflictedNames.add(name); | |
| 159 | + } | |
| 160 | + }; | |
| 161 | + | |
| 162 | + for (const paramEntry of paramEntries) { | |
| 163 | + const parameter = resolveParameterEntry(paramEntry, componentParameters); | |
| 164 | + const location = typeof parameter.in === "string" ? parameter.in : ""; | |
| 165 | + if (location !== "path" && location !== "query" && location !== "header" && location !== "cookie") { | |
| 166 | + continue; | |
| 167 | + } | |
| 168 | + | |
| 169 | + const name = typeof parameter.name === "string" ? parameter.name.trim() : ""; | |
| 170 | + if (!name) continue; | |
| 171 | + | |
| 172 | + const primitiveType = resolveSchemaPrimitiveType(parameter.schema, componentSchemas); | |
| 173 | + if (!primitiveType) continue; | |
| 174 | + upsertExpectedField(name, primitiveType); | |
| 175 | + } | |
| 176 | + | |
| 177 | + const requestBody = resolveRequestBodyEntry(operation.requestBody, componentRequestBodies); | |
| 178 | + const requestBodySchema = preferredContentSchema(requestBody.content); | |
| 179 | + const requestBodyProperties = toRecord(requestBodySchema.properties); | |
| 180 | + const requiredBodyKeys = Array.isArray(requestBodySchema.required) | |
| 181 | + ? requestBodySchema.required.filter((value): value is string => typeof value === "string") | |
| 182 | + : []; | |
| 183 | + for (const key of requiredBodyKeys) { | |
| 184 | + const primitiveType = resolveSchemaPrimitiveType(requestBodyProperties[key], componentSchemas); | |
| 185 | + if (!primitiveType) continue; | |
| 186 | + upsertExpectedField(key, primitiveType); | |
| 187 | + } | |
| 188 | + | |
| 189 | + const responses = toRecord(operation.responses); | |
| 190 | + let expectsKnownOutput = false; | |
| 191 | + let expectsVoidOutput = false; | |
| 192 | + let expectedOutputFields: Array<{ name: string; type: PrimitiveParamType }> = []; | |
| 193 | + for (const [statusCode, responseValue] of Object.entries(responses)) { | |
| 194 | + if (!statusCode.startsWith("2")) continue; | |
| 195 | + | |
| 196 | + const response = resolveResponseEntry(responseValue, componentResponses); | |
| 197 | + const responseSchema = preferredContentSchema(response.content); | |
| 198 | + if (Object.keys(responseSchema).length > 0) { | |
| 199 | + expectsKnownOutput = true; | |
| 200 | + | |
| 201 | + const responseProperties = toRecord(responseSchema.properties); | |
| 202 | + const requiredResponseKeys = Array.isArray(responseSchema.required) | |
| 203 | + ? responseSchema.required.filter((value): value is string => typeof value === "string") | |
| 204 | + : []; | |
| 205 | + expectedOutputFields = requiredResponseKeys | |
| 206 | + .map((key) => { | |
| 207 | + const primitiveType = resolveSchemaPrimitiveType(responseProperties[key], componentSchemas); | |
| 208 | + return primitiveType ? { name: key, type: primitiveType } : null; | |
| 209 | + }) | |
| 210 | + .filter((value): value is { name: string; type: PrimitiveParamType } => Boolean(value)); | |
| 211 | + } else if (statusCode === "204" || statusCode === "205") { | |
| 212 | + expectsVoidOutput = true; | |
| 213 | + } | |
| 214 | + break; | |
| 215 | + } | |
| 216 | + | |
| 217 | + byOperationId.set(operationId, { | |
| 218 | + expectedInputFields: [...expectedByName.entries()].map(([name, type]) => ({ name, type })), | |
| 219 | + expectedOutputFields, | |
| 220 | + expectsInput: paramEntries.length > 0 || Object.keys(requestBodySchema).length > 0, | |
| 221 | + expectsKnownOutput, | |
| 222 | + expectsVoidOutput, | |
| 223 | + }); | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + return byOperationId; | |
| 228 | +} | |
| 229 | + | |
| 230 | +function extractHintSegment(inputHint: string, paramName: string): string | null { | |
| 231 | + const splitTopLevelBy = (value: string, separator: string): string[] => { | |
| 232 | + const parts: string[] = []; | |
| 233 | + let segment = ""; | |
| 234 | + let parenDepth = 0; | |
| 235 | + let braceDepth = 0; | |
| 236 | + let bracketDepth = 0; | |
| 237 | + let angleDepth = 0; | |
| 238 | + let inSingle = false; | |
| 239 | + let inDouble = false; | |
| 240 | + let escapeNext = false; | |
| 241 | + | |
| 242 | + for (let i = 0; i < value.length; i += 1) { | |
| 243 | + const ch = value[i]!; | |
| 244 | + | |
| 245 | + if (escapeNext) { | |
| 246 | + segment += ch; | |
| 247 | + escapeNext = false; | |
| 248 | + continue; | |
| 249 | + } | |
| 250 | + | |
| 251 | + if ((inSingle || inDouble) && ch === "\\") { | |
| 252 | + segment += ch; | |
| 253 | + escapeNext = true; | |
| 254 | + continue; | |
| 255 | + } | |
| 256 | + | |
| 257 | + if (!inDouble && ch === "'" && !escapeNext) { | |
| 258 | + inSingle = !inSingle; | |
| 259 | + segment += ch; | |
| 260 | + continue; | |
| 261 | + } | |
| 262 | + | |
| 263 | + if (!inSingle && ch === '"' && !escapeNext) { | |
| 264 | + inDouble = !inDouble; | |
| 265 | + segment += ch; | |
| 266 | + continue; | |
| 267 | + } | |
| 268 | + | |
| 269 | + if (!inSingle && !inDouble) { | |
| 270 | + if (ch === "(") parenDepth += 1; | |
| 271 | + else if (ch === ")") parenDepth = Math.max(0, parenDepth - 1); | |
| 272 | + else if (ch === "{") braceDepth += 1; | |
| 273 | + else if (ch === "}") braceDepth = Math.max(0, braceDepth - 1); | |
| 274 | + else if (ch === "[") bracketDepth += 1; | |
| 275 | + else if (ch === "]") bracketDepth = Math.max(0, bracketDepth - 1); | |
| 276 | + else if (ch === "<") angleDepth += 1; | |
| 277 | + else if (ch === ">") angleDepth = Math.max(0, angleDepth - 1); | |
| 278 | + | |
| 279 | + if (ch === separator | |
| 280 | + && parenDepth === 0 | |
| 281 | + && braceDepth === 0 | |
| 282 | + && bracketDepth === 0 | |
| 283 | + && angleDepth === 0) { | |
| 284 | + const trimmed = segment.trim(); | |
| 285 | + if (trimmed) parts.push(trimmed); | |
| 286 | + segment = ""; | |
| 287 | + continue; | |
| 288 | + } | |
| 289 | + } | |
| 290 | + | |
| 291 | + segment += ch; | |
| 292 | + } | |
| 293 | + | |
| 294 | + const trimmed = segment.trim(); | |
| 295 | + if (trimmed) parts.push(trimmed); | |
| 296 | + return parts; | |
| 297 | + }; | |
| 298 | + | |
| 299 | + const hasBalancedWrappingParens = (value: string): boolean => { | |
| 300 | + if (!value.startsWith("(") || !value.endsWith(")")) return false; | |
| 301 | + | |
| 302 | + let depth = 0; | |
| 303 | + let inSingle = false; | |
| 304 | + let inDouble = false; | |
| 305 | + let escapeNext = false; | |
| 306 | + for (let i = 0; i < value.length; i += 1) { | |
| 307 | + const ch = value[i]!; | |
| 308 | + | |
| 309 | + if (escapeNext) { | |
| 310 | + escapeNext = false; | |
| 311 | + continue; | |
| 312 | + } | |
| 313 | + if ((inSingle || inDouble) && ch === "\\") { | |
| 314 | + escapeNext = true; | |
| 315 | + continue; | |
| 316 | + } | |
| 317 | + if (!inDouble && ch === "'") { | |
| 318 | + inSingle = !inSingle; | |
| 319 | + continue; | |
| 320 | + } | |
| 321 | + if (!inSingle && ch === '"') { | |
| 322 | + inDouble = !inDouble; | |
| 323 | + continue; | |
| 324 | + } | |
| 325 | + if (inSingle || inDouble) continue; | |
| 326 | + | |
| 327 | + if (ch === "(") depth += 1; | |
| 328 | + else if (ch === ")") { | |
| 329 | + depth = Math.max(0, depth - 1); | |
| 330 | + if (depth === 0 && i < value.length - 1) return false; | |
| 331 | + } | |
| 332 | + } | |
| 333 | + | |
| 334 | + return depth === 0; | |
| 335 | + }; | |
| 336 | + | |
| 337 | + const unwrapOuterParens = (value: string): string => { | |
| 338 | + let out = value.trim(); | |
| 339 | + while (hasBalancedWrappingParens(out)) { | |
| 340 | + out = out.slice(1, -1).trim(); | |
| 341 | + } | |
| 342 | + return out; | |
| 343 | + }; | |
| 344 | + | |
| 345 | + const queue = [inputHint]; | |
| 346 | + const visited = new Set<string>(); | |
| 347 | + const objectCandidates: string[] = []; | |
| 348 | + | |
| 349 | + while (queue.length > 0) { | |
| 350 | + const current = queue.shift(); | |
| 351 | + if (!current) continue; | |
| 352 | + const normalized = unwrapOuterParens(current); | |
| 353 | + if (!normalized || visited.has(normalized)) continue; | |
| 354 | + visited.add(normalized); | |
| 355 | + | |
| 356 | + const unionParts = splitTopLevelBy(normalized, "|"); | |
| 357 | + if (unionParts.length > 1) { | |
| 358 | + queue.push(...unionParts); | |
| 359 | + continue; | |
| 360 | + } | |
| 361 | + | |
| 362 | + const intersectionParts = splitTopLevelBy(normalized, "&"); | |
| 363 | + if (intersectionParts.length > 1) { | |
| 364 | + queue.push(...intersectionParts); | |
| 365 | + continue; | |
| 366 | + } | |
| 367 | + | |
| 368 | + if (normalized.startsWith("{") && normalized.endsWith("}")) { | |
| 369 | + objectCandidates.push(normalized); | |
| 370 | + } | |
| 371 | + } | |
| 372 | + | |
| 373 | + for (const candidate of objectCandidates) { | |
| 374 | + const inner = candidate.slice(1, -1); | |
| 375 | + const fields = splitTopLevelBy(inner, ";"); | |
| 376 | + for (const field of fields) { | |
| 377 | + const idx = field.indexOf(":"); | |
| 378 | + if (idx <= 0) continue; | |
| 379 | + const rawKey = field.slice(0, idx).trim(); | |
| 380 | + const key = rawKey.endsWith("?") ? rawKey.slice(0, -1).trim() : rawKey; | |
| 381 | + const normalizedKey = (key.startsWith('"') && key.endsWith('"')) | |
| 382 | + || (key.startsWith("'") && key.endsWith("'")) | |
| 383 | + ? key.slice(1, -1) | |
| 384 | + : key; | |
| 385 | + if (normalizedKey !== paramName) continue; | |
| 386 | + | |
| 387 | + const segment = field.slice(idx + 1).trim(); | |
| 388 | + if (segment.length > 0) return segment; | |
| 389 | + } | |
| 390 | + } | |
| 391 | + | |
| 392 | + return null; | |
| 393 | +} | |
| 394 | + | |
| 395 | +function hintSegmentMatchesPrimitiveType( | |
| 396 | + segment: string, | |
| 397 | + expectedType: PrimitiveParamType, | |
| 398 | + componentSchemas: Record<string, unknown> = {}, | |
| 399 | +): boolean { | |
| 400 | + if (expectedType === "number" && (/\bnumber\b/.test(segment) || /(^|\W)-?\d+(?:\.\d+)?(\W|$)/.test(segment))) return true; | |
| 401 | + if (expectedType === "boolean" && (/\bboolean\b/.test(segment) || /\btrue\b|\bfalse\b/.test(segment))) return true; | |
| 402 | + if (expectedType === "string" && (/\bstring\b/.test(segment) || segment.includes("\"") || segment.includes("'"))) { | |
| 403 | + return true; | |
| 404 | + } | |
| 405 | + | |
| 406 | + const refPattern = /components\["schemas"\]\["([^"]+)"\]/g; | |
| 407 | + for (const match of segment.matchAll(refPattern)) { | |
| 408 | + const key = match[1]; | |
| 409 | + if (!key) continue; | |
| 410 | + const resolved = resolveSchemaPrimitiveType(componentSchemas[key], componentSchemas); | |
| 411 | + if (resolved === expectedType) return true; | |
| 412 | + } | |
| 413 | + | |
| 414 | + return false; | |
| 415 | +} | |
| 416 | + | |
| 417 | +function containsUnknownTypeToken(segment: string): boolean { | |
| 418 | + const withoutQuotedLiterals = segment | |
| 419 | + .replace(/"(?:[^"\\]|\\.)*"/g, "") | |
| 420 | + .replace(/'(?:[^'\\]|\\.)*'/g, ""); | |
| 421 | + return /\bunknown\b/.test(withoutQuotedLiterals); | |
| 422 | +} | |
| 423 | + | |
| 21 | 424 | const SPECS: SpecFixture[] = [ |
| 22 | 425 | { |
| 23 | 426 | name: "jira", |
| @@ -331,4 +734,146 @@ describe("real-world OpenAPI specs", () => { · 307 unmodified lines | ||
| 331 | 734 | }, |
| 332 | 735 | 300_000, |
| 333 | 736 | ); |
| 737 | + | |
| 738 | + test( | |
| 739 | + "openai: URL parser path matches normal parsed-spec path", | |
| 740 | + async () => { | |
| 741 | + const openAiUrl = "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml"; | |
| 742 | + | |
| 743 | + const viaUrl = await prepareOpenApiSpec(openAiUrl, "openai", { | |
| 744 | + includeDts: true, | |
| 745 | + profile: "full", | |
| 746 | + }); | |
| 747 | + | |
| 748 | + const parsed = await SwaggerParser.parse(openAiUrl); | |
| 749 | + const viaParsed = await prepareOpenApiSpec(parsed as Record<string, unknown>, "openai", { | |
| 750 | + includeDts: true, | |
| 751 | + profile: "full", | |
| 752 | + }); | |
| 753 | + | |
| 754 | + const source = { | |
| 755 | + type: "openapi" as const, | |
| 756 | + name: "openai", | |
| 757 | + spec: openAiUrl, | |
| 758 | + baseUrl: viaUrl.servers[0] || "https://api.openai.com", | |
| 759 | + }; | |
| 760 | + | |
| 761 | + const toolsViaUrl = buildOpenApiToolsFromPrepared(source, viaUrl); | |
| 762 | + const toolsViaParsed = buildOpenApiToolsFromPrepared(source, viaParsed); | |
| 763 | + | |
| 764 | + const fingerprint = (tool: (typeof toolsViaUrl)[number]) => JSON.stringify({ | |
| 765 | + path: tool.path, | |
| 766 | + operationId: tool.typing?.typedRef?.kind === "openapi_operation" | |
| 767 | + ? tool.typing.typedRef.operationId | |
| 768 | + : undefined, | |
| 769 | + inputHint: tool.typing?.inputHint, | |
| 770 | + outputHint: tool.typing?.outputHint, | |
| 771 | + }); | |
| 772 | + | |
| 773 | + const normalizedViaUrl = toolsViaUrl.map(fingerprint).sort(); | |
| 774 | + const normalizedViaParsed = toolsViaParsed.map(fingerprint).sort(); | |
| 775 | + | |
| 776 | + expect(normalizedViaUrl).toEqual(normalizedViaParsed); | |
| 777 | + }, | |
| 778 | + 300_000, | |
| 779 | + ); | |
| 780 | + | |
| 781 | + test( | |
| 782 | + "inventory type hints validate every operation against parser-derived expectations across real specs", | |
| 783 | + async () => { | |
| 784 | + const fixtures = SPECS | |
| 785 | + .filter((fixture) => fixture.expectDts) | |
| 786 | + .filter((fixture) => fixture.name !== "jira") | |
| 787 | + .slice(0, 6); | |
| 788 | + | |
| 789 | + expect(fixtures.length).toBe(6); | |
| 790 | + | |
| 791 | + for (const fixture of fixtures) { | |
| 792 | + const parsed = await SwaggerParser.parse(fixture.url); | |
| 793 | + const parsedSpec = parsed as Record<string, unknown>; | |
| 794 | + const componentSchemas = toRecord(toRecord(parsedSpec.components).schemas); | |
| 795 | + const operationExpectations = collectOperationTypeExpectations(parsedSpec); | |
| 796 | + expect(operationExpectations.size).toBeGreaterThan(0); | |
| 797 | + | |
| 798 | + const prepared = await prepareOpenApiSpec(parsedSpec, fixture.name, { | |
| 799 | + includeDts: false, | |
| 800 | + profile: "inventory", | |
| 801 | + }); | |
| 802 | + const tools = buildOpenApiToolsFromPrepared( | |
| 803 | + { | |
| 804 | + type: "openapi", | |
| 805 | + name: fixture.name, | |
| 806 | + spec: fixture.url, | |
| 807 | + baseUrl: prepared.servers[0] || `https://${fixture.name}.example.com`, | |
| 808 | + }, | |
| 809 | + prepared, | |
| 810 | + ); | |
| 811 | + | |
| 812 | + const toolsByOperationId = new Map<string, (typeof tools)[number]>(); | |
| 813 | + for (const tool of tools) { | |
| 814 | + const operationId = tool.typing?.typedRef?.kind === "openapi_operation" | |
| 815 | + ? tool.typing.typedRef.operationId | |
| 816 | + : ""; | |
| 817 | + if (!operationId || toolsByOperationId.has(operationId)) continue; | |
| 818 | + toolsByOperationId.set(operationId, tool); | |
| 819 | + } | |
| 820 | + | |
| 821 | + expect(toolsByOperationId.size).toBe(operationExpectations.size); | |
| 822 | + | |
| 823 | + for (const [operationId, expectations] of operationExpectations.entries()) { | |
| 824 | + const tool = toolsByOperationId.get(operationId); | |
| 825 | + expect(tool).toBeDefined(); | |
| 826 | + if (!tool) continue; | |
| 827 | + | |
| 828 | + const inputHint = tool.typing?.inputHint ?? ""; | |
| 829 | + const outputHint = tool.typing?.outputHint ?? ""; | |
| 830 | + | |
| 831 | + if (expectations.expectsInput) { | |
| 832 | + expect(inputHint.length).toBeGreaterThan(0); | |
| 833 | + expect(inputHint).not.toBe("unknown"); | |
| 834 | + } | |
| 835 | + | |
| 836 | + if (expectations.expectsKnownOutput) { | |
| 837 | + expect(outputHint.length).toBeGreaterThan(0); | |
| 838 | + expect(outputHint).not.toBe("unknown"); | |
| 839 | + } | |
| 840 | + | |
| 841 | + if (expectations.expectsVoidOutput) { | |
| 842 | + expect(outputHint).toBe("void"); | |
| 843 | + } | |
| 844 | + | |
| 845 | + for (const expected of expectations.expectedInputFields) { | |
| 846 | + const segment = extractHintSegment(inputHint, expected.name); | |
| 847 | + if (!segment) { | |
| 848 | + throw new Error(`[${fixture.name}] missing input segment for ${operationId}.${expected.name}: ${inputHint}`); | |
| 849 | + } | |
| 850 | + if (containsUnknownTypeToken(segment)) { | |
| 851 | + throw new Error(`[${fixture.name}] unknown input segment for ${operationId}.${expected.name}: ${segment}`); | |
| 852 | + } | |
| 853 | + if (!hintSegmentMatchesPrimitiveType(segment, expected.type, componentSchemas)) { | |
| 854 | + throw new Error( | |
| 855 | + `[${fixture.name}] input type mismatch for ${operationId}.${expected.name}: expected ${expected.type}, got ${segment}`, | |
| 856 | + ); | |
| 857 | + } | |
| 858 | + } | |
| 859 | + | |
| 860 | + for (const expected of expectations.expectedOutputFields) { | |
| 861 | + const segment = extractHintSegment(outputHint, expected.name); | |
| 862 | + if (!segment) { | |
| 863 | + throw new Error(`[${fixture.name}] missing output segment for ${operationId}.${expected.name}: ${outputHint}`); | |
| 864 | + } | |
| 865 | + if (containsUnknownTypeToken(segment)) { | |
| 866 | + throw new Error(`[${fixture.name}] unknown output segment for ${operationId}.${expected.name}: ${segment}`); | |
| 867 | + } | |
| 868 | + if (!hintSegmentMatchesPrimitiveType(segment, expected.type, componentSchemas)) { | |
| 869 | + throw new Error( | |
| 870 | + `[${fixture.name}] output type mismatch for ${operationId}.${expected.name}: expected ${expected.type}, got ${segment}`, | |
| 871 | + ); | |
| 872 | + } | |
| 873 | + } | |
| 874 | + } | |
| 875 | + } | |
| 876 | + }, | |
| 877 | + 1_800_000, | |
| 878 | + ); | |
| 334 | 879 | }); |
| @@ -302,6 +302,62 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 301 unmodified lines | ||
| 302 | 302 | return filtered.length > 0 ? filtered : variants; |
| 303 | 303 | } |
| 304 | 304 | |
| 305 | +function mergeParentObjectIntoUnionVariants(parent: JsonSchema, variants: JsonSchema[]): JsonSchema[] { | |
| 306 | + const baseProps = toRecordOrEmpty(parent.properties); | |
| 307 | + const baseRequired = (Array.isArray(parent.required) ? parent.required : []) | |
| 308 | + .filter((value): value is string => typeof value === "string"); | |
| 309 | + const hasBaseObjectContext = parent.type === "object" | |
| 310 | + || Object.keys(baseProps).length > 0 | |
| 311 | + || baseRequired.length > 0; | |
| 312 | + if (!hasBaseObjectContext) return variants; | |
| 313 | + | |
| 314 | + return variants.map((variant) => { | |
| 315 | + const variantType = typeof variant.type === "string" ? variant.type : ""; | |
| 316 | + const isObjectLikeVariant = isObjectSchema(variant) | |
| 317 | + || (variantType === "" | |
| 318 | + && (Array.isArray(variant.required) | |
| 319 | + || variant.properties !== undefined | |
| 320 | + || variant.additionalProperties !== undefined)); | |
| 321 | + if (!isObjectLikeVariant) return variant; | |
| 322 | + | |
| 323 | + const variantProps = toRecordOrEmpty(variant.properties); | |
| 324 | + const mergedProps = { | |
| 325 | + ...baseProps, | |
| 326 | + ...variantProps, | |
| 327 | + }; | |
| 328 | + const mergedRequired = [...new Set([ | |
| 329 | + ...baseRequired, | |
| 330 | + ...(Array.isArray(variant.required) ? variant.required.filter((value): value is string => typeof value === "string") : []), | |
| 331 | + ])]; | |
| 332 | + | |
| 333 | + return { | |
| 334 | + ...(parent.type === "object" ? { type: "object" } : {}), | |
| 335 | + ...(parent.additionalProperties !== undefined && variant.additionalProperties === undefined | |
| 336 | + ? { additionalProperties: parent.additionalProperties } | |
| 337 | + : {}), | |
| 338 | + ...variant, | |
| 339 | + ...(Object.keys(mergedProps).length > 0 ? { properties: mergedProps } : {}), | |
| 340 | + ...(mergedRequired.length > 0 ? { required: mergedRequired } : {}), | |
| 341 | + }; | |
| 342 | + }); | |
| 343 | +} | |
| 344 | + | |
| 345 | +function mergeParentPrimitiveIntoUnionVariants(parent: JsonSchema, variants: JsonSchema[]): JsonSchema[] { | |
| 346 | + const baseType = typeof parent.type === "string" ? parent.type : ""; | |
| 347 | + if (baseType !== "string" && baseType !== "number" && baseType !== "integer" && baseType !== "boolean" && baseType !== "null") { | |
| 348 | + return variants; | |
| 349 | + } | |
| 350 | + | |
| 351 | + return variants.map((variant) => { | |
| 352 | + if (typeof variant.type === "string") return variant; | |
| 353 | + if (typeof variant.$ref === "string") return variant; | |
| 354 | + return { | |
| 355 | + type: baseType, | |
| 356 | + ...variant, | |
| 357 | + }; | |
| 358 | + }); | |
| 359 | +} | |
| 360 | + | |
| 305 | 361 | function extractStringLiteralFromSchema(schema: JsonSchema): string | null { |
| 306 | 362 | // Support the two common encodings we see in OpenAPI: |
| 307 | 363 | // - { type: "string", enum: ["A"] } |
| @@ -825,9 +881,13 @@ export function jsonSchemaTypeHintFallback( · 517 unmodified lines | ||
| 825 | 881 | |
| 826 | 882 | const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined; |
| 827 | 883 | if (oneOf && oneOf.length > 0) { |
| 828 | − const variants = normalizeUnionSchemaVariants( | |
| 829 | − oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | |
| 830 | − ); | |
| 884 | + const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants( | |
| 885 | + shape, | |
| 886 | + mergeParentPrimitiveIntoUnionVariants( | |
| 887 | + shape, | |
| 888 | + oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | |
| 889 | + ), | |
| 890 | + )); | |
| 831 | 891 | const objectVariantsPrepared = repairMissingRequiredProperties( |
| 832 | 892 | variants.filter(isObjectSchema), |
| 833 | 893 | depth, |
| @@ -874,9 +934,13 @@ export function jsonSchemaTypeHintFallback( · 40 unmodified lines | ||
| 874 | 934 | |
| 875 | 935 | const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined; |
| 876 | 936 | if (anyOf && anyOf.length > 0) { |
| 877 | − const variants = normalizeUnionSchemaVariants( | |
| 878 | − anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | |
| 879 | − ); | |
| 937 | + const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants( | |
| 938 | + shape, | |
| 939 | + mergeParentPrimitiveIntoUnionVariants( | |
| 940 | + shape, | |
| 941 | + anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | |
| 942 | + ), | |
| 943 | + )); | |
| 880 | 944 | const objectVariantsPrepared = repairMissingRequiredProperties( |
| 881 | 945 | variants.filter(isObjectSchema), |
| 882 | 946 | depth, |
| @@ -954,7 +1018,7 @@ export function jsonSchemaTypeHintFallback( · 71 unmodified lines | ||
| 954 | 1018 | } |
| 955 | 1019 | return "Record<string, unknown>"; |
| 956 | 1020 | } |
| 957 | − const maxInlineProps = 64; | |
| 1021 | + const maxInlineProps = 1000; | |
| 958 | 1022 | const isTruncated = propEntries.length > maxInlineProps; |
| 959 | 1023 | const inner = propEntries |
| 960 | 1024 | .slice(0, maxInlineProps) |
| @@ -316,6 +316,12 @@ export declare const api: { · 315 unmodified lines | ||
| 316 | 316 | { sessionId?: string; sourceId: string; workspaceId: Id<"workspaces"> }, |
| 317 | 317 | any |
| 318 | 318 | >; |
| 319 | + getMcpApiKey: FunctionReference< | |
| 320 | + "query", | |
| 321 | + "public", | |
| 322 | + { sessionId?: string; workspaceId: Id<"workspaces"> }, | |
| 323 | + any | |
| 324 | + >; | |
| 319 | 325 | getToolDetails: FunctionReference< |
| 320 | 326 | "mutation", |
| 321 | 327 | "public", |
| @@ -1464,6 +1470,12 @@ export declare const internal: { · 1142 unmodified lines | ||
| 1464 | 1470 | >; |
| 1465 | 1471 | }; |
| 1466 | 1472 | workspaceAuthInternal: { |
| 1473 | + getWorkspaceAccessForAccount: FunctionReference< | |
| 1474 | + "query", | |
| 1475 | + "internal", | |
| 1476 | + { accountId: Id<"accounts">; workspaceId: Id<"workspaces"> }, | |
| 1477 | + any | |
| 1478 | + >; | |
| 1467 | 1479 | getWorkspaceAccessForAnonymousSubject: FunctionReference< |
| 1468 | 1480 | "query", |
| 1469 | 1481 | "internal", |
| @@ -308,6 +308,21 @@ describe("authentication", () => { · 307 unmodified lines | ||
| 308 | 308 | const account = await t.query(api.app.getCurrentAccount, {}); |
| 309 | 309 | expect(account).toBeNull(); |
| 310 | 310 | }); |
| 311 | + | |
| 312 | + test("getCurrentAccount prefers authenticated identity over anonymous session", async () => { | |
| 313 | + const t = setup(); | |
| 314 | + const session = await t.mutation(api.workspace.bootstrapAnonymousSession, {}); | |
| 315 | + await seedUser(t, { subject: "account-prefers-auth", name: "Signed In User" }); | |
| 316 | + | |
| 317 | + const authed = t.withIdentity({ subject: "account-prefers-auth" }); | |
| 318 | + const account = await authed.query(api.app.getCurrentAccount, { | |
| 319 | + sessionId: session.sessionId, | |
| 320 | + }); | |
| 321 | + | |
| 322 | + expect(account).not.toBeNull(); | |
| 323 | + expect(account!.provider).toBe("workos"); | |
| 324 | + expect(account!.name).toBe("Signed In User"); | |
| 325 | + }); | |
| 311 | 326 | }); |
| 312 | 327 | |
| 313 | 328 | describe("workspace access controls", () => { |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { httpAction } from "../_generated/server"; |
| 2 | 2 | import { internal } from "../_generated/api"; |
| 3 | 3 | import { handleMcpRequest, type McpWorkspaceContext } from "../../../core/src/mcp-server"; |
| 4 | −import { getAnonymousAuthIssuer, isAnonymousIdentity } from "../../src/auth/anonymous"; | |
| 4 | +import { isMcpApiKeyConfigured, verifyMcpApiKey } from "../../src/auth/mcp_api_key"; | |
| 5 | 5 | import { |
| 6 | 6 | getMcpAuthConfig, |
| 7 | 7 | isAnonymousSessionId, |
| @@ -13,10 +13,21 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | type McpEndpointMode = "default" | "anonymous"; |
| 15 | 15 | |
| 16 | −function isAnonymousAuthConfigured(): boolean { | |
| 17 | − const issuer = getAnonymousAuthIssuer(); | |
| 18 | − const privateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; | |
| 19 | − return Boolean(issuer && privateKeyPem && privateKeyPem.trim().length > 0); | |
| 16 | +function parseMcpApiKey(request: Request): string | null { | |
| 17 | + const fromHeader = request.headers.get("x-api-key")?.trim(); | |
| 18 | + if (fromHeader) { | |
| 19 | + return fromHeader; | |
| 20 | + } | |
| 21 | + | |
| 22 | + const authorization = request.headers.get("authorization") ?? ""; | |
| 23 | + if (authorization.startsWith("Bearer ")) { | |
| 24 | + const bearer = authorization.slice("Bearer ".length).trim(); | |
| 25 | + if (bearer.length > 0) { | |
| 26 | + return bearer; | |
| 27 | + } | |
| 28 | + } | |
| 29 | + | |
| 30 | + return null; | |
| 20 | 31 | } |
| 21 | 32 | |
| 22 | 33 | function createMcpHandler(mode: McpEndpointMode) { |
| @@ -47,32 +58,53 @@ function createMcpHandler(mode: McpEndpointMode) { · 24 unmodified lines | ||
| 47 | 58 | ); |
| 48 | 59 | } |
| 49 | 60 | |
| 50 | − const anonymousAuthConfigured = isAnonymousAuthConfigured(); | |
| 51 | − if (!anonymousAuthConfigured) { | |
| 61 | + if (!isMcpApiKeyConfigured()) { | |
| 52 | 62 | return Response.json( |
| 53 | − { error: "Anonymous auth is not configured" }, | |
| 63 | + { error: "MCP API key signing is not configured" }, | |
| 54 | 64 | { status: 503 }, |
| 55 | 65 | ); |
| 56 | 66 | } |
| 57 | 67 | |
| 58 | − const identity = await ctx.auth.getUserIdentity().catch(() => null); | |
| 59 | − if (identity && isAnonymousIdentity(identity)) { | |
| 60 | − const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, { | |
| 61 | − workspaceId, | |
| 62 | − accountId: identity.subject, | |
| 63 | − }); | |
| 64 | − | |
| 65 | − context = { | |
| 66 | − workspaceId, | |
| 67 | − accountId: access.accountId, | |
| 68 | − clientId: requestedContext?.clientId, | |
| 69 | − }; | |
| 70 | − } else { | |
| 68 | + const apiKey = parseMcpApiKey(request); | |
| 69 | + if (!apiKey) { | |
| 70 | + return Response.json( | |
| 71 | + { error: "API key is required for /mcp/anonymous" }, | |
| 72 | + { status: 401 }, | |
| 73 | + ); | |
| 74 | + } | |
| 75 | + | |
| 76 | + const apiKeyIdentity = await verifyMcpApiKey(apiKey); | |
| 77 | + if (!apiKeyIdentity) { | |
| 71 | 78 | return Response.json( |
| 72 | − { error: "Anonymous bearer token is required for /mcp/anonymous" }, | |
| 79 | + { error: "Invalid API key" }, | |
| 73 | 80 | { status: 401 }, |
| 74 | 81 | ); |
| 75 | 82 | } |
| 83 | + | |
| 84 | + if (apiKeyIdentity.workspaceId !== workspaceId) { | |
| 85 | + return Response.json( | |
| 86 | + { error: "API key does not match requested workspace" }, | |
| 87 | + { status: 403 }, | |
| 88 | + ); | |
| 89 | + } | |
| 90 | + | |
| 91 | + const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAccount, { | |
| 92 | + workspaceId, | |
| 93 | + accountId: apiKeyIdentity.accountId, | |
| 94 | + }); | |
| 95 | + | |
| 96 | + if (access.provider !== "anonymous") { | |
| 97 | + return Response.json( | |
| 98 | + { error: "API key auth is currently enabled for anonymous accounts only" }, | |
| 99 | + { status: 403 }, | |
| 100 | + ); | |
| 101 | + } | |
| 102 | + | |
| 103 | + context = { | |
| 104 | + workspaceId, | |
| 105 | + accountId: access.accountId, | |
| 106 | + clientId: requestedContext?.clientId, | |
| 107 | + }; | |
| 76 | 108 | } catch (error) { |
| 77 | 109 | return Response.json( |
| 78 | 110 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, |
| @@ -18,6 +18,11 @@ import { · 17 unmodified lines | ||
| 18 | 18 | getWorkspaceInventoryProgressForContext, |
| 19 | 19 | listToolDetailsForContext, |
| 20 | 20 | } from "../src/runtime/workspace_tools"; |
| 21 | +import { | |
| 22 | + issueMcpApiKey, | |
| 23 | + isMcpApiKeyConfigured, | |
| 24 | + MCP_API_KEY_ENV_NAME, | |
| 25 | +} from "../src/auth/mcp_api_key"; | |
| 21 | 26 | |
| 22 | 27 | export const bootstrapAnonymousSession = customMutation({ |
| 23 | 28 | method: "POST", |
| @@ -31,6 +36,50 @@ export const bootstrapAnonymousSession = customMutation({ · 7 unmodified lines | ||
| 31 | 36 | }, |
| 32 | 37 | }); |
| 33 | 38 | |
| 39 | +export const getMcpApiKey = workspaceQuery({ | |
| 40 | + method: "GET", | |
| 41 | + args: {}, | |
| 42 | + handler: async (ctx) => { | |
| 43 | + if (ctx.account.provider !== "anonymous") { | |
| 44 | + return { | |
| 45 | + enabled: false, | |
| 46 | + envVar: MCP_API_KEY_ENV_NAME, | |
| 47 | + apiKey: null, | |
| 48 | + error: "MCP API keys are currently enabled for anonymous accounts only", | |
| 49 | + }; | |
| 50 | + } | |
| 51 | + | |
| 52 | + if (!isMcpApiKeyConfigured()) { | |
| 53 | + return { | |
| 54 | + enabled: false, | |
| 55 | + envVar: MCP_API_KEY_ENV_NAME, | |
| 56 | + apiKey: null, | |
| 57 | + error: "MCP API key signing is not configured", | |
| 58 | + }; | |
| 59 | + } | |
| 60 | + | |
| 61 | + const apiKey = await issueMcpApiKey({ | |
| 62 | + workspaceId: ctx.workspaceId, | |
| 63 | + accountId: ctx.account._id, | |
| 64 | + }); | |
| 65 | + | |
| 66 | + if (!apiKey) { | |
| 67 | + return { | |
| 68 | + enabled: false, | |
| 69 | + envVar: MCP_API_KEY_ENV_NAME, | |
| 70 | + apiKey: null, | |
| 71 | + error: "Failed to issue MCP API key", | |
| 72 | + }; | |
| 73 | + } | |
| 74 | + | |
| 75 | + return { | |
| 76 | + enabled: true, | |
| 77 | + envVar: MCP_API_KEY_ENV_NAME, | |
| 78 | + apiKey, | |
| 79 | + }; | |
| 80 | + }, | |
| 81 | +}); | |
| 82 | + | |
| 34 | 83 | export const listTasks = workspaceQuery({ |
| 35 | 84 | method: "GET", |
| 36 | 85 | args: {}, |
| @@ -80,3 +80,26 @@ export const getWorkspaceAccessForAnonymousSubject = internalQuery({ · 79 unmodified lines | ||
| 80 | 80 | }; |
| 81 | 81 | }, |
| 82 | 82 | }); |
| 83 | + | |
| 84 | +export const getWorkspaceAccessForAccount = internalQuery({ | |
| 85 | + args: { | |
| 86 | + workspaceId: v.id("workspaces"), | |
| 87 | + accountId: v.id("accounts"), | |
| 88 | + }, | |
| 89 | + handler: async (ctx, args) => { | |
| 90 | + const account = await ctx.db.get(args.accountId); | |
| 91 | + if (!account) { | |
| 92 | + throw new Error("Account is not recognized"); | |
| 93 | + } | |
| 94 | + | |
| 95 | + const access = await requireWorkspaceAccessForAccount(ctx, args.workspaceId, account); | |
| 96 | + | |
| 97 | + return { | |
| 98 | + workspaceId: args.workspaceId, | |
| 99 | + accountId: account._id, | |
| 100 | + provider: account.provider, | |
| 101 | + providerAccountId: account.providerAccountId, | |
| 102 | + role: access.workspaceMembership.role, | |
| 103 | + }; | |
| 104 | + }, | |
| 105 | +}); | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | −import { resolveIdentityProfile } from "./identity"; | |
| 2 | +import type { FunctionReference } from "convex/server"; | |
| 3 | +import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity"; | |
| 3 | 4 | |
| 4 | 5 | test("resolveIdentityProfile uses stable user label when identity is sparse", () => { |
| 5 | 6 | const subject = "user_01KH1TVHS4WJCPQG2XQJGMMJMJ"; |
| @@ -45,3 +46,92 @@ test("resolveIdentityProfile prefers explicit identity name", () => { · 39 unmodified lines | ||
| 45 | 46 | |
| 46 | 47 | expect(profile.fullName).toBe("Alexandra D."); |
| 47 | 48 | }); |
| 49 | + | |
| 50 | +test("resolveIdentityProfile reads camelCase identity claims", () => { | |
| 51 | + const profile = resolveIdentityProfile({ | |
| 52 | + identity: { | |
| 53 | + subject: "user_abc123", | |
| 54 | + emailAddress: "alex@example.com", | |
| 55 | + firstName: "Alex", | |
| 56 | + lastName: "Doe", | |
| 57 | + profilePictureUrl: "https://cdn.example.com/alex.png", | |
| 58 | + organizationId: "org_123", | |
| 59 | + }, | |
| 60 | + authKitProfile: null, | |
| 61 | + }); | |
| 62 | + | |
| 63 | + expect(profile.email).toBe("alex@example.com"); | |
| 64 | + expect(profile.firstName).toBe("Alex"); | |
| 65 | + expect(profile.lastName).toBe("Doe"); | |
| 66 | + expect(profile.fullName).toBe("Alex Doe"); | |
| 67 | + expect(profile.avatarUrl).toBe("https://cdn.example.com/alex.png"); | |
| 68 | + expect(profile.hintedWorkosOrgId).toBe("org_123"); | |
| 69 | +}); | |
| 70 | + | |
| 71 | +test("resolveIdentityProfile reads snake_case AuthKit profile fields", () => { | |
| 72 | + const profile = resolveIdentityProfile({ | |
| 73 | + identity: { | |
| 74 | + subject: "workos|user_01KH1TVHS4WJCPQG2XQJGMMJMJ", | |
| 75 | + }, | |
| 76 | + authKitProfile: { | |
| 77 | + email: "alex@example.com", | |
| 78 | + first_name: "Alex", | |
| 79 | + last_name: "Doe", | |
| 80 | + profile_picture_url: "https://cdn.example.com/alex.png", | |
| 81 | + }, | |
| 82 | + }); | |
| 83 | + | |
| 84 | + expect(profile.email).toBe("alex@example.com"); | |
| 85 | + expect(profile.firstName).toBe("Alex"); | |
| 86 | + expect(profile.lastName).toBe("Doe"); | |
| 87 | + expect(profile.fullName).toBe("Alex Doe"); | |
| 88 | + expect(profile.avatarUrl).toBe("https://cdn.example.com/alex.png"); | |
| 89 | +}); | |
| 90 | + | |
| 91 | +test("resolveIdentityProfile reads namespaced and nested identity claims", () => { | |
| 92 | + const profile = resolveIdentityProfile({ | |
| 93 | + identity: { | |
| 94 | + subject: "user_abc123", | |
| 95 | + "https://workos.com/claims/name": "Alex Doe", | |
| 96 | + "https://workos.com/claims/picture": "https://cdn.example.com/alex.png", | |
| 97 | + user: { | |
| 98 | + email: "alex@example.com", | |
| 99 | + }, | |
| 100 | + org: { | |
| 101 | + organization_id: "org_123", | |
| 102 | + }, | |
| 103 | + }, | |
| 104 | + authKitProfile: null, | |
| 105 | + }); | |
| 106 | + | |
| 107 | + expect(profile.email).toBe("alex@example.com"); | |
| 108 | + expect(profile.fullName).toBe("Alex Doe"); | |
| 109 | + expect(profile.avatarUrl).toBe("https://cdn.example.com/alex.png"); | |
| 110 | + expect(profile.hintedWorkosOrgId).toBe("org_123"); | |
| 111 | +}); | |
| 112 | + | |
| 113 | +test("getAuthKitUserProfile retries with normalized user id", async () => { | |
| 114 | + const requestedIds: string[] = []; | |
| 115 | + const ctx = { | |
| 116 | + runQuery: async (_fn: FunctionReference<"query", "internal">, args: { id: string }) => { | |
| 117 | + requestedIds.push(args.id); | |
| 118 | + return args.id === "user_01KH1TVHS4WJCPQG2XQJGMMJMJ" | |
| 119 | + ? { id: args.id, email: "alex@example.com" } | |
| 120 | + : null; | |
| 121 | + }, | |
| 122 | + }; | |
| 123 | + | |
| 124 | + const result = await getAuthKitUserProfile( | |
| 125 | + ctx as never, | |
| 126 | + "workos|user_01KH1TVHS4WJCPQG2XQJGMMJMJ", | |
| 127 | + ); | |
| 128 | + | |
| 129 | + expect(requestedIds).toEqual([ | |
| 130 | + "workos|user_01KH1TVHS4WJCPQG2XQJGMMJMJ", | |
| 131 | + "user_01KH1TVHS4WJCPQG2XQJGMMJMJ", | |
| 132 | + ]); | |
| 133 | + expect(result).toEqual({ | |
| 134 | + id: "user_01KH1TVHS4WJCPQG2XQJGMMJMJ", | |
| 135 | + email: "alex@example.com", | |
| 136 | + }); | |
| 137 | +}); | |
| @@ -4,9 +4,20 @@ import type { RunQueryCtx } from "./types"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | type WorkosProfile = { |
| 6 | 6 | email?: string; |
| 7 | + emailAddress?: string | null; | |
| 8 | + name?: string | null; | |
| 9 | + fullName?: string | null; | |
| 10 | + displayName?: string | null; | |
| 7 | 11 | firstName?: string | null; |
| 8 | 12 | lastName?: string | null; |
| 13 | + givenName?: string | null; | |
| 14 | + familyName?: string | null; | |
| 15 | + picture?: string | null; | |
| 16 | + pictureUrl?: string | null; | |
| 17 | + avatarUrl?: string | null; | |
| 9 | 18 | profilePictureUrl?: string | null; |
| 19 | + metadata?: Record<string, unknown> | null; | |
| 20 | + [key: string]: unknown; | |
| 10 | 21 | }; |
| 11 | 22 | |
| 12 | 23 | const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0); |
| @@ -15,6 +26,20 @@ function deriveFallbackUserLabel(workosUserId: string): string { · 2 unmodified lines | ||
| 15 | 26 | return `User ${workosUserId.slice(-6)}`; |
| 16 | 27 | } |
| 17 | 28 | |
| 29 | +function normalizeClaimKey(key: string): string { | |
| 30 | + return key.trim().toLowerCase(); | |
| 31 | +} | |
| 32 | + | |
| 33 | +function claimKeyMatches(actualKey: string, expectedKey: string): boolean { | |
| 34 | + const normalizedActual = normalizeClaimKey(actualKey); | |
| 35 | + const normalizedExpected = normalizeClaimKey(expectedKey); | |
| 36 | + | |
| 37 | + return normalizedActual === normalizedExpected | |
| 38 | + || normalizedActual.endsWith(`/${normalizedExpected}`) | |
| 39 | + || normalizedActual.endsWith(`:${normalizedExpected}`) | |
| 40 | + || normalizedActual.endsWith(`.${normalizedExpected}`); | |
| 41 | +} | |
| 42 | + | |
| 18 | 43 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { |
| 19 | 44 | for (const key of keys) { |
| 20 | 45 | const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]); |
| @@ -23,17 +48,113 @@ function getIdentityString(identity: Record<string, unknown>, keys: string[]): s · 2 unmodified lines | ||
| 23 | 48 | } |
| 24 | 49 | } |
| 25 | 50 | |
| 51 | + for (const [actualKey, rawValue] of Object.entries(identity)) { | |
| 52 | + if (!keys.some((expectedKey) => claimKeyMatches(actualKey, expectedKey))) { | |
| 53 | + continue; | |
| 54 | + } | |
| 55 | + | |
| 56 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(rawValue); | |
| 57 | + if (parsedValue.success) { | |
| 58 | + return parsedValue.data; | |
| 59 | + } | |
| 60 | + } | |
| 61 | + | |
| 62 | + const nestedRecords = Object.values(identity) | |
| 63 | + .filter((value): value is Record<string, unknown> => Boolean(value) && typeof value === "object" && !Array.isArray(value)); | |
| 64 | + for (const nestedRecord of nestedRecords) { | |
| 65 | + for (const key of keys) { | |
| 66 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(nestedRecord[key]); | |
| 67 | + if (parsedValue.success) { | |
| 68 | + return parsedValue.data; | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + for (const [actualKey, rawValue] of Object.entries(nestedRecord)) { | |
| 73 | + if (!keys.some((expectedKey) => claimKeyMatches(actualKey, expectedKey))) { | |
| 74 | + continue; | |
| 75 | + } | |
| 76 | + | |
| 77 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(rawValue); | |
| 78 | + if (parsedValue.success) { | |
| 79 | + return parsedValue.data; | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 26 | 84 | return undefined; |
| 27 | 85 | } |
| 28 | 86 | |
| 87 | +function getProfileString(profile: WorkosProfile | null, keys: string[]): string | undefined { | |
| 88 | + if (!profile) { | |
| 89 | + return undefined; | |
| 90 | + } | |
| 91 | + | |
| 92 | + const profileRecord = profile as Record<string, unknown>; | |
| 93 | + for (const key of keys) { | |
| 94 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(profileRecord[key]); | |
| 95 | + if (parsedValue.success) { | |
| 96 | + return parsedValue.data; | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + const metadata = profileRecord.metadata; | |
| 101 | + if (metadata && typeof metadata === "object") { | |
| 102 | + const metadataRecord = metadata as Record<string, unknown>; | |
| 103 | + for (const key of keys) { | |
| 104 | + const parsedValue = nonEmptyTrimmedStringSchema.safeParse(metadataRecord[key]); | |
| 105 | + if (parsedValue.success) { | |
| 106 | + return parsedValue.data; | |
| 107 | + } | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + return undefined; | |
| 112 | +} | |
| 113 | + | |
| 114 | +function deriveWorkosUserIdCandidates(subject: string): string[] { | |
| 115 | + const normalizedSubject = subject.trim(); | |
| 116 | + if (!normalizedSubject) { | |
| 117 | + return []; | |
| 118 | + } | |
| 119 | + | |
| 120 | + const candidates: string[] = [normalizedSubject]; | |
| 121 | + | |
| 122 | + const userIdMatch = normalizedSubject.match(/(user_[A-Za-z0-9]+)/); | |
| 123 | + if (userIdMatch?.[1]) { | |
| 124 | + candidates.push(userIdMatch[1]); | |
| 125 | + } | |
| 126 | + | |
| 127 | + const pipeSegment = normalizedSubject.split("|").at(-1)?.trim(); | |
| 128 | + if (pipeSegment) { | |
| 129 | + candidates.push(pipeSegment); | |
| 130 | + } | |
| 131 | + | |
| 132 | + const colonSegment = normalizedSubject.split(":").at(-1)?.trim(); | |
| 133 | + if (colonSegment) { | |
| 134 | + candidates.push(colonSegment); | |
| 135 | + } | |
| 136 | + | |
| 137 | + return [...new Set(candidates.filter((candidate) => candidate.length > 0))]; | |
| 138 | +} | |
| 139 | + | |
| 29 | 140 | export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { |
| 30 | − try { | |
| 31 | − return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | |
| 32 | − id: workosUserId, | |
| 33 | − }); | |
| 34 | − } catch { | |
| 35 | − return null; | |
| 141 | + const candidateIds = deriveWorkosUserIdCandidates(workosUserId); | |
| 142 | + | |
| 143 | + for (const candidateId of candidateIds) { | |
| 144 | + try { | |
| 145 | + const user = await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | |
| 146 | + id: candidateId, | |
| 147 | + }); | |
| 148 | + | |
| 149 | + if (user) { | |
| 150 | + return user; | |
| 151 | + } | |
| 152 | + } catch { | |
| 153 | + continue; | |
| 154 | + } | |
| 36 | 155 | } |
| 156 | + | |
| 157 | + return null; | |
| 37 | 158 | } |
| 38 | 159 | |
| 39 | 160 | export function resolveIdentityProfile(args: { |
| @@ -43,27 +164,43 @@ export function resolveIdentityProfile(args: { · 3 unmodified lines | ||
| 43 | 164 | const { identity, authKitProfile } = args; |
| 44 | 165 | |
| 45 | 166 | const email = |
| 46 | − authKitProfile?.email | |
| 47 | − ?? getIdentityString(identity, ["email", "upn"]); | |
| 167 | + getProfileString(authKitProfile, ["email", "emailAddress", "email_address"]) | |
| 168 | + ?? getIdentityString(identity, ["email", "upn", "emailAddress"]); | |
| 48 | 169 | |
| 49 | − const firstName = authKitProfile?.firstName | |
| 50 | − ?? getIdentityString(identity, ["given_name", "first_name"]); | |
| 170 | + const firstName = getProfileString(authKitProfile, ["firstName", "first_name", "givenName", "given_name"]) | |
| 171 | + ?? getIdentityString(identity, ["given_name", "first_name", "givenName", "firstName"]); | |
| 51 | 172 | |
| 52 | − const lastName = authKitProfile?.lastName | |
| 53 | − ?? getIdentityString(identity, ["family_name", "last_name"]); | |
| 173 | + const lastName = getProfileString(authKitProfile, ["lastName", "last_name", "familyName", "family_name"]) | |
| 174 | + ?? getIdentityString(identity, ["family_name", "last_name", "familyName", "lastName"]); | |
| 54 | 175 | |
| 55 | 176 | const combinedName = [firstName, lastName].filter(Boolean).join(" ").trim(); |
| 56 | 177 | |
| 57 | 178 | const fullName = |
| 58 | − getIdentityString(identity, ["name", "full_name"]) | |
| 179 | + getProfileString(authKitProfile, ["name", "fullName", "full_name", "displayName", "display_name"]) | |
| 180 | + ?? getIdentityString(identity, ["name", "full_name", "fullName", "display_name", "displayName"]) | |
| 59 | 181 | ?? (combinedName.length > 0 ? combinedName : undefined) |
| 60 | 182 | ?? deriveFallbackUserLabel(identity.subject); |
| 61 | 183 | |
| 62 | 184 | const avatarUrl = |
| 63 | − authKitProfile?.profilePictureUrl | |
| 64 | − ?? getIdentityString(identity, ["picture", "avatar_url", "profile_picture_url"]); | |
| 185 | + getProfileString(authKitProfile, [ | |
| 186 | + "profilePictureUrl", | |
| 187 | + "profile_picture_url", | |
| 188 | + "avatarUrl", | |
| 189 | + "avatar_url", | |
| 190 | + "pictureUrl", | |
| 191 | + "picture_url", | |
| 192 | + "picture", | |
| 193 | + ]) | |
| 194 | + ?? getIdentityString(identity, [ | |
| 195 | + "picture", | |
| 196 | + "avatar_url", | |
| 197 | + "profile_picture_url", | |
| 198 | + "pictureUrl", | |
| 199 | + "avatarUrl", | |
| 200 | + "profilePictureUrl", | |
| 201 | + ]); | |
| 65 | 202 | |
| 66 | − const hintedWorkosOrgId = getIdentityString(identity, ["org_id", "organization_id"]); | |
| 203 | + const hintedWorkosOrgId = getIdentityString(identity, ["org_id", "organization_id", "orgId", "organizationId"]); | |
| 67 | 204 | |
| 68 | 205 | return { |
| 69 | 206 | email, |
| @@ -0,0 +1,53 @@ | ||
| 1 | +import { afterEach, expect, test } from "bun:test"; | |
| 2 | +import type { Id } from "../../convex/_generated/dataModel.d.ts"; | |
| 3 | +import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key"; | |
| 4 | + | |
| 5 | +const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET; | |
| 6 | +const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; | |
| 7 | + | |
| 8 | +afterEach(() => { | |
| 9 | + process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret; | |
| 10 | + process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = originalAnonymousPrivateKey; | |
| 11 | +}); | |
| 12 | + | |
| 13 | +test("issueMcpApiKey + verifyMcpApiKey round trip", async () => { | |
| 14 | + process.env.MCP_API_KEY_SECRET = "test-secret"; | |
| 15 | + | |
| 16 | + const apiKey = await issueMcpApiKey({ | |
| 17 | + workspaceId: "workspace_123" as Id<"workspaces">, | |
| 18 | + accountId: "account_456" as Id<"accounts">, | |
| 19 | + }); | |
| 20 | + | |
| 21 | + expect(apiKey).toBeTruthy(); | |
| 22 | + const verified = await verifyMcpApiKey(apiKey); | |
| 23 | + expect(verified).toEqual({ | |
| 24 | + workspaceId: "workspace_123", | |
| 25 | + accountId: "account_456", | |
| 26 | + }); | |
| 27 | +}); | |
| 28 | + | |
| 29 | +test("verifyMcpApiKey rejects tampered token", async () => { | |
| 30 | + process.env.MCP_API_KEY_SECRET = "test-secret"; | |
| 31 | + | |
| 32 | + const apiKey = await issueMcpApiKey({ | |
| 33 | + workspaceId: "workspace_abc" as Id<"workspaces">, | |
| 34 | + accountId: "account_xyz" as Id<"accounts">, | |
| 35 | + }); | |
| 36 | + | |
| 37 | + expect(apiKey).toBeTruthy(); | |
| 38 | + const tampered = `${apiKey}a`; | |
| 39 | + const verified = await verifyMcpApiKey(tampered); | |
| 40 | + expect(verified).toBeNull(); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +test("issueMcpApiKey returns null when secret is missing", async () => { | |
| 44 | + delete process.env.MCP_API_KEY_SECRET; | |
| 45 | + delete process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; | |
| 46 | + | |
| 47 | + const apiKey = await issueMcpApiKey({ | |
| 48 | + workspaceId: "workspace_789" as Id<"workspaces">, | |
| 49 | + accountId: "account_987" as Id<"accounts">, | |
| 50 | + }); | |
| 51 | + | |
| 52 | + expect(apiKey).toBeNull(); | |
| 53 | +}); | |
| @@ -0,0 +1,113 @@ | ||
| 1 | +import { SignJWT, jwtVerify } from "jose"; | |
| 2 | +import type { Id } from "../../convex/_generated/dataModel.d.ts"; | |
| 3 | +import { getAnonymousAuthIssuer } from "./anonymous"; | |
| 4 | + | |
| 5 | +export const MCP_API_KEY_ENV_NAME = "API_KEY"; | |
| 6 | +const MCP_API_KEY_AUDIENCE = "executor-mcp"; | |
| 7 | +const MCP_API_KEY_ALGORITHM = "HS256"; | |
| 8 | + | |
| 9 | +export type VerifiedMcpApiKey = { | |
| 10 | + workspaceId: Id<"workspaces">; | |
| 11 | + accountId: Id<"accounts">; | |
| 12 | +}; | |
| 13 | + | |
| 14 | +function trimOrNull(value: string | undefined): string | null { | |
| 15 | + const trimmed = value?.trim(); | |
| 16 | + return trimmed && trimmed.length > 0 ? trimmed : null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +function normalizeSecret(raw: string): string { | |
| 20 | + return raw.replace(/\\n/g, "\n").trim(); | |
| 21 | +} | |
| 22 | + | |
| 23 | +function getConfiguredSecret(): string | null { | |
| 24 | + const explicit = trimOrNull(process.env.MCP_API_KEY_SECRET); | |
| 25 | + if (explicit) { | |
| 26 | + return normalizeSecret(explicit); | |
| 27 | + } | |
| 28 | + | |
| 29 | + const anonymousPrivateKey = trimOrNull(process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM); | |
| 30 | + if (anonymousPrivateKey) { | |
| 31 | + return normalizeSecret(anonymousPrivateKey); | |
| 32 | + } | |
| 33 | + | |
| 34 | + return null; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function getIssuer(): string | null { | |
| 38 | + return getAnonymousAuthIssuer(); | |
| 39 | +} | |
| 40 | + | |
| 41 | +function getSecretBytes(secret: string): Uint8Array { | |
| 42 | + return new TextEncoder().encode(secret); | |
| 43 | +} | |
| 44 | + | |
| 45 | +export function isMcpApiKeyConfigured(): boolean { | |
| 46 | + return Boolean(getConfiguredSecret()); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function issueMcpApiKey(input: { | |
| 50 | + workspaceId: Id<"workspaces">; | |
| 51 | + accountId: Id<"accounts">; | |
| 52 | +}): Promise<string | null> { | |
| 53 | + const secret = getConfiguredSecret(); | |
| 54 | + if (!secret) { | |
| 55 | + return null; | |
| 56 | + } | |
| 57 | + | |
| 58 | + const issuer = getIssuer(); | |
| 59 | + let jwt = new SignJWT({ | |
| 60 | + workspaceId: input.workspaceId, | |
| 61 | + accountId: input.accountId, | |
| 62 | + }) | |
| 63 | + .setProtectedHeader({ alg: MCP_API_KEY_ALGORITHM, typ: "JWT" }) | |
| 64 | + .setAudience(MCP_API_KEY_AUDIENCE) | |
| 65 | + .setSubject(input.accountId) | |
| 66 | + .setIssuedAt(); | |
| 67 | + | |
| 68 | + if (issuer) { | |
| 69 | + jwt = jwt.setIssuer(issuer); | |
| 70 | + } | |
| 71 | + | |
| 72 | + return await jwt.sign(getSecretBytes(secret)); | |
| 73 | +} | |
| 74 | + | |
| 75 | +export async function verifyMcpApiKey(rawApiKey: string | null | undefined): Promise<VerifiedMcpApiKey | null> { | |
| 76 | + const token = rawApiKey?.trim() ?? ""; | |
| 77 | + if (!token) { | |
| 78 | + return null; | |
| 79 | + } | |
| 80 | + | |
| 81 | + const secret = getConfiguredSecret(); | |
| 82 | + if (!secret) { | |
| 83 | + return null; | |
| 84 | + } | |
| 85 | + | |
| 86 | + const issuer = getIssuer(); | |
| 87 | + | |
| 88 | + try { | |
| 89 | + const { payload } = await jwtVerify(token, getSecretBytes(secret), { | |
| 90 | + algorithms: [MCP_API_KEY_ALGORITHM], | |
| 91 | + audience: MCP_API_KEY_AUDIENCE, | |
| 92 | + ...(issuer ? { issuer } : {}), | |
| 93 | + }); | |
| 94 | + | |
| 95 | + const workspaceIdRaw = typeof payload.workspaceId === "string" ? payload.workspaceId.trim() : ""; | |
| 96 | + const accountIdRaw = typeof payload.accountId === "string" | |
| 97 | + ? payload.accountId.trim() | |
| 98 | + : typeof payload.sub === "string" | |
| 99 | + ? payload.sub.trim() | |
| 100 | + : ""; | |
| 101 | + | |
| 102 | + if (!workspaceIdRaw || !accountIdRaw) { | |
| 103 | + return null; | |
| 104 | + } | |
| 105 | + | |
| 106 | + return { | |
| 107 | + workspaceId: workspaceIdRaw as Id<"workspaces">, | |
| 108 | + accountId: accountIdRaw as Id<"accounts">, | |
| 109 | + }; | |
| 110 | + } catch { | |
| 111 | + return null; | |
| 112 | + } | |
| 113 | +} | |