History

Update MCP API Key Handling and Documentation

- 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.

Rhys Sullivan committed 8 months ago

Commit f3e9beb1 parent e8d1e8219 files · +1384 −493Browse files

Unified · Split

19 files
  1. .env.example +2 −0
  2. executor/README.md +3 −2
  3. executor/apps/web/src/components/dashboard/view.tsx +0 −1
  4. executor/apps/web/src/components/tools/install-configs.ts +27 −306
  5. executor/apps/web/src/components/tools/setup-card.tsx +68 −117
  6. executor/apps/web/src/lib/convex-provider.tsx +59 −2
  7. executor/apps/web/src/lib/session-context.tsx +33 −9
  8. executor/packages/core/src/identity.ts +13 −10
  9. executor/packages/core/src/openapi/real-specs.test.ts +545 −0
  10. executor/packages/core/src/openapi/schema-hints.ts +71 −7
  11. executor/packages/database/convex/_generated/api.d.ts +12 −0
  12. executor/packages/database/convex/access-controls.test.ts +15 −0
  13. executor/packages/database/convex/http/mcp_handler.ts +54 −22
  14. executor/packages/database/convex/workspace.ts +49 −0
  15. executor/packages/database/convex/workspaceAuthInternal.ts +23 −0
  16. executor/packages/database/src/auth/identity.test.ts +91 −1
  17. executor/packages/database/src/auth/identity.ts +153 −16
  18. executor/packages/database/src/auth/mcp_api_key.test.ts +53 −0
  19. executor/packages/database/src/auth/mcp_api_key.ts +113 −0
