Unified · Split
| @@ -36,7 +36,11 @@ import { · 35 unmodified lines | |||
| 36 | IconSpinner, | 36 | IconSpinner, |
| 37 | } from "../components/icons"; | 37 | } from "../components/icons"; |
| 38 | import { cn } from "../lib/utils"; | 38 | import { cn } from "../lib/utils"; |
| 39 | import { sourceTemplates, type SourceTemplate } from "./source-templates"; | 39 | import { |
| 40 | isStdioMcpSourceTemplate, | ||
| 41 | sourceTemplates, | ||
| 42 | type SourceTemplate, | ||
| 43 | } from "./source-templates"; | ||
| 40 | import { getDomain } from "tldts"; | 44 | import { 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 | ||
| 90 | type OAuthRequiredInfo = { | 98 | type OAuthRequiredInfo = { |
| @@ -110,15 +118,22 @@ const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [ · 19 unmodified lines | |||
| 110 | "google_discovery", | 118 | "google_discovery", |
| 111 | ]; | 119 | ]; |
| 112 | 120 | ||
| 113 | const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [ | 121 | const transportOptions: ReadonlyArray< |
| 114 | "auto", | 122 | "auto" | "streamable-http" | "sse" | "stdio" |
| 115 | "streamable-http", | 123 | > = ["auto", "streamable-http", "sse", "stdio"]; |
| 116 | "sse", | ||
| 117 | ]; | ||
| 118 | 124 | ||
| 119 | const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = ["none", "bearer", "oauth2"]; | 125 | const authOptions: ReadonlyArray<ConnectFormState["authKind"]> = [ |
| 126 | "none", | ||
| 127 | "bearer", | ||
| 128 | "oauth2", | ||
| 129 | ]; | ||
| 120 | 130 | ||
| 121 | const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = ["none", "bearer", "basic", "headers"]; | 131 | const probeAuthOptions: ReadonlyArray<ProbeAuthKind> = [ |
| 132 | "none", | ||
| 133 | "bearer", | ||
| 134 | "basic", | ||
| 135 | "headers", | ||
| 136 | ]; | ||
| 122 | 137 | ||
| 123 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | 138 | const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; |
| 124 | const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | 139 | const 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 | ||
| 147 | const googleDiscoveryNamespace = (service: string): string => | 162 | const googleDiscoveryNamespace = (service: string): string => |
| 148 | `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`; | 163 | `google.${service |
| 149 | 164 | .trim() | |
| 150 | const googleDiscoveryDefaultsFromUrl = (value: string): { | 165 | .toLowerCase() |
| 166 | .replace(/[^a-z0-9]+/g, ".") | ||
| 167 | .replace(/^\.+|\.+$/g, "")}`; | ||
| 168 | |||
| 169 | const 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 | ||
| 185 | const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { | 209 | const parseJsonStringMap = ( |
| 210 | label: string, | ||
| 211 | text: string, | ||
| 212 | ): Record<string, string> | null => { | ||
| 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 | ||
| 241 | const parseJsonStringArray = ( | ||
| 242 | label: string, | ||
| 243 | text: string, | ||
| 244 | ): Array<string> | null => { | ||
| 245 | const trimmed = text.trim(); | ||
| 246 | if (trimmed.length === 0) { | ||
| 247 | return null; | ||
| 248 | } | ||
| 249 | |||
| 250 | let parsed: unknown; | ||
| 251 | try { | ||
| 252 | parsed = JSON.parse(trimmed); | ||
| 253 | } catch { | ||
| 254 | throw new Error(`${label} must be valid JSON.`); | ||
| 255 | } | ||
| 256 | |||
| 257 | if ( | ||
| 258 | !Array.isArray(parsed) || | ||
| 259 | !parsed.every((value) => typeof value === "string") | ||
| 260 | ) { | ||
| 261 | throw new Error(`${label} must be a JSON array of strings.`); | ||
| 262 | } | ||
| 263 | |||
| 264 | const normalized = parsed | ||
| 265 | .map((value) => value.trim()) | ||
| 266 | .filter((value) => value.length > 0); | ||
| 267 | |||
| 268 | return normalized.length > 0 ? normalized : null; | ||
| 269 | }; | ||
| 270 | |||
| 271 | const stringifyStringMap = ( | ||
| 272 | value: Readonly<Record<string, string>> | null | undefined, | ||
| 273 | ): string => | ||
| 274 | !value || Object.keys(value).length === 0 | ||
| 275 | ? "" | ||
| 276 | : JSON.stringify(value, null, 2); | ||
| 277 | |||
| 278 | const stringifyStringArray = ( | ||
| 279 | value: ReadonlyArray<string> | null | undefined, | ||
| 280 | ): string => | ||
| 281 | !value || value.length === 0 ? "" : JSON.stringify(value, null, 2); | ||
| 282 | |||
| 283 | const buildSyntheticMcpStdioEndpoint = (input: { | ||
| 284 | name?: string | null; | ||
| 285 | endpoint?: string | null; | ||
| 286 | command?: string | null; | ||
| 287 | }): string => { | ||
| 288 | const label = | ||
| 289 | input.name?.trim() || | ||
| 290 | input.endpoint?.trim() || | ||
| 291 | input.command?.trim() || | ||
| 292 | "mcp"; | ||
| 293 | const slug = label | ||
| 294 | .toLowerCase() | ||
| 295 | .replace(/[^a-z0-9]+/g, "-") | ||
| 296 | .replace(/^-+|-+$/g, ""); | ||
| 297 | |||
| 298 | return `stdio://local/${slug || "mcp"}`; | ||
| 299 | }; | ||
| 300 | |||
| 214 | // --------------------------------------------------------------------------- | 301 | // --------------------------------------------------------------------------- |
| 215 | // Derived defaults from discovery result | 302 | // Derived defaults from discovery result |
| 216 | // --------------------------------------------------------------------------- | 303 | // --------------------------------------------------------------------------- |
| 217 | 304 | ||
| 218 | const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState => { | 305 | const 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 | ||
| 297 | const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => ({ | 396 | const 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 template | 407 | 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 | ||
| 314 | const authKindForSourceKind = ( | 426 | const authKindForSourceKind = ( |
| @@ -319,10 +431,13 @@ const authKindForSourceKind = ( · 4 unmodified lines | |||
| 319 | ? "oauth2" | 431 | ? "oauth2" |
| 320 | : currentAuthKind; | 432 | : currentAuthKind; |
| 321 | 433 | ||
| 322 | const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => { | 434 | const 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 | // headers | 457 | // 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 | ||
| 347 | const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { | 464 | const 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 === 0 | 524 | form.workspaceOauthClientId.trim().length === 0 && |
| 388 | && form.oauthClientId.trim().length === 0 | 525 | 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 > 0 | 539 | 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 === 0 | 544 | 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 | ||
| 425 | const buildHttpAuth = ( | 566 | const 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-filled | 612 | // 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 | ||
| 492 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | 644 | const 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 | ||
| 505 | const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { | 659 | const 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 | ||
| 510 | const startSourceOAuthPopup = async (input: { | 666 | const 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 helper | 772 | // Confidence badge helper |
| 608 | // --------------------------------------------------------------------------- | 773 | // --------------------------------------------------------------------------- |
| 609 | 774 | ||
| 610 | |||
| 611 | |||
| 612 | // --------------------------------------------------------------------------- | 775 | // --------------------------------------------------------------------------- |
| 613 | // Page | 776 | // Page |
| 614 | // --------------------------------------------------------------------------- | 777 | // --------------------------------------------------------------------------- |
| @@ -645,17 +808,22 @@ export function AddSourcePage() { · 30 unmodified lines | |||
| 645 | 808 | ||
| 646 | // Discovery result | 809 | // 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 result | 814 | // Connect result |
| 651 | const [connectResult, setConnectResult] = useState<ConnectSourceResult | null>(null); | 815 | const [connectResult, setConnectResult] = |
| 816 | useState<ConnectSourceResult | null>(null); | ||
| 652 | 817 | ||
| 653 | // OAuth required state | 818 | // 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 banner | 828 | // 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 returned | 947 | // 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 | isDiscovering | 1195 | isDiscovering || |
| 988 | || isConnecting | 1196 | isConnecting || |
| 989 | || oauthBusy | 1197 | oauthBusy || |
| 990 | || connectSourceBatch.status === "pending" | 1198 | connectSourceBatch.status === "pending" || |
| 991 | || createWorkspaceOauthClient.status === "pending"; | 1199 | createWorkspaceOauthClient.status === "pending"; |
| 992 | 1200 | ||
| 993 | // ------------------------------------------------------------------------- | 1201 | // ------------------------------------------------------------------------- |
| 994 | // Render | 1202 | // 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 | isComplete | 1252 | "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 | : isCurrent | 1254 | ? "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 | <LocalMcpInstallCard | 1278 | <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 | <SelectInput | 1347 | <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 | <button | 1449 | <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.length | 1512 | 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 | <button | 1535 | <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 | selected | 1549 | "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 | <select | 1611 | <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 | <TextInput | 1639 | <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 | <TextInput | 1652 | <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 | <SelectInput | 1725 | <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 | <TextInput | 1735 | 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 | mono | 1739 | <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 | <TextInput | 1748 | <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 | <TextInput | 1784 | <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 | <SelectInput | 1805 | <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 | <CodeEditor | 1821 | <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 | <CodeEditor | 1828 | </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 | <SelectInput | 1915 | <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 | <SelectInput | 1936 | <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 | <TextInput | 1953 | ]} |
| 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 | <TextInput | 1961 | 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 edit | 2039 | 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 | <Button | 2047 | <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 | {oauthInfo | 2097 | {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.name | 2149 | ? connectResult.source.name |
| 1716 | : batchOauthInfo | 2150 | : 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 | {" "}connected | 2153 | 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 blocks | 2168 | // Form building blocks |
| 1733 | // --------------------------------------------------------------------------- | 2169 | // --------------------------------------------------------------------------- |
| 1734 | 2170 | ||
| 1735 | function Section(props: { title: string; children: ReactNode; className?: string }) { | 2171 | function 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 | ||
| 1816 | function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text: string }; className?: string }) { | 2263 | function StatusBanner(props: { |
| 2264 | state: { tone: "info" | "success" | "error"; text: string }; | ||
| 2265 | className?: string; | ||
| 2266 | }) { | ||
| 1817 | return ( | 2267 | return ( |
| 1818 | <div | 2268 | <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 picker | 2321 | // 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 | <input | 2487 | <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 | <input | 2499 | <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 | <select | 2511 | <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 & use | 2540 | Store & use |
| 2051 | </Button> | 2541 | </Button> |
| 2052 | </div> | 2542 | </div> |
| @@ -60,10 +60,10 @@ const SOURCE_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; · 59 unmodified lines | |||
| 60 | const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; | 60 | const SOURCE_OAUTH_POPUP_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:"; |
| 61 | 61 | ||
| 62 | const isSourceNotFoundLoadable = (loadable: Loadable<unknown>): boolean => | 62 | const 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 | ||
| 66 | type TransportValue = "" | "auto" | "streamable-http" | "sse"; | 66 | type TransportValue = "" | "auto" | "streamable-http" | "sse" | "stdio"; |
| 67 | 67 | ||
| 68 | type SourceFormState = { | 68 | type 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 | ||
| 93 | const kindOptions: ReadonlyArray<Source["kind"]> = [ | 100 | const 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 | ||
| 107 | const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [ | 115 | const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [ |
| @@ -129,9 +137,15 @@ const namespaceFromUrl = (url: string): string => { · 21 unmodified lines | |||
| 129 | }; | 137 | }; |
| 130 | 138 | ||
| 131 | const googleDiscoveryNamespace = (service: string): string => | 139 | const googleDiscoveryNamespace = (service: string): string => |
| 132 | `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`; | 140 | `google.${service |
| 133 | 141 | .trim() | |
| 134 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | 142 | .toLowerCase() |
| 143 | .replace(/[^a-z0-9]+/g, ".") | ||
| 144 | .replace(/^\.+|\.+$/g, "")}`; | ||
| 145 | |||
| 146 | const 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 | ||
| 161 | const startSourceOAuthPopup = async (input: { | 177 | const 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 the | 277 | 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 | ||
| 278 | const stringMapToEditor = (value: Record<string, string> | null): string => | 304 | const 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 | ||
| 307 | const stringArrayToEditor = ( | ||
| 308 | value: ReadonlyArray<string> | null | undefined, | ||
| 309 | ): string => | ||
| 310 | !value || value.length === 0 ? "" : JSON.stringify(value, null, 2); | ||
| 311 | |||
| 281 | const readBindingStringMap = ( | 312 | const 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 | ||
| 332 | const readBindingStringArray = ( | ||
| 333 | source: Source, | ||
| 334 | key: string, | ||
| 335 | ): Array<string> | null => { | ||
| 336 | const candidate = source.binding[key]; | ||
| 337 | return Array.isArray(candidate) && | ||
| 338 | candidate.every((value) => typeof value === "string") | ||
| 339 | ? [...candidate] | ||
| 340 | : null; | ||
| 341 | }; | ||
| 342 | |||
| 296 | const readBindingString = (source: Source, key: string): string => | 343 | const 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 | ||
| 299 | const readBindingTransport = (source: Source): TransportValue => { | 346 | const 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 | ? candidate | 353 | ? candidate |
| 303 | : ""; | 354 | : ""; |
| 304 | }; | 355 | }; |
| 305 | 356 | ||
| 357 | const parseJsonStringArray = ( | ||
| 358 | label: string, | ||
| 359 | text: string, | ||
| 360 | ): Array<string> | null => { | ||
| 361 | const trimmed = text.trim(); | ||
| 362 | if (trimmed.length === 0) { | ||
| 363 | return null; | ||
| 364 | } | ||
| 365 | |||
| 366 | let parsed: unknown; | ||
| 367 | try { | ||
| 368 | parsed = JSON.parse(trimmed); | ||
| 369 | } catch { | ||
| 370 | throw new Error(`${label} must be valid JSON.`); | ||
| 371 | } | ||
| 372 | |||
| 373 | if ( | ||
| 374 | !Array.isArray(parsed) || | ||
| 375 | !parsed.every((value) => typeof value === "string") | ||
| 376 | ) { | ||
| 377 | throw new Error(`${label} must be a JSON array of strings.`); | ||
| 378 | } | ||
| 379 | |||
| 380 | const normalized = parsed | ||
| 381 | .map((value) => value.trim()) | ||
| 382 | .filter((value) => value.length > 0); | ||
| 383 | |||
| 384 | return normalized.length > 0 ? normalized : null; | ||
| 385 | }; | ||
| 386 | |||
| 387 | const buildSyntheticMcpStdioEndpoint = (input: { | ||
| 388 | name?: string | null; | ||
| 389 | endpoint?: string | null; | ||
| 390 | command?: string | null; | ||
| 391 | }): string => { | ||
| 392 | const label = | ||
| 393 | input.name?.trim() || | ||
| 394 | input.endpoint?.trim() || | ||
| 395 | input.command?.trim() || | ||
| 396 | "mcp"; | ||
| 397 | const slug = label | ||
| 398 | .toLowerCase() | ||
| 399 | .replace(/[^a-z0-9]+/g, "-") | ||
| 400 | .replace(/^-+|-+$/g, ""); | ||
| 401 | |||
| 402 | return `stdio://local/${slug || "mcp"}`; | ||
| 403 | }; | ||
| 404 | |||
| 306 | const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ | 405 | const 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: template | 409 | namespace: template |
| 311 | ? "service" in template | 410 | ? (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 !== null | 484 | source.auth.kind === "oauth2" && source.auth.refreshToken !== null |
| 363 | ? source.auth.refreshToken.providerId | 485 | ? source.auth.refreshToken.providerId |
| @@ -367,12 +489,16 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 3 unmodified lines | |||
| 367 | ? source.auth.refreshToken.handle | 489 | ? 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.auth | 494 | ? source.auth |
| 372 | : null, | 495 | : null, |
| 373 | }); | 496 | }); |
| 374 | 497 | ||
| 375 | const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { | 498 | const parseJsonStringMap = ( |
| 499 | label: string, | ||
| 500 | text: string, | ||
| 501 | ): Record<string, string> | null => { | ||
| 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 | ||
| 404 | const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] => { | 530 | const 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 | ||
| 464 | const buildRequestedSourceStatus = (state: SourceFormState): CreateSourcePayload["status"] => { | 602 | const 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 | ||
| 472 | const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { | 616 | const 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 | ||
| 560 | const buildStartSourceOAuthPayload = (state: SourceFormState): StartSourceOAuthPayload => { | 742 | const 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 > 0 | 826 | 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.source | 967 | !props.source || |
| 762 | || isRevokingGrant | 968 | 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.source | 1006 | 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 | <SelectInput | 1095 | <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 | mono | 1100 | 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 | <TextInput | 1127 | <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 | <SelectInput | 1146 | <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 | <CodeEditor | 1159 | <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 | <CodeEditor | 1166 | </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 | <Button | 1316 | <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 > 0 | 1326 | <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 draft | 1338 | <p className="text-[11px] text-muted-foreground/70"> |
| 1042 | </p> | 1339 | Token refs attached to this draft |
| 1043 | <button | 1340 | </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 | <SelectInput | 1359 | <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 | <TextInput | 1367 | 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 | <TextInput | 1374 | }))} |
| 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 === null | 1380 | formState.kind === "mcp" && formState.transport === "stdio" |
| 1147 | && (formState.kind !== "mcp" | 1381 | ) && ( |
| 1148 | || formState.oauthAccessHandle.trim().length === 0 | 1382 | <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 | <SecretPicker | 1386 | <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 | <SecretPicker | 1482 | <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 | {isSubmitting | 1568 | {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 blocks | 1584 | // Form building blocks |
| 1220 | // --------------------------------------------------------------------------- | 1585 | // --------------------------------------------------------------------------- |
| 1221 | 1586 | ||
| 1222 | function Section(props: { title: string; children: ReactNode; className?: string }) { | 1587 | function 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 | ||
| 1303 | function ToggleButton(props: { checked: boolean; onChange: (checked: boolean) => void }) { | 1679 | function ToggleButton(props: { |
| 1680 | checked: boolean; | ||
| 1681 | onChange: (checked: boolean) => void; | ||
| 1682 | }) { | ||
| 1304 | return ( | 1683 | return ( |
| 1305 | <button | 1684 | <button |
| 1306 | type="button" | 1685 | type="button" |
| @@ -1328,9 +1707,12 @@ function StatusBanner(props: { state: StatusBannerState; className?: string }) { · 21 unmodified lines | |||
| 1328 | <div | 1707 | <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 | <input | 1865 | <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 | <input | 1877 | <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 | <select | 1889 | <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 & use | 1918 | Store & use |
| 1521 | </Button> | 1919 | </Button> |
| 1522 | </div> | 1920 | </div> |
| @@ -1,15 +1,24 @@ | |||
| 1 | import type { Source } from "@executor/react"; | ||
| 2 | |||
| 3 | type SourceTemplateBase = { | 1 | type 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 | ||
| 12 | export type McpSourceTemplate = SourceTemplateBase & { | ||
| 13 | kind: "mcp"; | ||
| 14 | connectionType?: "endpoint" | "command"; | ||
| 15 | transport?: "auto" | "streamable-http" | "sse" | "stdio"; | ||
| 16 | command?: string; | ||
| 17 | args?: ReadonlyArray<string>; | ||
| 18 | env?: Record<string, string>; | ||
| 19 | cwd?: string; | ||
| 20 | }; | ||
| 21 | |||
| 13 | export type OpenApiSourceTemplate = SourceTemplateBase & { | 22 | export 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 | ||
| 25 | export type NonOpenApiSourceTemplate = SourceTemplateBase & { | 34 | export type GraphqlSourceTemplate = SourceTemplateBase & { |
| 26 | kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">; | 35 | kind: "graphql"; |
| 27 | }; | 36 | }; |
| 28 | 37 | ||
| 29 | export type SourceTemplate = | 38 | export type SourceTemplate = |
| 39 | | McpSourceTemplate | ||
| 30 | | OpenApiSourceTemplate | 40 | | OpenApiSourceTemplate |
| 31 | | GoogleDiscoverySourceTemplate | 41 | | GoogleDiscoverySourceTemplate |
| 32 | | NonOpenApiSourceTemplate; | 42 | | GraphqlSourceTemplate; |
| 43 | |||
| 44 | export const isStdioMcpSourceTemplate = ( | ||
| 45 | template: SourceTemplate, | ||
| 46 | ): template is McpSourceTemplate & { transport: "stdio" } => | ||
| 47 | template.kind === "mcp" && | ||
| 48 | template.connectionType === "command" && | ||
| 49 | template.transport === "stdio"; | ||
| 33 | 50 | ||
| 34 | const googleDiscoveryUrl = (service: string, version: string): string => | 51 | const 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", |
| @@ -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 | } |
| @@ -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"; |
| 5 | import { clearMcpConnectionPoolRun } from "@executor/source-mcp"; | ||
| 5 | import { | 6 | import { |
| 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; |
| @@ -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 | }); |
| @@ -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 | }; |
| @@ -335,6 +335,233 @@ const makeAuthenticatedMcpServer = (expectedAuthorization: string) => · 334 unmodified lines | |||
| 335 | }).pipe(Effect.orDie), | 335 | }).pipe(Effect.orDie), |
| 336 | ); | 336 | ); |
| 337 | 337 | ||
| 338 | const makeStatefulMcpServer = Effect.acquireRelease( | ||
| 339 | Effect.promise<RealMcpServer>( | ||
| 340 | () => | ||
| 341 | new Promise<RealMcpServer>((resolve, reject) => { | ||
| 342 | const transports: Record<string, StreamableHTTPServerTransport> = {}; | ||
| 343 | const servers: Record<string, McpServer> = {}; | ||
| 344 | |||
| 345 | const createServer = () => { | ||
| 346 | let counter = 0; | ||
| 347 | const mcp = new McpServer( | ||
| 348 | { | ||
| 349 | name: "mcp-stateful-test-server", | ||
| 350 | version: "1.0.0", | ||
| 351 | }, | ||
| 352 | { | ||
| 353 | capabilities: { | ||
| 354 | tools: { | ||
| 355 | listChanged: true, | ||
| 356 | }, | ||
| 357 | }, | ||
| 358 | }, | ||
| 359 | ); | ||
| 360 | |||
| 361 | mcp.registerTool( | ||
| 362 | "increment_session", | ||
| 363 | { | ||
| 364 | title: "Increment Session", | ||
| 365 | description: "Increment session state", | ||
| 366 | inputSchema: {}, | ||
| 367 | }, | ||
| 368 | async () => { | ||
| 369 | counter += 1; | ||
| 370 | return { | ||
| 371 | content: [{ | ||
| 372 | type: "text", | ||
| 373 | text: String(counter), | ||
| 374 | }], | ||
| 375 | }; | ||
| 376 | }, | ||
| 377 | ); | ||
| 378 | |||
| 379 | mcp.registerTool( | ||
| 380 | "read_session", | ||
| 381 | { | ||
| 382 | title: "Read Session", | ||
| 383 | description: "Read current session state", | ||
| 384 | inputSchema: {}, | ||
| 385 | }, | ||
| 386 | async () => ({ | ||
| 387 | content: [{ | ||
| 388 | type: "text", | ||
| 389 | text: String(counter), | ||
| 390 | }], | ||
| 391 | }), | ||
| 392 | ); | ||
| 393 | |||
| 394 | return mcp; | ||
| 395 | }; | ||
| 396 | |||
| 397 | const app = createMcpExpressApp({ host: "127.0.0.1" }); | ||
| 398 | |||
| 399 | app.post("/mcp", async (req: any, res: any) => { | ||
| 400 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 401 | const sessionId = | ||
| 402 | typeof sessionIdHeader === "string" | ||
| 403 | ? sessionIdHeader | ||
| 404 | : Array.isArray(sessionIdHeader) | ||
| 405 | ? sessionIdHeader[0] | ||
| 406 | : undefined; | ||
| 407 | |||
| 408 | try { | ||
| 409 | let transport: StreamableHTTPServerTransport; | ||
| 410 | |||
| 411 | if (sessionId && transports[sessionId]) { | ||
| 412 | transport = transports[sessionId]; | ||
| 413 | } else { | ||
| 414 | transport = new StreamableHTTPServerTransport({ | ||
| 415 | sessionIdGenerator: () => randomUUID(), | ||
| 416 | onsessioninitialized: (newSessionId) => { | ||
| 417 | transports[newSessionId] = transport; | ||
| 418 | }, | ||
| 419 | }); | ||
| 420 | |||
| 421 | transport.onclose = () => { | ||
| 422 | const closedSessionId = transport.sessionId; | ||
| 423 | if (closedSessionId && transports[closedSessionId]) { | ||
| 424 | delete transports[closedSessionId]; | ||
| 425 | } | ||
| 426 | if (closedSessionId && servers[closedSessionId]) { | ||
| 427 | void servers[closedSessionId].close().catch(() => undefined); | ||
| 428 | delete servers[closedSessionId]; | ||
| 429 | } | ||
| 430 | }; | ||
| 431 | |||
| 432 | const server = createServer(); | ||
| 433 | await server.connect(transport); | ||
| 434 | const newSessionId = transport.sessionId; | ||
| 435 | if (newSessionId) { | ||
| 436 | servers[newSessionId] = server; | ||
| 437 | } | ||
| 438 | } | ||
| 439 | |||
| 440 | await transport.handleRequest(req, res, req.body); | ||
| 441 | } catch (error) { | ||
| 442 | if (!res.headersSent) { | ||
| 443 | res.status(500).json({ | ||
| 444 | jsonrpc: "2.0", | ||
| 445 | error: { | ||
| 446 | code: -32603, | ||
| 447 | message: error instanceof Error ? error.message : "Internal server error", | ||
| 448 | }, | ||
| 449 | id: null, | ||
| 450 | }); | ||
| 451 | } | ||
| 452 | } | ||
| 453 | }); | ||
| 454 | |||
| 455 | app.get("/mcp", async (req: any, res: any) => { | ||
| 456 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 457 | const sessionId = | ||
| 458 | typeof sessionIdHeader === "string" | ||
| 459 | ? sessionIdHeader | ||
| 460 | : Array.isArray(sessionIdHeader) | ||
| 461 | ? sessionIdHeader[0] | ||
| 462 | : undefined; | ||
| 463 | |||
| 464 | if (!sessionId || !transports[sessionId]) { | ||
| 465 | res.status(400).send("Invalid or missing session ID"); | ||
| 466 | return; | ||
| 467 | } | ||
| 468 | |||
| 469 | await transports[sessionId].handleRequest(req, res); | ||
| 470 | }); | ||
| 471 | |||
| 472 | app.delete("/mcp", async (req: any, res: any) => { | ||
| 473 | const sessionIdHeader = req.headers["mcp-session-id"]; | ||
| 474 | const sessionId = | ||
| 475 | typeof sessionIdHeader === "string" | ||
| 476 | ? sessionIdHeader | ||
| 477 | : Array.isArray(sessionIdHeader) | ||
| 478 | ? sessionIdHeader[0] | ||
| 479 | : undefined; | ||
| 480 | |||
| 481 | if (!sessionId || !transports[sessionId]) { | ||
| 482 | res.status(400).send("Invalid or missing session ID"); | ||
| 483 | return; | ||
| 484 | } | ||
| 485 | |||
| 486 | const transport = transports[sessionId]; | ||
| 487 | await transport.handleRequest(req, res, req.body); | ||
| 488 | await transport.close(); | ||
| 489 | delete transports[sessionId]; | ||
| 490 | |||
| 491 | if (servers[sessionId]) { | ||
| 492 | await servers[sessionId].close().catch(() => undefined); | ||
| 493 | delete servers[sessionId]; | ||
| 494 | } | ||
| 495 | }); | ||
| 496 | |||
| 497 | const listener = app.listen(0, "127.0.0.1", () => { | ||
| 498 | const address = listener.address(); | ||
| 499 | if (!address || typeof address === "string") { | ||
| 500 | reject(new Error("failed to resolve stateful MCP test server address")); | ||
| 501 | return; | ||
| 502 | } | ||
| 503 | |||
| 504 | resolve({ | ||
| 505 | endpoint: `http://127.0.0.1:${address.port}/mcp`, | ||
| 506 | close: async () => { | ||
| 507 | for (const transport of Object.values(transports)) { | ||
| 508 | await transport.close().catch(() => undefined); | ||
| 509 | } | ||
| 510 | for (const server of Object.values(servers)) { | ||
| 511 | await server.close().catch(() => undefined); | ||
| 512 | } | ||
| 513 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 514 | listener.close((error: Error | undefined) => { | ||
| 515 | if (error) { | ||
| 516 | closeReject(error); | ||
| 517 | return; | ||
| 518 | } | ||
| 519 | closeResolve(); | ||
| 520 | }); | ||
| 521 | }); | ||
| 522 | }, | ||
| 523 | }); | ||
| 524 | }); | ||
| 525 | |||
| 526 | listener.once("error", reject); | ||
| 527 | }), | ||
| 528 | ), | ||
| 529 | (server: RealMcpServer) => | ||
| 530 | Effect.tryPromise({ | ||
| 531 | try: () => server.close(), | ||
| 532 | catch: (error: unknown) => | ||
| 533 | error instanceof Error ? error : new Error(String(error)), | ||
| 534 | }).pipe(Effect.orDie), | ||
| 535 | ); | ||
| 536 | |||
| 537 | const STDIO_TEST_SERVER_SCRIPT = ` | ||
| 538 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| 539 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| 540 | import { z } from "zod/v4"; | ||
| 541 | |||
| 542 | const server = new McpServer( | ||
| 543 | { name: "mcp-stdio-test-server", version: "1.0.0" }, | ||
| 544 | { capabilities: { tools: { listChanged: true } } }, | ||
| 545 | ); | ||
| 546 | |||
| 547 | server.registerTool( | ||
| 548 | "echo_stdio", | ||
| 549 | { | ||
| 550 | title: "Echo Stdio", | ||
| 551 | description: "Echo a value over stdio", | ||
| 552 | inputSchema: { | ||
| 553 | value: z.string(), | ||
| 554 | }, | ||
| 555 | }, | ||
| 556 | async ({ value }) => ({ | ||
| 557 | content: [{ type: "text", text: "stdio:" + value }], | ||
| 558 | }), | ||
| 559 | ); | ||
| 560 | |||
| 561 | const transport = new StdioServerTransport(); | ||
| 562 | await server.connect(transport); | ||
| 563 | `; | ||
| 564 | |||
| 338 | const makeStaticAuthProvider = (accessToken: string): OAuthClientProvider => ({ | 565 | const 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 | }); |
| @@ -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.sourceId | 1654 | 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 | ) |
| @@ -32,13 +32,21 @@ const graphqlBinding = (defaultHeaders: Record<string, string> | null = null) => · 31 unmodified lines | |||
| 32 | }); | 32 | }); |
| 33 | 33 | ||
| 34 | const mcpBinding = (input: { | 34 | const 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 | ||
| 44 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ | 52 | const makeSource = (overrides: Partial<Source> = {}): Source => ({ |
| @@ -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 | ||
| 77 | export const parseJsonValue = <T>(input: { | 83 | export 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 | }; |
| @@ -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 | ||
| 31 | export const SourceImportAuthPolicySchema = Schema.Literal( | 32 | export 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 | ||
| 43 | export const StringArraySchema = Schema.Array(Schema.String); | ||
| 44 | |||
| 42 | export type JsonValue = | 45 | export type JsonValue = |
| 43 | | string | 46 | | string |
| 44 | | number | 47 | | number |
| @@ -177,6 +180,7 @@ export type SourceStatus = typeof SourceStatusSchema.Type; · 132 unmodified lines | |||
| 177 | export type SourceTransport = typeof SourceTransportSchema.Type; | 180 | export type SourceTransport = typeof SourceTransportSchema.Type; |
| 178 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; | 181 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; |
| 179 | export type StringMap = typeof StringMapSchema.Type; | 182 | export type StringMap = typeof StringMapSchema.Type; |
| 183 | export type StringArray = typeof StringArraySchema.Type; | ||
| 180 | export type SourceAuth = typeof SourceAuthSchema.Type; | 184 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 181 | export type SourceBinding = typeof SourceBindingSchema.Type; | 185 | export type SourceBinding = typeof SourceBindingSchema.Type; |
| 182 | export type Source = typeof SourceSchema.Type; | 186 | export type Source = typeof SourceSchema.Type; |
| @@ -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 | }; |
| @@ -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 | ||
| 74 | const mcpAdapterError = (message: string, cause?: unknown): Error => | ||
| 75 | sourceCoreEffectError( | ||
| 76 | "mcp/adapter", | ||
| 77 | cause === undefined | ||
| 78 | ? message | ||
| 79 | : `${message}: ${cause instanceof Error ? cause.message : String(cause)}`, | ||
| 80 | ); | ||
| 81 | |||
| 74 | const OptionalNullableStringArraySchema = Schema.optional( | 82 | const 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 | }; |
| @@ -0,0 +1,151 @@ | |||
| 1 | import * as Data from "effect/Data"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as Scope from "effect/Scope"; | ||
| 5 | |||
| 6 | import type { McpConnection, McpConnector } from "./tools"; | ||
| 7 | |||
| 8 | export class McpConnectionPoolError extends Data.TaggedError("McpConnectionPoolError")<{ | ||
| 9 | readonly operation: "connect" | "close"; | ||
| 10 | readonly message: string; | ||
| 11 | readonly cause: unknown; | ||
| 12 | }> {} | ||
| 13 | |||
| 14 | type PoolEntry = { | ||
| 15 | scope: Scope.CloseableScope; | ||
| 16 | connection: McpConnector; | ||
| 17 | }; | ||
| 18 | |||
| 19 | const pooledRuns = new Map<string, Map<string, PoolEntry>>(); | ||
| 20 | |||
| 21 | const mcpConnectionPoolError = (input: { | ||
| 22 | operation: "connect" | "close"; | ||
| 23 | message: string; | ||
| 24 | cause: unknown; | ||
| 25 | }): McpConnectionPoolError => new McpConnectionPoolError(input); | ||
| 26 | |||
| 27 | const deletePoolEntry = (runId: string, sourceKey: string, entry: PoolEntry) => { | ||
| 28 | const runEntries = pooledRuns.get(runId); | ||
| 29 | if (!runEntries || runEntries.get(sourceKey) !== entry) { | ||
| 30 | return; | ||
| 31 | } | ||
| 32 | |||
| 33 | runEntries.delete(sourceKey); | ||
| 34 | if (runEntries.size === 0) { | ||
| 35 | pooledRuns.delete(runId); | ||
| 36 | } | ||
| 37 | }; | ||
| 38 | |||
| 39 | const closePooledConnection = ( | ||
| 40 | connection: McpConnection, | ||
| 41 | ): Effect.Effect<void, never, never> => | ||
| 42 | Effect.tryPromise({ | ||
| 43 | try: () => Promise.resolve(connection.close?.()), | ||
| 44 | catch: (cause) => | ||
| 45 | mcpConnectionPoolError({ | ||
| 46 | operation: "close", | ||
| 47 | message: "Failed closing pooled MCP connection", | ||
| 48 | cause, | ||
| 49 | }), | ||
| 50 | }).pipe(Effect.ignore); | ||
| 51 | |||
| 52 | const createPoolEntry = (connect: McpConnector): PoolEntry => { | ||
| 53 | const scope = Effect.runSync(Scope.make()); | ||
| 54 | const connection = Effect.runSync( | ||
| 55 | Effect.cached( | ||
| 56 | Effect.acquireRelease( | ||
| 57 | connect.pipe( | ||
| 58 | Effect.mapError((cause) => | ||
| 59 | mcpConnectionPoolError({ | ||
| 60 | operation: "connect", | ||
| 61 | message: "Failed creating pooled MCP connection", | ||
| 62 | cause, | ||
| 63 | })), | ||
| 64 | ), | ||
| 65 | closePooledConnection, | ||
| 66 | ).pipe(Scope.extend(scope)), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | |||
| 70 | return { | ||
| 71 | scope, | ||
| 72 | connection, | ||
| 73 | }; | ||
| 74 | }; | ||
| 75 | |||
| 76 | const getOrCreatePoolEntry = (input: { | ||
| 77 | runId: string; | ||
| 78 | sourceKey: string; | ||
| 79 | connect: McpConnector; | ||
| 80 | }): PoolEntry => { | ||
| 81 | const existing = pooledRuns.get(input.runId)?.get(input.sourceKey); | ||
| 82 | if (existing) { | ||
| 83 | return existing; | ||
| 84 | } | ||
| 85 | |||
| 86 | let runEntries = pooledRuns.get(input.runId); | ||
| 87 | if (!runEntries) { | ||
| 88 | runEntries = new Map<string, PoolEntry>(); | ||
| 89 | pooledRuns.set(input.runId, runEntries); | ||
| 90 | } | ||
| 91 | |||
| 92 | const entry = createPoolEntry(input.connect); | ||
| 93 | entry.connection = entry.connection.pipe( | ||
| 94 | Effect.tapError(() => | ||
| 95 | Effect.sync(() => { | ||
| 96 | deletePoolEntry(input.runId, input.sourceKey, entry); | ||
| 97 | }).pipe( | ||
| 98 | Effect.zipRight(closePoolEntry(entry)), | ||
| 99 | )), | ||
| 100 | ); | ||
| 101 | runEntries.set(input.sourceKey, entry); | ||
| 102 | return entry; | ||
| 103 | }; | ||
| 104 | |||
| 105 | const closePoolEntry = (entry: PoolEntry): Effect.Effect<void, never, never> => | ||
| 106 | Scope.close(entry.scope, Exit.void).pipe(Effect.ignore); | ||
| 107 | |||
| 108 | export const createPooledMcpConnector = (input: { | ||
| 109 | connect: McpConnector; | ||
| 110 | runId?: string; | ||
| 111 | sourceKey?: string; | ||
| 112 | }): McpConnector => { | ||
| 113 | if (!input.runId || !input.sourceKey) { | ||
| 114 | return input.connect; | ||
| 115 | } | ||
| 116 | |||
| 117 | return Effect.gen(function* () { | ||
| 118 | const entry = getOrCreatePoolEntry({ | ||
| 119 | runId: input.runId!, | ||
| 120 | sourceKey: input.sourceKey!, | ||
| 121 | connect: input.connect, | ||
| 122 | }); | ||
| 123 | const connection = yield* entry.connection; | ||
| 124 | return { | ||
| 125 | client: connection.client, | ||
| 126 | close: async () => undefined, | ||
| 127 | }; | ||
| 128 | }); | ||
| 129 | }; | ||
| 130 | |||
| 131 | export const clearMcpConnectionPoolRun = ( | ||
| 132 | runId: string, | ||
| 133 | ): Effect.Effect<void, never, never> => { | ||
| 134 | const runEntries = pooledRuns.get(runId); | ||
| 135 | if (!runEntries) { | ||
| 136 | return Effect.void; | ||
| 137 | } | ||
| 138 | |||
| 139 | pooledRuns.delete(runId); | ||
| 140 | return Effect.forEach([...runEntries.values()], closePoolEntry, { | ||
| 141 | discard: true, | ||
| 142 | }); | ||
| 143 | }; | ||
| 144 | |||
| 145 | export const clearAllMcpConnectionPools = (): Effect.Effect<void, never, never> => { | ||
| 146 | const runEntries = [...pooledRuns.values()].flatMap((entries) => [...entries.values()]); | ||
| 147 | pooledRuns.clear(); | ||
| 148 | return Effect.forEach(runEntries, closePoolEntry, { | ||
| 149 | discard: true, | ||
| 150 | }); | ||
| 151 | }; | ||
| @@ -2,33 +2,71 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. · 1 unmodified line | |||
| 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| 5 | import * as Data from "effect/Data"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 5 | 7 | ||
| 6 | import type { McpConnection, McpConnector } from "./tools"; | 8 | import type { McpConnection, McpConnector } from "./tools"; |
| 7 | 9 | ||
| 8 | export type McpTransportPreference = "auto" | "streamable-http" | "sse"; | 10 | export type McpTransportPreference = "auto" | "streamable-http" | "sse" | "stdio"; |
| 9 | 11 | ||
| 10 | export type CreateSdkMcpConnectorInput = { | 12 | export 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 | ||
| 20 | const createEndpoint = ( | 26 | export 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)) { | 32 | export 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 | |
| 38 | const mcpConnectionError = (input: { | ||
| 39 | transport: McpTransportPreference; | ||
| 40 | message: string; | ||
| 41 | cause: unknown; | ||
| 42 | }): McpConnectionError => new McpConnectionError(input); | ||
| 43 | |||
| 44 | const createEndpoint = (input: { | ||
| 45 | endpoint: string | undefined; | ||
| 46 | queryParams: Record<string, string>; | ||
| 47 | transport: Exclude<McpTransportPreference, "stdio">; | ||
| 48 | }): Effect.Effect<URL, McpConnectionError, never> => | ||
| 49 | Effect.try({ | ||
| 50 | try: () => { | ||
| 51 | if (!input.endpoint) { | ||
| 52 | throw new Error("MCP endpoint is required for HTTP/SSE transports"); | ||
| 53 | } | ||
| 54 | |||
| 55 | const url = new URL(input.endpoint); | ||
| 56 | |||
| 57 | for (const [key, value] of Object.entries(input.queryParams)) { | ||
| 58 | url.searchParams.set(key, value); | ||
| 59 | } | ||
| 60 | |||
| 61 | return url; | ||
| 62 | }, | ||
| 63 | catch: (cause) => | ||
| 64 | mcpConnectionError({ | ||
| 65 | transport: input.transport, | ||
| 66 | message: "Failed building MCP endpoint URL", | ||
| 67 | cause, | ||
| 68 | }), | ||
| 69 | }); | ||
| 32 | 70 | ||
| 33 | type FetchInput = Parameters<typeof fetch>[0]; | 71 | type FetchInput = Parameters<typeof fetch>[0]; |
| 34 | type FetchInit = Parameters<typeof fetch>[1]; | 72 | type 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 | ||
| 96 | const closeClient = (client: Client): Effect.Effect<void, never, never> => | ||
| 97 | Effect.tryPromise({ | ||
| 98 | try: () => client.close(), | ||
| 99 | catch: (cause) => | ||
| 100 | mcpConnectionError({ | ||
| 101 | transport: "auto", | ||
| 102 | message: "Failed closing MCP client", | ||
| 103 | cause, | ||
| 104 | }), | ||
| 105 | }).pipe(Effect.ignore); | ||
| 106 | |||
| 107 | type DynamicImport = <TModule>(specifier: string) => Promise<TModule>; | ||
| 108 | |||
| 109 | const dynamicImport = new Function( | ||
| 110 | "specifier", | ||
| 111 | "return import(specifier)", | ||
| 112 | ) as DynamicImport; | ||
| 113 | |||
| 114 | const loadStdioClientTransport = (): Effect.Effect< | ||
| 115 | typeof import("@modelcontextprotocol/sdk/client/stdio.js").StdioClientTransport, | ||
| 116 | McpConnectionError, | ||
| 117 | never | ||
| 118 | > => | ||
| 119 | Effect.tryPromise({ | ||
| 120 | try: async () => | ||
| 121 | (await dynamicImport<typeof import("@modelcontextprotocol/sdk/client/stdio.js")>( | ||
| 122 | "@modelcontextprotocol/sdk/client/stdio.js", | ||
| 123 | )).StdioClientTransport, | ||
| 124 | catch: (cause) => | ||
| 125 | mcpConnectionError({ | ||
| 126 | transport: "stdio", | ||
| 127 | message: "Failed loading MCP stdio transport", | ||
| 128 | cause, | ||
| 129 | }), | ||
| 130 | }); | ||
| 131 | |||
| 132 | const connectClient = (input: { | ||
| 133 | createClient: () => Client; | ||
| 134 | transport: McpTransportPreference; | ||
| 135 | createTransport: () => Parameters<Client["connect"]>[0]; | ||
| 136 | }): Effect.Effect<McpConnection, McpConnectionError, never> => | ||
| 137 | Effect.gen(function* () { | ||
| 138 | const client = input.createClient(); | ||
| 139 | const transportInstance = input.createTransport(); | ||
| 140 | |||
| 141 | return yield* Effect.tryPromise({ | ||
| 142 | try: () => client.connect(transportInstance), | ||
| 143 | catch: (cause) => | ||
| 144 | mcpConnectionError({ | ||
| 145 | transport: input.transport, | ||
| 146 | message: `Failed connecting to MCP server via ${input.transport}`, | ||
| 147 | cause, | ||
| 148 | }), | ||
| 149 | }).pipe( | ||
| 150 | Effect.as(connectionFromClient(client)), | ||
| 151 | Effect.onError(() => closeClient(client)), | ||
| 152 | ); | ||
| 153 | }); | ||
| 154 | |||
| 58 | export const createSdkMcpConnector = ( | 155 | export 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 > 0 | 162 | 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 | }; |
| @@ -1,7 +1,15 @@ | |||
| 1 | export { | ||
| 2 | clearAllMcpConnectionPools, | ||
| 3 | clearMcpConnectionPoolRun, | ||
| 4 | createPooledMcpConnector, | ||
| 5 | McpConnectionPoolError, | ||
| 6 | } from "./connection-pool"; | ||
| 1 | export { | 7 | export { |
| 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"; |
| 6 | export { detectMcpSource } from "./discovery"; | 14 | export { detectMcpSource } from "./discovery"; |
| 7 | export { McpLocalConfigBindingSchema } from "./local-config"; | 15 | export { McpLocalConfigBindingSchema } from "./local-config"; |
| @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| @@ -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; |
| @@ -53,7 +53,7 @@ export type McpConnection = { · 52 unmodified lines | |||
| 53 | close?: () => Promise<void>; | 53 | close?: () => Promise<void>; |
| 54 | }; | 54 | }; |
| 55 | 55 | ||
| 56 | export type McpConnector = () => Promise<McpConnection>; | 56 | export type McpConnector = Effect.Effect<McpConnection, unknown, never>; |
| 57 | 57 | ||
| 58 | export type McpDiscoveryElicitationContext = { | 58 | export 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 | |||
| 447 | export const createMcpConnectorFromClient = ( | 444 | export 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 | }); |