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
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=60480025# 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_...
executor/README.md+3−2
@@ -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
139MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured.139MCP bearer-token verification is enabled on `/mcp` when `MCP_AUTHORIZATION_SERVER` / `MCP_AUTHORIZATION_SERVER_URL` is configured.
140Anonymous 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`).
141Anonymous bearer tokens can still be minted from `/auth/anonymous/token` for Convex anonymous auth flows.
141 142
142## Configuration Reference143## Configuration Reference
143 144
@@ -222,6 +223,6 @@ executor/ · 78 unmodified lines
222## Troubleshooting223## 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.
executor/apps/web/src/components/dashboard/view.tsx+0−1
@@ -63,7 +63,6 @@ export function DashboardView() { · 62 unmodified lines
63 <McpSetupCard63 <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>
executor/apps/web/src/components/tools/install-configs.ts+27−306
@@ -1,308 +1,29 @@
1export type McpProviderConfig = {1export 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
15export const MCP_PROVIDERS: McpProviderConfig[] = [7export 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];
executor/apps/web/src/components/tools/setup-card.tsx+68−117
@@ -1,27 +1,13 @@
1"use client";1"use client";
2 2
3import { useMemo, useState } from "react";3import { useMemo, useState } from "react";
4import { Check, ChevronRight, Copy } from "lucide-react";4import { useQuery } from "convex/react";
5import Image from "next/image";5import { Check, Copy } from "lucide-react";
6import { useQuery as useTanstackQuery } from "@tanstack/react-query";
7import { Button } from "@/components/ui/button";6import { Button } from "@/components/ui/button";
8import {
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12} from "@/components/ui/collapsible";
13import { Input } from "@/components/ui/input";7import { Input } from "@/components/ui/input";
14import { Label } from "@/components/ui/label";8import { Label } from "@/components/ui/label";
15import {9import { getAddMcpInstallConfig } from "@/components/tools/install-configs";
16 Select,10import { convexApi } from "@/lib/convex-api";
17 SelectContent,
18 SelectItem,
19 SelectTrigger,
20 SelectValue,
21} from "@/components/ui/select";
22import { MCP_PROVIDERS } from "@/components/tools/install-configs";
23import { getAnonymousAuthToken } from "@/lib/anonymous-auth";
24import { cn } from "@/lib/utils";
25 11
26function inferServerName(workspaceId?: string): string {12function 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
22function isWorkosSessionId(sessionId?: string): boolean {
23 if (!sessionId) return false;
24 return sessionId.startsWith("workos_");
25}
26
36function resolveMcpOrigin(windowOrigin: string): string {27function 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
58export function McpSetupCard({49export 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.isError90 const apiKeyValue = anonymousMcpApiKey?.enabled
102 ? anonymousTokenQuery.error instanceof Error91 ? anonymousMcpApiKey.apiKey
103 ? anonymousTokenQuery.error.message92 : 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 <ChevronRight145 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 <Button171 <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>
executor/apps/web/src/lib/convex-provider.tsx+59−2
@@ -24,10 +24,66 @@ const convexClient = new ConvexReactClient(convexUrl, { · 23 unmodified lines
24 unsavedChangesWarning: false,24 unsavedChangesWarning: false,
25});25});
26 26
27type WorkosAuthProfile = {
28 name?: string;
29 email?: string;
30 avatarUrl?: string | null;
31};
32
33function 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
42function 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
53function 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. */
28const WorkosAuthContext = createContext({83const WorkosAuthContext = createContext({
29 loading: false,84 loading: false,
30 authenticated: false,85 authenticated: false,
86 profile: null as WorkosAuthProfile | null,
31});87});
32 88
33export function useWorkosAuthState() {89export 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>
executor/apps/web/src/lib/session-context.tsx+33−9
@@ -34,6 +34,14 @@ import { clearAnonymousAuth } from "@/lib/anonymous-auth"; · 33 unmodified lines
34import type { AnonymousContext } from "./types";34import type { AnonymousContext } from "./types";
35import type { Id } from "@executor/database/convex/_generated/dataModel";35import type { Id } from "@executor/database/convex/_generated/dataModel";
36 36
37function 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
37interface SessionState {45interface 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
105export function SessionProvider({ children }: { children: ReactNode }) {113export 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.Provider355 <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 >
executor/packages/core/src/identity.ts+13−10
@@ -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.db43 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;
executor/packages/core/src/openapi/real-specs.test.ts+545−0
@@ -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 */
9import { test, expect, describe } from "bun:test";9import { test, expect, describe } from "bun:test";
10import SwaggerParser from "@apidevtools/swagger-parser";
10import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources";11import { prepareOpenApiSpec, buildOpenApiToolsFromPrepared } from "../tool-sources";
11 12
12interface SpecFixture {13interface SpecFixture {
@@ -18,6 +19,408 @@ interface SpecFixture { · 5 unmodified lines
18 expectDts: boolean;19 expectDts: boolean;
19}20}
20 21
22type PrimitiveParamType = "string" | "number" | "boolean";
23
24const HTTP_METHODS = ["get", "post", "put", "delete", "patch", "head", "options"] as const;
25const JSON_CONTENT_TYPES = ["application/json", "application/*+json", "text/json"] as const;
26
27interface 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
35function toRecord(value: unknown): Record<string, unknown> {
36 return value && typeof value === "object" && !Array.isArray(value)
37 ? value as Record<string, unknown>
38 : {};
39}
40
41function 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
63function 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
77function 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
91function 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
105function 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
121function 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
230function 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
395function 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
417function containsUnknownTypeToken(segment: string): boolean {
418 const withoutQuotedLiterals = segment
419 .replace(/"(?:[^"\\]|\\.)*"/g, "")
420 .replace(/'(?:[^'\\]|\\.)*'/g, "");
421 return /\bunknown\b/.test(withoutQuotedLiterals);
422}
423
21const SPECS: SpecFixture[] = [424const 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});
executor/packages/core/src/openapi/schema-hints.ts+71−7
@@ -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
305function 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
345function 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
305function extractStringLiteralFromSchema(schema: JsonSchema): string | null {361function 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 = propEntries1023 const inner = propEntries
960 .slice(0, maxInlineProps)1024 .slice(0, maxInlineProps)
executor/packages/database/convex/_generated/api.d.ts+12−0
@@ -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 any317 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",
executor/packages/database/convex/access-controls.test.ts+15−0
@@ -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
313describe("workspace access controls", () => {328describe("workspace access controls", () => {
executor/packages/database/convex/http/mcp_handler.ts+54−22
@@ -1,7 +1,7 @@
1import { httpAction } from "../_generated/server";1import { httpAction } from "../_generated/server";
2import { internal } from "../_generated/api";2import { internal } from "../_generated/api";
3import { handleMcpRequest, type McpWorkspaceContext } from "../../../core/src/mcp-server";3import { handleMcpRequest, type McpWorkspaceContext } from "../../../core/src/mcp-server";
4import { getAnonymousAuthIssuer, isAnonymousIdentity } from "../../src/auth/anonymous";4import { isMcpApiKeyConfigured, verifyMcpApiKey } from "../../src/auth/mcp_api_key";
5import {5import {
6 getMcpAuthConfig,6 getMcpAuthConfig,
7 isAnonymousSessionId,7 isAnonymousSessionId,
@@ -13,10 +13,21 @@ import { createMcpExecutorService } from "./mcp_service"; · 5 unmodified lines
13 13
14type McpEndpointMode = "default" | "anonymous";14type McpEndpointMode = "default" | "anonymous";
15 15
16function isAnonymousAuthConfigured(): boolean {16function 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
22function createMcpHandler(mode: McpEndpointMode) {33function 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" },
executor/packages/database/convex/workspace.ts+49−0
@@ -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";
21import {
22 issueMcpApiKey,
23 isMcpApiKeyConfigured,
24 MCP_API_KEY_ENV_NAME,
25} from "../src/auth/mcp_api_key";
21 26
22export const bootstrapAnonymousSession = customMutation({27export 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
39export 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
34export const listTasks = workspaceQuery({83export const listTasks = workspaceQuery({
35 method: "GET",84 method: "GET",
36 args: {},85 args: {},
executor/packages/database/convex/workspaceAuthInternal.ts+23−0
@@ -80,3 +80,26 @@ export const getWorkspaceAccessForAnonymousSubject = internalQuery({ · 79 unmodified lines
80 };80 };
81 },81 },
82});82});
83
84export 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 @@
1import { expect, test } from "bun:test";1import { expect, test } from "bun:test";
2import { resolveIdentityProfile } from "./identity";2import type { FunctionReference } from "convex/server";
3import { getAuthKitUserProfile, resolveIdentityProfile } from "./identity";
3 4
4test("resolveIdentityProfile uses stable user label when identity is sparse", () => {5test("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
50test("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
71test("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
91test("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
113test("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
4 4
5type WorkosProfile = {5type 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
12const nonEmptyTrimmedStringSchema = z.string().transform((value) => value.trim()).refine((value) => value.length > 0);23const 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
29function normalizeClaimKey(key: string): string {
30 return key.trim().toLowerCase();
31}
32
33function 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
18function getIdentityString(identity: Record<string, unknown>, keys: string[]): string | undefined {43function 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
87function 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
114function 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
29export async function getAuthKitUserProfile(ctx: RunQueryCtx, workosUserId: string) {140export 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
39export function resolveIdentityProfile(args: {160export 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?.email167 getProfileString(authKitProfile, ["email", "emailAddress", "email_address"])
47 ?? getIdentityString(identity, ["email", "upn"]);168 ?? getIdentityString(identity, ["email", "upn", "emailAddress"]);
48 169
49 const firstName = authKitProfile?.firstName170 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?.lastName173 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?.profilePictureUrl185 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,
executor/packages/database/src/auth/mcp_api_key.test.ts+53−0
@@ -0,0 +1,53 @@
1import { afterEach, expect, test } from "bun:test";
2import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import { issueMcpApiKey, verifyMcpApiKey } from "./mcp_api_key";
4
5const originalMcpApiKeySecret = process.env.MCP_API_KEY_SECRET;
6const originalAnonymousPrivateKey = process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM;
7
8afterEach(() => {
9 process.env.MCP_API_KEY_SECRET = originalMcpApiKeySecret;
10 process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = originalAnonymousPrivateKey;
11});
12
13test("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
29test("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
43test("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 @@
1import { SignJWT, jwtVerify } from "jose";
2import type { Id } from "../../convex/_generated/dataModel.d.ts";
3import { getAnonymousAuthIssuer } from "./anonymous";
4
5export const MCP_API_KEY_ENV_NAME = "API_KEY";
6const MCP_API_KEY_AUDIENCE = "executor-mcp";
7const MCP_API_KEY_ALGORITHM = "HS256";
8
9export type VerifiedMcpApiKey = {
10 workspaceId: Id<"workspaces">;
11 accountId: Id<"accounts">;
12};
13
14function trimOrNull(value: string | undefined): string | null {
15 const trimmed = value?.trim();
16 return trimmed && trimmed.length > 0 ? trimmed : null;
17}
18
19function normalizeSecret(raw: string): string {
20 return raw.replace(/\\n/g, "\n").trim();
21}
22
23function 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
37function getIssuer(): string | null {
38 return getAnonymousAuthIssuer();
39}
40
41function getSecretBytes(secret: string): Uint8Array {
42 return new TextEncoder().encode(secret);
43}
44
45export function isMcpApiKeyConfigured(): boolean {
46 return Boolean(getConfiguredSecret());
47}
48
49export 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
75export 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}