.env.example+2−0
@@ -23,6 +23,8 @@ CONVEX_SITE_URL=https://your-deployment.convex.site · 22 unmodified lines
2323 # ANONYMOUS_AUTH_PUBLIC_KEY_PEM="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
2424 # Optional overrides:
2525 # 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
2628
2729 # ── Stripe Billing (optional — required for checkout/portal) ──
2830 # STRIPE_SECRET_KEY=sk_test_...
executor/README.md+3−2
@@ -137,7 +137,8 @@ Convex HTTP routes (`packages/convex/http.ts`) expose: · 136 unmodified lines
137137 - `/internal/runs/:runId/tool-call`
138138
139139 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.
141142
142143 ## Configuration Reference
143144
@@ -222,6 +223,6 @@ executor/ · 78 unmodified lines
222223 ## Troubleshooting
223224
224225 - `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`).
226227 - Web UI cannot load data: verify `CONVEX_URL` / `CONVEX_SITE_URL` and that Convex dev is running.
227228 - Release build missing web archive files: run `bun run build:release` and verify `executor/dist/release/` contains all expected `executor-web-*.tar.gz` assets.
executor/apps/web/src/components/dashboard/view.tsx+0−1
@@ -63,7 +63,6 @@ export function DashboardView() { · 62 unmodified lines
6363 <McpSetupCard
6464 workspaceId={context?.workspaceId}
6565 sessionId={context?.sessionId}
66− accountId={context?.accountId}
6766 />
6867 </CardContent>
6968 </Card>
executor/apps/web/src/components/tools/install-configs.ts+27−306
@@ -1,308 +1,29 @@
1−export type McpProviderConfig = {
2− id: string;
3− name: string;
4− icon: string;
5− getConfig: (
6− mcpUrl: string,
7− serverName: string,
8− ) => {
9− type: "command" | "json" | "toml";
10− description: string;
11− content: string;
12− };
1+export type McpInstallConfig = {
2+ type: "command";
3+ description: string;
4+ content: string;
135 };
146
15−export const MCP_PROVIDERS: McpProviderConfig[] = [
16− {
17− id: "claude-code",
18− name: "Claude Code",
19− icon: "https://claude.ai/favicon.ico",
20− getConfig: (mcpUrl, serverName) => ({
21− type: "command",
22− description: "Run this command in your terminal:",
23− content: `claude mcp add --transport http ${serverName} ${mcpUrl}`,
24− }),
25− },
26− {
27− id: "cursor",
28− name: "Cursor",
29− icon: "https://cursor.com/favicon.ico",
30− getConfig: (mcpUrl, serverName) => ({
31− type: "json",
32− description: "Add to ~/.cursor/mcp.json:",
33− content: JSON.stringify(
34− {
35− mcpServers: {
36− [serverName]: {
37− url: mcpUrl,
38− },
39− },
40− },
41− null,
42− 2,
43− ),
44− }),
45− },
46− {
47− id: "vscode",
48− name: "VS Code",
49− icon: "https://code.visualstudio.com/assets/favicon.ico",
50− getConfig: (mcpUrl, serverName) => ({
51− type: "json",
52− description: "Add to VS Code settings (MCP extension):",
53− content: JSON.stringify(
54− {
55− mcp: {
56− servers: {
57− [serverName]: {
58− type: "http",
59− url: mcpUrl,
60− },
61− },
62− },
63− },
64− null,
65− 2,
66− ),
67− }),
68− },
69− {
70− id: "windsurf",
71− name: "Windsurf",
72− icon: "https://windsurf.com/favicon.png",
73− getConfig: (mcpUrl, serverName) => ({
74− type: "json",
75− description: "Add to your Windsurf MCP config file:",
76− content: JSON.stringify(
77− {
78− mcpServers: {
79− [serverName]: {
80− serverUrl: mcpUrl,
81− },
82− },
83− },
84− null,
85− 2,
86− ),
87− }),
88− },
89− {
90− id: "claude-desktop",
91− name: "Claude Desktop",
92− icon: "https://claude.ai/favicon.ico",
93− getConfig: (mcpUrl, serverName) => ({
94− type: "json",
95− description:
96− "Open Claude Desktop Settings > Connectors > Add Custom Connector, or add to ~/Library/Application Support/Claude/claude_desktop_config.json:",
97− content: JSON.stringify(
98− {
99− mcpServers: {
100− [serverName]: {
101− command: "npx",
102− args: ["-y", "mcp-remote", mcpUrl],
103− },
104− },
105− },
106− null,
107− 2,
108− ),
109− }),
110− },
111− {
112− id: "opencode",
113− name: "OpenCode",
114− icon: "https://opencode.ai/favicon-96x96.png",
115− getConfig: (mcpUrl, serverName) => ({
116− type: "json",
117− description: "Add to your OpenCode config (opencode.json):",
118− content: JSON.stringify(
119− {
120− mcp: {
121− [serverName]: {
122− type: "remote",
123− url: mcpUrl,
124− enabled: true,
125− },
126− },
127− },
128− null,
129− 2,
130− ),
131− }),
132− },
133− {
134− id: "cline",
135− name: "Cline",
136− icon: "https://saoudrizwan.gallerycdn.vsassets.io/extensions/saoudrizwan/claude-dev/3.46.1/1766441409033/Microsoft.VisualStudio.Services.Icons.Default",
137− getConfig: (mcpUrl, serverName) => ({
138− type: "json",
139− description:
140− "Open Cline > MCP Servers > Remote Servers > Edit Configuration:",
141− content: JSON.stringify(
142− {
143− mcpServers: {
144− [serverName]: {
145− url: mcpUrl,
146− type: "streamableHttp",
147− },
148− },
149− },
150− null,
151− 2,
152− ),
153− }),
154− },
155− {
156− id: "amp",
157− name: "Amp",
158− icon: "https://ampcode.com/app-icon.png",
159− getConfig: (mcpUrl, serverName) => ({
160− type: "command",
161− description: "Run this command in your terminal:",
162− content: `amp mcp add ${serverName} ${mcpUrl}`,
163− }),
164− },
165− {
166− id: "zed",
167− name: "Zed",
168− icon: "https://zed.dev/favicon_black_32.png",
169− getConfig: (mcpUrl, serverName) => ({
170− type: "json",
171− description: "Add to your Zed settings.json:",
172− content: JSON.stringify(
173− {
174− context_servers: {
175− [serverName]: {
176− source: "custom",
177− command: "npx",
178− args: ["-y", "mcp-remote", mcpUrl],
179− },
180− },
181− },
182− null,
183− 2,
184− ),
185− }),
186− },
187− {
188− id: "roo-code",
189− name: "Roo Code",
190− icon: "https://rooveterinaryinc.gallerycdn.vsassets.io/extensions/rooveterinaryinc/roo-cline/3.37.1/1766531704474/Microsoft.VisualStudio.Services.Icons.Default",
191− getConfig: (mcpUrl, serverName) => ({
192− type: "json",
193− description: "Add to your Roo Code MCP configuration file:",
194− content: JSON.stringify(
195− {
196− mcpServers: {
197− [serverName]: {
198− type: "streamable-http",
199− url: mcpUrl,
200− },
201− },
202− },
203− null,
204− 2,
205− ),
206− }),
207− },
208− {
209− id: "gemini-cli",
210− name: "Gemini CLI",
211− icon: "https://www.gstatic.com/devrel-devsite/prod/ve08add287a6b4bdf8961ab8a1be50bf551be3816cdd70b7cc934114ff3ad5f10/googledevai/images/favicon-new.png",
212− getConfig: (mcpUrl, serverName) => ({
213− type: "json",
214− description: "Add to ~/.gemini/settings.json:",
215− content: JSON.stringify(
216− {
217− mcpServers: {
218− [serverName]: {
219− httpUrl: mcpUrl,
220− headers: {
221− Accept: "application/json, text/event-stream",
222− },
223− },
224− },
225− },
226− null,
227− 2,
228− ),
229− }),
230− },
231− {
232− id: "copilot-coding-agent",
233− name: "Copilot Coding Agent",
234− icon: "https://github.githubassets.com/favicons/favicon.svg",
235− getConfig: (mcpUrl, serverName) => ({
236− type: "json",
237− description:
238− "Add to Repository > Settings > Copilot > Coding agent > MCP configuration:",
239− content: JSON.stringify(
240− {
241− mcpServers: {
242− [serverName]: {
243− type: "http",
244− url: mcpUrl,
245− },
246− },
247− },
248− null,
249− 2,
250− ),
251− }),
252− },
253− {
254− id: "jetbrains",
255− name: "JetBrains AI Assistant",
256− icon: "https://www.jetbrains.com/favicon.ico",
257− getConfig: (mcpUrl, serverName) => ({
258− type: "json",
259− description:
260− "Go to Settings > Tools > AI Assistant > Model Context Protocol (MCP), click + Add, select 'As JSON':",
261− content: JSON.stringify(
262− {
263− mcpServers: {
264− [serverName]: {
265− command: "npx",
266− args: ["-y", "mcp-remote", mcpUrl],
267− },
268− },
269− },
270− null,
271− 2,
272− ),
273− }),
274− },
275− {
276− id: "openai-codex",
277− name: "OpenAI Codex",
278− icon: "https://openai.com/favicon.ico",
279− getConfig: (mcpUrl, serverName) => ({
280− type: "toml",
281− description: "Add to your OpenAI Codex MCP configuration:",
282− content: `[mcp_servers.${serverName}]\nurl = "${mcpUrl}"`,
283− }),
284− },
285− {
286− id: "kilo-code",
287− name: "Kilo Code",
288− icon: "https://kilo.ai/favicon.ico",
289− getConfig: (mcpUrl, serverName) => ({
290− type: "json",
291− description:
292− "Open Settings > MCP Servers > Add Server, or add to .kilocode/mcp.json:",
293− content: JSON.stringify(
294− {
295− mcpServers: {
296− [serverName]: {
297− type: "streamable-http",
298− url: mcpUrl,
299− disabled: false,
300− },
301− },
302− },
303− null,
304− 2,
305− ),
306− }),
307− },
308−];
7+export function getAddMcpInstallConfig(
8+ mcpUrl: string,
9+ serverName: string,
10+ auth?: {
11+ apiKeyEnvName?: string;
12+ },
13+): McpInstallConfig {
14+ const apiKeyEnvName = auth?.apiKeyEnvName?.trim() || "API_KEY";
15+ const headerArg = auth
16+ ? ` --header "x-api-key: $${apiKeyEnvName}"`
17+ : "";
18+ const exportLine = auth
19+ ? `${apiKeyEnvName}="<paste-api-key-from-above>"`
20+ : "";
21+
22+ return {
23+ type: "command",
24+ description: "Run once to install for all supported clients (via add-mcp):",
25+ content: auth
26+ ? `${exportLine}\nnpx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}`
27+ : `npx add-mcp "${mcpUrl}" --transport http --name "${serverName}" --all${headerArg}`,
28+ };
29+}
executor/apps/web/src/components/tools/setup-card.tsx+68−117
@@ -1,27 +1,13 @@
11 "use client";
22
33 import { useMemo, useState } from "react";
4−import { Check, ChevronRight, Copy } from "lucide-react";
5−import Image from "next/image";
6−import { useQuery as useTanstackQuery } from "@tanstack/react-query";
4+import { useQuery } from "convex/react";
5+import { Check, Copy } from "lucide-react";
76 import { Button } from "@/components/ui/button";
8−import {
9− Collapsible,
10− CollapsibleContent,
11− CollapsibleTrigger,
12−} from "@/components/ui/collapsible";
137 import { Input } from "@/components/ui/input";
148 import { Label } from "@/components/ui/label";
15−import {
16− Select,
17− SelectContent,
18− SelectItem,
19− SelectTrigger,
20− SelectValue,
21−} from "@/components/ui/select";
22−import { MCP_PROVIDERS } from "@/components/tools/install-configs";
23−import { getAnonymousAuthToken } from "@/lib/anonymous-auth";
24−import { cn } from "@/lib/utils";
9+import { getAddMcpInstallConfig } from "@/components/tools/install-configs";
10+import { convexApi } from "@/lib/convex-api";
2511
2612 function inferServerName(workspaceId?: string): string {
2713 if (!workspaceId) return "executor";
@@ -33,6 +19,11 @@ function isAnonymousSessionId(sessionId?: string): boolean { · 5 unmodified lines
3319 return sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_");
3420 }
3521
22+function isWorkosSessionId(sessionId?: string): boolean {
23+ if (!sessionId) return false;
24+ return sessionId.startsWith("workos_");
25+}
26+
3627 function resolveMcpOrigin(windowOrigin: string): string {
3728 const explicit = process.env.NEXT_PUBLIC_EXECUTOR_HTTP_URL ?? process.env.NEXT_PUBLIC_CONVEX_SITE_URL;
3829 if (explicit && explicit.trim().length > 0) {
@@ -58,15 +49,11 @@ function resolveMcpOrigin(windowOrigin: string): string { · 19 unmodified lines
5849 export function McpSetupCard({
5950 workspaceId,
6051 sessionId,
61− accountId,
6252 }: {
6353 workspaceId?: string;
6454 sessionId?: string;
65− accountId?: string;
6655 }) {
67− const [selectedProviderId, setSelectedProviderId] = useState(MCP_PROVIDERS[0]?.id ?? "claude-code");
6856 const [copiedKey, setCopiedKey] = useState<string | null>(null);
69− const [headersOpen, setHeadersOpen] = useState(false);
7057
7158 const origin = useMemo(() => {
7259 if (typeof window === "undefined") return "";
@@ -78,7 +65,9 @@ export function McpSetupCard({ · 5 unmodified lines
7865 const mcpPath = isAnonymousSession ? "/mcp/anonymous" : "/mcp";
7966 const base = origin ? new URL(mcpPath, origin) : new URL(`http://localhost${mcpPath}`);
8067 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+ }
8271 if (!origin) {
8372 return `${base.pathname}${base.search}`;
8473 }
@@ -87,40 +76,39 @@ export function McpSetupCard({ · 2 unmodified lines
8776
8877 const isAnonymousSession = isAnonymousSessionId(sessionId);
8978
90− const anonymousTokenQuery = useTanstackQuery<{ accessToken: string; accountId: string; expiresAtMs: number } | null>({
91− queryKey: ["mcp-anonymous-token", sessionId, accountId],
92− queryFn: async () => {
93− const token = await getAnonymousAuthToken(false, accountId);
94− return token;
95− },
96− enabled: isAnonymousSession,
97− retry: false,
98− });
99−
100− const anonymousAccessToken = anonymousTokenQuery.data?.accessToken ?? null;
101− const anonymousTokenError = anonymousTokenQuery.isError
102− ? anonymousTokenQuery.error instanceof Error
103− ? anonymousTokenQuery.error.message
104− : "Failed to get anonymous token"
79+ const anonymousMcpApiKey = useQuery(
80+ convexApi.workspace.getMcpApiKey,
81+ isAnonymousSession && workspaceId
82+ ? {
83+ workspaceId,
84+ sessionId,
85+ }
86+ : "skip",
87+ );
88+
89+ const apiKeyEnvName = anonymousMcpApiKey?.envVar ?? "API_KEY";
90+ const apiKeyValue = anonymousMcpApiKey?.enabled
91+ ? anonymousMcpApiKey.apiKey
92+ : null;
93+ const apiKeyError = isAnonymousSession
94+ ? anonymousMcpApiKey?.enabled === false
95+ ? anonymousMcpApiKey.error
96+ : workspaceId
97+ ? null
98+ : "Workspace is required to issue an MCP API key"
10599 : null;
106100
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− }
122101 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+ );
124112
125113 const copyText = async (key: string, text: string) => {
126114 await navigator.clipboard.writeText(text);
@@ -128,9 +116,9 @@ export function McpSetupCard({ · 1 unmodified line
128116 setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 1500);
129117 };
130118
131− const codeLanguage = providerConfig.type === "command"
119+ const codeLanguage = installConfig.type === "command"
132120 ? "bash"
133− : providerConfig.type;
121+ : installConfig.type;
134122
135123 return (
136124 <div className="space-y-5">
@@ -150,81 +138,44 @@ export function McpSetupCard({ · 13 unmodified lines
150138 </div>
151139
152140 {isAnonymousSession && (
153− <Collapsible open={headersOpen} onOpenChange={setHeadersOpen}>
154− <div className="mt-2">
155− <CollapsibleTrigger asChild>
156− <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground">
157− <ChevronRight
158− className={cn("mr-1 h-3 w-3 transition-transform", headersOpen && "rotate-90")}
159− />
160− {headersOpen ? "Hide headers" : "Set these headers also"}
161− </Button>
162− </CollapsibleTrigger>
141+ <div className="mt-2 space-y-1.5">
142+ <Label className="text-xs text-muted-foreground">API Key ({apiKeyEnvName})</Label>
143+ <div className="flex items-center gap-2">
144+ <Input
145+ value={apiKeyValue ?? "Loading API key..."}
146+ readOnly
147+ className="h-8 text-xs font-mono bg-background"
148+ />
149+ <Button
150+ type="button"
151+ size="icon"
152+ variant="outline"
153+ className="h-8 w-8"
154+ disabled={!apiKeyValue}
155+ onClick={() => apiKeyValue ? void copyText("api-key", apiKeyValue) : undefined}
156+ >
157+ {copiedKey === "api-key" ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
158+ </Button>
163159 </div>
164−
165− <CollapsibleContent className="mt-1.5">
166− <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2 space-y-2">
167− <div className="flex items-center justify-end">
168− <Button
169− type="button"
170− variant="outline"
171− size="sm"
172− className="h-6 text-[10px]"
173− onClick={() => void copyText("headers", headerLines.join("\n"))}
174− >
175− {copiedKey === "headers" ? "Copied" : "Copy headers"}
176− </Button>
177− </div>
178− <pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-foreground">
179− <code>{headerLines.join("\n")}</code>
180− </pre>
181− {anonymousTokenError && <p className="text-[10px] text-destructive">{anonymousTokenError}</p>}
182− </div>
183− </CollapsibleContent>
184− </Collapsible>
160+ {apiKeyError && <p className="text-[10px] text-destructive">{apiKeyError}</p>}
161+ </div>
185162 )}
186163 </div>
187164
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−
214165 <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>
216167 <div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
217168 <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>
219170 </pre>
220171 <Button
221172 type="button"
222173 variant="outline"
223174 size="sm"
224175 className="h-7 text-[11px]"
225− onClick={() => void copyText("provider", providerConfig.content)}
176+ onClick={() => void copyText("install", installConfig.content)}
226177 >
227− {copiedKey === "provider" ? "Copied" : "Copy snippet"}
178+ {copiedKey === "install" ? "Copied" : "Copy command"}
228179 </Button>
229180 </div>
230181 </div>
executor/apps/web/src/lib/convex-provider.tsx+59−2
@@ -24,10 +24,66 @@ const convexClient = new ConvexReactClient(convexUrl, { · 23 unmodified lines
2424 unsavedChangesWarning: false,
2525 });
2626
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+
2782 /** Exposes whether the WorkOS auth token is still being resolved. */
2883 const WorkosAuthContext = createContext({
2984 loading: false,
3085 authenticated: false,
86+ profile: null as WorkosAuthProfile | null,
3187 });
3288
3389 export function useWorkosAuthState() {
@@ -110,9 +166,10 @@ function ConvexWithWorkos({ children }: { children: ReactNode }) { · 76 unmodified lines
110166 const { loading: tokenLoading } = useAccessToken();
111167 const authenticated = Boolean(user);
112168 const loading = authLoading || (authenticated && tokenLoading);
169+ const profile = buildWorkosAuthProfile(user);
113170
114171 return (
115− <WorkosAuthContext.Provider value={{ loading, authenticated }}>
172+ <WorkosAuthContext.Provider value={{ loading, authenticated, profile }}>
116173 <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromWorkosOrAnonymous}>
117174 {children}
118175 </ConvexProviderWithAuth>
@@ -130,7 +187,7 @@ export function AppConvexProvider({ children }: { children: ReactNode }) { · 11 unmodified lines
130187 }
131188
132189 return (
133− <WorkosAuthContext.Provider value={{ loading: false, authenticated: false }}>
190+ <WorkosAuthContext.Provider value={{ loading: false, authenticated: false, profile: null }}>
134191 <ConvexProviderWithAuth client={convexClient} useAuth={useConvexAuthFromAnonymous}>
135192 {children}
136193 </ConvexProviderWithAuth>
executor/apps/web/src/lib/session-context.tsx+33−9
@@ -34,6 +34,14 @@ import { clearAnonymousAuth } from "@/lib/anonymous-auth"; · 33 unmodified lines
3434 import type { AnonymousContext } from "./types";
3535 import type { Id } from "@executor/database/convex/_generated/dataModel";
3636
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+
3745 interface SessionState {
3846 context: AnonymousContext | null;
3947 loading: boolean;
@@ -103,7 +111,11 @@ const SessionContext = createContext<SessionState>({ · 63 unmodified lines
103111 });
104112
105113 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();
107119 const bootstrapAnonymousSession = useMutation(convexApi.workspace.bootstrapAnonymousSession);
108120 const [storedSessionId, setStoredSessionId] = useState<string | null>(readStoredSessionId);
109121 const [activeWorkspaceId, setActiveWorkspaceId] = useState<Id<"workspaces"> | null>(
@@ -319,6 +331,25 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 209 unmodified lines
319331 const workspaceOptions = useMemo(() => {
320332 return buildWorkspaceOptions(mode, workspaces, guestContext);
321333 }, [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]);
322353
323354 return (
324355 <SessionContext.Provider
@@ -337,14 +368,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 12 unmodified lines
337368 creatingAnonymousOrganization,
338369 createAnonymousOrganization,
339370 isSignedInToWorkos: Boolean(account && account.provider === "workos"),
340− workosProfile:
341− account && account.provider === "workos"
342− ? {
343− name: account.name,
344− email: account.email,
345− avatarUrl: account.avatarUrl ?? null,
346− }
347− : null,
371+ workosProfile: resolvedWorkosProfile,
348372 resetWorkspace,
349373 }}
350374 >
executor/packages/core/src/identity.ts+13−10
@@ -26,6 +26,18 @@ export async function resolveAccountForRequest( · 25 unmodified lines
2626 sessionId?: string,
2727 ): Promise<Doc<"accounts"> | null> {
2828 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+ }
2941
3042 if (normalizedSessionId.startsWith("anon_session_") || normalizedSessionId.startsWith("mcp_")) {
3143 const anonymousSession = await ctx.db
@@ -40,9 +52,8 @@ export async function resolveAccountForRequest( · 8 unmodified lines
4052 }
4153 }
4254
43− const identity = await ctx.auth.getUserIdentity();
4455 if (identity) {
45− if (isAnonymousIdentity(identity)) {
56+ if (!hasNonAnonymousIdentity) {
4657 const anonymousById = await ctx.db.get(identity.subject as Id<"accounts">);
4758 if (anonymousById?.provider === "anonymous") {
4859 return anonymousById;
@@ -56,14 +67,6 @@ export async function resolveAccountForRequest( · 7 unmodified lines
5667 return anonymousAccount;
5768 }
5869 }
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− }
6770 }
6871
6972 return null;
executor/packages/core/src/openapi/real-specs.test.ts+545−0
@@ -7,6 +7,7 @@ · 6 unmodified lines
77 * Specs are fetched live so these tests require network access and are slower (~5-60s each).
88 */
99 import { test, expect, describe } from "bun:test";
10+import SwaggerParser from "@apidevtools/swagger-parser";
1011 import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources";
1112
1213 interface SpecFixture {
@@ -18,6 +19,408 @@ interface SpecFixture { · 5 unmodified lines
1819 expectDts: boolean;
1920 }
2021
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+
21424 const SPECS: SpecFixture[] = [
22425 {
23426 name: "jira",
@@ -331,4 +734,146 @@ describe("real-world OpenAPI specs", () => { · 307 unmodified lines
331734 },
332735 300_000,
333736 );
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+ );
334879 });
executor/packages/core/src/openapi/schema-hints.ts+71−7
@@ -302,6 +302,62 @@ function normalizeUnionSchemaVariants(variants: JsonSchema[]): JsonSchema[] { · 301 unmodified lines
302302 return filtered.length > 0 ? filtered : variants;
303303 }
304304
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+
305361 function extractStringLiteralFromSchema(schema: JsonSchema): string | null {
306362 // Support the two common encodings we see in OpenAPI:
307363 // - { type: "string", enum: ["A"] }
@@ -825,9 +881,13 @@ export function jsonSchemaTypeHintFallback( · 517 unmodified lines
825881
826882 const oneOf = Array.isArray(shape.oneOf) ? shape.oneOf : undefined;
827883 if (oneOf && oneOf.length > 0) {
828− const variants = normalizeUnionSchemaVariants(
829− oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
830− );
884+ const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants(
885+ shape,
886+ mergeParentPrimitiveIntoUnionVariants(
887+ shape,
888+ oneOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
889+ ),
890+ ));
831891 const objectVariantsPrepared = repairMissingRequiredProperties(
832892 variants.filter(isObjectSchema),
833893 depth,
@@ -874,9 +934,13 @@ export function jsonSchemaTypeHintFallback( · 40 unmodified lines
874934
875935 const anyOf = Array.isArray(shape.anyOf) ? shape.anyOf : undefined;
876936 if (anyOf && anyOf.length > 0) {
877− const variants = normalizeUnionSchemaVariants(
878− anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
879− );
937+ const variants = normalizeUnionSchemaVariants(mergeParentObjectIntoUnionVariants(
938+ shape,
939+ mergeParentPrimitiveIntoUnionVariants(
940+ shape,
941+ anyOf.filter((entry): entry is JsonSchema => Boolean(entry && typeof entry === "object")),
942+ ),
943+ ));
880944 const objectVariantsPrepared = repairMissingRequiredProperties(
881945 variants.filter(isObjectSchema),
882946 depth,
@@ -954,7 +1018,7 @@ export function jsonSchemaTypeHintFallback( · 71 unmodified lines
9541018 }
9551019 return "Record<string, unknown>";
9561020 }
957− const maxInlineProps = 64;
1021+ const maxInlineProps = 1000;
9581022 const isTruncated = propEntries.length > maxInlineProps;
9591023 const inner = propEntries
9601024 .slice(0, maxInlineProps)
executor/packages/database/convex/_generated/api.d.ts+12−0
@@ -316,6 +316,12 @@ export declare const api: { · 315 unmodified lines
316316 { sessionId?: string; sourceId: string; workspaceId: Id<"workspaces"> },
317317 any
318318 >;
319+ getMcpApiKey: FunctionReference<
320+ "query",
321+ "public",
322+ { sessionId?: string; workspaceId: Id<"workspaces"> },
323+ any
324+ >;
319325 getToolDetails: FunctionReference<
320326 "mutation",
321327 "public",
@@ -1464,6 +1470,12 @@ export declare const internal: { · 1142 unmodified lines
14641470 >;
14651471 };
14661472 workspaceAuthInternal: {
1473+ getWorkspaceAccessForAccount: FunctionReference<
1474+ "query",
1475+ "internal",
1476+ { accountId: Id<"accounts">; workspaceId: Id<"workspaces"> },
1477+ any
1478+ >;
14671479 getWorkspaceAccessForAnonymousSubject: FunctionReference<
14681480 "query",
14691481 "internal",
executor/packages/database/convex/access-controls.test.ts+15−0
@@ -308,6 +308,21 @@ describe("authentication", () => { · 307 unmodified lines
308308 const account = await t.query(api.app.getCurrentAccount, {});
309309 expect(account).toBeNull();
310310 });
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+ });
311326 });
312327
313328 describe("workspace access controls", () => {
executor/packages/database/convex/http/mcp_handler.ts+54−22
@@ -1,7 +1,7 @@
11 import { httpAction } from "../_generated/server";
22 import { internal } from "../_generated/api";
33 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";
55 import {
66 getMcpAuthConfig,
77 isAnonymousSessionId,
@@ -13,10 +13,21 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines
1313
1414 type McpEndpointMode = "default" | "anonymous";
1515
16−function isAnonymousAuthConfigured(): boolean {
17− const issuer = getAnonymousAuthIssuer();
18− const privateKeyPem = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
19− return Boolean(issuer && privateKeyPem && privateKeyPem.trim().length > 0);
16+function parseMcpApiKey(request: Request): string | null {
17+ const fromHeader = request.headers.get("x-api-key")?.trim();
18+ if (fromHeader) {
19+ return fromHeader;
20+ }
21+
22+ const authorization = request.headers.get("authorization") ?? "";
23+ if (authorization.startsWith("Bearer ")) {
24+ const bearer = authorization.slice("Bearer ".length).trim();
25+ if (bearer.length > 0) {
26+ return bearer;
27+ }
28+ }
29+
30+ return null;
2031 }
2132
2233 function createMcpHandler(mode: McpEndpointMode) {
@@ -47,32 +58,53 @@ function createMcpHandler(mode: McpEndpointMode) { · 24 unmodified lines
4758 );
4859 }
4960
50− const anonymousAuthConfigured = isAnonymousAuthConfigured();
51− if (!anonymousAuthConfigured) {
61+ if (!isMcpApiKeyConfigured()) {
5262 return Response.json(
53− { error: "Anonymous auth is not configured" },
63+ { error: "MCP API key signing is not configured" },
5464 { status: 503 },
5565 );
5666 }
5767
58− const identity = await ctx.auth.getUserIdentity().catch(() => null);
59− if (identity && isAnonymousIdentity(identity)) {
60− const access = await ctx.runQuery(internal.workspaceAuthInternal.getWorkspaceAccessForAnonymousSubject, {
61− workspaceId,
62− accountId: identity.subject,
63− });
64−
65− context = {
66− workspaceId,
67− accountId: access.accountId,
68− clientId: requestedContext?.clientId,
69− };
70− } else {
68+ const apiKey = parseMcpApiKey(request);
69+ if (!apiKey) {
70+ return Response.json(
71+ { error: "API key is required for /mcp/anonymous" },
72+ { status: 401 },
73+ );
74+ }
75+
76+ const apiKeyIdentity = await verifyMcpApiKey(apiKey);
77+ if (!apiKeyIdentity) {
7178 return Response.json(
72− { error: "Anonymous bearer token is required for /mcp/anonymous" },
79+ { error: "Invalid API key" },
7380 { status: 401 },
7481 );
7582 }
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+ };
76108 } catch (error) {
77109 return Response.json(
78110 { error: error instanceof Error ? error.message : "Workspace authorization failed" },
executor/packages/database/convex/workspace.ts+49−0
@@ -18,6 +18,11 @@ import { · 17 unmodified lines
1818 getWorkspaceInventoryProgressForContext,
1919 listToolDetailsForContext,
2020 } from "../src/runtime/workspace_tools";
21+import {
22+ issueMcpApiKey,
23+ isMcpApiKeyConfigured,
24+ MCP_API_KEY_ENV_NAME,
25+} from "../src/auth/mcp_api_key";
2126
2227 export const bootstrapAnonymousSession = customMutation({
2328 method: "POST",
@@ -31,6 +36,50 @@ export const bootstrapAnonymousSession = customMutation({ · 7 unmodified lines
3136 },
3237 });
3338
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+
3483 export const listTasks = workspaceQuery({
3584 method: "GET",
3685 args: {},
executor/packages/database/convex/workspaceAuthInternal.ts+23−0
@@ -80,3 +80,26 @@ export const getWorkspaceAccessForAnonymousSubject = internalQuery({ · 79 unmodified lines
8080 };
8181 },
8282 });
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+});
executor/packages/database/src/auth/identity.test.ts+91−1
@@ -1,5 +1,6 @@
11 import { expect, test } from "bun:test";
2−import { resolveIdentityProfile } from "./identity";
2+import type { FunctionReference } from "convex/server";
3+import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity";
34
45 test("resolveIdentityProfile uses stable user label when identity is sparse", () => {
56 const subject = "user_01KH1TVHS4WJCPQG2XQJGMMJMJ";
@@ -45,3 +46,92 @@ test("resolveIdentityProfile prefers explicit identity name", () => { · 39 unmodified lines
4546
4647 expect(profile.fullName).toBe("Alexandra D.");
4748 });
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+});
executor/packages/database/src/auth/identity.ts+153−16
@@ -4,9 +4,20 @@ import type { RunQueryCtx } from "./types"; · 3 unmodified lines
44
55 type WorkosProfile = {
66 email?: string;
7+ emailAddress?: string | null;
8+ name?: string | null;
9+ fullName?: string | null;
10+ displayName?: string | null;
711 firstName?: string | null;
812 lastName?: string | null;
13+ givenName?: string | null;
14+ familyName?: string | null;
15+ picture?: string | null;
16+ pictureUrl?: string | null;
17+ avatarUrl?: string | null;
918 profilePictureUrl?: string | null;
19+ metadata?: Record<string, unknown> | null;
20+ [key: string]: unknown;
1021 };
1122
1223 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
1526 return `User ${workosUserId.slice(-6)}`;
1627 }
1728
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+
1843 function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {
1944 for (const key of keys) {
2045 const parsedValue = nonEmptyTrimmedStringSchema.safeParse(identity[key]);
@@ -23,17 +48,113 @@ function getIdentityString(identity: Record<string, unknown>, keys: string[]): s · 2 unmodified lines
2348 }
2449 }
2550
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+
2684 return undefined;
2785 }
2886
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+
29140 export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {
30− try {
31− return await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
32− id: workosUserId,
33− });
34− } catch {
35− return null;
141+ const candidateIds = deriveWorkosUserIdCandidates(workosUserId);
142+
143+ for (const candidateId of candidateIds) {
144+ try {
145+ const user = await ctx.runQuery(components.workOSAuthKit.lib.getAuthUser, {
146+ id: candidateId,
147+ });
148+
149+ if (user) {
150+ return user;
151+ }
152+ } catch {
153+ continue;
154+ }
36155 }
156+
157+ return null;
37158 }
38159
39160 export function resolveIdentityProfile(args: {
@@ -43,27 +164,43 @@ export function resolveIdentityProfile(args: { · 3 unmodified lines
43164 const { identity, authKitProfile } = args;
44165
45166 const email =
46− authKitProfile?.email
47− ?? getIdentityString(identity, ["email", "upn"]);
167+ getProfileString(authKitProfile, ["email", "emailAddress", "email_address"])
168+ ?? getIdentityString(identity, ["email", "upn", "emailAddress"]);
48169
49− const firstName = authKitProfile?.firstName
50− ?? getIdentityString(identity, ["given_name", "first_name"]);
170+ const firstName = getProfileString(authKitProfile, ["firstName", "first_name", "givenName", "given_name"])
171+ ?? getIdentityString(identity, ["given_name", "first_name", "givenName", "firstName"]);
51172
52− const lastName = authKitProfile?.lastName
53− ?? getIdentityString(identity, ["family_name", "last_name"]);
173+ const lastName = getProfileString(authKitProfile, ["lastName", "last_name", "familyName", "family_name"])
174+ ?? getIdentityString(identity, ["family_name", "last_name", "familyName", "lastName"]);
54175
55176 const combinedName = [firstName, lastName].filter(Boolean).join(" ").trim();
56177
57178 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"])
59181 ?? (combinedName.length > 0 ? combinedName : undefined)
60182 ?? deriveFallbackUserLabel(identity.subject);
61183
62184 const avatarUrl =
63− authKitProfile?.profilePictureUrl
64− ?? getIdentityString(identity, ["picture", "avatar_url", "profile_picture_url"]);
185+ getProfileString(authKitProfile, [
186+ "profilePictureUrl",
187+ "profile_picture_url",
188+ "avatarUrl",
189+ "avatar_url",
190+ "pictureUrl",
191+ "picture_url",
192+ "picture",
193+ ])
194+ ?? getIdentityString(identity, [
195+ "picture",
196+ "avatar_url",
197+ "profile_picture_url",
198+ "pictureUrl",
199+ "avatarUrl",
200+ "profilePictureUrl",
201+ ]);
65202
66− const hintedWorkosOrgId = getIdentityString(identity, ["org_id", "organization_id"]);
203+ const hintedWorkosOrgId = getIdentityString(identity, ["org_id", "organization_id", "orgId", "organizationId"]);
67204
68205 return {
69206 email,
executor/packages/database/src/auth/mcp_api_key.test.ts+53−0
@@ -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+});
executor/packages/database/src/auth/mcp_api_key.ts+113−0
@@ -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+}