History

Refactor OAuth handling in source management components to support both MCP and OpenAPI types. This includes renaming variables for clarity, updating function names, and enhancing error handling during OAuth connection processes. Additionally, adjustments were made to the UI components to reflect these changes, improving the overall user experience.

Rhys Sullivan committed 9 months ago

Commit b04024d1 parent 7186c5618 files · +307 −659Browse files

Unified · Split

18 files
  1. executor/apps/web/src/app/mcp/oauth/detect/route.ts +11 −3
  2. executor/apps/web/src/components/tools/add/source-dialog.tsx +25 −15
  3. executor/apps/web/src/components/tools/add/source/auth-panel.tsx +42 −41
  4. executor/apps/web/src/components/tools/add/source/form-utils.ts +2 −2
  5. executor/apps/web/src/components/tools/use/add/source/form-state.ts +44 −39
  6. executor/apps/web/src/lib/openapi/spec-inspector.ts +181 −0
  7. executor/packages/core/src/mcp-server.test.ts +0 −1
  8. executor/packages/core/src/types.ts +0 −1
  9. executor/packages/database/convex/_generated/api.d.ts +0 −60
  10. executor/packages/database/convex/database.test.ts +0 −2
  11. executor/packages/database/convex/database.ts +1 −1
  12. executor/packages/database/convex/database/anonymous_session.ts +0 −76
  13. executor/packages/database/convex/executor-mcp.e2e.test.ts +0 −232
  14. executor/packages/database/convex/mcp/run/code-oom.e2e.test.ts +0 −127
  15. executor/packages/database/convex/migrations.ts +0 −44
  16. executor/packages/database/convex/schema.ts +1 −2
  17. executor/packages/database/src/database/anonymous.ts +0 −12
  18. executor/packages/database/src/database/readers.ts +0 −1
