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
36 IconSpinner,36 IconSpinner,
37} from "../components/icons";37} from "../components/icons";
38import { cn } from "../lib/utils";38import { cn } from "../lib/utils";
39import { sourceTemplates, type SourceTemplate } from "./source-templates";39import {
40 isStdioMcpSourceTemplate,
41 sourceTemplates,
42 type SourceTemplate,
43} from "./source-templates";
40import { getDomain } from "tldts";44import { getDomain } from "tldts";
41 45
42// ---------------------------------------------------------------------------46// ---------------------------------------------------------------------------
@@ -73,7 +77,7 @@ type ConnectFormState = { · 30 unmodified lines
73 discoveryUrl: string;77 discoveryUrl: string;
74 name: string;78 name: string;
75 namespace: string;79 namespace: string;
76 transport: "" | "auto" | "streamable-http" | "sse";80 transport: "" | "auto" | "streamable-http" | "sse" | "stdio";
77 authKind: "none" | "bearer" | "oauth2";81 authKind: "none" | "bearer" | "oauth2";
78 authHeaderName: string;82 authHeaderName: string;
79 authPrefix: string;83 authPrefix: string;
@@ -85,6 +89,10 @@ type ConnectFormState = { · 5 unmodified lines
85 oauthClientSecret: string;89 oauthClientSecret: string;
86 queryParamsText: string;90 queryParamsText: string;
87 headersText: string;91 headersText: string;
92 command: string;
93 argsText: string;
94 envText: string;
95 cwd: string;
88};96};
89 97
90type OAuthRequiredInfo = {98type OAuthRequiredInfo = {
@@ -110,15 +118,22 @@ const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [ · 19 unmodified lines
110 "google_discovery",118 "google_discovery",
111];119];
112 120
113const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [121const transportOptions: ReadonlyArray<
114 "auto",122 "auto" | "streamable-http" | "sse" | "stdio"
115 "streamable-http",123> = ["auto", "streamable-http", "sse", "stdio"];
116 "sse",
117];
118 124
119const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = ["none", "bearer", "oauth2"];125const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [
126 "none",
127 "bearer",
128 "oauth2",
129];
120 130
121const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = ["none", "bearer", "basic", "headers"];131const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [
132 "none",
133 "bearer",
134 "basic",
135 "headers",
136];
122 137
123const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;138const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000;
124const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";139const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
@@ -145,16 +160,24 @@ const namespaceFromUrl = (url: string): string => { · 20 unmodified lines
145};160};
146 161
147const googleDiscoveryNamespace = (service: string): string =>162const googleDiscoveryNamespace = (service: string): string =>
148 `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;163 `google.${service
149 164 .trim()
150const googleDiscoveryDefaultsFromUrl = (value: string): {165 .toLowerCase()
166 .replace(/[^a-z0-9]+/g, ".")
167 .replace(/^\.+|\.+$/g, "")}`;
168
169const googleDiscoveryDefaultsFromUrl = (
170 value: string,
171): {
151 service: string;172 service: string;
152 version: string;173 version: string;
153 discoveryUrl: string;174 discoveryUrl: string;
154} | null => {175} | null => {
155 try {176 try {
156 const url = new URL(value);177 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 );
158 if (byDirectory) {181 if (byDirectory) {
159 return {182 return {
160 service: decodeURIComponent(byDirectory[1] ?? ""),183 service: decodeURIComponent(byDirectory[1] ?? ""),
@@ -165,9 +188,10 @@ const googleDiscoveryDefaultsFromUrl = (value: string): { · 4 unmodified lines
165 188
166 const versionParam = url.searchParams.get("version");189 const versionParam = url.searchParams.get("version");
167 const version = versionParam ? trimToNull(versionParam) : null;190 const version = versionParam ? trimToNull(versionParam) : null;
168 const isHostScopedDiscovery = url.pathname === "/$discovery/rest"191 const isHostScopedDiscovery =
169 && url.hostname.endsWith(".googleapis.com")192 url.pathname === "/$discovery/rest" &&
170 && url.hostname !== "www.googleapis.com";193 url.hostname.endsWith(".googleapis.com") &&
194 url.hostname !== "www.googleapis.com";
171 if (version && isHostScopedDiscovery) {195 if (version && isHostScopedDiscovery) {
172 return {196 return {
173 service: url.hostname.split(".")[0] ?? "",197 service: url.hostname.split(".")[0] ?? "",
@@ -182,7 +206,10 @@ const googleDiscoveryDefaultsFromUrl = (value: string): { · 8 unmodified lines
182 }206 }
183};207};
184 208
185const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {209const parseJsonStringMap = (
210 label: string,
211 text: string,
212): Record<string, string> | null => {
186 const trimmed = text.trim();213 const trimmed = text.trim();
187 if (trimmed.length === 0) {214 if (trimmed.length === 0) {
188 return null;215 return null;
@@ -211,11 +238,73 @@ const parseJsonStringMap = (label: string, text: string): Record<string, string> · 22 unmodified lines
211 return Object.keys(normalized).length === 0 ? null : normalized;238 return Object.keys(normalized).length === 0 ? null : normalized;
212};239};
213 240
241const 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
271const 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
278const stringifyStringArray = (
279 value: ReadonlyArray<string> | null | undefined,
280): string =>
281 !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
282
283const 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
214// ---------------------------------------------------------------------------301// ---------------------------------------------------------------------------
215// Derived defaults from discovery result302// Derived defaults from discovery result
216// ---------------------------------------------------------------------------303// ---------------------------------------------------------------------------
217 304
218const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState => {305const defaultConnectForm = (
306 discovery?: SourceDiscoveryResult,
307): ConnectFormState => {
219 if (!discovery || discovery.detectedKind === "unknown") {308 if (!discovery || discovery.detectedKind === "unknown") {
220 return {309 return {
221 kind: "openapi",310 kind: "openapi",
@@ -225,7 +314,8 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 3 unmodified lines
225 version: "",314 version: "",
226 discoveryUrl: "",315 discoveryUrl: "",
227 name: discovery?.name ?? "",316 name: discovery?.name ?? "",
228 namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),317 namespace:
318 discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
229 transport: "",319 transport: "",
230 authKind: "none",320 authKind: "none",
231 authHeaderName: "Authorization",321 authHeaderName: "Authorization",
@@ -238,6 +328,10 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 6 unmodified lines
238 oauthClientSecret: "",328 oauthClientSecret: "",
239 queryParamsText: "",329 queryParamsText: "",
240 headersText: "",330 headersText: "",
331 command: "",
332 argsText: "",
333 envText: "",
334 cwd: "",
241 };335 };
242 }336 }
243 337
@@ -266,9 +360,10 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 22 unmodified lines
266 }360 }
267 }361 }
268 362
269 const googleDiscoveryDefaults = kind === "google_discovery"363 const googleDiscoveryDefaults =
270 ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)364 kind === "google_discovery"
271 : null;365 ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
366 : null;
272 367
273 return {368 return {
274 kind,369 kind,
@@ -291,24 +386,41 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 16 unmodified lines
291 oauthClientSecret: "",386 oauthClientSecret: "",
292 queryParamsText: "",387 queryParamsText: "",
293 headersText: "",388 headersText: "",
389 command: "",
390 argsText: "",
391 envText: "",
392 cwd: "",
294 };393 };
295};394};
296 395
297const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({396const connectFormFromTemplate = (
397 template: SourceTemplate,
398): ConnectFormState => ({
298 ...defaultConnectForm(),399 ...defaultConnectForm(),
299 kind: template.kind as ConnectFormState["kind"],400 kind: template.kind as ConnectFormState["kind"],
300 endpoint: template.endpoint,401 endpoint: template.endpoint ?? "",
301 specUrl: "specUrl" in template ? template.specUrl : "",402 specUrl: "specUrl" in template ? template.specUrl : "",
302 service: "service" in template ? template.service : "",403 service: "service" in template ? template.service : "",
303 version: "version" in template ? template.version : "",404 version: "version" in template ? template.version : "",
304 discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "",405 discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "",
305 name: template.name,406 name: template.name,
306 namespace: "service" in template407 namespace:
307 ? googleDiscoveryNamespace(template.service)408 template.namespace ??
308 : namespaceFromUrl(template.endpoint),409 ("service" in template
309 transport: template.kind === "mcp" ? "auto" : "",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 : "",
310 authKind: template.kind === "google_discovery" ? "oauth2" : "none",418 authKind: template.kind === "google_discovery" ? "oauth2" : "none",
311 workspaceOauthClientId: "",419 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 ?? "") : "",
312});424});
313 425
314const authKindForSourceKind = (426const authKindForSourceKind = (
@@ -319,10 +431,13 @@ const authKindForSourceKind = ( · 4 unmodified lines
319 ? "oauth2"431 ? "oauth2"
320 : currentAuthKind;432 : currentAuthKind;
321 433
322const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => {434const buildProbeAuth = (
435 state: ProbeAuthState,
436): DiscoverSourcePayload["probeAuth"] => {
323 if (state.kind === "none") return { kind: "none" };437 if (state.kind === "none") return { kind: "none" };
324 if (state.kind === "bearer") {438 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.");
326 return {441 return {
327 kind: "bearer",442 kind: "bearer",
328 headerName: trimToNull(state.headerName),443 headerName: trimToNull(state.headerName),
@@ -331,7 +446,8 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 2 unmodified lines
331 };446 };
332 }447 }
333 if (state.kind === "basic") {448 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.");
335 return {451 return {
336 kind: "basic",452 kind: "basic",
337 username: state.username.trim(),453 username: state.username.trim(),
@@ -340,22 +456,41 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 2 unmodified lines
340 }456 }
341 // headers457 // headers
342 const headers = parseJsonStringMap("Probe headers", state.headersText);458 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.");
344 return { kind: "headers", headers };461 return { kind: "headers", headers };
345};462};
346 463
347const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {464const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {
348 if (form.kind === "mcp") {465 if (form.kind === "mcp") {
349 const endpoint = form.endpoint.trim();466 const transport = form.transport === "" ? "auto" : form.transport;
350 if (!endpoint) throw new Error("Endpoint is required.");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.");
351 return {478 return {
352 kind: "mcp",479 kind: "mcp",
353 endpoint,480 endpoint,
354 name: trimToNull(form.name),481 name: trimToNull(form.name),
355 namespace: trimToNull(form.namespace),482 namespace: trimToNull(form.namespace),
356 transport: form.transport === "" ? "auto" : form.transport,483 transport,
357 queryParams: parseJsonStringMap("Query params", form.queryParamsText),484 queryParams: isStdio
358 headers: parseJsonStringMap("Request headers", form.headersText),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,
359 };494 };
360 }495 }
361 496
@@ -380,14 +515,18 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 18 unmodified lines
380 if (form.kind === "google_discovery") {515 if (form.kind === "google_discovery") {
381 const service = form.service.trim();516 const service = form.service.trim();
382 const version = form.version.trim();517 const version = form.version.trim();
383 if (!service) throw new Error("Google Discovery sources require a service name.");518 if (!service)
384 if (!version) throw new Error("Google Discovery sources require a version.");519 throw new Error("Google Discovery sources require a service name.");
520 if (!version)
521 throw new Error("Google Discovery sources require a version.");
385 if (522 if (
386 form.authKind === "oauth2"523 form.authKind === "oauth2" &&
387 && form.workspaceOauthClientId.trim().length === 0524 form.workspaceOauthClientId.trim().length === 0 &&
388 && form.oauthClientId.trim().length === 0525 form.oauthClientId.trim().length === 0
389 ) {526 ) {
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 );
391 }530 }
392 return {531 return {
393 kind: "google_discovery",532 kind: "google_discovery",
@@ -397,11 +536,13 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 3 unmodified lines
397 name: trimToNull(form.name),536 name: trimToNull(form.name),
398 namespace: trimToNull(form.namespace),537 namespace: trimToNull(form.namespace),
399 workspaceOauthClientId:538 workspaceOauthClientId:
400 form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length > 0539 form.authKind === "oauth2" &&
540 form.workspaceOauthClientId.trim().length > 0
401 ? form.workspaceOauthClientId.trim()541 ? form.workspaceOauthClientId.trim()
402 : undefined,542 : undefined,
403 oauthClient:543 oauthClient:
404 form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length === 0544 form.authKind === "oauth2" &&
545 form.workspaceOauthClientId.trim().length === 0
405 ? {546 ? {
406 clientId: form.oauthClientId.trim(),547 clientId: form.oauthClientId.trim(),
407 clientSecret: trimToNull(form.oauthClientSecret),548 clientSecret: trimToNull(form.oauthClientSecret),
@@ -424,7 +565,16 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 16 unmodified lines
424 565
425const buildHttpAuth = (566const buildHttpAuth = (
426 form: ConnectFormState,567 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 => {
428 if (form.authKind === "none") return { kind: "none" };578 if (form.authKind === "none") return { kind: "none" };
429 579
430 if (form.authKind === "bearer") {580 if (form.authKind === "bearer") {
@@ -454,7 +604,9 @@ const buildHttpAuth = ( · 23 unmodified lines
454 };604 };
455 }605 }
456 606
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 );
458 }610 }
459 611
460 // oauth2 is handled via the connect result flow, not pre-filled612 // oauth2 is handled via the connect result flow, not pre-filled
@@ -489,7 +641,9 @@ type SourceOAuthPopupMessage = · 28 unmodified lines
489 error: string;641 error: string;
490 };642 };
491 643
492const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {644const readStoredSourceOAuthPopupResult = (
645 sessionId: string,
646): SourceOAuthPopupMessage | null => {
493 if (typeof window === "undefined") return null;647 if (typeof window === "undefined") return null;
494 const raw = window.localStorage.getItem(648 const raw = window.localStorage.getItem(
495 `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,649 `${SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX}${sessionId}`,
@@ -504,7 +658,9 @@ const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMe · 8 unmodified lines
504 658
505const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {659const clearStoredSourceOAuthPopupResult = (sessionId: string): void => {
506 if (typeof window === "undefined") return;660 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 );
508};664};
509 665
510const startSourceOAuthPopup = async (input: {666const startSourceOAuthPopup = async (input: {
@@ -566,7 +722,12 @@ const startSourceOAuthPopup = async (input: { · 55 unmodified lines
566 if (!data) return;722 if (!data) return;
567 if (data.type === "executor:oauth-result") {723 if (data.type === "executor:oauth-result") {
568 if (data.ok && data.sessionId !== input.sessionId) return;724 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;
570 } else if (data.type !== "executor:source-oauth-result") {731 } else if (data.type !== "executor:source-oauth-result") {
571 return;732 return;
572 }733 }
@@ -576,7 +737,9 @@ const startSourceOAuthPopup = async (input: { · 3 unmodified lines
576 window.addEventListener("message", onMessage);737 window.addEventListener("message", onMessage);
577 738
578 resultTimeout = window.setTimeout(() => {739 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 );
580 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);743 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
581 744
582 closedPoll = window.setInterval(() => {745 closedPoll = window.setInterval(() => {
@@ -591,7 +754,9 @@ const startSourceOAuthPopup = async (input: { · 8 unmodified lines
591 window.clearInterval(closedPoll);754 window.clearInterval(closedPoll);
592 closedPoll = 0;755 closedPoll = 0;
593 window.setTimeout(() => {756 window.setTimeout(() => {
594 const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId);757 const delayedStored = readStoredSourceOAuthPopupResult(
758 input.sessionId,
759 );
595 if (delayedStored) {760 if (delayedStored) {
596 settleFromPayload(delayedStored);761 settleFromPayload(delayedStored);
597 return;762 return;
@@ -607,8 +772,6 @@ const startSourceOAuthPopup = async (input: { · 9 unmodified lines
607// Confidence badge helper772// Confidence badge helper
608// ---------------------------------------------------------------------------773// ---------------------------------------------------------------------------
609 774
610
611
612// ---------------------------------------------------------------------------775// ---------------------------------------------------------------------------
613// Page776// Page
614// ---------------------------------------------------------------------------777// ---------------------------------------------------------------------------
@@ -645,17 +808,22 @@ export function AddSourcePage() { · 30 unmodified lines
645 808
646 // Discovery result809 // Discovery result
647 // Editable connect form (populated after discovery)810 // Editable connect form (populated after discovery)
648 const [connectForm, setConnectForm] = useState<ConnectFormState>(defaultConnectForm());811 const [connectForm, setConnectForm] =
812 useState<ConnectFormState>(defaultConnectForm());
649 813
650 // Connect result814 // Connect result
651 const [connectResult, setConnectResult] = useState<ConnectSourceResult | null>(null);815 const [connectResult, setConnectResult] =
816 useState<ConnectSourceResult | null>(null);
652 817
653 // OAuth required state818 // OAuth required state
654 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);819 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);
655 const [batchOauthInfo, setBatchOauthInfo] = useState<BatchOAuthRequiredInfo | null>(null);820 const [batchOauthInfo, setBatchOauthInfo] =
821 useState<BatchOAuthRequiredInfo | null>(null);
656 const [oauthBusy, setOauthBusy] = useState(false);822 const [oauthBusy, setOauthBusy] = useState(false);
657 const [batchConnecting, setBatchConnecting] = useState(false);823 const [batchConnecting, setBatchConnecting] = useState(false);
658 const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<ReadonlyArray<string>>([]);824 const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<
825 ReadonlyArray<string>
826 >([]);
659 827
660 // Status banner828 // Status banner
661 const [statusBanner, setStatusBanner] = useState<{829 const [statusBanner, setStatusBanner] = useState<{
@@ -663,7 +831,10 @@ export function AddSourcePage() { · 1 unmodified line
663 text: string;831 text: string;
664 } | null>(null);832 } | null>(null);
665 833
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 ) => {
667 setConnectForm((current) => ({ ...current, [key]: value }));838 setConnectForm((current) => ({ ...current, [key]: value }));
668 };839 };
669 840
@@ -672,11 +843,15 @@ export function AddSourcePage() { · 2 unmodified lines
672 ...current,843 ...current,
673 kind,844 kind,
674 authKind: authKindForSourceKind(current.authKind, kind),845 authKind: authKindForSourceKind(current.authKind, kind),
675 workspaceOauthClientId: kind === "google_discovery" ? current.workspaceOauthClientId : "",846 workspaceOauthClientId:
847 kind === "google_discovery" ? current.workspaceOauthClientId : "",
676 }));848 }));
677 };849 };
678 850
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 ) => {
680 setProbeAuth((current) => ({ ...current, [key]: value }));855 setProbeAuth((current) => ({ ...current, [key]: value }));
681 };856 };
682 857
@@ -692,7 +867,10 @@ export function AddSourcePage() { · 9 unmodified lines
692 setStatusBanner(null);867 setStatusBanner(null);
693 const trimmedUrl = url.trim();868 const trimmedUrl = url.trim();
694 if (!trimmedUrl) {869 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 });
696 return;874 return;
697 }875 }
698 876
@@ -746,8 +924,20 @@ export function AddSourcePage() { · 47 unmodified lines
746 return;924 return;
747 }925 }
748 926
749 const discoveryUrl = "specUrl" in template ? template.specUrl : template.endpoint;927 if (isStdioMcpSourceTemplate(template)) {
750 setUrl(template.endpoint);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 ?? "");
751 setStatusBanner(null);941 setStatusBanner(null);
752 setPhase("discovering");942 setPhase("discovering");
753 943
@@ -756,8 +946,19 @@ export function AddSourcePage() { · 2 unmodified lines
756 const form = defaultConnectForm(result);946 const form = defaultConnectForm(result);
757 // Prefer the template's own values over whatever discover returned947 // Prefer the template's own values over whatever discover returned
758 form.name = template.name;948 form.name = template.name;
759 form.endpoint = template.endpoint;949 form.endpoint = template.endpoint ?? "";
760 form.namespace = namespaceFromUrl(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 }
761 if ("specUrl" in template) {962 if ("specUrl" in template) {
762 form.specUrl = template.specUrl;963 form.specUrl = template.specUrl;
763 }964 }
@@ -846,7 +1047,8 @@ export function AddSourcePage() { · 82 unmodified lines
846 1047
847 try {1048 try {
848 await startSourceOAuthPopup({1049 await startSourceOAuthPopup({
849 authorizationUrl: oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,1050 authorizationUrl:
1051 oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
850 sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,1052 sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,
851 });1053 });
852 1054
@@ -892,7 +1094,9 @@ export function AddSourcePage() { · 39 unmodified lines
892 1094
893 const clientId = connectForm.oauthClientId.trim();1095 const clientId = connectForm.oauthClientId.trim();
894 if (clientId.length === 0) {1096 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 );
896 }1100 }
897 1101
898 const created = await createWorkspaceOauthClient.mutateAsync({1102 const created = await createWorkspaceOauthClient.mutateAsync({
@@ -918,11 +1122,14 @@ export function AddSourcePage() { · 19 unmodified lines
918 try {1122 try {
919 const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId();1123 const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId();
920 const selectedTemplates = googleTemplates.filter(1124 const selectedTemplates = googleTemplates.filter(
921 (template): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>1125 (
1126 template,
1127 ): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>
922 selectedGoogleTemplateIds.includes(template.id),1128 selectedGoogleTemplateIds.includes(template.id),
923 );1129 );
924 const payload: ConnectSourceBatchPayload = {1130 const payload: ConnectSourceBatchPayload = {
925 workspaceOauthClientId: workspaceOauthClientId as WorkspaceOauthClient["id"],1131 workspaceOauthClientId:
1132 workspaceOauthClientId as WorkspaceOauthClient["id"],
926 sources: selectedTemplates.map((template) => ({1133 sources: selectedTemplates.map((template) => ({
927 service: template.service,1134 service: template.service,
928 version: template.version,1135 version: template.version,
@@ -964,7 +1171,8 @@ export function AddSourcePage() { · 35 unmodified lines
964 setPhase("idle");1171 setPhase("idle");
965 setStatusBanner({1172 setStatusBanner({
966 tone: "error",1173 tone: "error",
967 text: error instanceof Error ? error.message : "Batch connection failed.",1174 text:
1175 error instanceof Error ? error.message : "Batch connection failed.",
968 });1176 });
969 }1177 }
970 };1178 };
@@ -984,11 +1192,11 @@ export function AddSourcePage() { · 13 unmodified lines
984 const isDiscovering = phase === "discovering";1192 const isDiscovering = phase === "discovering";
985 const isConnecting = phase === "connecting";1193 const isConnecting = phase === "connecting";
986 const isBusy =1194 const isBusy =
987 isDiscovering1195 isDiscovering ||
988 || isConnecting1196 isConnecting ||
989 || oauthBusy1197 oauthBusy ||
990 || connectSourceBatch.status === "pending"1198 connectSourceBatch.status === "pending" ||
991 || createWorkspaceOauthClient.status === "pending";1199 createWorkspaceOauthClient.status === "pending";
992 1200
993 // -------------------------------------------------------------------------1201 // -------------------------------------------------------------------------
994 // Render1202 // Render
@@ -1032,19 +1240,23 @@ export function AddSourcePage() { · 37 unmodified lines
1032 return (1240 return (
1033 <div key={step} className="flex items-center gap-1.5">1241 <div key={step} className="flex items-center gap-1.5">
1034 {i > 0 && (1242 {i > 0 && (
1035 <div className={cn(1243 <div
1036 "h-px w-6 transition-colors",1244 className={cn(
1037 isComplete || isCurrent ? "bg-primary/40" : "bg-border",1245 "h-px w-6 transition-colors",
1038 )} />1246 isComplete || isCurrent ? "bg-primary/40" : "bg-border",
1247 )}
1248 />
1039 )}1249 )}
1040 <div className={cn(1250 <div
1041 "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",1251 className={cn(
1042 isComplete1252 "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",
1043 ? "text-primary"1253 isComplete
1044 : isCurrent1254 ? "text-primary"
1045 ? "text-foreground bg-muted/50"1255 : isCurrent
1046 : "text-muted-foreground/40",1256 ? "text-foreground bg-muted/50"
1047 )}>1257 : "text-muted-foreground/40",
1258 )}
1259 >
1048 {isComplete ? (1260 {isComplete ? (
1049 <IconCheck className="size-2.5" />1261 <IconCheck className="size-2.5" />
1050 ) : (1262 ) : (
@@ -1062,7 +1274,6 @@ export function AddSourcePage() { · 11 unmodified lines
1062 1274
1063 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}1275 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
1064 1276
1065
1066 {(phase === "idle" || phase === "discovering") && (1277 {(phase === "idle" || phase === "discovering") && (
1067 <LocalMcpInstallCard1278 <LocalMcpInstallCard
1068 className="mb-6 rounded-xl border border-border bg-card/80 p-5"1279 className="mb-6 rounded-xl border border-border bg-card/80 p-5"
@@ -1076,7 +1287,8 @@ export function AddSourcePage() { · 7 unmodified lines
1076 <Section title="Discover">1287 <Section title="Discover">
1077 <div className="space-y-4">1288 <div className="space-y-4">
1078 <p className="text-[13px] text-muted-foreground">1289 <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.
1080 </p>1292 </p>
1081 1293
1082 <Field label="URL">1294 <Field label="URL">
@@ -1094,13 +1306,23 @@ export function AddSourcePage() { · 11 unmodified lines
1094 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"1306 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"
1095 />1307 />
1096 <Button onClick={handleDiscover} disabled={isBusy}>1308 <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 )}
1098 {isDiscovering ? "Discovering\u2026" : "Discover"}1314 {isDiscovering ? "Discovering\u2026" : "Discover"}
1099 </Button>1315 </Button>
1100 </div>1316 </div>
1101 {isDiscovering && (1317 {isDiscovering && (
1102 <div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-muted">1318 <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 />
1104 </div>1326 </div>
1105 )}1327 )}
1106 </div>1328 </div>
@@ -1113,7 +1335,9 @@ export function AddSourcePage() { · 6 unmodified lines
1113 onClick={() => setShowProbeAuth(!showProbeAuth)}1335 onClick={() => setShowProbeAuth(!showProbeAuth)}
1114 className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"1336 className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"
1115 >1337 >
1116 {showProbeAuth ? "Hide probe auth" : "Need auth to discover?"}1338 {showProbeAuth
1339 ? "Hide probe auth"
1340 : "Need auth to discover?"}
1117 </button>1341 </button>
1118 </div>1342 </div>
1119 1343
@@ -1122,8 +1346,13 @@ export function AddSourcePage() { · 2 unmodified lines
1122 <Field label="Auth type">1346 <Field label="Auth type">
1123 <SelectInput1347 <SelectInput
1124 value={probeAuth.kind}1348 value={probeAuth.kind}
1125 onChange={(v) => setProbeField("kind", v as ProbeAuthKind)}1349 onChange={(v) =>
1126 options={probeAuthOptions.map((v) => ({ value: v, label: v }))}1350 setProbeField("kind", v as ProbeAuthKind)
1351 }
1352 options={probeAuthOptions.map((v) => ({
1353 value: v,
1354 label: v,
1355 }))}
1127 />1356 />
1128 </Field>1357 </Field>
1129 1358
@@ -1200,48 +1429,70 @@ export function AddSourcePage() { · 70 unmodified lines
1200 {/* ---- Template catalogue ---- */}1429 {/* ---- Template catalogue ---- */}
1201 <div className="space-y-5 border-t border-border pt-5">1430 <div className="space-y-5 border-t border-border pt-5">
1202 <div>1431 <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>
1204 <p className="mt-0.5 text-[12px] text-muted-foreground">1435 <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.
1206 </p>1438 </p>
1207 </div>1439 </div>
1208 1440
1209 {/* -- Popular / non-Google templates -- */}1441 {/* -- Popular / non-Google templates -- */}
1210 {(() => {1442 {(() => {
1211 const popularTemplates = sourceTemplates.filter((t) => t.groupId !== "google_workspace");1443 const popularTemplates = sourceTemplates.filter(
1212 return popularTemplates.length > 0 && (1444 (t) => t.groupId !== "google_workspace",
1213 <div className="space-y-2.5">1445 );
1214 <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Popular</p>1446 return (
1215 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">1447 popularTemplates.length > 0 && (
1216 {popularTemplates.map((template) => (1448 <div className="space-y-2.5">
1217 <button1449 <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">
1218 key={template.id}1450 Popular
1219 type="button"1451 </p>
1220 onClick={() => applyTemplate(template)}1452 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1221 disabled={isBusy}1453 {popularTemplates.map((template) => (
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"1454 <button
1223 >1455 key={template.id}
1224 <div className="mb-1.5 flex items-center gap-2.5">1456 type="button"
1225 <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50">1457 onClick={() => applyTemplate(template)}
1226 <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-3.5" />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>
1227 </div>1478 </div>
1228 <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">{template.name}</span>1479 <span className="line-clamp-1 text-[11px] text-muted-foreground/70">
1229 <Badge variant="outline" className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity">{template.kind}</Badge>1480 {template.summary}
1230 </div>1481 </span>
1231 <span className="line-clamp-1 text-[11px] text-muted-foreground/70">1482 </button>
1232 {template.summary}1483 ))}
1233 </span>1484 </div>
1234 </button>
1235 ))}
1236 </div>1485 </div>
1237 </div>1486 )
1238 );1487 );
1239 })()}1488 })()}
1240 1489
1241 {/* -- Google Workspace batch section -- */}1490 {/* -- Google Workspace batch section -- */}
1242 <div className="space-y-3">1491 <div className="space-y-3">
1243 <div className="flex items-center justify-between gap-3">1492 <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>
1245 {googleTemplates.length > 0 && (1496 {googleTemplates.length > 0 && (
1246 <div className="flex items-center gap-2">1497 <div className="flex items-center gap-2">
1247 {selectedGoogleTemplateIds.length > 0 && (1498 {selectedGoogleTemplateIds.length > 0 && (
@@ -1258,7 +1509,8 @@ export function AddSourcePage() { · 10 unmodified lines
1258 type="button"1509 type="button"
1259 onClick={() =>1510 onClick={() =>
1260 setSelectedGoogleTemplateIds(1511 setSelectedGoogleTemplateIds(
1261 selectedGoogleTemplateIds.length === googleTemplates.length1512 selectedGoogleTemplateIds.length ===
1513 googleTemplates.length
1262 ? []1514 ? []
1263 : googleTemplates.map((t) => t.id),1515 : googleTemplates.map((t) => t.id),
1264 )1516 )
@@ -1266,14 +1518,19 @@ export function AddSourcePage() { · 1 unmodified line
1266 disabled={isBusy}1518 disabled={isBusy}
1267 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"1519 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1268 >1520 >
1269 {selectedGoogleTemplateIds.length === googleTemplates.length ? "Deselect all" : "Select all"}1521 {selectedGoogleTemplateIds.length ===
1522 googleTemplates.length
1523 ? "Deselect all"
1524 : "Select all"}
1270 </button>1525 </button>
1271 </div>1526 </div>
1272 )}1527 )}
1273 </div>1528 </div>
1274 <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">1529 <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
1275 {googleTemplates.map((template) => {1530 {googleTemplates.map((template) => {
1276 const selected = selectedGoogleTemplateIds.includes(template.id);1531 const selected = selectedGoogleTemplateIds.includes(
1532 template.id,
1533 );
1277 return (1534 return (
1278 <button1535 <button
1279 key={template.id}1536 key={template.id}
@@ -1287,23 +1544,33 @@ export function AddSourcePage() { · 7 unmodified lines
1287 : "border-border bg-card/50 hover:bg-accent/40 hover:border-border",1544 : "border-border bg-card/50 hover:bg-accent/40 hover:border-border",
1288 )}1545 )}
1289 >1546 >
1290 <div className={cn(1547 <div
1291 "flex size-4 shrink-0 items-center justify-center rounded transition-colors",1548 className={cn(
1292 selected1549 "flex size-4 shrink-0 items-center justify-center rounded transition-colors",
1293 ? "text-primary"1550 selected
1294 : "text-muted-foreground/30 group-hover:text-muted-foreground/50",1551 ? "text-primary"
1295 )}>1552 : "text-muted-foreground/30 group-hover:text-muted-foreground/50",
1553 )}
1554 >
1296 {selected ? (1555 {selected ? (
1297 <IconCheck className="size-3.5" />1556 <IconCheck className="size-3.5" />
1298 ) : (1557 ) : (
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 />
1300 )}1563 )}
1301 </div>1564 </div>
1302 <div className="min-w-0 flex-1">1565 <div className="min-w-0 flex-1">
1303 <span className={cn(1566 <span
1304 "block truncate text-[12px] font-medium transition-colors",1567 className={cn(
1305 selected ? "text-foreground" : "text-foreground/80",1568 "block truncate text-[12px] font-medium transition-colors",
1306 )}>1569 selected
1570 ? "text-foreground"
1571 : "text-foreground/80",
1572 )}
1573 >
1307 {template.name}1574 {template.name}
1308 </span>1575 </span>
1309 <span className="block truncate text-[10px] text-muted-foreground/60">1576 <span className="block truncate text-[10px] text-muted-foreground/60">
@@ -1322,10 +1589,15 @@ export function AddSourcePage() { · 12 unmodified lines
1322 <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3">1589 <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3">
1323 <div className="flex items-center gap-2.5">1590 <div className="flex items-center gap-2.5">
1324 <div className="flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary">1591 <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>
1326 </div>1595 </div>
1327 <p className="text-[13px] font-medium text-foreground">1596 <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"}
1329 </p>1601 </p>
1330 </div>1602 </div>
1331 <p className="hidden text-[11px] text-muted-foreground/60 sm:block">1603 <p className="hidden text-[11px] text-muted-foreground/60 sm:block">
@@ -1338,41 +1610,71 @@ export function AddSourcePage() { · 6 unmodified lines
1338 <Field label="Workspace OAuth client">1610 <Field label="Workspace OAuth client">
1339 <select1611 <select
1340 value={connectForm.workspaceOauthClientId}1612 value={connectForm.workspaceOauthClientId}
1341 onChange={(event) => setFormField("workspaceOauthClientId", event.target.value)}1613 onChange={(event) =>
1614 setFormField(
1615 "workspaceOauthClientId",
1616 event.target.value,
1617 )
1618 }
1342 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"1619 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"
1343 >1620 >
1344 <option value="">Enter new client credentials</option>1621 <option value="">
1345 {workspaceOauthClients.status === "ready" && workspaceOauthClients.data.map((client) => (1622 Enter new client credentials
1346 <option key={client.id} value={client.id}>1623 </option>
1347 {client.label ?? client.clientId}1624 {workspaceOauthClients.status === "ready" &&
1348 </option>1625 workspaceOauthClients.data.map((client) => (
1349 ))}1626 <option key={client.id} value={client.id}>
1627 {client.label ?? client.clientId}
1628 </option>
1629 ))}
1350 </select>1630 </select>
1351 </Field>1631 </Field>
1352 {connectForm.workspaceOauthClientId.trim().length === 0 && (1632 {connectForm.workspaceOauthClientId.trim().length ===
1633 0 && (
1353 <div className="grid gap-3 sm:grid-cols-2">1634 <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 >
1355 <TextInput1639 <TextInput
1356 type="password"1640 type="password"
1357 value={connectForm.oauthClientId}1641 value={connectForm.oauthClientId}
1358 onChange={(v) => setFormField("oauthClientId", v)}1642 onChange={(v) =>
1643 setFormField("oauthClientId", v)
1644 }
1359 placeholder="1234567890-abcdef.apps.googleusercontent.com"1645 placeholder="1234567890-abcdef.apps.googleusercontent.com"
1360 />1646 />
1361 </Field>1647 </Field>
1362 <Field label="Client secret" className="sm:col-span-2">1648 <Field
1649 label="Client secret"
1650 className="sm:col-span-2"
1651 >
1363 <TextInput1652 <TextInput
1364 type="password"1653 type="password"
1365 value={connectForm.oauthClientSecret}1654 value={connectForm.oauthClientSecret}
1366 onChange={(v) => setFormField("oauthClientSecret", v)}1655 onChange={(v) =>
1656 setFormField("oauthClientSecret", v)
1657 }
1367 placeholder="GOCSPX-..."1658 placeholder="GOCSPX-..."
1368 />1659 />
1369 </Field>1660 </Field>
1370 </div>1661 </div>
1371 )}1662 )}
1372 <div className="flex items-center justify-end pt-1">1663 <div className="flex items-center justify-end pt-1">
1373 <Button type="button" onClick={handleConnectGoogleBatch} disabled={isBusy}>1664 <Button
1374 {isBusy ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />}1665 type="button"
1375 Connect {selectedGoogleTemplateIds.length} source{selectedGoogleTemplateIds.length === 1 ? "" : "s"}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"}
1376 </Button>1678 </Button>
1377 </div>1679 </div>
1378 </div>1680 </div>
@@ -1385,8 +1687,6 @@ export function AddSourcePage() { · 6 unmodified lines
1385 </div>1687 </div>
1386 )}1688 )}
1387 1689
1388
1389
1390 {/* Batch connecting interstitial */}1690 {/* Batch connecting interstitial */}
1391 {batchConnecting && phase === "connecting" && (1691 {batchConnecting && phase === "connecting" && (
1392 <div className="flex flex-col items-center justify-center py-20">1692 <div className="flex flex-col items-center justify-center py-20">
@@ -1396,16 +1696,21 @@ export function AddSourcePage() { · 3 unmodified lines
1396 </div>1696 </div>
1397 <div className="space-y-1.5">1697 <div className="space-y-1.5">
1398 <h2 className="font-display text-xl tracking-tight text-foreground">1698 <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"}
1400 </h2>1701 </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>
1402 </div>1705 </div>
1403 </div>1706 </div>
1404 </div>1707 </div>
1405 )}1708 )}
1406 1709
1407 {/* Step 2-4: Editing / Connecting */}1710 {/* 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") && (
1409 <div className="space-y-6">1714 <div className="space-y-6">
1410 <Section title="Configuration">1715 <Section title="Configuration">
1411 <div className="grid gap-4 sm:grid-cols-2">1716 <div className="grid gap-4 sm:grid-cols-2">
@@ -1419,20 +1724,26 @@ export function AddSourcePage() { · 7 unmodified lines
1419 <Field label="Kind">1724 <Field label="Kind">
1420 <SelectInput1725 <SelectInput
1421 value={connectForm.kind}1726 value={connectForm.kind}
1422 onChange={(v) => setSourceKind(v as ConnectFormState["kind"])}1727 onChange={(v) =>
1728 setSourceKind(v as ConnectFormState["kind"])
1729 }
1423 options={kindOptions.map((v) => ({ value: v, label: v }))}1730 options={kindOptions.map((v) => ({ value: v, label: v }))}
1424 />1731 />
1425 </Field>1732 </Field>
1426 {connectForm.kind !== "google_discovery" && (1733 {connectForm.kind !== "google_discovery" &&
1427 <Field label="Endpoint" className="sm:col-span-2">1734 !(
1428 <TextInput1735 connectForm.kind === "mcp" &&
1429 value={connectForm.endpoint}1736 connectForm.transport === "stdio"
1430 onChange={(v) => setFormField("endpoint", v)}1737 ) && (
1431 placeholder="https://api.example.com"1738 <Field label="Endpoint" className="sm:col-span-2">
1432 mono1739 <TextInput
1433 />1740 value={connectForm.endpoint}
1434 </Field>1741 onChange={(v) => setFormField("endpoint", v)}
1435 )}1742 placeholder="https://api.example.com"
1743 mono
1744 />
1745 </Field>
1746 )}
1436 <Field label="Namespace">1747 <Field label="Namespace">
1437 <TextInput1748 <TextInput
1438 value={connectForm.namespace}1749 value={connectForm.namespace}
@@ -1466,7 +1777,10 @@ export function AddSourcePage() { · 27 unmodified lines
1466 placeholder="v4"1777 placeholder="v4"
1467 />1778 />
1468 </Field>1779 </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 >
1470 <TextInput1784 <TextInput
1471 value={connectForm.discoveryUrl}1785 value={connectForm.discoveryUrl}
1472 onChange={(v) => setFormField("discoveryUrl", v)}1786 onChange={(v) => setFormField("discoveryUrl", v)}
@@ -1475,7 +1789,8 @@ export function AddSourcePage() { · 2 unmodified lines
1475 />1789 />
1476 </Field>1790 </Field>
1477 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">1791 <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`.
1479 </div>1794 </div>
1480 </>1795 </>
1481 )}1796 )}
@@ -1489,26 +1804,76 @@ export function AddSourcePage() { · 7 unmodified lines
1489 <Field label="Transport mode">1804 <Field label="Transport mode">
1490 <SelectInput1805 <SelectInput
1491 value={connectForm.transport || "auto"}1806 value={connectForm.transport || "auto"}
1492 onChange={(v) => setFormField("transport", v as ConnectFormState["transport"])}1807 onChange={(v) =>
1493 options={transportOptions.map((v) => ({ value: v, label: v }))}1808 setFormField(
1809 "transport",
1810 v as ConnectFormState["transport"],
1811 )
1812 }
1813 options={transportOptions.map((v) => ({
1814 value: v,
1815 label: v,
1816 }))}
1494 />1817 />
1495 </Field>1818 </Field>
1496 <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">1819 {connectForm.transport === "stdio" ? (
1497 <Field label="Query params (JSON)">1820 <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
1498 <CodeEditor1821 <Field label="Command">
1499 value={connectForm.queryParamsText}1822 <TextInput
1500 onChange={(v) => setFormField("queryParamsText", v)}1823 value={connectForm.command}
1501 placeholder={'{\n "workspace": "demo"\n}'}1824 onChange={(v) => setFormField("command", v)}
1502 />1825 placeholder="npx"
1503 </Field>1826 mono
1504 <Field label="Headers (JSON)">1827 />
1505 <CodeEditor1828 </Field>
1506 value={connectForm.headersText}1829 <Field label="Working directory (optional)">
1507 onChange={(v) => setFormField("headersText", v)}1830 <TextInput
1508 placeholder={'{\n "x-api-key": "..."\n}'}1831 value={connectForm.cwd}
1509 />1832 onChange={(v) => setFormField("cwd", v)}
1510 </Field>1833 placeholder="/path/to/project"
1511 </div>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 )}
1512 </div>1877 </div>
1513 </Section>1878 </Section>
1514 )}1879 )}
@@ -1517,15 +1882,28 @@ export function AddSourcePage() { · 2 unmodified lines
1517 {phase === "credential_required" && (1882 {phase === "credential_required" && (
1518 <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">1883 <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">
1519 <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100/80 dark:bg-amber-900/40">1884 <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">1885 <svg
1521 <path d="M12 9v4" /><path d="M12 17h.01" />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" />
1522 <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" />1896 <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" />
1523 </svg>1897 </svg>
1524 </div>1898 </div>
1525 <div className="space-y-0.5">1899 <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>
1527 <p className="text-[11px] leading-relaxed text-amber-800/70 dark:text-amber-300/60">1903 <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.
1529 </p>1907 </p>
1530 </div>1908 </div>
1531 </div>1909 </div>
@@ -1536,7 +1914,12 @@ export function AddSourcePage() { · 4 unmodified lines
1536 <Field label="Auth mode">1914 <Field label="Auth mode">
1537 <SelectInput1915 <SelectInput
1538 value={connectForm.authKind}1916 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 }
1540 options={authOptions.map((v) => ({ value: v, label: v }))}1923 options={authOptions.map((v) => ({ value: v, label: v }))}
1541 />1924 />
1542 </Field>1925 </Field>
@@ -1547,45 +1930,62 @@ export function AddSourcePage() { · 4 unmodified lines
1547 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}1930 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
1548 </div>1931 </div>
1549 )}1932 )}
1550 {connectForm.kind === "google_discovery" && connectForm.authKind === "oauth2" && (1933 {connectForm.kind === "google_discovery" &&
1551 <>1934 connectForm.authKind === "oauth2" && (
1552 <Field label="Workspace OAuth client" className="sm:col-span-2">1935 <>
1553 <SelectInput1936 <Field
1554 value={connectForm.workspaceOauthClientId}1937 label="Workspace OAuth client"
1555 onChange={(value) => setFormField("workspaceOauthClientId", value)}1938 className="sm:col-span-2"
1556 options={[1939 >
1557 { value: "", label: "Enter new client" },1940 <SelectInput
1558 ...(workspaceOauthClients.status === "ready"1941 value={connectForm.workspaceOauthClientId}
1559 ? workspaceOauthClients.data.map((client) => ({1942 onChange={(value) =>
1560 value: client.id,1943 setFormField("workspaceOauthClientId", value)
1561 label: client.label ?? client.clientId,1944 }
1562 }))1945 options={[
1563 : []),1946 { value: "", label: "Enter new client" },
1564 ]}1947 ...(workspaceOauthClients.status === "ready"
1565 />1948 ? workspaceOauthClients.data.map((client) => ({
1566 </Field>1949 value: client.id,
1567 {connectForm.workspaceOauthClientId.trim().length === 0 && (1950 label: client.label ?? client.clientId,
1568 <>1951 }))
1569 <Field label="New client ID" className="sm:col-span-2">1952 : []),
1570 <TextInput1953 ]}
1571 type="password"1954 />
1572 value={connectForm.oauthClientId}1955 </Field>
1573 onChange={(v) => setFormField("oauthClientId", v)}1956 {connectForm.workspaceOauthClientId.trim().length ===
1574 placeholder="1234567890-abcdef.apps.googleusercontent.com"1957 0 && (
1575 />1958 <>
1576 </Field>1959 <Field
1577 <Field label="New client secret" className="sm:col-span-2">1960 label="New client ID"
1578 <TextInput1961 className="sm:col-span-2"
1579 type="password"1962 >
1580 value={connectForm.oauthClientSecret}1963 <TextInput
1581 onChange={(v) => setFormField("oauthClientSecret", v)}1964 type="password"
1582 placeholder="GOCSPX-..."1965 value={connectForm.oauthClientId}
1583 />1966 onChange={(v) =>
1584 </Field>1967 setFormField("oauthClientId", v)
1585 </>1968 }
1586 )}1969 placeholder="1234567890-abcdef.apps.googleusercontent.com"
1587 </>1970 />
1588 )}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 )}
1589 {connectForm.authKind !== "none" && (1989 {connectForm.authKind !== "none" && (
1590 <>1990 <>
1591 <Field label="Header name">1991 <Field label="Header name">
@@ -1631,18 +2031,32 @@ export function AddSourcePage() { · 39 unmodified lines
1631 {/* Actions */}2031 {/* Actions */}
1632 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">2032 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
1633 {phase === "credential_required" && (2033 {phase === "credential_required" && (
1634 <Button variant="ghost" type="button" onClick={handleBackToEditing}>2034 <Button
2035 variant="ghost"
2036 type="button"
2037 onClick={handleBackToEditing}
2038 >
1635 Back to edit2039 Back to edit
1636 </Button>2040 </Button>
1637 )}2041 )}
1638 <Link to="/" className="inline-flex">2042 <Link to="/" className="inline-flex">
1639 <Button variant="ghost" type="button">Cancel</Button>2043 <Button variant="ghost" type="button">
2044 Cancel
2045 </Button>
1640 </Link>2046 </Link>
1641 <Button2047 <Button
1642 onClick={phase === "credential_required" ? handleCredentialConnect : handleConnect}2048 onClick={
2049 phase === "credential_required"
2050 ? handleCredentialConnect
2051 : handleConnect
2052 }
1643 disabled={isBusy}2053 disabled={isBusy}
1644 >2054 >
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 )}
1646 {isConnecting ? "Connecting\u2026" : "Connect"}2060 {isConnecting ? "Connecting\u2026" : "Connect"}
1647 </Button>2061 </Button>
1648 </div>2062 </div>
@@ -1658,7 +2072,15 @@ export function AddSourcePage() { · 9 unmodified lines
1658 {oauthBusy ? (2072 {oauthBusy ? (
1659 <IconSpinner className="size-6 text-primary" />2073 <IconSpinner className="size-6 text-primary" />
1660 ) : (2074 ) : (
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 >
1662 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />2084 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1663 <path d="M7 11V7a5 5 0 0110 0v4" />2085 <path d="M7 11V7a5 5 0 0110 0v4" />
1664 </svg>2086 </svg>
@@ -1667,24 +2089,36 @@ export function AddSourcePage() { · 2 unmodified lines
1667 2089
1668 <div className="space-y-2">2090 <div className="space-y-2">
1669 <h2 className="font-display text-xl tracking-tight text-foreground">2091 <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"}
1671 </h2>2095 </h2>
1672 <p className="text-[13px] leading-relaxed text-muted-foreground">2096 <p className="text-[13px] leading-relaxed text-muted-foreground">
1673 {oauthInfo2097 {oauthInfo ? (
1674 ? <>2098 <>
1675 <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth authentication.2099 <strong className="text-foreground">
1676 A popup will open for you to authorize access.2100 {oauthInfo.source.name}
1677 </>2101 </strong>{" "}
1678 : <>2102 requires OAuth authentication. A popup will open for you
1679 {batchOauthInfo!.sourceIds.length} Google source{batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need{batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth.2103 to authorize access.
1680 One consent screen covers all selected APIs.2104 </>
1681 </>2105 ) : (
1682 }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 )}
1683 </p>2113 </p>
1684 </div>2114 </div>
1685 2115
1686 <div className="flex flex-col items-center gap-3">2116 <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 >
1688 {oauthBusy ? <IconSpinner className="size-3.5" /> : null}2122 {oauthBusy ? <IconSpinner className="size-3.5" /> : null}
1689 {oauthBusy ? "Authenticating\u2026" : "Open sign-in"}2123 {oauthBusy ? "Authenticating\u2026" : "Open sign-in"}
1690 </Button>2124 </Button>
@@ -1715,10 +2149,12 @@ export function AddSourcePage() { · 24 unmodified lines
1715 ? connectResult.source.name2149 ? connectResult.source.name
1716 : batchOauthInfo2150 : batchOauthInfo
1717 ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`2151 ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`
1718 : "Source"}2152 : "Source"}{" "}
1719 {" "}connected2153 connected
1720 </h2>2154 </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>
1722 </div>2158 </div>
1723 </div>2159 </div>
1724 </div>2160 </div>
@@ -1732,9 +2168,18 @@ export function AddSourcePage() { · 7 unmodified lines
1732// Form building blocks2168// Form building blocks
1733// ---------------------------------------------------------------------------2169// ---------------------------------------------------------------------------
1734 2170
1735function Section(props: { title: string; children: ReactNode; className?: string }) {2171function Section(props: {
2172 title: string;
2173 children: ReactNode;
2174 className?: string;
2175}) {
1736 return (2176 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 >
1738 <div className="border-b border-border px-5 py-3">2183 <div className="border-b border-border px-5 py-3">
1739 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>2184 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
1740 </div>2185 </div>
@@ -1750,7 +2195,9 @@ function Field(props: { · 9 unmodified lines
1750}) {2195}) {
1751 return (2196 return (
1752 <label className={cn("block space-y-1.5", props.className)}>2197 <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>
1754 {props.children}2201 {props.children}
1755 </label>2202 </label>
1756 );2203 );
@@ -1813,27 +2260,55 @@ function CodeEditor(props: { · 56 unmodified lines
1813 );2260 );
1814}2261}
1815 2262
1816function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text: string }; className?: string }) {2263function StatusBanner(props: {
2264 state: { tone: "info" | "success" | "error"; text: string };
2265 className?: string;
2266}) {
1817 return (2267 return (
1818 <div2268 <div
1819 className={cn(2269 className={cn(
1820 "flex items-start gap-2.5 rounded-lg border px-4 py-3 text-[13px]",2270 "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",2271 props.state.tone === "success" &&
1822 props.state.tone === "info" && "border-border bg-card text-muted-foreground",2272 "border-primary/30 bg-primary/8 text-foreground",
1823 props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",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",
1824 props.className,2277 props.className,
1825 )}2278 )}
1826 >2279 >
1827 <span className="mt-0.5 shrink-0">2280 <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 )}
1829 {props.state.tone === "error" && (2284 {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">2285 <svg
1831 <circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6" /><path d="M9 9l6 6" />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" />
1832 </svg>2297 </svg>
1833 )}2298 )}
1834 {props.state.tone === "info" && (2299 {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">2300 <svg
1836 <circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" />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" />
1837 </svg>2312 </svg>
1838 )}2313 )}
1839 </span>2314 </span>
@@ -1842,8 +2317,6 @@ function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text · 2 unmodified lines
1842 );2317 );
1843}2318}
1844 2319
1845
1846
1847// ---------------------------------------------------------------------------2320// ---------------------------------------------------------------------------
1848// Secret or inline token picker2321// Secret or inline token picker
1849// ---------------------------------------------------------------------------2322// ---------------------------------------------------------------------------
@@ -1876,7 +2349,9 @@ function SecretOrTokenInput(props: { · 26 unmodified lines
1876 const [createError, setCreateError] = useState<string | null>(null);2349 const [createError, setCreateError] = useState<string | null>(null);
1877 const storableProviders =2350 const storableProviders =
1878 instanceConfig.status === "ready"2351 instanceConfig.status === "ready"
1879 ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)2352 ? instanceConfig.data.secretProviders.filter(
2353 (provider) => provider.canStore,
2354 )
1880 : [];2355 : [];
1881 2356
1882 useEffect(() => {2357 useEffect(() => {
@@ -1888,7 +2363,11 @@ function SecretOrTokenInput(props: { · 5 unmodified lines
1888 }2363 }
1889 }, [instanceConfig, newProviderId]);2364 }, [instanceConfig, newProviderId]);
1890 2365
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 || "";
1892 2371
1893 const handleSelectChange = (value: string) => {2372 const handleSelectChange = (value: string) => {
1894 if (value === CREATE_NEW_VALUE) {2373 if (value === CREATE_NEW_VALUE) {
@@ -1918,9 +2397,10 @@ function SecretOrTokenInput(props: { · 23 unmodified lines
1918 }2397 }
1919 setUseInline(false);2398 setUseInline(false);
1920 setShowCreate(false);2399 setShowCreate(false);
1921 const matchedSecret = secrets.status === "ready"2400 const matchedSecret =
1922 ? secrets.data.find((secret) => secret.id === value)2401 secrets.status === "ready"
1923 : null;2402 ? secrets.data.find((secret) => secret.id === value)
2403 : null;
1924 onSelectSecret(matchedSecret?.providerId ?? "local", value);2404 onSelectSecret(matchedSecret?.providerId ?? "local", value);
1925 };2405 };
1926 2406
@@ -1945,7 +2425,9 @@ function SecretOrTokenInput(props: { · 18 unmodified lines
1945 onSelectSecret(result.providerId, result.id);2425 onSelectSecret(result.providerId, result.id);
1946 setShowCreate(false);2426 setShowCreate(false);
1947 } catch (err) {2427 } catch (err) {
1948 setCreateError(err instanceof Error ? err.message : "Failed creating secret.");2428 setCreateError(
2429 err instanceof Error ? err.message : "Failed creating secret.",
2430 );
1949 }2431 }
1950 };2432 };
1951 2433
@@ -1999,7 +2481,9 @@ function SecretOrTokenInput(props: { · 47 unmodified lines
1999 )}2481 )}
2000 <div className="grid gap-3 sm:grid-cols-2">2482 <div className="grid gap-3 sm:grid-cols-2">
2001 <label className="block space-y-1">2483 <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>
2003 <input2487 <input
2004 value={newName}2488 value={newName}
2005 onChange={(e) => setNewName(e.target.value)}2489 onChange={(e) => setNewName(e.target.value)}
@@ -2009,7 +2493,9 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
2009 />2493 />
2010 </label>2494 </label>
2011 <label className="block space-y-1">2495 <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>
2013 <input2499 <input
2014 type="password"2500 type="password"
2015 value={newValue}2501 value={newValue}
@@ -2019,7 +2505,9 @@ function SecretOrTokenInput(props: { · 3 unmodified lines
2019 />2505 />
2020 </label>2506 </label>
2021 <label className="block space-y-1">2507 <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>
2023 <select2511 <select
2024 value={newProviderId}2512 value={newProviderId}
2025 onChange={(e) => setNewProviderId(e.target.value)}2513 onChange={(e) => setNewProviderId(e.target.value)}
@@ -2046,7 +2534,9 @@ function SecretOrTokenInput(props: { · 20 unmodified lines
2046 onClick={handleCreate}2534 onClick={handleCreate}
2047 disabled={createSecret.status === "pending"}2535 disabled={createSecret.status === "pending"}
2048 >2536 >
2049 {createSecret.status === "pending" && <IconSpinner className="size-3" />}2537 {createSecret.status === "pending" && (
2538 <IconSpinner className="size-3" />
2539 )}
2050 Store & use2540 Store & use
2051 </Button>2541 </Button>
2052 </div>2542 </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
60const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";60const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
61 61
62const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>62const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean =>
63 loadable.status === "error"63 loadable.status === "error" &&
64 && loadable.error.message.toLowerCase().includes("source not found");64 loadable.error.message.toLowerCase().includes("source not found");
65 65
66type TransportValue = "" | "auto" | "streamable-http" | "sse";66type TransportValue = "" | "auto" | "streamable-http" | "sse" | "stdio";
67 67
68type SourceFormState = {68type SourceFormState = {
69 name: string;69 name: string;
@@ -74,6 +74,10 @@ type SourceFormState = { · 4 unmodified lines
74 transport: TransportValue;74 transport: TransportValue;
75 queryParamsText: string;75 queryParamsText: string;
76 headersText: string;76 headersText: string;
77 command: string;
78 argsText: string;
79 envText: string;
80 cwd: string;
77 specUrl: string;81 specUrl: string;
78 service: string;82 service: string;
79 version: string;83 version: string;
@@ -87,7 +91,10 @@ type SourceFormState = { · 7 unmodified lines
87 oauthAccessHandle: string;91 oauthAccessHandle: string;
88 oauthRefreshProviderId: string;92 oauthRefreshProviderId: string;
89 oauthRefreshHandle: string;93 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;
91};98};
92 99
93const kindOptions: ReadonlyArray<Source["kind"]> = [100const kindOptions: ReadonlyArray<Source["kind"]> = [
@@ -102,6 +109,7 @@ const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ · 8 unmodified lines
102 "auto",109 "auto",
103 "streamable-http",110 "streamable-http",
104 "sse",111 "sse",
112 "stdio",
105];113];
106 114
107const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [115const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
@@ -129,9 +137,15 @@ const namespaceFromUrl = (url: string): string => { · 21 unmodified lines
129};137};
130 138
131const googleDiscoveryNamespace = (service: string): string =>139const googleDiscoveryNamespace = (service: string): string =>
132 `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;140 `google.${service
133 141 .trim()
134const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {142 .toLowerCase()
143 .replace(/[^a-z0-9]+/g, ".")
144 .replace(/^\.+|\.+$/g, "")}`;
145
146const readStoredSourceOAuthPopupResult = (
147 sessionId: string,
148): SourceOAuthPopupMessage | null => {
135 if (typeof window === "undefined") {149 if (typeof window === "undefined") {
136 return null;150 return null;
137 }151 }
@@ -155,7 +169,9 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines
155 return;169 return;
156 }170 }
157 171
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 );
159};175};
160 176
161const startSourceOAuthPopup = async (input: {177const startSourceOAuthPopup = async (input: {
@@ -180,110 +196,130 @@ const startSourceOAuthPopup = async (input: { · 18 unmodified lines
180 196
181 popup.focus();197 popup.focus();
182 198
183 return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {199 return await new Promise<CompleteSourceOAuthResult["auth"]>(
184 let settled = false;200 (resolve, reject) => {
185 let closedPoll = 0;201 let settled = false;
186 let resultTimeout = 0;202 let closedPoll = 0;
187 203 let resultTimeout = 0;
188 const cleanup = () => {204
189 window.removeEventListener("message", onMessage);205 const cleanup = () => {
190 if (closedPoll) {206 window.removeEventListener("message", onMessage);
191 window.clearInterval(closedPoll);207 if (closedPoll) {
192 }208 window.clearInterval(closedPoll);
193 if (resultTimeout) {209 }
194 window.clearTimeout(resultTimeout);210 if (resultTimeout) {
195 }211 window.clearTimeout(resultTimeout);
196 if (!popup.closed) {212 }
197 popup.close();213 if (!popup.closed) {
198 }214 popup.close();
199 clearStoredSourceOAuthPopupResult(input.sessionId);215 }
200 };216 clearStoredSourceOAuthPopupResult(input.sessionId);
201 217 };
202 const settleWithError = (message: string) => {218
203 if (settled) {219 const settleWithError = (message: string) => {
204 return;220 if (settled) {
205 }221 return;
206 222 }
207 settled = true;223
208 cleanup();224 settled = true;
209 reject(new Error(message));225 cleanup();
210 };226 reject(new Error(message));
211 227 };
212 const settleFromPayload = (data: SourceOAuthPopupMessage) => {228
213 if (!data.ok) {229 const settleFromPayload = (data: SourceOAuthPopupMessage) => {
214 settleWithError(data.error || "OAuth failed");230 if (!data.ok) {
215 return;231 settleWithError(data.error || "OAuth failed");
216 }232 return;
217 233 }
218 if (settled) {234
219 return;235 if (settled) {
220 }236 return;
221 237 }
222 settled = true;238
223 cleanup();239 settled = true;
224 resolve(data.auth);240 cleanup();
225 };241 resolve(data.auth);
226 242 };
227 const onMessage = (event: MessageEvent) => {243
228 if (event.origin !== window.location.origin) {244 const onMessage = (event: MessageEvent) => {
229 return;245 if (event.origin !== window.location.origin) {
230 }246 return;
231 247 }
232 const data = event.data as SourceOAuthPopupMessage | undefined;248
233 if (!data || data.type !== "executor:oauth-result") {249 const data = event.data as SourceOAuthPopupMessage | undefined;
234 return;250 if (!data || data.type !== "executor:oauth-result") {
235 }251 return;
236 252 }
237 if (data.ok && data.sessionId !== input.sessionId) {253
238 return;254 if (data.ok && data.sessionId !== input.sessionId) {
239 }255 return;
240 256 }
241 if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) {257
242 return;258 if (
243 }259 !data.ok &&
244 260 data.sessionId !== null &&
245 settleFromPayload(data);261 data.sessionId !== input.sessionId
246 };262 ) {
247 263 return;
248 window.addEventListener("message", onMessage);264 }
249 265
250 resultTimeout = window.setTimeout(() => {266 settleFromPayload(data);
251 settleWithError("OAuth popup timed out before completion. Please try again.");267 };
252 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);268
253 269 window.addEventListener("message", onMessage);
254 closedPoll = window.setInterval(() => {270
255 const stored = readStoredSourceOAuthPopupResult(input.sessionId);271 resultTimeout = window.setTimeout(() => {
256 if (stored) {272 settleWithError(
257 settleFromPayload(stored);273 "OAuth popup timed out before completion. Please try again.",
258 return;274 );
259 }275 }, SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS);
260 if (popup.closed) {276
261 // Stop polling — only run one final deferred check to give the277 closedPoll = window.setInterval(() => {
262 // callback page time to write localStorage before we give up.278 const stored = readStoredSourceOAuthPopupResult(input.sessionId);
263 window.clearInterval(closedPoll);279 if (stored) {
264 closedPoll = 0;280 settleFromPayload(stored);
265 window.setTimeout(() => {281 return;
266 const delayedStored = readStoredSourceOAuthPopupResult(input.sessionId);282 }
267 if (delayedStored) {283 if (popup.closed) {
268 settleFromPayload(delayedStored);284 // Stop polling — only run one final deferred check to give the
269 return;285 // callback page time to write localStorage before we give up.
270 }286 window.clearInterval(closedPoll);
271 settleWithError("OAuth popup was closed before completion.");287 closedPoll = 0;
272 }, 1500);288 window.setTimeout(() => {
273 }289 const delayedStored = readStoredSourceOAuthPopupResult(
274 }, 300);290 input.sessionId,
275 });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 );
276};302};
277 303
278const stringMapToEditor = (value: Record<string, string> | null): string =>304const stringMapToEditor = (value: Record<string, string> | null): string =>
279 value === null ? "" : JSON.stringify(value, null, 2);305 value === null ? "" : JSON.stringify(value, null, 2);
280 306
307const stringArrayToEditor = (
308 value: ReadonlyArray<string> | null | undefined,
309): string =>
310 !value || value.length === 0 ? "" : JSON.stringify(value, null, 2);
311
281const readBindingStringMap = (312const readBindingStringMap = (
282 source: Source,313 source: Source,
283 key: string,314 key: string,
284): Record<string, string> | null => {315): Record<string, string> | null => {
285 const candidate = source.binding[key];316 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 ) {
287 return null;323 return null;
288 }324 }
289 325
@@ -293,29 +329,103 @@ const readBindingStringMap = ( · 3 unmodified lines
293 : null;329 : null;
294};330};
295 331
332const 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
296const readBindingString = (source: Source, key: string): string =>343const readBindingString = (source: Source, key: string): string =>
297 typeof source.binding[key] === "string" ? String(source.binding[key]) : "";344 typeof source.binding[key] === "string" ? String(source.binding[key]) : "";
298 345
299const readBindingTransport = (source: Source): TransportValue => {346const readBindingTransport = (source: Source): TransportValue => {
300 const candidate = source.binding.transport;347 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")
302 ? candidate353 ? candidate
303 : "";354 : "";
304};355};
305 356
357const 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
387const 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
306const defaultFormState = (template?: SourceTemplate): SourceFormState => ({405const defaultFormState = (template?: SourceTemplate): SourceFormState => ({
307 name: template?.name ?? "",406 name: template?.name ?? "",
308 kind: template?.kind ?? "openapi",407 kind: template?.kind ?? "openapi",
309 endpoint: template?.endpoint ?? "",408 endpoint: template?.endpoint ?? "",
310 namespace: template409 namespace: template
311 ? "service" in template410 ? (template.namespace ??
312 ? googleDiscoveryNamespace(template.service)411 ("service" in template
313 : namespaceFromUrl(template.endpoint)412 ? googleDiscoveryNamespace(template.service)
413 : namespaceFromUrl(template.endpoint ?? "")))
314 : "",414 : "",
315 enabled: true,415 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 : "",
317 queryParamsText: "",422 queryParamsText: "",
318 headersText: "",423 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 ?? "") : "",
319 specUrl: template && "specUrl" in template ? template.specUrl : "",429 specUrl: template && "specUrl" in template ? template.specUrl : "",
320 service: template && "service" in template ? template.service : "",430 service: template && "service" in template ? template.service : "",
321 version: template && "version" in template ? template.version : "",431 version: template && "version" in template ? template.version : "",
@@ -338,13 +448,22 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 16 unmodified lines
338 endpoint: source.endpoint,448 endpoint: source.endpoint,
339 namespace: source.namespace ?? "",449 namespace: source.namespace ?? "",
340 enabled: source.enabled,450 enabled: source.enabled,
341 transport: source.kind === "mcp" ? (readBindingTransport(source) || "auto") : "",451 transport:
342 queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")),452 source.kind === "mcp" ? readBindingTransport(source) || "auto" : "",
453 queryParamsText: stringMapToEditor(
454 readBindingStringMap(source, "queryParams"),
455 ),
343 headersText: stringMapToEditor(readBindingStringMap(source, "headers")),456 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"),
344 specUrl: readBindingString(source, "specUrl"),461 specUrl: readBindingString(source, "specUrl"),
345 service: readBindingString(source, "service"),462 service: readBindingString(source, "service"),
346 version: readBindingString(source, "version"),463 version: readBindingString(source, "version"),
347 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),464 defaultHeadersText: stringMapToEditor(
465 readBindingStringMap(source, "defaultHeaders"),
466 ),
348 authKind: source.auth.kind,467 authKind: source.auth.kind,
349 authHeaderName:468 authHeaderName:
350 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"469 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
@@ -354,10 +473,13 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 3 unmodified lines
354 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"473 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
355 ? "Bearer "474 ? "Bearer "
356 : source.auth.prefix,475 : source.auth.prefix,
357 bearerProviderId: source.auth.kind === "bearer" ? source.auth.token.providerId : "",476 bearerProviderId:
477 source.auth.kind === "bearer" ? source.auth.token.providerId : "",
358 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",478 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",
359 oauthAccessProviderId: source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",479 oauthAccessProviderId:
360 oauthAccessHandle: source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "",480 source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
481 oauthAccessHandle:
482 source.auth.kind === "oauth2" ? source.auth.accessToken.handle : "",
361 oauthRefreshProviderId:483 oauthRefreshProviderId:
362 source.auth.kind === "oauth2" && source.auth.refreshToken !== null484 source.auth.kind === "oauth2" && source.auth.refreshToken !== null
363 ? source.auth.refreshToken.providerId485 ? source.auth.refreshToken.providerId
@@ -367,12 +489,16 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 3 unmodified lines
367 ? source.auth.refreshToken.handle489 ? source.auth.refreshToken.handle
368 : "",490 : "",
369 managedAuth:491 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"
371 ? source.auth494 ? source.auth
372 : null,495 : null,
373});496});
374 497
375const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {498const parseJsonStringMap = (
499 label: string,
500 text: string,
501): Record<string, string> | null => {
376 const trimmed = text.trim();502 const trimmed = text.trim();
377 if (trimmed.length === 0) {503 if (trimmed.length === 0) {
378 return null;504 return null;
@@ -401,12 +527,18 @@ const parseJsonStringMap = (label: string, text: string): Record<string, string> · 22 unmodified lines
401 return Object.keys(normalized).length === 0 ? null : normalized;527 return Object.keys(normalized).length === 0 ? null : normalized;
402};528};
403 529
404const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] => {530const buildAuthPayload = (
531 state: SourceFormState,
532): CreateSourcePayload["auth"] => {
405 if (state.authKind === "none") {533 if (state.authKind === "none") {
406 return { kind: "none" };534 return { kind: "none" };
407 }535 }
408 536
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 ) {
410 return state.managedAuth;542 return state.managedAuth;
411 }543 }
412 544
@@ -417,7 +549,9 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 4 unmodified lines
417 const providerId = state.bearerProviderId.trim();549 const providerId = state.bearerProviderId.trim();
418 const handle = state.bearerHandle.trim();550 const handle = state.bearerHandle.trim();
419 if (!providerId || !handle) {551 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 );
421 }555 }
422 556
423 return {557 return {
@@ -434,13 +568,17 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 10 unmodified lines
434 const accessProviderId = state.oauthAccessProviderId.trim();568 const accessProviderId = state.oauthAccessProviderId.trim();
435 const accessHandle = state.oauthAccessHandle.trim();569 const accessHandle = state.oauthAccessHandle.trim();
436 if (!accessProviderId || !accessHandle) {570 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 );
438 }574 }
439 575
440 const refreshProviderId = trimToNull(state.oauthRefreshProviderId);576 const refreshProviderId = trimToNull(state.oauthRefreshProviderId);
441 const refreshHandle = trimToNull(state.oauthRefreshHandle);577 const refreshHandle = trimToNull(state.oauthRefreshHandle);
442 if ((refreshProviderId === null) !== (refreshHandle === null)) {578 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 );
444 }582 }
445 583
446 return {584 return {
@@ -461,8 +599,14 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 14 unmodified lines
461 };599 };
462};600};
463 601
464const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload["status"] => {602const buildRequestedSourceStatus = (
465 if (state.kind !== "mcp" && state.kind !== "openapi" && state.kind !== "graphql") {603 state: SourceFormState,
604): CreateSourcePayload["status"] => {
605 if (
606 state.kind !== "mcp" &&
607 state.kind !== "openapi" &&
608 state.kind !== "graphql"
609 ) {
466 return undefined;610 return undefined;
467 }611 }
468 612
@@ -471,7 +615,14 @@ const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload · 2 unmodified lines
471 615
472const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {616const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => {
473 const name = state.name.trim();617 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();
475 626
476 if (!name) {627 if (!name) {
477 throw new Error("Source name is required.");628 throw new Error("Source name is required.");
@@ -480,6 +631,9 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
480 if (!endpoint) {631 if (!endpoint) {
481 throw new Error("Source endpoint is required.");632 throw new Error("Source endpoint is required.");
482 }633 }
634 if (isMcpStdio && !state.command.trim()) {
635 throw new Error("MCP stdio transport requires a command.");
636 }
483 637
484 const shared = {638 const shared = {
485 name,639 name,
@@ -488,16 +642,35 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
488 status: buildRequestedSourceStatus(state),642 status: buildRequestedSourceStatus(state),
489 enabled: state.enabled,643 enabled: state.enabled,
490 namespace: trimToNull(state.namespace),644 namespace: trimToNull(state.namespace),
491 auth: buildAuthPayload(state),645 auth: isMcpStdio ? { kind: "none" as const } : buildAuthPayload(state),
492 } satisfies Pick<CreateSourcePayload, "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth">;646 } satisfies Pick<
647 CreateSourcePayload,
648 "name" | "kind" | "endpoint" | "status" | "enabled" | "namespace" | "auth"
649 >;
493 650
494 if (state.kind === "mcp") {651 if (state.kind === "mcp") {
495 return {652 return {
496 ...shared,653 ...shared,
497 binding: {654 binding: {
498 transport: state.transport === "" ? "auto" : state.transport,655 transport: state.transport === "" ? "auto" : state.transport,
499 queryParams: parseJsonStringMap("Query params", state.queryParamsText),656 queryParams:
500 headers: parseJsonStringMap("Request headers", state.headersText),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,
501 },674 },
502 };675 };
503 }676 }
@@ -512,7 +685,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 8 unmodified lines
512 ...shared,685 ...shared,
513 binding: {686 binding: {
514 specUrl,687 specUrl,
515 defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),688 defaultHeaders: parseJsonStringMap(
689 "Default headers",
690 state.defaultHeadersText,
691 ),
516 },692 },
517 };693 };
518 }694 }
@@ -521,7 +697,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 2 unmodified lines
521 return {697 return {
522 ...shared,698 ...shared,
523 binding: {699 binding: {
524 defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),700 defaultHeaders: parseJsonStringMap(
701 "Default headers",
702 state.defaultHeadersText,
703 ),
525 },704 },
526 };705 };
527 }706 }
@@ -542,7 +721,10 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 14 unmodified lines
542 service,721 service,
543 version,722 version,
544 discoveryUrl: endpoint,723 discoveryUrl: endpoint,
545 defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),724 defaultHeaders: parseJsonStringMap(
725 "Default headers",
726 state.defaultHeadersText,
727 ),
546 },728 },
547 };729 };
548 }730 }
@@ -557,10 +739,15 @@ const buildUpdatePayload = (state: SourceFormState): UpdateSourcePayload => ({ · 8 unmodified lines
557 ...buildSourcePayload(state),739 ...buildSourcePayload(state),
558});740});
559 741
560const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => {742const buildStartSourceOAuthPayload = (
743 state: SourceFormState,
744): StartSourceOAuthPayload => {
561 if (state.kind !== "mcp") {745 if (state.kind !== "mcp") {
562 throw new Error("OAuth sign-in is only available for MCP sources.");746 throw new Error("OAuth sign-in is only available for MCP sources.");
563 }747 }
748 if (state.transport === "stdio") {
749 throw new Error("OAuth sign-in is not available for MCP stdio sources.");
750 }
564 751
565 const endpoint = state.endpoint.trim();752 const endpoint = state.endpoint.trim();
566 if (!endpoint) {753 if (!endpoint) {
@@ -585,9 +772,9 @@ export function EditSourcePage(props: { sourceId: string }) { · 18 unmodified lines
585 const sources = useSources();772 const sources = useSources();
586 const source = useSource(props.sourceId);773 const source = useSource(props.sourceId);
587 const missingSource =774 const missingSource =
588 (sources.status === "ready"775 (sources.status === "ready" &&
589 && !sources.data.some((candidate) => candidate.id === props.sourceId))776 !sources.data.some((candidate) => candidate.id === props.sourceId)) ||
590 || isSourceNotFoundLoadable(source);777 isSourceNotFoundLoadable(source);
591 778
592 if (missingSource) {779 if (missingSource) {
593 return <SourceNotFoundState />;780 return <SourceNotFoundState />;
@@ -619,23 +806,35 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 25 unmodified lines
619 const [formState, setFormState] = useState<SourceFormState>(() =>806 const [formState, setFormState] = useState<SourceFormState>(() =>
620 props.source ? formStateFromSource(props.source) : defaultFormState(),807 props.source ? formStateFromSource(props.source) : defaultFormState(),
621 );808 );
622 const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(null);809 const [statusBanner, setStatusBanner] = useState<StatusBannerState | null>(
623 const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);810 null,
811 );
812 const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
813 null,
814 );
624 const [oauthPopupBusy, setOauthPopupBusy] = useState(false);815 const [oauthPopupBusy, setOauthPopupBusy] = useState(false);
625 const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] = useState<string | null>(null);816 const [expandedOauthSecretRefTarget, setExpandedOauthSecretRefTarget] =
817 useState<string | null>(null);
626 818
627 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";819 const isSubmitting =
820 createSource.status === "pending" || updateSource.status === "pending";
628 const isDeleting = removeSource.status === "pending";821 const isDeleting = removeSource.status === "pending";
629 const isRevokingGrant = removeProviderAuthGrant.status === "pending";822 const isRevokingGrant = removeProviderAuthGrant.status === "pending";
630 const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy;823 const isOAuthSubmitting =
824 startSourceOAuth.status === "pending" || oauthPopupBusy;
631 const oauthSecretRefTarget =825 const oauthSecretRefTarget =
632 formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0826 formState.authKind === "oauth2" &&
827 formState.oauthAccessHandle.trim().length > 0
633 ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}`828 ? `${formState.oauthAccessProviderId}:${formState.oauthAccessHandle}:${formState.oauthRefreshProviderId}:${formState.oauthRefreshHandle}`
634 : null;829 : null;
635 const showOauthSecretRefs =830 const showOauthSecretRefs =
636 oauthSecretRefTarget !== null && expandedOauthSecretRefTarget === oauthSecretRefTarget;831 oauthSecretRefTarget !== null &&
832 expandedOauthSecretRefTarget === oauthSecretRefTarget;
637 833
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 ) => {
639 setFormState((current) => ({ ...current, [key]: value }));838 setFormState((current) => ({ ...current, [key]: value }));
640 };839 };
641 840
@@ -657,7 +856,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 15 unmodified lines
657 856
658 try {857 try {
659 if (props.mode === "create") {858 if (props.mode === "create") {
660 const createdSource = await createSource.mutateAsync(buildSourcePayload(formState));859 const createdSource = await createSource.mutateAsync(
860 buildSourcePayload(formState),
861 );
661 void navigate({862 void navigate({
662 to: "/sources/$sourceId",863 to: "/sources/$sourceId",
663 params: { sourceId: createdSource.id },864 params: { sourceId: createdSource.id },
@@ -691,7 +892,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 27 unmodified lines
691 setStatusBanner(null);892 setStatusBanner(null);
692 893
693 try {894 try {
694 const result = await startSourceOAuth.mutateAsync(buildStartSourceOAuthPayload(formState));895 const result = await startSourceOAuth.mutateAsync(
896 buildStartSourceOAuthPayload(formState),
897 );
695 898
696 setStatusBanner({899 setStatusBanner({
697 tone: "info",900 tone: "info",
@@ -735,7 +938,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 37 unmodified lines
735 return;938 return;
736 }939 }
737 940
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 );
739 if (!confirmed) {944 if (!confirmed) {
740 return;945 return;
741 }946 }
@@ -751,16 +956,17 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 9 unmodified lines
751 } catch (error) {956 } catch (error) {
752 setStatusBanner({957 setStatusBanner({
753 tone: "error",958 tone: "error",
754 text: error instanceof Error ? error.message : "Failed removing source.",959 text:
960 error instanceof Error ? error.message : "Failed removing source.",
755 });961 });
756 }962 }
757 };963 };
758 964
759 const handleRevokeProviderGrant = async () => {965 const handleRevokeProviderGrant = async () => {
760 if (966 if (
761 !props.source967 !props.source ||
762 || isRevokingGrant968 isRevokingGrant ||
763 || formState.managedAuth?.kind !== "provider_grant_ref"969 formState.managedAuth?.kind !== "provider_grant_ref"
764 ) {970 ) {
765 return;971 return;
766 }972 }
@@ -775,7 +981,9 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 8 unmodified lines
775 setStatusBanner(null);981 setStatusBanner(null);
776 982
777 try {983 try {
778 const result = await removeProviderAuthGrant.mutateAsync(formState.managedAuth.grantId);984 const result = await removeProviderAuthGrant.mutateAsync(
985 formState.managedAuth.grantId,
986 );
779 if (!result.removed) {987 if (!result.removed) {
780 throw new Error("Shared provider grant was not removed.");988 throw new Error("Shared provider grant was not removed.");
781 }989 }
@@ -787,14 +995,22 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 5 unmodified lines
787 } catch (error) {995 } catch (error) {
788 setStatusBanner({996 setStatusBanner({
789 tone: "error",997 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.",
791 });1002 });
792 }1003 }
793 };1004 };
794 1005
795 const backLink = props.mode === "edit" && props.source1006 const backLink =
796 ? { to: "/sources/$sourceId" as const, params: { sourceId: props.source.id }, search: { tab: "model" as const } }1007 props.mode === "edit" && props.source
797 : { to: "/" as const };1008 ? {
1009 to: "/sources/$sourceId" as const,
1010 params: { sourceId: props.source.id },
1011 search: { tab: "model" as const },
1012 }
1013 : { to: "/" as const };
798 1014
799 return (1015 return (
800 <div className="flex-1 overflow-y-auto">1016 <div className="flex-1 overflow-y-auto">
@@ -841,11 +1057,19 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 40 unmodified lines
841 <div className="mb-1 flex items-center justify-between gap-2">1057 <div className="mb-1 flex items-center justify-between gap-2">
842 <div className="flex min-w-0 items-center gap-2">1058 <div className="flex min-w-0 items-center gap-2">
843 <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">1059 <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 />
845 </div>1065 </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>
847 </div>1069 </div>
848 <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>1070 <Badge variant="outline" className="text-[9px]">
1071 {template.kind}
1072 </Badge>
849 </div>1073 </div>
850 <span className="text-[11px] text-muted-foreground line-clamp-1">1074 <span className="text-[11px] text-muted-foreground line-clamp-1">
851 {template.summary}1075 {template.summary}
@@ -870,29 +1094,35 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 18 unmodified lines
870 <Field label="Kind">1094 <Field label="Kind">
871 <SelectInput1095 <SelectInput
872 value={formState.kind}1096 value={formState.kind}
873 onChange={(value) => setField("kind", value as Source["kind"])}1097 onChange={(value) =>
874 options={kindOptions.map((value) => ({ value, label: value }))}1098 setField("kind", value as Source["kind"])
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"
892 }1099 }
893 mono1100 options={kindOptions.map((value) => ({
1101 value,
1102 label: value,
1103 }))}
894 />1104 />
895 </Field>1105 </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 )}
896 <Field label="Namespace">1126 <Field label="Namespace">
897 <TextInput1127 <TextInput
898 value={formState.namespace}1128 value={formState.namespace}
@@ -915,26 +1145,70 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 16 unmodified lines
915 <Field label="Transport mode">1145 <Field label="Transport mode">
916 <SelectInput1146 <SelectInput
917 value={formState.transport || "auto"}1147 value={formState.transport || "auto"}
918 onChange={(value) => setField("transport", value as TransportValue)}1148 onChange={(value) =>
919 options={transportOptions.map((value) => ({ value, label: value }))}1149 setField("transport", value as TransportValue)
1150 }
1151 options={transportOptions.map((value) => ({
1152 value,
1153 label: value,
1154 }))}
920 />1155 />
921 </Field>1156 </Field>
922 <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">1157 {formState.transport === "stdio" ? (
923 <Field label="Query params (JSON)">1158 <div className="sm:col-span-2 grid gap-4 sm:grid-cols-2">
924 <CodeEditor1159 <Field label="Command">
925 value={formState.queryParamsText}1160 <TextInput
926 onChange={(value) => setField("queryParamsText", value)}1161 value={formState.command}
927 placeholder={'{\n "workspace": "demo"\n}'}1162 onChange={(value) => setField("command", value)}
928 />1163 placeholder="npx"
929 </Field>1164 mono
930 <Field label="Headers (JSON)">1165 />
931 <CodeEditor1166 </Field>
932 value={formState.headersText}1167 <Field label="Working directory (optional)">
933 onChange={(value) => setField("headersText", value)}1168 <TextInput
934 placeholder={'{\n "x-api-key": "..."\n}'}1169 value={formState.cwd}
935 />1170 onChange={(value) => setField("cwd", value)}
936 </Field>1171 placeholder="/path/to/project"
937 </div>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 )}
938 </div>1212 </div>
939 </Section>1213 </Section>
940 )}1214 )}
@@ -1005,20 +1279,38 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 64 unmodified lines
1005 1279
1006 <Section title="Authentication">1280 <Section title="Authentication">
1007 <div className="grid gap-4 sm:grid-cols-2">1281 <div className="grid gap-4 sm:grid-cols-2">
1008 {formState.kind === "mcp" && (1282 {formState.kind === "mcp" && formState.transport !== "stdio" && (
1009 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">1283 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">
1010 <div className="flex items-start gap-3 px-4 py-3.5">1284 <div className="flex items-start gap-3 px-4 py-3.5">
1011 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">1285 <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">1286 <svg
1013 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />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 />
1014 <path d="M7 11V7a5 5 0 0110 0v4" />1303 <path d="M7 11V7a5 5 0 0110 0v4" />
1015 </svg>1304 </svg>
1016 </div>1305 </div>
1017 <div className="flex-1 min-w-0 space-y-2">1306 <div className="flex-1 min-w-0 space-y-2">
1018 <div className="space-y-0.5">1307 <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>
1020 <p className="text-[11px] text-muted-foreground">1311 <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.
1022 </p>1314 </p>
1023 </div>1315 </div>
1024 <Button1316 <Button
@@ -1026,155 +1318,219 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 1 unmodified line
1026 variant="outline"1318 variant="outline"
1027 size="sm"1319 size="sm"
1028 onClick={handleMcpOAuthConnect}1320 onClick={handleMcpOAuthConnect}
1029 disabled={isSubmitting || isDeleting || isOAuthSubmitting}1321 disabled={
1322 isSubmitting || isDeleting || isOAuthSubmitting
1323 }
1030 >1324 >
1031 {isOAuthSubmitting ? <IconSpinner className="size-3" /> : null}1325 {isOAuthSubmitting ? (
1032 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 01326 <IconSpinner className="size-3" />
1327 ) : null}
1328 {formState.authKind === "oauth2" &&
1329 formState.oauthAccessHandle.trim().length > 0
1033 ? "Reconnect"1330 ? "Reconnect"
1034 : "Sign in with OAuth"}1331 : "Sign in with OAuth"}
1035 </Button>1332 </Button>
1036 </div>1333 </div>
1037 </div>1334 </div>
1038 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 && (1335 {formState.authKind === "oauth2" &&
1039 <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">1336 formState.oauthAccessHandle.trim().length > 0 && (
1040 <p className="text-[11px] text-muted-foreground/70">1337 <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
1041 Token refs attached to this draft1338 <p className="text-[11px] text-muted-foreground/70">
1042 </p>1339 Token refs attached to this draft
1043 <button1340 </p>
1044 type="button"1341 <button
1045 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"1342 type="button"
1046 onClick={() =>1343 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1047 setExpandedOauthSecretRefTarget((current) =>1344 onClick={() =>
1048 current === oauthSecretRefTarget ? null : oauthSecretRefTarget,1345 setExpandedOauthSecretRefTarget((current) =>
1049 )}1346 current === oauthSecretRefTarget
1050 >1347 ? null
1051 {showOauthSecretRefs ? "Hide refs" : "Show refs"}1348 : oauthSecretRefTarget,
1052 </button>1349 )
1053 </div>1350 }
1054 )}1351 >
1352 {showOauthSecretRefs ? "Hide refs" : "Show refs"}
1353 </button>
1354 </div>
1355 )}
1055 </div>1356 </div>
1056 )}1357 )}
1057 <Field label="Auth mode">1358 {formState.kind === "mcp" && formState.transport === "stdio" ? (
1058 <SelectInput1359 <div className="sm:col-span-2 rounded-xl border border-border bg-muted/40 px-4 py-3 text-[12px] text-muted-foreground">
1059 value={formState.authKind}1360 Local MCP stdio sources do not use executor-managed HTTP or
1060 onChange={(value) => setField("authKind", value as Source["auth"]["kind"])}1361 OAuth credentials.
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 )}
1109 </div>1362 </div>
1110 )}1363 ) : (
1111 {formState.authKind !== "none" && formState.managedAuth === null && (1364 <Field label="Auth mode">
1112 <>1365 <SelectInput
1113 <Field label="Header name">1366 value={formState.authKind}
1114 <TextInput1367 onChange={(value) =>
1115 value={formState.authHeaderName}1368 setField("authKind", value as Source["auth"]["kind"])
1116 onChange={(value) => setField("authHeaderName", value)}1369 }
1117 placeholder="Authorization"1370 disabled={formState.managedAuth !== null}
1118 />1371 options={authOptions.map((value) => ({
1119 </Field>1372 value,
1120 <Field label="Prefix">1373 label: value,
1121 <TextInput1374 }))}
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 }}
1141 />1375 />
1142 </Field>1376 </Field>
1143 )}1377 )}
1144 1378 {formState.managedAuth !== null &&
1145 {formState.authKind === "oauth2"1379 !(
1146 && formState.managedAuth === null1380 formState.kind === "mcp" && formState.transport === "stdio"
1147 && (formState.kind !== "mcp"1381 ) && (
1148 || formState.oauthAccessHandle.trim().length === 01382 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden">
1149 || showOauthSecretRefs) && (1383 <div className="flex items-start gap-3 px-4 py-3.5">
1150 <>1384 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1151 <Field label="Access token" className="sm:col-span-2">1385 {formState.managedAuth.kind === "provider_grant_ref" ? (
1152 <SecretPicker1386 <svg
1153 instanceConfig={instanceConfig}1387 className="size-4"
1154 secrets={secrets}1388 viewBox="0 0 24 24"
1155 providerId={formState.oauthAccessProviderId}1389 fill="none"
1156 handle={formState.oauthAccessHandle}1390 stroke="currentColor"
1157 onSelect={(providerId, handle) => {1391 strokeWidth="1.5"
1158 setField("oauthAccessProviderId", providerId);1392 strokeLinecap="round"
1159 setField("oauthAccessHandle", handle);1393 strokeLinejoin="round"
1160 }}1394 >
1161 />1395 <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
1162 </Field>1396 <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
1163 <Field label="Refresh token (optional)" className="sm:col-span-2">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">
1164 <SecretPicker1482 <SecretPicker
1165 instanceConfig={instanceConfig}1483 instanceConfig={instanceConfig}
1166 secrets={secrets}1484 secrets={secrets}
1167 providerId={formState.oauthRefreshProviderId}1485 providerId={formState.bearerProviderId}
1168 handle={formState.oauthRefreshHandle}1486 handle={formState.bearerHandle}
1169 onSelect={(providerId, handle) => {1487 onSelect={(providerId, handle) => {
1170 setField("oauthRefreshProviderId", providerId);1488 setField("bearerProviderId", providerId);
1171 setField("oauthRefreshHandle", handle);1489 setField("bearerHandle", handle);
1172 }}1490 }}
1173 allowEmpty
1174 />1491 />
1175 </Field>1492 </Field>
1176 </>1493 )}
1177 )}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 )}
1178 </div>1534 </div>
1179 </Section>1535 </Section>
1180 1536
@@ -1196,10 +1552,19 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 15 unmodified lines
1196 {/* Actions */}1552 {/* Actions */}
1197 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">1553 <div className="flex items-center justify-end gap-3 border-t border-border pt-5">
1198 <Link {...backLink} className="inline-flex">1554 <Link {...backLink} className="inline-flex">
1199 <Button variant="ghost" type="button">Cancel</Button>1555 <Button variant="ghost" type="button">
1556 Cancel
1557 </Button>
1200 </Link>1558 </Link>
1201 <Button onClick={handleSubmit} disabled={isSubmitting || isRevokingGrant}>1559 <Button
1202 {props.mode === "edit" ? <IconPencil className="size-3.5" /> : <IconPlus className="size-3.5" />}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 )}
1203 {isSubmitting1568 {isSubmitting
1204 ? props.mode === "edit"1569 ? props.mode === "edit"
1205 ? "Saving\u2026"1570 ? "Saving\u2026"
@@ -1219,9 +1584,18 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 13 unmodified lines
1219// Form building blocks1584// Form building blocks
1220// ---------------------------------------------------------------------------1585// ---------------------------------------------------------------------------
1221 1586
1222function Section(props: { title: string; children: ReactNode; className?: string }) {1587function Section(props: {
1588 title: string;
1589 children: ReactNode;
1590 className?: string;
1591}) {
1223 return (1592 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 >
1225 <div className="border-b border-border px-5 py-3">1599 <div className="border-b border-border px-5 py-3">
1226 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>1600 <h2 className="text-sm font-semibold text-foreground">{props.title}</h2>
1227 </div>1601 </div>
@@ -1237,7 +1611,9 @@ function Field(props: { · 9 unmodified lines
1237}) {1611}) {
1238 return (1612 return (
1239 <label className={cn("block space-y-1.5", props.className)}>1613 <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>
1241 {props.children}1617 {props.children}
1242 </label>1618 </label>
1243 );1619 );
@@ -1300,7 +1676,10 @@ function CodeEditor(props: { · 56 unmodified lines
1300 );1676 );
1301}1677}
1302 1678
1303function ToggleButton(props: { checked: boolean; onChange: (checked: boolean) => void }) {1679function ToggleButton(props: {
1680 checked: boolean;
1681 onChange: (checked: boolean) => void;
1682}) {
1304 return (1683 return (
1305 <button1684 <button
1306 type="button"1685 type="button"
@@ -1328,9 +1707,12 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 21 unmodified lines
1328 <div1707 <div
1329 className={cn(1708 className={cn(
1330 "rounded-lg border px-4 py-3 text-[13px]",1709 "rounded-lg border px-4 py-3 text-[13px]",
1331 props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",1710 props.state.tone === "success" &&
1332 props.state.tone === "info" && "border-border bg-card text-muted-foreground",1711 "border-primary/30 bg-primary/8 text-foreground",
1333 props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",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",
1334 props.className,1716 props.className,
1335 )}1717 )}
1336 >1718 >
@@ -1349,7 +1731,8 @@ function SecretPicker(props: { · 12 unmodified lines
1349 onSelect: (providerId: string, handle: string) => void;1731 onSelect: (providerId: string, handle: string) => void;
1350 allowEmpty?: boolean;1732 allowEmpty?: boolean;
1351}) {1733}) {
1352 const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } = props;1734 const { instanceConfig, secrets, providerId, handle, onSelect, allowEmpty } =
1735 props;
1353 const createSecret = useCreateSecret();1736 const createSecret = useCreateSecret();
1354 const [showCreate, setShowCreate] = useState(false);1737 const [showCreate, setShowCreate] = useState(false);
1355 const [newName, setNewName] = useState("");1738 const [newName, setNewName] = useState("");
@@ -1358,7 +1741,9 @@ function SecretPicker(props: { · 2 unmodified lines
1358 const [createError, setCreateError] = useState<string | null>(null);1741 const [createError, setCreateError] = useState<string | null>(null);
1359 const storableProviders =1742 const storableProviders =
1360 instanceConfig.status === "ready"1743 instanceConfig.status === "ready"
1361 ? instanceConfig.data.secretProviders.filter((provider) => provider.canStore)1744 ? instanceConfig.data.secretProviders.filter(
1745 (provider) => provider.canStore,
1746 )
1362 : [];1747 : [];
1363 1748
1364 useEffect(() => {1749 useEffect(() => {
@@ -1390,9 +1775,10 @@ function SecretPicker(props: { · 25 unmodified lines
1390 onSelect("", "");1775 onSelect("", "");
1391 return;1776 return;
1392 }1777 }
1393 const matchedSecret = secrets.status === "ready"1778 const matchedSecret =
1394 ? secrets.data.find((secret) => secret.id === value)1779 secrets.status === "ready"
1395 : null;1780 ? secrets.data.find((secret) => secret.id === value)
1781 : null;
1396 onSelect(matchedSecret?.providerId ?? "local", value);1782 onSelect(matchedSecret?.providerId ?? "local", value);
1397 };1783 };
1398 1784
@@ -1417,7 +1803,9 @@ function SecretPicker(props: { · 18 unmodified lines
1417 onSelect(result.providerId, result.id);1803 onSelect(result.providerId, result.id);
1418 setShowCreate(false);1804 setShowCreate(false);
1419 } catch (err) {1805 } catch (err) {
1420 setCreateError(err instanceof Error ? err.message : "Failed creating secret.");1806 setCreateError(
1807 err instanceof Error ? err.message : "Failed creating secret.",
1808 );
1421 }1809 }
1422 };1810 };
1423 1811
@@ -1446,7 +1834,9 @@ function SecretPicker(props: { · 22 unmodified lines
1446 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"1834 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"
1447 >1835 >
1448 {allowEmpty && <option value="">None</option>}1836 {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 )}
1450 {items.map((secret) => (1840 {items.map((secret) => (
1451 <option key={secret.id} value={secret.id}>1841 <option key={secret.id} value={secret.id}>
1452 {secret.name || secret.id} ({secret.providerId})1842 {secret.name || secret.id} ({secret.providerId})
@@ -1469,7 +1859,9 @@ function SecretPicker(props: { · 16 unmodified lines
1469 )}1859 )}
1470 <div className="grid gap-3 sm:grid-cols-2">1860 <div className="grid gap-3 sm:grid-cols-2">
1471 <label className="block space-y-1">1861 <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>
1473 <input1865 <input
1474 value={newName}1866 value={newName}
1475 onChange={(e) => setNewName(e.target.value)}1867 onChange={(e) => setNewName(e.target.value)}
@@ -1479,7 +1871,9 @@ function SecretPicker(props: { · 3 unmodified lines
1479 />1871 />
1480 </label>1872 </label>
1481 <label className="block space-y-1">1873 <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>
1483 <input1877 <input
1484 type="password"1878 type="password"
1485 value={newValue}1879 value={newValue}
@@ -1489,7 +1883,9 @@ function SecretPicker(props: { · 3 unmodified lines
1489 />1883 />
1490 </label>1884 </label>
1491 <label className="block space-y-1">1885 <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>
1493 <select1889 <select
1494 value={newProviderId}1890 value={newProviderId}
1495 onChange={(e) => setNewProviderId(e.target.value)}1891 onChange={(e) => setNewProviderId(e.target.value)}
@@ -1516,7 +1912,9 @@ function SecretPicker(props: { · 20 unmodified lines
1516 onClick={handleCreate}1912 onClick={handleCreate}
1517 disabled={createSecret.status === "pending"}1913 disabled={createSecret.status === "pending"}
1518 >1914 >
1519 {createSecret.status === "pending" && <IconSpinner className="size-3" />}1915 {createSecret.status === "pending" && (
1916 <IconSpinner className="size-3" />
1917 )}
1520 Store & use1918 Store & use
1521 </Button>1919 </Button>
1522 </div>1920 </div>
apps/web/src/views/source-templates.ts+63−20
@@ -1,15 +1,24 @@
1import type { Source } from "@executor/react";
2
3type SourceTemplateBase = {1type SourceTemplateBase = {
4 id: string;2 id: string;
5 name: string;3 name: string;
6 summary: string;4 summary: string;
7 endpoint: string;5 endpoint?: string;
6 namespace?: string;
8 groupId?: string;7 groupId?: string;
9 groupLabel?: string;8 groupLabel?: string;
10 batchable?: boolean;9 batchable?: boolean;
11};10};
12 11
12export 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
13export type OpenApiSourceTemplate = SourceTemplateBase & {22export type OpenApiSourceTemplate = SourceTemplateBase & {
14 kind: "openapi";23 kind: "openapi";
15 specUrl: string;24 specUrl: string;
@@ -22,14 +31,22 @@ export type GoogleDiscoverySourceTemplate = SourceTemplateBase & { · 6 unmodified lines
22 discoveryUrl: string;31 discoveryUrl: string;
23};32};
24 33
25export type NonOpenApiSourceTemplate = SourceTemplateBase & {34export type GraphqlSourceTemplate = SourceTemplateBase & {
26 kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">;35 kind: "graphql";
27};36};
28 37
29export type SourceTemplate =38export type SourceTemplate =
39 | McpSourceTemplate
30 | OpenApiSourceTemplate40 | OpenApiSourceTemplate
31 | GoogleDiscoverySourceTemplate41 | GoogleDiscoverySourceTemplate
32 | NonOpenApiSourceTemplate;42 | GraphqlSourceTemplate;
43
44export const isStdioMcpSourceTemplate = (
45 template: SourceTemplate,
46): template is McpSourceTemplate & { transport: "stdio" } =>
47 template.kind === "mcp" &&
48 template.connectionType === "command" &&
49 template.transport === "stdio";
33 50
34const googleDiscoveryUrl = (service: string, version: string): string =>51const googleDiscoveryUrl = (service: string, version: string): string =>
35 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;52 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
@@ -42,7 +59,8 @@ const googleDiscoveryTemplate = (input: { · 6 unmodified lines
42 version: string;59 version: string;
43 discoveryUrl?: string;60 discoveryUrl?: string;
44}): GoogleDiscoverySourceTemplate => {61}): GoogleDiscoverySourceTemplate => {
45 const discoveryUrl = input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);62 const discoveryUrl =
63 input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);
46 return {64 return {
47 id: input.id,65 id: input.id,
48 name: input.name,66 name: input.name,
@@ -80,6 +98,18 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 31 unmodified lines
80 kind: "mcp",98 kind: "mcp",
81 endpoint: "https://mcp.neon.tech/mcp",99 endpoint: "https://mcp.neon.tech/mcp",
82 },100 },
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 },
83 {113 {
84 id: "neon-api",114 id: "neon-api",
85 name: "Neon API",115 name: "Neon API",
@@ -94,7 +124,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 8 unmodified lines
94 summary: "Repos, issues, pull requests, actions, and org settings.",124 summary: "Repos, issues, pull requests, actions, and org settings.",
95 kind: "openapi",125 kind: "openapi",
96 endpoint: "https://api.github.com",126 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",
98 groupId: "github",129 groupId: "github",
99 groupLabel: "GitHub",130 groupLabel: "GitHub",
100 batchable: false,131 batchable: false,
@@ -102,7 +133,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
102 {133 {
103 id: "github-graphql",134 id: "github-graphql",
104 name: "GitHub GraphQL",135 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.",
106 kind: "graphql",138 kind: "graphql",
107 endpoint: "https://api.github.com/graphql",139 endpoint: "https://api.github.com/graphql",
108 groupId: "github",140 groupId: "github",
@@ -122,7 +154,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 13 unmodified lines
122 summary: "Models, files, responses, and fine-tuning.",154 summary: "Models, files, responses, and fine-tuning.",
123 kind: "openapi",155 kind: "openapi",
124 endpoint: "https://api.openai.com/v1",156 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",
126 },159 },
127 {160 {
128 id: "vercel-api",161 id: "vercel-api",
@@ -138,7 +171,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 9 unmodified lines
138 summary: "Payments, billing, subscriptions, and invoices.",171 summary: "Payments, billing, subscriptions, and invoices.",
139 kind: "openapi",172 kind: "openapi",
140 endpoint: "https://api.stripe.com",173 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",
142 },176 },
143 {177 {
144 id: "linear-graphql",178 id: "linear-graphql",
@@ -167,7 +201,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 22 unmodified lines
167 summary: "Calendars, events, ACLs, and scheduling workflows.",201 summary: "Calendars, events, ACLs, and scheduling workflows.",
168 service: "calendar",202 service: "calendar",
169 version: "v3",203 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",
171 }),206 }),
172 googleDiscoveryTemplate({207 googleDiscoveryTemplate({
173 id: "google-drive",208 id: "google-drive",
@@ -219,10 +254,12 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 45 unmodified lines
219 googleDiscoveryTemplate({254 googleDiscoveryTemplate({
220 id: "google-search-console",255 id: "google-search-console",
221 name: "Google Search Console",256 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.",
223 service: "searchconsole",259 service: "searchconsole",
224 version: "v1",260 version: "v1",
225 discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1",261 discoveryUrl:
262 "https://searchconsole.googleapis.com/$discovery/rest?version=v1",
226 }),263 }),
227 googleDiscoveryTemplate({264 googleDiscoveryTemplate({
228 id: "google-people",265 id: "google-people",
@@ -270,7 +307,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 41 unmodified lines
270 summary: "Users, groups, org units, roles, and domain directory resources.",307 summary: "Users, groups, org units, roles, and domain directory resources.",
271 service: "admin",308 service: "admin",
272 version: "directory_v1",309 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",
274 }),312 }),
275 googleDiscoveryTemplate({313 googleDiscoveryTemplate({
276 id: "google-admin-reports",314 id: "google-admin-reports",
@@ -278,12 +316,14 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
278 summary: "Audit events, usage reports, and admin activity logs.",316 summary: "Audit events, usage reports, and admin activity logs.",
279 service: "admin",317 service: "admin",
280 version: "reports_v1",318 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",
282 }),321 }),
283 googleDiscoveryTemplate({322 googleDiscoveryTemplate({
284 id: "google-apps-script",323 id: "google-apps-script",
285 name: "Google Apps Script",324 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.",
287 service: "script",327 service: "script",
288 version: "v1",328 version: "v1",
289 discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",329 discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
@@ -291,7 +331,8 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
291 googleDiscoveryTemplate({331 googleDiscoveryTemplate({
292 id: "google-bigquery",332 id: "google-bigquery",
293 name: "Google BigQuery",333 name: "Google BigQuery",
294 summary: "Datasets, tables, jobs, routines, and analytical query workflows.",334 summary:
335 "Datasets, tables, jobs, routines, and analytical query workflows.",
295 service: "bigquery",336 service: "bigquery",
296 version: "v2",337 version: "v2",
297 discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",338 discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
@@ -299,10 +340,12 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
299 googleDiscoveryTemplate({340 googleDiscoveryTemplate({
300 id: "google-cloud-resource-manager",341 id: "google-cloud-resource-manager",
301 name: "Google Cloud Resource Manager",342 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.",
303 service: "cloudresourcemanager",345 service: "cloudresourcemanager",
304 version: "v3",346 version: "v3",
305 discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",347 discoveryUrl:
348 "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
306 }),349 }),
307 googleDiscoveryTemplate({350 googleDiscoveryTemplate({
308 id: "google-youtube-data",351 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
687 transport: payload.transport,687 transport: payload.transport,
688 queryParams: payload.queryParams,688 queryParams: payload.queryParams,
689 headers: payload.headers,689 headers: payload.headers,
690 command: payload.command,
691 args: payload.args,
692 env: payload.env,
693 cwd: payload.cwd,
690 baseUrl,694 baseUrl,
691 });695 });
692 }696 }
packages/platform/control-plane/src/runtime/execution/live.ts+12−5
@@ -2,6 +2,7 @@ import type { · 1 unmodified line
2 ElicitationResponse,2 ElicitationResponse,
3 OnElicitation,3 OnElicitation,
4} from "@executor/codemode-core";4} from "@executor/codemode-core";
5import { clearMcpConnectionPoolRun } from "@executor/source-mcp";
5import {6import {
6 ExecutionInteractionIdSchema,7 ExecutionInteractionIdSchema,
7 type Execution,8 type Execution,
@@ -233,15 +234,21 @@ export const createLiveExecutionManager = () => { · 225 unmodified lines
233 234
234 finishRun: ({ executionId, state }) =>235 finishRun: ({ executionId, state }) =>
235 publishState({ executionId, state }).pipe(236 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(
236 Effect.zipRight(Effect.sync(() => {248 Effect.zipRight(Effect.sync(() => {
237 runs.delete(executionId);249 runs.delete(executionId);
238 })),250 })),
239 ),251 ),
240
241 clearRun: (executionId) =>
242 Effect.sync(() => {
243 runs.delete(executionId);
244 }),
245 } satisfies LiveExecutionManagerShape;252 } satisfies LiveExecutionManagerShape;
246 253
247 return manager;254 return manager;
packages/platform/control-plane/src/runtime/execution/mcp-resume.test.ts+22−18
@@ -244,24 +244,28 @@ const makeMcpExecutionResolver = ( · 243 unmodified lines
244 ({ onElicitation }) =>244 ({ onElicitation }) =>
245 (Effect.gen(function* () {245 (Effect.gen(function* () {
246 const discovered = yield* discoverMcpToolsFromConnector({246 const discovered = yield* discoverMcpToolsFromConnector({
247 connect: async () => {247 connect: Effect.tryPromise({
248 const client = new Client(248 try: async () => {
249 {249 const client = new Client(
250 name: "control-plane-mcp-execution-client",250 {
251 version: "1.0.0",251 name: "control-plane-mcp-execution-client",
252 },252 version: "1.0.0",
253 { capabilities: { elicitation: { form: {} } } },253 },
254 );254 { capabilities: { elicitation: { form: {} } } },
255 const transport = new StreamableHTTPClientTransport(new URL(endpoint));255 );
256 await client.connect(transport);256 const transport = new StreamableHTTPClientTransport(new URL(endpoint));
257 257 await client.connect(transport);
258 return {258
259 client,259 return {
260 close: async () => {260 client,
261 await client.close();261 close: async () => {
262 },262 await client.close();
263 };263 },
264 },264 };
265 },
266 catch: (cause) =>
267 cause instanceof Error ? cause : new Error(String(cause)),
268 }),
265 namespace: "source.form",269 namespace: "source.form",
266 sourceKey: "mcp.form",270 sourceKey: "mcp.form",
267 });271 });
packages/platform/control-plane/src/runtime/index.ts+1−1
@@ -283,7 +283,7 @@ export const createControlPlaneRuntime = ( · 282 unmodified lines
283 managedRuntime,283 managedRuntime,
284 runtimeLayer: concreteRuntimeLayer as RuntimeControlPlaneLayer,284 runtimeLayer: concreteRuntimeLayer as RuntimeControlPlaneLayer,
285 close: async () => {285 close: async () => {
286 await clearAllMcpConnectionPools().catch(() => undefined);286 await Effect.runPromise(clearAllMcpConnectionPools()).catch(() => undefined);
287 await managedRuntime.dispose().catch(() => undefined);287 await managedRuntime.dispose().catch(() => undefined);
288 },288 },
289 };289 };
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
335 }).pipe(Effect.orDie),335 }).pipe(Effect.orDie),
336 );336 );
337 337
338const 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
537const STDIO_TEST_SERVER_SCRIPT = `
538import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
539import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
540import { z } from "zod/v4";
541
542const server = new McpServer(
543 { name: "mcp-stdio-test-server", version: "1.0.0" },
544 { capabilities: { tools: { listChanged: true } } },
545);
546
547server.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
561const transport = new StdioServerTransport();
562await server.connect(transport);
563`;
564
338const makeStaticAuthProvider = (accessToken: string): OAuthClientProvider => ({565const makeStaticAuthProvider = (accessToken: string): OAuthClientProvider => ({
339 get redirectUrl() {566 get redirectUrl() {
340 return "http://127.0.0.1/oauth/callback";567 return "http://127.0.0.1/oauth/callback";
@@ -617,4 +844,218 @@ describe("mcp source adapter", () => { · 276 unmodified lines
617 });844 });
618 }),845 }),
619 );846 );
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 );
620});1061});
packages/platform/control-plane/src/runtime/sources/source-auth-service.ts+23−19
@@ -165,10 +165,10 @@ const createSyntheticMcpStdioEndpoint = (input: { · 164 unmodified lines
165 command?: string | null;165 command?: string | null;
166}): string => {166}): string => {
167 const label =167 const label =
168 trimOrNull(input.name)168 trimOrNull(input.name) ??
169 ?? trimOrNull(input.endpoint)169 trimOrNull(input.endpoint) ??
170 ?? trimOrNull(input.command)170 trimOrNull(input.command) ??
171 ?? "mcp";171 "mcp";
172 172
173 return `stdio://local/${slugifySourceLabel(label)}`;173 return `stdio://local/${slugifySourceLabel(label)}`;
174};174};
@@ -287,20 +287,15 @@ const probeMcpSourceWithoutAuth = ( · 112 unmodified lines
287 }287 }
288 const bindingState = yield* sourceBindingStateFromSource(source);288 const bindingState = yield* sourceBindingStateFromSource(source);
289 289
290 const connector = yield* Effect.try({290 const connector = createSdkMcpConnector({
291 try: () =>291 endpoint: source.endpoint,
292 createSdkMcpConnector({292 transport: bindingState.transport ?? undefined,
293 endpoint: source.endpoint,293 queryParams: bindingState.queryParams ?? undefined,
294 transport: bindingState.transport ?? undefined,294 headers: bindingState.headers ?? undefined,
295 queryParams: bindingState.queryParams ?? undefined,295 command: bindingState.command ?? undefined,
296 headers: bindingState.headers ?? undefined,296 args: bindingState.args ?? undefined,
297 command: bindingState.command ?? undefined,297 env: bindingState.env ?? undefined,
298 args: bindingState.args ?? undefined,298 cwd: bindingState.cwd ?? undefined,
299 env: bindingState.env ?? undefined,
300 cwd: bindingState.cwd ?? undefined,
301 }),
302 catch: (cause) =>
303 cause instanceof Error ? cause : new Error(String(cause)),
304 });299 });
305 300
306 return yield* discoverMcpToolsFromConnector({301 return yield* discoverMcpToolsFromConnector({
@@ -1647,6 +1642,14 @@ const connectMcpSourceInternal = (input: { · 1340 unmodified lines
1647 resolveSecretMaterial: ResolveSecretMaterial;1642 resolveSecretMaterial: ResolveSecretMaterial;
1648}): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>1643}): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>
1649 Effect.gen(function* () {1644 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 });
1650 const existing = yield* (1653 const existing = yield* (
1651 input.sourceId1654 input.sourceId
1652 ? input.sourceStore.loadSourceById({1655 ? input.sourceStore.loadSourceById({
@@ -1667,7 +1670,8 @@ const connectMcpSourceInternal = (input: { · 14 unmodified lines
1667 sources.find(1670 sources.find(
1668 (source) =>1671 (source) =>
1669 sourceAdapterRequiresInteractiveConnect(source.kind)1672 sourceAdapterRequiresInteractiveConnect(source.kind)
1670 && normalizeEndpoint(source.endpoint) === normalizedEndpoint,1673 && lookupEndpoint !== null
1674 && normalizeEndpoint(source.endpoint) === lookupEndpoint,
1671 ),1675 ),
1672 ),1676 ),
1673 )1677 )
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
32});32});
33 33
34const mcpBinding = (input: {34const mcpBinding = (input: {
35 transport?: "auto" | "streamable-http" | "sse" | null;35 transport?: "auto" | "streamable-http" | "sse" | "stdio" | null;
36 queryParams?: Record<string, string> | null;36 queryParams?: Record<string, string> | null;
37 headers?: Record<string, string> | null;37 headers?: Record<string, string> | null;
38 command?: string | null;
39 args?: ReadonlyArray<string> | null;
40 env?: Record<string, string> | null;
41 cwd?: string | null;
38} = {}) => ({42} = {}) => ({
39 transport: input.transport ?? null,43 transport: input.transport ?? null,
40 queryParams: input.queryParams ?? null,44 queryParams: input.queryParams ?? null,
41 headers: input.headers ?? null,45 headers: input.headers ?? null,
46 command: input.command ?? null,
47 args: input.args ?? null,
48 env: input.env ?? null,
49 cwd: input.cwd ?? null,
42});50});
43 51
44const makeSource = (overrides: Partial<Source> = {}): Source => ({52const makeSource = (overrides: Partial<Source> = {}): Source => ({
packages/sources/core/src/shared.ts+14−0
@@ -8,11 +8,13 @@ import { · 7 unmodified lines
8 SourceBindingVersionSchema,8 SourceBindingVersionSchema,
9 SourceImportAuthPolicySchema,9 SourceImportAuthPolicySchema,
10 SourceOauthClientInputSchema,10 SourceOauthClientInputSchema,
11 StringArraySchema,
11 SourceTransportSchema,12 SourceTransportSchema,
12 StringMapSchema,13 StringMapSchema,
13 type CredentialSlot,14 type CredentialSlot,
14 type SecretRef,15 type SecretRef,
15 type SourceBinding,16 type SourceBinding,
17 type StringArray,
16 type SourceTransport,18 type SourceTransport,
17 type StringMap,19 type StringMap,
18} from "./source-models";20} from "./source-models";
@@ -72,6 +74,10 @@ export const McpConnectFieldsSchema = Schema.Struct({ · 53 unmodified lines
72 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),74 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
73 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),75 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
74 headers: Schema.optional(Schema.NullOr(StringMapSchema)),76 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)),
75});81});
76 82
77export const parseJsonValue = <T>(input: {83export const parseJsonValue = <T>(input: {
@@ -111,12 +117,20 @@ export const emptySourceBindingState = { · 33 unmodified lines
111 transport: null,117 transport: null,
112 queryParams: null,118 queryParams: null,
113 headers: null,119 headers: null,
120 command: null,
121 args: null,
122 env: null,
123 cwd: null,
114 specUrl: null,124 specUrl: null,
115 defaultHeaders: null,125 defaultHeaders: null,
116} satisfies {126} satisfies {
117 transport: SourceTransport | null;127 transport: SourceTransport | null;
118 queryParams: StringMap | null;128 queryParams: StringMap | null;
119 headers: StringMap | null;129 headers: StringMap | null;
130 command: string | null;
131 args: StringArray | null;
132 env: StringMap | null;
133 cwd: string | null;
120 specUrl: string | null;134 specUrl: string | null;
121 defaultHeaders: StringMap | null;135 defaultHeaders: StringMap | null;
122};136};
packages/sources/core/src/source-models.ts+4−0
@@ -26,6 +26,7 @@ export const SourceTransportSchema = Schema.Literal( · 25 unmodified lines
26 "auto",26 "auto",
27 "streamable-http",27 "streamable-http",
28 "sse",28 "sse",
29 "stdio",
29);30);
30 31
31export const SourceImportAuthPolicySchema = Schema.Literal(32export const SourceImportAuthPolicySchema = Schema.Literal(
@@ -39,6 +40,8 @@ export const StringMapSchema = Schema.Record({ · 7 unmodified lines
39 value: Schema.String,40 value: Schema.String,
40});41});
41 42
43export const StringArraySchema = Schema.Array(Schema.String);
44
42export type JsonValue =45export type JsonValue =
43 | string46 | string
44 | number47 | number
@@ -177,6 +180,7 @@ export type SourceStatus = typeof SourceStatusSchema.Type; · 132 unmodified lines
177export type SourceTransport = typeof SourceTransportSchema.Type;180export type SourceTransport = typeof SourceTransportSchema.Type;
178export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;181export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
179export type StringMap = typeof StringMapSchema.Type;182export type StringMap = typeof StringMapSchema.Type;
183export type StringArray = typeof StringArraySchema.Type;
180export type SourceAuth = typeof SourceAuthSchema.Type;184export type SourceAuth = typeof SourceAuthSchema.Type;
181export type SourceBinding = typeof SourceBindingSchema.Type;185export type SourceBinding = typeof SourceBindingSchema.Type;
182export type Source = typeof SourceSchema.Type;186export type Source = typeof SourceSchema.Type;
packages/sources/core/src/types.ts+5−0
@@ -17,6 +17,7 @@ import type { · 16 unmodified lines
17 SourceCatalogKind,17 SourceCatalogKind,
18 SourceImportAuthPolicy,18 SourceImportAuthPolicy,
19 SourceOauthClientInput,19 SourceOauthClientInput,
20 StringArray,
20 SourceTransport,21 SourceTransport,
21 StoredSourceRecord,22 StoredSourceRecord,
22 StringMap,23 StringMap,
@@ -77,6 +78,10 @@ export type SourceBindingState = { · 54 unmodified lines
77 transport: SourceTransport | null;78 transport: SourceTransport | null;
78 queryParams: StringMap | null;79 queryParams: StringMap | null;
79 headers: StringMap | null;80 headers: StringMap | null;
81 command: string | null;
82 args: StringArray | null;
83 env: StringMap | null;
84 cwd: string | null;
80 specUrl: string | null;85 specUrl: string | null;
81 defaultHeaders: StringMap | null;86 defaultHeaders: StringMap | null;
82};87};
packages/sources/mcp/src/adapter.ts+64−48
@@ -71,6 +71,14 @@ const namespaceFromSourceName = (name: string): string => { · 70 unmodified lines
71 return normalized.length > 0 ? normalized : "source";71 return normalized.length > 0 ? normalized : "source";
72};72};
73 73
74const 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
74const OptionalNullableStringArraySchema = Schema.optional(82const OptionalNullableStringArraySchema = Schema.optional(
75 Schema.NullOr(StringArraySchema),83 Schema.NullOr(StringArraySchema),
76);84);
@@ -208,9 +216,13 @@ const normalizeMcpBindingConfig = ( · 131 unmodified lines
208 cwd: trimOrNull(bindingConfig.cwd),216 cwd: trimOrNull(bindingConfig.cwd),
209 } satisfies McpBindingConfig;217 } satisfies McpBindingConfig;
210 }218 }
211 219 if (
212 if (command !== null || bindingConfig.args || bindingConfig.env || trimOrNull(bindingConfig.cwd) !== null) {220 command !== null ||
213 return yield* sourceCoreEffectError("mcp/adapter", "MCP process settings require transport: \"stdio\"");221 bindingConfig.args ||
222 bindingConfig.env ||
223 trimOrNull(bindingConfig.cwd) !== null
224 ) {
225 return yield* sourceCoreEffectError("mcp/adapter", 'MCP process settings require transport: "stdio"');
214 }226 }
215 227
216 return {228 return {
@@ -400,28 +412,23 @@ export const mcpSourceAdapter: SourceAdapter = { · 183 unmodified lines
400 Effect.gen(function* () {412 Effect.gen(function* () {
401 const bindingConfig = yield* mcpBindingConfigFromSource(source);413 const bindingConfig = yield* mcpBindingConfigFromSource(source);
402 const auth = yield* resolveAuthMaterialForSlot("import");414 const auth = yield* resolveAuthMaterialForSlot("import");
403 const connector = yield* Effect.try({415 const connector = createSdkMcpConnector({
404 try: () =>416 endpoint: source.endpoint,
405 createSdkMcpConnector({417 transport: bindingConfig.transport ?? undefined,
406 endpoint: source.endpoint,418 queryParams: {
407 transport: bindingConfig.transport ?? undefined,419 ...bindingConfig.queryParams,
408 queryParams: {420 ...auth.queryParams,
409 ...bindingConfig.queryParams,421 },
410 ...auth.queryParams,422 headers: headersWithAuthCookies({
411 },423 headers: bindingConfig.headers ?? {},
412 headers: headersWithAuthCookies({424 authHeaders: auth.headers,
413 headers: bindingConfig.headers ?? {},425 authCookies: auth.cookies,
414 authHeaders: auth.headers,426 }),
415 authCookies: auth.cookies,427 authProvider: auth.authProvider,
416 }),428 command: bindingConfig.command ?? undefined,
417 authProvider: auth.authProvider,429 args: bindingConfig.args ?? undefined,
418 command: bindingConfig.command ?? undefined,430 env: bindingConfig.env ?? undefined,
419 args: bindingConfig.args ?? undefined,431 cwd: bindingConfig.cwd ?? undefined,
420 env: bindingConfig.env ?? undefined,
421 cwd: bindingConfig.cwd ?? undefined,
422 }),
423 catch: (cause) =>
424 cause instanceof Error ? cause : new Error(String(cause)),
425 });432 });
426 433
427 const discovered = yield* discoverMcpToolsFromConnector({434 const discovered = yield* discoverMcpToolsFromConnector({
@@ -444,25 +451,28 @@ export const mcpSourceAdapter: SourceAdapter = { · 16 unmodified lines
444 });451 });
445 }),452 }),
446 invoke: (input) =>453 invoke: (input) =>
447 Effect.tryPromise({454 Effect.gen(function* () {
448 try: async () => {
449 if (input.executable.adapterKey !== "mcp") {455 if (input.executable.adapterKey !== "mcp") {
450 throw new Error(456 return yield* sourceCoreEffectError(
457 "mcp/adapter",
451 `Expected MCP executable binding, got ${input.executable.adapterKey}`,458 `Expected MCP executable binding, got ${input.executable.adapterKey}`,
452 );459 );
453 }460 }
454 const providerData = decodeExecutableBindingPayload({461 const providerData = (yield* Effect.try({
455 executableId: input.executable.id,462 try: () =>
456 label: "MCP",463 decodeExecutableBindingPayload({
457 version: input.executable.bindingVersion,464 executableId: input.executable.id,
458 expectedVersion: EXECUTABLE_BINDING_VERSION,465 label: "MCP",
459 schema: McpExecutableBindingSchema,466 version: input.executable.bindingVersion,
460 value: input.executable.binding,467 expectedVersion: EXECUTABLE_BINDING_VERSION,
461 }) as McpExecutableBinding;468 schema: McpExecutableBindingSchema,
462 469 value: input.executable.binding,
463 const bindingConfig = Effect.runSync(470 }) as McpExecutableBinding,
464 mcpBindingConfigFromSource(input.source),471 catch: (cause) =>
465 );472 mcpAdapterError("Failed decoding MCP executable binding", cause),
473 })) as McpExecutableBinding;
474
475 const bindingConfig = yield* mcpBindingConfigFromSource(input.source);
466 const connector = createPooledMcpConnector({476 const connector = createPooledMcpConnector({
467 connect: createSdkMcpConnector({477 connect: createSdkMcpConnector({
468 endpoint: input.source.endpoint,478 endpoint: input.source.endpoint,
@@ -531,7 +541,8 @@ export const mcpSourceAdapter: SourceAdapter = { · 62 unmodified lines
531 : entry;541 : entry;
532 542
533 if (!definition) {543 if (!definition) {
534 throw new Error(544 return yield* sourceCoreEffectError(
545 "mcp/adapter",
535 `Missing MCP tool definition for ${providerData.toolName}`,546 `Missing MCP tool definition for ${providerData.toolName}`,
536 );547 );
537 }548 }
@@ -566,10 +577,18 @@ export const mcpSourceAdapter: SourceAdapter = { · 28 unmodified lines
566 onElicitation: input.onElicitation,577 onElicitation: input.onElicitation,
567 }578 }
568 : undefined;579 : undefined;
569 const result = await definition.execute(580 const result = yield* Effect.tryPromise({
570 asRecord(payload),581 try: async () =>
571 executionContext,582 await definition.execute(
572 );583 asRecord(payload),
584 executionContext,
585 ),
586 catch: (cause) =>
587 mcpAdapterError(
588 `Failed invoking MCP tool ${providerData.toolName}`,
589 cause,
590 ),
591 });
573 const resultRecord = asRecord(result);592 const resultRecord = asRecord(result);
574 const isError = resultRecord.isError === true;593 const isError = resultRecord.isError === true;
575 594
@@ -579,8 +598,5 @@ export const mcpSourceAdapter: SourceAdapter = { · 3 unmodified lines
579 headers: {},598 headers: {},
580 status: null,599 status: null,
581 };600 };
582 },
583 catch: (cause) =>
584 cause instanceof Error ? cause : new Error(String(cause)),
585 }),601 }),
586};602};
packages/sources/mcp/src/connection-pool.ts+151−0
@@ -0,0 +1,151 @@
1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Scope from "effect/Scope";
5
6import type { McpConnection, McpConnector } from "./tools";
7
8export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{
9 readonly operation: "connect" | "close";
10 readonly message: string;
11 readonly cause: unknown;
12}> {}
13
14type PoolEntry = {
15 scope: Scope.CloseableScope;
16 connection: McpConnector;
17};
18
19const pooledRuns = new Map<string, Map<string, PoolEntry>>();
20
21const mcpConnectionPoolError = (input: {
22 operation: "connect" | "close";
23 message: string;
24 cause: unknown;
25}): McpConnectionPoolError => new McpConnectionPoolError(input);
26
27const 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
39const 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
52const 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
76const 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
105const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> =>
106 Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
107
108export 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
131export 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
145export 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
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
5 7
6import type { McpConnection, McpConnector } from "./tools";8import type { McpConnection, McpConnector } from "./tools";
7 9
8export type McpTransportPreference = "auto" | "streamable-http" | "sse";10export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio";
9 11
10export type CreateSdkMcpConnectorInput = {12export type CreateSdkMcpConnectorInput = {
11 endpoint: string;13 endpoint?: string;
12 transport?: McpTransportPreference;14 transport?: McpTransportPreference;
13 queryParams?: Record<string, string>;15 queryParams?: Record<string, string>;
14 headers?: Record<string, string>;16 headers?: Record<string, string>;
15 authProvider?: OAuthClientProvider;17 authProvider?: OAuthClientProvider;
16 clientName?: string;18 clientName?: string;
17 clientVersion?: string;19 clientVersion?: string;
20 command?: string;
21 args?: ReadonlyArray<string>;
22 env?: Record<string, string>;
23 cwd?: string;
18};24};
19 25
20const createEndpoint = (26export class McpConnectionError extends Data.TaggedError("McpConnectionError")<{
21 endpoint: string,27 readonly transport: McpTransportPreference;
22 queryParams: Record<string, string>,28 readonly message: string;
23): URL => {29 readonly cause: unknown;
24 const url = new URL(endpoint);30}> {}
25 31
26 for (const [key, value] of Object.entries(queryParams)) {32export const isMcpStdioTransport = (
27 url.searchParams.set(key, value);33 input: Pick<CreateSdkMcpConnectorInput, "transport" | "command">,
28 }34): boolean =>
29 35 input.transport === "stdio"
30 return url;36 || (typeof input.command === "string" && input.command.trim().length > 0);
31};37
38const mcpConnectionError = (input: {
39 transport: McpTransportPreference;
40 message: string;
41 cause: unknown;
42}): McpConnectionError => new McpConnectionError(input);
43
44const 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 });
32 70
33type FetchInput = Parameters<typeof fetch>[0];71type FetchInput = Parameters<typeof fetch>[0];
34type FetchInit = Parameters<typeof fetch>[1];72type FetchInit = Parameters<typeof fetch>[1];
@@ -55,12 +93,72 @@ const connectionFromClient = (client: Client): McpConnection => ({ · 20 unmodified lines
55 close: () => client.close(),93 close: () => client.close(),
56});94});
57 95
96const 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
107type DynamicImport = <TModule>(specifier: string) => Promise<TModule>;
108
109const dynamicImport = new Function(
110 "specifier",
111 "return import(specifier)",
112) as DynamicImport;
113
114const 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
132const 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
58export const createSdkMcpConnector = (155export const createSdkMcpConnector = (
59 input: CreateSdkMcpConnectorInput,156 input: CreateSdkMcpConnectorInput,
60): McpConnector => {157): McpConnector => {
61 const endpoint = createEndpoint(input.endpoint, input.queryParams ?? {});
62 const headers = input.headers ?? {};158 const headers = input.headers ?? {};
63 const transport = input.transport ?? "auto";159 const transport = isMcpStdioTransport(input)
160 ? "stdio"
161 : (input.transport ?? "auto");
64 const requestInit = Object.keys(headers).length > 0162 const requestInit = Object.keys(headers).length > 0
65 ? { headers }163 ? { headers }
66 : undefined;164 : undefined;
@@ -74,49 +172,75 @@ export const createSdkMcpConnector = ( · 7 unmodified lines
74 { capabilities: { elicitation: { form: {}, url: {} } } },172 { capabilities: { elicitation: { form: {}, url: {} } } },
75 );173 );
76 174
77 return async () => {175 return Effect.gen(function* () {
78 const client = createClient();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 });
79 217
80 if (transport === "streamable-http") {218 if (transport === "streamable-http") {
81 await client.connect(new StreamableHTTPClientTransport(endpoint, {219 return yield* connectStreamableHttp;
82 requestInit,
83 authProvider: input.authProvider,
84 }));
85 return connectionFromClient(client);
86 }220 }
87 221
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
88 if (transport === "sse") {238 if (transport === "sse") {
89 await client.connect(new SSEClientTransport(endpoint, {239 return yield* connectSse;
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);
100 }240 }
101 241
102 try {242 return yield* connectStreamableHttp.pipe(
103 await client.connect(new StreamableHTTPClientTransport(endpoint, {243 Effect.catchAll(() => connectSse),
104 requestInit,244 );
105 authProvider: input.authProvider,245 });
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 };
122};246};
packages/sources/mcp/src/index.ts+8−0
@@ -1,7 +1,15 @@
1export {
2 clearAllMcpConnectionPools,
3 clearMcpConnectionPoolRun,
4 createPooledMcpConnector,
5 McpConnectionPoolError,
6} from "./connection-pool";
1export {7export {
2 createSdkMcpConnector,8 createSdkMcpConnector,
3 type CreateSdkMcpConnectorInput,9 type CreateSdkMcpConnectorInput,
10 McpConnectionError,
4 type McpTransportPreference,11 type McpTransportPreference,
12 isMcpStdioTransport,
5} from "./connection";13} from "./connection";
6export { detectMcpSource } from "./discovery";14export { detectMcpSource } from "./discovery";
7export { McpLocalConfigBindingSchema } from "./local-config";15export { 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
2 2
3import {3import {
4 SourceTransportSchema,4 SourceTransportSchema,
5 StringArraySchema,
5 StringMapSchema,6 StringMapSchema,
6} from "@executor/source-core";7} from "@executor/source-core";
7 8
@@ -9,4 +10,8 @@ export const McpLocalConfigBindingSchema = Schema.Struct({ · 1 unmodified line
9 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),10 transport: Schema.optional(Schema.NullOr(SourceTransportSchema)),
10 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),11 queryParams: Schema.optional(Schema.NullOr(StringMapSchema)),
11 headers: Schema.optional(Schema.NullOr(StringMapSchema)),12 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)),
12});17});
packages/sources/mcp/src/tools.test.ts+4−4
@@ -306,7 +306,7 @@ describe("codemode-mcp", () => { · 305 unmodified lines
306 let closeCalls = 0;306 let closeCalls = 0;
307 const callInputs: Array<{ name: string; arguments: Record<string, unknown> }> = [];307 const callInputs: Array<{ name: string; arguments: Record<string, unknown> }> = [];
308 308
309 const connect = async () => {309 const connect = Effect.sync(() => {
310 connectCalls += 1;310 connectCalls += 1;
311 311
312 return {312 return {
@@ -360,7 +360,7 @@ describe("codemode-mcp", () => { · 47 unmodified lines
360 closeCalls += 1;360 closeCalls += 1;
361 },361 },
362 };362 };
363 };363 });
364 364
365 const discovered = yield* discoverMcpToolsFromConnector({365 const discovered = yield* discoverMcpToolsFromConnector({
366 connect,366 connect,
@@ -432,7 +432,7 @@ describe("codemode-mcp", () => { · 65 unmodified lines
432 Effect.gen(function* () {432 Effect.gen(function* () {
433 const outcome = yield* Effect.either(433 const outcome = yield* Effect.either(
434 discoverMcpToolsFromConnector({434 discoverMcpToolsFromConnector({
435 connect: async () => ({435 connect: Effect.succeed({
436 client: {436 client: {
437 listTools: async () => {437 listTools: async () => {
438 throw new Error("nope");438 throw new Error("nope");
@@ -456,7 +456,7 @@ describe("codemode-mcp", () => { · 17 unmodified lines
456 const elicitationMessages: Array<string> = [];456 const elicitationMessages: Array<string> = [];
457 457
458 const discovered = yield* discoverMcpToolsFromConnector({458 const discovered = yield* discoverMcpToolsFromConnector({
459 connect: async () => ({459 connect: Effect.succeed({
460 client: {460 client: {
461 listTools: async () => {461 listTools: async () => {
462 attempts += 1;462 attempts += 1;
packages/sources/mcp/src/tools.ts+3−6
@@ -53,7 +53,7 @@ export type McpConnection = { · 52 unmodified lines
53 close?: () => Promise<void>;53 close?: () => Promise<void>;
54};54};
55 55
56export type McpConnector = () => Promise<McpConnection>;56export type McpConnector = Effect.Effect<McpConnection, unknown, never>;
57 57
58export type McpDiscoveryElicitationContext = {58export type McpDiscoveryElicitationContext = {
59 onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;59 onElicitation: NonNullable<ToolExecutionContext["onElicitation"]>;
@@ -127,10 +127,7 @@ const withConnectionEffect = <A, E>(input: { · 67 unmodified lines
127 run: (connection: McpConnection) => Effect.Effect<A, E>;127 run: (connection: McpConnection) => Effect.Effect<A, E>;
128}): Effect.Effect<A, E | McpToolsError> =>128}): Effect.Effect<A, E | McpToolsError> =>
129 Effect.acquireUseRelease(129 Effect.acquireUseRelease(
130 Effect.tryPromise({130 input.connect.pipe(Effect.mapError(input.onConnectError)),
131 try: input.connect,
132 catch: input.onConnectError,
133 }),
134 input.run,131 input.run,
135 closeConnection,132 closeConnection,
136 );133 );
@@ -447,7 +444,7 @@ const runMcpToolCallEffect = (input: { · 310 unmodified lines
447export const createMcpConnectorFromClient = (444export const createMcpConnectorFromClient = (
448 client: McpClientLike,445 client: McpClientLike,
449): McpConnector =>446): McpConnector =>
450 async () => ({447 Effect.succeed({
451 client,448 client,
452 close: async () => undefined,449 close: async () => undefined,
453 });450 });