- 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 | # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" | 23 | # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----" |
| 24 | # Optional overrides: | 24 | # Optional overrides: |
| 25 | # ANONYMOUS_AUTH_TOKEN_TTL_SECONDS=604800 | 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 | # ── Stripe Billing (optional — required for checkout/portal) ── | 29 | # ── Stripe Billing (optional — required for checkout/portal) ── |
| 28 | # STRIPE_SECRET_KEY=sk_test_... | 30 | # STRIPE_SECRET_KEY=sk_test_... |
| @@ -137,7 +137,8 @@ Convex HTTP routes (`packages/convex/http.ts`) expose: · 136 unmodified lines | |||
| 137 | - `/internal/runs/:runId/tool-call` | 137 | - `/internal/runs/:runId/tool-call` |
| 138 | 138 | ||
| 139 | MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured. | 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 | ## Configuration Reference | 143 | ## Configuration Reference |
| 143 | 144 | ||
| @@ -222,6 +223,6 @@ executor/ · 78 unmodified lines | |||
| 222 | ## Troubleshooting | 223 | ## Troubleshooting |
| 223 | 224 | ||
| 224 | - `401` on `/mcp`: verify your bearer token issuer matches `MCP_AUTHORIZATION_SERVER`. | 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 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. | 227 | - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running. |
| 227 | - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets. | 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 | <McpSetupCard | 63 | <McpSetupCard |
| 64 | workspaceId={context?.workspaceId} | 64 | workspaceId={context?.workspaceId} |
| 65 | sessionId={context?.sessionId} | 65 | sessionId={context?.sessionId} |
| 66 | accountId={context?.accountId} | ||
| 67 | /> | 66 | /> |
| 68 | </CardContent> | 67 | </CardContent> |
| 69 | </Card> | 68 | </Card> |
| @@ -1,308 +1,29 @@ | |||
| 1 | export type McpProviderConfig = { | 1 | export type McpInstallConfig = { |
| 2 | id: string; | 2 | type: "command"; |
| 3 | name: string; | 3 | description: string; |
| 4 | icon: string; | 4 | content: string; |
| 5 | getConfig: ( | ||
| 6 | mcpUrl: string, | ||
| 7 | serverName: string, | ||
| 8 | ) => { | ||
| 9 | type: "command" | "json" | "toml"; | ||
| 10 | description: string; | ||
| 11 | content: string; | ||
| 12 | }; | ||
| 13 | }; | 5 | }; |
| 14 | 6 | ||
| 15 | export const MCP_PROVIDERS: McpProviderConfig[] = [ | 7 | export function getAddMcpInstallConfig( |
| 16 | { | 8 | mcpUrl: string, |
| 17 | id: "claude-code", | 9 | serverName: string, |
| 18 | name: "Claude Code", | 10 | auth?: { |
| 19 | icon: "https://claude.ai/favicon.ico", | 11 | apiKeyEnvName?: string; |
| 20 | getConfig: (mcpUrl, serverName) => ({ | 12 | }, |
| 21 | type: "command", | 13 | ): McpInstallConfig { |
| 22 | description: "Run this command in your terminal:", | 14 | const apiKeyEnvName = auth?.apiKeyEnvName?.trim() || "API_KEY"; |
| 23 | content: `claude mcp add --transport http ${serverName} ${mcpUrl}`, | 15 | const headerArg = auth |
| 24 | }), | 16 | ? ` --header "x-api-key: $${apiKeyEnvName}"` |
| 25 | }, | 17 | : ""; |
| 26 | { | 18 | const exportLine = auth |
| 27 | id: "cursor", | 19 | ? `${apiKeyEnvName}="<paste-api-key-from-above>"` |
| 28 | name: "Cursor", | 20 | : ""; |
| 29 | icon: "https://cursor.com/favicon.ico", | 21 | |
| 30 | getConfig: (mcpUrl, serverName) => ({ | 22 | return { |
| 31 | type: "json", | 23 | type: "command", |
| 32 | description: "Add to ~/.cursor/mcp.json:", | 24 | description: "Run once to install for all supported clients (via add-mcp):", |
| 33 | content: JSON.stringify( | 25 | content: auth |
| 34 | { | 26 | ? `${exportLine}\nnpx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}` |
| 35 | mcpServers: { | 27 | : `npx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}`, |
| 36 | [serverName]: { | 28 | }; |
| 37 | url: mcpUrl, | 29 | } |
| 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 | ]; | ||
| @@ -1,27 +1,13 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { useMemo, useState } from "react"; | 3 | import { useMemo, useState } from "react"; |
| 4 | import { Check, ChevronRight, Copy } from "lucide-react"; | 4 | import { useQuery } from "convex/react"; |
| 5 | import Image from "next/image"; | 5 | import { Check, Copy } from "lucide-react"; |
| 6 | import { useQuery as useTanstackQuery } from "@tanstack/react-query"; | ||
| 7 | import { Button } from "@/components/ui/button"; | 6 | import { Button } from "@/components/ui/button"; |
| 8 | import { | ||
| 9 | Collapsible, | ||
| 10 | CollapsibleContent, | ||
| 11 | CollapsibleTrigger, | ||
| 12 | } from "@/components/ui/collapsible"; | ||
| 13 | import { Input } from "@/components/ui/input"; | 7 | import { Input } from "@/components/ui/input"; |
| 14 | import { Label } from "@/components/ui/label"; | 8 | import { Label } from "@/components/ui/label"; |
| 15 | import { | 9 | import { getAddMcpInstallConfig } from "@/components/tools/install-configs"; |
| 16 | Select, | 10 | import { convexApi } from "@/lib/convex-api"; |
| 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"; | ||
| 25 | 11 | ||
| 26 | function inferServerName(workspaceId?: string): string { | 12 | function inferServerName(workspaceId?: string): string { |
| 27 | if (!workspaceId) return "executor"; | 13 | if (!workspaceId) return "executor"; |
| @@ -33,6 +19,11 @@ function isAnonymousSessionId(sessionId?: string): boolean { · 5 unmodified lines | |||
| 33 | return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_"); | 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 | function resolveMcpOrigin(windowOrigin: string): string { | 27 | function resolveMcpOrigin(windowOrigin: string): string { |
| 37 | const explicit = process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL ?? process.env.NEXT_PUBLIC_CONVEX_SITE_URL; | 28 | const explicit = process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL ?? process.env.NEXT_PUBLIC_CONVEX_SITE_URL; |
| 38 | if (explicit && explicit.trim().length > 0) { | 29 | if (explicit && explicit.trim().length > 0) { |
| @@ -58,15 +49,11 @@ function resolveMcpOrigin(windowOrigin: string): string { · 19 unmodified lines | |||
| 58 | export function McpSetupCard({ | 49 | export function McpSetupCard({ |
| 59 | workspaceId, | 50 | workspaceId, |
| 60 | sessionId, | 51 | sessionId, |
| 61 | accountId, | ||
| 62 | }: { | 52 | }: { |
| 63 | workspaceId?: string; | 53 | workspaceId?: string; |
| 64 | sessionId?: string; | 54 | sessionId?: string; |
| 65 | accountId?: string; | ||
| 66 | }) { | 55 | }) { |
| 67 | const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code"); | ||
| 68 | const [copiedKey, setCopiedKey] = useState<string | null>(null); | 56 | const [copiedKey, setCopiedKey] = useState<string | null>(null); |
| 69 | const [headersOpen, setHeadersOpen] = useState(false); | ||
| 70 | 57 | ||
| 71 | const origin = useMemo(() => { | 58 | const origin = useMemo(() => { |
| 72 | if (typeof window === "undefined") return ""; | 59 | if (typeof window === "undefined") return ""; |
| @@ -78,7 +65,9 @@ export function McpSetupCard({ · 5 unmodified lines | |||
| 78 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; | 65 | const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp"; |
| 79 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); | 66 | const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`); |
| 80 | if (workspaceId) base.searchParams.set("workspaceId", workspaceId); | 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 | if (!origin) { | 71 | if (!origin) { |
| 83 | return `${base.pathname}${base.search}`; | 72 | return `${base.pathname}${base.search}`; |
| 84 | } | 73 | } |
| @@ -87,40 +76,39 @@ export function McpSetupCard({ · 2 unmodified lines | |||
| 87 | 76 | ||
| 88 | const isAnonymousSession = isAnonymousSessionId(sessionId); | 77 | const isAnonymousSession = isAnonymousSessionId(sessionId); |
| 89 | 78 | ||
| 90 | const anonymousTokenQuery = useTanstackQuery<{ accessToken: string; accountId: string; expiresAtMs: number } | null>({ | 79 | const anonymousMcpApiKey = useQuery( |
| 91 | queryKey: ["mcp-anonymous-token", sessionId, accountId], | 80 | convexApi.workspace.getMcpApiKey, |
| 92 | queryFn: async () => { | 81 | isAnonymousSession && workspaceId |
| 93 | const token = await getAnonymousAuthToken(false, accountId); | 82 | ? { |
| 94 | return token; | 83 | workspaceId, |
| 95 | }, | 84 | sessionId, |
| 96 | enabled: isAnonymousSession, | 85 | } |
| 97 | retry: false, | 86 | : "skip", |
| 98 | }); | 87 | ); |
| 99 | 88 | ||
| 100 | const anonymousAccessToken = anonymousTokenQuery.data?.accessToken ?? null; | 89 | const apiKeyEnvName = anonymousMcpApiKey?.envVar ?? "API_KEY"; |
| 101 | const anonymousTokenError = anonymousTokenQuery.isError | 90 | const apiKeyValue = anonymousMcpApiKey?.enabled |
| 102 | ? anonymousTokenQuery.error instanceof Error | 91 | ? anonymousMcpApiKey.apiKey |
| 103 | ? anonymousTokenQuery.error.message | 92 | : null; |
| 104 | : "Failed to get anonymous token" | 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 | : null; | 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 | const serverName = inferServerName(workspaceId); | 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 | const copyText = async (key: string, text: string) => { | 113 | const copyText = async (key: string, text: string) => { |
| 126 | await navigator.clipboard.writeText(text); | 114 | await navigator.clipboard.writeText(text); |
| @@ -128,9 +116,9 @@ export function McpSetupCard({ · 1 unmodified line | |||
| 128 | setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1500); | 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 | ? "bash" | 120 | ? "bash" |
| 133 | : providerConfig.type; | 121 | : installConfig.type; |
| 134 | 122 | ||
| 135 | return ( | 123 | return ( |
| 136 | <div className="space-y-5"> | 124 | <div className="space-y-5"> |
| @@ -150,81 +138,44 @@ export function McpSetupCard({ · 13 unmodified lines | |||
| 150 | </div> | 138 | </div> |
| 151 | 139 | ||
| 152 | {isAnonymousSession && ( | 140 | {isAnonymousSession && ( |
| 153 | <Collapsible open={headersOpen} onOpenChange={setHeadersOpen}> | 141 | <div className="mt-2 space-y-1.5"> |
| 154 | <div className="mt-2"> | 142 | <Label className="text-xs text-muted-foreground">API Key ({apiKeyEnvName})</Label> |
| 155 | <CollapsibleTrigger asChild> | 143 | <div className="flex items-center gap-2"> |
| 156 | <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> | 144 | <Input |
| 157 | <ChevronRight | 145 | value={apiKeyValue ?? "Loading API key..."} |
| 158 | className={cn("mr-1 h-3 w-3 transition-transform", headersOpen && "rotate-90")} | 146 | readOnly |
| 159 | /> | 147 | className="h-8 text-xs font-mono bg-background" |
| 160 | {headersOpen ? "Hide headers" : "Set these headers also"} | 148 | /> |
| 161 | </Button> | 149 | <Button |
| 162 | </CollapsibleTrigger> | 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 | </div> | 159 | </div> |
| 164 | 160 | {apiKeyError && <p className="text-[10px] text-destructive">{apiKeyError}</p>} | |
| 165 | <CollapsibleContent className="mt-1.5"> | 161 | </div> |
| 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> | ||
| 185 | )} | 162 | )} |
| 186 | </div> | 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 | <div className="space-y-2"> | 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 | <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2"> | 167 | <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2"> |
| 217 | <pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-foreground"> | 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 | </pre> | 170 | </pre> |
| 220 | <Button | 171 | <Button |
| 221 | type="button" | 172 | type="button" |
| 222 | variant="outline" | 173 | variant="outline" |
| 223 | size="sm" | 174 | size="sm" |
| 224 | className="h-7 text-[11px]" | 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 | </Button> | 179 | </Button> |
| 229 | </div> | 180 | </div> |
| 230 | </div> | 181 | </div> |
| @@ -24,10 +24,66 @@ const convexClient = new ConvexReactClient(convexUrl, { · 23 unmodified lines | |||
| 24 | unsavedChangesWarning: false, | 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 | /** Exposes whether the WorkOS auth token is still being resolved. */ | 82 | /** Exposes whether the WorkOS auth token is still being resolved. */ |
| 28 | const WorkosAuthContext = createContext({ | 83 | const WorkosAuthContext = createContext({ |
| 29 | loading: false, | 84 | loading: false, |
| 30 | authenticated: false, | 85 | authenticated: false, |
| 86 | profile: null as WorkosAuthProfile | null, | ||
| 31 | }); | 87 | }); |
| 32 | 88 | ||
| 33 | export function useWorkosAuthState() { | 89 | export function useWorkosAuthState() { |
| @@ -110,9 +166,10 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 76 unmodified lines | |||
| 110 | const { loading: tokenLoading } = useAccessToken(); | 166 | const { loading: tokenLoading } = useAccessToken(); |
| 111 | const authenticated = Boolean(user); | 167 | const authenticated = Boolean(user); |
| 112 | const loading = authLoading || (authenticated && tokenLoading); | 168 | const loading = authLoading || (authenticated && tokenLoading); |
| 169 | const profile = buildWorkosAuthProfile(user); | ||
| 113 | 170 | ||
| 114 | return ( | 171 | return ( |
| 115 | <WorkosAuthContext.Provider value={{ loading, authenticated }}> | 172 | <WorkosAuthContext.Provider value={{ loading, authenticated, profile }}> |
| 116 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}> | 173 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}> |
| 117 | {children} | 174 | {children} |
| 118 | </ConvexProviderWithAuth> | 175 | </ConvexProviderWithAuth> |
| @@ -130,7 +187,7 @@ export function AppConvexProvider({ children }: { children: ReactNode }) { · 11 unmodified lines | |||
| 130 | } | 187 | } |
| 131 | 188 | ||
| 132 | return ( | 189 | return ( |
| 133 | <WorkosAuthContext.Provider value={{ loading: false, authenticated: false }}> | 190 | <WorkosAuthContext.Provider value={{ loading: false, authenticated: false, profile: null }}> |
| 134 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromAnonymous}> | 191 | <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromAnonymous}> |
| 135 | {children} | 192 | {children} |
| 136 | </ConvexProviderWithAuth> | 193 | </ConvexProviderWithAuth> |
| @@ -34,6 +34,14 @@ import { clearAnonymousAuth } from "@/lib/anonymous-auth"; · 33 unmodified lines | |||
| 34 | import type { AnonymousContext } from "./types"; | 34 | import type { AnonymousContext } from "./types"; |
| 35 | import type { Id } from "@executor/database/convex/_generated/dataModel"; | 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 | interface SessionState { | 45 | interface SessionState { |
| 38 | context: AnonymousContext | null; | 46 | context: AnonymousContext | null; |
| 39 | loading: boolean; | 47 | loading: boolean; |
| @@ -103,7 +111,11 @@ const SessionContext = createContext<SessionState>({ · 63 unmodified lines | |||
| 103 | }); | 111 | }); |
| 104 | 112 | ||
| 105 | export function SessionProvider({ children }: { children: ReactNode }) { | 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 | const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession); | 119 | const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession); |
| 108 | const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId); | 120 | const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId); |
| 109 | const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>( | 121 | const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>( |
| @@ -319,6 +331,25 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 209 unmodified lines | |||
| 319 | const workspaceOptions = useMemo(() => { | 331 | const workspaceOptions = useMemo(() => { |
| 320 | return buildWorkspaceOptions(mode, workspaces, guestContext); | 332 | return buildWorkspaceOptions(mode, workspaces, guestContext); |
| 321 | }, [mode, workspaces, guestContext]); | 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 | return ( | 354 | return ( |
| 324 | <SessionContext.Provider | 355 | <SessionContext.Provider |
| @@ -337,14 +368,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines | |||
| 337 | creatingAnonymousOrganization, | 368 | creatingAnonymousOrganization, |
| 338 | createAnonymousOrganization, | 369 | createAnonymousOrganization, |
| 339 | isSignedInToWorkos: Boolean(account && account.provider === "workos"), | 370 | isSignedInToWorkos: Boolean(account && account.provider === "workos"), |
| 340 | workosProfile: | 371 | workosProfile: resolvedWorkosProfile, |
| 341 | account && account.provider === "workos" | ||
| 342 | ? { | ||
| 343 | name: account.name, | ||
| 344 | email: account.email, | ||
| 345 | avatarUrl: account.avatarUrl ?? null, | ||
| 346 | } | ||
| 347 | : null, | ||
| 348 | resetWorkspace, | 372 | resetWorkspace, |
| 349 | }} | 373 | }} |
| 350 | > | 374 | > |
| @@ -26,6 +26,18 @@ export async function resolveAccountForRequest( · 25 unmodified lines | |||
| 26 | sessionId?: string, | 26 | sessionId?: string, |
| 27 | ): Promise<Doc<"accounts"> | null> { | 27 | ): Promise<Doc<"accounts"> | null> { |
| 28 | const normalizedSessionId = sessionId?.trim() || ""; | 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 | if (normalizedSessionId.startsWith("anon_session_") || normalizedSessionId.startsWith("mcp_")) { | 42 | if (normalizedSessionId.startsWith("anon_session_") || normalizedSessionId.startsWith("mcp_")) { |
| 31 | const anonymousSession = await ctx.db | 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 | if (identity) { | 55 | if (identity) { |
| 45 | if (isAnonymousIdentity(identity)) { | 56 | if (!hasNonAnonymousIdentity) { |
| 46 | const anonymousById = await ctx.db.get(identity.subject as Id<"accounts">); | 57 | const anonymousById = await ctx.db.get(identity.subject as Id<"accounts">); |
| 47 | if (anonymousById?.provider === "anonymous") { | 58 | if (anonymousById?.provider === "anonymous") { |
| 48 | return anonymousById; | 59 | return anonymousById; |
| @@ -56,14 +67,6 @@ export async function resolveAccountForRequest( · 7 unmodified lines | |||
| 56 | return anonymousAccount; | 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 | return null; | 72 | return null; |
| @@ -7,6 +7,7 @@ · 6 unmodified lines | |||
| 7 | * Specs are fetched live so these tests require network access and are slower (~5-60s each). | 7 | * Specs are fetched live so these tests require network access and are slower (~5-60s each). |
| 8 | */ | 8 | */ |
| 9 | import { test, expect, describe } from "bun:test"; | 9 | import { test, expect, describe } from "bun:test"; |
| 10 | import SwaggerParser from "@apidevtools/swagger-parser"; | ||
| 10 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources"; | 11 | import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources"; |
| 11 | 12 | ||
| 12 | interface SpecFixture { | 13 | interface SpecFixture { |
| @@ -18,6 +19,408 @@ interface SpecFixture { · 5 unmodified lines | |||
| 18 | expectDts: boolean; | 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 | const SPECS: SpecFixture[] = [ | 424 | const SPECS: SpecFixture[] = [ |
| 22 | { | 425 | { |
| 23 | name: "jira", | 426 | name: "jira", |
| @@ -331,4 +734,146 @@ describe("real-world OpenAPI specs", () => { · 307 unmodified lines | |||
| 331 | }, | 734 | }, |
| 332 | 300_000, | 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 | return filtered.length > 0 ? filtered : variants; | 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 | function extractStringLiteralFromSchema(schema: JsonSchema): string | null { | 361 | function extractStringLiteralFromSchema(schema: JsonSchema): string | null { |
| 306 | // Support the two common encodings we see in OpenAPI: | 362 | // Support the two common encodings we see in OpenAPI: |
| 307 | // - { type: "string", enum: ["A"] } | 363 | // - { type: "string", enum: ["A"] } |
| @@ -825,9 +881,13 @@ export function jsonSchemaTypeHintFallback( · 517 unmodified lines | |||
| 825 | 881 | ||
| 826 | const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined; | 882 | const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined; |
| 827 | if (oneOf && oneOf.length > 0) { | 883 | if (oneOf && oneOf.length > 0) { |
| 828 | const variants = normalizeUnionSchemaVariants( | 884 | const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants( |
| 829 | oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | 885 | shape, |
| 830 | ); | 886 | mergeParentPrimitiveIntoUnionVariants( |
| 887 | shape, | ||
| 888 | oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | ||
| 889 | ), | ||
| 890 | )); | ||
| 831 | const objectVariantsPrepared = repairMissingRequiredProperties( | 891 | const objectVariantsPrepared = repairMissingRequiredProperties( |
| 832 | variants.filter(isObjectSchema), | 892 | variants.filter(isObjectSchema), |
| 833 | depth, | 893 | depth, |
| @@ -874,9 +934,13 @@ export function jsonSchemaTypeHintFallback( · 40 unmodified lines | |||
| 874 | 934 | ||
| 875 | const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined; | 935 | const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined; |
| 876 | if (anyOf && anyOf.length > 0) { | 936 | if (anyOf && anyOf.length > 0) { |
| 877 | const variants = normalizeUnionSchemaVariants( | 937 | const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants( |
| 878 | anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | 938 | shape, |
| 879 | ); | 939 | mergeParentPrimitiveIntoUnionVariants( |
| 940 | shape, | ||
| 941 | anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")), | ||
| 942 | ), | ||
| 943 | )); | ||
| 880 | const objectVariantsPrepared = repairMissingRequiredProperties( | 944 | const objectVariantsPrepared = repairMissingRequiredProperties( |
| 881 | variants.filter(isObjectSchema), | 945 | variants.filter(isObjectSchema), |
| 882 | depth, | 946 | depth, |
| @@ -954,7 +1018,7 @@ export function jsonSchemaTypeHintFallback( · 71 unmodified lines | |||
| 954 | } | 1018 | } |
| 955 | return "Record<string, unknown>"; | 1019 | return "Record<string, unknown>"; |
| 956 | } | 1020 | } |
| 957 | const maxInlineProps = 64; | 1021 | const maxInlineProps = 1000; |
| 958 | const isTruncated = propEntries.length > maxInlineProps; | 1022 | const isTruncated = propEntries.length > maxInlineProps; |
| 959 | const inner = propEntries | 1023 | const inner = propEntries |
| 960 | .slice(0, maxInlineProps) | 1024 | .slice(0, maxInlineProps) |
| @@ -316,6 +316,12 @@ export declare const api: { · 315 unmodified lines | |||
| 316 | { sessionId?: string; sourceId: string; workspaceId: Id<"workspaces"> }, | 316 | { sessionId?: string; sourceId: string; workspaceId: Id<"workspaces"> }, |
| 317 | any | 317 | any |
| 318 | >; | 318 | >; |
| 319 | getMcpApiKey: FunctionReference< | ||
| 320 | "query", | ||
| 321 | "public", | ||
| 322 | { sessionId?: string; workspaceId: Id<"workspaces"> }, | ||
| 323 | any | ||
| 324 | >; | ||
| 319 | getToolDetails: FunctionReference< | 325 | getToolDetails: FunctionReference< |
| 320 | "mutation", | 326 | "mutation", |
| 321 | "public", | 327 | "public", |
| @@ -1464,6 +1470,12 @@ export declare const internal: { · 1142 unmodified lines | |||
| 1464 | >; | 1470 | >; |
| 1465 | }; | 1471 | }; |
| 1466 | workspaceAuthInternal: { | 1472 | workspaceAuthInternal: { |
| 1473 | getWorkspaceAccessForAccount: FunctionReference< | ||
| 1474 | "query", | ||
| 1475 | "internal", | ||
| 1476 | { accountId: Id<"accounts">; workspaceId: Id<"workspaces"> }, | ||
| 1477 | any | ||
| 1478 | >; | ||
| 1467 | getWorkspaceAccessForAnonymousSubject: FunctionReference< | 1479 | getWorkspaceAccessForAnonymousSubject: FunctionReference< |
| 1468 | "query", | 1480 | "query", |
| 1469 | "internal", | 1481 | "internal", |
| @@ -308,6 +308,21 @@ describe("authentication", () => { · 307 unmodified lines | |||
| 308 | const account = await t.query(api.app.getCurrentAccount, {}); | 308 | const account = await t.query(api.app.getCurrentAccount, {}); |
| 309 | expect(account).toBeNull(); | 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 | describe("workspace access controls", () => { | 328 | describe("workspace access controls", () => { |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { httpAction } from "../_generated/server"; | 1 | import { httpAction } from "../_generated/server"; |
| 2 | import { internal } from "../_generated/api"; | 2 | import { internal } from "../_generated/api"; |
| 3 | import { handleMcpRequest, type McpWorkspaceContext } from "../../../core/src/mcp-server"; | 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 | import { | 5 | import { |
| 6 | getMcpAuthConfig, | 6 | getMcpAuthConfig, |
| 7 | isAnonymousSessionId, | 7 | isAnonymousSessionId, |
| @@ -13,10 +13,21 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines | |||
| 13 | 13 | ||
| 14 | type McpEndpointMode = "default" | "anonymous"; | 14 | type McpEndpointMode = "default" | "anonymous"; |
| 15 | 15 | ||
| 16 | function isAnonymousAuthConfigured(): boolean { | 16 | function parseMcpApiKey(request: Request): string | null { |
| 17 | const issuer = getAnonymousAuthIssuer(); | 17 | const fromHeader = request.headers.get("x-api-key")?.trim(); |
| 18 | const privateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM; | 18 | if (fromHeader) { |
| 19 | return Boolean(issuer && privateKeyPem && privateKeyPem.trim().length > 0); | 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 | function createMcpHandler(mode: McpEndpointMode) { | 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(); | 61 | if (!isMcpApiKeyConfigured()) { |
| 51 | if (!anonymousAuthConfigured) { | ||
| 52 | return Response.json( | 62 | return Response.json( |
| 53 | { error: "Anonymous auth is not configured" }, | 63 | { error: "MCP API key signing is not configured" }, |
| 54 | { status: 503 }, | 64 | { status: 503 }, |
| 55 | ); | 65 | ); |
| 56 | } | 66 | } |
| 57 | 67 | ||
| 58 | const identity = await ctx.auth.getUserIdentity().catch(() => null); | 68 | const apiKey = parseMcpApiKey(request); |
| 59 | if (identity && isAnonymousIdentity(identity)) { | 69 | if (!apiKey) { |
| 60 | const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, { | 70 | return Response.json( |
| 61 | workspaceId, | 71 | { error: "API key is required for /mcp/anonymous" }, |
| 62 | accountId: identity.subject, | 72 | { status: 401 }, |
| 63 | }); | 73 | ); |
| 64 | 74 | } | |
| 65 | context = { | 75 | |
| 66 | workspaceId, | 76 | const apiKeyIdentity = await verifyMcpApiKey(apiKey); |
| 67 | accountId: access.accountId, | 77 | if (!apiKeyIdentity) { |
| 68 | clientId: requestedContext?.clientId, | ||
| 69 | }; | ||
| 70 | } else { | ||
| 71 | return Response.json( | 78 | return Response.json( |
| 72 | { error: "Anonymous bearer token is required for /mcp/anonymous" }, | 79 | { error: "Invalid API key" }, |
| 73 | { status: 401 }, | 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 | } catch (error) { | 108 | } catch (error) { |
| 77 | return Response.json( | 109 | return Response.json( |
| 78 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, | 110 | { error: error instanceof Error ? error.message : "Workspace authorization failed" }, |
| @@ -18,6 +18,11 @@ import { · 17 unmodified lines | |||
| 18 | getWorkspaceInventoryProgressForContext, | 18 | getWorkspaceInventoryProgressForContext, |
| 19 | listToolDetailsForContext, | 19 | listToolDetailsForContext, |
| 20 | } from "../src/runtime/workspace_tools"; | 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 | export const bootstrapAnonymousSession = customMutation({ | 27 | export const bootstrapAnonymousSession = customMutation({ |
| 23 | method: "POST", | 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 | export const listTasks = workspaceQuery({ | 83 | export const listTasks = workspaceQuery({ |
| 35 | method: "GET", | 84 | method: "GET", |
| 36 | args: {}, | 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 | import { expect, test } from "bun:test"; | 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 | test("resolveIdentityProfile uses stable user label when identity is sparse", () => { | 5 | test("resolveIdentityProfile uses stable user label when identity is sparse", () => { |
| 5 | const subject = "user_01KH1TVHS4WJCPQG2XQJGMMJMJ"; | 6 | const subject = "user_01KH1TVHS4WJCPQG2XQJGMMJMJ"; |
| @@ -45,3 +46,92 @@ test("resolveIdentityProfile prefers explicit identity name", () => { · 39 unmodified lines | |||
| 45 | 46 | ||
| 46 | expect(profile.fullName).toBe("Alexandra D."); | 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 | type WorkosProfile = { | 5 | type WorkosProfile = { |
| 6 | email?: string; | 6 | email?: string; |
| 7 | emailAddress?: string | null; | ||
| 8 | name?: string | null; | ||
| 9 | fullName?: string | null; | ||
| 10 | displayName?: string | null; | ||
| 7 | firstName?: string | null; | 11 | firstName?: string | null; |
| 8 | lastName?: string | null; | 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 | profilePictureUrl?: string | null; | 18 | profilePictureUrl?: string | null; |
| 19 | metadata?: Record<string, unknown> | null; | ||
| 20 | [key: string]: unknown; | ||
| 10 | }; | 21 | }; |
| 11 | 22 | ||
| 12 | const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0); | 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 | return `User ${workosUserId.slice(-6)}`; | 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 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { | 43 | function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined { |
| 19 | for (const key of keys) { | 44 | for (const key of keys) { |
| 20 | const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]); | 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 | return undefined; | 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 | export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { | 140 | export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) { |
| 30 | try { | 141 | const candidateIds = deriveWorkosUserIdCandidates(workosUserId); |
| 31 | return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { | 142 | |
| 32 | id: workosUserId, | 143 | for (const candidateId of candidateIds) { |
| 33 | }); | 144 | try { |
| 34 | } catch { | 145 | const user = await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, { |
| 35 | return null; | 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 | export function resolveIdentityProfile(args: { | 160 | export function resolveIdentityProfile(args: { |
| @@ -43,27 +164,43 @@ export function resolveIdentityProfile(args: { · 3 unmodified lines | |||
| 43 | const { identity, authKitProfile } = args; | 164 | const { identity, authKitProfile } = args; |
| 44 | 165 | ||
| 45 | const email = | 166 | const email = |
| 46 | authKitProfile?.email | 167 | getProfileString(authKitProfile, ["email", "emailAddress", "email_address"]) |
| 47 | ?? getIdentityString(identity, ["email", "upn"]); | 168 | ?? getIdentityString(identity, ["email", "upn", "emailAddress"]); |
| 48 | 169 | ||
| 49 | const firstName = authKitProfile?.firstName | 170 | const firstName = getProfileString(authKitProfile, ["firstName", "first_name", "givenName", "given_name"]) |
| 50 | ?? getIdentityString(identity, ["given_name", "first_name"]); | 171 | ?? getIdentityString(identity, ["given_name", "first_name", "givenName", "firstName"]); |
| 51 | 172 | ||
| 52 | const lastName = authKitProfile?.lastName | 173 | const lastName = getProfileString(authKitProfile, ["lastName", "last_name", "familyName", "family_name"]) |
| 53 | ?? getIdentityString(identity, ["family_name", "last_name"]); | 174 | ?? getIdentityString(identity, ["family_name", "last_name", "familyName", "lastName"]); |
| 54 | 175 | ||
| 55 | const combinedName = [firstName, lastName].filter(Boolean).join(" ").trim(); | 176 | const combinedName = [firstName, lastName].filter(Boolean).join(" ").trim(); |
| 56 | 177 | ||
| 57 | const fullName = | 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 | ?? (combinedName.length > 0 ? combinedName : undefined) | 181 | ?? (combinedName.length > 0 ? combinedName : undefined) |
| 60 | ?? deriveFallbackUserLabel(identity.subject); | 182 | ?? deriveFallbackUserLabel(identity.subject); |
| 61 | 183 | ||
| 62 | const avatarUrl = | 184 | const avatarUrl = |
| 63 | authKitProfile?.profilePictureUrl | 185 | getProfileString(authKitProfile, [ |
| 64 | ?? getIdentityString(identity, ["picture", "avatar_url", "profile_picture_url"]); | 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 | return { | 205 | return { |
| 69 | email, | 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 | } | ||