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
89 },89 },
90 );90 );
91 91
92 const hasBearerChallenge = /^Bearer\s/i.test(response.headers.get("WWW-Authenticate") ?? "");92 const challengeHeader = response.headers.get("WWW-Authenticate") ?? "";
93 const challenge = extractWWWAuthenticateParams(response);93 const hasBearerChallenge = /^Bearer\s/i.test(challengeHeader);
94 await response.body?.cancel();94 await response.body?.cancel();
95 95
96 if ((response.status === 401 || response.status === 403) && hasBearerChallenge) {96 if ((response.status === 401 || response.status === 403) && hasBearerChallenge) {
97 return true;97 return true;
98 }98 }
99 99
100 let challenge: ReturnType<typeof extractWWWAuthenticateParams>;
101 try {
102 challenge = extractWWWAuthenticateParams(response);
103 } catch {
104 return false;
105 }
106
100 return Boolean(challenge.resourceMetadataUrl);107 return Boolean(challenge.resourceMetadataUrl);
101}108}
102 109
@@ -146,11 +153,12 @@ export async function GET(request: NextRequest) { · 43 unmodified lines
146 const probeDetail = challengeProbeResult.isOk()153 const probeDetail = challengeProbeResult.isOk()
147 ? ""154 ? ""
148 : `; challenge probe: ${resultErrorMessage(challengeProbeResult.error, "failed")}`;155 : `; challenge probe: ${resultErrorMessage(challengeProbeResult.error, "failed")}`;
156
149 return noStoreJson({157 return noStoreJson({
150 oauth: false,158 oauth: false,
151 authorizationServers: [],159 authorizationServers: [],
152 detail: `${resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed")}${probeDetail}`,160 detail: `${resultErrorMessage(metadataResult.error, "OAuth metadata lookup failed")}${probeDetail}`,
153 }, 502);161 });
154 }162 }
155 163
156 const metadata = metadataResult.value;164 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
113 const credentialsLoading = Boolean(context) && credentials === undefined;113 const credentialsLoading = Boolean(context) && credentials === undefined;
114 const [open, setOpen] = useState(false);114 const [open, setOpen] = useState(false);
115 const [submitting, setSubmitting] = useState(false);115 const [submitting, setSubmitting] = useState(false);
116 const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false);116 const [sourceOAuthBusy, setSourceOAuthBusy] = useState(false);
117 const [deleting, setDeleting] = useState(false);117 const [deleting, setDeleting] = useState(false);
118 118
119 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";119 const sourceDialogHeader = sourceToEdit ? "Edit Tool Source" : "Add Tool Source";
@@ -218,20 +218,30 @@ export function AddSourceDialog({ · 98 unmodified lines
218 setOpen(false);218 setOpen(false);
219 };219 };
220 220
221 const handleMcpOAuthConnect = async () => {221 const sourceOAuthConnectUrl = (): string => {
222 if (form.type !== "mcp") {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") {
223 return;231 return;
224 }232 }
225 const endpoint = form.endpoint.trim();233
234 const endpoint = sourceOAuthConnectUrl();
226 if (!endpoint) {235 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`);
228 return;238 return;
229 }239 }
230 const initiatedEndpoint = normalizeEndpoint(endpoint);240 const initiatedEndpoint = normalizeEndpoint(endpoint);
231 241
232 setMcpOAuthBusy(true);242 setSourceOAuthBusy(true);
233 const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));243 const oauthResult = await Result.tryPromise(() => startMcpOAuthPopup(endpoint));
234 setMcpOAuthBusy(false);244 setSourceOAuthBusy(false);
235 245
236 if (!oauthResult.isOk()) {246 if (!oauthResult.isOk()) {
237 toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));247 toast.error(resultErrorMessage(oauthResult.error, "Failed to connect OAuth"));
@@ -244,7 +254,7 @@ export function AddSourceDialog({ · 6 unmodified lines
244 return;254 return;
245 }255 }
246 256
247 const currentEndpoint = normalizeEndpoint(form.endpoint);257 const currentEndpoint = normalizeEndpoint(sourceOAuthConnectUrl());
248 if (currentEndpoint !== initiatedEndpoint) {258 if (currentEndpoint !== initiatedEndpoint) {
249 toast.error("Endpoint changed while OAuth was running. Please reconnect OAuth.");259 toast.error("Endpoint changed while OAuth was running. Please reconnect OAuth.");
250 return;260 return;
@@ -254,7 +264,7 @@ export function AddSourceDialog({ · 3 unmodified lines
254 form.handleAuthTypeChange("bearer");264 form.handleAuthTypeChange("bearer");
255 }265 }
256 form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);266 form.handleAuthFieldChange("tokenValue", oauthResult.value.accessToken);
257 form.markMcpOAuthLinked(initiatedEndpoint);267 form.markSourceOAuthLinked(initiatedEndpoint);
258 toast.success("OAuth linked successfully.");268 toast.success("OAuth linked successfully.");
259 };269 };
260 270
@@ -445,10 +455,10 @@ export function AddSourceDialog({ · 184 unmodified lines
445 specStatus: form.specStatus,455 specStatus: form.specStatus,
446 inferredSpecAuth: form.inferredSpecAuth,456 inferredSpecAuth: form.inferredSpecAuth,
447 specError: form.specError,457 specError: form.specError,
448 mcpOAuthStatus: form.mcpOAuthStatus,458 sourceOAuthStatus: form.sourceOAuthStatus,
449 mcpOAuthDetail: form.mcpOAuthDetail,459 sourceOAuthDetail: form.sourceOAuthDetail,
450 mcpOAuthAuthorizationServers: form.mcpOAuthAuthorizationServers,460 sourceOAuthAuthorizationServers: form.sourceOAuthAuthorizationServers,
451 mcpOAuthConnected: form.mcpOAuthConnected,461 sourceOAuthConnected: form.sourceOAuthConnected,
452 authType: form.authType,462 authType: form.authType,
453 scopeType: form.scopeType,463 scopeType: form.scopeType,
454 authScope: form.authScope,464 authScope: form.authScope,
@@ -462,8 +472,8 @@ export function AddSourceDialog({ · 7 unmodified lines
462 onAuthTypeChange={form.handleAuthTypeChange}472 onAuthTypeChange={form.handleAuthTypeChange}
463 onScopeChange={form.handleScopePresetChange}473 onScopeChange={form.handleScopePresetChange}
464 onFieldChange={form.handleAuthFieldChange}474 onFieldChange={form.handleAuthFieldChange}
465 onMcpOAuthConnect={form.type === "mcp" ? handleMcpOAuthConnect : undefined}475 onSourceOAuthConnect={form.type === "mcp" || form.type === "openapi" ? handleSourceOAuthConnect : undefined}
466 mcpOAuthBusy={mcpOAuthBusy}476 sourceOAuthBusy={sourceOAuthBusy}
467 />477 />
468 </CustomViewSection>478 </CustomViewSection>
469 )}479 )}
executor/apps/web/src/components/tools/add/source/auth-panel.tsx+42−41
@@ -36,10 +36,10 @@ export type SourceAuthPanelModel = { · 35 unmodified lines
36 specStatus: "idle" | "detecting" | "ready" | "error";36 specStatus: "idle" | "detecting" | "ready" | "error";
37 inferredSpecAuth: InferredSpecAuth | null;37 inferredSpecAuth: InferredSpecAuth | null;
38 specError: string;38 specError: string;
39 mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";39 sourceOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error";
40 mcpOAuthDetail: string;40 sourceOAuthDetail: string;
41 mcpOAuthAuthorizationServers: string[];41 sourceOAuthAuthorizationServers: string[];
42 mcpOAuthConnected: boolean;42 sourceOAuthConnected: boolean;
43 authType: Exclude<SourceAuthType, "mixed">;43 authType: Exclude<SourceAuthType, "mixed">;
44 scopeType: "organization" | "workspace";44 scopeType: "organization" | "workspace";
45 authScope: CredentialScope;45 authScope: CredentialScope;
@@ -75,25 +75,25 @@ export function SourceAuthPanel({ · 29 unmodified lines
75 onAuthTypeChange,75 onAuthTypeChange,
76 onScopeChange,76 onScopeChange,
77 onFieldChange,77 onFieldChange,
78 onMcpOAuthConnect,78 onSourceOAuthConnect,
79 mcpOAuthBusy = false,79 sourceOAuthBusy = false,
80}: {80}: {
81 model: SourceAuthPanelModel;81 model: SourceAuthPanelModel;
82 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;82 onAuthTypeChange: (value: Exclude<SourceAuthType, "mixed">) => void;
83 onScopeChange: (value: SharingScope) => void;83 onScopeChange: (value: SharingScope) => void;
84 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;84 onFieldChange: (field: SourceAuthPanelEditableField, value: string) => void;
85 onMcpOAuthConnect?: () => void;85 onSourceOAuthConnect?: () => void;
86 mcpOAuthBusy?: boolean;86 sourceOAuthBusy?: boolean;
87}) {87}) {
88 const {88 const {
89 sourceType,89 sourceType,
90 specStatus,90 specStatus,
91 inferredSpecAuth,91 inferredSpecAuth,
92 specError,92 specError,
93 mcpOAuthStatus,93 sourceOAuthStatus,
94 mcpOAuthDetail,94 sourceOAuthDetail,
95 mcpOAuthAuthorizationServers,95 sourceOAuthAuthorizationServers,
96 mcpOAuthConnected,96 sourceOAuthConnected,
97 authType,97 authType,
98 apiKeyHeader,98 apiKeyHeader,
99 tokenValue,99 tokenValue,
@@ -109,10 +109,11 @@ export function SourceAuthPanel({ · 9 unmodified lines
109 }109 }
110 110
111 const badge = inferredAuthBadge(inferredSpecAuth);111 const badge = inferredAuthBadge(inferredSpecAuth);
112 const mcpBearerConnected = sourceType === "mcp" && authType === "bearer" && mcpOAuthConnected;112 const usesSourceOAuthFlow = sourceType === "mcp" || sourceType === "openapi";
113 const mcpOAuthLoading = sourceType === "mcp" && mcpOAuthStatus === "checking";113 const bearerOAuthConnected = usesSourceOAuthFlow && authType === "bearer" && sourceOAuthConnected;
114 const mcpOAuthDetected = sourceType === "mcp" && mcpOAuthStatus === "oauth";114 const sourceOAuthLoading = usesSourceOAuthFlow && sourceOAuthStatus === "checking";
115 const useMcpOAuthFlow = mcpOAuthLoading || mcpOAuthDetected;115 const sourceOAuthDetected = usesSourceOAuthFlow && sourceOAuthStatus === "oauth";
116 const useSourceOAuthFlow = sourceOAuthLoading || sourceOAuthDetected;
116 return (117 return (
117 <div className="space-y-3">118 <div className="space-y-3">
118 119
@@ -132,32 +133,32 @@ export function SourceAuthPanel({ · 13 unmodified lines
132 </div>133 </div>
133 ) : null}134 ) : null}
134 135
135 {sourceType === "mcp" ? (136 {usesSourceOAuthFlow ? (
136 <div className="flex items-center gap-2 flex-wrap">137 <div className="flex items-center gap-2 flex-wrap">
137 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">138 <Badge variant="outline" className="text-[10px] uppercase tracking-wider">
138 {mcpOAuthStatus === "idle"139 {sourceOAuthStatus === "idle"
139 ? "Awaiting URL"140 ? "Awaiting URL"
140 : mcpOAuthStatus === "checking"141 : sourceOAuthStatus === "checking"
141 ? "Checking OAuth"142 ? "Checking OAuth"
142 : mcpOAuthStatus === "oauth"143 : sourceOAuthStatus === "oauth"
143 ? "OAuth detected"144 ? "OAuth detected"
144 : mcpOAuthStatus === "error"145 : sourceOAuthStatus === "error"
145 ? "OAuth unknown"146 ? "OAuth unknown"
146 : "No OAuth metadata"}147 : "No OAuth metadata"}
147 </Badge>148 </Badge>
148 {mcpOAuthAuthorizationServers.length > 0 ? (149 {sourceOAuthAuthorizationServers.length > 0 ? (
149 <span className="text-[10px] text-muted-foreground">150 <span className="text-[10px] text-muted-foreground">
150 {mcpOAuthAuthorizationServers[0]}151 {sourceOAuthAuthorizationServers[0]}
151 </span>152 </span>
152 ) : null}153 ) : null}
153 {mcpOAuthStatus === "error" && mcpOAuthDetail ? (154 {sourceOAuthStatus === "error" && sourceOAuthDetail ? (
154 <span className="text-[10px] text-terminal-amber">{mcpOAuthDetail}</span>155 <span className="text-[10px] text-terminal-amber">{sourceOAuthDetail}</span>
155 ) : null}156 ) : null}
156 </div>157 </div>
157 ) : null}158 ) : null}
158 159
159 <div className={`grid ${useMcpOAuthFlow ? "grid-cols-1" : "grid-cols-2"} gap-3`}>160 <div className={`grid ${useSourceOAuthFlow ? "grid-cols-1" : "grid-cols-2"} gap-3`}>
160 {!useMcpOAuthFlow ? (161 {!useSourceOAuthFlow ? (
161 <div className="space-y-1.5">162 <div className="space-y-1.5">
162 <Label className="text-xs text-muted-foreground">Auth Type</Label>163 <Label className="text-xs text-muted-foreground">Auth Type</Label>
163 <Select value={authType} onValueChange={(value) => onAuthTypeChange(value as Exclude<SourceAuthType, "mixed">)}>164 <Select value={authType} onValueChange={(value) => onAuthTypeChange(value as Exclude<SourceAuthType, "mixed">)}>
@@ -193,17 +194,17 @@ export function SourceAuthPanel({ · 29 unmodified lines
193 </div>194 </div>
194 </div>195 </div>
195 196
196 {sourceType === "mcp" && useMcpOAuthFlow && onMcpOAuthConnect ? (197 {usesSourceOAuthFlow && useSourceOAuthFlow && onSourceOAuthConnect ? (
197 <div className="space-y-1.5">198 <div className="space-y-1.5">
198 <div className="flex items-center gap-2">199 <div className="flex items-center gap-2">
199 <Label className="text-xs text-muted-foreground">OAuth</Label>200 <Label className="text-xs text-muted-foreground">OAuth</Label>
200 {mcpBearerConnected ? (201 {bearerOAuthConnected ? (
201 <Badge variant="outline" className="text-[10px] uppercase tracking-wider text-terminal-green">202 <Badge variant="outline" className="text-[10px] uppercase tracking-wider text-terminal-green">
202 Connected203 Connected
203 </Badge>204 </Badge>
204 ) : null}205 ) : null}
205 </div>206 </div>
206 {mcpOAuthLoading ? (207 {sourceOAuthLoading ? (
207 <Skeleton className="h-8 w-full" />208 <Skeleton className="h-8 w-full" />
208 ) : (209 ) : (
209 <Button210 <Button
@@ -211,13 +212,13 @@ export function SourceAuthPanel({ · 1 unmodified line
211 variant="outline"212 variant="outline"
212 size="sm"213 size="sm"
213 className="h-8 text-xs"214 className="h-8 text-xs"
214 disabled={mcpOAuthBusy}215 disabled={sourceOAuthBusy}
215 onClick={onMcpOAuthConnect}216 onClick={onSourceOAuthConnect}
216 >217 >
217 {mcpOAuthBusy ? "Connecting..." : mcpBearerConnected ? "Reconnect OAuth" : "Connect OAuth in popup"}218 {sourceOAuthBusy ? "Connecting..." : bearerOAuthConnected ? "Reconnect OAuth" : "Connect OAuth in popup"}
218 </Button>219 </Button>
219 )}220 )}
220 {mcpBearerConnected ? (221 {bearerOAuthConnected ? (
221 <p className="text-[11px] text-muted-foreground">OAuth linked successfully.</p>222 <p className="text-[11px] text-muted-foreground">OAuth linked successfully.</p>
222 ) : null}223 ) : null}
223 </div>224 </div>
@@ -235,7 +236,7 @@ export function SourceAuthPanel({ · 11 unmodified lines
235 </div>236 </div>
236 ) : null}237 ) : null}
237 238
238 {authType === "bearer" && sourceType !== "mcp" && !mcpBearerConnected ? (239 {authType === "bearer" && !usesSourceOAuthFlow ? (
239 <div className="space-y-1.5">240 <div className="space-y-1.5">
240 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">241 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
241 <LockKeyhole className="h-3 w-3" />242 <LockKeyhole className="h-3 w-3" />
@@ -251,7 +252,7 @@ export function SourceAuthPanel({ · 9 unmodified lines
251 </div>252 </div>
252 ) : null}253 ) : null}
253 254
254 {authType === "apiKey" && !useMcpOAuthFlow ? (255 {authType === "apiKey" && !useSourceOAuthFlow ? (
255 <div className="space-y-1.5">256 <div className="space-y-1.5">
256 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">257 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">
257 <KeyRound className="h-3 w-3" />258 <KeyRound className="h-3 w-3" />
@@ -267,7 +268,7 @@ export function SourceAuthPanel({ · 9 unmodified lines
267 </div>268 </div>
268 ) : null}269 ) : null}
269 270
270 {authType === "basic" && !useMcpOAuthFlow ? (271 {authType === "basic" && !useSourceOAuthFlow ? (
271 <div className="grid grid-cols-2 gap-3">272 <div className="grid grid-cols-2 gap-3">
272 <div className="space-y-1.5">273 <div className="space-y-1.5">
273 <Label className="text-xs text-muted-foreground flex items-center gap-1.5">274 <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
53 locallyReservedNames: string[];53 locallyReservedNames: string[];
54 authManuallyEdited: boolean;54 authManuallyEdited: boolean;
55 authRevision: number;55 authRevision: number;
56 mcpOAuthLinkedEndpoint: string | null;56 sourceOAuthLinkedEndpoint: string | null;
57};57};
58 58
59export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {59export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSourceUiState {
@@ -65,7 +65,7 @@ export function createDefaultUiState(view: SourceDialogView = "catalog"): AddSou · 5 unmodified lines
65 locallyReservedNames: [],65 locallyReservedNames: [],
66 authManuallyEdited: false,66 authManuallyEdited: false,
67 authRevision: 0,67 authRevision: 0,
68 mcpOAuthLinkedEndpoint: null,68 sourceOAuthLinkedEndpoint: null,
69 };69 };
70}70}
71 71
executor/apps/web/src/components/tools/use/add/source/form-state.ts+44−39
@@ -144,8 +144,8 @@ export function useAddSourceFormState({ · 143 unmodified lines
144 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;144 .sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
145 }, [accountId, credentialItems, editingSourceKey, values.authScope, values.scopeType]);145 }, [accountId, credentialItems, editingSourceKey, values.authScope, values.scopeType]);
146 146
147 const hasPersistedMcpBearerToken = useMemo(() => {147 const hasPersistedSourceBearerToken = useMemo(() => {
148 if (values.type !== "mcp" || values.authType !== "bearer") {148 if ((values.type !== "mcp" && values.type !== "openapi") || values.authType !== "bearer") {
149 return false;149 return false;
150 }150 }
151 if (!existingScopedCredential) {151 if (!existingScopedCredential) {
@@ -192,7 +192,7 @@ export function useAddSourceFormState({ · 40 unmodified lines
192 192
193 const handleEndpointChange = (endpoint: string) => {193 const handleEndpointChange = (endpoint: string) => {
194 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });194 form.setValue("endpoint", endpoint, { shouldDirty: true, shouldTouch: true });
195 patchUi(setUi, { mcpOAuthLinkedEndpoint: null });195 patchUi(setUi, { sourceOAuthLinkedEndpoint: null });
196 196
197 if (values.type === "openapi") {197 if (values.type === "openapi") {
198 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);198 const inferredBaseUrl = deriveBaseUrlFromEndpoint(endpoint);
@@ -246,7 +246,7 @@ export function useAddSourceFormState({ · 47 unmodified lines
246 patchUi(setUi, {246 patchUi(setUi, {
247 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],247 openApiBaseUrlOptions: inferredBaseUrl ? [inferredBaseUrl] : [],
248 authManuallyEdited: false,248 authManuallyEdited: false,
249 mcpOAuthLinkedEndpoint: null,249 sourceOAuthLinkedEndpoint: null,
250 });250 });
251 if (!baseUrlManuallyEdited) {251 if (!baseUrlManuallyEdited) {
252 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });252 form.setValue("baseUrl", inferredBaseUrl, { shouldDirty: false, shouldTouch: false });
@@ -257,7 +257,7 @@ export function useAddSourceFormState({ · 4 unmodified lines
257 patchUiWithAuthRevision(setUi, {257 patchUiWithAuthRevision(setUi, {
258 openApiBaseUrlOptions: [],258 openApiBaseUrlOptions: [],
259 authManuallyEdited: false,259 authManuallyEdited: false,
260 mcpOAuthLinkedEndpoint: null,260 sourceOAuthLinkedEndpoint: null,
261 });261 });
262 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });262 form.setValue("authType", "none", { shouldDirty: false, shouldTouch: false });
263 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });263 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
@@ -274,7 +274,7 @@ export function useAddSourceFormState({ · 10 unmodified lines
274 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });274 form.setValue("authType", authType, { shouldDirty: true, shouldTouch: true });
275 patchUiWithAuthRevision(setUi, {275 patchUiWithAuthRevision(setUi, {
276 authManuallyEdited: true,276 authManuallyEdited: true,
277 ...(authType === "bearer" ? {} : { mcpOAuthLinkedEndpoint: null }),277 ...(authType === "bearer" ? {} : { sourceOAuthLinkedEndpoint: null }),
278 });278 });
279 };279 };
280 280
@@ -282,7 +282,7 @@ export function useAddSourceFormState({ · 1 unmodified line
282 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });282 form.setValue("authScope", authScope, { shouldDirty: true, shouldTouch: true });
283 patchUiWithAuthRevision(setUi, {283 patchUiWithAuthRevision(setUi, {
284 authManuallyEdited: true,284 authManuallyEdited: true,
285 mcpOAuthLinkedEndpoint: null,285 sourceOAuthLinkedEndpoint: null,
286 });286 });
287 };287 };
288 288
@@ -295,7 +295,7 @@ export function useAddSourceFormState({ · 6 unmodified lines
295 295
296 if (field === "tokenValue") {296 if (field === "tokenValue") {
297 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });297 form.setValue("tokenValue", value, { shouldDirty: true, shouldTouch: true });
298 patchUiWithAuthRevision(setUi, { mcpOAuthLinkedEndpoint: null });298 patchUiWithAuthRevision(setUi, { sourceOAuthLinkedEndpoint: null });
299 return;299 return;
300 }300 }
301 if (field === "apiKeyValue") {301 if (field === "apiKeyValue") {
@@ -394,19 +394,21 @@ export function useAddSourceFormState({ · 92 unmodified lines
394 : "Failed to fetch spec"394 : "Failed to fetch spec"
395 : "";395 : "";
396 396
397 const mcpDetectionEndpoint = useDeferredValue(values.endpoint.trim());397 const sourceOAuthDetectionEndpoint = useDeferredValue(values.type === "openapi"
398 const mcpDetectionEnabled = open398 ? (values.baseUrl.trim() || values.endpoint).trim()
399 : values.endpoint.trim());
400 const sourceOAuthDetectionEnabled = open
399 && ui.view === "custom"401 && ui.view === "custom"
400 && values.type === "mcp"402 && (values.type === "mcp" || values.type === "openapi")
401 && mcpDetectionEndpoint.length > 0;403 && sourceOAuthDetectionEndpoint.length > 0;
402 404
403 const mcpOAuthQuery = useTanstackQuery({405 const sourceOAuthQuery = useTanstackQuery({
404 queryKey: ["mcp-oauth-detect", mcpDetectionEndpoint],406 queryKey: ["mcp-oauth-detect", sourceOAuthDetectionEndpoint],
405 queryFn: async () => {407 queryFn: async () => {
406 const controller = new AbortController();408 const controller = new AbortController();
407 const timeoutId = setTimeout(() => controller.abort(), MCP_OAUTH_DETECT_REQUEST_TIMEOUT_MS);409 const timeoutId = setTimeout(() => controller.abort(), MCP_OAUTH_DETECT_REQUEST_TIMEOUT_MS);
408 try {410 try {
409 const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(mcpDetectionEndpoint)}`, {411 const response = await fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(sourceOAuthDetectionEndpoint)}`, {
410 signal: controller.signal,412 signal: controller.signal,
411 cache: "no-store",413 cache: "no-store",
412 });414 });
@@ -437,34 +439,37 @@ export function useAddSourceFormState({ · 24 unmodified lines
437 clearTimeout(timeoutId);439 clearTimeout(timeoutId);
438 }440 }
439 },441 },
440 enabled: mcpDetectionEnabled,442 enabled: sourceOAuthDetectionEnabled,
441 retry: false,443 retry: false,
442 staleTime: 60_000,444 staleTime: 60_000,
443 });445 });
444 446
445 const mcpOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !mcpDetectionEnabled447 const sourceOAuthStatus: "idle" | "checking" | "oauth" | "none" | "error" = !sourceOAuthDetectionEnabled
446 ? "idle"448 ? "idle"
447 : mcpOAuthQuery.isFetching449 : sourceOAuthQuery.isFetching
448 ? "checking"450 ? "checking"
449 : mcpOAuthQuery.isError451 : sourceOAuthQuery.isError
450 ? "error"452 ? "error"
451 : mcpOAuthQuery.data?.oauth453 : sourceOAuthQuery.data?.oauth
452 ? "oauth"454 ? "oauth"
453 : "none";455 : "none";
454 456
455 const mcpOAuthDetail = mcpDetectionEnabled457 const sourceOAuthDetail = sourceOAuthDetectionEnabled
456 ? mcpOAuthQuery.isError458 ? sourceOAuthQuery.isError
457 ? mcpOAuthQuery.error instanceof Error459 ? sourceOAuthQuery.error instanceof Error
458 ? mcpOAuthQuery.error.message460 ? sourceOAuthQuery.error.message
459 : "OAuth detection failed"461 : "OAuth detection failed"
460 : mcpOAuthQuery.data?.detail ?? ""462 : sourceOAuthQuery.data?.detail ?? ""
461 : "";463 : "";
462 464
463 const mcpOAuthAuthorizationServers = mcpOAuthQuery.data?.authorizationServers ?? [];465 const sourceOAuthAuthorizationServers = sourceOAuthQuery.data?.authorizationServers ?? [];
464 const mcpOAuthConnected = values.type === "mcp"466 const sourceOAuthConnectedEndpoint = values.type === "openapi"
467 ? values.baseUrl.trim() || values.endpoint
468 : values.endpoint;
469 const sourceOAuthConnected = (values.type === "mcp" || values.type === "openapi")
465 && values.authType === "bearer"470 && values.authType === "bearer"
466 && normalizeEndpointForOAuth(values.endpoint) !== ""471 && normalizeEndpointForOAuth(sourceOAuthConnectedEndpoint) !== ""
467 && normalizeEndpointForOAuth(values.endpoint) === ui.mcpOAuthLinkedEndpoint472 && normalizeEndpointForOAuth(sourceOAuthConnectedEndpoint) === ui.sourceOAuthLinkedEndpoint
468 && values.tokenValue.trim().length > 0;473 && values.tokenValue.trim().length > 0;
469 474
470 useEffect(() => {475 useEffect(() => {
@@ -525,10 +530,10 @@ export function useAddSourceFormState({ · 54 unmodified lines
525 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);530 }, [inspectionEnabled, inspectionEndpoint, specInspectionQuery.isError, ui.openApiBaseUrlOptions]);
526 531
527 useEffect(() => {532 useEffect(() => {
528 if (values.type !== "mcp") {533 if (values.type !== "mcp" && values.type !== "openapi") {
529 return;534 return;
530 }535 }
531 if (mcpOAuthStatus !== "oauth") {536 if (sourceOAuthStatus !== "oauth") {
532 return;537 return;
533 }538 }
534 if (values.authType !== "bearer") {539 if (values.authType !== "bearer") {
@@ -537,7 +542,7 @@ export function useAddSourceFormState({ · 2 unmodified lines
537 if (values.authScope !== "workspace") {542 if (values.authScope !== "workspace") {
538 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });543 form.setValue("authScope", "workspace", { shouldDirty: false, shouldTouch: false });
539 }544 }
540 }, [form, mcpOAuthStatus, values.authScope, values.authType, values.type]);545 }, [form, sourceOAuthStatus, values.authScope, values.authType, values.type]);
541 546
542 const isNameTaken = (candidate: string) => {547 const isNameTaken = (candidate: string) => {
543 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());548 const taken = [...getTakenSourceNames()].map((entry) => entry.toLowerCase());
@@ -570,11 +575,11 @@ export function useAddSourceFormState({ · 26 unmodified lines
570 visibleCatalogItems,575 visibleCatalogItems,
571 specStatus,576 specStatus,
572 specError,577 specError,
573 mcpOAuthStatus,578 sourceOAuthStatus,
574 mcpOAuthDetail,579 sourceOAuthDetail,
575 mcpOAuthAuthorizationServers,580 sourceOAuthAuthorizationServers,
576 mcpOAuthConnected,581 sourceOAuthConnected,
577 hasPersistedMcpBearerToken,582 hasPersistedSourceBearerToken,
578 inferredSpecAuth,583 inferredSpecAuth,
579 authType: values.authType,584 authType: values.authType,
580 authScope: values.authScope,585 authScope: values.authScope,
@@ -595,9 +600,9 @@ export function useAddSourceFormState({ · 14 unmodified lines
595 handleAuthScopeChange,600 handleAuthScopeChange,
596 handleScopePresetChange: (scopePreset: SharingScope) => applySharingScope(form, scopePreset),601 handleScopePresetChange: (scopePreset: SharingScope) => applySharingScope(form, scopePreset),
597 handleAuthFieldChange,602 handleAuthFieldChange,
598 markMcpOAuthLinked: (endpoint: string) =>603 markSourceOAuthLinked: (endpoint: string) =>
599 patchUi(setUi, {604 patchUi(setUi, {
600 mcpOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),605 sourceOAuthLinkedEndpoint: normalizeEndpointForOAuth(endpoint),
601 }),606 }),
602 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),607 setBaseUrl: (baseUrl: string) => form.setValue("baseUrl", baseUrl, { shouldDirty: true, shouldTouch: true }),
603 setMcpTransport: (mcpTransport: "auto" | "streamable-http" | "sse") =>608 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
2import { parse as parseYaml } from "yaml";2import { parse as parseYaml } from "yaml";
3import { z } from "zod";3import { z } from "zod";
4import type { CredentialScope, SourceAuthType } from "@/lib/types";4import type { CredentialScope, SourceAuthType } from "@/lib/types";
5import { HARD_CODED_CATALOG_ITEMS } from "@/lib/catalog-collections";
5 6
6type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;7type SupportedAuthType = Exclude<SourceAuthType, "none" | "mixed">;
7 8
@@ -17,6 +18,147 @@ type OpenApiInspectionResult = { · 9 unmodified lines
17 inferredAuth: InferredSpecAuth;18 inferredAuth: InferredSpecAuth;
18};19};
19 20
21export type OpenApiSpecDiscoveryResult = OpenApiInspectionResult & {
22 specUrl: string;
23 discovered: boolean;
24 attemptedSpecUrls: string[];
25};
26
27const 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
49const 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
72function 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
78function 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
98function 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
20const recordSchema = z.record(z.unknown());162const recordSchema = z.record(z.unknown());
21 163
22const securityRequirementSchema = z.record(z.array(z.unknown()).optional());164const securityRequirementSchema = z.record(z.array(z.unknown()).optional());
@@ -183,3 +325,42 @@ export async function fetchAndInspectOpenApiSpec(input: { · 160 unmodified lines
183 const inferredAuth = inferSecuritySchemaAuth(spec);325 const inferredAuth = inferSecuritySchemaAuth(spec);
184 return { spec, inferredAuth };326 return { spec, inferredAuth };
185}327}
328
329export 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
73 workspaceId: `ws_${crypto.randomUUID()}` as Id<"workspaces">,73 workspaceId: `ws_${crypto.randomUUID()}` as Id<"workspaces">,
74 clientId: "mcp",74 clientId: "mcp",
75 accountId: `account_${crypto.randomUUID()}` as Id<"accounts">,75 accountId: `account_${crypto.randomUUID()}` as Id<"accounts">,
76 userId: `member_${crypto.randomUUID()}` as Id<"workspaceMembers">,
77 createdAt: now,76 createdAt: now,
78 lastSeenAt: now,77 lastSeenAt: now,
79 };78 };
executor/packages/core/src/types.ts+0−1
@@ -170,7 +170,6 @@ export interface AnonymousContext { · 169 unmodified lines
170 workspaceId: Id<"workspaces">;170 workspaceId: Id<"workspaces">;
171 clientId: string;171 clientId: string;
172 accountId: Id<"accounts">;172 accountId: Id<"accounts">;
173 userId: Id<"workspaceMembers">;
174 createdAt: number;173 createdAt: number;
175 lastSeenAt: number;174 lastSeenAt: number;
176}175}
executor/packages/database/convex/_generated/api.d.ts+0−60
@@ -479,12 +479,6 @@ export declare const internal: { · 478 unmodified lines
479 { accountId?: string; clientId?: string; sessionId?: string },479 { accountId?: string; clientId?: string; sessionId?: string },
480 any480 any
481 >;481 >;
482 ensureAnonymousMcpSession: FunctionReference<
483 "mutation",
484 "internal",
485 { accountId: string; workspaceId: Id<"workspaces"> },
486 any
487 >;
488 };482 };
489 approvals: {483 approvals: {
490 createApproval: FunctionReference<484 createApproval: FunctionReference<
@@ -621,12 +615,6 @@ export declare const internal: { · 130 unmodified lines
621 { sourceId: string; workspaceId: Id<"workspaces"> },615 { sourceId: string; workspaceId: Id<"workspaces"> },
622 any616 any
623 >;617 >;
624 ensureAnonymousMcpSession: FunctionReference<
625 "mutation",
626 "internal",
627 { accountId: string; workspaceId: Id<"workspaces"> },
628 any
629 >;
630 finishToolCall: FunctionReference<618 finishToolCall: FunctionReference<
631 "mutation",619 "mutation",
632 "internal",620 "internal",
@@ -1122,54 +1110,6 @@ export declare const internal: { · 489 unmodified lines
1122 >;1110 >;
1123 };1111 };
1124 migrations: {1112 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 >;
1173 run: FunctionReference<1113 run: FunctionReference<
1174 "mutation",1114 "mutation",
1175 "internal",1115 "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
126 expect(first.sessionId).toContain("anon_session_");126 expect(first.sessionId).toContain("anon_session_");
127 expect(first.workspaceId.length).toBeGreaterThan(0);127 expect(first.workspaceId.length).toBeGreaterThan(0);
128 expect(first.accountId).toBeDefined();128 expect(first.accountId).toBeDefined();
129 expect(first.userId).toBeDefined();
130 129
131 const again = await t.mutation(internal.database.bootstrapAnonymousSession, {130 const again = await t.mutation(internal.database.bootstrapAnonymousSession, {
132 sessionId: first.sessionId,131 sessionId: first.sessionId,
@@ -134,7 +133,6 @@ test("anonymous bootstrap links guest account membership", async () => { · 1 unmodified line
134 133
135 expect(again.sessionId).toBe(first.sessionId);134 expect(again.sessionId).toBe(first.sessionId);
136 expect(again.accountId).toBe(first.accountId);135 expect(again.accountId).toBe(first.accountId);
137 expect(again.userId).toBe(first.userId);
138});136});
139 137
140test("bootstrap ignores non-MCP caller-provided session id", async () => {138test("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
22 finishToolCall,22 finishToolCall,
23 listToolCalls,23 listToolCalls,
24} from "./database/tool_calls";24} from "./database/tool_calls";
25export { bootstrapAnonymousSession, ensureAnonymousMcpSession } from "./database/anonymous_session";25export { bootstrapAnonymousSession } from "./database/anonymous_session";
26export {26export {
27 listRuntimeTargets,27 listRuntimeTargets,
28 upsertAccessPolicy,28 upsertAccessPolicy,
executor/packages/database/convex/database/anonymous_session.ts+0−76
@@ -37,7 +37,6 @@ export const bootstrapAnonymousSession = internalMutation({ · 36 unmodified lines
37 clientId,37 clientId,
38 workspaceId: identity.workspaceId,38 workspaceId: identity.workspaceId,
39 accountId: identity.accountId,39 accountId: identity.accountId,
40 userId: identity.userId,
41 lastSeenAt: now,40 lastSeenAt: now,
42 });41 });
43 42
@@ -70,7 +69,6 @@ export const bootstrapAnonymousSession = internalMutation({ · 26 unmodified lines
70 workspaceId: identity.workspaceId,69 workspaceId: identity.workspaceId,
71 clientId,70 clientId,
72 accountId: identity.accountId,71 accountId: identity.accountId,
73 userId: identity.userId,
74 createdAt: now,72 createdAt: now,
75 lastSeenAt: now,73 lastSeenAt: now,
76 });74 });
@@ -86,77 +84,3 @@ export const bootstrapAnonymousSession = internalMutation({ · 9 unmodified lines
86 return mapAnonymousContext(created);84 return mapAnonymousContext(created);
87 },85 },
88});86});
89
90export 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 @@
1import { expect, test } from "bun:test";
2import { generateKeyPairSync } from "node:crypto";
3import { convexTest } from "convex-test";
4import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6import { internal } from "./_generated/api";
7import type { Id } from "./_generated/dataModel.d.ts";
8import schema from "./schema";
9
10function 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
20ensureAnonymousAuthKeysConfigured();
21
22function 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
37type AnonymousTokenResponse = {
38 error?: unknown;
39 accessToken?: unknown;
40};
41
42function readErrorMessage(body: AnonymousTokenResponse): string {
43 return typeof body.error === "string" ? body.error : "";
44}
45
46function readAccessToken(body: AnonymousTokenResponse): string | null {
47 return typeof body.accessToken === "string" && body.accessToken.length > 0 ? body.accessToken : null;
48}
49
50async 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
70async 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
103async 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
116async 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
135test("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
186test("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 @@
1import { expect, test } from "bun:test";
2import { generateKeyPairSync } from "node:crypto";
3import { convexTest } from "convex-test";
4import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
6import { internal } from "../../_generated/api";
7import schema from "../../schema";
8
9const OOM_MESSAGE = "JavaScript execution ran out of memory (maximum memory usage: 64 MB): request stream size was 0 bytes";
10
11function 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
21ensureAnonymousAuthKeysConfigured();
22
23function 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
38type AnonymousTokenResponse = {
39 error?: unknown;
40 accessToken?: unknown;
41};
42
43function readErrorMessage(body: AnonymousTokenResponse): string {
44 return typeof body.error === "string" ? body.error : "";
45}
46
47function readAccessToken(body: AnonymousTokenResponse): string | null {
48 return typeof body.accessToken === "string" && body.accessToken.length > 0 ? body.accessToken : null;
49}
50
51async 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
71async 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
104test("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
4 4
5const migrations = new Migrations<DataModel>(components.migrations);5const migrations = new Migrations<DataModel>(components.migrations);
6 6
7export 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
18export 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
29export 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
39export 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
51export const run = migrations.runner();7export const run = migrations.runner();
executor/packages/database/convex/schema.ts+1−2
@@ -508,7 +508,7 @@ export default defineSchema({ · 507 unmodified lines
508 508
509 // Anonymous session linkage.509 // Anonymous session linkage.
510 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a510 // Used to map an unauthenticated/anonymous account to a backing `accounts` row and a
511 // `workspaceMembers` user entry.511 // workspace.
512 //512 //
513 // Primary access patterns:513 // Primary access patterns:
514 // - Resolve by session id.514 // - Resolve by session id.
@@ -519,7 +519,6 @@ export default defineSchema({ · 4 unmodified lines
519 workspaceId: v.id("workspaces"),519 workspaceId: v.id("workspaces"),
520 clientId: v.string(), // client label: "web", "mcp", etc.520 clientId: v.string(), // client label: "web", "mcp", etc.
521 accountId: v.id("accounts"),521 accountId: v.id("accounts"),
522 userId: v.id("workspaceMembers"),
523 createdAt: v.number(),522 createdAt: v.number(),
524 lastSeenAt: v.number(),523 lastSeenAt: v.number(),
525 })524 })
executor/packages/database/src/database/anonymous.ts+0−12
@@ -98,20 +98,8 @@ export async function ensureAnonymousIdentity( · 97 unmodified lines
98 now,98 now,
99 });99 });
100 100
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
112 return {101 return {
113 accountId: account._id,102 accountId: account._id,
114 workspaceId: workspace._id,103 workspaceId: workspace._id,
115 userId: user._id,
116 };104 };
117}105}
executor/packages/database/src/database/readers.ts+0−1
@@ -13,7 +13,6 @@ export function mapAnonymousContext(doc: Doc<"anonymousSessions">) { · 12 unmodified lines
13 workspaceId: doc.workspaceId,13 workspaceId: doc.workspaceId,
14 clientId: doc.clientId,14 clientId: doc.clientId,
15 accountId: doc.accountId,15 accountId: doc.accountId,
16 userId: doc.userId,
17 createdAt: doc.createdAt,16 createdAt: doc.createdAt,
18 lastSeenAt: doc.lastSeenAt,17 lastSeenAt: doc.lastSeenAt,
19 };18 };