executor/apps/web/src/app/mcp/oauth/detect/route.ts+11−3
@@ -89,14 +89,21 @@ async function probeOAuthChallenge(sourceUrl: URL): Promise<boolean> { · 88 unmodified lines
8989 },
9090 );
9191
92− const hasBearerChallenge = /^Bearer\s/i.test(response.headers.get("WWW-Authenticate") ?? "");
93− const challenge = extractWWWAuthenticateParams(response);
92+ const challengeHeader = response.headers.get("WWW-Authenticate") ?? "";
93+ const hasBearerChallenge = /^Bearer\s/i.test(challengeHeader);
9494 await response.body?.cancel();
9595
9696 if ((response.status === 401 || response.status === 403) && hasBearerChallenge) {
9797 return true;
9898 }
9999
100+ let challenge: ReturnType<typeof extractWWWAuthenticateParams>;
101+ try {
102+ challenge = extractWWWAuthenticateParams(response);
103+ } catch {
104+ return false;
105+ }
106+
100107 return Boolean(challenge.resourceMetadataUrl);
101108 }
102109
@@ -146,11 +153,12 @@ export async function GET(request: NextRequest) { · 43 unmodified lines
146153 const probeDetail = challengeProbeResult.isOk()
147154 ? ""
148155 : `; challenge probe: ${resultErrorMessage(challengeProbeResult.error, "failed")}`;
156+
149157 return noStoreJson({
150158 oauth: false,
151159 authorizationServers: [],
152160 detail: `${resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed")}${probeDetail}`,
153− }, 502);
161+ });
154162 }
155163
156164 const metadata = metadataResult.value;
executor/apps/web/src/components/tools/add/source-dialog.tsx+25−15
@@ -113,7 +113,7 @@ export function AddSourceDialog({ · 112 unmodified lines
113113 const credentialsLoading = Boolean(context) && credentials === undefined;
114114 const [open, setOpen] = useState(false);
115115 const [submitting, setSubmitting] = useState(false);
116− const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);
116+ const [sourceOAuthBusy, setSourceOAuthBusy] = useState(false);
117117 const [deleting, setDeleting] = useState(false);
118118
119119 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
@@ -218,20 +218,30 @@ export function AddSourceDialog({ · 98 unmodified lines
218218 setOpen(false);
219219 };
220220
221− const handleMcpOAuthConnect = async () => {
222− if (form.type !== "mcp") {
221+ const sourceOAuthConnectUrl = (): string => {
222+ if (form.type === "openapi") {
223+ return form.baseUrl.trim() || form.endpoint.trim();
224+ }
225+
226+ return form.endpoint.trim();
227+ };
228+
229+ const handleSourceOAuthConnect = async () => {
230+ if (form.type !== "mcp" && form.type !== "openapi") {
223231 return;
224232 }
225− const endpoint = form.endpoint.trim();
233+
234+ const endpoint = sourceOAuthConnectUrl();
226235 if (!endpoint) {
227− toast.error("Enter an MCP endpoint URL first");
236+ const fieldName = form.type === "openapi" ? "spec or API base URL" : "endpoint URL";
237+ toast.error(`Enter a ${fieldName} first`);
228238 return;
229239 }
230240 const initiatedEndpoint = normalizeEndpoint(endpoint);
231241
232− setMcpOAuthBusy(true);
242+ setSourceOAuthBusy(true);
233243 const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));
234− setMcpOAuthBusy(false);
244+ setSourceOAuthBusy(false);
235245
236246 if (!oauthResult.isOk()) {
237247 toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));
@@ -244,7 +254,7 @@ export function AddSourceDialog({ · 6 unmodified lines
244254 return;
245255 }
246256
247− const currentEndpoint = normalizeEndpoint(form.endpoint);
257+ const currentEndpoint = normalizeEndpoint(sourceOAuthConnectUrl());
248258 if (currentEndpoint !== initiatedEndpoint) {
249259 toast.error("Endpoint changed while OAuth was running. Please reconnect OAuth.");
250260 return;
@@ -254,7 +264,7 @@ export function AddSourceDialog({ · 3 unmodified lines
254264 form.handleAuthTypeChange("bearer");
255265 }
256266 form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
257− form.markMcpOAuthLinked(initiatedEndpoint);
267+ form.markSourceOAuthLinked(initiatedEndpoint);
258268 toast.success("OAuth linked successfully.");
259269 };
260270
@@ -445,10 +455,10 @@ export function AddSourceDialog({ · 184 unmodified lines
445455 specStatus: form.specStatus,
446456 inferredSpecAuth: form.inferredSpecAuth,
447457 specError: form.specError,
448− mcpOAuthStatus: form.mcpOAuthStatus,
449− mcpOAuthDetail: form.mcpOAuthDetail,
450− mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers,
451− mcpOAuthConnected: form.mcpOAuthConnected,
458+ sourceOAuthStatus: form.sourceOAuthStatus,
459+ sourceOAuthDetail: form.sourceOAuthDetail,
460+ sourceOAuthAuthorizationServers: form.sourceOAuthAuthorizationServers,
461+ sourceOAuthConnected: form.sourceOAuthConnected,
452462 authType: form.authType,
453463 scopeType: form.scopeType,
454464 authScope: form.authScope,
@@ -462,8 +472,8 @@ export function AddSourceDialog({ · 7 unmodified lines
462472 onAuthTypeChange={form.handleAuthTypeChange}
463473 onScopeChange={form.handleScopePresetChange}
464474 onFieldChange={form.handleAuthFieldChange}
465− onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined}
466− mcpOAuthBusy={mcpOAuthBusy}
475+ onSourceOAuthConnect={form.type === "mcp" || form.type === "openapi" ? handleSourceOAuthConnect : undefined}
476+ sourceOAuthBusy={sourceOAuthBusy}
467477 />
468478 </CustomViewSection>
469479 )}
executor/apps/web/src/components/tools/add/source/auth-panel.tsx+42−41
@@ -36,10 +36,10 @@ export type SourceAuthPanelModel = { · 35 unmodified lines
3636 specStatus: "idle" | "detecting" | "ready" | "error";
3737 inferredSpecAuth: InferredSpecAuth | null;
3838 specError: string;
39− mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";
40− mcpOAuthDetail: string;
41− mcpOAuthAuthorizationServers: string[];
42− mcpOAuthConnected: boolean;
39+ sourceOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";
40+ sourceOAuthDetail: string;
41+ sourceOAuthAuthorizationServers: string[];
42+ sourceOAuthConnected: boolean;
4343 authType: Exclude<SourceAuthType, "mixed">;
4444 scopeType: "organization" | "workspace";
4545 authScope: CredentialScope;
@@ -75,25 +75,25 @@ export function SourceAuthPanel({ · 29 unmodified lines
7575 onAuthTypeChange,
7676 onScopeChange,
7777 onFieldChange,
78− onMcpOAuthConnect,
79− mcpOAuthBusy = false,
78+ onSourceOAuthConnect,
79+ sourceOAuthBusy = false,
8080 }: {
8181 model: SourceAuthPanelModel;
8282 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;
8383 onScopeChange: (value: SharingScope) => void;
8484 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;
85− onMcpOAuthConnect?: () => void;
86− mcpOAuthBusy?: boolean;
85+ onSourceOAuthConnect?: () => void;
86+ sourceOAuthBusy?: boolean;
8787 }) {
8888 const {
8989 sourceType,
9090 specStatus,
9191 inferredSpecAuth,
9292 specError,
93− mcpOAuthStatus,
94− mcpOAuthDetail,
95− mcpOAuthAuthorizationServers,
96− mcpOAuthConnected,
93+ sourceOAuthStatus,
94+ sourceOAuthDetail,
95+ sourceOAuthAuthorizationServers,
96+ sourceOAuthConnected,
9797 authType,
9898 apiKeyHeader,
9999 tokenValue,
@@ -109,10 +109,11 @@ export function SourceAuthPanel({ · 9 unmodified lines
109109 }
110110
111111 const badge = inferredAuthBadge(inferredSpecAuth);
112− const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected;
113− const mcpOAuthLoading = sourceType === "mcp" && mcpOAuthStatus === "checking";
114− const mcpOAuthDetected = sourceType === "mcp" && mcpOAuthStatus === "oauth";
115− const useMcpOAuthFlow = mcpOAuthLoading || mcpOAuthDetected;
112+ const usesSourceOAuthFlow = sourceType === "mcp" || sourceType === "openapi";
113+ const bearerOAuthConnected = usesSourceOAuthFlow && authType === "bearer" && sourceOAuthConnected;
114+ const sourceOAuthLoading = usesSourceOAuthFlow && sourceOAuthStatus === "checking";
115+ const sourceOAuthDetected = usesSourceOAuthFlow && sourceOAuthStatus === "oauth";
116+ const useSourceOAuthFlow = sourceOAuthLoading || sourceOAuthDetected;
116117 return (
117118 <div className="space-y-3">
118119
@@ -132,32 +133,32 @@ export function SourceAuthPanel({ · 13 unmodified lines
132133 </div>
133134 ) : null}
134135
135− {sourceType === "mcp" ? (
136+ {usesSourceOAuthFlow ? (
136137 <div className="flex items-center gap-2 flex-wrap">
137138 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
138− {mcpOAuthStatus === "idle"
139+ {sourceOAuthStatus === "idle"
139140 ? "Awaiting URL"
140− : mcpOAuthStatus === "checking"
141− ? "Checking OAuth"
142− : mcpOAuthStatus === "oauth"
143− ? "OAuth detected"
144− : mcpOAuthStatus === "error"
145− ? "OAuth unknown"
146− : "No OAuth metadata"}
141+ : sourceOAuthStatus === "checking"
142+ ? "Checking OAuth"
143+ : sourceOAuthStatus === "oauth"
144+ ? "OAuth detected"
145+ : sourceOAuthStatus === "error"
146+ ? "OAuth unknown"
147+ : "No OAuth metadata"}
147148 </Badge>
148− {mcpOAuthAuthorizationServers.length > 0 ? (
149+ {sourceOAuthAuthorizationServers.length > 0 ? (
149150 <span className="text-[10px] text-muted-foreground">
150− {mcpOAuthAuthorizationServers[0]}
151+ {sourceOAuthAuthorizationServers[0]}
151152 </span>
152153 ) : null}
153− {mcpOAuthStatus === "error" && mcpOAuthDetail ? (
154− <span className="text-[10px] text-terminal-amber">{mcpOAuthDetail}</span>
154+ {sourceOAuthStatus === "error" && sourceOAuthDetail ? (
155+ <span className="text-[10px] text-terminal-amber">{sourceOAuthDetail}</span>
155156 ) : null}
156157 </div>
157158 ) : null}
158159
159− <div className={`grid ${useMcpOAuthFlow ? "grid-cols-1" : "grid-cols-2"} gap-3`}>
160− {!useMcpOAuthFlow ? (
160+ <div className={`grid ${useSourceOAuthFlow ? "grid-cols-1" : "grid-cols-2"} gap-3`}>
161+ {!useSourceOAuthFlow ? (
161162 <div className="space-y-1.5">
162163 <Label className="text-xs text-muted-foreground">Auth Type</Label>
163164 <Select value={authType} onValueChange={(value) => onAuthTypeChange(value as Exclude<SourceAuthType, "mixed">)}>
@@ -193,17 +194,17 @@ export function SourceAuthPanel({ · 29 unmodified lines
193194 </div>
194195 </div>
195196
196− {sourceType === "mcp" && useMcpOAuthFlow && onMcpOAuthConnect ? (
197+ {usesSourceOAuthFlow && useSourceOAuthFlow && onSourceOAuthConnect ? (
197198 <div className="space-y-1.5">
198199 <div className="flex items-center gap-2">
199200 <Label className="text-xs text-muted-foreground">OAuth</Label>
200− {mcpBearerConnected ? (
201+ {bearerOAuthConnected ? (
201202 <Badge variant="outline" className="text-[10px] uppercase tracking-wider text-terminal-green">
202203 Connected
203204 </Badge>
204205 ) : null}
205206 </div>
206− {mcpOAuthLoading ? (
207+ {sourceOAuthLoading ? (
207208 <Skeleton className="h-8 w-full" />
208209 ) : (
209210 <Button
@@ -211,13 +212,13 @@ export function SourceAuthPanel({ · 1 unmodified line
211212 variant="outline"
212213 size="sm"
213214 className="h-8 text-xs"
214− disabled={mcpOAuthBusy}
215− onClick={onMcpOAuthConnect}
215+ disabled={sourceOAuthBusy}
216+ onClick={onSourceOAuthConnect}
216217 >
217− {mcpOAuthBusy ? "Connecting..." : mcpBearerConnected ? "Reconnect OAuth" : "Connect OAuth in popup"}
218+ {sourceOAuthBusy ? "Connecting..." : bearerOAuthConnected ? "Reconnect OAuth" : "Connect OAuth in popup"}
218219 </Button>
219220 )}
220− {mcpBearerConnected ? (
221+ {bearerOAuthConnected ? (
221222 <p className="text-[11px] text-muted-foreground">OAuth linked successfully.</p>
222223 ) : null}
223224 </div>
@@ -235,7 +236,7 @@ export function SourceAuthPanel({ · 11 unmodified lines
235236 </div>
236237 ) : null}
237238
238− {authType === "bearer" && sourceType !== "mcp" && !mcpBearerConnected ? (
239+ {authType === "bearer" && !usesSourceOAuthFlow ? (
239240 <div className="space-y-1.5">
240241 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
241242 <LockKeyhole className="h-3 w-3" />
@@ -251,7 +252,7 @@ export function SourceAuthPanel({ · 9 unmodified lines
251252 </div>
252253 ) : null}
253254
254− {authType === "apiKey" && !useMcpOAuthFlow ? (
255+ {authType === "apiKey" && !useSourceOAuthFlow ? (
255256 <div className="space-y-1.5">
256257 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
257258 <KeyRound className="h-3 w-3" />
@@ -267,7 +268,7 @@ export function SourceAuthPanel({ · 9 unmodified lines
267268 </div>
268269 ) : null}
269270
270− {authType === "basic" && !useMcpOAuthFlow ? (
271+ {authType === "basic" && !useSourceOAuthFlow ? (
271272 <div className="grid grid-cols-2 gap-3">
272273 <div className="space-y-1.5">
273274 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
executor/apps/web/src/components/tools/add/source/form-utils.ts+2−2
@@ -53,7 +53,7 @@ export type AddSourceUiState = { · 52 unmodified lines
5353 locallyReservedNames: string[];
5454 authManuallyEdited: boolean;
5555 authRevision: number;
56− mcpOAuthLinkedEndpoint: string | null;
56+ sourceOAuthLinkedEndpoint: string | null;
5757 };
5858
5959 export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {
@@ -65,7 +65,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines
6565 locallyReservedNames: [],
6666 authManuallyEdited: false,
6767 authRevision: 0,
68− mcpOAuthLinkedEndpoint: null,
68+ sourceOAuthLinkedEndpoint: null,
6969 };
7070 }
7171
executor/apps/web/src/components/tools/use/add/source/form-state.ts+44−39
@@ -144,8 +144,8 @@ export function useAddSourceFormState({ · 143 unmodified lines
144144 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
145145 }, [accountId, credentialItems, editingSourceKey, values.authScope, values.scopeType]);
146146
147− const hasPersistedMcpBearerToken = useMemo(() => {
148− if (values.type !== "mcp" || values.authType !== "bearer") {
147+ const hasPersistedSourceBearerToken = useMemo(() => {
148+ if ((values.type !== "mcp" && values.type !== "openapi") || values.authType !== "bearer") {
149149 return false;
150150 }
151151 if (!existingScopedCredential) {
@@ -192,7 +192,7 @@ export function useAddSourceFormState({ · 40 unmodified lines
192192
193193 const handleEndpointChange = (endpoint: string) => {
194194 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });
195− patchUi(setUi, { mcpOAuthLinkedEndpoint: null });
195+ patchUi(setUi, { sourceOAuthLinkedEndpoint: null });
196196
197197 if (values.type === "openapi") {
198198 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);
@@ -246,7 +246,7 @@ export function useAddSourceFormState({ · 47 unmodified lines
246246 patchUi(setUi, {
247247 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],
248248 authManuallyEdited: false,
249− mcpOAuthLinkedEndpoint: null,
249+ sourceOAuthLinkedEndpoint: null,
250250 });
251251 if (!baseUrlManuallyEdited) {
252252 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });
@@ -257,7 +257,7 @@ export function useAddSourceFormState({ · 4 unmodified lines
257257 patchUiWithAuthRevision(setUi, {
258258 openApiBaseUrlOptions: [],
259259 authManuallyEdited: false,
260− mcpOAuthLinkedEndpoint: null,
260+ sourceOAuthLinkedEndpoint: null,
261261 });
262262 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });
263263 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
@@ -274,7 +274,7 @@ export function useAddSourceFormState({ · 10 unmodified lines
274274 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });
275275 patchUiWithAuthRevision(setUi, {
276276 authManuallyEdited: true,
277− ...(authType === "bearer" ? {} : { mcpOAuthLinkedEndpoint: null }),
277+ ...(authType === "bearer" ? {} : { sourceOAuthLinkedEndpoint: null }),
278278 });
279279 };
280280
@@ -282,7 +282,7 @@ export function useAddSourceFormState({ · 1 unmodified line
282282 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });
283283 patchUiWithAuthRevision(setUi, {
284284 authManuallyEdited: true,
285− mcpOAuthLinkedEndpoint: null,
285+ sourceOAuthLinkedEndpoint: null,
286286 });
287287 };
288288
@@ -295,7 +295,7 @@ export function useAddSourceFormState({ · 6 unmodified lines
295295
296296 if (field === "tokenValue") {
297297 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });
298− patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null });
298+ patchUiWithAuthRevision(setUi, { sourceOAuthLinkedEndpoint: null });
299299 return;
300300 }
301301 if (field === "apiKeyValue") {
@@ -394,19 +394,21 @@ export function useAddSourceFormState({ · 92 unmodified lines
394394 : "Failed to fetch spec"
395395 : "";
396396
397− const mcpDetectionEndpoint = useDeferredValue(values.endpoint.trim());
398− const mcpDetectionEnabled = open
397+ const sourceOAuthDetectionEndpoint = useDeferredValue(values.type === "openapi"
398+ ? (values.baseUrl.trim() || values.endpoint).trim()
399+ : values.endpoint.trim());
400+ const sourceOAuthDetectionEnabled = open
399401 && ui.view === "custom"
400− && values.type === "mcp"
401− && mcpDetectionEndpoint.length > 0;
402+ && (values.type === "mcp" || values.type === "openapi")
403+ && sourceOAuthDetectionEndpoint.length > 0;
402404
403− const mcpOAuthQuery = useTanstackQuery({
404− queryKey: ["mcp-oauth-detect", mcpDetectionEndpoint],
405+ const sourceOAuthQuery = useTanstackQuery({
406+ queryKey: ["mcp-oauth-detect", sourceOAuthDetectionEndpoint],
405407 queryFn: async () => {
406408 const controller = new AbortController();
407409 const timeoutId = setTimeout(() => controller.abort(), MCP_OAUTH_DETECT_REQUEST_TIMEOUT_MS);
408410 try {
409− const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(mcpDetectionEndpoint)}`, {
411+ const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(sourceOAuthDetectionEndpoint)}`, {
410412 signal: controller.signal,
411413 cache: "no-store",
412414 });
@@ -437,34 +439,37 @@ export function useAddSourceFormState({ · 24 unmodified lines
437439 clearTimeout(timeoutId);
438440 }
439441 },
440− enabled: mcpDetectionEnabled,
442+ enabled: sourceOAuthDetectionEnabled,
441443 retry: false,
442444 staleTime: 60_000,
443445 });
444446
445− const mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !mcpDetectionEnabled
447+ const sourceOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !sourceOAuthDetectionEnabled
446448 ? "idle"
447− : mcpOAuthQuery.isFetching
449+ : sourceOAuthQuery.isFetching
448450 ? "checking"
449− : mcpOAuthQuery.isError
451+ : sourceOAuthQuery.isError
450452 ? "error"
451− : mcpOAuthQuery.data?.oauth
453+ : sourceOAuthQuery.data?.oauth
452454 ? "oauth"
453455 : "none";
454456
455− const mcpOAuthDetail = mcpDetectionEnabled
456− ? mcpOAuthQuery.isError
457− ? mcpOAuthQuery.error instanceof Error
458− ? mcpOAuthQuery.error.message
457+ const sourceOAuthDetail = sourceOAuthDetectionEnabled
458+ ? sourceOAuthQuery.isError
459+ ? sourceOAuthQuery.error instanceof Error
460+ ? sourceOAuthQuery.error.message
459461 : "OAuth detection failed"
460− : mcpOAuthQuery.data?.detail ?? ""
462+ : sourceOAuthQuery.data?.detail ?? ""
461463 : "";
462464
463− const mcpOAuthAuthorizationServers = mcpOAuthQuery.data?.authorizationServers ?? [];
464− const mcpOAuthConnected = values.type === "mcp"
465+ const sourceOAuthAuthorizationServers = sourceOAuthQuery.data?.authorizationServers ?? [];
466+ const sourceOAuthConnectedEndpoint = values.type === "openapi"
467+ ? values.baseUrl.trim() || values.endpoint
468+ : values.endpoint;
469+ const sourceOAuthConnected = (values.type === "mcp" || values.type === "openapi")
465470 && values.authType === "bearer"
466− && normalizeEndpointForOAuth(values.endpoint) !== ""
467− && normalizeEndpointForOAuth(values.endpoint) === ui.mcpOAuthLinkedEndpoint
471+ && normalizeEndpointForOAuth(sourceOAuthConnectedEndpoint) !== ""
472+ && normalizeEndpointForOAuth(sourceOAuthConnectedEndpoint) === ui.sourceOAuthLinkedEndpoint
468473 && values.tokenValue.trim().length > 0;
469474
470475 useEffect(() => {
@@ -525,10 +530,10 @@ export function useAddSourceFormState({ · 54 unmodified lines
525530 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);
526531
527532 useEffect(() => {
528− if (values.type !== "mcp") {
533+ if (values.type !== "mcp" && values.type !== "openapi") {
529534 return;
530535 }
531− if (mcpOAuthStatus !== "oauth") {
536+ if (sourceOAuthStatus !== "oauth") {
532537 return;
533538 }
534539 if (values.authType !== "bearer") {
@@ -537,7 +542,7 @@ export function useAddSourceFormState({ · 2 unmodified lines
537542 if (values.authScope !== "workspace") {
538543 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
539544 }
540− }, [form, mcpOAuthStatus, values.authScope, values.authType, values.type]);
545+ }, [form, sourceOAuthStatus, values.authScope, values.authType, values.type]);
541546
542547 const isNameTaken = (candidate: string) => {
543548 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
@@ -570,11 +575,11 @@ export function useAddSourceFormState({ · 26 unmodified lines
570575 visibleCatalogItems,
571576 specStatus,
572577 specError,
573− mcpOAuthStatus,
574− mcpOAuthDetail,
575− mcpOAuthAuthorizationServers,
576− mcpOAuthConnected,
577− hasPersistedMcpBearerToken,
578+ sourceOAuthStatus,
579+ sourceOAuthDetail,
580+ sourceOAuthAuthorizationServers,
581+ sourceOAuthConnected,
582+ hasPersistedSourceBearerToken,
578583 inferredSpecAuth,
579584 authType: values.authType,
580585 authScope: values.authScope,
@@ -595,9 +600,9 @@ export function useAddSourceFormState({ · 14 unmodified lines
595600 handleAuthScopeChange,
596601 handleScopePresetChange: (scopePreset: SharingScope) => applySharingScope(form, scopePreset),
597602 handleAuthFieldChange,
598− markMcpOAuthLinked: (endpoint: string) =>
603+ markSourceOAuthLinked: (endpoint: string) =>
599604 patchUi(setUi, {
600− mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),
605+ sourceOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),
601606 }),
602607 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),
603608 setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") =>
executor/apps/web/src/lib/openapi/spec-inspector.ts+181−0
@@ -2,6 +2,7 @@ import { Result } from "better-result"; · 1 unmodified line
22 import { parse as parseYaml } from "yaml";
33 import { z } from "zod";
44 import type { CredentialScope, SourceAuthType } from "@/lib/types";
5+import { HARD_CODED_CATALOG_ITEMS } from "@/lib/catalog-collections";
56
67 type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;
78
@@ -17,6 +18,147 @@ type OpenApiInspectionResult = { · 9 unmodified lines
1718 inferredAuth: InferredSpecAuth;
1819 };
1920
21+export type OpenApiSpecDiscoveryResult = OpenApiInspectionResult & {
22+ specUrl: string;
23+ discovered: boolean;
24+ attemptedSpecUrls: string[];
25+};
26+
27+const OPENAPI_SPEC_PATH_HINTS = [
28+ "/openapi.json",
29+ "/openapi.yaml",
30+ "/openapi.yml",
31+ "/swagger.json",
32+ "/swagger.yaml",
33+ "/swagger.yml",
34+ "/api-docs",
35+ "/api-docs.json",
36+ "/api-docs.yaml",
37+ "/api-docs.yml",
38+ "/v3/api-docs",
39+ "/v2/api-docs",
40+ "/docs/openapi.json",
41+ "/docs/openapi.yaml",
42+ "/docs/openapi.yml",
43+ "/.well-known/openapi.json",
44+ "/.well-known/openapi.yaml",
45+ "/.well-known/openapi.yml",
46+ "/.well-known/openapi",
47+];
48+
49+const OPENAPI_SPEC_RELATIVE_HINTS = [
50+ "openapi.json",
51+ "openapi.yaml",
52+ "openapi.yml",
53+ "swagger.json",
54+ "swagger.yaml",
55+ "swagger.yml",
56+ "api-docs",
57+ "api-docs.json",
58+ "api-docs.yaml",
59+ "api-docs.yml",
60+ "v3/api-docs",
61+ "v2/api-docs",
62+ "docs/openapi.json",
63+ "docs/openapi.yaml",
64+ "docs/openapi.yml",
65+ ".well-known/openapi.json",
66+ ".well-known/openapi.yaml",
67+ ".well-known/openapi.yml",
68+ "openapi",
69+ "swagger.json",
70+];
71+
72+function isLikelySpecCandidateUrl(text: string): boolean {
73+ const lowered = text.toLowerCase();
74+ return lowered.endsWith(".json") || lowered.endsWith(".yaml") || lowered.endsWith(".yml")
75+ || lowered.endsWith("/openapi") || lowered.includes("openapi") || lowered.includes("swagger");
76+}
77+
78+function dedupeUrls(values: string[]): string[] {
79+ const seen = new Set<string>();
80+ const next: string[] = [];
81+
82+ for (const value of values) {
83+ const normalized = value.trim();
84+ if (!normalized) {
85+ continue;
86+ }
87+
88+ if (seen.has(normalized)) {
89+ continue;
90+ }
91+ seen.add(normalized);
92+ next.push(normalized);
93+ }
94+
95+ return next;
96+}
97+
98+function buildOpenApiSpecCandidateUrls(sourceUrl: string): string[] {
99+ const parsedSource = new URL(sourceUrl);
100+ const sourceOrigin = parsedSource.origin;
101+ const cleanedSource = (() => {
102+ const cleaned = new URL(sourceUrl);
103+ cleaned.hash = "";
104+ cleaned.search = "";
105+ return cleaned.toString();
106+ })();
107+
108+ const candidates: string[] = [cleanedSource];
109+
110+ if (isLikelySpecCandidateUrl(cleanedSource)) {
111+ candidates.unshift(cleanedSource);
112+ }
113+
114+ const sourceHost = parsedSource.hostname.toLowerCase();
115+ const catalogHints = HARD_CODED_CATALOG_ITEMS
116+ .filter((item) => item.sourceType === "openapi" && item.specUrl)
117+ .map((item): string | null => {
118+ if (!item.originUrl) {
119+ return null;
120+ }
121+
122+ try {
123+ const origin = new URL(item.originUrl);
124+ return origin.hostname.toLowerCase() === sourceHost ? item.specUrl : null;
125+ } catch {
126+ return null;
127+ }
128+ })
129+ .filter((entry): entry is string => Boolean(entry));
130+
131+ candidates.push(...catalogHints);
132+
133+ const pathBase = parsedSource.pathname.endsWith("/")
134+ ? parsedSource.pathname
135+ : parsedSource.pathname.replace(/[^/]*$/, "");
136+ const baseWithSlash = pathBase.length === 0 ? "/" : pathBase;
137+
138+ const absoluteRoots = [
139+ `${sourceOrigin}/`,
140+ `${sourceOrigin}${baseWithSlash}`,
141+ ];
142+
143+ for (const root of absoluteRoots) {
144+ for (const pathHint of OPENAPI_SPEC_PATH_HINTS) {
145+ candidates.push(new URL(pathHint, root).toString());
146+ }
147+ }
148+
149+ const relativeRoots = [
150+ `${sourceOrigin}/`,
151+ `${sourceOrigin}${baseWithSlash}`,
152+ ];
153+ for (const root of relativeRoots) {
154+ for (const pathHint of OPENAPI_SPEC_RELATIVE_HINTS) {
155+ candidates.push(new URL(pathHint, `${root}/`).toString());
156+ }
157+ }
158+
159+ return dedupeUrls(candidates);
160+}
161+
20162 const recordSchema = z.record(z.unknown());
21163
22164 const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
@@ -183,3 +325,42 @@ export async function fetchAndInspectOpenApiSpec(input: { · 160 unmodified lines
183325 const inferredAuth = inferSecuritySchemaAuth(spec);
184326 return { spec, inferredAuth };
185327 }
328+
329+export async function discoverOpenApiSpecFromUrl(input: {
330+ sourceUrl: string;
331+ headers?: Record<string, string>;
332+ signal?: AbortSignal;
333+}): Promise<OpenApiSpecDiscoveryResult> {
334+ const sourceUrl = input.sourceUrl.trim();
335+ if (!sourceUrl) {
336+ throw new Error("Spec URL is required");
337+ }
338+
339+ const candidates = buildOpenApiSpecCandidateUrls(sourceUrl);
340+
341+ let lastError: unknown;
342+ for (const candidate of candidates) {
343+ try {
344+ const result = await fetchAndInspectOpenApiSpec({
345+ specUrl: candidate,
346+ headers: input.headers,
347+ signal: input.signal,
348+ });
349+
350+ return {
351+ ...result,
352+ specUrl: candidate,
353+ discovered: candidate !== sourceUrl,
354+ attemptedSpecUrls: candidates,
355+ };
356+ } catch (error) {
357+ lastError = error;
358+ }
359+ }
360+
361+ if (lastError instanceof Error) {
362+ throw lastError;
363+ }
364+
365+ throw new Error("No OpenAPI spec URL found");
366+}
executor/packages/core/src/mcp-server.test.ts+0−1
@@ -73,7 +73,6 @@ class FakeMcpService { · 72 unmodified lines
7373 workspaceId: `ws_${crypto.randomUUID()}` as Id<"workspaces">,
7474 clientId: "mcp",
7575 accountId: `account_${crypto.randomUUID()}` as Id<"accounts">,
76− userId: `member_${crypto.randomUUID()}` as Id<"workspaceMembers">,
7776 createdAt: now,
7877 lastSeenAt: now,
7978 };
executor/packages/core/src/types.ts+0−1
@@ -170,7 +170,6 @@ export interface AnonymousContext { · 169 unmodified lines
170170 workspaceId: Id<"workspaces">;
171171 clientId: string;
172172 accountId: Id<"accounts">;
173− userId: Id<"workspaceMembers">;
174173 createdAt: number;
175174 lastSeenAt: number;
176175 }
executor/packages/database/convex/_generated/api.d.ts+0−60
@@ -479,12 +479,6 @@ export declare const internal: { · 478 unmodified lines
479479 { accountId?: string; clientId?: string; sessionId?: string },
480480 any
481481 >;
482− ensureAnonymousMcpSession: FunctionReference<
483− "mutation",
484− "internal",
485− { accountId: string; workspaceId: Id<"workspaces"> },
486− any
487− >;
488482 };
489483 approvals: {
490484 createApproval: FunctionReference<
@@ -621,12 +615,6 @@ export declare const internal: { · 130 unmodified lines
621615 { sourceId: string; workspaceId: Id<"workspaces"> },
622616 any
623617 >;
624− ensureAnonymousMcpSession: FunctionReference<
625− "mutation",
626− "internal",
627− { accountId: string; workspaceId: Id<"workspaces"> },
628− any
629− >;
630618 finishToolCall: FunctionReference<
631619 "mutation",
632620 "internal",
@@ -1122,54 +1110,6 @@ export declare const internal: { · 489 unmodified lines
11221110 >;
11231111 };
11241112 migrations: {
1125− cleanupAccessPolicyEmptyStringSentinels: FunctionReference<
1126− "mutation",
1127− "internal",
1128− {
1129− batchSize?: number;
1130− cursor?: string | null;
1131− dryRun?: boolean;
1132− fn?: string;
1133− next?: Array<string>;
1134− },
1135− any
1136− >;
1137− cleanupTaskEmptyStringSentinels: FunctionReference<
1138− "mutation",
1139− "internal",
1140− {
1141− batchSize?: number;
1142− cursor?: string | null;
1143− dryRun?: boolean;
1144− fn?: string;
1145− next?: Array<string>;
1146− },
1147− any
1148− >;
1149− deleteAnonymousSessionsMissingAccountId: FunctionReference<
1150− "mutation",
1151− "internal",
1152− {
1153− batchSize?: number;
1154− cursor?: string | null;
1155− dryRun?: boolean;
1156− fn?: string;
1157− next?: Array<string>;
1158− },
1159− any
1160− >;
1161− deleteSourceCredentialsMissingProvider: FunctionReference<
1162− "mutation",
1163− "internal",
1164− {
1165− batchSize?: number;
1166− cursor?: string | null;
1167− dryRun?: boolean;
1168− fn?: string;
1169− next?: Array<string>;
1170− },
1171− any
1172− >;
11731113 run: FunctionReference<
11741114 "mutation",
11751115 "internal",
executor/packages/database/convex/database.test.ts+0−2
@@ -126,7 +126,6 @@ test("anonymous bootstrap links guest account membership", async () => { · 125 unmodified lines
126126 expect(first.sessionId).toContain("anon_session_");
127127 expect(first.workspaceId.length).toBeGreaterThan(0);
128128 expect(first.accountId).toBeDefined();
129− expect(first.userId).toBeDefined();
130129
131130 const again = await t.mutation(internal.database.bootstrapAnonymousSession, {
132131 sessionId: first.sessionId,
@@ -134,7 +133,6 @@ test("anonymous bootstrap links guest account membership", async () => { · 1 unmodified line
134133
135134 expect(again.sessionId).toBe(first.sessionId);
136135 expect(again.accountId).toBe(first.accountId);
137− expect(again.userId).toBe(first.userId);
138136 });
139137
140138 test("bootstrap ignores non-MCP caller-provided session id", async () => {
executor/packages/database/convex/database.ts+1−1
@@ -22,7 +22,7 @@ export { · 21 unmodified lines
2222 finishToolCall,
2323 listToolCalls,
2424 } from "./database/tool_calls";
25−export { bootstrapAnonymousSession, ensureAnonymousMcpSession } from "./database/anonymous_session";
25+export { bootstrapAnonymousSession } from "./database/anonymous_session";
2626 export {
2727 listRuntimeTargets,
2828 upsertAccessPolicy,
executor/packages/database/convex/database/anonymous_session.ts+0−76
@@ -37,7 +37,6 @@ export const bootstrapAnonymousSession = internalMutation({ · 36 unmodified lines
3737 clientId,
3838 workspaceId: identity.workspaceId,
3939 accountId: identity.accountId,
40− userId: identity.userId,
4140 lastSeenAt: now,
4241 });
4342
@@ -70,7 +69,6 @@ export const bootstrapAnonymousSession = internalMutation({ · 26 unmodified lines
7069 workspaceId: identity.workspaceId,
7170 clientId,
7271 accountId: identity.accountId,
73− userId: identity.userId,
7472 createdAt: now,
7573 lastSeenAt: now,
7674 });
@@ -86,77 +84,3 @@ export const bootstrapAnonymousSession = internalMutation({ · 9 unmodified lines
8684 return mapAnonymousContext(created);
8785 },
8886 });
89−
90−export const ensureAnonymousMcpSession = internalMutation({
91− args: {
92− workspaceId: v.id("workspaces"),
93− accountId: v.string(),
94− },
95− handler: async (ctx, args) => {
96− const accountId = args.accountId.trim();
97− if (!accountId) {
98− throw new Error("Anonymous accountId is required");
99− }
100−
101− const account = await ctx.db.get(accountId as Id<"accounts">);
102− if (!account || account.provider !== "anonymous") {
103− throw new Error("Anonymous account is not recognized");
104− }
105−
106− const membership = await ctx.db
107− .query("workspaceMembers")
108− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id))
109− .unique();
110− if (!membership || membership.status !== "active") {
111− throw new Error("Anonymous account does not have workspace access");
112− }
113−
114− const now = Date.now();
115− const existingSessions = await ctx.db
116− .query("anonymousSessions")
117− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", args.workspaceId).eq("accountId", account._id))
118− .collect();
119− const existing = existingSessions.find((session) => session.sessionId.startsWith("mcp_"))
120− ?? existingSessions[0]
121− ?? null;
122−
123− if (existing) {
124− await ctx.db.patch(existing._id, {
125− accountId: account._id,
126− userId: membership._id,
127− clientId: "mcp",
128− lastSeenAt: now,
129− });
130−
131− const refreshed = await ctx.db
132− .query("anonymousSessions")
133− .withIndex("by_session_id", (q) => q.eq("sessionId", existing.sessionId))
134− .unique();
135− if (!refreshed) {
136− throw new Error("Failed to refresh anonymous MCP session");
137− }
138− return mapAnonymousContext(refreshed);
139− }
140−
141− const sessionId = `mcp_${crypto.randomUUID()}`;
142− await ctx.db.insert("anonymousSessions", {
143− sessionId,
144− workspaceId: args.workspaceId,
145− clientId: "mcp",
146− accountId: account._id,
147− userId: membership._id,
148− createdAt: now,
149− lastSeenAt: now,
150− });
151−
152− const created = await ctx.db
153− .query("anonymousSessions")
154− .withIndex("by_session_id", (q) => q.eq("sessionId", sessionId))
155− .unique();
156− if (!created) {
157− throw new Error("Failed to create anonymous MCP session");
158− }
159−
160− return mapAnonymousContext(created);
161− },
162−});
executor/packages/database/convex/executor-mcp.e2e.test.ts+0−232
@@ -1,232 +0,0 @@
1−import { expect, test } from "bun:test";
2−import { generateKeyPairSync } from "node:crypto";
3−import { convexTest } from "convex-test";
4−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6−import { internal } from "./_generated/api";
7−import type { Id } from "./_generated/dataModel.d.ts";
8−import schema from "./schema";
9−
10−function ensureAnonymousAuthKeysConfigured() {
11− if (process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM && process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM) {
12− return;
13− }
14−
15− const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
16− process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
17− process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM = publicKey.export({ type: "spki", format: "pem" }).toString();
18−}
19−
20−ensureAnonymousAuthKeysConfigured();
21−
22−function setup() {
23− return convexTest(schema, {
24− "./database.ts": () => import("./database"),
25− "./executor.ts": () => import("./executor"),
26− "./executorNode.ts": () => import("./executorNode"),
27− "./http.ts": () => import("./http"),
28− "./auth.ts": () => import("./auth"),
29− "./workspaceAuthInternal.ts": () => import("./workspaceAuthInternal"),
30− "./workspaceToolCache.ts": () => import("./workspaceToolCache"),
31− "./toolRegistry.ts": () => import("./toolRegistry"),
32− "./openApiSpecCache.ts": () => import("./openApiSpecCache"),
33− "./_generated/api.js": () => import("./_generated/api.js"),
34− });
35−}
36−
37−type AnonymousTokenResponse = {
38− error?: unknown;
39− accessToken?: unknown;
40−};
41−
42−function readErrorMessage(body: AnonymousTokenResponse): string {
43− return typeof body.error === "string" ? body.error : "";
44−}
45−
46−function readAccessToken(body: AnonymousTokenResponse): string | null {
47− return typeof body.accessToken === "string" && body.accessToken.length > 0 ? body.accessToken : null;
48−}
49−
50−async function getAnonymousAccessToken(
51− t: ReturnType<typeof setup>,
52− accountId: string,
53−): Promise<string> {
54− const resp = await t.fetch(`/auth/anonymous/token?accountId=${encodeURIComponent(accountId)}`);
55− const body = await resp.json().catch(() => ({} as AnonymousTokenResponse)) as AnonymousTokenResponse;
56−
57− if (!resp.ok) {
58− const message = readErrorMessage(body) || `HTTP ${resp.status}`;
59− throw new Error(`Failed to issue anonymous token: ${message}`);
60− }
61−
62− const token = readAccessToken(body);
63− if (!token) {
64− throw new Error("Anonymous token response missing accessToken");
65− }
66−
67− return token;
68−}
69−
70−async function createMcpTransport(
71− t: ReturnType<typeof setup>,
72− workspaceId: string,
73− accountId: string,
74− sessionId: string,
75− clientId = "e2e",
76−) {
77− const isAnonymousSession = sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_");
78− const url = new URL(`https://executor.test${isAnonymousSession ? "/mcp/anonymous" : "/mcp"}`);
79− url.searchParams.set("workspaceId", workspaceId);
80− if (!isAnonymousSession) {
81− url.searchParams.set("sessionId", sessionId);
82− }
83− url.searchParams.set("clientId", clientId);
84−
85− const anonymousToken = isAnonymousSession
86− ? await getAnonymousAccessToken(t, accountId)
87− : null;
88−
89− return new StreamableHTTPClientTransport(url, {
90− fetch: async (input, init) => {
91− const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
92− const parsed = new URL(raw);
93− const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
94− const headers = new Headers(init?.headers ?? {});
95− if (anonymousToken) {
96− headers.set("authorization", `Bearer ${anonymousToken}`);
97− }
98− return await t.fetch(path, { ...init, headers });
99− },
100− });
101−}
102−
103−async function waitForTaskId(t: ReturnType<typeof setup>, workspaceId: string, timeoutMs = 10_000): Promise<string> {
104− const start = Date.now();
105− while (Date.now() - start < timeoutMs) {
106− const tasks = await t.query(internal.database.listTasks, { workspaceId: workspaceId as Id<"workspaces"> });
107− if (tasks.length > 0) {
108− return tasks[0]!.id;
109− }
110− await Bun.sleep(50);
111− }
112−
113− throw new Error("Timed out waiting for created task");
114−}
115−
116−async function waitForPendingApproval(
117− t: ReturnType<typeof setup>,
118− workspaceId: string,
119− toolPath: string,
120− timeoutMs = 10_000,
121−): Promise<string> {
122− const start = Date.now();
123− while (Date.now() - start < timeoutMs) {
124− const approvals = await t.query(internal.database.listPendingApprovals, { workspaceId: workspaceId as Id<"workspaces"> });
125− const approval = approvals.find((item: { toolPath: string; id: string }) => item.toolPath === toolPath);
126− if (approval) {
127− return approval.id;
128− }
129− await Bun.sleep(50);
130− }
131−
132− throw new Error(`Timed out waiting for pending approval on ${toolPath}`);
133−}
134−
135−test("MCP run_code survives delayed approval and completes", async () => {
136− const t = setup();
137− const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
138−
139− const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });
140− const transport = await createMcpTransport(t, session.workspaceId, session.accountId, session.sessionId, "e2e-approval-delay");
141−
142− try {
143− await client.connect(transport);
144−
145− const runCode = client.callTool({
146− name: "run_code",
147− arguments: {
148− code: `return await tools.admin.send_announcement({ channel: "general", message: "hello from convex-test" });`,
149− },
150− });
151−
152− const taskId = await waitForTaskId(t, session.workspaceId);
153− const runTask = t.action(internal.executorNode.runTask, { taskId });
154−
155− const approvalId = await waitForPendingApproval(t, session.workspaceId, "admin.send_announcement");
156−
157− await Bun.sleep(16_000);
158−
159− await t.mutation(internal.executor.resolveApprovalInternal, {
160− workspaceId: session.workspaceId,
161− approvalId,
162− decision: "approved",
163− reviewerId: "e2e-reviewer",
164− });
165−
166− await runTask;
167−
168− const result = (await runCode) as {
169− content: Array<{ type: string; text?: string }>;
170− isError?: boolean;
171− };
172− const text = result.content.find((item) => item.type === "text")?.text ?? "";
173−
174− expect(result.isError).toBeFalsy();
175− expect(text).toContain("status: completed");
176− expect(text).toContain("hello from convex-test");
177−
178− const task = await t.query(internal.database.getTask, { taskId });
179− expect(task?.status).toBe("completed");
180− } finally {
181− await transport.close().catch(() => {});
182− await client.close().catch(() => {});
183− }
184−}, 60_000);
185−
186−test("MCP run_code returns denied after approval denial", async () => {
187− const t = setup();
188− const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
189−
190− const client = new Client({ name: "executor-e2e", version: "0.0.1" }, { capabilities: {} });
191− const transport = await createMcpTransport(t, session.workspaceId, session.accountId, session.sessionId, "e2e-deny");
192−
193− try {
194− await client.connect(transport);
195−
196− const runCode = client.callTool({
197− name: "run_code",
198− arguments: {
199− code: `return await tools.admin.delete_data({ key: "important" });`,
200− },
201− });
202−
203− const taskId = await waitForTaskId(t, session.workspaceId);
204− const runTask = t.action(internal.executorNode.runTask, { taskId });
205−
206− const approvalId = await waitForPendingApproval(t, session.workspaceId, "admin.delete_data");
207− await t.mutation(internal.executor.resolveApprovalInternal, {
208− workspaceId: session.workspaceId,
209− approvalId,
210− decision: "denied",
211− reviewerId: "e2e-reviewer",
212− reason: "not allowed",
213− });
214−
215− await runTask;
216−
217− const result = (await runCode) as {
218− content: Array<{ type: string; text?: string }>;
219− isError?: boolean;
220− };
221− const text = result.content.find((item) => item.type === "text")?.text ?? "";
222−
223− expect(result.isError).toBe(true);
224− expect(text).toContain("status: denied");
225−
226− const task = await t.query(internal.database.getTask, { taskId });
227− expect(task?.status).toBe("denied");
228− } finally {
229− await transport.close().catch(() => {});
230− await client.close().catch(() => {});
231− }
232−}, 30_000);
executor/packages/database/convex/mcp/run/code-oom.e2e.test.ts+0−127
@@ -1,127 +0,0 @@
1−import { expect, test } from "bun:test";
2−import { generateKeyPairSync } from "node:crypto";
3−import { convexTest } from "convex-test";
4−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6−import { internal } from "../../_generated/api";
7−import schema from "../../schema";
8−
9−const OOM_MESSAGE = "JavaScript execution ran out of memory (maximum memory usage: 64 MB): request stream size was 0 bytes";
10−
11−function ensureAnonymousAuthKeysConfigured() {
12− if (process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM && process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM) {
13− return;
14− }
15−
16− const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
17− process.env.ANONYMOUS_AUTH_PRIVATE_KEY_PEM = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
18− process.env.ANONYMOUS_AUTH_PUBLIC_KEY_PEM = publicKey.export({ type: "spki", format: "pem" }).toString();
19−}
20−
21−ensureAnonymousAuthKeysConfigured();
22−
23−function setup() {
24− return convexTest(schema, {
25− "../../database.ts": () => import("../../database"),
26− "../../executor.ts": () => import("../../executor"),
27− "../../executorNode.ts": () => import("../../../src/test-fixtures/executorNode.oomFixture"),
28− "../../http.ts": () => import("../../http"),
29− "../../auth.ts": () => import("../../auth"),
30− "../../workspaceAuthInternal.ts": () => import("../../workspaceAuthInternal"),
31− "../../workspaceToolCache.ts": () => import("../../workspaceToolCache"),
32− "../../toolRegistry.ts": () => import("../../toolRegistry"),
33− "../../openApiSpecCache.ts": () => import("../../openApiSpecCache"),
34− "../../_generated/api.js": () => import("../../_generated/api.js"),
35− });
36−}
37−
38−type AnonymousTokenResponse = {
39− error?: unknown;
40− accessToken?: unknown;
41−};
42−
43−function readErrorMessage(body: AnonymousTokenResponse): string {
44− return typeof body.error === "string" ? body.error : "";
45−}
46−
47−function readAccessToken(body: AnonymousTokenResponse): string | null {
48− return typeof body.accessToken === "string" && body.accessToken.length > 0 ? body.accessToken : null;
49−}
50−
51−async function getAnonymousAccessToken(
52− t: ReturnType<typeof setup>,
53− accountId: string,
54−): Promise<string> {
55− const resp = await t.fetch(`/auth/anonymous/token?accountId=${encodeURIComponent(accountId)}`);
56− const body = await resp.json().catch(() => ({} as AnonymousTokenResponse)) as AnonymousTokenResponse;
57−
58− if (!resp.ok) {
59− const message = readErrorMessage(body) || `HTTP ${resp.status}`;
60− throw new Error(`Failed to issue anonymous token: ${message}`);
61− }
62−
63− const token = readAccessToken(body);
64− if (!token) {
65− throw new Error("Anonymous token response missing accessToken");
66− }
67−
68− return token;
69−}
70−
71−async function createMcpTransport(
72− t: ReturnType<typeof setup>,
73− workspaceId: string,
74− accountId: string,
75− sessionId: string,
76− clientId = "oom-repro",
77−) {
78− const isAnonymousSession = sessionId.startsWith("anon_session_") || sessionId.startsWith("mcp_");
79− const url = new URL(`https://executor.test${isAnonymousSession ? "/mcp/anonymous" : "/mcp"}`);
80− url.searchParams.set("workspaceId", workspaceId);
81− if (!isAnonymousSession) {
82− url.searchParams.set("sessionId", sessionId);
83− }
84− url.searchParams.set("clientId", clientId);
85−
86− const anonymousToken = isAnonymousSession
87− ? await getAnonymousAccessToken(t, accountId)
88− : null;
89−
90− return new StreamableHTTPClientTransport(url, {
91− fetch: async (input, init) => {
92− const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
93− const parsed = new URL(raw);
94− const path = `${parsed.pathname}${parsed.search}${parsed.hash}`;
95− const headers = new Headers(init?.headers ?? {});
96− if (anonymousToken) {
97− headers.set("authorization", `Bearer ${anonymousToken}`);
98− }
99− return await t.fetch(path, { ...init, headers });
100− },
101− });
102−}
103−
104−test("MCP run_code no longer hits typecheck OOM path", async () => {
105− const t = setup();
106− const session = await t.mutation(internal.database.bootstrapAnonymousSession, {});
107−
108− const client = new Client({ name: "executor-oom-repro", version: "0.0.1" }, { capabilities: {} });
109− const transport = await createMcpTransport(t, session.workspaceId, session.accountId, session.sessionId);
110−
111− try {
112− await client.connect(transport);
113−
114− const result = await client.callTool({
115− name: "run_code",
116− arguments: {
117− code: "return await tools.github.users.get_authenticated();",
118− },
119− });
120−
121− const serialized = JSON.stringify(result);
122− expect(serialized.includes(OOM_MESSAGE)).toBe(false);
123− } finally {
124− await transport.close().catch(() => {});
125− await client.close().catch(() => {});
126− }
127−}, 30_000);
executor/packages/database/convex/migrations.ts+0−44
@@ -4,48 +4,4 @@ import { components } from "./_generated/api"; · 3 unmodified lines
44
55 const migrations = new Migrations<DataModel>(components.migrations);
66
7−export const deleteAnonymousSessionsMissingAccountId = migrations.define({
8− table: "anonymousSessions",
9− migrateOne: async (ctx, session) => {
10− if (session.accountId) {
11− return;
12− }
13−
14− await ctx.db.delete(session._id);
15− },
16−});
17−
18−export const deleteSourceCredentialsMissingProvider = migrations.define({
19− table: "sourceCredentials",
20− migrateOne: async (ctx, credential) => {
21− if (credential.provider) {
22− return;
23− }
24−
25− await ctx.db.delete(credential._id);
26− },
27−});
28−
29−export const cleanupTaskEmptyStringSentinels = migrations.define({
30− table: "tasks",
31− migrateOne: async (_ctx, task) => {
32− const patch: Record<string, undefined> = {};
33− if (Reflect.get(task, "accountId") === "") patch.accountId = undefined;
34− if (task.clientId === "") patch.clientId = undefined;
35− if (Object.keys(patch).length > 0) return patch;
36− },
37−});
38−
39−export const cleanupAccessPolicyEmptyStringSentinels = migrations.define({
40− table: "accessPolicies",
41− migrateOne: async (_ctx, policy) => {
42− const policyRecord = policy as Record<string, unknown>;
43− const patch: Record<string, undefined> = {};
44− if (Reflect.get(policyRecord, "accountId") === "") patch["accountId"] = undefined;
45− if (Reflect.get(policyRecord, "targetAccountId") === "") patch["targetAccountId"] = undefined;
46− if (policy.clientId === "") patch["clientId"] = undefined;
47− if (Object.keys(patch).length > 0) return patch;
48− },
49−});
50−
517 export const run = migrations.runner();
executor/packages/database/convex/schema.ts+1−2
@@ -508,7 +508,7 @@ export default defineSchema({ · 507 unmodified lines
508508
509509 // Anonymous session linkage.
510510 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a
511− // `workspaceMembers` user entry.
511+ // workspace.
512512 //
513513 // Primary access patterns:
514514 // - Resolve by session id.
@@ -519,7 +519,6 @@ export default defineSchema({ · 4 unmodified lines
519519 workspaceId: v.id("workspaces"),
520520 clientId: v.string(), // client label: "web", "mcp", etc.
521521 accountId: v.id("accounts"),
522− userId: v.id("workspaceMembers"),
523522 createdAt: v.number(),
524523 lastSeenAt: v.number(),
525524 })
executor/packages/database/src/database/anonymous.ts+0−12
@@ -98,20 +98,8 @@ export async function ensureAnonymousIdentity( · 97 unmodified lines
9898 now,
9999 });
100100
101− let user = await ctx.db
102− .query("workspaceMembers")
103− .withIndex("by_workspace_account", (q) => q.eq("workspaceId", workspace._id).eq("accountId", account._id))
104− .unique();
105−
106− if (!user) {
107− throw new Error("Failed to project anonymous workspace membership");
108− }
109−
110− await ctx.db.patch(user._id, { updatedAt: now });
111−
112101 return {
113102 accountId: account._id,
114103 workspaceId: workspace._id,
115− userId: user._id,
116104 };
117105 }
executor/packages/database/src/database/readers.ts+0−1
@@ -13,7 +13,6 @@ export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { · 12 unmodified lines
1313 workspaceId: doc.workspaceId,
1414 clientId: doc.clientId,
1515 accountId: doc.accountId,
16− userId: doc.userId,
1716 createdAt: doc.createdAt,
1817 lastSeenAt: doc.lastSeenAt,
1918 };