History

feat: add native stdio MCP support

Rhys Sullivan committed 8 months ago

Commit 567c4891 parent 81bcb9d20 files · +2528 −805Browse files

Unified · Split

20 files
  1. apps/web/src/views/add-source.tsx +768 −278
  2. apps/web/src/views/source-editor.tsx +749 −351
  3. apps/web/src/views/source-templates.ts +63 −20
  4. packages/platform/control-plane/src/api/sources/http.ts +4 −0
  5. packages/platform/control-plane/src/runtime/execution/live.ts +12 −5
  6. packages/platform/control-plane/src/runtime/execution/mcp-resume.test.ts +22 −18
  7. packages/platform/control-plane/src/runtime/index.ts +1 −1
  8. packages/platform/control-plane/src/runtime/sources/source-adapters/mcp.test.ts +441 −0
  9. packages/platform/control-plane/src/runtime/sources/source-auth-service.ts +23 −19
  10. packages/platform/control-plane/src/runtime/sources/source-definitions.test.ts +9 −1
  11. packages/sources/core/src/shared.ts +14 −0
  12. packages/sources/core/src/source-models.ts +4 −0
  13. packages/sources/core/src/types.ts +5 −0
  14. packages/sources/mcp/src/adapter.ts +64 −48
  15. packages/sources/mcp/src/connection-pool.ts +151 −0
  16. packages/sources/mcp/src/connection.ts +178 −54
  17. packages/sources/mcp/src/index.ts +8 −0
  18. packages/sources/mcp/src/local-config.ts +5 −0
  19. packages/sources/mcp/src/tools.test.ts +4 −4
  20. packages/sources/mcp/src/tools.ts +3 −6
apps/web/src/views/add-source.tsx+768−278
@@ -36,7 +36,11 @@ import { · 35 unmodified lines
3636 IconSpinner,
3737 } from "../components/icons";
3838 import { cn } from "../lib/utils";
39−import { sourceTemplates, type SourceTemplate } from "./source-templates";
39+import {
40+ isStdioMcpSourceTemplate,
41+ sourceTemplates,
42+ type SourceTemplate,
43+} from "./source-templates";
4044 import { getDomain } from "tldts";
4145
4246 // ---------------------------------------------------------------------------
@@ -73,7 +77,7 @@ type ConnectFormState = { · 30 unmodified lines
7377 discoveryUrl: string;
7478 name: string;
7579 namespace: string;
76− transport: "" | "auto" | "streamable-http" | "sse";
80+ transport: "" | "auto" | "streamable-http" | "sse" | "stdio";
7781 authKind: "none" | "bearer" | "oauth2";
7882 authHeaderName: string;
7983 authPrefix: string;
@@ -85,6 +89,10 @@ type ConnectFormState = { · 5 unmodified lines
8589 oauthClientSecret: string;
8690 queryParamsText: string;
8791 headersText: string;
92+ command: string;
93+ argsText: string;
94+ envText: string;
95+ cwd: string;
8896 };
8997
9098 type OAuthRequiredInfo = {
@@ -110,15 +118,22 @@ const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [ · 19 unmodified lines
110118 "google_discovery",
111119 ];
112120
113−const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [
114− "auto",
115− "streamable-http",
116− "sse",
117−];
121+const transportOptions: ReadonlyArray<
122+ "auto" | "streamable-http" | "sse" | "stdio"
123+> = ["auto", "streamable-http", "sse", "stdio"];
118124
119−const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = ["none", "bearer", "oauth2"];
125+const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [
126+ "none",
127+ "bearer",
128+ "oauth2",
129+];
120130
121−const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = ["none", "bearer", "basic", "headers"];
131+const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [
132+ "none",
133+ "bearer",
134+ "basic",
135+ "headers",
136+];
122137
123138 const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
124139 const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
@@ -145,16 +160,24 @@ const namespaceFromUrl = (url: string): string => { · 20 unmodified lines
145160 };
146161
147162 const googleDiscoveryNamespace = (service: string): string =>
148− `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
149−
150−const googleDiscoveryDefaultsFromUrl = (value: string): {
163+ `google.${service
164+ .trim()
165+ .toLowerCase()
166+ .replace(/[^a-z0-9]+/g, ".")
167+ .replace(/^\.+|\.+$/g, "")}`;
168+
169+const googleDiscoveryDefaultsFromUrl = (
170+ value: string,
171+): {
151172 service: string;
152173 version: string;
153174 discoveryUrl: string;
154175 } | null => {
155176 try {
156177 const url = new URL(value);
157− const byDirectory = url.pathname.match(/^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/);
178+ const byDirectory = url.pathname.match(
179+ /^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/,
180+ );
158181 if (byDirectory) {
159182 return {
160183 service: decodeURIComponent(byDirectory[1] ?? ""),
@@ -165,9 +188,10 @@ const googleDiscoveryDefaultsFromUrl = (value: string): { · 4 unmodified lines
165188
166189 const versionParam = url.searchParams.get("version");
167190 const version = versionParam ? trimToNull(versionParam) : null;
168− const isHostScopedDiscovery = url.pathname === "/$discovery/rest"
169− && url.hostname.endsWith(".googleapis.com")
170− && url.hostname !== "www.googleapis.com";
191+ const isHostScopedDiscovery =
192+ url.pathname === "/$discovery/rest" &&
193+ url.hostname.endsWith(".googleapis.com") &&
194+ url.hostname !== "www.googleapis.com";
171195 if (version && isHostScopedDiscovery) {
172196 return {
173197 service: url.hostname.split(".")[0] ?? "",
@@ -182,7 +206,10 @@ const googleDiscoveryDefaultsFromUrl = (value: string): { · 8 unmodified lines
182206 }
183207 };
184208
185−const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
209+const parseJsonStringMap = (
210+ label: string,
211+ text: string,
212+): Record<string, string> | null => {
186213 const trimmed = text.trim();
187214 if (trimmed.length === 0) {
188215 return null;
@@ -211,11 +238,73 @@ const parseJsonStringMap = (label: string, text: string): Record<string, string> · 22 unmodified lines
211238 return Object.keys(normalized).length === 0 ? null : normalized;
212239 };
213240
241+const parseJsonStringArray = (
242+ label: string,
243+ text: string,
244+): Array<string> | null => {
245+ const trimmed = text.trim();
246+ if (trimmed.length === 0) {
247+ return null;
248+ }
249+
250+ let parsed: unknown;
251+ try {
252+ parsed = JSON.parse(trimmed);
253+ } catch {
254+ throw new Error(`${label} must be valid JSON.`);
255+ }
256+
257+ if (
258+ !Array.isArray(parsed) ||
259+ !parsed.every((value) => typeof value === "string")
260+ ) {
261+ throw new Error(`${label} must be a JSON array of strings.`);
262+ }
263+
264+ const normalized = parsed
265+ .map((value) => value.trim())
266+ .filter((value) => value.length > 0);
267+
268+ return normalized.length > 0 ? normalized : null;
269+};
270+
271+const stringifyStringMap = (
272+ value: Readonly<Record<string, string>> | null | undefined,
273+): string =>
274+ !value || Object.keys(value).length === 0
275+ ? ""
276+ : JSON.stringify(value, null, 2);
277+
278+const stringifyStringArray = (
279+ value: ReadonlyArray<string> | null | undefined,
280+): string =>
281+ !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
282+
283+const buildSyntheticMcpStdioEndpoint = (input: {
284+ name?: string | null;
285+ endpoint?: string | null;
286+ command?: string | null;
287+}): string => {
288+ const label =
289+ input.name?.trim() ||
290+ input.endpoint?.trim() ||
291+ input.command?.trim() ||
292+ "mcp";
293+ const slug = label
294+ .toLowerCase()
295+ .replace(/[^a-z0-9]+/g, "-")
296+ .replace(/^-+|-+$/g, "");
297+
298+ return `stdio://local/${slug || "mcp"}`;
299+};
300+
214301 // ---------------------------------------------------------------------------
215302 // Derived defaults from discovery result
216303 // ---------------------------------------------------------------------------
217304
218−const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState => {
305+const defaultConnectForm = (
306+ discovery?: SourceDiscoveryResult,
307+): ConnectFormState => {
219308 if (!discovery || discovery.detectedKind === "unknown") {
220309 return {
221310 kind: "openapi",
@@ -225,7 +314,8 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 3 unmodified lines
225314 version: "",
226315 discoveryUrl: "",
227316 name: discovery?.name ?? "",
228− namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
317+ namespace:
318+ discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
229319 transport: "",
230320 authKind: "none",
231321 authHeaderName: "Authorization",
@@ -238,6 +328,10 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 6 unmodified lines
238328 oauthClientSecret: "",
239329 queryParamsText: "",
240330 headersText: "",
331+ command: "",
332+ argsText: "",
333+ envText: "",
334+ cwd: "",
241335 };
242336 }
243337
@@ -266,9 +360,10 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 22 unmodified lines
266360 }
267361 }
268362
269− const googleDiscoveryDefaults = kind === "google_discovery"
270− ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
271− : null;
363+ const googleDiscoveryDefaults =
364+ kind === "google_discovery"
365+ ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
366+ : null;
272367
273368 return {
274369 kind,
@@ -291,24 +386,41 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 16 unmodified lines
291386 oauthClientSecret: "",
292387 queryParamsText: "",
293388 headersText: "",
389+ command: "",
390+ argsText: "",
391+ envText: "",
392+ cwd: "",
294393 };
295394 };
296395
297−const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({
396+const connectFormFromTemplate = (
397+ template: SourceTemplate,
398+): ConnectFormState => ({
298399 ...defaultConnectForm(),
299400 kind: template.kind as ConnectFormState["kind"],
300− endpoint: template.endpoint,
401+ endpoint: template.endpoint ?? "",
301402 specUrl: "specUrl" in template ? template.specUrl : "",
302403 service: "service" in template ? template.service : "",
303404 version: "version" in template ? template.version : "",
304405 discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "",
305406 name: template.name,
306− namespace: "service" in template
307− ? googleDiscoveryNamespace(template.service)
308− : namespaceFromUrl(template.endpoint),
309− transport: template.kind === "mcp" ? "auto" : "",
407+ namespace:
408+ template.namespace ??
409+ ("service" in template
410+ ? googleDiscoveryNamespace(template.service)
411+ : namespaceFromUrl(template.endpoint ?? "")),
412+ transport:
413+ template.kind === "mcp"
414+ ? template.connectionType === "command"
415+ ? (template.transport ?? "stdio")
416+ : (template.transport ?? "auto")
417+ : "",
310418 authKind: template.kind === "google_discovery" ? "oauth2" : "none",
311419 workspaceOauthClientId: "",
420+ command: template.kind === "mcp" ? (template.command ?? "") : "",
421+ argsText: template.kind === "mcp" ? stringifyStringArray(template.args) : "",
422+ envText: template.kind === "mcp" ? stringifyStringMap(template.env) : "",
423+ cwd: template.kind === "mcp" ? (template.cwd ?? "") : "",
312424 });
313425
314426 const authKindForSourceKind = (
@@ -319,10 +431,13 @@ const authKindForSourceKind = ( · 4 unmodified lines
319431 ? "oauth2"
320432 : currentAuthKind;
321433
322−const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => {
434+const buildProbeAuth = (
435+ state: ProbeAuthState,
436+): DiscoverSourcePayload["probeAuth"] => {
323437 if (state.kind === "none") return { kind: "none" };
324438 if (state.kind === "bearer") {
325− if (!state.token.trim()) throw new Error("Token is required for bearer probe auth.");
439+ if (!state.token.trim())
440+ throw new Error("Token is required for bearer probe auth.");
326441 return {
327442 kind: "bearer",
328443 headerName: trimToNull(state.headerName),
@@ -331,7 +446,8 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 2 unmodified lines
331446 };
332447 }
333448 if (state.kind === "basic") {
334− if (!state.username.trim()) throw new Error("Username is required for basic probe auth.");
449+ if (!state.username.trim())
450+ throw new Error("Username is required for basic probe auth.");
335451 return {
336452 kind: "basic",
337453 username: state.username.trim(),
@@ -340,22 +456,41 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 2 unmodified lines
340456 }
341457 // headers
342458 const headers = parseJsonStringMap("Probe headers", state.headersText);
343− if (!headers) throw new Error("At least one header is required for headers probe auth.");
459+ if (!headers)
460+ throw new Error("At least one header is required for headers probe auth.");
344461 return { kind: "headers", headers };
345462 };
346463
347464 const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {
348465 if (form.kind === "mcp") {
349− const endpoint = form.endpoint.trim();
350− if (!endpoint) throw new Error("Endpoint is required.");
466+ const transport = form.transport === "" ? "auto" : form.transport;
467+ const isStdio = transport === "stdio";
468+ const endpoint = isStdio
469+ ? buildSyntheticMcpStdioEndpoint({
470+ name: form.name,
471+ endpoint: form.endpoint,
472+ command: form.command,
473+ })
474+ : form.endpoint.trim();
475+ if (!isStdio && !endpoint) throw new Error("Endpoint is required.");
476+ if (isStdio && !form.command.trim())
477+ throw new Error("Command is required for stdio MCP sources.");
351478 return {
352479 kind: "mcp",
353480 endpoint,
354481 name: trimToNull(form.name),
355482 namespace: trimToNull(form.namespace),
356− transport: form.transport === "" ? "auto" : form.transport,
357− queryParams: parseJsonStringMap("Query params", form.queryParamsText),
358− headers: parseJsonStringMap("Request headers", form.headersText),
483+ transport,
484+ queryParams: isStdio
485+ ? null
486+ : parseJsonStringMap("Query params", form.queryParamsText),
487+ headers: isStdio
488+ ? null
489+ : parseJsonStringMap("Request headers", form.headersText),
490+ command: isStdio ? form.command.trim() : null,
491+ args: isStdio ? parseJsonStringArray("Args", form.argsText) : null,
492+ env: isStdio ? parseJsonStringMap("Environment", form.envText) : null,
493+ cwd: isStdio ? trimToNull(form.cwd) : null,
359494 };
360495 }
361496
@@ -380,14 +515,18 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 18 unmodified lines
380515 if (form.kind === "google_discovery") {
381516 const service = form.service.trim();
382517 const version = form.version.trim();
383− if (!service) throw new Error("Google Discovery sources require a service name.");
384− if (!version) throw new Error("Google Discovery sources require a version.");
518+ if (!service)
519+ throw new Error("Google Discovery sources require a service name.");
520+ if (!version)
521+ throw new Error("Google Discovery sources require a version.");
385522 if (
386− form.authKind === "oauth2"
387− && form.workspaceOauthClientId.trim().length === 0
388− && form.oauthClientId.trim().length === 0
523+ form.authKind === "oauth2" &&
524+ form.workspaceOauthClientId.trim().length === 0 &&
525+ form.oauthClientId.trim().length === 0
389526 ) {
390− throw new Error("Google OAuth requires a workspace OAuth client or a new client ID.");
527+ throw new Error(
528+ "Google OAuth requires a workspace OAuth client or a new client ID.",
529+ );
391530 }
392531 return {
393532 kind: "google_discovery",
@@ -397,11 +536,13 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 3 unmodified lines
397536 name: trimToNull(form.name),
398537 namespace: trimToNull(form.namespace),
399538 workspaceOauthClientId:
400− form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length > 0
539+ form.authKind === "oauth2" &&
540+ form.workspaceOauthClientId.trim().length > 0
401541 ? form.workspaceOauthClientId.trim()
402542 : undefined,
403543 oauthClient:
404− form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length === 0
544+ form.authKind === "oauth2" &&
545+ form.workspaceOauthClientId.trim().length === 0
405546 ? {
406547 clientId: form.oauthClientId.trim(),
407548 clientSecret: trimToNull(form.oauthClientSecret),
@@ -424,7 +565,16 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 16 unmodified lines
424565
425566 const buildHttpAuth = (
426567 form: ConnectFormState,
427−): { kind: "none" } | { kind: "bearer"; headerName?: string | null; prefix?: string | null; token?: string | null; tokenRef?: { providerId: string; handle: string } | null } | undefined => {
568+):
569+ | { kind: "none" }
570+ | {
571+ kind: "bearer";
572+ headerName?: string | null;
573+ prefix?: string | null;
574+ token?: string | null;
575+ tokenRef?: { providerId: string; handle: string } | null;
576+ }
577+ | undefined => {
428578 if (form.authKind === "none") return { kind: "none" };
429579
430580 if (form.authKind === "bearer") {
@@ -454,7 +604,9 @@ const buildHttpAuth = ( · 23 unmodified lines
454604 };
455605 }
456606
457− throw new Error("Bearer auth requires a token. Select or create a secret, or enter a token directly.");
607+ throw new Error(
608+ "Bearer auth requires a token. Select or create a secret, or enter a token directly.",
609+ );
458610 }
459611
460612 // oauth2 is handled via the connect result flow, not pre-filled
@@ -489,7 +641,9 @@ type SourceOAuthPopupMessage = · 28 unmodified lines
489641 error: string;
490642 };
491643
492−const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
644+const readStoredSourceOAuthPopupResult = (
645+ sessionId: string,
646+): SourceOAuthPopupMessage | null => {
493647 if (typeof window === "undefined") return null;
494648 const raw = window.localStorage.getItem(
495649 `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
@@ -504,7 +658,9 @@ const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMe · 8 unmodified lines
504658
505659 const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {
506660 if (typeof window === "undefined") return;
507− window.localStorage.removeItem(`${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`);
661+ window.localStorage.removeItem(
662+ `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
663+ );
508664 };
509665
510666 const startSourceOAuthPopup = async (input: {
@@ -566,7 +722,12 @@ const startSourceOAuthPopup = async (input: { · 55 unmodified lines
566722 if (!data) return;
567723 if (data.type === "executor:oauth-result") {
568724 if (data.ok && data.sessionId !== input.sessionId) return;
569− if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return;
725+ if (
726+ !data.ok &&
727+ data.sessionId !== null &&
728+ data.sessionId !== input.sessionId
729+ )
730+ return;
570731 } else if (data.type !== "executor:source-oauth-result") {
571732 return;
572733 }
@@ -576,7 +737,9 @@ const startSourceOAuthPopup = async (input: { · 3 unmodified lines
576737 window.addEventListener("message", onMessage);
577738
578739 resultTimeout = window.setTimeout(() => {
579− settleWithError("OAuth popup timed out before completion. Please try again.");
740+ settleWithError(
741+ "OAuth popup timed out before completion. Please try again.",
742+ );
580743 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
581744
582745 closedPoll = window.setInterval(() => {
@@ -591,7 +754,9 @@ const startSourceOAuthPopup = async (input: { · 8 unmodified lines
591754 window.clearInterval(closedPoll);
592755 closedPoll = 0;
593756 window.setTimeout(() => {
594− const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId);
757+ const delayedStored = readStoredSourceOAuthPopupResult(
758+ input.sessionId,
759+ );
595760 if (delayedStored) {
596761 settleFromPayload(delayedStored);
597762 return;
@@ -607,8 +772,6 @@ const startSourceOAuthPopup = async (input: { · 9 unmodified lines
607772 // Confidence badge helper
608773 // ---------------------------------------------------------------------------
609774
610−
611−
612775 // ---------------------------------------------------------------------------
613776 // Page
614777 // ---------------------------------------------------------------------------
@@ -645,17 +808,22 @@ export function AddSourcePage() { · 30 unmodified lines
645808
646809 // Discovery result
647810 // Editable connect form (populated after discovery)
648− const [connectForm, setConnectForm] = useState<ConnectFormState>(defaultConnectForm());
811+ const [connectForm, setConnectForm] =
812+ useState<ConnectFormState>(defaultConnectForm());
649813
650814 // Connect result
651− const [connectResult, setConnectResult] = useState<ConnectSourceResult | null>(null);
815+ const [connectResult, setConnectResult] =
816+ useState<ConnectSourceResult | null>(null);
652817
653818 // OAuth required state
654819 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);
655− const [batchOauthInfo, setBatchOauthInfo] = useState<BatchOAuthRequiredInfo | null>(null);
820+ const [batchOauthInfo, setBatchOauthInfo] =
821+ useState<BatchOAuthRequiredInfo | null>(null);
656822 const [oauthBusy, setOauthBusy] = useState(false);
657823 const [batchConnecting, setBatchConnecting] = useState(false);
658− const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<ReadonlyArray<string>>([]);
824+ const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<
825+ ReadonlyArray<string>
826+ >([]);
659827
660828 // Status banner
661829 const [statusBanner, setStatusBanner] = useState<{
@@ -663,7 +831,10 @@ export function AddSourcePage() { · 1 unmodified line
663831 text: string;
664832 } | null>(null);
665833
666− const setFormField = <K extends keyof ConnectFormState>(key: K, value: ConnectFormState[K]) => {
834+ const setFormField = <K extends keyof ConnectFormState>(
835+ key: K,
836+ value: ConnectFormState[K],
837+ ) => {
667838 setConnectForm((current) => ({ ...current, [key]: value }));
668839 };
669840
@@ -672,11 +843,15 @@ export function AddSourcePage() { · 2 unmodified lines
672843 ...current,
673844 kind,
674845 authKind: authKindForSourceKind(current.authKind, kind),
675− workspaceOauthClientId: kind === "google_discovery" ? current.workspaceOauthClientId : "",
846+ workspaceOauthClientId:
847+ kind === "google_discovery" ? current.workspaceOauthClientId : "",
676848 }));
677849 };
678850
679− const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => {
851+ const setProbeField = <K extends keyof ProbeAuthState>(
852+ key: K,
853+ value: ProbeAuthState[K],
854+ ) => {
680855 setProbeAuth((current) => ({ ...current, [key]: value }));
681856 };
682857
@@ -692,7 +867,10 @@ export function AddSourcePage() { · 9 unmodified lines
692867 setStatusBanner(null);
693868 const trimmedUrl = url.trim();
694869 if (!trimmedUrl) {
695− setStatusBanner({ tone: "error", text: "Please enter a URL to discover." });
870+ setStatusBanner({
871+ tone: "error",
872+ text: "Please enter a URL to discover.",
873+ });
696874 return;
697875 }
698876
@@ -746,8 +924,20 @@ export function AddSourcePage() { · 47 unmodified lines
746924 return;
747925 }
748926
749− const discoveryUrl = "specUrl" in template ? template.specUrl : template.endpoint;
750− setUrl(template.endpoint);
927+ if (isStdioMcpSourceTemplate(template)) {
928+ setUrl("");
929+ setConnectForm(connectFormFromTemplate(template));
930+ setPhase("editing");
931+ setStatusBanner({
932+ tone: "info",
933+ text: `${template.name} loaded. Review the local command, then connect.`,
934+ });
935+ return;
936+ }
937+
938+ const discoveryUrl =
939+ "specUrl" in template ? template.specUrl : (template.endpoint ?? "");
940+ setUrl(template.endpoint ?? "");
751941 setStatusBanner(null);
752942 setPhase("discovering");
753943
@@ -756,8 +946,19 @@ export function AddSourcePage() { · 2 unmodified lines
756946 const form = defaultConnectForm(result);
757947 // Prefer the template's own values over whatever discover returned
758948 form.name = template.name;
759− form.endpoint = template.endpoint;
760− form.namespace = namespaceFromUrl(template.endpoint);
949+ form.endpoint = template.endpoint ?? "";
950+ form.namespace =
951+ template.namespace ?? namespaceFromUrl(template.endpoint ?? "");
952+ if (template.kind === "mcp") {
953+ form.transport =
954+ template.connectionType === "command"
955+ ? (template.transport ?? "stdio")
956+ : (template.transport ?? form.transport);
957+ form.command = template.command ?? "";
958+ form.argsText = stringifyStringArray(template.args);
959+ form.envText = stringifyStringMap(template.env);
960+ form.cwd = template.cwd ?? "";
961+ }
761962 if ("specUrl" in template) {
762963 form.specUrl = template.specUrl;
763964 }
@@ -846,7 +1047,8 @@ export function AddSourcePage() { · 82 unmodified lines
8461047
8471048 try {
8481049 await startSourceOAuthPopup({
849− authorizationUrl: oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
1050+ authorizationUrl:
1051+ oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
8501052 sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,
8511053 });
8521054
@@ -892,7 +1094,9 @@ export function AddSourcePage() { · 39 unmodified lines
8921094
8931095 const clientId = connectForm.oauthClientId.trim();
8941096 if (clientId.length === 0) {
895− throw new Error("Choose an existing Google workspace OAuth client or enter a new client ID.");
1097+ throw new Error(
1098+ "Choose an existing Google workspace OAuth client or enter a new client ID.",
1099+ );
8961100 }
8971101
8981102 const created = await createWorkspaceOauthClient.mutateAsync({
@@ -918,11 +1122,14 @@ export function AddSourcePage() { · 19 unmodified lines
9181122 try {
9191123 const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId();
9201124 const selectedTemplates = googleTemplates.filter(
921− (template): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>
1125+ (
1126+ template,
1127+ ): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>
9221128 selectedGoogleTemplateIds.includes(template.id),
9231129 );
9241130 const payload: ConnectSourceBatchPayload = {
925− workspaceOauthClientId: workspaceOauthClientId as WorkspaceOauthClient["id"],
1131+ workspaceOauthClientId:
1132+ workspaceOauthClientId as WorkspaceOauthClient["id"],
9261133 sources: selectedTemplates.map((template) => ({
9271134 service: template.service,
9281135 version: template.version,
@@ -964,7 +1171,8 @@ export function AddSourcePage() { · 35 unmodified lines
9641171 setPhase("idle");
9651172 setStatusBanner({
9661173 tone: "error",
967− text: error instanceof Error ? error.message : "Batch connection failed.",
1174+ text:
1175+ error instanceof Error ? error.message : "Batch connection failed.",
9681176 });
9691177 }
9701178 };
@@ -984,11 +1192,11 @@ export function AddSourcePage() { · 13 unmodified lines
9841192 const isDiscovering = phase === "discovering";
9851193 const isConnecting = phase === "connecting";
9861194 const isBusy =
987− isDiscovering
988− || isConnecting
989− || oauthBusy
990− || connectSourceBatch.status === "pending"
991− || createWorkspaceOauthClient.status === "pending";
1195+ isDiscovering ||
1196+ isConnecting ||
1197+ oauthBusy ||
1198+ connectSourceBatch.status === "pending" ||
1199+ createWorkspaceOauthClient.status === "pending";
9921200
9931201 // -------------------------------------------------------------------------
9941202 // Render
@@ -1032,19 +1240,23 @@ export function AddSourcePage() { · 37 unmodified lines
10321240 return (
10331241 <div key={step} className="flex items-center gap-1.5">
10341242 {i > 0 && (
1035− <div className={cn(
1036− "h-px w-6 transition-colors",
1037− isComplete || isCurrent ? "bg-primary/40" : "bg-border",
1038− )} />
1243+ <div
1244+ className={cn(
1245+ "h-px w-6 transition-colors",
1246+ isComplete || isCurrent ? "bg-primary/40" : "bg-border",
1247+ )}
1248+ />
10391249 )}
1040− <div className={cn(
1041− "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",
1042− isComplete
1043− ? "text-primary"
1044− : isCurrent
1045− ? "text-foreground bg-muted/50"
1046− : "text-muted-foreground/40",
1047− )}>
1250+ <div
1251+ className={cn(
1252+ "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",
1253+ isComplete
1254+ ? "text-primary"
1255+ : isCurrent
1256+ ? "text-foreground bg-muted/50"
1257+ : "text-muted-foreground/40",
1258+ )}
1259+ >
10481260 {isComplete ? (
10491261 <IconCheck className="size-2.5" />
10501262 ) : (
@@ -1062,7 +1274,6 @@ export function AddSourcePage() { · 11 unmodified lines
10621274
10631275 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
10641276
1065−
10661277 {(phase === "idle" || phase === "discovering") && (
10671278 <LocalMcpInstallCard
10681279 className="mb-6 rounded-xl border border-border bg-card/80 p-5"
@@ -1076,7 +1287,8 @@ export function AddSourcePage() { · 7 unmodified lines
10761287 <Section title="Discover">
10771288 <div className="space-y-4">
10781289 <p className="text-[13px] text-muted-foreground">
1079− Enter a URL and we'll auto-detect the source type, endpoint, auth requirements, and more.
1290+ Enter a URL and we'll auto-detect the source type, endpoint,
1291+ auth requirements, and more.
10801292 </p>
10811293
10821294 <Field label="URL">
@@ -1094,13 +1306,23 @@ export function AddSourcePage() { · 11 unmodified lines
10941306 className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
10951307 />
10961308 <Button onClick={handleDiscover} disabled={isBusy}>
1097− {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />}
1309+ {isDiscovering ? (
1310+ <IconSpinner className="size-3.5" />
1311+ ) : (
1312+ <IconDiscover className="size-3.5" />
1313+ )}
10981314 {isDiscovering ? "Discovering\u2026" : "Discover"}
10991315 </Button>
11001316 </div>
11011317 {isDiscovering && (
11021318 <div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-muted">
1103− <div className="h-full w-1/4 rounded-full bg-primary/60" style={{ animation: "indeterminate-progress 1.2s ease-in-out infinite" }} />
1319+ <div
1320+ className="h-full w-1/4 rounded-full bg-primary/60"
1321+ style={{
1322+ animation:
1323+ "indeterminate-progress 1.2s ease-in-out infinite",
1324+ }}
1325+ />
11041326 </div>
11051327 )}
11061328 </div>
@@ -1113,7 +1335,9 @@ export function AddSourcePage() { · 6 unmodified lines
11131335 onClick={() => setShowProbeAuth(!showProbeAuth)}
11141336 className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
11151337 >
1116− {showProbeAuth ? "Hide probe auth" : "Need auth to discover?"}
1338+ {showProbeAuth
1339+ ? "Hide probe auth"
1340+ : "Need auth to discover?"}
11171341 </button>
11181342 </div>
11191343
@@ -1122,8 +1346,13 @@ export function AddSourcePage() { · 2 unmodified lines
11221346 <Field label="Auth type">
11231347 <SelectInput
11241348 value={probeAuth.kind}
1125− onChange={(v) => setProbeField("kind", v as ProbeAuthKind)}
1126− options={probeAuthOptions.map((v) => ({ value: v, label: v }))}
1349+ onChange={(v) =>
1350+ setProbeField("kind", v as ProbeAuthKind)
1351+ }
1352+ options={probeAuthOptions.map((v) => ({
1353+ value: v,
1354+ label: v,
1355+ }))}
11271356 />
11281357 </Field>
11291358
@@ -1200,48 +1429,70 @@ export function AddSourcePage() { · 70 unmodified lines
12001429 {/* ---- Template catalogue ---- */}
12011430 <div className="space-y-5 border-t border-border pt-5">
12021431 <div>
1203− <p className="text-[13px] font-medium text-foreground">Start from a template</p>
1432+ <p className="text-[13px] font-medium text-foreground">
1433+ Start from a template
1434+ </p>
12041435 <p className="mt-0.5 text-[12px] text-muted-foreground">
1205− Pick a known source to skip discovery, or select multiple Google APIs for batch connect.
1436+ Pick a known source to skip discovery, or select multiple
1437+ Google APIs for batch connect.
12061438 </p>
12071439 </div>
12081440
12091441 {/* -- Popular / non-Google templates -- */}
12101442 {(() => {
1211− const popularTemplates = sourceTemplates.filter((t) => t.groupId !== "google_workspace");
1212− return popularTemplates.length > 0 && (
1213− <div className="space-y-2.5">
1214− <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Popular</p>
1215− <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1216− {popularTemplates.map((template) => (
1217− <button
1218− key={template.id}
1219− type="button"
1220− onClick={() => applyTemplate(template)}
1221− disabled={isBusy}
1222− className="group rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-all hover:bg-accent/50 hover:border-primary/20 hover:shadow-sm disabled:opacity-60"
1223− >
1224− <div className="mb-1.5 flex items-center gap-2.5">
1225− <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50">
1226− <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-3.5" />
1443+ const popularTemplates = sourceTemplates.filter(
1444+ (t) => t.groupId !== "google_workspace",
1445+ );
1446+ return (
1447+ popularTemplates.length > 0 && (
1448+ <div className="space-y-2.5">
1449+ <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">
1450+ Popular
1451+ </p>
1452+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1453+ {popularTemplates.map((template) => (
1454+ <button
1455+ key={template.id}
1456+ type="button"
1457+ onClick={() => applyTemplate(template)}
1458+ disabled={isBusy}
1459+ className="group rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-all hover:bg-accent/50 hover:border-primary/20 hover:shadow-sm disabled:opacity-60"
1460+ >
1461+ <div className="mb-1.5 flex items-center gap-2.5">
1462+ <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50">
1463+ <SourceFavicon
1464+ endpoint={template.endpoint}
1465+ kind={template.kind}
1466+ className="size-3.5"
1467+ />
1468+ </div>
1469+ <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">
1470+ {template.name}
1471+ </span>
1472+ <Badge
1473+ variant="outline"
1474+ className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity"
1475+ >
1476+ {template.kind}
1477+ </Badge>
12271478 </div>
1228− <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">{template.name}</span>
1229− <Badge variant="outline" className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity">{template.kind}</Badge>
1230− </div>
1231− <span className="line-clamp-1 text-[11px] text-muted-foreground/70">
1232− {template.summary}
1233− </span>
1234− </button>
1235− ))}
1479+ <span className="line-clamp-1 text-[11px] text-muted-foreground/70">
1480+ {template.summary}
1481+ </span>
1482+ </button>
1483+ ))}
1484+ </div>
12361485 </div>
1237− </div>
1486+ )
12381487 );
12391488 })()}
12401489
12411490 {/* -- Google Workspace batch section -- */}
12421491 <div className="space-y-3">
12431492 <div className="flex items-center justify-between gap-3">
1244− <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Google Workspace</p>
1493+ <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">
1494+ Google Workspace
1495+ </p>
12451496 {googleTemplates.length > 0 && (
12461497 <div className="flex items-center gap-2">
12471498 {selectedGoogleTemplateIds.length > 0 && (
@@ -1258,7 +1509,8 @@ export function AddSourcePage() { · 10 unmodified lines
12581509 type="button"
12591510 onClick={() =>
12601511 setSelectedGoogleTemplateIds(
1261− selectedGoogleTemplateIds.length === googleTemplates.length
1512+ selectedGoogleTemplateIds.length ===
1513+ googleTemplates.length
12621514 ? []
12631515 : googleTemplates.map((t) => t.id),
12641516 )
@@ -1266,14 +1518,19 @@ export function AddSourcePage() { · 1 unmodified line
12661518 disabled={isBusy}
12671519 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
12681520 >
1269− {selectedGoogleTemplateIds.length === googleTemplates.length ? "Deselect all" : "Select all"}
1521+ {selectedGoogleTemplateIds.length ===
1522+ googleTemplates.length
1523+ ? "Deselect all"
1524+ : "Select all"}
12701525 </button>
12711526 </div>
12721527 )}
12731528 </div>
12741529 <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
12751530 {googleTemplates.map((template) => {
1276− const selected = selectedGoogleTemplateIds.includes(template.id);
1531+ const selected = selectedGoogleTemplateIds.includes(
1532+ template.id,
1533+ );
12771534 return (
12781535 <button
12791536 key={template.id}
@@ -1287,23 +1544,33 @@ export function AddSourcePage() { · 7 unmodified lines
12871544 : "border-border bg-card/50 hover:bg-accent/40 hover:border-border",
12881545 )}
12891546 >
1290− <div className={cn(
1291− "flex size-4 shrink-0 items-center justify-center rounded transition-colors",
1292− selected
1293− ? "text-primary"
1294− : "text-muted-foreground/30 group-hover:text-muted-foreground/50",
1295− )}>
1547+ <div
1548+ className={cn(
1549+ "flex size-4 shrink-0 items-center justify-center rounded transition-colors",
1550+ selected
1551+ ? "text-primary"
1552+ : "text-muted-foreground/30 group-hover:text-muted-foreground/50",
1553+ )}
1554+ >
12961555 {selected ? (
12971556 <IconCheck className="size-3.5" />
12981557 ) : (
1299− <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-3.5" />
1558+ <SourceFavicon
1559+ endpoint={template.endpoint}
1560+ kind={template.kind}
1561+ className="size-3.5"
1562+ />
13001563 )}
13011564 </div>
13021565 <div className="min-w-0 flex-1">
1303− <span className={cn(
1304− "block truncate text-[12px] font-medium transition-colors",
1305− selected ? "text-foreground" : "text-foreground/80",
1306− )}>
1566+ <span
1567+ className={cn(
1568+ "block truncate text-[12px] font-medium transition-colors",
1569+ selected
1570+ ? "text-foreground"
1571+ : "text-foreground/80",
1572+ )}
1573+ >
13071574 {template.name}
13081575 </span>
13091576 <span className="block truncate text-[10px] text-muted-foreground/60">
@@ -1322,10 +1589,15 @@ export function AddSourcePage() { · 12 unmodified lines
13221589 <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3">
13231590 <div className="flex items-center gap-2.5">
13241591 <div className="flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary">
1325− <span className="text-[11px] font-bold tabular-nums">{selectedGoogleTemplateIds.length}</span>
1592+ <span className="text-[11px] font-bold tabular-nums">
1593+ {selectedGoogleTemplateIds.length}
1594+ </span>
13261595 </div>
13271596 <p className="text-[13px] font-medium text-foreground">
1328− Connect Google API{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1597+ Connect Google API
1598+ {selectedGoogleTemplateIds.length === 1
1599+ ? ""
1600+ : "s"}
13291601 </p>
13301602 </div>
13311603 <p className="hidden text-[11px] text-muted-foreground/60 sm:block">
@@ -1338,41 +1610,71 @@ export function AddSourcePage() { · 6 unmodified lines
13381610 <Field label="Workspace OAuth client">
13391611 <select
13401612 value={connectForm.workspaceOauthClientId}
1341− onChange={(event) => setFormField("workspaceOauthClientId", event.target.value)}
1613+ onChange={(event) =>
1614+ setFormField(
1615+ "workspaceOauthClientId",
1616+ event.target.value,
1617+ )
1618+ }
13421619 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
13431620 >
1344− <option value="">Enter new client credentials</option>
1345− {workspaceOauthClients.status === "ready" && workspaceOauthClients.data.map((client) => (
1346− <option key={client.id} value={client.id}>
1347− {client.label ?? client.clientId}
1348− </option>
1349− ))}
1621+ <option value="">
1622+ Enter new client credentials
1623+ </option>
1624+ {workspaceOauthClients.status === "ready" &&
1625+ workspaceOauthClients.data.map((client) => (
1626+ <option key={client.id} value={client.id}>
1627+ {client.label ?? client.clientId}
1628+ </option>
1629+ ))}
13501630 </select>
13511631 </Field>
1352− {connectForm.workspaceOauthClientId.trim().length === 0 && (
1632+ {connectForm.workspaceOauthClientId.trim().length ===
1633+ 0 && (
13531634 <div className="grid gap-3 sm:grid-cols-2">
1354− <Field label="Client ID" className="sm:col-span-2">
1635+ <Field
1636+ label="Client ID"
1637+ className="sm:col-span-2"
1638+ >
13551639 <TextInput
13561640 type="password"
13571641 value={connectForm.oauthClientId}
1358− onChange={(v) => setFormField("oauthClientId", v)}
1642+ onChange={(v) =>
1643+ setFormField("oauthClientId", v)
1644+ }
13591645 placeholder="1234567890-abcdef.apps.googleusercontent.com"
13601646 />
13611647 </Field>
1362− <Field label="Client secret" className="sm:col-span-2">
1648+ <Field
1649+ label="Client secret"
1650+ className="sm:col-span-2"
1651+ >
13631652 <TextInput
13641653 type="password"
13651654 value={connectForm.oauthClientSecret}
1366− onChange={(v) => setFormField("oauthClientSecret", v)}
1655+ onChange={(v) =>
1656+ setFormField("oauthClientSecret", v)
1657+ }
13671658 placeholder="GOCSPX-..."
13681659 />
13691660 </Field>
13701661 </div>
13711662 )}
13721663 <div className="flex items-center justify-end pt-1">
1373− <Button type="button" onClick={handleConnectGoogleBatch} disabled={isBusy}>
1374− {isBusy ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />}
1375− Connect {selectedGoogleTemplateIds.length} source{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1664+ <Button
1665+ type="button"
1666+ onClick={handleConnectGoogleBatch}
1667+ disabled={isBusy}
1668+ >
1669+ {isBusy ? (
1670+ <IconSpinner className="size-3.5" />
1671+ ) : (
1672+ <IconPlus className="size-3.5" />
1673+ )}
1674+ Connect {selectedGoogleTemplateIds.length} source
1675+ {selectedGoogleTemplateIds.length === 1
1676+ ? ""
1677+ : "s"}
13761678 </Button>
13771679 </div>
13781680 </div>
@@ -1385,8 +1687,6 @@ export function AddSourcePage() { · 6 unmodified lines
13851687 </div>
13861688 )}
13871689
1388−
1389−
13901690 {/* Batch connecting interstitial */}
13911691 {batchConnecting && phase === "connecting" && (
13921692 <div className="flex flex-col items-center justify-center py-20">
@@ -1396,16 +1696,21 @@ export function AddSourcePage() { · 3 unmodified lines
13961696 </div>
13971697 <div className="space-y-1.5">
13981698 <h2 className="font-display text-xl tracking-tight text-foreground">
1399− Connecting {selectedGoogleTemplateIds.length} source{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1699+ Connecting {selectedGoogleTemplateIds.length} source
1700+ {selectedGoogleTemplateIds.length === 1 ? "" : "s"}
14001701 </h2>
1401− <p className="text-[13px] text-muted-foreground/70">Setting up Google Workspace APIs\u2026</p>
1702+ <p className="text-[13px] text-muted-foreground/70">
1703+ Setting up Google Workspace APIs\u2026
1704+ </p>
14021705 </div>
14031706 </div>
14041707 </div>
14051708 )}
14061709
14071710 {/* Step 2-4: Editing / Connecting */}
1408− {(phase === "editing" || (phase === "connecting" && !batchConnecting) || phase === "credential_required") && (
1711+ {(phase === "editing" ||
1712+ (phase === "connecting" && !batchConnecting) ||
1713+ phase === "credential_required") && (
14091714 <div className="space-y-6">
14101715 <Section title="Configuration">
14111716 <div className="grid gap-4 sm:grid-cols-2">
@@ -1419,20 +1724,26 @@ export function AddSourcePage() { · 7 unmodified lines
14191724 <Field label="Kind">
14201725 <SelectInput
14211726 value={connectForm.kind}
1422− onChange={(v) => setSourceKind(v as ConnectFormState["kind"])}
1727+ onChange={(v) =>
1728+ setSourceKind(v as ConnectFormState["kind"])
1729+ }
14231730 options={kindOptions.map((v) => ({ value: v, label: v }))}
14241731 />
14251732 </Field>
1426− {connectForm.kind !== "google_discovery" && (
1427− <Field label="Endpoint" className="sm:col-span-2">
1428− <TextInput
1429− value={connectForm.endpoint}
1430− onChange={(v) => setFormField("endpoint", v)}
1431− placeholder="https://api.example.com"
1432− mono
1433− />
1434− </Field>
1435− )}
1733+ {connectForm.kind !== "google_discovery" &&
1734+ !(
1735+ connectForm.kind === "mcp" &&
1736+ connectForm.transport === "stdio"
1737+ ) && (
1738+ <Field label="Endpoint" className="sm:col-span-2">
1739+ <TextInput
1740+ value={connectForm.endpoint}
1741+ onChange={(v) => setFormField("endpoint", v)}
1742+ placeholder="https://api.example.com"
1743+ mono
1744+ />
1745+ </Field>
1746+ )}
14361747 <Field label="Namespace">
14371748 <TextInput
14381749 value={connectForm.namespace}
@@ -1466,7 +1777,10 @@ export function AddSourcePage() { · 27 unmodified lines
14661777 placeholder="v4"
14671778 />
14681779 </Field>
1469− <Field label="Discovery URL (optional)" className="sm:col-span-2">
1780+ <Field
1781+ label="Discovery URL (optional)"
1782+ className="sm:col-span-2"
1783+ >
14701784 <TextInput
14711785 value={connectForm.discoveryUrl}
14721786 onChange={(v) => setFormField("discoveryUrl", v)}
@@ -1475,7 +1789,8 @@ export function AddSourcePage() { · 2 unmodified lines
14751789 />
14761790 </Field>
14771791 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1478− Common Google API versions: Gmail `v1`, Sheets `v4`, Drive `v3`, Calendar `v3`, Docs `v1`.
1792+ Common Google API versions: Gmail `v1`, Sheets `v4`, Drive
1793+ `v3`, Calendar `v3`, Docs `v1`.
14791794 </div>
14801795 </>
14811796 )}
@@ -1489,26 +1804,76 @@ export function AddSourcePage() { · 7 unmodified lines
14891804 <Field label="Transport mode">
14901805 <SelectInput
14911806 value={connectForm.transport || "auto"}
1492− onChange={(v) => setFormField("transport", v as ConnectFormState["transport"])}
1493− options={transportOptions.map((v) => ({ value: v, label: v }))}
1807+ onChange={(v) =>
1808+ setFormField(
1809+ "transport",
1810+ v as ConnectFormState["transport"],
1811+ )
1812+ }
1813+ options={transportOptions.map((v) => ({
1814+ value: v,
1815+ label: v,
1816+ }))}
14941817 />
14951818 </Field>
1496− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1497− <Field label="Query params (JSON)">
1498− <CodeEditor
1499− value={connectForm.queryParamsText}
1500− onChange={(v) => setFormField("queryParamsText", v)}
1501− placeholder={'{\n "workspace": "demo"\n}'}
1502− />
1503− </Field>
1504− <Field label="Headers (JSON)">
1505− <CodeEditor
1506− value={connectForm.headersText}
1507− onChange={(v) => setFormField("headersText", v)}
1508− placeholder={'{\n "x-api-key": "..."\n}'}
1509− />
1510− </Field>
1511− </div>
1819+ {connectForm.transport === "stdio" ? (
1820+ <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1821+ <Field label="Command">
1822+ <TextInput
1823+ value={connectForm.command}
1824+ onChange={(v) => setFormField("command", v)}
1825+ placeholder="npx"
1826+ mono
1827+ />
1828+ </Field>
1829+ <Field label="Working directory (optional)">
1830+ <TextInput
1831+ value={connectForm.cwd}
1832+ onChange={(v) => setFormField("cwd", v)}
1833+ placeholder="/path/to/project"
1834+ mono
1835+ />
1836+ </Field>
1837+ <Field label="Args (JSON)" className="sm:col-span-2">
1838+ <CodeEditor
1839+ value={connectForm.argsText}
1840+ onChange={(v) => setFormField("argsText", v)}
1841+ placeholder={
1842+ '[\n "-y",\n "chrome-devtools-mcp@latest"\n]'
1843+ }
1844+ />
1845+ </Field>
1846+ <Field
1847+ label="Environment (JSON)"
1848+ className="sm:col-span-2"
1849+ >
1850+ <CodeEditor
1851+ value={connectForm.envText}
1852+ onChange={(v) => setFormField("envText", v)}
1853+ placeholder={
1854+ '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}'
1855+ }
1856+ />
1857+ </Field>
1858+ </div>
1859+ ) : (
1860+ <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1861+ <Field label="Query params (JSON)">
1862+ <CodeEditor
1863+ value={connectForm.queryParamsText}
1864+ onChange={(v) => setFormField("queryParamsText", v)}
1865+ placeholder={'{\n "workspace": "demo"\n}'}
1866+ />
1867+ </Field>
1868+ <Field label="Headers (JSON)">
1869+ <CodeEditor
1870+ value={connectForm.headersText}
1871+ onChange={(v) => setFormField("headersText", v)}
1872+ placeholder={'{\n "x-api-key": "..."\n}'}
1873+ />
1874+ </Field>
1875+ </div>
1876+ )}
15121877 </div>
15131878 </Section>
15141879 )}
@@ -1517,15 +1882,28 @@ export function AddSourcePage() { · 2 unmodified lines
15171882 {phase === "credential_required" && (
15181883 <div className="flex items-start gap-3 rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3.5 dark:border-amber-500/20 dark:bg-amber-950/20">
15191884 <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100/80 dark:bg-amber-900/40">
1520− <svg className="size-3.5 text-amber-600 dark:text-amber-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1521− <path d="M12 9v4" /><path d="M12 17h.01" />
1885+ <svg
1886+ className="size-3.5 text-amber-600 dark:text-amber-400"
1887+ viewBox="0 0 24 24"
1888+ fill="none"
1889+ stroke="currentColor"
1890+ strokeWidth="2"
1891+ strokeLinecap="round"
1892+ strokeLinejoin="round"
1893+ >
1894+ <path d="M12 9v4" />
1895+ <path d="M12 17h.01" />
15221896 <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
15231897 </svg>
15241898 </div>
15251899 <div className="space-y-0.5">
1526− <p className="text-[12px] font-medium text-amber-900 dark:text-amber-200">Credentials required</p>
1900+ <p className="text-[12px] font-medium text-amber-900 dark:text-amber-200">
1901+ Credentials required
1902+ </p>
15271903 <p className="text-[11px] leading-relaxed text-amber-800/70 dark:text-amber-300/60">
1528− The server responded that this source requires authentication. Add a token or secret below, then try connecting again.
1904+ The server responded that this source requires
1905+ authentication. Add a token or secret below, then try
1906+ connecting again.
15291907 </p>
15301908 </div>
15311909 </div>
@@ -1536,7 +1914,12 @@ export function AddSourcePage() { · 4 unmodified lines
15361914 <Field label="Auth mode">
15371915 <SelectInput
15381916 value={connectForm.authKind}
1539− onChange={(v) => setFormField("authKind", v as ConnectFormState["authKind"])}
1917+ onChange={(v) =>
1918+ setFormField(
1919+ "authKind",
1920+ v as ConnectFormState["authKind"],
1921+ )
1922+ }
15401923 options={authOptions.map((v) => ({ value: v, label: v }))}
15411924 />
15421925 </Field>
@@ -1547,45 +1930,62 @@ export function AddSourcePage() { · 4 unmodified lines
15471930 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
15481931 </div>
15491932 )}
1550− {connectForm.kind === "google_discovery" && connectForm.authKind === "oauth2" && (
1551− <>
1552− <Field label="Workspace OAuth client" className="sm:col-span-2">
1553− <SelectInput
1554− value={connectForm.workspaceOauthClientId}
1555− onChange={(value) => setFormField("workspaceOauthClientId", value)}
1556− options={[
1557− { value: "", label: "Enter new client" },
1558− ...(workspaceOauthClients.status === "ready"
1559− ? workspaceOauthClients.data.map((client) => ({
1560− value: client.id,
1561− label: client.label ?? client.clientId,
1562− }))
1563− : []),
1564− ]}
1565− />
1566− </Field>
1567− {connectForm.workspaceOauthClientId.trim().length === 0 && (
1568− <>
1569− <Field label="New client ID" className="sm:col-span-2">
1570− <TextInput
1571− type="password"
1572− value={connectForm.oauthClientId}
1573− onChange={(v) => setFormField("oauthClientId", v)}
1574− placeholder="1234567890-abcdef.apps.googleusercontent.com"
1575− />
1576− </Field>
1577− <Field label="New client secret" className="sm:col-span-2">
1578− <TextInput
1579− type="password"
1580− value={connectForm.oauthClientSecret}
1581− onChange={(v) => setFormField("oauthClientSecret", v)}
1582− placeholder="GOCSPX-..."
1583− />
1584− </Field>
1585− </>
1586− )}
1587− </>
1588− )}
1933+ {connectForm.kind === "google_discovery" &&
1934+ connectForm.authKind === "oauth2" && (
1935+ <>
1936+ <Field
1937+ label="Workspace OAuth client"
1938+ className="sm:col-span-2"
1939+ >
1940+ <SelectInput
1941+ value={connectForm.workspaceOauthClientId}
1942+ onChange={(value) =>
1943+ setFormField("workspaceOauthClientId", value)
1944+ }
1945+ options={[
1946+ { value: "", label: "Enter new client" },
1947+ ...(workspaceOauthClients.status === "ready"
1948+ ? workspaceOauthClients.data.map((client) => ({
1949+ value: client.id,
1950+ label: client.label ?? client.clientId,
1951+ }))
1952+ : []),
1953+ ]}
1954+ />
1955+ </Field>
1956+ {connectForm.workspaceOauthClientId.trim().length ===
1957+ 0 && (
1958+ <>
1959+ <Field
1960+ label="New client ID"
1961+ className="sm:col-span-2"
1962+ >
1963+ <TextInput
1964+ type="password"
1965+ value={connectForm.oauthClientId}
1966+ onChange={(v) =>
1967+ setFormField("oauthClientId", v)
1968+ }
1969+ placeholder="1234567890-abcdef.apps.googleusercontent.com"
1970+ />
1971+ </Field>
1972+ <Field
1973+ label="New client secret"
1974+ className="sm:col-span-2"
1975+ >
1976+ <TextInput
1977+ type="password"
1978+ value={connectForm.oauthClientSecret}
1979+ onChange={(v) =>
1980+ setFormField("oauthClientSecret", v)
1981+ }
1982+ placeholder="GOCSPX-..."
1983+ />
1984+ </Field>
1985+ </>
1986+ )}
1987+ </>
1988+ )}
15891989 {connectForm.authKind !== "none" && (
15901990 <>
15911991 <Field label="Header name">
@@ -1631,18 +2031,32 @@ export function AddSourcePage() { · 39 unmodified lines
16312031 {/* Actions */}
16322032 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
16332033 {phase === "credential_required" && (
1634− <Button variant="ghost" type="button" onClick={handleBackToEditing}>
2034+ <Button
2035+ variant="ghost"
2036+ type="button"
2037+ onClick={handleBackToEditing}
2038+ >
16352039 Back to edit
16362040 </Button>
16372041 )}
16382042 <Link to="/" className="inline-flex">
1639− <Button variant="ghost" type="button">Cancel</Button>
2043+ <Button variant="ghost" type="button">
2044+ Cancel
2045+ </Button>
16402046 </Link>
16412047 <Button
1642− onClick={phase === "credential_required" ? handleCredentialConnect : handleConnect}
2048+ onClick={
2049+ phase === "credential_required"
2050+ ? handleCredentialConnect
2051+ : handleConnect
2052+ }
16432053 disabled={isBusy}
16442054 >
1645− {isConnecting ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />}
2055+ {isConnecting ? (
2056+ <IconSpinner className="size-3.5" />
2057+ ) : (
2058+ <IconPlus className="size-3.5" />
2059+ )}
16462060 {isConnecting ? "Connecting\u2026" : "Connect"}
16472061 </Button>
16482062 </div>
@@ -1658,7 +2072,15 @@ export function AddSourcePage() { · 9 unmodified lines
16582072 {oauthBusy ? (
16592073 <IconSpinner className="size-6 text-primary" />
16602074 ) : (
1661− <svg className="size-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
2075+ <svg
2076+ className="size-6 text-primary"
2077+ viewBox="0 0 24 24"
2078+ fill="none"
2079+ stroke="currentColor"
2080+ strokeWidth="1.5"
2081+ strokeLinecap="round"
2082+ strokeLinejoin="round"
2083+ >
16622084 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
16632085 <path d="M7 11V7a5 5 0 0110 0v4" />
16642086 </svg>
@@ -1667,24 +2089,36 @@ export function AddSourcePage() { · 2 unmodified lines
16672089
16682090 <div className="space-y-2">
16692091 <h2 className="font-display text-xl tracking-tight text-foreground">
1670− {oauthBusy ? "Waiting for sign-in\u2026" : "Sign in to continue"}
2092+ {oauthBusy
2093+ ? "Waiting for sign-in\u2026"
2094+ : "Sign in to continue"}
16712095 </h2>
16722096 <p className="text-[13px] leading-relaxed text-muted-foreground">
1673− {oauthInfo
1674− ? <>
1675− <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth authentication.
1676− A popup will open for you to authorize access.
1677− </>
1678− : <>
1679− {batchOauthInfo!.sourceIds.length} Google source{batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need{batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth.
1680− One consent screen covers all selected APIs.
1681− </>
1682− }
2097+ {oauthInfo ? (
2098+ <>
2099+ <strong className="text-foreground">
2100+ {oauthInfo.source.name}
2101+ </strong>{" "}
2102+ requires OAuth authentication. A popup will open for you
2103+ to authorize access.
2104+ </>
2105+ ) : (
2106+ <>
2107+ {batchOauthInfo!.sourceIds.length} Google source
2108+ {batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need
2109+ {batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth.
2110+ One consent screen covers all selected APIs.
2111+ </>
2112+ )}
16832113 </p>
16842114 </div>
16852115
16862116 <div className="flex flex-col items-center gap-3">
1687− <Button onClick={handleOAuthPopup} disabled={oauthBusy} className="w-full max-w-xs">
2117+ <Button
2118+ onClick={handleOAuthPopup}
2119+ disabled={oauthBusy}
2120+ className="w-full max-w-xs"
2121+ >
16882122 {oauthBusy ? <IconSpinner className="size-3.5" /> : null}
16892123 {oauthBusy ? "Authenticating\u2026" : "Open sign-in"}
16902124 </Button>
@@ -1715,10 +2149,12 @@ export function AddSourcePage() { · 24 unmodified lines
17152149 ? connectResult.source.name
17162150 : batchOauthInfo
17172151 ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`
1718− : "Source"}
1719− {" "}connected
2152+ : "Source"}{" "}
2153+ connected
17202154 </h2>
1721− <p className="text-[13px] text-muted-foreground/70">Redirecting\u2026</p>
2155+ <p className="text-[13px] text-muted-foreground/70">
2156+ Redirecting\u2026
2157+ </p>
17222158 </div>
17232159 </div>
17242160 </div>
@@ -1732,9 +2168,18 @@ export function AddSourcePage() { · 7 unmodified lines
17322168 // Form building blocks
17332169 // ---------------------------------------------------------------------------
17342170
1735−function Section(props: { title: string; children: ReactNode; className?: string }) {
2171+function Section(props: {
2172+ title: string;
2173+ children: ReactNode;
2174+ className?: string;
2175+}) {
17362176 return (
1737− <section className={cn("rounded-xl border border-border bg-card/80", props.className)}>
2177+ <section
2178+ className={cn(
2179+ "rounded-xl border border-border bg-card/80",
2180+ props.className,
2181+ )}
2182+ >
17382183 <div className="border-b border-border px-5 py-3">
17392184 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
17402185 </div>
@@ -1750,7 +2195,9 @@ function Field(props: { · 9 unmodified lines
17502195 }) {
17512196 return (
17522197 <label className={cn("block space-y-1.5", props.className)}>
1753− <span className="text-[12px] font-medium text-foreground">{props.label}</span>
2198+ <span className="text-[12px] font-medium text-foreground">
2199+ {props.label}
2200+ </span>
17542201 {props.children}
17552202 </label>
17562203 );
@@ -1813,27 +2260,55 @@ function CodeEditor(props: { · 56 unmodified lines
18132260 );
18142261 }
18152262
1816−function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text: string }; className?: string }) {
2263+function StatusBanner(props: {
2264+ state: { tone: "info" | "success" | "error"; text: string };
2265+ className?: string;
2266+}) {
18172267 return (
18182268 <div
18192269 className={cn(
18202270 "flex items-start gap-2.5 rounded-lg border px-4 py-3 text-[13px]",
1821− props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",
1822− props.state.tone === "info" && "border-border bg-card text-muted-foreground",
1823− props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",
2271+ props.state.tone === "success" &&
2272+ "border-primary/30 bg-primary/8 text-foreground",
2273+ props.state.tone === "info" &&
2274+ "border-border bg-card text-muted-foreground",
2275+ props.state.tone === "error" &&
2276+ "border-destructive/30 bg-destructive/8 text-destructive",
18242277 props.className,
18252278 )}
18262279 >
18272280 <span className="mt-0.5 shrink-0">
1828− {props.state.tone === "success" && <IconCheck className="size-3.5 text-primary" />}
2281+ {props.state.tone === "success" && (
2282+ <IconCheck className="size-3.5 text-primary" />
2283+ )}
18292284 {props.state.tone === "error" && (
1830− <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1831− <circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6" /><path d="M9 9l6 6" />
2285+ <svg
2286+ className="size-3.5"
2287+ viewBox="0 0 24 24"
2288+ fill="none"
2289+ stroke="currentColor"
2290+ strokeWidth="2"
2291+ strokeLinecap="round"
2292+ strokeLinejoin="round"
2293+ >
2294+ <circle cx="12" cy="12" r="10" />
2295+ <path d="M15 9l-6 6" />
2296+ <path d="M9 9l6 6" />
18322297 </svg>
18332298 )}
18342299 {props.state.tone === "info" && (
1835− <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1836− <circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" />
2300+ <svg
2301+ className="size-3.5"
2302+ viewBox="0 0 24 24"
2303+ fill="none"
2304+ stroke="currentColor"
2305+ strokeWidth="2"
2306+ strokeLinecap="round"
2307+ strokeLinejoin="round"
2308+ >
2309+ <circle cx="12" cy="12" r="10" />
2310+ <path d="M12 16v-4" />
2311+ <path d="M12 8h.01" />
18372312 </svg>
18382313 )}
18392314 </span>
@@ -1842,8 +2317,6 @@ function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text · 2 unmodified lines
18422317 );
18432318 }
18442319
1845−
1846−
18472320 // ---------------------------------------------------------------------------
18482321 // Secret or inline token picker
18492322 // ---------------------------------------------------------------------------
@@ -1876,7 +2349,9 @@ function SecretOrTokenInput(props: { · 26 unmodified lines
18762349 const [createError, setCreateError] = useState<string | null>(null);
18772350 const storableProviders =
18782351 instanceConfig.status === "ready"
1879− ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
2352+ ? instanceConfig.data.secretProviders.filter(
2353+ (provider) => provider.canStore,
2354+ )
18802355 : [];
18812356
18822357 useEffect(() => {
@@ -1888,7 +2363,11 @@ function SecretOrTokenInput(props: { · 5 unmodified lines
18882363 }
18892364 }, [instanceConfig, newProviderId]);
18902365
1891− const selectedValue = showCreate ? CREATE_NEW_VALUE : useInline ? INLINE_TOKEN_VALUE : (handle || "");
2366+ const selectedValue = showCreate
2367+ ? CREATE_NEW_VALUE
2368+ : useInline
2369+ ? INLINE_TOKEN_VALUE
2370+ : handle || "";
18922371
18932372 const handleSelectChange = (value: string) => {
18942373 if (value === CREATE_NEW_VALUE) {
@@ -1918,9 +2397,10 @@ function SecretOrTokenInput(props: { · 23 unmodified lines
19182397 }
19192398 setUseInline(false);
19202399 setShowCreate(false);
1921− const matchedSecret = secrets.status === "ready"
1922− ? secrets.data.find((secret) => secret.id === value)
1923− : null;
2400+ const matchedSecret =
2401+ secrets.status === "ready"
2402+ ? secrets.data.find((secret) => secret.id === value)
2403+ : null;
19242404 onSelectSecret(matchedSecret?.providerId ?? "local", value);
19252405 };
19262406
@@ -1945,7 +2425,9 @@ function SecretOrTokenInput(props: { · 18 unmodified lines
19452425 onSelectSecret(result.providerId, result.id);
19462426 setShowCreate(false);
19472427 } catch (err) {
1948− setCreateError(err instanceof Error ? err.message : "Failed creating secret.");
2428+ setCreateError(
2429+ err instanceof Error ? err.message : "Failed creating secret.",
2430+ );
19492431 }
19502432 };
19512433
@@ -1999,7 +2481,9 @@ function SecretOrTokenInput(props: { · 47 unmodified lines
19992481 )}
20002482 <div className="grid gap-3 sm:grid-cols-2">
20012483 <label className="block space-y-1">
2002− <span className="text-[11px] font-medium text-muted-foreground">Name</span>
2484+ <span className="text-[11px] font-medium text-muted-foreground">
2485+ Name
2486+ </span>
20032487 <input
20042488 value={newName}
20052489 onChange={(e) => setNewName(e.target.value)}
@@ -2009,7 +2493,9 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
20092493 />
20102494 </label>
20112495 <label className="block space-y-1">
2012− <span className="text-[11px] font-medium text-muted-foreground">Value</span>
2496+ <span className="text-[11px] font-medium text-muted-foreground">
2497+ Value
2498+ </span>
20132499 <input
20142500 type="password"
20152501 value={newValue}
@@ -2019,7 +2505,9 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
20192505 />
20202506 </label>
20212507 <label className="block space-y-1">
2022− <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
2508+ <span className="text-[11px] font-medium text-muted-foreground">
2509+ Store in
2510+ </span>
20232511 <select
20242512 value={newProviderId}
20252513 onChange={(e) => setNewProviderId(e.target.value)}
@@ -2046,7 +2534,9 @@ function SecretOrTokenInput(props: { · 20 unmodified lines
20462534 onClick={handleCreate}
20472535 disabled={createSecret.status === "pending"}
20482536 >
2049− {createSecret.status === "pending" && <IconSpinner className="size-3" />}
2537+ {createSecret.status === "pending" && (
2538+ <IconSpinner className="size-3" />
2539+ )}
20502540 Store & use
20512541 </Button>
20522542 </div>
apps/web/src/views/source-editor.tsx+749−351
@@ -60,10 +60,10 @@ const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; · 59 unmodified lines
6060 const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
6161
6262 const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
63− loadable.status === "error"
64− && loadable.error.message.toLowerCase().includes("source not found");
63+ loadable.status === "error" &&
64+ loadable.error.message.toLowerCase().includes("source not found");
6565
66−type TransportValue = "" | "auto" | "streamable-http" | "sse";
66+type TransportValue = "" | "auto" | "streamable-http" | "sse" | "stdio";
6767
6868 type SourceFormState = {
6969 name: string;
@@ -74,6 +74,10 @@ type SourceFormState = { · 4 unmodified lines
7474 transport: TransportValue;
7575 queryParamsText: string;
7676 headersText: string;
77+ command: string;
78+ argsText: string;
79+ envText: string;
80+ cwd: string;
7781 specUrl: string;
7882 service: string;
7983 version: string;
@@ -87,7 +91,10 @@ type SourceFormState = { · 7 unmodified lines
8791 oauthAccessHandle: string;
8892 oauthRefreshProviderId: string;
8993 oauthRefreshHandle: string;
90− managedAuth: Extract<Source["auth"], { kind: "provider_grant_ref" | "mcp_oauth" }> | null;
94+ managedAuth: Extract<
95+ Source["auth"],
96+ { kind: "provider_grant_ref" | "mcp_oauth" }
97+ > | null;
9198 };
9299
93100 const kindOptions: ReadonlyArray<Source["kind"]> = [
@@ -102,6 +109,7 @@ const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ · 8 unmodified lines
102109 "auto",
103110 "streamable-http",
104111 "sse",
112+ "stdio",
105113 ];
106114
107115 const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
@@ -129,9 +137,15 @@ const namespaceFromUrl = (url: string): string => { · 21 unmodified lines
129137 };
130138
131139 const googleDiscoveryNamespace = (service: string): string =>
132− `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
133−
134−const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
140+ `google.${service
141+ .trim()
142+ .toLowerCase()
143+ .replace(/[^a-z0-9]+/g, ".")
144+ .replace(/^\.+|\.+$/g, "")}`;
145+
146+const readStoredSourceOAuthPopupResult = (
147+ sessionId: string,
148+): SourceOAuthPopupMessage | null => {
135149 if (typeof window === "undefined") {
136150 return null;
137151 }
@@ -155,7 +169,9 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines
155169 return;
156170 }
157171
158− window.localStorage.removeItem(`${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`);
172+ window.localStorage.removeItem(
173+ `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
174+ );
159175 };
160176
161177 const startSourceOAuthPopup = async (input: {
@@ -180,110 +196,130 @@ const startSourceOAuthPopup = async (input: { · 18 unmodified lines
180196
181197 popup.focus();
182198
183− return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {
184− let settled = false;
185− let closedPoll = 0;
186− let resultTimeout = 0;
187−
188− const cleanup = () => {
189− window.removeEventListener("message", onMessage);
190− if (closedPoll) {
191− window.clearInterval(closedPoll);
192− }
193− if (resultTimeout) {
194− window.clearTimeout(resultTimeout);
195− }
196− if (!popup.closed) {
197− popup.close();
198− }
199− clearStoredSourceOAuthPopupResult(input.sessionId);
200− };
201−
202− const settleWithError = (message: string) => {
203− if (settled) {
204− return;
205− }
206−
207− settled = true;
208− cleanup();
209− reject(new Error(message));
210− };
211−
212− const settleFromPayload = (data: SourceOAuthPopupMessage) => {
213− if (!data.ok) {
214− settleWithError(data.error || "OAuth failed");
215− return;
216− }
217−
218− if (settled) {
219− return;
220− }
221−
222− settled = true;
223− cleanup();
224− resolve(data.auth);
225− };
226−
227− const onMessage = (event: MessageEvent) => {
228− if (event.origin !== window.location.origin) {
229− return;
230− }
231−
232− const data = event.data as SourceOAuthPopupMessage | undefined;
233− if (!data || data.type !== "executor:oauth-result") {
234− return;
235− }
236−
237− if (data.ok && data.sessionId !== input.sessionId) {
238− return;
239− }
240−
241− if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) {
242− return;
243− }
244−
245− settleFromPayload(data);
246− };
247−
248− window.addEventListener("message", onMessage);
249−
250− resultTimeout = window.setTimeout(() => {
251− settleWithError("OAuth popup timed out before completion. Please try again.");
252− }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
253−
254− closedPoll = window.setInterval(() => {
255− const stored = readStoredSourceOAuthPopupResult(input.sessionId);
256− if (stored) {
257− settleFromPayload(stored);
258− return;
259− }
260− if (popup.closed) {
261− // Stop polling — only run one final deferred check to give the
262− // callback page time to write localStorage before we give up.
263− window.clearInterval(closedPoll);
264− closedPoll = 0;
265− window.setTimeout(() => {
266− const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId);
267− if (delayedStored) {
268− settleFromPayload(delayedStored);
269− return;
270− }
271− settleWithError("OAuth popup was closed before completion.");
272− }, 1500);
273− }
274− }, 300);
275− });
199+ return await new Promise<CompleteSourceOAuthResult["auth"]>(
200+ (resolve, reject) => {
201+ let settled = false;
202+ let closedPoll = 0;
203+ let resultTimeout = 0;
204+
205+ const cleanup = () => {
206+ window.removeEventListener("message", onMessage);
207+ if (closedPoll) {
208+ window.clearInterval(closedPoll);
209+ }
210+ if (resultTimeout) {
211+ window.clearTimeout(resultTimeout);
212+ }
213+ if (!popup.closed) {
214+ popup.close();
215+ }
216+ clearStoredSourceOAuthPopupResult(input.sessionId);
217+ };
218+
219+ const settleWithError = (message: string) => {
220+ if (settled) {
221+ return;
222+ }
223+
224+ settled = true;
225+ cleanup();
226+ reject(new Error(message));
227+ };
228+
229+ const settleFromPayload = (data: SourceOAuthPopupMessage) => {
230+ if (!data.ok) {
231+ settleWithError(data.error || "OAuth failed");
232+ return;
233+ }
234+
235+ if (settled) {
236+ return;
237+ }
238+
239+ settled = true;
240+ cleanup();
241+ resolve(data.auth);
242+ };
243+
244+ const onMessage = (event: MessageEvent) => {
245+ if (event.origin !== window.location.origin) {
246+ return;
247+ }
248+
249+ const data = event.data as SourceOAuthPopupMessage | undefined;
250+ if (!data || data.type !== "executor:oauth-result") {
251+ return;
252+ }
253+
254+ if (data.ok && data.sessionId !== input.sessionId) {
255+ return;
256+ }
257+
258+ if (
259+ !data.ok &&
260+ data.sessionId !== null &&
261+ data.sessionId !== input.sessionId
262+ ) {
263+ return;
264+ }
265+
266+ settleFromPayload(data);
267+ };
268+
269+ window.addEventListener("message", onMessage);
270+
271+ resultTimeout = window.setTimeout(() => {
272+ settleWithError(
273+ "OAuth popup timed out before completion. Please try again.",
274+ );
275+ }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
276+
277+ closedPoll = window.setInterval(() => {
278+ const stored = readStoredSourceOAuthPopupResult(input.sessionId);
279+ if (stored) {
280+ settleFromPayload(stored);
281+ return;
282+ }
283+ if (popup.closed) {
284+ // Stop polling — only run one final deferred check to give the
285+ // callback page time to write localStorage before we give up.
286+ window.clearInterval(closedPoll);
287+ closedPoll = 0;
288+ window.setTimeout(() => {
289+ const delayedStored = readStoredSourceOAuthPopupResult(
290+ input.sessionId,
291+ );
292+ if (delayedStored) {
293+ settleFromPayload(delayedStored);
294+ return;
295+ }
296+ settleWithError("OAuth popup was closed before completion.");
297+ }, 1500);
298+ }
299+ }, 300);
300+ },
301+ );
276302 };
277303
278304 const stringMapToEditor = (value: Record<string, string> | null): string =>
279305 value === null ? "" : JSON.stringify(value, null, 2);
280306
307+const stringArrayToEditor = (
308+ value: ReadonlyArray<string> | null | undefined,
309+): string =>
310+ !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
311+
281312 const readBindingStringMap = (
282313 source: Source,
283314 key: string,
284315 ): Record<string, string> | null => {
285316 const candidate = source.binding[key];
286− if (candidate === null || candidate === undefined || typeof candidate !== "object" || Array.isArray(candidate)) {
317+ if (
318+ candidate === null ||
319+ candidate === undefined ||
320+ typeof candidate !== "object" ||
321+ Array.isArray(candidate)
322+ ) {
287323 return null;
288324 }
289325
@@ -293,29 +329,103 @@ const readBindingStringMap = ( · 3 unmodified lines
293329 : null;
294330 };
295331
332+const readBindingStringArray = (
333+ source: Source,
334+ key: string,
335+): Array<string> | null => {
336+ const candidate = source.binding[key];
337+ return Array.isArray(candidate) &&
338+ candidate.every((value) => typeof value === "string")
339+ ? [...candidate]
340+ : null;
341+};
342+
296343 const readBindingString = (source: Source, key: string): string =>
297344 typeof source.binding[key] === "string" ? String(source.binding[key]) : "";
298345
299346 const readBindingTransport = (source: Source): TransportValue => {
300347 const candidate = source.binding.transport;
301− return typeof candidate === "string" && (candidate === "auto" || candidate === "streamable-http" || candidate === "sse")
348+ return typeof candidate === "string" &&
349+ (candidate === "auto" ||
350+ candidate === "streamable-http" ||
351+ candidate === "sse" ||
352+ candidate === "stdio")
302353 ? candidate
303354 : "";
304355 };
305356
357+const parseJsonStringArray = (
358+ label: string,
359+ text: string,
360+): Array<string> | null => {
361+ const trimmed = text.trim();
362+ if (trimmed.length === 0) {
363+ return null;
364+ }
365+
366+ let parsed: unknown;
367+ try {
368+ parsed = JSON.parse(trimmed);
369+ } catch {
370+ throw new Error(`${label} must be valid JSON.`);
371+ }
372+
373+ if (
374+ !Array.isArray(parsed) ||
375+ !parsed.every((value) => typeof value === "string")
376+ ) {
377+ throw new Error(`${label} must be a JSON array of strings.`);
378+ }
379+
380+ const normalized = parsed
381+ .map((value) => value.trim())
382+ .filter((value) => value.length > 0);
383+
384+ return normalized.length > 0 ? normalized : null;
385+};
386+
387+const buildSyntheticMcpStdioEndpoint = (input: {
388+ name?: string | null;
389+ endpoint?: string | null;
390+ command?: string | null;
391+}): string => {
392+ const label =
393+ input.name?.trim() ||
394+ input.endpoint?.trim() ||
395+ input.command?.trim() ||
396+ "mcp";
397+ const slug = label
398+ .toLowerCase()
399+ .replace(/[^a-z0-9]+/g, "-")
400+ .replace(/^-+|-+$/g, "");
401+
402+ return `stdio://local/${slug || "mcp"}`;
403+};
404+
306405 const defaultFormState = (template?: SourceTemplate): SourceFormState => ({
307406 name: template?.name ?? "",
308407 kind: template?.kind ?? "openapi",
309408 endpoint: template?.endpoint ?? "",
310409 namespace: template
311− ? "service" in template
312− ? googleDiscoveryNamespace(template.service)
313− : namespaceFromUrl(template.endpoint)
410+ ? (template.namespace ??
411+ ("service" in template
412+ ? googleDiscoveryNamespace(template.service)
413+ : namespaceFromUrl(template.endpoint ?? "")))
314414 : "",
315415 enabled: true,
316− transport: template?.kind === "mcp" ? "auto" : "",
416+ transport:
417+ template?.kind === "mcp"
418+ ? template.connectionType === "command"
419+ ? (template.transport ?? "stdio")
420+ : (template.transport ?? "auto")
421+ : "",
317422 queryParamsText: "",
318423 headersText: "",
424+ command: template?.kind === "mcp" ? (template.command ?? "") : "",
425+ argsText: template?.kind === "mcp" ? stringArrayToEditor(template.args) : "",
426+ envText:
427+ template?.kind === "mcp" ? stringMapToEditor(template.env ?? null) : "",
428+ cwd: template?.kind === "mcp" ? (template.cwd ?? "") : "",
319429 specUrl: template && "specUrl" in template ? template.specUrl : "",
320430 service: template && "service" in template ? template.service : "",
321431 version: template && "version" in template ? template.version : "",
@@ -338,13 +448,22 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 16 unmodified lines
338448 endpoint: source.endpoint,
339449 namespace: source.namespace ?? "",
340450 enabled: source.enabled,
341− transport: source.kind === "mcp" ? (readBindingTransport(source) || "auto") : "",
342− queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")),
451+ transport:
452+ source.kind === "mcp" ? readBindingTransport(source) || "auto" : "",
453+ queryParamsText: stringMapToEditor(
454+ readBindingStringMap(source, "queryParams"),
455+ ),
343456 headersText: stringMapToEditor(readBindingStringMap(source, "headers")),
457+ command: readBindingString(source, "command"),
458+ argsText: stringArrayToEditor(readBindingStringArray(source, "args")),
459+ envText: stringMapToEditor(readBindingStringMap(source, "env")),
460+ cwd: readBindingString(source, "cwd"),
344461 specUrl: readBindingString(source, "specUrl"),
345462 service: readBindingString(source, "service"),
346463 version: readBindingString(source, "version"),
347− defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),
464+ defaultHeadersText: stringMapToEditor(
465+ readBindingStringMap(source, "defaultHeaders"),
466+ ),
348467 authKind: source.auth.kind,
349468 authHeaderName:
350469 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
@@ -354,10 +473,13 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 3 unmodified lines
354473 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
355474 ? "Bearer "
356475 : source.auth.prefix,
357− bearerProviderId: source.auth.kind === "bearer" ? source.auth.token.providerId : "",
476+ bearerProviderId:
477+ source.auth.kind === "bearer" ? source.auth.token.providerId : "",
358478 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",
359− oauthAccessProviderId: source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
360− oauthAccessHandle: source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "",
479+ oauthAccessProviderId:
480+ source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
481+ oauthAccessHandle:
482+ source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "",
361483 oauthRefreshProviderId:
362484 source.auth.kind === "oauth2" && source.auth.refreshToken !== null
363485 ? source.auth.refreshToken.providerId
@@ -367,12 +489,16 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 3 unmodified lines
367489 ? source.auth.refreshToken.handle
368490 : "",
369491 managedAuth:
370− source.auth.kind === "provider_grant_ref" || source.auth.kind === "mcp_oauth"
492+ source.auth.kind === "provider_grant_ref" ||
493+ source.auth.kind === "mcp_oauth"
371494 ? source.auth
372495 : null,
373496 });
374497
375−const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
498+const parseJsonStringMap = (
499+ label: string,
500+ text: string,
501+): Record<string, string> | null => {
376502 const trimmed = text.trim();
377503 if (trimmed.length === 0) {
378504 return null;
@@ -401,12 +527,18 @@ const parseJsonStringMap = (label: string, text: string): Record<string, string> · 22 unmodified lines
401527 return Object.keys(normalized).length === 0 ? null : normalized;
402528 };
403529
404−const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] => {
530+const buildAuthPayload = (
531+ state: SourceFormState,
532+): CreateSourcePayload["auth"] => {
405533 if (state.authKind === "none") {
406534 return { kind: "none" };
407535 }
408536
409− if ((state.authKind === "provider_grant_ref" || state.authKind === "mcp_oauth") && state.managedAuth !== null) {
537+ if (
538+ (state.authKind === "provider_grant_ref" ||
539+ state.authKind === "mcp_oauth") &&
540+ state.managedAuth !== null
541+ ) {
410542 return state.managedAuth;
411543 }
412544
@@ -417,7 +549,9 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 4 unmodified lines
417549 const providerId = state.bearerProviderId.trim();
418550 const handle = state.bearerHandle.trim();
419551 if (!providerId || !handle) {
420− throw new Error("Bearer auth requires a token. Select or create a secret.");
552+ throw new Error(
553+ "Bearer auth requires a token. Select or create a secret.",
554+ );
421555 }
422556
423557 return {
@@ -434,13 +568,17 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 10 unmodified lines
434568 const accessProviderId = state.oauthAccessProviderId.trim();
435569 const accessHandle = state.oauthAccessHandle.trim();
436570 if (!accessProviderId || !accessHandle) {
437− throw new Error("OAuth2 auth requires an access token. Select or create a secret.");
571+ throw new Error(
572+ "OAuth2 auth requires an access token. Select or create a secret.",
573+ );
438574 }
439575
440576 const refreshProviderId = trimToNull(state.oauthRefreshProviderId);
441577 const refreshHandle = trimToNull(state.oauthRefreshHandle);
442578 if ((refreshProviderId === null) !== (refreshHandle === null)) {
443− throw new Error("OAuth2 refresh token provider ID and handle must be set together.");
579+ throw new Error(
580+ "OAuth2 refresh token provider ID and handle must be set together.",
581+ );
444582 }
445583
446584 return {
@@ -461,8 +599,14 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 14 unmodified lines
461599 };
462600 };
463601
464−const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload["status"] => {
465− if (state.kind !== "mcp" && state.kind !== "openapi" && state.kind !== "graphql") {
602+const buildRequestedSourceStatus = (
603+ state: SourceFormState,
604+): CreateSourcePayload["status"] => {
605+ if (
606+ state.kind !== "mcp" &&
607+ state.kind !== "openapi" &&
608+ state.kind !== "graphql"
609+ ) {
466610 return undefined;
467611 }
468612
@@ -471,7 +615,14 @@ const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload · 2 unmodified lines
471615
472616 const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {
473617 const name = state.name.trim();
474− const endpoint = state.endpoint.trim();
618+ const isMcpStdio = state.kind === "mcp" && state.transport === "stdio";
619+ const endpoint = isMcpStdio
620+ ? buildSyntheticMcpStdioEndpoint({
621+ name: state.name,
622+ endpoint: state.endpoint,
623+ command: state.command,
624+ })
625+ : state.endpoint.trim();
475626
476627 if (!name) {
477628 throw new Error("Source name is required.");
@@ -480,6 +631,9 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
480631 if (!endpoint) {
481632 throw new Error("Source endpoint is required.");
482633 }
634+ if (isMcpStdio && !state.command.trim()) {
635+ throw new Error("MCP stdio transport requires a command.");
636+ }
483637
484638 const shared = {
485639 name,
@@ -488,16 +642,35 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
488642 status: buildRequestedSourceStatus(state),
489643 enabled: state.enabled,
490644 namespace: trimToNull(state.namespace),
491− auth: buildAuthPayload(state),
492− } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth">;
645+ auth: isMcpStdio ? { kind: "none" as const } : buildAuthPayload(state),
646+ } satisfies Pick<
647+ CreateSourcePayload,
648+ "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth"
649+ >;
493650
494651 if (state.kind === "mcp") {
495652 return {
496653 ...shared,
497654 binding: {
498655 transport: state.transport === "" ? "auto" : state.transport,
499− queryParams: parseJsonStringMap("Query params", state.queryParamsText),
500− headers: parseJsonStringMap("Request headers", state.headersText),
656+ queryParams:
657+ state.transport === "stdio"
658+ ? null
659+ : parseJsonStringMap("Query params", state.queryParamsText),
660+ headers:
661+ state.transport === "stdio"
662+ ? null
663+ : parseJsonStringMap("Request headers", state.headersText),
664+ command: state.transport === "stdio" ? state.command.trim() : null,
665+ args:
666+ state.transport === "stdio"
667+ ? parseJsonStringArray("Args", state.argsText)
668+ : null,
669+ env:
670+ state.transport === "stdio"
671+ ? parseJsonStringMap("Environment", state.envText)
672+ : null,
673+ cwd: state.transport === "stdio" ? trimToNull(state.cwd) : null,
501674 },
502675 };
503676 }
@@ -512,7 +685,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 8 unmodified lines
512685 ...shared,
513686 binding: {
514687 specUrl,
515− defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),
688+ defaultHeaders: parseJsonStringMap(
689+ "Default headers",
690+ state.defaultHeadersText,
691+ ),
516692 },
517693 };
518694 }
@@ -521,7 +697,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
521697 return {
522698 ...shared,
523699 binding: {
524− defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),
700+ defaultHeaders: parseJsonStringMap(
701+ "Default headers",
702+ state.defaultHeadersText,
703+ ),
525704 },
526705 };
527706 }
@@ -542,7 +721,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 14 unmodified lines
542721 service,
543722 version,
544723 discoveryUrl: endpoint,
545− defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),
724+ defaultHeaders: parseJsonStringMap(
725+ "Default headers",
726+ state.defaultHeadersText,
727+ ),
546728 },
547729 };
548730 }
@@ -557,10 +739,15 @@ const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ · 8 unmodified lines
557739 ...buildSourcePayload(state),
558740 });
559741
560−const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => {
742+const buildStartSourceOAuthPayload = (
743+ state: SourceFormState,
744+): StartSourceOAuthPayload => {
561745 if (state.kind !== "mcp") {
562746 throw new Error("OAuth sign-in is only available for MCP sources.");
563747 }
748+ if (state.transport === "stdio") {
749+ throw new Error("OAuth sign-in is not available for MCP stdio sources.");
750+ }
564751
565752 const endpoint = state.endpoint.trim();
566753 if (!endpoint) {
@@ -585,9 +772,9 @@ export function EditSourcePage(props: { sourceId: string }) { · 18 unmodified lines
585772 const sources = useSources();
586773 const source = useSource(props.sourceId);
587774 const missingSource =
588− (sources.status === "ready"
589− && !sources.data.some((candidate) => candidate.id === props.sourceId))
590− || isSourceNotFoundLoadable(source);
775+ (sources.status === "ready" &&
776+ !sources.data.some((candidate) => candidate.id === props.sourceId)) ||
777+ isSourceNotFoundLoadable(source);
591778
592779 if (missingSource) {
593780 return <SourceNotFoundState />;
@@ -619,23 +806,35 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 25 unmodified lines
619806 const [formState, setFormState] = useState<SourceFormState>(() =>
620807 props.source ? formStateFromSource(props.source) : defaultFormState(),
621808 );
622− const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null);
623− const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
809+ const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(
810+ null,
811+ );
812+ const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
813+ null,
814+ );
624815 const [oauthPopupBusy, setOauthPopupBusy] = useState(false);
625− const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] = useState<string | null>(null);
816+ const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] =
817+ useState<string | null>(null);
626818
627− const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";
819+ const isSubmitting =
820+ createSource.status === "pending" || updateSource.status === "pending";
628821 const isDeleting = removeSource.status === "pending";
629822 const isRevokingGrant = removeProviderAuthGrant.status === "pending";
630− const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy;
823+ const isOAuthSubmitting =
824+ startSourceOAuth.status === "pending" || oauthPopupBusy;
631825 const oauthSecretRefTarget =
632− formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
826+ formState.authKind === "oauth2" &&
827+ formState.oauthAccessHandle.trim().length > 0
633828 ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}`
634829 : null;
635830 const showOauthSecretRefs =
636− oauthSecretRefTarget !== null && expandedOauthSecretRefTarget === oauthSecretRefTarget;
831+ oauthSecretRefTarget !== null &&
832+ expandedOauthSecretRefTarget === oauthSecretRefTarget;
637833
638− const setField = <K extends keyof SourceFormState>(key: K, value: SourceFormState[K]) => {
834+ const setField = <K extends keyof SourceFormState>(
835+ key: K,
836+ value: SourceFormState[K],
837+ ) => {
639838 setFormState((current) => ({ ...current, [key]: value }));
640839 };
641840
@@ -657,7 +856,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 15 unmodified lines
657856
658857 try {
659858 if (props.mode === "create") {
660− const createdSource = await createSource.mutateAsync(buildSourcePayload(formState));
859+ const createdSource = await createSource.mutateAsync(
860+ buildSourcePayload(formState),
861+ );
661862 void navigate({
662863 to: "/sources/$sourceId",
663864 params: { sourceId: createdSource.id },
@@ -691,7 +892,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 27 unmodified lines
691892 setStatusBanner(null);
692893
693894 try {
694− const result = await startSourceOAuth.mutateAsync(buildStartSourceOAuthPayload(formState));
895+ const result = await startSourceOAuth.mutateAsync(
896+ buildStartSourceOAuthPayload(formState),
897+ );
695898
696899 setStatusBanner({
697900 tone: "info",
@@ -735,7 +938,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 37 unmodified lines
735938 return;
736939 }
737940
738− const confirmed = window.confirm(`Remove "${props.source.name}" and its indexed tools?`);
941+ const confirmed = window.confirm(
942+ `Remove "${props.source.name}" and its indexed tools?`,
943+ );
739944 if (!confirmed) {
740945 return;
741946 }
@@ -751,16 +956,17 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 9 unmodified lines
751956 } catch (error) {
752957 setStatusBanner({
753958 tone: "error",
754− text: error instanceof Error ? error.message : "Failed removing source.",
959+ text:
960+ error instanceof Error ? error.message : "Failed removing source.",
755961 });
756962 }
757963 };
758964
759965 const handleRevokeProviderGrant = async () => {
760966 if (
761− !props.source
762− || isRevokingGrant
763− || formState.managedAuth?.kind !== "provider_grant_ref"
967+ !props.source ||
968+ isRevokingGrant ||
969+ formState.managedAuth?.kind !== "provider_grant_ref"
764970 ) {
765971 return;
766972 }
@@ -775,7 +981,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 8 unmodified lines
775981 setStatusBanner(null);
776982
777983 try {
778− const result = await removeProviderAuthGrant.mutateAsync(formState.managedAuth.grantId);
984+ const result = await removeProviderAuthGrant.mutateAsync(
985+ formState.managedAuth.grantId,
986+ );
779987 if (!result.removed) {
780988 throw new Error("Shared provider grant was not removed.");
781989 }
@@ -787,14 +995,22 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
787995 } catch (error) {
788996 setStatusBanner({
789997 tone: "error",
790− text: error instanceof Error ? error.message : "Failed revoking shared provider grant.",
998+ text:
999+ error instanceof Error
1000+ ? error.message
1001+ : "Failed revoking shared provider grant.",
7911002 });
7921003 }
7931004 };
7941005
795− const backLink = props.mode === "edit" && props.source
796− ? { to: "/sources/$sourceId" as const, params: { sourceId: props.source.id }, search: { tab: "model" as const } }
797− : { to: "/" as const };
1006+ const backLink =
1007+ props.mode === "edit" && props.source
1008+ ? {
1009+ to: "/sources/$sourceId" as const,
1010+ params: { sourceId: props.source.id },
1011+ search: { tab: "model" as const },
1012+ }
1013+ : { to: "/" as const };
7981014
7991015 return (
8001016 <div className="flex-1 overflow-y-auto">
@@ -841,11 +1057,19 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 40 unmodified lines
8411057 <div className="mb-1 flex items-center justify-between gap-2">
8421058 <div className="flex min-w-0 items-center gap-2">
8431059 <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
844− <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-4" />
1060+ <SourceFavicon
1061+ endpoint={template.endpoint}
1062+ kind={template.kind}
1063+ className="size-4"
1064+ />
8451065 </div>
846− <span className="truncate text-[13px] font-medium text-foreground">{template.name}</span>
1066+ <span className="truncate text-[13px] font-medium text-foreground">
1067+ {template.name}
1068+ </span>
8471069 </div>
848− <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>
1070+ <Badge variant="outline" className="text-[9px]">
1071+ {template.kind}
1072+ </Badge>
8491073 </div>
8501074 <span className="text-[11px] text-muted-foreground line-clamp-1">
8511075 {template.summary}
@@ -870,29 +1094,35 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 18 unmodified lines
8701094 <Field label="Kind">
8711095 <SelectInput
8721096 value={formState.kind}
873− onChange={(value) => setField("kind", value as Source["kind"])}
874− options={kindOptions.map((value) => ({ value, label: value }))}
875− />
876− </Field>
877− <Field
878− label="Endpoint"
879− className="sm:col-span-2"
880− >
881− <TextInput
882− value={formState.endpoint}
883− onChange={(value) => setField("endpoint", value)}
884− placeholder={
885− formState.kind === "openapi"
886− ? "https://api.github.com"
887− : formState.kind === "graphql"
888− ? "https://api.linear.app/graphql"
889− : formState.kind === "google_discovery"
890− ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
891− : "https://mcp.deepwiki.com/mcp"
1097+ onChange={(value) =>
1098+ setField("kind", value as Source["kind"])
8921099 }
893− mono
1100+ options={kindOptions.map((value) => ({
1101+ value,
1102+ label: value,
1103+ }))}
8941104 />
8951105 </Field>
1106+ {!(
1107+ formState.kind === "mcp" && formState.transport === "stdio"
1108+ ) && (
1109+ <Field label="Endpoint" className="sm:col-span-2">
1110+ <TextInput
1111+ value={formState.endpoint}
1112+ onChange={(value) => setField("endpoint", value)}
1113+ placeholder={
1114+ formState.kind === "openapi"
1115+ ? "https://api.github.com"
1116+ : formState.kind === "graphql"
1117+ ? "https://api.linear.app/graphql"
1118+ : formState.kind === "google_discovery"
1119+ ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
1120+ : "https://mcp.deepwiki.com/mcp"
1121+ }
1122+ mono
1123+ />
1124+ </Field>
1125+ )}
8961126 <Field label="Namespace">
8971127 <TextInput
8981128 value={formState.namespace}
@@ -915,26 +1145,70 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 16 unmodified lines
9151145 <Field label="Transport mode">
9161146 <SelectInput
9171147 value={formState.transport || "auto"}
918− onChange={(value) => setField("transport", value as TransportValue)}
919− options={transportOptions.map((value) => ({ value, label: value }))}
1148+ onChange={(value) =>
1149+ setField("transport", value as TransportValue)
1150+ }
1151+ options={transportOptions.map((value) => ({
1152+ value,
1153+ label: value,
1154+ }))}
9201155 />
9211156 </Field>
922− <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
923− <Field label="Query params (JSON)">
924− <CodeEditor
925− value={formState.queryParamsText}
926− onChange={(value) => setField("queryParamsText", value)}
927− placeholder={'{\n "workspace": "demo"\n}'}
928− />
929− </Field>
930− <Field label="Headers (JSON)">
931− <CodeEditor
932− value={formState.headersText}
933− onChange={(value) => setField("headersText", value)}
934− placeholder={'{\n "x-api-key": "..."\n}'}
935− />
936− </Field>
937− </div>
1157+ {formState.transport === "stdio" ? (
1158+ <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1159+ <Field label="Command">
1160+ <TextInput
1161+ value={formState.command}
1162+ onChange={(value) => setField("command", value)}
1163+ placeholder="npx"
1164+ mono
1165+ />
1166+ </Field>
1167+ <Field label="Working directory (optional)">
1168+ <TextInput
1169+ value={formState.cwd}
1170+ onChange={(value) => setField("cwd", value)}
1171+ placeholder="/path/to/project"
1172+ mono
1173+ />
1174+ </Field>
1175+ <Field label="Args (JSON)" className="sm:col-span-2">
1176+ <CodeEditor
1177+ value={formState.argsText}
1178+ onChange={(value) => setField("argsText", value)}
1179+ placeholder={
1180+ '[\n "-y",\n "chrome-devtools-mcp@latest"\n]'
1181+ }
1182+ />
1183+ </Field>
1184+ <Field label="Environment (JSON)" className="sm:col-span-2">
1185+ <CodeEditor
1186+ value={formState.envText}
1187+ onChange={(value) => setField("envText", value)}
1188+ placeholder={
1189+ '{\n "CHROME_PATH": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"\n}'
1190+ }
1191+ />
1192+ </Field>
1193+ </div>
1194+ ) : (
1195+ <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1196+ <Field label="Query params (JSON)">
1197+ <CodeEditor
1198+ value={formState.queryParamsText}
1199+ onChange={(value) => setField("queryParamsText", value)}
1200+ placeholder={'{\n "workspace": "demo"\n}'}
1201+ />
1202+ </Field>
1203+ <Field label="Headers (JSON)">
1204+ <CodeEditor
1205+ value={formState.headersText}
1206+ onChange={(value) => setField("headersText", value)}
1207+ placeholder={'{\n "x-api-key": "..."\n}'}
1208+ />
1209+ </Field>
1210+ </div>
1211+ )}
9381212 </div>
9391213 </Section>
9401214 )}
@@ -1005,20 +1279,38 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 64 unmodified lines
10051279
10061280 <Section title="Authentication">
10071281 <div className="grid gap-4 sm:grid-cols-2">
1008− {formState.kind === "mcp" && (
1282+ {formState.kind === "mcp" && formState.transport !== "stdio" && (
10091283 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">
10101284 <div className="flex items-start gap-3 px-4 py-3.5">
10111285 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1012− <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1013− <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1286+ <svg
1287+ className="size-4"
1288+ viewBox="0 0 24 24"
1289+ fill="none"
1290+ stroke="currentColor"
1291+ strokeWidth="1.5"
1292+ strokeLinecap="round"
1293+ strokeLinejoin="round"
1294+ >
1295+ <rect
1296+ x="3"
1297+ y="11"
1298+ width="18"
1299+ height="11"
1300+ rx="2"
1301+ ry="2"
1302+ />
10141303 <path d="M7 11V7a5 5 0 0110 0v4" />
10151304 </svg>
10161305 </div>
10171306 <div className="flex-1 min-w-0 space-y-2">
10181307 <div className="space-y-0.5">
1019− <p className="text-[12px] font-medium text-foreground">MCP OAuth</p>
1308+ <p className="text-[12px] font-medium text-foreground">
1309+ MCP OAuth
1310+ </p>
10201311 <p className="text-[11px] text-muted-foreground">
1021− Opens the server's built-in OAuth flow to authenticate this source.
1312+ Opens the server's built-in OAuth flow to authenticate
1313+ this source.
10221314 </p>
10231315 </div>
10241316 <Button
@@ -1026,155 +1318,219 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1 unmodified line
10261318 variant="outline"
10271319 size="sm"
10281320 onClick={handleMcpOAuthConnect}
1029− disabled={isSubmitting || isDeleting || isOAuthSubmitting}
1321+ disabled={
1322+ isSubmitting || isDeleting || isOAuthSubmitting
1323+ }
10301324 >
1031− {isOAuthSubmitting ? <IconSpinner className="size-3" /> : null}
1032− {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
1325+ {isOAuthSubmitting ? (
1326+ <IconSpinner className="size-3" />
1327+ ) : null}
1328+ {formState.authKind === "oauth2" &&
1329+ formState.oauthAccessHandle.trim().length > 0
10331330 ? "Reconnect"
10341331 : "Sign in with OAuth"}
10351332 </Button>
10361333 </div>
10371334 </div>
1038− {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 && (
1039− <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
1040− <p className="text-[11px] text-muted-foreground/70">
1041− Token refs attached to this draft
1042− </p>
1043− <button
1044− type="button"
1045− className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1046− onClick={() =>
1047− setExpandedOauthSecretRefTarget((current) =>
1048− current === oauthSecretRefTarget ? null : oauthSecretRefTarget,
1049− )}
1050− >
1051− {showOauthSecretRefs ? "Hide refs" : "Show refs"}
1052− </button>
1053− </div>
1054− )}
1335+ {formState.authKind === "oauth2" &&
1336+ formState.oauthAccessHandle.trim().length > 0 && (
1337+ <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
1338+ <p className="text-[11px] text-muted-foreground/70">
1339+ Token refs attached to this draft
1340+ </p>
1341+ <button
1342+ type="button"
1343+ className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1344+ onClick={() =>
1345+ setExpandedOauthSecretRefTarget((current) =>
1346+ current === oauthSecretRefTarget
1347+ ? null
1348+ : oauthSecretRefTarget,
1349+ )
1350+ }
1351+ >
1352+ {showOauthSecretRefs ? "Hide refs" : "Show refs"}
1353+ </button>
1354+ </div>
1355+ )}
10551356 </div>
10561357 )}
1057− <Field label="Auth mode">
1058− <SelectInput
1059− value={formState.authKind}
1060− onChange={(value) => setField("authKind", value as Source["auth"]["kind"])}
1061− disabled={formState.managedAuth !== null}
1062− options={authOptions.map((value) => ({ value, label: value }))}
1063− />
1064− </Field>
1065− {formState.managedAuth !== null && (
1066− <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden">
1067− <div className="flex items-start gap-3 px-4 py-3.5">
1068− <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1069− {formState.managedAuth.kind === "provider_grant_ref" ? (
1070− <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1071− <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
1072− <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
1073− </svg>
1074− ) : (
1075− <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1076− <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1077− <path d="M7 11V7a5 5 0 0110 0v4" />
1078− </svg>
1079− )}
1080− </div>
1081− <div className="flex-1 min-w-0 space-y-1">
1082− <p className="text-[12px] font-medium text-foreground">
1083− {formState.managedAuth.kind === "provider_grant_ref"
1084− ? "Shared provider grant"
1085− : "Managed MCP OAuth"}
1086− </p>
1087− <p className="text-[11px] leading-relaxed text-muted-foreground">
1088− {formState.managedAuth.kind === "provider_grant_ref"
1089− ? "This source uses a shared Google auth grant. Reconnect from Add Source to change the linked account or scopes."
1090− : "Authenticated through a persisted MCP OAuth session. Reconnect the source to refresh or replace the binding."}
1091− </p>
1092− </div>
1093− </div>
1094− {formState.managedAuth.kind === "provider_grant_ref" && props.mode === "edit" && (
1095− <div className="flex items-center justify-end border-t border-border/50 px-4 py-2.5">
1096− <Button
1097− type="button"
1098− variant="ghost"
1099− size="sm"
1100− className="text-destructive/70 hover:text-destructive hover:bg-destructive/8"
1101− onClick={handleRevokeProviderGrant}
1102− disabled={isRevokingGrant}
1103− >
1104− <IconTrash className="size-3" />
1105− {isRevokingGrant ? "Revoking\u2026" : "Revoke shared auth"}
1106− </Button>
1107− </div>
1108− )}
1358+ {formState.kind === "mcp" && formState.transport === "stdio" ? (
1359+ <div className="sm:col-span-2 rounded-xl border border-border bg-muted/40 px-4 py-3 text-[12px] text-muted-foreground">
1360+ Local MCP stdio sources do not use executor-managed HTTP or
1361+ OAuth credentials.
11091362 </div>
1110− )}
1111− {formState.authKind !== "none" && formState.managedAuth === null && (
1112− <>
1113− <Field label="Header name">
1114− <TextInput
1115− value={formState.authHeaderName}
1116− onChange={(value) => setField("authHeaderName", value)}
1117− placeholder="Authorization"
1118− />
1119− </Field>
1120− <Field label="Prefix">
1121− <TextInput
1122− value={formState.authPrefix}
1123− onChange={(value) => setField("authPrefix", value)}
1124− placeholder="Bearer "
1125− />
1126− </Field>
1127− </>
1128− )}
1129−
1130− {formState.authKind === "bearer" && formState.managedAuth === null && (
1131− <Field label="Token" className="sm:col-span-2">
1132− <SecretPicker
1133− instanceConfig={instanceConfig}
1134− secrets={secrets}
1135− providerId={formState.bearerProviderId}
1136− handle={formState.bearerHandle}
1137− onSelect={(providerId, handle) => {
1138− setField("bearerProviderId", providerId);
1139− setField("bearerHandle", handle);
1140− }}
1363+ ) : (
1364+ <Field label="Auth mode">
1365+ <SelectInput
1366+ value={formState.authKind}
1367+ onChange={(value) =>
1368+ setField("authKind", value as Source["auth"]["kind"])
1369+ }
1370+ disabled={formState.managedAuth !== null}
1371+ options={authOptions.map((value) => ({
1372+ value,
1373+ label: value,
1374+ }))}
11411375 />
11421376 </Field>
11431377 )}
1144−
1145− {formState.authKind === "oauth2"
1146− && formState.managedAuth === null
1147− && (formState.kind !== "mcp"
1148− || formState.oauthAccessHandle.trim().length === 0
1149− || showOauthSecretRefs) && (
1150− <>
1151− <Field label="Access token" className="sm:col-span-2">
1152− <SecretPicker
1153− instanceConfig={instanceConfig}
1154− secrets={secrets}
1155− providerId={formState.oauthAccessProviderId}
1156− handle={formState.oauthAccessHandle}
1157− onSelect={(providerId, handle) => {
1158− setField("oauthAccessProviderId", providerId);
1159− setField("oauthAccessHandle", handle);
1160− }}
1161− />
1162− </Field>
1163− <Field label="Refresh token (optional)" className="sm:col-span-2">
1378+ {formState.managedAuth !== null &&
1379+ !(
1380+ formState.kind === "mcp" && formState.transport === "stdio"
1381+ ) && (
1382+ <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden">
1383+ <div className="flex items-start gap-3 px-4 py-3.5">
1384+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1385+ {formState.managedAuth.kind === "provider_grant_ref" ? (
1386+ <svg
1387+ className="size-4"
1388+ viewBox="0 0 24 24"
1389+ fill="none"
1390+ stroke="currentColor"
1391+ strokeWidth="1.5"
1392+ strokeLinecap="round"
1393+ strokeLinejoin="round"
1394+ >
1395+ <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
1396+ <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
1397+ </svg>
1398+ ) : (
1399+ <svg
1400+ className="size-4"
1401+ viewBox="0 0 24 24"
1402+ fill="none"
1403+ stroke="currentColor"
1404+ strokeWidth="1.5"
1405+ strokeLinecap="round"
1406+ strokeLinejoin="round"
1407+ >
1408+ <rect
1409+ x="3"
1410+ y="11"
1411+ width="18"
1412+ height="11"
1413+ rx="2"
1414+ ry="2"
1415+ />
1416+ <path d="M7 11V7a5 5 0 0110 0v4" />
1417+ </svg>
1418+ )}
1419+ </div>
1420+ <div className="flex-1 min-w-0 space-y-1">
1421+ <p className="text-[12px] font-medium text-foreground">
1422+ {formState.managedAuth.kind === "provider_grant_ref"
1423+ ? "Shared provider grant"
1424+ : "Managed MCP OAuth"}
1425+ </p>
1426+ <p className="text-[11px] leading-relaxed text-muted-foreground">
1427+ {formState.managedAuth.kind === "provider_grant_ref"
1428+ ? "This source uses a shared Google auth grant. Reconnect from Add Source to change the linked account or scopes."
1429+ : "Authenticated through a persisted MCP OAuth session. Reconnect the source to refresh or replace the binding."}
1430+ </p>
1431+ </div>
1432+ </div>
1433+ {formState.managedAuth.kind === "provider_grant_ref" &&
1434+ props.mode === "edit" && (
1435+ <div className="flex items-center justify-end border-t border-border/50 px-4 py-2.5">
1436+ <Button
1437+ type="button"
1438+ variant="ghost"
1439+ size="sm"
1440+ className="text-destructive/70 hover:text-destructive hover:bg-destructive/8"
1441+ onClick={handleRevokeProviderGrant}
1442+ disabled={isRevokingGrant}
1443+ >
1444+ <IconTrash className="size-3" />
1445+ {isRevokingGrant
1446+ ? "Revoking\u2026"
1447+ : "Revoke shared auth"}
1448+ </Button>
1449+ </div>
1450+ )}
1451+ </div>
1452+ )}
1453+ {formState.authKind !== "none" &&
1454+ formState.managedAuth === null &&
1455+ !(
1456+ formState.kind === "mcp" && formState.transport === "stdio"
1457+ ) && (
1458+ <>
1459+ <Field label="Header name">
1460+ <TextInput
1461+ value={formState.authHeaderName}
1462+ onChange={(value) => setField("authHeaderName", value)}
1463+ placeholder="Authorization"
1464+ />
1465+ </Field>
1466+ <Field label="Prefix">
1467+ <TextInput
1468+ value={formState.authPrefix}
1469+ onChange={(value) => setField("authPrefix", value)}
1470+ placeholder="Bearer "
1471+ />
1472+ </Field>
1473+ </>
1474+ )}
1475+
1476+ {formState.authKind === "bearer" &&
1477+ formState.managedAuth === null &&
1478+ !(
1479+ formState.kind === "mcp" && formState.transport === "stdio"
1480+ ) && (
1481+ <Field label="Token" className="sm:col-span-2">
11641482 <SecretPicker
11651483 instanceConfig={instanceConfig}
11661484 secrets={secrets}
1167− providerId={formState.oauthRefreshProviderId}
1168− handle={formState.oauthRefreshHandle}
1485+ providerId={formState.bearerProviderId}
1486+ handle={formState.bearerHandle}
11691487 onSelect={(providerId, handle) => {
1170− setField("oauthRefreshProviderId", providerId);
1171− setField("oauthRefreshHandle", handle);
1488+ setField("bearerProviderId", providerId);
1489+ setField("bearerHandle", handle);
11721490 }}
1173− allowEmpty
11741491 />
11751492 </Field>
1176− </>
1177− )}
1493+ )}
1494+
1495+ {formState.authKind === "oauth2" &&
1496+ formState.managedAuth === null &&
1497+ !(
1498+ formState.kind === "mcp" && formState.transport === "stdio"
1499+ ) &&
1500+ (formState.kind !== "mcp" ||
1501+ formState.oauthAccessHandle.trim().length === 0 ||
1502+ showOauthSecretRefs) && (
1503+ <>
1504+ <Field label="Access token" className="sm:col-span-2">
1505+ <SecretPicker
1506+ instanceConfig={instanceConfig}
1507+ secrets={secrets}
1508+ providerId={formState.oauthAccessProviderId}
1509+ handle={formState.oauthAccessHandle}
1510+ onSelect={(providerId, handle) => {
1511+ setField("oauthAccessProviderId", providerId);
1512+ setField("oauthAccessHandle", handle);
1513+ }}
1514+ />
1515+ </Field>
1516+ <Field
1517+ label="Refresh token (optional)"
1518+ className="sm:col-span-2"
1519+ >
1520+ <SecretPicker
1521+ instanceConfig={instanceConfig}
1522+ secrets={secrets}
1523+ providerId={formState.oauthRefreshProviderId}
1524+ handle={formState.oauthRefreshHandle}
1525+ onSelect={(providerId, handle) => {
1526+ setField("oauthRefreshProviderId", providerId);
1527+ setField("oauthRefreshHandle", handle);
1528+ }}
1529+ allowEmpty
1530+ />
1531+ </Field>
1532+ </>
1533+ )}
11781534 </div>
11791535 </Section>
11801536
@@ -1196,10 +1552,19 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 15 unmodified lines
11961552 {/* Actions */}
11971553 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
11981554 <Link {...backLink} className="inline-flex">
1199− <Button variant="ghost" type="button">Cancel</Button>
1555+ <Button variant="ghost" type="button">
1556+ Cancel
1557+ </Button>
12001558 </Link>
1201− <Button onClick={handleSubmit} disabled={isSubmitting || isRevokingGrant}>
1202− {props.mode === "edit" ? <IconPencil className="size-3.5" /> : <IconPlus className="size-3.5" />}
1559+ <Button
1560+ onClick={handleSubmit}
1561+ disabled={isSubmitting || isRevokingGrant}
1562+ >
1563+ {props.mode === "edit" ? (
1564+ <IconPencil className="size-3.5" />
1565+ ) : (
1566+ <IconPlus className="size-3.5" />
1567+ )}
12031568 {isSubmitting
12041569 ? props.mode === "edit"
12051570 ? "Saving\u2026"
@@ -1219,9 +1584,18 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 13 unmodified lines
12191584 // Form building blocks
12201585 // ---------------------------------------------------------------------------
12211586
1222−function Section(props: { title: string; children: ReactNode; className?: string }) {
1587+function Section(props: {
1588+ title: string;
1589+ children: ReactNode;
1590+ className?: string;
1591+}) {
12231592 return (
1224− <section className={cn("rounded-xl border border-border bg-card/80", props.className)}>
1593+ <section
1594+ className={cn(
1595+ "rounded-xl border border-border bg-card/80",
1596+ props.className,
1597+ )}
1598+ >
12251599 <div className="border-b border-border px-5 py-3">
12261600 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
12271601 </div>
@@ -1237,7 +1611,9 @@ function Field(props: { · 9 unmodified lines
12371611 }) {
12381612 return (
12391613 <label className={cn("block space-y-1.5", props.className)}>
1240− <span className="text-[12px] font-medium text-foreground">{props.label}</span>
1614+ <span className="text-[12px] font-medium text-foreground">
1615+ {props.label}
1616+ </span>
12411617 {props.children}
12421618 </label>
12431619 );
@@ -1300,7 +1676,10 @@ function CodeEditor(props: { · 56 unmodified lines
13001676 );
13011677 }
13021678
1303−function ToggleButton(props: { checked: boolean; onChange: (checked: boolean) => void }) {
1679+function ToggleButton(props: {
1680+ checked: boolean;
1681+ onChange: (checked: boolean) => void;
1682+}) {
13041683 return (
13051684 <button
13061685 type="button"
@@ -1328,9 +1707,12 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 21 unmodified lines
13281707 <div
13291708 className={cn(
13301709 "rounded-lg border px-4 py-3 text-[13px]",
1331− props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",
1332− props.state.tone === "info" && "border-border bg-card text-muted-foreground",
1333− props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",
1710+ props.state.tone === "success" &&
1711+ "border-primary/30 bg-primary/8 text-foreground",
1712+ props.state.tone === "info" &&
1713+ "border-border bg-card text-muted-foreground",
1714+ props.state.tone === "error" &&
1715+ "border-destructive/30 bg-destructive/8 text-destructive",
13341716 props.className,
13351717 )}
13361718 >
@@ -1349,7 +1731,8 @@ function SecretPicker(props: { · 12 unmodified lines
13491731 onSelect: (providerId: string, handle: string) => void;
13501732 allowEmpty?: boolean;
13511733 }) {
1352− const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props;
1734+ const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } =
1735+ props;
13531736 const createSecret = useCreateSecret();
13541737 const [showCreate, setShowCreate] = useState(false);
13551738 const [newName, setNewName] = useState("");
@@ -1358,7 +1741,9 @@ function SecretPicker(props: { · 2 unmodified lines
13581741 const [createError, setCreateError] = useState<string | null>(null);
13591742 const storableProviders =
13601743 instanceConfig.status === "ready"
1361− ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)
1744+ ? instanceConfig.data.secretProviders.filter(
1745+ (provider) => provider.canStore,
1746+ )
13621747 : [];
13631748
13641749 useEffect(() => {
@@ -1390,9 +1775,10 @@ function SecretPicker(props: { · 25 unmodified lines
13901775 onSelect("", "");
13911776 return;
13921777 }
1393− const matchedSecret = secrets.status === "ready"
1394− ? secrets.data.find((secret) => secret.id === value)
1395− : null;
1778+ const matchedSecret =
1779+ secrets.status === "ready"
1780+ ? secrets.data.find((secret) => secret.id === value)
1781+ : null;
13961782 onSelect(matchedSecret?.providerId ?? "local", value);
13971783 };
13981784
@@ -1417,7 +1803,9 @@ function SecretPicker(props: { · 18 unmodified lines
14171803 onSelect(result.providerId, result.id);
14181804 setShowCreate(false);
14191805 } catch (err) {
1420− setCreateError(err instanceof Error ? err.message : "Failed creating secret.");
1806+ setCreateError(
1807+ err instanceof Error ? err.message : "Failed creating secret.",
1808+ );
14211809 }
14221810 };
14231811
@@ -1446,7 +1834,9 @@ function SecretPicker(props: { · 22 unmodified lines
14461834 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
14471835 >
14481836 {allowEmpty && <option value="">None</option>}
1449− {!allowEmpty && !selectedValue && <option value="">Select a secret…</option>}
1837+ {!allowEmpty && !selectedValue && (
1838+ <option value="">Select a secret…</option>
1839+ )}
14501840 {items.map((secret) => (
14511841 <option key={secret.id} value={secret.id}>
14521842 {secret.name || secret.id} ({secret.providerId})
@@ -1469,7 +1859,9 @@ function SecretPicker(props: { · 16 unmodified lines
14691859 )}
14701860 <div className="grid gap-3 sm:grid-cols-2">
14711861 <label className="block space-y-1">
1472− <span className="text-[11px] font-medium text-muted-foreground">Name</span>
1862+ <span className="text-[11px] font-medium text-muted-foreground">
1863+ Name
1864+ </span>
14731865 <input
14741866 value={newName}
14751867 onChange={(e) => setNewName(e.target.value)}
@@ -1479,7 +1871,9 @@ function SecretPicker(props: { · 3 unmodified lines
14791871 />
14801872 </label>
14811873 <label className="block space-y-1">
1482− <span className="text-[11px] font-medium text-muted-foreground">Value</span>
1874+ <span className="text-[11px] font-medium text-muted-foreground">
1875+ Value
1876+ </span>
14831877 <input
14841878 type="password"
14851879 value={newValue}
@@ -1489,7 +1883,9 @@ function SecretPicker(props: { · 3 unmodified lines
14891883 />
14901884 </label>
14911885 <label className="block space-y-1">
1492− <span className="text-[11px] font-medium text-muted-foreground">Store in</span>
1886+ <span className="text-[11px] font-medium text-muted-foreground">
1887+ Store in
1888+ </span>
14931889 <select
14941890 value={newProviderId}
14951891 onChange={(e) => setNewProviderId(e.target.value)}
@@ -1516,7 +1912,9 @@ function SecretPicker(props: { · 20 unmodified lines
15161912 onClick={handleCreate}
15171913 disabled={createSecret.status === "pending"}
15181914 >
1519− {createSecret.status === "pending" && <IconSpinner className="size-3" />}
1915+ {createSecret.status === "pending" && (
1916+ <IconSpinner className="size-3" />
1917+ )}
15201918 Store & use
15211919 </Button>
15221920 </div>
apps/web/src/views/source-templates.ts+63−20
@@ -1,15 +1,24 @@
1−import type { Source } from "@executor/react";
2−
31 type SourceTemplateBase = {
42 id: string;
53 name: string;
64 summary: string;
7− endpoint: string;
5+ endpoint?: string;
6+ namespace?: string;
87 groupId?: string;
98 groupLabel?: string;
109 batchable?: boolean;
1110 };
1211
12+export type McpSourceTemplate = SourceTemplateBase & {
13+ kind: "mcp";
14+ connectionType?: "endpoint" | "command";
15+ transport?: "auto" | "streamable-http" | "sse" | "stdio";
16+ command?: string;
17+ args?: ReadonlyArray<string>;
18+ env?: Record<string, string>;
19+ cwd?: string;
20+};
21+
1322 export type OpenApiSourceTemplate = SourceTemplateBase & {
1423 kind: "openapi";
1524 specUrl: string;
@@ -22,14 +31,22 @@ export type GoogleDiscoverySourceTemplate = SourceTemplateBase & { · 6 unmodified lines
2231 discoveryUrl: string;
2332 };
2433
25−export type NonOpenApiSourceTemplate = SourceTemplateBase & {
26− kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">;
34+export type GraphqlSourceTemplate = SourceTemplateBase & {
35+ kind: "graphql";
2736 };
2837
2938 export type SourceTemplate =
39+ | McpSourceTemplate
3040 | OpenApiSourceTemplate
3141 | GoogleDiscoverySourceTemplate
32− | NonOpenApiSourceTemplate;
42+ | GraphqlSourceTemplate;
43+
44+export const isStdioMcpSourceTemplate = (
45+ template: SourceTemplate,
46+): template is McpSourceTemplate & { transport: "stdio" } =>
47+ template.kind === "mcp" &&
48+ template.connectionType === "command" &&
49+ template.transport === "stdio";
3350
3451 const googleDiscoveryUrl = (service: string, version: string): string =>
3552 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
@@ -42,7 +59,8 @@ const googleDiscoveryTemplate = (input: { · 6 unmodified lines
4259 version: string;
4360 discoveryUrl?: string;
4461 }): GoogleDiscoverySourceTemplate => {
45− const discoveryUrl = input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);
62+ const discoveryUrl =
63+ input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);
4664 return {
4765 id: input.id,
4866 name: input.name,
@@ -80,6 +98,18 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 31 unmodified lines
8098 kind: "mcp",
8199 endpoint: "https://mcp.neon.tech/mcp",
82100 },
101+ {
102+ id: "chrome-devtools-mcp",
103+ name: "Chrome DevTools MCP",
104+ summary:
105+ "Debug a live Chrome browser session over a local MCP stdio transport.",
106+ kind: "mcp",
107+ namespace: "chrome.devtools",
108+ connectionType: "command",
109+ transport: "stdio",
110+ command: "npx",
111+ args: ["-y", "chrome-devtools-mcp@latest"],
112+ },
83113 {
84114 id: "neon-api",
85115 name: "Neon API",
@@ -94,7 +124,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 8 unmodified lines
94124 summary: "Repos, issues, pull requests, actions, and org settings.",
95125 kind: "openapi",
96126 endpoint: "https://api.github.com",
97− specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
127+ specUrl:
128+ "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
98129 groupId: "github",
99130 groupLabel: "GitHub",
100131 batchable: false,
@@ -102,7 +133,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
102133 {
103134 id: "github-graphql",
104135 name: "GitHub GraphQL",
105− summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",
136+ summary:
137+ "Issues, pull requests, discussions, and repository objects via GraphQL.",
106138 kind: "graphql",
107139 endpoint: "https://api.github.com/graphql",
108140 groupId: "github",
@@ -122,7 +154,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 13 unmodified lines
122154 summary: "Models, files, responses, and fine-tuning.",
123155 kind: "openapi",
124156 endpoint: "https://api.openai.com/v1",
125− specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
157+ specUrl:
158+ "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml",
126159 },
127160 {
128161 id: "vercel-api",
@@ -138,7 +171,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 9 unmodified lines
138171 summary: "Payments, billing, subscriptions, and invoices.",
139172 kind: "openapi",
140173 endpoint: "https://api.stripe.com",
141− specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
174+ specUrl:
175+ "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json",
142176 },
143177 {
144178 id: "linear-graphql",
@@ -167,7 +201,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 22 unmodified lines
167201 summary: "Calendars, events, ACLs, and scheduling workflows.",
168202 service: "calendar",
169203 version: "v3",
170− discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
204+ discoveryUrl:
205+ "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
171206 }),
172207 googleDiscoveryTemplate({
173208 id: "google-drive",
@@ -219,10 +254,12 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 45 unmodified lines
219254 googleDiscoveryTemplate({
220255 id: "google-search-console",
221256 name: "Google Search Console",
222− summary: "Sites, sitemaps, URL inspection, and search and Discover performance.",
257+ summary:
258+ "Sites, sitemaps, URL inspection, and search and Discover performance.",
223259 service: "searchconsole",
224260 version: "v1",
225− discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
261+ discoveryUrl:
262+ "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
226263 }),
227264 googleDiscoveryTemplate({
228265 id: "google-people",
@@ -270,7 +307,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 41 unmodified lines
270307 summary: "Users, groups, org units, roles, and domain directory resources.",
271308 service: "admin",
272309 version: "directory_v1",
273− discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
310+ discoveryUrl:
311+ "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
274312 }),
275313 googleDiscoveryTemplate({
276314 id: "google-admin-reports",
@@ -278,12 +316,14 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
278316 summary: "Audit events, usage reports, and admin activity logs.",
279317 service: "admin",
280318 version: "reports_v1",
281− discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
319+ discoveryUrl:
320+ "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
282321 }),
283322 googleDiscoveryTemplate({
284323 id: "google-apps-script",
285324 name: "Google Apps Script",
286− summary: "Projects, deployments, script execution, and Apps Script metadata.",
325+ summary:
326+ "Projects, deployments, script execution, and Apps Script metadata.",
287327 service: "script",
288328 version: "v1",
289329 discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
@@ -291,7 +331,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
291331 googleDiscoveryTemplate({
292332 id: "google-bigquery",
293333 name: "Google BigQuery",
294− summary: "Datasets, tables, jobs, routines, and analytical query workflows.",
334+ summary:
335+ "Datasets, tables, jobs, routines, and analytical query workflows.",
295336 service: "bigquery",
296337 version: "v2",
297338 discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
@@ -299,10 +340,12 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
299340 googleDiscoveryTemplate({
300341 id: "google-cloud-resource-manager",
301342 name: "Google Cloud Resource Manager",
302− summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
343+ summary:
344+ "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
303345 service: "cloudresourcemanager",
304346 version: "v3",
305− discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
347+ discoveryUrl:
348+ "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
306349 }),
307350 googleDiscoveryTemplate({
308351 id: "google-youtube-data",
packages/platform/control-plane/src/api/sources/http.ts+4−0
@@ -687,6 +687,10 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 686 unmodified lines
687687 transport: payload.transport,
688688 queryParams: payload.queryParams,
689689 headers: payload.headers,
690+ command: payload.command,
691+ args: payload.args,
692+ env: payload.env,
693+ cwd: payload.cwd,
690694 baseUrl,
691695 });
692696 }
packages/platform/control-plane/src/runtime/execution/live.ts+12−5
@@ -2,6 +2,7 @@ import type { · 1 unmodified line
22 ElicitationResponse,
33 OnElicitation,
44 } from "@executor/codemode-core";
5+import { clearMcpConnectionPoolRun } from "@executor/source-mcp";
56 import {
67 ExecutionInteractionIdSchema,
78 type Execution,
@@ -233,15 +234,21 @@ export const createLiveExecutionManager = () => { · 225 unmodified lines
233234
234235 finishRun: ({ executionId, state }) =>
235236 publishState({ executionId, state }).pipe(
237+ Effect.zipRight(
238+ clearMcpConnectionPoolRun(executionId).pipe(
239+ Effect.zipRight(Effect.sync(() => {
240+ runs.delete(executionId);
241+ })),
242+ ),
243+ ),
244+ ),
245+
246+ clearRun: (executionId) =>
247+ clearMcpConnectionPoolRun(executionId).pipe(
236248 Effect.zipRight(Effect.sync(() => {
237249 runs.delete(executionId);
238250 })),
239251 ),
240−
241− clearRun: (executionId) =>
242− Effect.sync(() => {
243− runs.delete(executionId);
244− }),
245252 } satisfies LiveExecutionManagerShape;
246253
247254 return manager;
packages/platform/control-plane/src/runtime/execution/mcp-resume.test.ts+22−18
@@ -244,24 +244,28 @@ const makeMcpExecutionResolver = ( · 243 unmodified lines
244244 ({ onElicitation }) =>
245245 (Effect.gen(function* () {
246246 const discovered = yield* discoverMcpToolsFromConnector({
247− connect: async () => {
248− const client = new Client(
249− {
250− name: "control-plane-mcp-execution-client",
251− version: "1.0.0",
252− },
253− { capabilities: { elicitation: { form: {} } } },
254− );
255− const transport = new StreamableHTTPClientTransport(new URL(endpoint));
256− await client.connect(transport);
257−
258− return {
259− client,
260− close: async () => {
261− await client.close();
262− },
263− };
264− },
247+ connect: Effect.tryPromise({
248+ try: async () => {
249+ const client = new Client(
250+ {
251+ name: "control-plane-mcp-execution-client",
252+ version: "1.0.0",
253+ },
254+ { capabilities: { elicitation: { form: {} } } },
255+ );
256+ const transport = new StreamableHTTPClientTransport(new URL(endpoint));
257+ await client.connect(transport);
258+
259+ return {
260+ client,
261+ close: async () => {
262+ await client.close();
263+ },
264+ };
265+ },
266+ catch: (cause) =>
267+ cause instanceof Error ? cause : new Error(String(cause)),
268+ }),
265269 namespace: "source.form",
266270 sourceKey: "mcp.form",
267271 });
packages/platform/control-plane/src/runtime/index.ts+1−1
@@ -283,7 +283,7 @@ export const createControlPlaneRuntime = ( · 282 unmodified lines
283283 managedRuntime,
284284 runtimeLayer: concreteRuntimeLayer as RuntimeControlPlaneLayer,
285285 close: async () => {
286− await clearAllMcpConnectionPools().catch(() => undefined);
286+ await Effect.runPromise(clearAllMcpConnectionPools()).catch(() => undefined);
287287 await managedRuntime.dispose().catch(() => undefined);
288288 },
289289 };
packages/platform/control-plane/src/runtime/sources/source-adapters/mcp.test.ts+441−0
@@ -335,6 +335,233 @@ const makeAuthenticatedMcpServer = (expectedAuthorization: string) => · 334 unmodified lines
335335 }).pipe(Effect.orDie),
336336 );
337337
338+const makeStatefulMcpServer = Effect.acquireRelease(
339+ Effect.promise<RealMcpServer>(
340+ () =>
341+ new Promise<RealMcpServer>((resolve, reject) => {
342+ const transports: Record<string, StreamableHTTPServerTransport> = {};
343+ const servers: Record<string, McpServer> = {};
344+
345+ const createServer = () => {
346+ let counter = 0;
347+ const mcp = new McpServer(
348+ {
349+ name: "mcp-stateful-test-server",
350+ version: "1.0.0",
351+ },
352+ {
353+ capabilities: {
354+ tools: {
355+ listChanged: true,
356+ },
357+ },
358+ },
359+ );
360+
361+ mcp.registerTool(
362+ "increment_session",
363+ {
364+ title: "Increment Session",
365+ description: "Increment session state",
366+ inputSchema: {},
367+ },
368+ async () => {
369+ counter += 1;
370+ return {
371+ content: [{
372+ type: "text",
373+ text: String(counter),
374+ }],
375+ };
376+ },
377+ );
378+
379+ mcp.registerTool(
380+ "read_session",
381+ {
382+ title: "Read Session",
383+ description: "Read current session state",
384+ inputSchema: {},
385+ },
386+ async () => ({
387+ content: [{
388+ type: "text",
389+ text: String(counter),
390+ }],
391+ }),
392+ );
393+
394+ return mcp;
395+ };
396+
397+ const app = createMcpExpressApp({ host: "127.0.0.1" });
398+
399+ app.post("/mcp", async (req: any, res: any) => {
400+ const sessionIdHeader = req.headers["mcp-session-id"];
401+ const sessionId =
402+ typeof sessionIdHeader === "string"
403+ ? sessionIdHeader
404+ : Array.isArray(sessionIdHeader)
405+ ? sessionIdHeader[0]
406+ : undefined;
407+
408+ try {
409+ let transport: StreamableHTTPServerTransport;
410+
411+ if (sessionId && transports[sessionId]) {
412+ transport = transports[sessionId];
413+ } else {
414+ transport = new StreamableHTTPServerTransport({
415+ sessionIdGenerator: () => randomUUID(),
416+ onsessioninitialized: (newSessionId) => {
417+ transports[newSessionId] = transport;
418+ },
419+ });
420+
421+ transport.onclose = () => {
422+ const closedSessionId = transport.sessionId;
423+ if (closedSessionId && transports[closedSessionId]) {
424+ delete transports[closedSessionId];
425+ }
426+ if (closedSessionId && servers[closedSessionId]) {
427+ void servers[closedSessionId].close().catch(() => undefined);
428+ delete servers[closedSessionId];
429+ }
430+ };
431+
432+ const server = createServer();
433+ await server.connect(transport);
434+ const newSessionId = transport.sessionId;
435+ if (newSessionId) {
436+ servers[newSessionId] = server;
437+ }
438+ }
439+
440+ await transport.handleRequest(req, res, req.body);
441+ } catch (error) {
442+ if (!res.headersSent) {
443+ res.status(500).json({
444+ jsonrpc: "2.0",
445+ error: {
446+ code: -32603,
447+ message: error instanceof Error ? error.message : "Internal server error",
448+ },
449+ id: null,
450+ });
451+ }
452+ }
453+ });
454+
455+ app.get("/mcp", async (req: any, res: any) => {
456+ const sessionIdHeader = req.headers["mcp-session-id"];
457+ const sessionId =
458+ typeof sessionIdHeader === "string"
459+ ? sessionIdHeader
460+ : Array.isArray(sessionIdHeader)
461+ ? sessionIdHeader[0]
462+ : undefined;
463+
464+ if (!sessionId || !transports[sessionId]) {
465+ res.status(400).send("Invalid or missing session ID");
466+ return;
467+ }
468+
469+ await transports[sessionId].handleRequest(req, res);
470+ });
471+
472+ app.delete("/mcp", async (req: any, res: any) => {
473+ const sessionIdHeader = req.headers["mcp-session-id"];
474+ const sessionId =
475+ typeof sessionIdHeader === "string"
476+ ? sessionIdHeader
477+ : Array.isArray(sessionIdHeader)
478+ ? sessionIdHeader[0]
479+ : undefined;
480+
481+ if (!sessionId || !transports[sessionId]) {
482+ res.status(400).send("Invalid or missing session ID");
483+ return;
484+ }
485+
486+ const transport = transports[sessionId];
487+ await transport.handleRequest(req, res, req.body);
488+ await transport.close();
489+ delete transports[sessionId];
490+
491+ if (servers[sessionId]) {
492+ await servers[sessionId].close().catch(() => undefined);
493+ delete servers[sessionId];
494+ }
495+ });
496+
497+ const listener = app.listen(0, "127.0.0.1", () => {
498+ const address = listener.address();
499+ if (!address || typeof address === "string") {
500+ reject(new Error("failed to resolve stateful MCP test server address"));
501+ return;
502+ }
503+
504+ resolve({
505+ endpoint: `http://127.0.0.1:${address.port}/mcp`,
506+ close: async () => {
507+ for (const transport of Object.values(transports)) {
508+ await transport.close().catch(() => undefined);
509+ }
510+ for (const server of Object.values(servers)) {
511+ await server.close().catch(() => undefined);
512+ }
513+ await new Promise<void>((closeResolve, closeReject) => {
514+ listener.close((error: Error | undefined) => {
515+ if (error) {
516+ closeReject(error);
517+ return;
518+ }
519+ closeResolve();
520+ });
521+ });
522+ },
523+ });
524+ });
525+
526+ listener.once("error", reject);
527+ }),
528+ ),
529+ (server: RealMcpServer) =>
530+ Effect.tryPromise({
531+ try: () => server.close(),
532+ catch: (error: unknown) =>
533+ error instanceof Error ? error : new Error(String(error)),
534+ }).pipe(Effect.orDie),
535+);
536+
537+const STDIO_TEST_SERVER_SCRIPT = `
538+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
539+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
540+import { z } from "zod/v4";
541+
542+const server = new McpServer(
543+ { name: "mcp-stdio-test-server", version: "1.0.0" },
544+ { capabilities: { tools: { listChanged: true } } },
545+);
546+
547+server.registerTool(
548+ "echo_stdio",
549+ {
550+ title: "Echo Stdio",
551+ description: "Echo a value over stdio",
552+ inputSchema: {
553+ value: z.string(),
554+ },
555+ },
556+ async ({ value }) => ({
557+ content: [{ type: "text", text: "stdio:" + value }],
558+ }),
559+);
560+
561+const transport = new StdioServerTransport();
562+await server.connect(transport);
563+`;
564+
338565 const makeStaticAuthProvider = (accessToken: string): OAuthClientProvider => ({
339566 get redirectUrl() {
340567 return "http://127.0.0.1/oauth/callback";
@@ -617,4 +844,218 @@ describe("mcp source adapter", () => { · 276 unmodified lines
617844 });
618845 }),
619846 );
847+
848+ it.scoped("reuses a stateful MCP session across invocations in the same run", () =>
849+ Effect.gen(function* () {
850+ const realServer = yield* makeStatefulMcpServer;
851+ const source = yield* createSourceFromPayload({
852+ workspaceId: "ws_test" as any,
853+ sourceId: SourceIdSchema.make(`src_${randomUUID()}`),
854+ payload: {
855+ name: "Stateful MCP Demo",
856+ kind: "mcp",
857+ endpoint: realServer.endpoint,
858+ namespace: "mcp.stateful.demo",
859+ binding: {
860+ transport: "streamable-http",
861+ queryParams: null,
862+ headers: null,
863+ },
864+ importAuthPolicy: "reuse_runtime",
865+ importAuth: { kind: "none" },
866+ auth: { kind: "none" },
867+ status: "connected",
868+ enabled: true,
869+ },
870+ now: Date.now(),
871+ });
872+
873+ const syncResult = yield* mcpSourceAdapter.syncCatalog({
874+ source,
875+ resolveSecretMaterial: () =>
876+ Effect.fail(runtimeEffectError("sources/source-adapters/mcp.test", "unexpected secret lookup")),
877+ resolveAuthMaterialForSlot: () =>
878+ Effect.succeed({
879+ placements: [],
880+ headers: {},
881+ queryParams: {},
882+ cookies: {},
883+ bodyValues: {},
884+ expiresAt: null,
885+ refreshAfter: null,
886+ }),
887+ });
888+ const snapshot = snapshotFromSourceCatalogSyncResult(syncResult);
889+ const catalog = makeLoadedCatalog({
890+ source,
891+ snapshot,
892+ });
893+
894+ const incrementTool = yield* expandCatalogToolByPath({
895+ catalogs: [catalog],
896+ path: "mcp.stateful.demo.increment_session",
897+ });
898+ const readTool = yield* expandCatalogToolByPath({
899+ catalogs: [catalog],
900+ path: "mcp.stateful.demo.read_session",
901+ });
902+
903+ if (!incrementTool || !readTool) {
904+ throw new Error("Expected stateful MCP tools to resolve");
905+ }
906+
907+ const incrementResult = yield* invokeIrTool({
908+ workspaceId: source.workspaceId,
909+ accountId: "acct_test" as any,
910+ tool: incrementTool,
911+ auth: {
912+ placements: [],
913+ headers: {},
914+ queryParams: {},
915+ cookies: {},
916+ bodyValues: {},
917+ expiresAt: null,
918+ refreshAfter: null,
919+ },
920+ args: {},
921+ context: {
922+ runId: "exec_mcp_stateful",
923+ },
924+ });
925+
926+ const readResult = yield* invokeIrTool({
927+ workspaceId: source.workspaceId,
928+ accountId: "acct_test" as any,
929+ tool: readTool,
930+ auth: {
931+ placements: [],
932+ headers: {},
933+ queryParams: {},
934+ cookies: {},
935+ bodyValues: {},
936+ expiresAt: null,
937+ refreshAfter: null,
938+ },
939+ args: {},
940+ context: {
941+ runId: "exec_mcp_stateful",
942+ },
943+ });
944+
945+ expect(incrementResult).toEqual({
946+ data: {
947+ content: [{
948+ type: "text",
949+ text: "1",
950+ }],
951+ },
952+ error: null,
953+ headers: {},
954+ status: null,
955+ });
956+ expect(readResult).toEqual({
957+ data: {
958+ content: [{
959+ type: "text",
960+ text: "1",
961+ }],
962+ },
963+ error: null,
964+ headers: {},
965+ status: null,
966+ });
967+ }),
968+ );
969+
970+ it.scoped("supports stdio MCP sources", () =>
971+ Effect.gen(function* () {
972+ const source = yield* createSourceFromPayload({
973+ workspaceId: "ws_test" as any,
974+ sourceId: SourceIdSchema.make(`src_${randomUUID()}`),
975+ payload: {
976+ name: "Stdio MCP Demo",
977+ kind: "mcp",
978+ endpoint: "stdio://local/mcp-stdio-demo",
979+ namespace: "mcp.stdio.demo",
980+ binding: {
981+ transport: "stdio",
982+ queryParams: null,
983+ headers: null,
984+ command: "node",
985+ args: ["--input-type=module", "-e", STDIO_TEST_SERVER_SCRIPT],
986+ env: null,
987+ cwd: process.cwd(),
988+ },
989+ importAuthPolicy: "reuse_runtime",
990+ importAuth: { kind: "none" },
991+ auth: { kind: "none" },
992+ status: "connected",
993+ enabled: true,
994+ },
995+ now: Date.now(),
996+ });
997+
998+ const syncResult = yield* mcpSourceAdapter.syncCatalog({
999+ source,
1000+ resolveSecretMaterial: () =>
1001+ Effect.fail(runtimeEffectError("sources/source-adapters/mcp.test", "unexpected secret lookup")),
1002+ resolveAuthMaterialForSlot: () =>
1003+ Effect.succeed({
1004+ placements: [],
1005+ headers: {},
1006+ queryParams: {},
1007+ cookies: {},
1008+ bodyValues: {},
1009+ expiresAt: null,
1010+ refreshAfter: null,
1011+ }),
1012+ });
1013+ const snapshot = snapshotFromSourceCatalogSyncResult(syncResult);
1014+
1015+ const tool = yield* expandCatalogToolByPath({
1016+ catalogs: [makeLoadedCatalog({
1017+ source,
1018+ snapshot,
1019+ })],
1020+ path: "mcp.stdio.demo.echo_stdio",
1021+ });
1022+
1023+ if (!tool) {
1024+ throw new Error("Expected stdio MCP tool to resolve");
1025+ }
1026+
1027+ const result = yield* invokeIrTool({
1028+ workspaceId: source.workspaceId,
1029+ accountId: "acct_test" as any,
1030+ tool,
1031+ auth: {
1032+ placements: [],
1033+ headers: {},
1034+ queryParams: {},
1035+ cookies: {},
1036+ bodyValues: {},
1037+ expiresAt: null,
1038+ refreshAfter: null,
1039+ },
1040+ args: {
1041+ value: "ok",
1042+ },
1043+ context: {
1044+ runId: "exec_mcp_stdio",
1045+ },
1046+ });
1047+
1048+ expect(result).toEqual({
1049+ data: {
1050+ content: [{
1051+ type: "text",
1052+ text: "stdio:ok",
1053+ }],
1054+ },
1055+ error: null,
1056+ headers: {},
1057+ status: null,
1058+ });
1059+ }),
1060+ );
6201061 });
packages/platform/control-plane/src/runtime/sources/source-auth-service.ts+23−19
@@ -165,10 +165,10 @@ const createSyntheticMcpStdioEndpoint = (input: { · 164 unmodified lines
165165 command?: string | null;
166166 }): string => {
167167 const label =
168− trimOrNull(input.name)
169− ?? trimOrNull(input.endpoint)
170− ?? trimOrNull(input.command)
171− ?? "mcp";
168+ trimOrNull(input.name) ??
169+ trimOrNull(input.endpoint) ??
170+ trimOrNull(input.command) ??
171+ "mcp";
172172
173173 return `stdio://local/${slugifySourceLabel(label)}`;
174174 };
@@ -287,20 +287,15 @@ const probeMcpSourceWithoutAuth = ( · 112 unmodified lines
287287 }
288288 const bindingState = yield* sourceBindingStateFromSource(source);
289289
290− const connector = yield* Effect.try({
291− try: () =>
292− createSdkMcpConnector({
293− endpoint: source.endpoint,
294− transport: bindingState.transport ?? undefined,
295− queryParams: bindingState.queryParams ?? undefined,
296− headers: bindingState.headers ?? undefined,
297− command: bindingState.command ?? undefined,
298− args: bindingState.args ?? undefined,
299− env: bindingState.env ?? undefined,
300− cwd: bindingState.cwd ?? undefined,
301− }),
302− catch: (cause) =>
303− cause instanceof Error ? cause : new Error(String(cause)),
290+ const connector = createSdkMcpConnector({
291+ endpoint: source.endpoint,
292+ transport: bindingState.transport ?? undefined,
293+ queryParams: bindingState.queryParams ?? undefined,
294+ headers: bindingState.headers ?? undefined,
295+ command: bindingState.command ?? undefined,
296+ args: bindingState.args ?? undefined,
297+ env: bindingState.env ?? undefined,
298+ cwd: bindingState.cwd ?? undefined,
304299 });
305300
306301 return yield* discoverMcpToolsFromConnector({
@@ -1647,6 +1642,14 @@ const connectMcpSourceInternal = (input: { · 1340 unmodified lines
16471642 resolveSecretMaterial: ResolveSecretMaterial;
16481643 }): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>
16491644 Effect.gen(function* () {
1645+ const lookupEndpoint = input.sourceId
1646+ ? null
1647+ : normalizeMcpEndpoint({
1648+ endpoint: input.endpoint ?? null,
1649+ transport: input.transport ?? null,
1650+ command: input.command ?? null,
1651+ name: input.name ?? null,
1652+ });
16501653 const existing = yield* (
16511654 input.sourceId
16521655 ? input.sourceStore.loadSourceById({
@@ -1667,7 +1670,8 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines
16671670 sources.find(
16681671 (source) =>
16691672 sourceAdapterRequiresInteractiveConnect(source.kind)
1670− && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
1673+ && lookupEndpoint !== null
1674+ && normalizeEndpoint(source.endpoint) === lookupEndpoint,
16711675 ),
16721676 ),
16731677 )
packages/platform/control-plane/src/runtime/sources/source-definitions.test.ts+9−1
@@ -32,13 +32,21 @@ const graphqlBinding = (defaultHeaders: Record<string, string> | null = null) => · 31 unmodified lines
3232 });
3333
3434 const mcpBinding = (input: {
35− transport?: "auto" | "streamable-http" | "sse" | null;
35+ transport?: "auto" | "streamable-http" | "sse" | "stdio" | null;
3636 queryParams?: Record<string, string> | null;
3737 headers?: Record<string, string> | null;
38+ command?: string | null;
39+ args?: ReadonlyArray<string> | null;
40+ env?: Record<string, string> | null;
41+ cwd?: string | null;
3842 } = {}) => ({
3943 transport: input.transport ?? null,
4044 queryParams: input.queryParams ?? null,
4145 headers: input.headers ?? null,
46+ command: input.command ?? null,
47+ args: input.args ?? null,
48+ env: input.env ?? null,
49+ cwd: input.cwd ?? null,
4250 });
4351
4452 const makeSource = (overrides: Partial<Source> = {}): Source => ({
packages/sources/core/src/shared.ts+14−0
@@ -8,11 +8,13 @@ import { · 7 unmodified lines
88 SourceBindingVersionSchema,
99 SourceImportAuthPolicySchema,
1010 SourceOauthClientInputSchema,
11+ StringArraySchema,
1112 SourceTransportSchema,
1213 StringMapSchema,
1314 type CredentialSlot,
1415 type SecretRef,
1516 type SourceBinding,
17+ type StringArray,
1618 type SourceTransport,
1719 type StringMap,
1820 } from "./source-models";
@@ -72,6 +74,10 @@ export const McpConnectFieldsSchema = Schema.Struct({ · 53 unmodified lines
7274 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
7375 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
7476 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
77+ command: Schema.optional(Schema.NullOr(Schema.String)),
78+ args: Schema.optional(Schema.NullOr(StringArraySchema)),
79+ env: Schema.optional(Schema.NullOr(StringMapSchema)),
80+ cwd: Schema.optional(Schema.NullOr(Schema.String)),
7581 });
7682
7783 export const parseJsonValue = <T>(input: {
@@ -111,12 +117,20 @@ export const emptySourceBindingState = { · 33 unmodified lines
111117 transport: null,
112118 queryParams: null,
113119 headers: null,
120+ command: null,
121+ args: null,
122+ env: null,
123+ cwd: null,
114124 specUrl: null,
115125 defaultHeaders: null,
116126 } satisfies {
117127 transport: SourceTransport | null;
118128 queryParams: StringMap | null;
119129 headers: StringMap | null;
130+ command: string | null;
131+ args: StringArray | null;
132+ env: StringMap | null;
133+ cwd: string | null;
120134 specUrl: string | null;
121135 defaultHeaders: StringMap | null;
122136 };
packages/sources/core/src/source-models.ts+4−0
@@ -26,6 +26,7 @@ export const SourceTransportSchema = Schema.Literal( · 25 unmodified lines
2626 "auto",
2727 "streamable-http",
2828 "sse",
29+ "stdio",
2930 );
3031
3132 export const SourceImportAuthPolicySchema = Schema.Literal(
@@ -39,6 +40,8 @@ export const StringMapSchema = Schema.Record({ · 7 unmodified lines
3940 value: Schema.String,
4041 });
4142
43+export const StringArraySchema = Schema.Array(Schema.String);
44+
4245 export type JsonValue =
4346 | string
4447 | number
@@ -177,6 +180,7 @@ export type SourceStatus = typeof SourceStatusSchema.Type; · 132 unmodified lines
177180 export type SourceTransport = typeof SourceTransportSchema.Type;
178181 export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
179182 export type StringMap = typeof StringMapSchema.Type;
183+export type StringArray = typeof StringArraySchema.Type;
180184 export type SourceAuth = typeof SourceAuthSchema.Type;
181185 export type SourceBinding = typeof SourceBindingSchema.Type;
182186 export type Source = typeof SourceSchema.Type;
packages/sources/core/src/types.ts+5−0
@@ -17,6 +17,7 @@ import type { · 16 unmodified lines
1717 SourceCatalogKind,
1818 SourceImportAuthPolicy,
1919 SourceOauthClientInput,
20+ StringArray,
2021 SourceTransport,
2122 StoredSourceRecord,
2223 StringMap,
@@ -77,6 +78,10 @@ export type SourceBindingState = { · 54 unmodified lines
7778 transport: SourceTransport | null;
7879 queryParams: StringMap | null;
7980 headers: StringMap | null;
81+ command: string | null;
82+ args: StringArray | null;
83+ env: StringMap | null;
84+ cwd: string | null;
8085 specUrl: string | null;
8186 defaultHeaders: StringMap | null;
8287 };
packages/sources/mcp/src/adapter.ts+64−48
@@ -71,6 +71,14 @@ const namespaceFromSourceName = (name: string): string => { · 70 unmodified lines
7171 return normalized.length > 0 ? normalized : "source";
7272 };
7373
74+const mcpAdapterError = (message: string, cause?: unknown): Error =>
75+ sourceCoreEffectError(
76+ "mcp/adapter",
77+ cause === undefined
78+ ? message
79+ : `${message}: ${cause instanceof Error ? cause.message : String(cause)}`,
80+ );
81+
7482 const OptionalNullableStringArraySchema = Schema.optional(
7583 Schema.NullOr(StringArraySchema),
7684 );
@@ -208,9 +216,13 @@ const normalizeMcpBindingConfig = ( · 131 unmodified lines
208216 cwd: trimOrNull(bindingConfig.cwd),
209217 } satisfies McpBindingConfig;
210218 }
211−
212− if (command !== null || bindingConfig.args || bindingConfig.env || trimOrNull(bindingConfig.cwd) !== null) {
213− return yield* sourceCoreEffectError("mcp/adapter", "MCP process settings require transport: \"stdio\"");
219+ if (
220+ command !== null ||
221+ bindingConfig.args ||
222+ bindingConfig.env ||
223+ trimOrNull(bindingConfig.cwd) !== null
224+ ) {
225+ return yield* sourceCoreEffectError("mcp/adapter", 'MCP process settings require transport: "stdio"');
214226 }
215227
216228 return {
@@ -400,28 +412,23 @@ export const mcpSourceAdapter: SourceAdapter = { · 183 unmodified lines
400412 Effect.gen(function* () {
401413 const bindingConfig = yield* mcpBindingConfigFromSource(source);
402414 const auth = yield* resolveAuthMaterialForSlot("import");
403− const connector = yield* Effect.try({
404− try: () =>
405− createSdkMcpConnector({
406− endpoint: source.endpoint,
407− transport: bindingConfig.transport ?? undefined,
408− queryParams: {
409− ...bindingConfig.queryParams,
410− ...auth.queryParams,
411− },
412− headers: headersWithAuthCookies({
413− headers: bindingConfig.headers ?? {},
414− authHeaders: auth.headers,
415− authCookies: auth.cookies,
416− }),
417− authProvider: auth.authProvider,
418− command: bindingConfig.command ?? undefined,
419− args: bindingConfig.args ?? undefined,
420− env: bindingConfig.env ?? undefined,
421− cwd: bindingConfig.cwd ?? undefined,
422− }),
423− catch: (cause) =>
424− cause instanceof Error ? cause : new Error(String(cause)),
415+ const connector = createSdkMcpConnector({
416+ endpoint: source.endpoint,
417+ transport: bindingConfig.transport ?? undefined,
418+ queryParams: {
419+ ...bindingConfig.queryParams,
420+ ...auth.queryParams,
421+ },
422+ headers: headersWithAuthCookies({
423+ headers: bindingConfig.headers ?? {},
424+ authHeaders: auth.headers,
425+ authCookies: auth.cookies,
426+ }),
427+ authProvider: auth.authProvider,
428+ command: bindingConfig.command ?? undefined,
429+ args: bindingConfig.args ?? undefined,
430+ env: bindingConfig.env ?? undefined,
431+ cwd: bindingConfig.cwd ?? undefined,
425432 });
426433
427434 const discovered = yield* discoverMcpToolsFromConnector({
@@ -444,25 +451,28 @@ export const mcpSourceAdapter: SourceAdapter = { · 16 unmodified lines
444451 });
445452 }),
446453 invoke: (input) =>
447− Effect.tryPromise({
448− try: async () => {
454+ Effect.gen(function* () {
449455 if (input.executable.adapterKey !== "mcp") {
450− throw new Error(
456+ return yield* sourceCoreEffectError(
457+ "mcp/adapter",
451458 `Expected MCP executable binding, got ${input.executable.adapterKey}`,
452459 );
453460 }
454− const providerData = decodeExecutableBindingPayload({
455− executableId: input.executable.id,
456− label: "MCP",
457− version: input.executable.bindingVersion,
458− expectedVersion: EXECUTABLE_BINDING_VERSION,
459− schema: McpExecutableBindingSchema,
460− value: input.executable.binding,
461− }) as McpExecutableBinding;
462−
463− const bindingConfig = Effect.runSync(
464− mcpBindingConfigFromSource(input.source),
465− );
461+ const providerData = (yield* Effect.try({
462+ try: () =>
463+ decodeExecutableBindingPayload({
464+ executableId: input.executable.id,
465+ label: "MCP",
466+ version: input.executable.bindingVersion,
467+ expectedVersion: EXECUTABLE_BINDING_VERSION,
468+ schema: McpExecutableBindingSchema,
469+ value: input.executable.binding,
470+ }) as McpExecutableBinding,
471+ catch: (cause) =>
472+ mcpAdapterError("Failed decoding MCP executable binding", cause),
473+ })) as McpExecutableBinding;
474+
475+ const bindingConfig = yield* mcpBindingConfigFromSource(input.source);
466476 const connector = createPooledMcpConnector({
467477 connect: createSdkMcpConnector({
468478 endpoint: input.source.endpoint,
@@ -531,7 +541,8 @@ export const mcpSourceAdapter: SourceAdapter = { · 62 unmodified lines
531541 : entry;
532542
533543 if (!definition) {
534− throw new Error(
544+ return yield* sourceCoreEffectError(
545+ "mcp/adapter",
535546 `Missing MCP tool definition for ${providerData.toolName}`,
536547 );
537548 }
@@ -566,10 +577,18 @@ export const mcpSourceAdapter: SourceAdapter = { · 28 unmodified lines
566577 onElicitation: input.onElicitation,
567578 }
568579 : undefined;
569− const result = await definition.execute(
570− asRecord(payload),
571− executionContext,
572− );
580+ const result = yield* Effect.tryPromise({
581+ try: async () =>
582+ await definition.execute(
583+ asRecord(payload),
584+ executionContext,
585+ ),
586+ catch: (cause) =>
587+ mcpAdapterError(
588+ `Failed invoking MCP tool ${providerData.toolName}`,
589+ cause,
590+ ),
591+ });
573592 const resultRecord = asRecord(result);
574593 const isError = resultRecord.isError === true;
575594
@@ -579,8 +598,5 @@ export const mcpSourceAdapter: SourceAdapter = { · 3 unmodified lines
579598 headers: {},
580599 status: null,
581600 };
582− },
583− catch: (cause) =>
584− cause instanceof Error ? cause : new Error(String(cause)),
585601 }),
586602 };
packages/sources/mcp/src/connection-pool.ts+151−0
@@ -0,0 +1,151 @@
1+import * as Data from "effect/Data";
2+import * as Effect from "effect/Effect";
3+import * as Exit from "effect/Exit";
4+import * as Scope from "effect/Scope";
5+
6+import type { McpConnection, McpConnector } from "./tools";
7+
8+export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{
9+ readonly operation: "connect" | "close";
10+ readonly message: string;
11+ readonly cause: unknown;
12+}> {}
13+
14+type PoolEntry = {
15+ scope: Scope.CloseableScope;
16+ connection: McpConnector;
17+};
18+
19+const pooledRuns = new Map<string, Map<string, PoolEntry>>();
20+
21+const mcpConnectionPoolError = (input: {
22+ operation: "connect" | "close";
23+ message: string;
24+ cause: unknown;
25+}): McpConnectionPoolError => new McpConnectionPoolError(input);
26+
27+const deletePoolEntry = (runId: string, sourceKey: string, entry: PoolEntry) => {
28+ const runEntries = pooledRuns.get(runId);
29+ if (!runEntries || runEntries.get(sourceKey) !== entry) {
30+ return;
31+ }
32+
33+ runEntries.delete(sourceKey);
34+ if (runEntries.size === 0) {
35+ pooledRuns.delete(runId);
36+ }
37+};
38+
39+const closePooledConnection = (
40+ connection: McpConnection,
41+): Effect.Effect<void, never, never> =>
42+ Effect.tryPromise({
43+ try: () => Promise.resolve(connection.close?.()),
44+ catch: (cause) =>
45+ mcpConnectionPoolError({
46+ operation: "close",
47+ message: "Failed closing pooled MCP connection",
48+ cause,
49+ }),
50+ }).pipe(Effect.ignore);
51+
52+const createPoolEntry = (connect: McpConnector): PoolEntry => {
53+ const scope = Effect.runSync(Scope.make());
54+ const connection = Effect.runSync(
55+ Effect.cached(
56+ Effect.acquireRelease(
57+ connect.pipe(
58+ Effect.mapError((cause) =>
59+ mcpConnectionPoolError({
60+ operation: "connect",
61+ message: "Failed creating pooled MCP connection",
62+ cause,
63+ })),
64+ ),
65+ closePooledConnection,
66+ ).pipe(Scope.extend(scope)),
67+ ),
68+ );
69+
70+ return {
71+ scope,
72+ connection,
73+ };
74+};
75+
76+const getOrCreatePoolEntry = (input: {
77+ runId: string;
78+ sourceKey: string;
79+ connect: McpConnector;
80+}): PoolEntry => {
81+ const existing = pooledRuns.get(input.runId)?.get(input.sourceKey);
82+ if (existing) {
83+ return existing;
84+ }
85+
86+ let runEntries = pooledRuns.get(input.runId);
87+ if (!runEntries) {
88+ runEntries = new Map<string, PoolEntry>();
89+ pooledRuns.set(input.runId, runEntries);
90+ }
91+
92+ const entry = createPoolEntry(input.connect);
93+ entry.connection = entry.connection.pipe(
94+ Effect.tapError(() =>
95+ Effect.sync(() => {
96+ deletePoolEntry(input.runId, input.sourceKey, entry);
97+ }).pipe(
98+ Effect.zipRight(closePoolEntry(entry)),
99+ )),
100+ );
101+ runEntries.set(input.sourceKey, entry);
102+ return entry;
103+};
104+
105+const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> =>
106+ Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
107+
108+export const createPooledMcpConnector = (input: {
109+ connect: McpConnector;
110+ runId?: string;
111+ sourceKey?: string;
112+}): McpConnector => {
113+ if (!input.runId || !input.sourceKey) {
114+ return input.connect;
115+ }
116+
117+ return Effect.gen(function* () {
118+ const entry = getOrCreatePoolEntry({
119+ runId: input.runId!,
120+ sourceKey: input.sourceKey!,
121+ connect: input.connect,
122+ });
123+ const connection = yield* entry.connection;
124+ return {
125+ client: connection.client,
126+ close: async () => undefined,
127+ };
128+ });
129+};
130+
131+export const clearMcpConnectionPoolRun = (
132+ runId: string,
133+): Effect.Effect<void, never, never> => {
134+ const runEntries = pooledRuns.get(runId);
135+ if (!runEntries) {
136+ return Effect.void;
137+ }
138+
139+ pooledRuns.delete(runId);
140+ return Effect.forEach([...runEntries.values()], closePoolEntry, {
141+ discard: true,
142+ });
143+};
144+
145+export const clearAllMcpConnectionPools = (): Effect.Effect<void, never, never> => {
146+ const runEntries = [...pooledRuns.values()].flatMap((entries) => [...entries.values()]);
147+ pooledRuns.clear();
148+ return Effect.forEach(runEntries, closePoolEntry, {
149+ discard: true,
150+ });
151+};
packages/sources/mcp/src/connection.ts+178−54
@@ -2,33 +2,71 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 1 unmodified line
22 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
33 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
44 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5+import * as Data from "effect/Data";
6+import * as Effect from "effect/Effect";
57
68 import type { McpConnection, McpConnector } from "./tools";
79
8−export type McpTransportPreference = "auto" | "streamable-http" | "sse";
10+export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio";
911
1012 export type CreateSdkMcpConnectorInput = {
11− endpoint: string;
13+ endpoint?: string;
1214 transport?: McpTransportPreference;
1315 queryParams?: Record<string, string>;
1416 headers?: Record<string, string>;
1517 authProvider?: OAuthClientProvider;
1618 clientName?: string;
1719 clientVersion?: string;
20+ command?: string;
21+ args?: ReadonlyArray<string>;
22+ env?: Record<string, string>;
23+ cwd?: string;
1824 };
1925
20−const createEndpoint = (
21− endpoint: string,
22− queryParams: Record<string, string>,
23−): URL => {
24− const url = new URL(endpoint);
25−
26− for (const [key, value] of Object.entries(queryParams)) {
27− url.searchParams.set(key, value);
28− }
29−
30− return url;
31−};
26+export class McpConnectionError extends Data.TaggedError("McpConnectionError")<{
27+ readonly transport: McpTransportPreference;
28+ readonly message: string;
29+ readonly cause: unknown;
30+}> {}
31+
32+export const isMcpStdioTransport = (
33+ input: Pick<CreateSdkMcpConnectorInput, "transport" | "command">,
34+): boolean =>
35+ input.transport === "stdio"
36+ || (typeof input.command === "string" && input.command.trim().length > 0);
37+
38+const mcpConnectionError = (input: {
39+ transport: McpTransportPreference;
40+ message: string;
41+ cause: unknown;
42+}): McpConnectionError => new McpConnectionError(input);
43+
44+const createEndpoint = (input: {
45+ endpoint: string | undefined;
46+ queryParams: Record<string, string>;
47+ transport: Exclude<McpTransportPreference, "stdio">;
48+}): Effect.Effect<URL, McpConnectionError, never> =>
49+ Effect.try({
50+ try: () => {
51+ if (!input.endpoint) {
52+ throw new Error("MCP endpoint is required for HTTP/SSE transports");
53+ }
54+
55+ const url = new URL(input.endpoint);
56+
57+ for (const [key, value] of Object.entries(input.queryParams)) {
58+ url.searchParams.set(key, value);
59+ }
60+
61+ return url;
62+ },
63+ catch: (cause) =>
64+ mcpConnectionError({
65+ transport: input.transport,
66+ message: "Failed building MCP endpoint URL",
67+ cause,
68+ }),
69+ });
3270
3371 type FetchInput = Parameters<typeof fetch>[0];
3472 type FetchInit = Parameters<typeof fetch>[1];
@@ -55,12 +93,72 @@ const connectionFromClient = (client: Client): McpConnection => ({ · 20 unmodified lines
5593 close: () => client.close(),
5694 });
5795
96+const closeClient = (client: Client): Effect.Effect<void, never, never> =>
97+ Effect.tryPromise({
98+ try: () => client.close(),
99+ catch: (cause) =>
100+ mcpConnectionError({
101+ transport: "auto",
102+ message: "Failed closing MCP client",
103+ cause,
104+ }),
105+ }).pipe(Effect.ignore);
106+
107+type DynamicImport = <TModule>(specifier: string) => Promise<TModule>;
108+
109+const dynamicImport = new Function(
110+ "specifier",
111+ "return import(specifier)",
112+) as DynamicImport;
113+
114+const loadStdioClientTransport = (): Effect.Effect<
115+ typeof import("@modelcontextprotocol/sdk/client/stdio.js").StdioClientTransport,
116+ McpConnectionError,
117+ never
118+> =>
119+ Effect.tryPromise({
120+ try: async () =>
121+ (await dynamicImport<typeof import("@modelcontextprotocol/sdk/client/stdio.js")>(
122+ "@modelcontextprotocol/sdk/client/stdio.js",
123+ )).StdioClientTransport,
124+ catch: (cause) =>
125+ mcpConnectionError({
126+ transport: "stdio",
127+ message: "Failed loading MCP stdio transport",
128+ cause,
129+ }),
130+ });
131+
132+const connectClient = (input: {
133+ createClient: () => Client;
134+ transport: McpTransportPreference;
135+ createTransport: () => Parameters<Client["connect"]>[0];
136+}): Effect.Effect<McpConnection, McpConnectionError, never> =>
137+ Effect.gen(function* () {
138+ const client = input.createClient();
139+ const transportInstance = input.createTransport();
140+
141+ return yield* Effect.tryPromise({
142+ try: () => client.connect(transportInstance),
143+ catch: (cause) =>
144+ mcpConnectionError({
145+ transport: input.transport,
146+ message: `Failed connecting to MCP server via ${input.transport}`,
147+ cause,
148+ }),
149+ }).pipe(
150+ Effect.as(connectionFromClient(client)),
151+ Effect.onError(() => closeClient(client)),
152+ );
153+ });
154+
58155 export const createSdkMcpConnector = (
59156 input: CreateSdkMcpConnectorInput,
60157 ): McpConnector => {
61− const endpoint = createEndpoint(input.endpoint, input.queryParams ?? {});
62158 const headers = input.headers ?? {};
63− const transport = input.transport ?? "auto";
159+ const transport = isMcpStdioTransport(input)
160+ ? "stdio"
161+ : (input.transport ?? "auto");
64162 const requestInit = Object.keys(headers).length > 0
65163 ? { headers }
66164 : undefined;
@@ -74,49 +172,75 @@ export const createSdkMcpConnector = ( · 7 unmodified lines
74172 { capabilities: { elicitation: { form: {}, url: {} } } },
75173 );
76174
77− return async () => {
78− const client = createClient();
175+ return Effect.gen(function* () {
176+ if (transport === "stdio") {
177+ const command = input.command?.trim();
178+ if (!command) {
179+ return yield* Effect.fail(
180+ mcpConnectionError({
181+ transport: "stdio",
182+ message: "MCP stdio transport requires a command",
183+ cause: new Error("Missing MCP stdio command"),
184+ }),
185+ );
186+ }
187+
188+ const StdioClientTransport = yield* loadStdioClientTransport();
189+ return yield* connectClient({
190+ createClient,
191+ transport: "stdio",
192+ createTransport: () =>
193+ new StdioClientTransport({
194+ command,
195+ args: input.args ? [...input.args] : undefined,
196+ env: input.env,
197+ cwd: input.cwd?.trim().length ? input.cwd.trim() : undefined,
198+ }),
199+ });
200+ }
201+
202+ const endpoint = yield* createEndpoint({
203+ endpoint: input.endpoint,
204+ queryParams: input.queryParams ?? {},
205+ transport,
206+ });
207+
208+ const connectStreamableHttp = connectClient({
209+ createClient,
210+ transport: "streamable-http",
211+ createTransport: () =>
212+ new StreamableHTTPClientTransport(endpoint, {
213+ requestInit,
214+ authProvider: input.authProvider,
215+ }),
216+ });
79217
80218 if (transport === "streamable-http") {
81− await client.connect(new StreamableHTTPClientTransport(endpoint, {
82− requestInit,
83− authProvider: input.authProvider,
84− }));
85− return connectionFromClient(client);
219+ return yield* connectStreamableHttp;
86220 }
87221
222+ const connectSse = connectClient({
223+ createClient,
224+ transport: "sse",
225+ createTransport: () =>
226+ new SSEClientTransport(endpoint, {
227+ authProvider: input.authProvider,
228+ requestInit,
229+ eventSourceInit: requestInit
230+ ? {
231+ fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
232+ mergeHeadersForFetch(requestInput, requestOptions, headers),
233+ }
234+ : undefined,
235+ }),
236+ });
237+
88238 if (transport === "sse") {
89− await client.connect(new SSEClientTransport(endpoint, {
90− authProvider: input.authProvider,
91− requestInit,
92− eventSourceInit: requestInit
93− ? {
94− fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
95− mergeHeadersForFetch(requestInput, requestOptions, headers),
96− }
97− : undefined,
98− }));
99− return connectionFromClient(client);
239+ return yield* connectSse;
100240 }
101241
102− try {
103− await client.connect(new StreamableHTTPClientTransport(endpoint, {
104− requestInit,
105− authProvider: input.authProvider,
106− }));
107− return connectionFromClient(client);
108− } catch {
109− await client.connect(new SSEClientTransport(endpoint, {
110− authProvider: input.authProvider,
111− requestInit,
112− eventSourceInit: requestInit
113− ? {
114− fetch: (requestInput: FetchInput, requestOptions: FetchInit) =>
115− mergeHeadersForFetch(requestInput, requestOptions, headers),
116− }
117− : undefined,
118− }));
119− return connectionFromClient(client);
120− }
121− };
242+ return yield* connectStreamableHttp.pipe(
243+ Effect.catchAll(() => connectSse),
244+ );
245+ });
122246 };
packages/sources/mcp/src/index.ts+8−0
@@ -1,7 +1,15 @@
1+export {
2+ clearAllMcpConnectionPools,
3+ clearMcpConnectionPoolRun,
4+ createPooledMcpConnector,
5+ McpConnectionPoolError,
6+} from "./connection-pool";
17 export {
28 createSdkMcpConnector,
39 type CreateSdkMcpConnectorInput,
10+ McpConnectionError,
411 type McpTransportPreference,
12+ isMcpStdioTransport,
513 } from "./connection";
614 export { detectMcpSource } from "./discovery";
715 export { McpLocalConfigBindingSchema } from "./local-config";
packages/sources/mcp/src/local-config.ts+5−0
@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line
22
33 import {
44 SourceTransportSchema,
5+ StringArraySchema,
56 StringMapSchema,
67 } from "@executor/source-core";
78
@@ -9,4 +10,8 @@ export const McpLocalConfigBindingSchema = Schema.Struct({ · 1 unmodified line
910 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
1011 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
1112 headers: Schema.optional(Schema.NullOr(StringMapSchema)),
13+ command: Schema.optional(Schema.NullOr(Schema.String)),
14+ args: Schema.optional(Schema.NullOr(StringArraySchema)),
15+ env: Schema.optional(Schema.NullOr(StringMapSchema)),
16+ cwd: Schema.optional(Schema.NullOr(Schema.String)),
1217 });
packages/sources/mcp/src/tools.test.ts+4−4
@@ -306,7 +306,7 @@ describe("codemode-mcp", () => { · 305 unmodified lines
306306 let closeCalls = 0;
307307 const callInputs: Array<{ name: string; arguments: Record<string, unknown> }> = [];
308308
309− const connect = async () => {
309+ const connect = Effect.sync(() => {
310310 connectCalls += 1;
311311
312312 return {
@@ -360,7 +360,7 @@ describe("codemode-mcp", () => { · 47 unmodified lines
360360 closeCalls += 1;
361361 },
362362 };
363− };
363+ });
364364
365365 const discovered = yield* discoverMcpToolsFromConnector({
366366 connect,
@@ -432,7 +432,7 @@ describe("codemode-mcp", () => { · 65 unmodified lines
432432 Effect.gen(function* () {
433433 const outcome = yield* Effect.either(
434434 discoverMcpToolsFromConnector({
435− connect: async () => ({
435+ connect: Effect.succeed({
436436 client: {
437437 listTools: async () => {
438438 throw new Error("nope");
@@ -456,7 +456,7 @@ describe("codemode-mcp", () => { · 17 unmodified lines
456456 const elicitationMessages: Array<string> = [];
457457
458458 const discovered = yield* discoverMcpToolsFromConnector({
459− connect: async () => ({
459+ connect: Effect.succeed({
460460 client: {
461461 listTools: async () => {
462462 attempts += 1;
packages/sources/mcp/src/tools.ts+3−6
@@ -53,7 +53,7 @@ export type McpConnection = { · 52 unmodified lines
5353 close?: () => Promise<void>;
5454 };
5555
56−export type McpConnector = () => Promise<McpConnection>;
56+export type McpConnector = Effect.Effect<McpConnection, unknown, never>;
5757
5858 export type McpDiscoveryElicitationContext = {
5959 onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
@@ -127,10 +127,7 @@ const withConnectionEffect = <A, E>(input: { · 67 unmodified lines
127127 run: (connection: McpConnection) => Effect.Effect<A, E>;
128128 }): Effect.Effect<A, E | McpToolsError> =>
129129 Effect.acquireUseRelease(
130− Effect.tryPromise({
131− try: input.connect,
132− catch: input.onConnectError,
133− }),
130+ input.connect.pipe(Effect.mapError(input.onConnectError)),
134131 input.run,
135132 closeConnection,
136133 );
@@ -447,7 +444,7 @@ const runMcpToolCallEffect = (input: { · 310 unmodified lines
447444 export const createMcpConnectorFromClient = (
448445 client: McpClientLike,
449446 ): McpConnector =>
450− async () => ({
447+ Effect.succeed({
451448 client,
452449 close: async () => undefined,
453450 });