History

merge main

Rhys Sullivan committed 2 months ago

Commit 132b3ff2 parents aa4f2b2 ad702a933 files · +2812 −234Browse files

Unified · Split

33 files
  1. .changeset/mcp-stdio-edit-ui.md +26 −0
  2. .changeset/misconfigured-health-status.md +7 −0
  3. .changeset/oauth-refresh-cross-session.md +13 −0
  4. apps/cloud/src/engine/execution-stack.ts +3 −100
  5. apps/cloud/src/engine/first-party-oauth-clients.test.ts +99 −10
  6. apps/cloud/src/engine/first-party-oauth-clients.ts +336 −0
  7. apps/cloud/src/env-augment.d.ts +20 −0
  8. apps/local/vite.config.ts +35 −1
  9. e2e/local/stdio-mcp-edit-ui.test.ts +138 −0
  10. e2e/local/stdio-mcp.test.ts +42 −0
  11. e2e/scenarios/google-disabled-api.test.ts +364 −0
  12. e2e/selfhost/oauth-refresh-cross-session.test.ts +275 −0
  13. packages/core/api/src/admin/api.ts +1 −1
  14. packages/core/sdk/src/executor.ts +94 −23
  15. packages/core/sdk/src/health-check.test.ts +72 −0
  16. packages/core/sdk/src/health-check.ts +62 −6
  17. packages/core/sdk/src/index.ts +1 −0
  18. packages/core/sdk/src/oauth-client.ts +19 −0
  19. packages/core/sdk/src/oauth-first-party.test.ts +36 −0
  20. packages/core/sdk/src/oauth-flow.test.ts +423 −9
  21. packages/core/sdk/src/oauth-helpers.test.ts +183 −1
  22. packages/core/sdk/src/oauth-helpers.ts +169 −33
  23. packages/core/sdk/src/oauth-service.ts +23 −3
  24. packages/core/sdk/src/shared.ts +1 −0
  25. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +1 −13
  26. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +158 −22
  27. packages/plugins/mcp/src/react/stdio-fields.test.ts +70 −0
  28. packages/plugins/mcp/src/react/stdio-fields.ts +51 −0
  29. packages/plugins/openapi/src/sdk/backing.ts +4 −2
  30. packages/plugins/openapi/src/sdk/presets.ts +2 −2
  31. packages/react/src/components/accounts-section.tsx +57 −0
  32. packages/react/src/components/add-account-modal.tsx +7 −1
  33. packages/react/src/lib/health-display.ts +20 −7
.changeset/mcp-stdio-edit-ui.md+26−0
@@ -0,0 +1,26 @@
1---
2"executor": patch
3---
4
5**Stdio MCP integrations can be edited from the UI**
6
7The integration Edit sheet showed stdio servers as read-only text and told you
8to remove and recreate the integration to change its command. Fixing a typo in
9an argument, moving a server to a new path, or adding a static environment
10variable meant editing `executor.jsonc` by hand, or losing the integration's
11connections and tool policies to a delete-and-re-add.
12
13The sheet now edits the command, its arguments, the working directory, and the
14declared environment map, staged and applied by the sheet's own Save like the
15remote editor beside it. Arguments use the same quote-aware parsing as the add
16flow, so an argument containing spaces survives a round trip.
17
18The environment field edits the DECLARED static variables only. A stdio server
19receives those plus a small fixed base set — it does not inherit executor's
20environment — and secret values still belong to the connection, entered per
21account against the server's declared `stdio_env` method.
22
23Saving revises the integration config, which is already enough to rebuild the
24tool catalog: connections whose catalog predates the revision re-list on their
25next read, so an edited command's tools are correct without an explicit
26refresh.
.changeset/misconfigured-health-status.md+7−0
@@ -0,0 +1,7 @@
1---
2"executor": patch
3---
4
5**A disabled upstream API now reports `misconfigured` instead of `expired`**
6
7A 403 caused by the provider disabling the API (Google SERVICE_DISABLED / accessNotConfigured shapes) is a configuration problem, not a credential problem — reconnecting cannot fix it. Health checks now classify it as a fifth status, `misconfigured`, shown with an amber badge and a link to the provider console instead of a Reconnect prompt. Ordinary 401/403 credential rejections still report `expired`.
.changeset/oauth-refresh-cross-session.md+13−0
@@ -0,0 +1,13 @@
1---
2"@executor-js/sdk": patch
3---
4
5Share the OAuth refresh gate across execution stacks so a rotating refresh token is redeemed once.
6
7The in-flight refresh gate was built inside `createExecutor`, so it only covered one execution stack. A host builds a fresh stack per MCP session, and now per request, so two sessions resolving the same connection each read the same stored refresh token and each believed they were the refresh winner. Against a provider that rotates refresh tokens, the loser redeems a token the winner already spent, and a provider that detects reuse revokes the whole token family: the connection dies and the user has to reauthorize. The first refresh always succeeds, so the fault stayed invisible until a later expiry.
8
9The gate now hangs off the root database handle, which is the object hosts already share across sessions and requests, so every stack over one handle converges on one gate. Its key includes the tenant, because a gate that spans tenants would otherwise let two tenants collide on one entry.
10
11The grant also runs on its own detached fiber that callers await, rather than on whichever caller registered it. Sharing an entry across stacks would otherwise share the first caller's cancellation: a disconnected MCP client or an execution deadline would fail every peer waiting on that entry, and would abandon a refresh token the authorization server had already rotated, which is itself a dead connection. A cancelled peer now detaches without touching the grant, and a grant nobody is left waiting on still settles and still persists the rotated token.
12
13Deduplication covers one database handle in one process. A host that builds a fresh handle per request or per session, and any multi-instance or multi-replica deployment, is out of scope here and still needs database-backed coordination, such as a compare-and-swap on the stored refresh token.
apps/cloud/src/engine/execution-stack.ts+3−100
@@ -42,19 +42,14 @@ import { · 41 unmodified lines
42 PluginsProvider,42 PluginsProvider,
43 collectTables,43 collectTables,
44} from "@executor-js/api/server";44} from "@executor-js/api/server";
45import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
47import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
48import {46import { type AnyPlugin } from "@executor-js/sdk";
49 IntegrationSlug,
50 type AnyPlugin,
51 type FirstPartyOAuthClientConfig,
52} from "@executor-js/sdk";
53 47
54import executorConfig from "../../executor.config";48import executorConfig from "../../executor.config";
55import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";49import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";
56import { DbService } from "../db/db";50import { DbService } from "../db/db";
57import { cloudDbProviderLayer } from "../db/fuma";51import { cloudDbProviderLayer } from "../db/fuma";
52import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients";
58 53
59export { makeExecutionStack } from "@executor-js/api/server";54export { makeExecutionStack } from "@executor-js/api/server";
60 55
@@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines
95 */90 */
96export const CLOUD_MOUNT_PREFIX = "/api" as const;91export const CLOUD_MOUNT_PREFIX = "/api" as const;
97 92
98// Consumer Google launch boundary. Keep this list aligned with the scopes
99// submitted for the Executor-owned production app: ordinary Workspace services
100// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
101// BigQuery, and Cloud Resource Manager have materially different audiences or
102// provider requirements and remain BYO OAuth. The same scope source builds each
103// catalog auth template, preventing picker/start drift.
104const GOOGLE_FIRST_PARTY_PRESET_IDS = [
105 "google-calendar",
106 "google-meet",
107 "google-gmail",
108 "google-sheets",
109 "google-drive",
110 "google-docs",
111 "google-slides",
112 "google-forms",
113 "google-tasks",
114 "google-people",
115 "google-photos-library",
116 "google-photos-picker",
117 "google-search-console",
118] as const;
119
120const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
121 ...new Set([
122 ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
123 // Connections created before the full-Gmail review retain this declared
124 // scope on reconnect. New Gmail presets request `mail.google.com`.
125 "https://www.googleapis.com/auth/gmail.modify",
126 ]),
127];
128
129// Executor-owned provider apps, enabled per provider by setting BOTH env vars
130// (id + secret). Each provider-side registration must list
131// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
132// travels inside OAuth `state`, so the single static callback serves every org.
133//
134// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
135// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
136// an emulated provider (`@executor-js/emulate`) and run the complete flow.
137// Production leaves them unset.
138export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
139 ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
140 ? [
141 {
142 name: "github",
143 authorizationUrl:
144 env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
145 tokenUrl:
146 env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
147 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
148 clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
149 integrations: [IntegrationSlug.make("github_rest")],
150 // GitHub App user access tokens do not use classic OAuth scopes;
151 // their capabilities come from the app's registered permissions.
152 authorizationScopes: [],
153 },
154 ]
155 : []),
156 ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
157 ? [
158 {
159 name: "google",
160 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
161 tokenUrl: "https://oauth2.googleapis.com/token",
162 clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
163 clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
164 allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
165 // Withdrawn from the connect picker: no new connection is offered the
166 // Executor-owned Google app. The entry stays declared on purpose —
167 // every connection already minted against it keeps refreshing and
168 // reconnecting through it. Deleting this block, or unsetting the env
169 // vars, would strand those connections instead.
170 unlisted: true,
171 },
172 ]
173 : []),
174 ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET
175 ? [
176 {
177 name: "slack",
178 authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
179 tokenUrl: "https://slack.com/api/oauth.v2.user.access",
180 resource: "https://mcp.slack.com",
181 clientId: env.FIRST_PARTY_SLACK_CLIENT_ID,
182 clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET,
183 integrations: [IntegrationSlug.make("slack")],
184 allowedScopes: slackMcpUserScopes,
185 },
186 ]
187 : []),
188];
189
190export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({93export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
191 // SSRF / private-network egress guard. Config-driven, NOT a test flag:94 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
192 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);95 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -198,7 +101,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
198 // WorkOS Vault is cloud's credential storage implementation detail, not a101 // WorkOS Vault is cloud's credential storage implementation detail, not a
199 // user-selectable provider surface.102 // user-selectable provider surface.
200 exposeCredentialProviders: false,103 exposeCredentialProviders: false,
201 firstPartyOAuthClients: cloudFirstPartyOAuthClients(),104 firstPartyOAuthClients: firstPartyOAuthClientsFor(env),
202 // Workers cancel request-scoped I/O once the response settles; the ambient105 // Workers cancel request-scoped I/O once the response settles; the ambient
203 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),106 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204 // so stale tool-catalog rebuilds that outlive a read still converge.107 // so stale tool-catalog rebuilds that outlive a read still converge.
apps/cloud/src/engine/first-party-oauth-clients.test.ts+99−10
@@ -1,7 +1,100 @@
1import { env } from "cloudflare:workers";1import { describe, expect, it } from "@effect/vitest";
2import { beforeAll, describe, expect, it } from "@effect/vitest";
3 2
4import { cloudFirstPartyOAuthClients } from "./execution-stack";3import {
4 firstPartyOAuthClientsFor,
5 type FirstPartyOAuthClientEnv,
6} from "./first-party-oauth-clients";
7
8const completeEnv: FirstPartyOAuthClientEnv = {
9 FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id",
10 FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret",
11 FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id",
12 FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret",
13 FIRST_PARTY_BOX_CLIENT_ID: "box-id",
14 FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret",
15 FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id",
16 FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret",
17 FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id",
18 FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret",
19 FIRST_PARTY_GITHUB_CLIENT_ID: "github-id",
20 FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret",
21 FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id",
22 FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret",
23 FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id",
24 FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret",
25 FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id",
26 FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret",
27 FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id",
28 FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret",
29 FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id",
30 FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret",
31 FIRST_PARTY_NOTION_CLIENT_ID: "notion-id",
32 FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret",
33 FIRST_PARTY_SLACK_CLIENT_ID: "slack-id",
34 FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret",
35};
36
37describe("cloud first-party OAuth clients", () => {
38 it("enables every registered OAuth 2 provider from complete secret pairs", () => {
39 const clients = firstPartyOAuthClientsFor(completeEnv);
40
41 expect(clients.map((client) => client.name)).toEqual([
42 "airtable",
43 "atlassian",
44 "box",
45 "clickup",
46 "figma",
47 "github",
48 "gitlab",
49 "google",
50 "hubspot",
51 "linear",
52 "microsoft",
53 "notion",
54 "slack",
55 ]);
56 });
57
58 it("fails closed when either half of a provider secret pair is absent", () => {
59 expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]);
60 expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]);
61 });
62
63 it("carries provider-specific authorization and token contracts", () => {
64 const byName = new Map(
65 firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]),
66 );
67
68 expect(byName.get("airtable")).toMatchObject({
69 tokenEndpointAuthMethod: "basic",
70 });
71 expect(byName.get("atlassian")).toMatchObject({
72 tokenRequestFormat: "json",
73 authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
74 });
75 expect(byName.get("figma")).toMatchObject({
76 tokenEndpointAuthMethod: "basic",
77 allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]),
78 });
79 expect(byName.get("hubspot")).toMatchObject({
80 tokenUrl: "https://api.hubapi.com/oauth/v3/token",
81 authorizationExtraParams: {
82 optional_scope: "content crm.objects.custom.read crm.schemas.custom.read",
83 },
84 });
85 expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," });
86 expect(byName.get("microsoft")).toMatchObject({
87 additionalAuthorizationScopes: ["offline_access"],
88 allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]),
89 });
90 expect(byName.get("notion")).toMatchObject({
91 authorizationScopes: [],
92 authorizationExtraParams: { owner: "user" },
93 tokenEndpointAuthMethod: "basic",
94 tokenRequestFormat: "json",
95 });
96 });
97});
5 98
6// The reviewed consumer scope boundary of the Executor-owned Google app.99// The reviewed consumer scope boundary of the Executor-owned Google app.
7//100//
@@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines
12// scopes an `oauth.start` requests, and that admin scopes are refused).105// scopes an `oauth.start` requests, and that admin scopes are refused).
13const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;106const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;
14 107
15describe("cloud first-party oauth clients", () => {108describe("cloud first-party Google app", () => {
16 beforeAll(() => {109 const google = () =>
17 env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client";110 firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");
18 env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret";
19 });
20
21 const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google");
22 111
23 it("declares the Google app but withholds it from every listing", () => {112 it("declares the Google app but withholds it from every listing", () => {
24 const client = google();113 const client = google();
apps/cloud/src/engine/first-party-oauth-clients.ts+336−0
@@ -0,0 +1,336 @@
1import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets";
2import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
3import {
4 MICROSOFT_AUTHORIZATION_URL,
5 MICROSOFT_TOKEN_URL,
6} from "@executor-js/plugin-openapi/providers/microsoft";
7import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9
10/** Cloud secret bindings that enable host-operated OAuth clients. A provider
11 * is absent unless both values in its pair are present. */
12export interface FirstPartyOAuthClientEnv {
13 readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
14 readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
15 readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
16 readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
17 readonly FIRST_PARTY_BOX_CLIENT_ID?: string;
18 readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string;
19 readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
20 readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
21 readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string;
22 readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
23 readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string;
24 readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
25 readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
26 readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string;
27 readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string;
28 readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
29 readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
30 readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
31 readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
32 readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
33 readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string;
34 readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
35 readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
36 readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
37 readonly FIRST_PARTY_NOTION_CLIENT_ID?: string;
38 readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
39 readonly FIRST_PARTY_SLACK_CLIENT_ID?: string;
40 readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
41}
42
43const AIRTABLE_SCOPES = [
44 "data.recordComments:read",
45 "data.recordComments:write",
46 "data.records:read",
47 "data.records:write",
48 "data.records:manage",
49 "schema.bases:read",
50 "schema.bases:write",
51 "user.email:read",
52 "workspacesAndBases:read",
53 "workspacesAndBases:write",
54 "workspacesAndBases.shares:manage",
55 "webhook:manage",
56] as const;
57
58const ATLASSIAN_SCOPES = [
59 "read:me",
60 "read:account",
61 "read:jira-work",
62 "manage:jira-project",
63 "manage:jira-configuration",
64 "read:jira-user",
65 "write:jira-work",
66 "manage:jira-webhook",
67 "read:servicedesk-request",
68 "manage:servicedesk-customer",
69 "write:servicedesk-request",
70 "write:confluence-content",
71 "read:confluence-space.summary",
72 "write:confluence-space",
73 "write:confluence-file",
74 "read:confluence-props",
75 "write:confluence-props",
76 "manage:confluence-configuration",
77 "read:confluence-content.all",
78 "read:confluence-content.summary",
79 "search:confluence",
80 "read:confluence-content.permission",
81 "read:confluence-user",
82 "read:confluence-groups",
83 "write:confluence-groups",
84 "offline_access",
85] as const;
86
87const BOX_SCOPES = [
88 "root_readonly",
89 "root_readwrite",
90 "sign_requests.readwrite",
91 "ai.readwrite",
92 "manage_webhook",
93 "manage_triggers",
94] as const;
95
96const GITLAB_SCOPES = [
97 "api",
98 "read_api",
99 "read_user",
100 "create_runner",
101 "manage_runner",
102 "k8s_proxy",
103 "mcp",
104 "mcp_orbit",
105 "read_repository",
106 "write_repository",
107 "read_registry",
108 "write_registry",
109 "read_virtual_registry",
110 "write_virtual_registry",
111 "read_observability",
112 "write_observability",
113 "ai_features",
114 "openid",
115 "profile",
116 "email",
117] as const;
118
119const HUBSPOT_REQUIRED_SCOPES = [
120 "oauth",
121 "account-info.security.read",
122 "cms.domains.read",
123 "cms.domains.write",
124 "crm.export",
125 "crm.import",
126 "crm.lists.read",
127 "crm.lists.write",
128 "crm.objects.companies.read",
129 "crm.objects.companies.write",
130 "crm.objects.contacts.read",
131 "crm.objects.contacts.write",
132 "crm.objects.deals.read",
133 "crm.objects.deals.write",
134 "crm.objects.marketing_events.read",
135 "crm.objects.marketing_events.write",
136 "crm.objects.owners.read",
137 "crm.objects.quotes.read",
138 "crm.objects.quotes.write",
139 "crm.schemas.companies.read",
140 "crm.schemas.companies.write",
141 "crm.schemas.contacts.read",
142 "crm.schemas.contacts.write",
143 "sales-email-read",
144 "settings.users.read",
145 "settings.users.write",
146 "tickets",
147 "timeline",
148] as const;
149
150const HUBSPOT_OPTIONAL_SCOPES = [
151 "content",
152 "crm.objects.custom.read",
153 "crm.schemas.custom.read",
154] as const;
155
156const MICROSOFT_SCOPES = [
157 "User.Read",
158 "Calendars.ReadWrite",
159 "Channel.ReadBasic.All",
160 "ChannelMessage.Read.All",
161 "ChannelMessage.Send",
162 "Chat.ReadWrite",
163 "Files.ReadWrite.All",
164 "Mail.ReadWrite",
165 "Mail.Send",
166 "MailboxSettings.ReadWrite",
167 "OnlineMeetings.ReadWrite",
168 "Sites.ReadWrite.All",
169 "Team.ReadBasic.All",
170 "offline_access",
171] as const;
172
173// Consumer Google launch boundary. Keep this list aligned with the scopes
174// submitted for the Executor-owned production app: ordinary Workspace services
175// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
176// BigQuery, and Cloud Resource Manager have materially different audiences or
177// provider requirements and remain BYO OAuth. The same scope source builds each
178// catalog auth template, preventing picker/start drift.
179const GOOGLE_FIRST_PARTY_PRESET_IDS = [
180 "google-calendar",
181 "google-meet",
182 "google-gmail",
183 "google-sheets",
184 "google-drive",
185 "google-docs",
186 "google-slides",
187 "google-forms",
188 "google-tasks",
189 "google-people",
190 "google-photos-library",
191 "google-photos-picker",
192 "google-search-console",
193] as const;
194
195const GOOGLE_ALLOWED_SCOPES: readonly string[] = [
196 ...new Set([
197 ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
198 // Connections created before the full-Gmail review retain this declared
199 // scope on reconnect. New Gmail presets request `mail.google.com`.
200 "https://www.googleapis.com/auth/gmail.modify",
201 ]),
202];
203
204const client = (
205 clientId: string | undefined,
206 clientSecret: string | undefined,
207 config: Omit<FirstPartyOAuthClientConfig, "clientId" | "clientSecret">,
208): readonly FirstPartyOAuthClientConfig[] =>
209 clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : [];
210
211/** Build the enabled first-party registry from secret bindings. Provider
212 * protocol details and scope ceilings live here so the cloud composition root
213 * cannot drift from the registered production clients.
214 *
215 * Each provider-side registration must list
216 * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
217 * travels inside OAuth `state`, so the single static callback serves every
218 * org.
219 *
220 * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
221 * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
222 * an emulated provider (`@executor-js/emulate`) and run the complete flow.
223 * Production leaves them unset. */
224export const firstPartyOAuthClientsFor = (
225 env: FirstPartyOAuthClientEnv,
226): readonly FirstPartyOAuthClientConfig[] => [
227 ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, {
228 name: "airtable",
229 authorizationUrl: "https://airtable.com/oauth2/v1/authorize",
230 tokenUrl: "https://airtable.com/oauth2/v1/token",
231 tokenEndpointAuthMethod: "basic",
232 authorizationScopes: AIRTABLE_SCOPES,
233 allowedScopes: AIRTABLE_SCOPES,
234 }),
235 ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, {
236 name: "atlassian",
237 authorizationUrl: "https://auth.atlassian.com/authorize",
238 tokenUrl: "https://auth.atlassian.com/oauth/token",
239 tokenRequestFormat: "json",
240 authorizationScopes: ATLASSIAN_SCOPES,
241 allowedScopes: ATLASSIAN_SCOPES,
242 authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
243 }),
244 ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, {
245 name: "box",
246 authorizationUrl: "https://account.box.com/api/oauth2/authorize",
247 tokenUrl: "https://api.box.com/oauth2/token",
248 authorizationScopes: BOX_SCOPES,
249 allowedScopes: BOX_SCOPES,
250 }),
251 ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, {
252 name: "clickup",
253 authorizationUrl: "https://app.clickup.com/api",
254 tokenUrl: "https://api.clickup.com/api/v2/oauth/token",
255 tokenRequestFormat: "json",
256 authorizationScopes: [],
257 }),
258 ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, {
259 name: "figma",
260 authorizationUrl: "https://www.figma.com/oauth",
261 tokenUrl: "https://api.figma.com/v1/oauth/token",
262 tokenEndpointAuthMethod: "basic",
263 integrations: [IntegrationSlug.make("figma_api")],
264 authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
265 allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
266 }),
267 ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, {
268 name: "github",
269 authorizationUrl:
270 env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
271 tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
272 integrations: [IntegrationSlug.make("github_rest")],
273 // GitHub App user access tokens do not use classic OAuth scopes; their
274 // capabilities come from the app's registered permissions.
275 authorizationScopes: [],
276 }),
277 ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, {
278 name: "gitlab",
279 authorizationUrl: "https://gitlab.com/oauth/authorize",
280 tokenUrl: "https://gitlab.com/oauth/token",
281 authorizationScopes: GITLAB_SCOPES,
282 allowedScopes: GITLAB_SCOPES,
283 }),
284 ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, {
285 name: "google",
286 authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
287 tokenUrl: "https://oauth2.googleapis.com/token",
288 allowedScopes: GOOGLE_ALLOWED_SCOPES,
289 // Withdrawn from the connect picker: no new connection is offered the
290 // Executor-owned Google app. The entry stays declared on purpose — every
291 // connection already minted against it keeps refreshing and reconnecting
292 // through it. Deleting this block, or unsetting the env vars, would strand
293 // those connections instead.
294 unlisted: true,
295 }),
296 ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {
297 name: "hubspot",
298 authorizationUrl: "https://app.hubspot.com/oauth/authorize",
299 tokenUrl: "https://api.hubapi.com/oauth/v3/token",
300 authorizationScopes: HUBSPOT_REQUIRED_SCOPES,
301 allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES],
302 authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") },
303 }),
304 ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, {
305 name: "linear",
306 authorizationUrl: "https://linear.app/oauth/authorize",
307 tokenUrl: "https://api.linear.app/oauth/token",
308 authorizationScopes: ["read", "write"],
309 authorizationScopeSeparator: ",",
310 allowedScopes: ["read", "write"],
311 }),
312 ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, {
313 name: "microsoft",
314 authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
315 tokenUrl: MICROSOFT_TOKEN_URL,
316 allowedScopes: MICROSOFT_SCOPES,
317 additionalAuthorizationScopes: ["offline_access"],
318 }),
319 ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, {
320 name: "notion",
321 authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
322 tokenUrl: "https://api.notion.com/v1/oauth/token",
323 authorizationExtraParams: { owner: "user" },
324 tokenEndpointAuthMethod: "basic",
325 tokenRequestFormat: "json",
326 authorizationScopes: [],
327 }),
328 ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, {
329 name: "slack",
330 authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
331 tokenUrl: "https://slack.com/api/oauth.v2.user.access",
332 resource: "https://mcp.slack.com",
333 integrations: [IntegrationSlug.make("slack")],
334 allowedScopes: slackMcpUserScopes,
335 }),
336];
apps/cloud/src/env-augment.d.ts+20−0
@@ -72,6 +72,16 @@ declare global { · 71 unmodified lines
72 // unset pair simply ships no first-party app for that provider. The72 // unset pair simply ships no first-party app for that provider. The
73 // registered callback on the provider side must be73 // registered callback on the provider side must be
74 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.74 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
75 FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
76 FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
77 FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
78 FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
79 FIRST_PARTY_BOX_CLIENT_ID?: string;
80 FIRST_PARTY_BOX_CLIENT_SECRET?: string;
81 FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
82 FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
83 FIRST_PARTY_FIGMA_CLIENT_ID?: string;
84 FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
75 FIRST_PARTY_GITHUB_CLIENT_ID?: string;85 FIRST_PARTY_GITHUB_CLIENT_ID?: string;
76 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;86 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
77 // Endpoint overrides for the GitHub first-party app, so tests/dev can87 // Endpoint overrides for the GitHub first-party app, so tests/dev can
@@ -79,8 +89,18 @@ declare global { · 1 unmodified line
79 // production (the real github.com endpoints are the defaults).89 // production (the real github.com endpoints are the defaults).
80 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;90 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
81 FIRST_PARTY_GITHUB_TOKEN_URL?: string;91 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
92 FIRST_PARTY_GITLAB_CLIENT_ID?: string;
93 FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
82 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;94 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
83 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;95 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
96 FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
97 FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
98 FIRST_PARTY_LINEAR_CLIENT_ID?: string;
99 FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
100 FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
101 FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
102 FIRST_PARTY_NOTION_CLIENT_ID?: string;
103 FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
84 FIRST_PARTY_SLACK_CLIENT_ID?: string;104 FIRST_PARTY_SLACK_CLIENT_ID?: string;
85 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;105 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
86 106
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
1import { readFileSync } from "node:fs";1import { readFileSync } from "node:fs";
2import { resolve } from "node:path";2import { createRequire } from "node:module";
3import { dirname, resolve } from "node:path";
3import { Readable } from "node:stream";4import { Readable } from "node:stream";
4import { fileURLToPath } from "node:url";5import { fileURLToPath } from "node:url";
5import { defineConfig, type Plugin } from "vite";6import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
42const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));43const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
43const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));44const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
44 45
46// js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it
47// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from
48// `root`, which here is packages/app, and bun's isolated install keeps js-yaml
49// in the openapi plugin's own node_modules — out of reach of both a plain
50// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point
51// the bare specifier at the package the plugin already resolves, so `root` can
52// name it. It is the same copy either way, so nothing about resolution changes.
53const JS_YAML_DIR = dirname(
54 createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55 "js-yaml",
56 ),
57);
58
45const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>59const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
46 new Response(60 new Response(
47 JSON.stringify(61 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215 outDir: resolve(import.meta.dirname, "dist"),229 outDir: resolve(import.meta.dirname, "dist"),
216 emptyOutDir: true,230 emptyOutDir: true,
217 },231 },
232 // Deps vite only discovers once a lazy-loaded React chunk actually renders
233 // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source
234 // flow). Discovering one mid-run forces a re-optimize and a full page
235 // reload, which throws away whatever the person was doing — an open sheet
236 // and its form state included. Pre-bundle them at boot so vite never
237 // discovers them mid-run. Keep in sync with apps/host-selfhost and
238 // apps/cloud.
239 optimizeDeps: {
240 include: [
241 "effect/Match",
242 "effect/Predicate",
243 "effect/Exit",
244 "effect/Option",
245 "effect/Cause",
246 "effect/Data",
247 "effect/Schema",
248 "js-yaml",
249 ],
250 },
218 define: {251 define: {
219 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220 // The local app IS the npm-installed CLI, so its update card shows the npm253 // The local app IS the npm-installed CLI, so its update card shows the npm
@@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines
229 },262 },
230 resolve: {263 resolve: {
231 tsconfigPaths: true,264 tsconfigPaths: true,
265 alias: { "js-yaml": JS_YAML_DIR },
232 },266 },
233 server: {267 server: {
234 port: parseInt(process.env.PORT ?? "5173", 10),268 port: parseInt(process.env.PORT ?? "5173", 10),
e2e/local/stdio-mcp-edit-ui.test.ts+138−0
@@ -0,0 +1,138 @@
1// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
2// real browser. `local` is the only surface that enables stdio MCP
3// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
4// exercised end to end.
5//
6// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
7// recreate the integration with the updated command." (#812) — changing a
8// command meant editing `executor.jsonc` by hand, or losing the integration's
9// connections and policies to a delete-and-re-add.
10//
11// The assertion is deliberately not "the field accepted my text". The scenario
12// edits the DECLARED env map through the form and then reads the tool catalog:
13// the fixture advertises `saw_declared_env` only when that variable is present
14// in the spawned child's environment, so the tool appearing proves the edited
15// config travelled the whole way — form, config write, respawn, rediscovery.
16import { fileURLToPath } from "node:url";
17
18import { expect } from "@effect/vitest";
19import { Effect } from "effect";
20import { HttpApiClient } from "effect/unstable/httpapi";
21import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22import { composePluginApi } from "@executor-js/api/server";
23import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24
25import { scenario } from "../src/scenario";
26import { Browser, Cli, RunDir, Target } from "../src/services";
27import { withLocalServer } from "./local-server";
28
29const api = composePluginApi([mcpHttpPlugin()] as const);
30
31const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32
33const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34
35scenario(
36 "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
37 { timeout: 300_000 },
38 Effect.gen(function* () {
39 const cli = yield* Cli;
40 const browser = yield* Browser;
41 const target = yield* Target;
42 const runDir = yield* RunDir;
43 const identity = yield* target.newIdentity();
44
45 yield* withLocalServer(cli, runDir, (server) =>
46 Effect.gen(function* () {
47 const client = yield* HttpApiClient.make(api, {
48 baseUrl: new URL("/api", server.origin).toString(),
49 transformClient: HttpClient.mapRequest((request) =>
50 HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
51 ),
52 }).pipe(Effect.provide(FetchHttpClient.layer));
53
54 const slug = "e2e-stdio-editable";
55
56 // A plain stdio server: no declared env, so the env-gated tool is absent
57 // until the edit adds the variable.
58 yield* client.mcp.addServer({
59 payload: {
60 transport: "stdio",
61 name: "E2E Stdio Editable",
62 command: "node",
63 args: [FIXTURE],
64 slug,
65 },
66 });
67
68 const before = yield* client.tools.list({ query: { integration: slug } });
69 expect(
70 before.map((t) => t.name),
71 "the server starts with its base tool and no declared env",
72 ).toContain("echo_tool");
73 expect(
74 before.map((t) => t.name),
75 "the env-gated tool is absent before the edit",
76 ).not.toContain("saw_declared_env");
77
78 yield* browser.session(identity, async ({ page, step }) => {
79 await step("Open the stdio integration from the console", async () => {
80 await page.goto(server.url, { waitUntil: "domcontentloaded" });
81 await page.getByTestId(`integration-entry-${slug}`).first().click();
82 // Wait for the detail page's own data, not just its shell: clicking
83 // Edit while the accounts panel is still a skeleton races the layout
84 // shift that lands when it resolves.
85 await page.getByText("default").first().waitFor({ timeout: 30_000 });
86 await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
87 });
88
89 await step("Open the Edit sheet — the stdio command is editable", async () => {
90 await page.getByRole("button", { name: "Edit" }).click();
91 await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
92 await page.getByText("Server command").waitFor({ timeout: 30_000 });
93 // The read-only dead end this replaces.
94 expect(
95 await page.getByText("Stdio MCP integrations cannot be edited").count(),
96 "the read-only message is gone",
97 ).toBe(0);
98 expect(
99 await page.getByRole("textbox", { name: "Command" }).inputValue(),
100 "the stored command is loaded into the form",
101 ).toBe("node");
102 });
103
104 await step("Declare an environment variable and save", async () => {
105 await page
106 .getByRole("textbox", { name: "Environment variables" })
107 .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
108 await page.getByRole("button", { name: "Save" }).click();
109 await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
110 });
111 });
112
113 // The edit persisted as the DECLARED static env map on the config.
114 const stored = yield* client.mcp.getServer({ params: { slug } });
115 expect(
116 stored?.config.transport === "stdio" ? stored.config.env : undefined,
117 "the sheet wrote the declared env map",
118 ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
119 expect(
120 stored?.config.transport === "stdio" ? stored.config.command : undefined,
121 "the command it did not touch is unchanged",
122 ).toBe("node");
123
124 // And it reached the spawned server: the fixture gates this tool's very
125 // existence on that variable being in its own environment.
126 const after = yield* client.tools.list({ query: { integration: slug } });
127 expect(
128 after.map((t) => t.name),
129 "the edited environment reached the respawned server and the catalog was rebuilt",
130 ).toContain("saw_declared_env");
131 expect(
132 after.map((t) => t.name),
133 "the rebuild kept the tools that still exist",
134 ).toContain("echo_tool");
135 }),
136 );
137 }),
138);
e2e/local/stdio-mcp.test.ts+42−0
@@ -222,6 +222,48 @@ scenario( · 221 unmodified lines
222 autoTools.map((t) => t.name),222 autoTools.map((t) => t.name),
223 "auto negotiation falls back to legacy and still discovers tools",223 "auto negotiation falls back to legacy and still discovers tools",
224 ).toContain("echo_tool");224 ).toContain("echo_tool");
225 expect(
226 autoTools.map((t) => t.name),
227 "this server declared no env, so the env-gated tool is absent to begin with",
228 ).not.toContain("saw_declared_env");
229
230 // --- Editing a stdio server's config (what the integration Edit sheet
231 // now does instead of telling you to remove and recreate). The tool
232 // catalog is persisted per connection, so a plain config replace is
233 // only enough because core stamps `config_revised_at` on a config
234 // write and every connection whose catalog predates that stamp is
235 // rebuilt on the next read. The edit path therefore needs NO explicit
236 // refresh of its own — this asserts that, so nobody adds one back.
237 //
238 // Adding a declared env var is the lever: the fixture advertises
239 // `saw_declared_env` only when that variable reached the child, so the
240 // tool appearing with no further action proves both halves — the new
241 // config reached the spawn, and the catalog was rebuilt from it. ---
242 const editedConfig = {
243 ...autoStored!.config,
244 env: { EXECUTOR_E2E_SECRET: SECRET },
245 } as typeof autoStored.config;
246
247 yield* client.mcp.configureServer({
248 params: { slug: autoSlug },
249 payload: { config: editedConfig },
250 });
251
252 const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
253 expect(
254 JSON.stringify(editedStored?.config ?? {}),
255 "the edit persisted, and left the untouched negotiation mode alone",
256 ).toContain('"versionNegotiation":"auto"');
257
258 const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
259 expect(
260 editedTools.map((t) => t.name),
261 "the edited config reached the spawn and the catalog was rediscovered",
262 ).toContain("saw_declared_env");
263 expect(
264 editedTools.map((t) => t.name),
265 "rediscovery replaced the catalog rather than dropping what still exists",
266 ).toContain("echo_tool");
225 }),267 }),
226 { env: DAEMON_ENV },268 { env: DAEMON_ENV },
227 );269 );
e2e/scenarios/google-disabled-api.test.ts+364−0
@@ -0,0 +1,364 @@
1// A Google 403 for a DISABLED API must NOT read as an expired credential.
2//
3// Production case (2026-07-10): a user's "Personal Google" connection showed
4// the red Expired badge although its OAuth token was alive and refreshing.
5// The org's Google OAuth client lives in a GCP project without the Gmail /
6// Drive / Calendar APIs enabled, so the health probe gets Google's
7// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is
8// disabled"). The old status-only classifier mapped every 401/403 to
9// "expired", so a healthy credential rendered as Expired and the UI told the
10// user to reconnect - advice that cannot fix a disabled upstream API.
11//
12// The scenario replays that exact journey against the Google emulator: a real
13// OAuth grant probes healthy; then the upstream starts answering the probe
14// operation with Google's real SERVICE_DISABLED body (fault injection - the
15// token stays valid, exactly like prod). The fixed classifier reads the error
16// body and reports "misconfigured": on screen that is the amber "API
17// disabled" badge with Google's own remediation text (including the console
18// link that enables the API), and no reconnect prompt.
19import { randomBytes } from "node:crypto";
20
21import { expect } from "@effect/vitest";
22import { Effect } from "effect";
23import type { HttpApiClient } from "effect/unstable/httpapi";
24import { composePluginApi } from "@executor-js/api/server";
25import { connectEmulator, type EmulatorClient } from "@executor-js/emulate";
26import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
27import {
28 AuthTemplateSlug,
29 ConnectionName,
30 IntegrationSlug,
31 OAuthClientSlug,
32} from "@executor-js/sdk/shared";
33
34import { createEmulatorInstance } from "../src/emulator-instance";
35import { scenario } from "../src/scenario";
36import { Api, Browser, Target } from "../src/services";
37import type { Identity, Target as TargetShape } from "../src/target";
38import type { BrowserSurface } from "../src/surfaces/browser";
39
40const api = composePluginApi([openApiHttpPlugin()] as const);
41type Client = HttpApiClient.ForApi<typeof api>;
42const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
43const CONNECTION = ConnectionName.make("main");
44
45// The Gmail preset's declared health check, and the probe the fault hijacks.
46const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list";
47
48// Google's real error body for an API that is not enabled in the OAuth
49// client's GCP project. Verbatim shape from production: HTTP 403 with reason
50// `accessNotConfigured` - an authorization-configuration error, NOT an
51// expired/revoked credential.
52const DISABLED_API_MESSAGE =
53 "Gmail API has not been used in project 000000000000 before or it is disabled. " +
54 "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";
55const disabledApiBody = {
56 error: {
57 code: 403,
58 message: DISABLED_API_MESSAGE,
59 errors: [
60 { message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" },
61 ],
62 status: "PERMISSION_DENIED",
63 },
64};
65
66const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
67
68const decodeHtml = (value: string): string =>
69 value
70 .replaceAll("&amp;", "&")
71 .replaceAll("&quot;", '"')
72 .replaceAll("&#x27;", "'")
73 .replaceAll("&lt;", "<")
74 .replaceAll("&gt;", ">");
75
76const inputFields = (html: string): Record<string, string> => {
77 const fields: Record<string, string> = {};
78 for (const input of html.matchAll(/<input\b[^>]*>/gi)) {
79 const tag = input[0];
80 const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1];
81 const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? "";
82 if (name) fields[decodeHtml(name)] = decodeHtml(value);
83 }
84 return fields;
85};
86
87const formAction = (html: string, fallback: string): string => {
88 const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1];
89 return action ? decodeHtml(action) : fallback;
90};
91
92const completeGoogleConsent = (authorizationUrl: string) =>
93 Effect.promise(async () => {
94 const page = await fetch(authorizationUrl);
95 if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`);
96 const html = await page.text();
97 const callback = await fetch(formAction(html, authorizationUrl), {
98 method: "POST",
99 body: new URLSearchParams(inputFields(html)),
100 redirect: "manual",
101 });
102 const location = callback.headers.get("location");
103 if (callback.status !== 302 || !location) {
104 throw new Error(`Google emulator consent did not redirect: ${callback.status}`);
105 }
106 const code = new URL(location).searchParams.get("code");
107 if (!code) throw new Error("Google emulator callback did not include a code");
108 return code;
109 });
110
111const createGoogleEmulator = Effect.gen(function* () {
112 const baseUrl = yield* createEmulatorInstance("google", "disabled-api");
113 const client = yield* Effect.promise(() => connectEmulator({ baseUrl }));
114 return { client, baseUrl };
115});
116
117const addGooglePresetFromCatalog = (
118 browser: BrowserSurface,
119 identity: Identity,
120 presetName: string,
121 slug: string,
122) =>
123 browser.session(identity, async ({ page, step }) => {
124 await step(`Open ${presetName} from the connect catalog`, async () => {
125 await page.goto("/integrations", { waitUntil: "networkidle" });
126 await page
127 .getByRole("button", { name: /Connect/ })
128 .first()
129 .click();
130 const dialog = page.getByRole("dialog", { name: "Connect an integration" });
131 await dialog.waitFor();
132 await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
133 await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
134 });
135
136 await step(`Add the ${presetName} integration`, async () => {
137 await page.waitForURL(/\/integrations\/add\/openapi/);
138 await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
139 const button = page.getByRole("button", { name: "Add integration" });
140 await button.waitFor({ timeout: 120_000 });
141 await button.click({ timeout: 120_000 });
142 await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 });
143 });
144 });
145
146const connectGoogleAccount = (input: {
147 readonly client: Client;
148 readonly emulator: EmulatorClient;
149 readonly emulatorBaseUrl: string;
150 readonly target: TargetShape;
151 readonly integration: IntegrationSlug;
152 readonly oauthClient: OAuthClientSlug;
153}) =>
154 Effect.gen(function* () {
155 const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
156 const credential = yield* Effect.promise(() =>
157 input.emulator.credentials.mint({
158 type: "oauth-authorization-code",
159 redirect_uris: [redirectUri],
160 }),
161 );
162 if (!credential.client_id || !credential.client_secret) {
163 return yield* Effect.die(`Google emulator did not mint an OAuth client`);
164 }
165
166 yield* input.client.openapi.configure({
167 params: { slug: input.integration },
168 payload: { baseUrl: input.emulatorBaseUrl },
169 });
170 yield* input.client.oauth.createClient({
171 payload: {
172 owner: "org",
173 slug: input.oauthClient,
174 grant: "authorization_code",
175 authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`,
176 tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`,
177 clientId: credential.client_id,
178 clientSecret: credential.client_secret,
179 originIntegration: input.integration,
180 },
181 });
182
183 const started = yield* input.client.oauth.start({
184 payload: {
185 client: input.oauthClient,
186 clientOwner: "org",
187 owner: "org",
188 name: CONNECTION,
189 integration: input.integration,
190 template: GOOGLE_AUTH_TEMPLATE,
191 redirectUri,
192 },
193 });
194 expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
195 if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected");
196
197 const code = yield* completeGoogleConsent(started.authorizationUrl);
198 const completed = yield* input.client.oauth.complete({
199 payload: { state: started.state, code },
200 });
201 expect(completed.integration, "OAuth completion creates the connection").toBe(
202 input.integration,
203 );
204 });
205
206scenario(
207 "Google · a disabled upstream API reads as API disabled with the enable link, not Expired",
208 { timeout: 300_000 },
209 Effect.gen(function* () {
210 const target = yield* Target;
211 const browser = yield* Browser;
212 const { client: makeClient } = yield* Api;
213 const identity = yield* target.newIdentity();
214 const client = yield* makeClient(api, identity);
215 const emulator = yield* createGoogleEmulator;
216 const slug = IntegrationSlug.make("google_gmail");
217 const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth"));
218
219 yield* Effect.ensuring(
220 Effect.gen(function* () {
221 yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug));
222
223 const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
224 expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(
225 GMAIL_HEALTH_OPERATION,
226 );
227
228 yield* connectGoogleAccount({
229 client,
230 emulator: emulator.client,
231 emulatorBaseUrl: emulator.baseUrl,
232 target,
233 integration: slug,
234 oauthClient,
235 });
236
237 // Baseline: the freshly granted token probes healthy against the
238 // emulator - same as prod the day the account was connected.
239 const healthy = yield* client.connections.checkHealth({
240 params: { owner: "org", integration: slug, name: CONNECTION },
241 query: { ifStaleMs: 0 },
242 });
243 expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe(
244 "healthy",
245 );
246
247 // The upstream flips: the probe operation now answers with Google's
248 // real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token
249 // endpoints stay live, so the credential itself remains valid and
250 // refreshable, exactly the production condition. Generous `times`
251 // covers the UI's automatic revalidations on top of explicit checks.
252 yield* Effect.promise(() =>
253 emulator.client.faults.arm({
254 match: { operationId: GMAIL_HEALTH_OPERATION },
255 response: { status: 403, body: disabledApiBody },
256 times: 100,
257 }),
258 );
259 yield* Effect.promise(() => emulator.client.ledger.clear());
260
261 // The fix under test: a 403 that means "enable this API in your GCP
262 // project" classifies as misconfigured, not as a dead credential.
263 const verdict = yield* client.connections.checkHealth({
264 params: { owner: "org", integration: slug, name: CONNECTION },
265 query: { ifStaleMs: 0 },
266 });
267 expect(
268 verdict.status,
269 `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`,
270 ).toBe("misconfigured");
271 expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
272 expect(
273 verdict.detail,
274 "the stored detail names the real cause (a disabled API, not an expired token)",
275 ).toContain("has not been used in project");
276
277 // The 403 came from the armed fault on the probe route - the token
278 // was never rejected by the emulator's auth layer.
279 const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50));
280 const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION);
281 expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true);
282
283 yield* browser.session(identity, async ({ page, step }) => {
284 const connections = page.locator("section").filter({
285 has: page.getByRole("heading", { level: 3, name: "Connections" }),
286 });
287
288 await step(
289 "The connection shows the amber API disabled badge - not Expired - with no clicks",
290 async () => {
291 await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
292 await connections
293 .getByText("API disabled", { exact: true })
294 .waitFor({ timeout: 30_000 });
295 await connections.getByLabel("Status: API disabled").waitFor();
296 // The dead-credential story must be gone entirely.
297 expect(
298 await connections.getByText("Expired", { exact: true }).count(),
299 "no Expired badge on a misconfigured connection",
300 ).toBe(0);
301 },
302 );
303
304 await step(
305 "The row carries Google's own instruction, console link included",
306 async () => {
307 const detail = connections.getByText(/has not been used in project/);
308 await detail.waitFor();
309 const consoleLink = connections.getByRole("link", {
310 name: /console\.developers\.google\.com/,
311 });
312 await consoleLink.waitFor();
313 expect(
314 await consoleLink.getAttribute("href"),
315 "the enable-API console link is clickable",
316 ).toContain("console.developers.google.com");
317 // Visible in FULL, not just present in the DOM: a one-line
318 // truncate keeps the link in the tree while clipping it from
319 // view, which is exactly the regression this guards against.
320 // A clipped element overflows horizontally; a wrapped one
321 // grows taller instead.
322 const clipped = await detail.evaluate(
323 (el) =>
324 el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
325 );
326 expect(clipped, "the remediation text wraps instead of clipping").toBe(false);
327 const linkVisible = await consoleLink.evaluate((el) => {
328 const rect = el.getBoundingClientRect();
329 const detailRect = el.closest("p")?.getBoundingClientRect();
330 return detailRect ? rect.right <= detailRect.right + 1 : false;
331 });
332 expect(linkVisible, "the console link sits inside the visible detail box").toBe(true);
333 },
334 );
335
336 await step(
337 "Check now explains the disabled API instead of prescribing reconnect",
338 async () => {
339 await connections.locator('button[aria-haspopup="menu"]').click();
340 await page.getByRole("menuitem", { name: "Check now" }).click();
341 await page
342 .getByText(/has not been used in project/)
343 .first()
344 .waitFor({ timeout: 30_000 });
345 expect(
346 await page.getByText("Connection expired, reconnect to restore access").count(),
347 "the reconnect toast never fires for a configuration 403",
348 ).toBe(0);
349 },
350 );
351 });
352 }),
353 Effect.gen(function* () {
354 yield* client.connections
355 .remove({ params: { owner: "org", integration: slug, name: CONNECTION } })
356 .pipe(Effect.ignore);
357 yield* client.oauth
358 .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
359 .pipe(Effect.ignore);
360 yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
361 }),
362 );
363 }),
364);
e2e/selfhost/oauth-refresh-cross-session.test.ts+275−0
@@ -0,0 +1,275 @@
1// Selfhost-only: two MCP sessions that hit an expired token at the same moment
2// must share ONE refresh-token grant, never race two.
3//
4// Issue #1520: the in-flight refresh gate lived inside a single execution
5// stack, but the self-host builds a fresh stack per MCP session, so each
6// session believed it was the refresh winner and redeemed the same stored
7// refresh token. Providers that rotate refresh tokens answer the second
8// redemption with `invalid_grant: refresh token reuse detected` and may revoke
9// the whole token family — the connection dies and the user must reauthorize.
10// The first refresh cycle succeeds, so the bug stays invisible until a later
11// expiry.
12//
13// The journey: an OpenAPI integration completes a real authorization-code flow
14// against a live test authorization server; the upstream then rejects both
15// sessions' first call with a 401 at the same instant (it holds both requests
16// until both have arrived, so the contention is forced rather than left to the
17// scheduler); and the authorization server's own request ledger proves exactly
18// one refresh grant was issued and both retries carried the same new bearer.
19import { randomBytes } from "node:crypto";
20import { createServer, type ServerResponse } from "node:http";
21
22import { expect } from "@effect/vitest";
23import { Effect } from "effect";
24import { composePluginApi } from "@executor-js/api/server";
25import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
26import {
27 AuthTemplateSlug,
28 ConnectionName,
29 IntegrationSlug,
30 OAuthClientSlug,
31} from "@executor-js/sdk/shared";
32import { serveOAuthTestServer } from "@executor-js/sdk/testing";
33
34import { scenario } from "../src/scenario";
35import { Api, Mcp, Target } from "../src/services";
36
37const api = composePluginApi([openApiHttpPlugin()] as const);
38
39const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
40
41/** Both sessions call once, are rejected together, then retry once. */
42const SESSIONS = 2;
43
44type UpstreamHandle = {
45 readonly url: string;
46 readonly bearers: () => readonly string[];
47 readonly close: () => void;
48};
49
50/**
51 * Upstream that rejects the whole first wave at once.
52 *
53 * The barrier is the point: it holds every session's first call until all have
54 * arrived, then 401s them together, which forces both sessions into a genuinely
55 * simultaneous refresh instead of hoping the scheduler interleaves them.
56 */
57const serveUpstream = () =>
58 Effect.acquireRelease(
59 Effect.callback<UpstreamHandle>((resume) => {
60 const bearers: string[] = [];
61 const held: ServerResponse[] = [];
62 const server = createServer((request, response) => {
63 if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) {
64 bearers.push((request.headers.authorization ?? "").replace(/^Bearer\s+/i, ""));
65 if (held.length < SESSIONS) {
66 held.push(response);
67 if (held.length === SESSIONS) {
68 for (const rejected of held) {
69 rejected.writeHead(401, { "content-type": "application/json" });
70 rejected.end(JSON.stringify({ error: "invalid_token" }));
71 }
72 }
73 return;
74 }
75 response.writeHead(200, { "content-type": "application/json" });
76 response.end(JSON.stringify({ issues: [] }));
77 return;
78 }
79 response.writeHead(404, { "content-type": "application/json" });
80 response.end(JSON.stringify({ error: "not_found" }));
81 });
82 server.listen(0, "127.0.0.1", () => {
83 const address = server.address();
84 const port = typeof address === "object" && address ? address.port : 0;
85 resume(
86 Effect.succeed({
87 url: `http://127.0.0.1:${port}`,
88 bearers: () => [...bearers],
89 close: () => {
90 server.close();
91 server.closeAllConnections();
92 },
93 }),
94 );
95 });
96 }),
97 (server) => Effect.sync(server.close),
98 );
99
100const spec = (
101 baseUrl: string,
102 oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
103): string =>
104 JSON.stringify({
105 openapi: "3.0.3",
106 info: { title: "Issues API", version: "1.0.0" },
107 servers: [{ url: baseUrl }],
108 paths: {
109 "/issues": {
110 get: {
111 operationId: "listIssues",
112 security: [{ oauth: ["issues.read"] }],
113 responses: { "200": { description: "issues" } },
114 },
115 },
116 },
117 components: {
118 securitySchemes: {
119 oauth: {
120 type: "oauth2",
121 flows: {
122 authorizationCode: {
123 authorizationUrl: oauth.authorizationEndpoint,
124 tokenUrl: oauth.tokenEndpoint,
125 scopes: { "issues.read": "Read issues" },
126 },
127 },
128 },
129 },
130 },
131 });
132
133const invokeByAddressCode = (address: string) => `
134const segments = ${JSON.stringify(address)}.split(".").slice(1);
135let node = tools;
136for (const segment of segments) node = node[segment];
137const result = await node({});
138return JSON.stringify(result);
139`;
140
141const completeAuthorization = (authorizationUrl: string) =>
142 Effect.promise(async () => {
143 const authorize = await fetch(authorizationUrl, { redirect: "manual" });
144 const loginUrl = authorize.headers.get("location");
145 if (!loginUrl) return null;
146 const login = await fetch(loginUrl, {
147 method: "POST",
148 headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
149 redirect: "manual",
150 });
151 const callbackUrl = login.headers.get("location");
152 if (!callbackUrl) return null;
153 return new URL(callbackUrl).searchParams.get("code");
154 });
155
156scenario(
157 "OAuth refresh · separate MCP sessions share one rotating-token refresh grant",
158 { timeout: 180_000 },
159 Effect.scoped(
160 Effect.gen(function* () {
161 const target = yield* Target;
162 const { client: makeClient } = yield* Api;
163 const mcp = yield* Mcp;
164 const identity = yield* target.newIdentity();
165 const client = yield* makeClient(api, identity);
166 const upstream = yield* serveUpstream();
167 const oauth = yield* serveOAuthTestServer({ scopes: ["issues.read"] });
168 const slug = unique("refreshcrosssession");
169 const clientSlug = OAuthClientSlug.make(unique("refreshcrosssessionc"));
170
171 yield* Effect.ensuring(
172 Effect.gen(function* () {
173 yield* client.openapi.addSpec({
174 payload: {
175 spec: { kind: "blob", value: spec(upstream.url, oauth) },
176 slug,
177 baseUrl: upstream.url,
178 authenticationTemplate: [
179 {
180 slug: "oauth",
181 kind: "oauth2",
182 authorizationUrl: oauth.authorizationEndpoint,
183 tokenUrl: oauth.tokenEndpoint,
184 scopes: ["issues.read"],
185 },
186 ],
187 },
188 });
189 yield* client.oauth.createClient({
190 payload: {
191 owner: "org",
192 slug: clientSlug,
193 grant: "authorization_code",
194 authorizationUrl: oauth.authorizationEndpoint,
195 tokenUrl: oauth.tokenEndpoint,
196 clientId: "test-client",
197 clientSecret: "test-secret",
198 originIntegration: IntegrationSlug.make(slug),
199 },
200 });
201 const started = yield* client.oauth.start({
202 payload: {
203 client: clientSlug,
204 clientOwner: "org",
205 owner: "org",
206 name: ConnectionName.make("main"),
207 integration: IntegrationSlug.make(slug),
208 template: AuthTemplateSlug.make("oauth"),
209 },
210 });
211 expect(started.status, "oauth.start redirects to the authorization server").toBe(
212 "redirect",
213 );
214 if (started.status !== "redirect") return yield* Effect.die("no redirect");
215 const code = yield* completeAuthorization(started.authorizationUrl);
216 expect(code, "the authorization server issued a callback code").toBeDefined();
217 if (code == null) return yield* Effect.die("no authorization code");
218 yield* client.oauth.complete({ payload: { state: started.state, code } });
219
220 const address = (yield* client.tools.list({ query: {} }))
221 .filter((tool) => String(tool.integration) === slug)
222 .map((tool) => String(tool.address))
223 .find((tool) => tool.endsWith("listIssues"));
224 expect(address, "the OAuth-protected tool is in the catalog").toBeDefined();
225 if (!address) return yield* Effect.die("no listIssues tool");
226 yield* oauth.clearRequests;
227
228 const sessions = Array.from({ length: SESSIONS }, () => mcp.session(identity));
229 const call = (session: (typeof sessions)[number]) =>
230 Effect.gen(function* () {
231 let result = yield* session.call("execute", { code: invokeByAddressCode(address) });
232 let approvals = 0;
233 while (result.text.includes("executionId:") && approvals < 10) {
234 result = yield* session.approvePaused(result.text);
235 approvals += 1;
236 }
237 // Without a shared gate the loser redeems a retired refresh token
238 // and the authorization server answers invalid_grant, so this is
239 // the assertion that carries the user-visible failure.
240 expect(result.ok, `MCP execute completed: ${result.text.slice(0, 400)}`).toBe(true);
241 });
242
243 yield* Effect.all(sessions.map(call), { concurrency: "unbounded" });
244
245 const refreshGrants = (yield* oauth.requests).filter(
246 (request) =>
247 request.path === "/token" && request.body.includes("grant_type=refresh_token"),
248 );
249 expect(refreshGrants, "both sessions joined one refresh grant").toHaveLength(1);
250 const bearers = upstream.bearers();
251 expect(bearers, "both rejected calls retried after the refresh").toHaveLength(
252 SESSIONS * 2,
253 );
254 expect(bearers[2], "the first retry used a new bearer").not.toBe(bearers[0]);
255 expect(bearers[3], "both retries used the refreshed bearer").toBe(bearers[2]);
256 }),
257 Effect.gen(function* () {
258 yield* client.connections
259 .remove({
260 params: {
261 owner: "org",
262 integration: IntegrationSlug.make(slug),
263 name: ConnectionName.make("main"),
264 },
265 })
266 .pipe(Effect.ignore);
267 yield* client.oauth
268 .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
269 .pipe(Effect.ignore);
270 yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
271 }),
272 );
273 }),
274 ),
275);
packages/core/api/src/admin/api.ts+1−1
@@ -145,7 +145,7 @@ export const AdminUser = Schema.Struct({ · 144 unmodified lines
145 * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`.145 * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`.
146 */146 */
147export const AdminConnectionHealth = Schema.Struct({147export const AdminConnectionHealth = Schema.Struct({
148 /** The stored verdict: healthy / expired / degraded / unknown. */148 /** The stored verdict: healthy / expired / misconfigured / degraded / unknown. */
149 status: HealthStatus,149 status: HealthStatus,
150 /** Epoch ms the check ran, so an operator can tell a fresh verdict from a150 /** Epoch ms the check ran, so an operator can tell a fresh verdict from a
151 * stale one. */151 * stale one. */
packages/core/sdk/src/executor.ts+94−23
@@ -199,6 +199,47 @@ const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; · 198 unmodified lines
199const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;199const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;
200const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;200const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
201 201
202// ---------------------------------------------------------------------------
203// In-flight OAuth refresh gate — a CROSS-STACK resource.
204//
205// Concurrent resolves of one connection must share a single refresh-token
206// grant: the authorization server rotates the refresh token, so a second
207// grant redeems a token the first already consumed, and a provider that
208// detects reuse revokes the whole token family. The first refresh cycle still
209// succeeds, so the fault hides until a later expiry.
210//
211// The gate therefore cannot live on a single execution stack. A host builds a
212// fresh scoped executor per MCP session (and, since request-scoped stack
213// builds, per request), so a per-`createExecutor` map put every session in its
214// own gate and deduplicated nothing. Hanging it off the root DB handle instead
215// converges every stack over one handle on one map — the same object hosts
216// already treat as their shared, process-lived resource.
217//
218// SCOPE OF THE GUARANTEE: dedup reaches exactly as far as one root DB handle
219// in one process. A host that hands every scoped executor a FRESH handle keys
220// a different map each time and gets no dedup — silently, because an unshared
221// gate still behaves correctly for the one caller holding it. Multi-instance
222// deployments are outside it for the same reason: a process-local map cannot
223// see a peer isolate or replica. Both need database-backed coordination
224// (compare-and-swap on the stored refresh token) rather than a wider map.
225//
226// Weakly keyed so the map dies with the handle and a host that opens and drops
227// handles does not leak one gate per handle.
228type RefreshGate = Map<
229 string,
230 Deferred.Deferred<string | null, StorageFailure | CredentialResolutionError>
231>;
232
233const refreshGateByRootDb = new WeakMap<object, RefreshGate>();
234
235const refreshGateFor = (rootDb: object): RefreshGate => {
236 const existing = refreshGateByRootDb.get(rootDb);
237 if (existing) return existing;
238 const created: RefreshGate = new Map();
239 refreshGateByRootDb.set(rootDb, created);
240 return created;
241};
242
202// ---------------------------------------------------------------------------243// ---------------------------------------------------------------------------
203// Elicitation handler — resolved once at `createExecutor({ onElicitation })`244// Elicitation handler — resolved once at `createExecutor({ onElicitation })`
204// and overridable per `execute`. A tool that requests user input mid-execution245// and overridable per `execute`. A tool that requests user input mid-execution
@@ -1697,6 +1738,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1492 unmodified lines
1697 });1738 });
1698 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;1739 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;
1699 const closeDb = "db" in dbInput ? dbInput.close : undefined;1740 const closeDb = "db" in dbInput ? dbInput.close : undefined;
1741 // Shared with every other execution stack built over this same handle —
1742 // see the gate's definition for what that does and does not cover.
1743 const refreshInFlight = refreshGateFor(rootDbUntyped);
1700 yield* Effect.try({1744 yield* Effect.try({
1701 try: () => {1745 try: () => {
1702 validateExecutorDbTables(tables, rootDbUntyped.internal.tables);1746 validateExecutorDbTables(tables, rootDbUntyped.internal.tables);
@@ -1920,17 +1964,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 217 unmodified lines
1920 ),1964 ),
1921 });1965 });
1922 1966
1923 // In-flight refresh gate — concurrent resolves of the same connection share1967 // Key for the shared in-flight refresh gate (`refreshInFlight`, bound at
1924 // one refresh (mirrors v1's refresh deferred-map) so we never fire two1968 // the top of `createExecutor`). The tenant leads because the gate spans
1925 // refresh-token grants for the same connection in parallel (the AS rotates1969 // every execution stack over one DB handle, so it spans tenants too:
1926 // the refresh token; the second request would race on a consumed token).1970 // without it, two tenants whose rows agree on owner/subject/integration/
1927 const refreshInFlight = new Map<1971 // name would collide on one entry and one tenant's caller could be handed
1928 string,1972 // the other's access token. JSON.stringify keeps the components
1929 Effect.Effect<string | null, StorageFailure | CredentialResolutionError>1973 // unambiguous: tenant, subject, integration, and name are opaque strings
1930 >();1974 // that may contain any delimiter, so a delimiter-joined key would let
1931 1975 // tenant "a" + subject "user:b" collide with tenant "a:user" + subject
1976 // "b" — the same cross-tenant bleed by another route.
1932 const connectionKey = (row: ConnectionRow): string =>1977 const connectionKey = (row: ConnectionRow): string =>
1933 `${row.owner}:${row.subject}:${row.integration}:${row.name}`;1978 JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]);
1934 1979
1935 const loadOAuthClientRow = (1980 const loadOAuthClientRow = (
1936 owner: Owner,1981 owner: Owner,
@@ -1955,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
1955 readonly tokenUrl: string;2000 readonly tokenUrl: string;
1956 readonly grant: string;2001 readonly grant: string;
1957 readonly resource: string | null;2002 readonly resource: string | null;
2003 readonly tokenEndpointAuthMethod?: "body" | "basic";
2004 readonly tokenRequestFormat?: "form" | "json";
1958 }2005 }
1959 2006
1960 /** What drove a refresh: the pre-call expiry check (`proactive`), or an2007 /** What drove a refresh: the pre-call expiry check (`proactive`), or an
@@ -2267,6 +2314,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 306 unmodified lines
2267 // audience the original grant was bound to. Dropping it here2314 // audience the original grant was bound to. Dropping it here
2268 // made refresh asymmetric with authorize for first-party apps.2315 // made refresh asymmetric with authorize for first-party apps.
2269 resource: firstParty.resource ?? null,2316 resource: firstParty.resource ?? null,
2317 ...(firstParty.tokenEndpointAuthMethod === undefined
2318 ? {}
2319 : { tokenEndpointAuthMethod: firstParty.tokenEndpointAuthMethod }),
2320 ...(firstParty.tokenRequestFormat === undefined
2321 ? {}
2322 : { tokenRequestFormat: firstParty.tokenRequestFormat }),
2270 } satisfies RefreshClient;2323 } satisfies RefreshClient;
2271 }2324 }
2272 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;2325 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -2398,6 +2451,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 125 unmodified lines
2398 // RFC 8707: keep the re-minted token bound to the same resource2451 // RFC 8707: keep the re-minted token bound to the same resource
2399 // (MCP servers require this on refresh).2452 // (MCP servers require this on refresh).
2400 resource: clientRow.resource ?? undefined,2453 resource: clientRow.resource ?? undefined,
2454 clientAuth: clientRow.tokenEndpointAuthMethod,
2455 requestFormat: clientRow.tokenRequestFormat,
2401 endpointUrlPolicy: config.oauthEndpointUrlPolicy,2456 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
2402 fetch: config.fetch,2457 fetch: config.fetch,
2403 }).pipe(2458 }).pipe(
@@ -2530,26 +2585,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 126 unmodified lines
2530 // token; parallel grants would race on a consumed token — v1's refresh2585 // token; parallel grants would race on a consumed token — v1's refresh
2531 // deferred-map). The gate is cleared once the refresh settles so a later2586 // deferred-map). The gate is cleared once the refresh settles so a later
2532 // expiry can refresh again.2587 // expiry can refresh again.
2533 Effect.gen(function* () {2588 Effect.suspend(() => {
2534 const key = connectionKey(row);2589 const key = connectionKey(row);
2535 // Joining an in-flight grant is correct for BOTH triggers: whatever2590 // Joining an in-flight grant is correct for BOTH triggers: whatever
2536 // that peer mints is newer than the token this fiber just saw rejected,2591 // that peer mints is newer than the token this fiber just saw rejected,
2537 // which is exactly what a reactive retry wants. The gate is cleared on2592 // which is exactly what a reactive retry wants. The gate is cleared on
2538 // settle, so a 401 arriving after a refresh completed starts a fresh2593 // settle, so a 401 arriving after a refresh completed starts a fresh
2539 // grant rather than replaying the stale memoized one.2594 // grant rather than replaying a stale result.
2540 const existing = refreshInFlight.get(key);2595 const existing = refreshInFlight.get(key);
2541 if (existing) return yield* existing;2596 if (existing) return Deferred.await(existing);
2542 // `Effect.cached` memoizes the grant onto a deferred: it runs once and2597
2543 // replays to every awaiter sharing this entry.2598 // The grant runs on a DETACHED fiber and every caller — including this
2544 const memoized = yield* Effect.cached(performTokenRefresh(row, provider, trigger));2599 // one — only awaits its deferred. The entry is shared across execution
2545 const gated = memoized.pipe(2600 // stacks, so the fiber that registers it is merely the first arrival,
2546 Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))),2601 // not an owner. Running the grant ON that fiber would hand it that
2602 // caller's interruption: a disconnected MCP client, an execution
2603 // deadline or a cancelled tool call would fail every peer awaiting the
2604 // same entry with an interrupt none of them caused and none can act on.
2605 // Awaiting is per-caller, so a cancelled peer detaches without touching
2606 // the grant or its siblings, and a grant nobody is left waiting on
2607 // still settles and still persists the rotated token — which is what
2608 // keeps the next caller off a consumed one. Token requests are bounded
2609 // by `AbortSignal.timeout`, so the detached fiber cannot outlive its
2610 // request.
2611 const deferred = Deferred.makeUnsafe<
2612 string | null,
2613 StorageFailure | CredentialResolutionError
2614 >();
2615 // Nothing suspends between the lookup above and this registration, so
2616 // check-and-set is atomic against peer fibers and cannot double-fire.
2617 refreshInFlight.set(key, deferred);
2618 const run = performTokenRefresh(row, provider, trigger).pipe(
2619 Effect.exit,
2620 Effect.flatMap((exit) => Deferred.done(deferred, exit)),
2621 Effect.ensuring(Effect.sync(() => void refreshInFlight.delete(key))),
2547 );2622 );
2548 // Re-check after building (a peer fiber may have registered first while2623 return Effect.forkDetach(run).pipe(Effect.andThen(Deferred.await(deferred)));
2549 // we built ours) so everyone converges on the same shared grant.
2550 const winner = refreshInFlight.get(key) ?? gated;
2551 if (winner === gated) refreshInFlight.set(key, gated);
2552 return yield* winner;
2553 });2624 });
2554 2625
2555 // Resolve every named input of a connection (`variable → value`). A2626 // Resolve every named input of a connection (`variable → value`). A
packages/core/sdk/src/health-check.test.ts+72−0
@@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; · 4 unmodified lines
5 5
6import {6import {
7 candidateIdentityTier,7 candidateIdentityTier,
8 classifyHttpStatus,
9 classifyProbeResponse,
8 rankResponseSample,10 rankResponseSample,
9 sortHealthCheckCandidatesByIdentity,11 sortHealthCheckCandidatesByIdentity,
10} from "./health-check";12} from "./health-check";
@@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { · 55 unmodified lines
66 ).toBe("user.getAuthUser");68 ).toBe("user.getAuthUser");
67 });69 });
68});70});
71
72// Probe classification: 401/403 mean "credential dead" EXCEPT the
73// configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which
74// must read misconfigured: the token authenticated, the API is disabled in
75// the OAuth client's project, and reconnecting cannot fix it. This is the
76// production case behind the "Expired when it is not" report (2026-07-10).
77describe("classifyProbeResponse", () => {
78 const disabledMessage =
79 "Gmail API has not been used in project 000000000000 before or it is disabled. " +
80 "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";
81
82 it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => {
83 const body = {
84 error: {
85 code: 403,
86 message: disabledMessage,
87 errors: [
88 { message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" },
89 ],
90 status: "PERMISSION_DENIED",
91 },
92 };
93 expect(classifyProbeResponse(403, body)).toBe("misconfigured");
94 });
95
96 it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => {
97 const body = {
98 error: {
99 code: 403,
100 message: disabledMessage,
101 status: "PERMISSION_DENIED",
102 details: [
103 {
104 "@type": "type.googleapis.com/google.rpc.ErrorInfo",
105 reason: "SERVICE_DISABLED",
106 domain: "googleapis.com",
107 },
108 ],
109 },
110 };
111 expect(classifyProbeResponse(403, body)).toBe("misconfigured");
112 });
113
114 it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => {
115 expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe(
116 "expired",
117 );
118 expect(classifyProbeResponse(403, undefined)).toBe("expired");
119 expect(classifyProbeResponse(403, "Forbidden")).toBe("expired");
120 // A permission reason that is NOT a configuration marker stays expired.
121 expect(
122 classifyProbeResponse(403, {
123 error: { errors: [{ reason: "insufficientPermissions" }] },
124 }),
125 ).toBe("expired");
126 });
127
128 it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => {
129 const body = { error: { errors: [{ reason: "accessNotConfigured" }] } };
130 expect(classifyProbeResponse(401, body)).toBe("expired");
131 });
132
133 it("matches the status-only classifier everywhere else", () => {
134 for (const status of [200, 204, 404, 429, 500, 503]) {
135 expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe(
136 classifyHttpStatus(status),
137 );
138 }
139 });
140});
packages/core/sdk/src/health-check.ts+62−6
@@ -18,14 +18,24 @@ · 17 unmodified lines
18import { Schema } from "effect";18import { Schema } from "effect";
19 19
20// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
21// Status: the four states a connection can be in. `expired` is the one this21// Status: the five states a connection can be in. `expired` is the one this
22// whole feature exists for (Google's 7-day dev-token revocation): the credential22// whole feature exists for (Google's 7-day dev-token revocation): the credential
23// authenticated fine yesterday and now returns 401/403. `degraded` covers any23// authenticated fine yesterday and now returns 401/403. `misconfigured` is the
24// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is24// 403 that is NOT a credential problem: the token authenticated, but the
25// "never checked / no health check configured".25// upstream rejects on configuration (a Google API not enabled in the OAuth
26// client's GCP project): the fix is in the provider console, so telling the
27// user to reconnect would mislead. `degraded` covers any other non-2xx
28// (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked
29// / no health check configured".
26// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
27 31
28export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);32export const HealthStatus = Schema.Literals([
33 "healthy",
34 "expired",
35 "misconfigured",
36 "degraded",
37 "unknown",
38]);
29export type HealthStatus = typeof HealthStatus.Type;39export type HealthStatus = typeof HealthStatus.Type;
30 40
31// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
@@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; · 107 unmodified lines
139// ---------------------------------------------------------------------------149// ---------------------------------------------------------------------------
140 150
141/** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth151/** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth
142 * wall), everything else degraded. */152 * wall), everything else degraded. Status-only fallback: when the response
153 * BODY is available, use `classifyProbeResponse` instead, which tells a
154 * credential 403 apart from a configuration 403. */
143export const classifyHttpStatus = (status: number): HealthStatus => {155export const classifyHttpStatus = (status: number): HealthStatus => {
144 if (status >= 200 && status < 300) return "healthy";156 if (status >= 200 && status < 300) return "healthy";
145 if (status === 401 || status === 403) return "expired";157 if (status === 401 || status === 403) return "expired";
146 return "degraded";158 return "degraded";
147};159};
148 160
161// Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection
162// rather than a credential one. Deliberately narrow and provider-shaped:
163// Google's error envelope carries `errors[].reason: "accessNotConfigured"`
164// (message "<API> has not been used in project N before or it is disabled")
165// and newer surfaces use `status: "PERMISSION_DENIED"` with
166// `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired
167// classification: a false "expired" prompts a harmless reconnect, but a false
168// "misconfigured" would hide a genuinely dead credential.
169const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]);
170
171/** Collect candidate `reason` markers from a Google-shaped error envelope:
172 * `error.errors[].reason` (classic) and `error.details[].reason` (newer
173 * google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */
174const errorReasonMarkers = (body: unknown): string[] => {
175 if (body == null || typeof body !== "object") return [];
176 const error = (body as Record<string, unknown>)["error"];
177 if (error == null || typeof error !== "object") return [];
178 const markers: string[] = [];
179 for (const key of ["errors", "details"] as const) {
180 const entries = (error as Record<string, unknown>)[key];
181 if (!Array.isArray(entries)) continue;
182 for (const entry of entries) {
183 if (entry == null || typeof entry !== "object") continue;
184 const reason = (entry as Record<string, unknown>)["reason"];
185 if (typeof reason === "string") markers.push(reason.toLowerCase());
186 }
187 }
188 return markers;
189};
190
191/** Classify a probe response from its status AND body. Everything is
192 * `classifyHttpStatus` except one carve-out: a 403 whose error body carries a
193 * known configuration reason (Google `accessNotConfigured` /
194 * `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential
195 * authenticated; the upstream API is disabled in the OAuth client's project,
196 * and only enabling it there (not reconnecting) fixes it. */
197export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => {
198 const byStatus = classifyHttpStatus(status);
199 if (status !== 403 || byStatus !== "expired") return byStatus;
200 return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason))
201 ? "misconfigured"
202 : "expired";
203};
204
149/** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a205/** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a
150 * display string. Numeric segments index arrays. Returns undefined when the206 * display string. Numeric segments index arrays. Returns undefined when the
151 * path is empty, missing, or lands on a non-scalar. */207 * path is empty, missing, or lands on a non-scalar. */
packages/core/sdk/src/index.ts+1−0
@@ -122,6 +122,7 @@ export { · 121 unmodified lines
122 HealthCheckCandidateParameter,122 HealthCheckCandidateParameter,
123 HealthCheckResponseField,123 HealthCheckResponseField,
124 classifyHttpStatus,124 classifyHttpStatus,
125 classifyProbeResponse,
125 extractIdentity,126 extractIdentity,
126 compareHealthCheckCandidates,127 compareHealthCheckCandidates,
127 candidateIdentityTier,128 candidateIdentityTier,
packages/core/sdk/src/oauth-client.ts+19−0
@@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines
187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth
188 * user-token flow does not use scopes. Omit for normal OAuth clients. */188 * user-token flow does not use scopes. Omit for normal OAuth clients. */
189 readonly authorizationScopes?: readonly string[];189 readonly authorizationScopes?: readonly string[];
190 /** Scopes the host always adds to the integration-declared set. Use this for
191 * provider lifecycle scopes such as Atlassian and Microsoft
192 * `offline_access`; unlike `authorizationScopes`, this preserves the
193 * integration's least-privilege scopes. */
194 readonly additionalAuthorizationScopes?: readonly string[];
195 /** Separator used in the authorize URL's `scope` parameter. OAuth defaults
196 * to a space; legacy providers such as Linear require a comma. */
197 readonly authorizationScopeSeparator?: string;
198 /** Provider-specific, non-secret authorize parameters fixed by the host
199 * registration contract (for example Atlassian's `audience`). These are
200 * merged after generic provider defaults and may intentionally override
201 * them. */
202 readonly authorizationExtraParams?: Readonly<Record<string, string>>;
203 /** Token endpoint client-auth transport. Omitted means
204 * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */
205 readonly tokenEndpointAuthMethod?: "body" | "basic";
206 /** Token endpoint request encoding. OAuth defaults to URL-encoded form;
207 * providers such as Atlassian, ClickUp, and Notion require JSON. */
208 readonly tokenRequestFormat?: "form" | "json";
190 /** Withdraw the app from every listing surface without retiring it. It stops209 /** Withdraw the app from every listing surface without retiring it. It stops
191 * appearing in `listClients` — so connect pickers and the agent-facing210 * appearing in `listClients` — so connect pickers and the agent-facing
192 * client list never offer it — while remaining fully resolvable by slug.211 * client list never offer it — while remaining fully resolvable by slug.
packages/core/sdk/src/oauth-first-party.test.ts+36−0
@@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { · 198 unmodified lines
199 ),199 ),
200 );200 );
201 201
202 it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () =>
203 Effect.scoped(
204 Effect.gen(function* () {
205 const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });
206 const { executor } = yield* makeTestWorkspaceHarness({
207 plugins,
208 firstPartyOAuthClients: [
209 {
210 ...firstPartyClientFor(server),
211 allowedScopes: ["read", "offline_access"],
212 additionalAuthorizationScopes: ["offline_access"],
213 authorizationScopeSeparator: ",",
214 authorizationExtraParams: { audience: "api.example.com", prompt: "consent" },
215 },
216 ],
217 });
218 yield* executor.acme.seed(["read"]);
219
220 const started = yield* executor.oauth.start({
221 owner: "org",
222 client: FIRST_PARTY,
223 clientOwner: "org",
224 name: ConnectionName.make("lifecycle"),
225 integration: INTEG,
226 template: TEMPLATE,
227 });
228 expect(started.status).toBe("redirect");
229 if (started.status !== "redirect") return;
230 const authorizationUrl = new URL(started.authorizationUrl);
231 expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access");
232 expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com");
233 expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
234 }),
235 ),
236 );
237
202 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>238 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
203 Effect.scoped(239 Effect.scoped(
204 Effect.gen(function* () {240 Effect.gen(function* () {
packages/core/sdk/src/oauth-flow.test.ts+423−9
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Deferred, Effect, Fiber, Predicate } from "effect";2import { Deferred, Effect, Fiber, Predicate } from "effect";
3import { withQueryContext } from "@executor-js/fumadb/query";
3 4
4import {5import {
5 AuthTemplateSlug,6 AuthTemplateSlug,
@@ -8,6 +9,8 @@ import { · 2 unmodified lines
8 OAuthClientSlug,9 OAuthClientSlug,
9 OAuthState,10 OAuthState,
10 ProviderKey,11 ProviderKey,
12 Subject,
13 Tenant,
11 ToolAddress,14 ToolAddress,
12 ToolName,15 ToolName,
13} from "./ids";16} from "./ids";
@@ -68,6 +71,69 @@ const oauthPlugin = definePlugin(() => ({ · 54 unmodified lines
68 71
69const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const;72const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const;
70 73
74// Stated explicitly where a test builds a SECOND root database handle by hand:
75// both handles must carry the same owner-policy context to address one
76// connection, so the values cannot be left to `makeTestConfig`'s defaults.
77const SHARED_STORE_TENANT = "test-tenant";
78const SHARED_STORE_SUBJECT = "test-subject";
79
80/** The URL a `fetch` double was handed, however the caller spelled it. */
81const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string =>
82 typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
83
84/**
85 * A `fetch` that holds token-endpoint requests open AFTER the authorization
86 * server has answered them.
87 *
88 * Parking after the response is the point. The refresh token has been rotated
89 * upstream by then, and the in-flight gate entry is still registered, so a peer
90 * arriving during the park has to resolve against an OPEN grant rather than a
91 * settled one. Parking before the response would prove nothing: the grant would
92 * never reach the server, and a peer that went on to run its own grant would
93 * find the stored token still live and succeed.
94 *
95 * Idle until `arm()`, so connection setup (the authorization-code exchange)
96 * runs through untouched.
97 */
98const makeTokenRequestPark = () => {
99 let armed = false;
100 let onSeen: (() => void) | null = null;
101 const seen = new Promise<void>((resolve) => {
102 onSeen = resolve;
103 });
104 let onRelease: (() => void) | null = null;
105 const parked = new Promise<void>((resolve) => {
106 onRelease = resolve;
107 });
108 // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant.
109 const platformFetch: typeof globalThis.fetch = globalThis.fetch;
110 const fetch: typeof globalThis.fetch = async (input, init) => {
111 const response = await platformFetch(input, init);
112 if (armed && new URL(fetchTarget(input)).pathname === "/token") {
113 onSeen?.();
114 await parked;
115 }
116 return response;
117 };
118 return {
119 fetch,
120 arm: () => {
121 armed = true;
122 },
123 /** Resolves once a token request has been answered and is being held. */
124 seen,
125 release: () => onRelease?.(),
126 };
127};
128
129/** Every refresh-token grant the authorization server was asked for. */
130const refreshGrantsIn = (
131 requests: ReadonlyArray<{ readonly path: string; readonly body: string }>,
132) =>
133 requests.filter(
134 (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"),
135 );
136
71interface TokenEndpointCall {137interface TokenEndpointCall {
72 readonly host: string;138 readonly host: string;
73 readonly grantType: string | null;139 readonly grantType: string | null;
@@ -887,14 +953,344 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 813 unmodified lines
887 ),953 ),
888 );954 );
889 955
956 // Issue #1520, in one process. A self-host builds a FRESH execution stack per
957 // MCP session over ONE database handle, so two sessions resolving the same
958 // connection each read the same stored refresh token and each believe they
959 // are the refresh winner. The authorization server rotates that token, so the
960 // loser redeems one the winner already spent, and a server that detects reuse
961 // revokes the whole family: the connection dies and the user must
962 // reauthorize. The first refresh always succeeds, which is why the fault
963 // stays invisible until a later expiry.
964 it.effect("two execution stacks over one host database share a single refresh grant", () =>
965 Effect.scoped(
966 Effect.gen(function* () {
967 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
968 const park = makeTokenRequestPark();
969
970 // One database handle and one credential store under two execution
971 // stacks — what a self-host holds while two MCP sessions are open.
972 const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
973 const sessionA = yield* createExecutor(config);
974 const sessionB = yield* createExecutor(config);
975 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
976 yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
977 yield* Effect.addFinalizer(() =>
978 Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
979 );
980
981 yield* sessionA.acme.seed();
982 yield* sessionA.oauth.createClient({
983 owner: "org",
984 slug: CLIENT,
985 authorizationUrl: server.authorizationEndpoint,
986 tokenUrl: server.tokenEndpoint,
987 grant: "authorization_code",
988 clientId: "test-client",
989 clientSecret: "test-secret",
990 });
991 const started = yield* sessionA.oauth.start({
992 owner: "org",
993 client: CLIENT,
994 clientOwner: "org",
995 name: ConnectionName.make("main"),
996 integration: INTEG,
997 template: TEMPLATE,
998 });
999 expect(started.status).toBe("redirect");
1000 if (started.status !== "redirect") return;
1001 const callback = yield* server.completeAuthorizationCodeFlow({
1002 authorizationUrl: started.authorizationUrl,
1003 });
1004 yield* sessionA.oauth.complete({ state: started.state, code: callback.code });
1005
1006 const address = ToolAddress.make("tools.acme.org.main.whoami");
1007 const original = (yield* sessionA.execute(address, {})) as { token: string };
1008
1009 // Expire the access token so BOTH stacks must refresh.
1010 yield* Effect.promise(() =>
1011 config.db.updateMany("connection", {
1012 where: (b) => b("name", "=", "main"),
1013 set: { expires_at: Date.now() - 60_000 },
1014 }),
1015 );
1016 yield* server.clearRequests;
1017 park.arm();
1018
1019 const first = yield* Effect.forkChild(sessionA.execute(address, {}));
1020 const second = yield* Effect.forkChild(sessionB.execute(address, {}));
1021 // Release only once a grant has been answered and is being held open,
1022 // so the peer resolves against a grant that is still in flight. Without
1023 // the park the peer could arrive after the winner had already settled,
1024 // find a fresh token, refresh nothing, and pass this test for the wrong
1025 // reason.
1026 yield* Effect.promise(() => park.seen);
1027 park.release();
1028
1029 const firstToken = (yield* Fiber.join(first)) as { token: string };
1030 const secondToken = (yield* Fiber.join(second)) as { token: string };
1031
1032 expect(firstToken.token, "the refresh minted a new access token").not.toBe(original.token);
1033 expect(secondToken.token, "both stacks resolved the SAME refreshed token").toBe(
1034 firstToken.token,
1035 );
1036 expect(
1037 refreshGrantsIn(yield* server.requests),
1038 "one refresh grant for the connection, not one per execution stack",
1039 ).toHaveLength(1);
1040 }),
1041 ),
1042 );
1043
1044 // The gate spans tenants (one map per root DB handle), so its key must keep
1045 // tenant and subject unambiguous. Both are opaque strings that may contain
1046 // any delimiter: under a colon-joined key, tenant "a" + subject "user:b" and
1047 // tenant "a:user" + subject "b" both flatten to "a:user:user:b:…", so two
1048 // DIFFERENT tenants' refreshes would share one gate entry and one tenant's
1049 // caller would be handed the other tenant's access token.
1050 it.effect(
1051 "colliding tenant/subject pairs never share a refresh gate entry",
1052 () =>
1053 Effect.scoped(
1054 Effect.gen(function* () {
1055 const serverA = yield* serveOAuthTestServer({ scopes: ["read"] });
1056 const serverB = yield* serveOAuthTestServer({ scopes: ["read"] });
1057 const parkA = makeTokenRequestPark();
1058 const parkB = makeTokenRequestPark();
1059
1060 // ONE root DB handle under TWO tenants — the shape a multi-tenant host
1061 // holds — so both executors share one refresh gate. `shared.db` stays
1062 // bound to tenant A's owner-policy context; tenant B's row edits below
1063 // build their own scoped handle by hand. Each tenant gets its OWN
1064 // credential store instance: the memory store keys items without a
1065 // tenant, so sharing one across tenants would cross their tokens at
1066 // the store layer and mask the gate-key collision this test is about.
1067 const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;
1068 const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;
1069 const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" });
1070 const configA = { ...shared, fetch: parkA.fetch };
1071 const configB = {
1072 ...shared,
1073 plugins: pluginsB,
1074 tenant: Tenant.make("a:user"),
1075 subject: Subject.make("b"),
1076 fetch: parkB.fetch,
1077 };
1078 const sessionA = yield* createExecutor(configA);
1079 const sessionB = yield* createExecutor(configB);
1080 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
1081 yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
1082 yield* Effect.addFinalizer(() =>
1083 Effect.promise(() => shared.testDb.close()).pipe(Effect.ignore),
1084 );
1085
1086 // Each tenant mints its own USER-owned connection (user rows carry the
1087 // session subject, which is what the colliding pair needs) against its
1088 // own authorization server, so token provenance is observable.
1089 const connect = (session: typeof sessionA, server: typeof serverA) =>
1090 Effect.gen(function* () {
1091 yield* session.acme.seed();
1092 yield* session.oauth.createClient({
1093 owner: "org",
1094 slug: CLIENT,
1095 authorizationUrl: server.authorizationEndpoint,
1096 tokenUrl: server.tokenEndpoint,
1097 grant: "authorization_code",
1098 clientId: "test-client",
1099 clientSecret: "test-secret",
1100 });
1101 const started = yield* session.oauth.start({
1102 owner: "user",
1103 client: CLIENT,
1104 clientOwner: "org",
1105 name: ConnectionName.make("mine"),
1106 integration: INTEG,
1107 template: TEMPLATE,
1108 });
1109 expect(started.status).toBe("redirect");
1110 if (started.status !== "redirect") return;
1111 const callback = yield* server.completeAuthorizationCodeFlow({
1112 authorizationUrl: started.authorizationUrl,
1113 });
1114 yield* session.oauth.complete({ state: started.state, code: callback.code });
1115 });
1116 yield* connect(sessionA, serverA);
1117 yield* connect(sessionB, serverB);
1118
1119 const address = ToolAddress.make("tools.acme.user.mine.whoami");
1120 const originalA = (yield* sessionA.execute(address, {})) as { token: string };
1121 const originalB = (yield* sessionB.execute(address, {})) as { token: string };
1122 expect(originalB.token).not.toBe(originalA.token);
1123
1124 // Expire BOTH rows so both tenants must refresh. `shared.db` is bound
1125 // to tenant A; tenant B's partition needs its own scoped handle.
1126 const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" });
1127 yield* Effect.promise(() =>
1128 shared.db.updateMany("connection", {
1129 where: (b) => b("name", "=", "mine"),
1130 set: { expires_at: Date.now() - 60_000 },
1131 }),
1132 );
1133 yield* Effect.promise(() =>
1134 dbB.updateMany("connection", {
1135 where: (b) => b("name", "=", "mine"),
1136 set: { expires_at: Date.now() - 60_000 },
1137 }),
1138 );
1139
1140 parkA.arm();
1141 parkB.arm();
1142 const first = yield* Effect.forkChild(sessionA.execute(address, {}));
1143 // Hold tenant A's grant open so its gate entry is still registered
1144 // when tenant B performs its lookup of the would-be colliding key.
1145 yield* Effect.promise(() => parkA.seen);
1146 const second = yield* Effect.forkChild(sessionB.execute(address, {}));
1147 // With a collision-free key, tenant B misses the gate and sends its
1148 // OWN grant. Under a colliding key it would await tenant A's deferred
1149 // and never reach its server, so cap the wait with a real timer (the
1150 // test clock is virtual, so Effect.sleep would never fire) instead of
1151 // hanging the suite; the assertions below then report the bleed.
1152 yield* Effect.promise(() =>
1153 Promise.race([parkB.seen, new Promise((resolve) => setTimeout(resolve, 2_000))]),
1154 );
1155 parkA.release();
1156 parkB.release();
1157
1158 const tokenA = (yield* Fiber.join(first)) as { token: string };
1159 const tokenB = (yield* Fiber.join(second)) as { token: string };
1160
1161 expect(tokenA.token, "tenant A refreshed to a new token").not.toBe(originalA.token);
1162 expect(tokenB.token, "tenant B refreshed to a new token").not.toBe(originalB.token);
1163 expect(tokenB.token, "no cross-tenant token bleed").not.toBe(tokenA.token);
1164 expect(yield* serverA.acceptsAccessToken(tokenA.token)).toBe(true);
1165 expect(
1166 yield* serverB.acceptsAccessToken(tokenB.token),
1167 "tenant B's token was minted by tenant B's own authorization server",
1168 ).toBe(true);
1169 expect(
1170 refreshGrantsIn(yield* serverA.requests),
1171 "tenant A ran its own refresh grant",
1172 ).toHaveLength(1);
1173 expect(
1174 refreshGrantsIn(yield* serverB.requests),
1175 "tenant B ran its own refresh grant — two distinct refresh executions",
1176 ).toHaveLength(1);
1177 }),
1178 ),
1179 // Two authorization servers, two full mint flows and two refreshes — about
1180 // twice the cost of the single-tenant gate tests, which sits on the 5s
1181 // default when the test runs cold.
1182 20_000,
1183 );
1184
1185 // The gate entry is shared, so the stack that REGISTERS a grant is only the
1186 // first arrival, not its owner. Running the grant on that caller's fiber
1187 // would hand it that caller's interruption — a disconnected MCP client, an
1188 // execution deadline, a cancelled tool call — and abandon a refresh token the
1189 // authorization server has ALREADY rotated. What the store still holds is
1190 // then dead, and the next grant is answered invalid_grant: the interruption
1191 // would have killed the connection. So the grant runs detached, and callers
1192 // only await it.
1193 it.effect("an interrupted first arrival still settles the grant and persists its token", () =>
1194 Effect.scoped(
1195 Effect.gen(function* () {
1196 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1197 const park = makeTokenRequestPark();
1198
1199 const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
1200 const sessionA = yield* createExecutor(config);
1201 const sessionB = yield* createExecutor(config);
1202 yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
1203 yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
1204 yield* Effect.addFinalizer(() =>
1205 Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
1206 );
1207
1208 yield* sessionA.acme.seed();
1209 yield* sessionA.oauth.createClient({
1210 owner: "org",
1211 slug: CLIENT,
1212 authorizationUrl: server.authorizationEndpoint,
1213 tokenUrl: server.tokenEndpoint,
1214 grant: "authorization_code",
1215 clientId: "test-client",
1216 clientSecret: "test-secret",
1217 });
1218 const started = yield* sessionA.oauth.start({
1219 owner: "org",
1220 client: CLIENT,
1221 clientOwner: "org",
1222 name: ConnectionName.make("main"),
1223 integration: INTEG,
1224 template: TEMPLATE,
1225 });
1226 expect(started.status).toBe("redirect");
1227 if (started.status !== "redirect") return;
1228 const callback = yield* server.completeAuthorizationCodeFlow({
1229 authorizationUrl: started.authorizationUrl,
1230 });
1231 yield* sessionA.oauth.complete({ state: started.state, code: callback.code });
1232
1233 const address = ToolAddress.make("tools.acme.org.main.whoami");
1234 const original = (yield* sessionA.execute(address, {})) as { token: string };
1235 yield* Effect.promise(() =>
1236 config.db.updateMany("connection", {
1237 where: (b) => b("name", "=", "main"),
1238 set: { expires_at: Date.now() - 60_000 },
1239 }),
1240 );
1241 yield* server.clearRequests;
1242 park.arm();
1243
1244 // The first arrival registers the grant, and its session drops the
1245 // instant the authorization server has rotated the token — the worst
1246 // possible moment, and the one a disconnecting MCP client picks.
1247 const arrival = yield* Effect.forkChild(Effect.exit(sessionA.execute(address, {})));
1248 yield* Effect.promise(() => park.seen);
1249 yield* Fiber.interrupt(arrival);
1250 park.release();
1251
1252 // That the grant finishes at all, with nobody left waiting on it, is
1253 // the property under test: a rotated token that is never persisted is a
1254 // dead connection.
1255 const persisted = yield* Effect.promise(async () => {
1256 for (let attempt = 0; attempt < 500; attempt += 1) {
1257 const row = await config.db.findFirst("connection", {
1258 where: (b) => b("name", "=", "main"),
1259 });
1260 const expiresAt = row?.expires_at;
1261 if (expiresAt != null && Number(expiresAt) > Date.now()) return true;
1262 await new Promise((resolve) => setTimeout(resolve, 10));
1263 }
1264 return false;
1265 });
1266 expect(persisted, "the detached grant settled and persisted its rotated token").toBe(true);
1267
1268 const recovered = (yield* sessionB.execute(address, {})) as { token: string };
1269 expect(recovered.token, "the peer stack resolved the rotated token").not.toBe(
1270 original.token,
1271 );
1272 expect(
1273 yield* server.acceptsAccessToken(recovered.token),
1274 "and the authorization server still honours it",
1275 ).toBe(true);
1276 expect(
1277 refreshGrantsIn(yield* server.requests),
1278 "the interrupted arrival's grant settled, so no second grant was needed",
1279 ).toHaveLength(1);
1280 }),
1281 ),
1282 );
1283
890 // Two product instances, one connection, one credential store. The in-flight1284 // Two product instances, one connection, one credential store. The in-flight
891 // refresh gate serialises refreshes WITHIN an instance and cannot see across1285 // refresh gate serialises refreshes across every execution stack over ONE
892 // them, so nothing but the store itself stands between two refreshers and1286 // root database handle and cannot see past it, so between two INSTANCES —
893 // the same rotated token. The provider below opens a seam exactly where the1287 // two replicas, two isolates — nothing but the store itself stands between
894 // danger is — between the read of the stored refresh token and whatever the1288 // two refreshers and the same rotated token. The provider below opens a seam
895 // reader writes next — because a probe that "tests" the store by rewriting1289 // exactly where the danger is — between the read of the stored refresh token
896 // the value it just read would put the spent token back over the peer's1290 // and whatever the reader writes next — because a probe that "tests" the
897 // rotated one, and kill the connection it was added to protect.1291 // store by rewriting the value it just read would put the spent token back
1292 // over the peer's rotated one, and kill the connection it was added to
1293 // protect.
898 it.effect(1294 it.effect(
899 "a refresher paused after reading the stored token never writes it back over a peer's rotated one",1295 "a refresher paused after reading the stored token never writes it back over a peer's rotated one",
900 () =>1296 () =>
@@ -936,12 +1332,30 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 35 unmodified lines
936 // One database and one credential store, two executors over them —1332 // One database and one credential store, two executors over them —
937 // the deployment this race needs and the one a single harness1333 // the deployment this race needs and the one a single harness
938 // cannot express.1334 // cannot express.
1335 //
1336 // Each executor gets its OWN root database handle onto that one
1337 // database, because that handle is what identifies an instance: the
1338 // in-flight refresh gate is shared per handle, so two executors over
1339 // the SAME handle are two execution stacks in one instance and the
1340 // second would simply join the first's grant — closing the very
1341 // window this test exists to open. A second replica holds a second
1342 // handle, which is what the extra `withQueryContext` wrapper is.
939 const config = {1343 const config = {
940 ...makeTestConfig({ plugins: [oauthPlugin] as const }),1344 ...makeTestConfig({
1345 plugins: [oauthPlugin] as const,
1346 tenant: SHARED_STORE_TENANT,
1347 subject: SHARED_STORE_SUBJECT,
1348 }),
941 providers: [sharedStore],1349 providers: [sharedStore],
942 };1350 };
943 const instanceA = yield* createExecutor(config);1351 const instanceA = yield* createExecutor(config);
944 const instanceB = yield* createExecutor(config);1352 const instanceB = yield* createExecutor({
1353 ...config,
1354 db: withQueryContext(config.testDb.db, {
1355 tenant: SHARED_STORE_TENANT,
1356 subject: SHARED_STORE_SUBJECT,
1357 }),
1358 });
945 yield* Effect.addFinalizer(() =>1359 yield* Effect.addFinalizer(() =>
946 Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),1360 Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
947 );1361 );
packages/core/sdk/src/oauth-helpers.test.ts+183−1
@@ -6,7 +6,8 @@ · 5 unmodified lines
6// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
7 7
8import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import { Cause, Effect, Exit, Ref } from "effect";9import { Cause, Effect, Exit, Ref, Schema } from "effect";
10import type * as Tracer from "effect/Tracer";
10import { HttpServerResponse } from "effect/unstable/http";11import { HttpServerResponse } from "effect/unstable/http";
11 12
12import {13import {
@@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines
34 readonly url: string;35 readonly url: string;
35 readonly headers: Readonly<Record<string, string>>;36 readonly headers: Readonly<Record<string, string>>;
36 readonly body: URLSearchParams;37 readonly body: URLSearchParams;
38 readonly jsonBody: unknown;
37}39}
38 40
41const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42
39type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;43type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;
40 44
41const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>45const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
@@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines
52 url: request.url ?? "/",56 url: request.url ?? "/",
53 headers: request.headers,57 headers: request.headers,
54 body: new URLSearchParams(bodyText),58 body: new URLSearchParams(bodyText),
59 jsonBody: request.headers["content-type"]?.startsWith("application/json")
60 ? decodeJsonBody(bodyText)
61 : null,
55 };62 };
56 yield* Ref.update(calls, (all) => [...all, call]);63 yield* Ref.update(calls, (all) => [...all, call]);
57 return handler(call);64 return handler(call);
@@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines
103 () =>110 () =>
104 json(200, body);111 json(200, body);
105 112
113/** Records each span's attributes and its ending exit, so a test can assert on
114 * exactly what telemetry would export for the token-request span. */
115interface RecordedSpan {
116 readonly attributes: Map<string, unknown>;
117 endExit?: unknown;
118}
119
120const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({
121 span: (options) => {
122 const record: RecordedSpan = { attributes: new Map() };
123 spans.set(options.name, record);
124 let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
125 return {
126 _tag: "Span",
127 name: options.name,
128 spanId: "0000000000000001",
129 traceId: "00000000000000000000000000000001",
130 parent: options.parent,
131 annotations: options.annotations,
132 get status() {
133 return status;
134 },
135 attributes: record.attributes,
136 links: options.links,
137 sampled: options.sampled,
138 kind: options.kind,
139 end: (endTime, exit) => {
140 record.endExit = exit;
141 status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
142 },
143 attribute: (key, value) => {
144 record.attributes.set(key, value);
145 },
146 event: () => undefined,
147 addLinks: () => undefined,
148 };
149 },
150});
151
106const tokenResponseFetch =152const tokenResponseFetch =
107 (body: unknown): typeof globalThis.fetch =>153 (body: unknown): typeof globalThis.fetch =>
108 async () =>154 async () =>
@@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines
260});306});
261 307
262describe("exchangeAuthorizationCode", () => {308describe("exchangeAuthorizationCode", () => {
309 it.effect("supports JSON token exchange with HTTP Basic client authentication", () =>
310 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
311 Effect.gen(function* () {
312 yield* exchangeAuthorizationCode({
313 tokenUrl,
314 clientId: "cid",
315 clientSecret: "csecret",
316 redirectUrl: "https://app.example.com/cb",
317 codeVerifier: "verifier",
318 code: "abc",
319 clientAuth: "basic",
320 requestFormat: "json",
321 });
322 const call = (yield* calls)[0]!;
323 expect(call.headers["content-type"]).toBe("application/json");
324 expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");
325 expect(call.jsonBody).toEqual({
326 grant_type: "authorization_code",
327 code: "abc",
328 redirect_uri: "https://app.example.com/cb",
329 code_verifier: "verifier",
330 });
331 }),
332 ),
333 );
334
335 it.effect("supports JSON token exchange with client credentials in the body", () =>
336 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
337 Effect.gen(function* () {
338 yield* exchangeAuthorizationCode({
339 tokenUrl,
340 clientId: "cid",
341 clientSecret: "csecret",
342 redirectUrl: "https://app.example.com/cb",
343 codeVerifier: "verifier",
344 code: "abc",
345 requestFormat: "json",
346 });
347 expect((yield* calls)[0]!.jsonBody).toEqual({
348 grant_type: "authorization_code",
349 code: "abc",
350 redirect_uri: "https://app.example.com/cb",
351 code_verifier: "verifier",
352 client_id: "cid",
353 client_secret: "csecret",
354 });
355 }),
356 ),
357 );
358
263 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>359 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>
264 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>360 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
265 Effect.gen(function* () {361 Effect.gen(function* () {
@@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines
765 }),861 }),
766 );862 );
767 863
864 // Non-secret description text must keep propagating even from a confidential
865 // client: the scrub below removes ONLY the exact submitted secret, never the
866 // AS's verdict prose around it.
768 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>867 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>
769 withTokenEndpoint(868 withTokenEndpoint(
770 () =>869 () =>
@@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines
778 exchangeAuthorizationCode({877 exchangeAuthorizationCode({
779 tokenUrl,878 tokenUrl,
780 clientId: "cid",879 clientId: "cid",
880 clientSecret: "csecret",
781 redirectUrl: "https://cb",881 redirectUrl: "https://cb",
782 codeVerifier: "v",882 codeVerifier: "v",
783 code: "c",883 code: "c",
@@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines
786 expect(Exit.isFailure(exit)).toBe(true);886 expect(Exit.isFailure(exit)).toBe(true);
787 if (!Exit.isFailure(exit)) return;887 if (!Exit.isFailure(exit)) return;
788 expect(JSON.stringify(exit.cause)).toContain("Code expired");888 expect(JSON.stringify(exit.cause)).toContain("Code expired");
889 const failure = Cause.squash(exit.cause) as OAuth2Error;
890 expect(failure.error).toBe("invalid_grant");
789 }),891 }),
790 ),892 ),
791 );893 );
792 894
895 // The review's canary for the first-party secret leak. A provider that
896 // echoes the submitted client_secret inside error_description would carry a
897 // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there
898 // into OAuthCompleteError, the popup's browser-visible errorDetails, and the
899 // token-request span. Assert on the WHOLE rendered failure, not just the
900 // message: what a log line or a `JSON.stringify` prints includes the retained
901 // rejection, so an echo surviving anywhere in the cause is still a leak.
902 it.effect("scrubs a client secret echoed in error_description from the whole failure", () => {
903 const spans = new Map<string, RecordedSpan>();
904 return withTokenEndpoint(
905 () =>
906 json(400, {
907 error: "invalid_client",
908 error_description:
909 "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials",
910 }),
911 ({ tokenUrl }) =>
912 Effect.gen(function* () {
913 const exit = yield* Effect.exit(
914 exchangeAuthorizationCode({
915 tokenUrl,
916 clientId: "cid",
917 clientSecret: "SECRET-CANARY-must-not-escape",
918 redirectUrl: "https://cb",
919 codeVerifier: "v",
920 code: "c",
921 }),
922 );
923 expect(Exit.isFailure(exit)).toBe(true);
924 if (!Exit.isFailure(exit)) return;
925 for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) {
926 expect(rendered).not.toContain("SECRET-CANARY-must-not-escape");
927 }
928 const failure = Cause.squash(exit.cause) as OAuth2Error;
929 expect(failure).toBeInstanceOf(OAuth2Error);
930 // Masked, not dropped: the verdict prose around the secret survives,
931 // and so does everything classification and the span read.
932 expect(failure.message).toContain("[redacted]");
933 expect(failure.message).toContain("check your credentials");
934 expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape");
935 expect(failure.error).toBe("invalid_client");
936 expect(failure.status).toBe(400);
937 // The token-request span — attributes AND ending exit — is what the
938 // exporter sees; the echo must not survive there either.
939 const tokenSpan = spans.get("executor.oauth.token_request");
940 expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client");
941 const spanRendered = JSON.stringify({
942 attributes: [...(tokenSpan?.attributes ?? [])],
943 exit: tokenSpan?.endExit,
944 });
945 expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape");
946 }),
947 ).pipe(Effect.withTracer(makeRecordingTracer(spans)));
948 });
949
793 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>950 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>
794 withTokenEndpoint(951 withTokenEndpoint(
795 () => HttpServerResponse.text("route not found", { status: 404 }),952 () => HttpServerResponse.text("route not found", { status: 404 }),
@@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines
1192});1349});
1193 1350
1194describe("refreshAccessToken", () => {1351describe("refreshAccessToken", () => {
1352 it.effect("persists provider-compatible JSON refresh rotation requests", () =>
1353 withTokenEndpoint(
1354 tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }),
1355 ({ tokenUrl, calls }) =>
1356 Effect.gen(function* () {
1357 const result = yield* refreshAccessToken({
1358 tokenUrl,
1359 clientId: "cid",
1360 clientSecret: "csecret",
1361 refreshToken: "old",
1362 scopes: ["read", "offline_access"],
1363 requestFormat: "json",
1364 });
1365 expect(result.refresh_token).toBe("rotated");
1366 expect((yield* calls)[0]!.jsonBody).toEqual({
1367 grant_type: "refresh_token",
1368 refresh_token: "old",
1369 scope: "read offline_access",
1370 client_id: "cid",
1371 client_secret: "csecret",
1372 });
1373 }),
1374 ),
1375 );
1376
1195 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>1377 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>
1196 Effect.gen(function* () {1378 Effect.gen(function* () {
1197 const result = yield* refreshAccessToken({1379 const result = yield* refreshAccessToken({
packages/core/sdk/src/oauth-helpers.ts+169−33
@@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines
641 });641 });
642};642};
643 643
644const toOAuth2Error = (cause: unknown): OAuth2Error => {644/** Exact-string scrub of the submitted client secret from text bound for an
645 * `OAuth2Error`. A misbehaving authorization server can echo the credential it
646 * was just sent back inside `error_description` — a field that is deliberately
647 * previewable everywhere else in this module because it is the AS's
648 * human-readable verdict. The message it lands in reaches the OAuth popup's
649 * browser-visible error details, persisted connection health, and telemetry,
650 * and for a first-party client the secret is deployment-wide. The helper knows
651 * exactly which string it submitted, so this is a targeted replacement of that
652 * one value, not a heuristic. */
653const redactSubmittedClientSecret = (
654 text: string,
655 clientSecret: string | null | undefined,
656): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text);
657
658const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => {
645 if (isOAuth2Error(cause)) return cause;659 if (isOAuth2Error(cause)) return cause;
660 const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret);
646 if (typeof cause === "object" && cause !== null) {661 if (typeof cause === "object" && cause !== null) {
647 const c = cause as {662 const c = cause as {
648 error?: unknown;663 error?: unknown;
@@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines
657 ? c.message672 ? c.message
658 : undefined;673 : undefined;
659 return new OAuth2Error({674 return new OAuth2Error({
660 message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`,675 message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`),
661 error: code,676 // The code is scrubbed too: a conform envelope's `error` is an arbitrary
677 // string the AS chose, and it flows into the token-request span attribute.
678 error: code === undefined ? undefined : scrub(code),
662 cause,679 cause,
663 });680 });
664 }681 }
@@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines
674 *691 *
675 * `fallbackMessage` is for the paths that hold the error Response directly692 * `fallbackMessage` is for the paths that hold the error Response directly
676 * rather than catching a thrown oauth4webapi error: there is no library693 * rather than catching a thrown oauth4webapi error: there is no library
677 * message to build on, so the caller names the step instead. */694 * message to build on, so the caller names the step instead.
695 *
696 * `clientSecret` is the secret the failed request submitted, when the client
697 * is confidential. Every constructed message is scrubbed of it, because the
698 * body it is built from is the AS's and may echo the credential back. */
678const toOAuth2ErrorWithHttpSummary = (699const toOAuth2ErrorWithHttpSummary = (
679 cause: unknown,700 cause: unknown,
680 options?: { readonly fallbackMessage?: string },701 options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null },
681): Effect.Effect<OAuth2Error> => {702): Effect.Effect<OAuth2Error> => {
682 if (isOAuth2Error(cause)) return Effect.succeed(cause);703 if (isOAuth2Error(cause)) return Effect.succeed(cause);
683 const base = toOAuth2Error(cause);704 const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret);
705 const base = toOAuth2Error(cause, options?.clientSecret);
684 const response = responseFromOAuthErrorCause(cause);706 const response = responseFromOAuthErrorCause(cause);
685 if (!response) {707 if (!response) {
686 // No Response, but possibly a body the library already parsed off one it708 // No Response, but possibly a body the library already parsed off one it
@@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines
704 // exactly the leak the message allowlist exists to prevent. The other726 // exactly the leak the message allowlist exists to prevent. The other
705 // branches keep their cause because theirs is diagnostic, not a body.727 // branches keep their cause because theirs is diagnostic, not a body.
706 new OAuth2Error({728 new OAuth2Error({
707 message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`,729 message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`),
708 error: base.error ?? envelope?.error,730 error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)),
709 status: PARSED_BODY_CAUSE_STATUS,731 status: PARSED_BODY_CAUSE_STATUS,
710 }),732 }),
711 );733 );
@@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines
727 : `${headline}: ${recovered.code}${749 : `${headline}: ${recovered.code}${
728 recovered.description === undefined ? "" : ` — ${recovered.description}`750 recovered.description === undefined ? "" : ` — ${recovered.description}`
729 }`;751 }`;
752 const code = base.error ?? recovered?.code;
753 // The diagnostic rejection is dropped when the submitted secret is visible
754 // in its serialised form — oauth4webapi's ResponseBodyError carries the
755 // AS's `error_description` as an own enumerable field, so anything that
756 // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would
757 // replay the echo straight past the message scrub above. Everything
758 // classification reads — status, code, HTTP summary — is already lifted
759 // out, mirroring the malformed-200 branch's treatment of token-bearing
760 // rejections; the ordinary no-echo failure keeps its cause untouched.
761 const causeEchoesSecret =
762 Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? "");
730 return new OAuth2Error({763 return new OAuth2Error({
731 message: `${described} (${summary})`,764 message: scrub(`${described} (${summary})`),
732 error: base.error ?? recovered?.code,765 error: code === undefined ? undefined : scrub(code),
733 // Carried even when no code was recovered: the status is what tells a766 // Carried even when no code was recovered: the status is what tells a
734 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx767 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx
735 // — retry). Most real refusals arrive with no code at all.768 // — retry). Most real refusals arrive with no code at all.
736 status: response.status,769 status: response.status,
737 cause,770 ...(causeEchoesSecret ? {} : { cause }),
738 });771 });
739 });772 });
740};773};
741 774
742const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>775/** Curried on the submitted client secret so every `Effect.catch` site names
743 toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));776 * the credential its request carried — the scrub cannot work without it. */
777const failOAuth2WithHttpSummary =
778 (clientSecret: string | null | undefined) =>
779 (cause: unknown): Effect.Effect<never, OAuth2Error> =>
780 toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe(
781 Effect.flatMap((error) => Effect.fail(error)),
782 );
744 783
745/** Fail from a token-endpoint error Response the caller holds directly — the784/** Fail from a token-endpoint error Response the caller holds directly — the
746 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw785 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw
@@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines
751const failOAuth2FromErrorResponse = (790const failOAuth2FromErrorResponse = (
752 response: Response,791 response: Response,
753 fallbackMessage: string,792 fallbackMessage: string,
793 clientSecret: string | null | undefined,
754): Effect.Effect<never, OAuth2Error> =>794): Effect.Effect<never, OAuth2Error> =>
755 toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe(795 toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe(
756 Effect.flatMap((error) => Effect.fail(error)),796 Effect.flatMap((error) => Effect.fail(error)),
757 );797 );
758 798
@@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines
1069 readonly codeVerifier: string;1109 readonly codeVerifier: string;
1070 readonly code: string;1110 readonly code: string;
1071 readonly clientAuth?: ClientAuthMethod;1111 readonly clientAuth?: ClientAuthMethod;
1112 /** Encoding required by the provider's token endpoint. OAuth defaults to
1113 * URL-encoded form; a small set of providers require a JSON object. */
1114 readonly requestFormat?: "form" | "json";
1072 readonly idTokenSigningAlgValuesSupported?: readonly string[];1115 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1073 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on1116 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
1074 * the token request when the client knows the resource it intends1117 * the token request when the client knows the resource it intends
@@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines
1079 readonly fetch?: typeof globalThis.fetch;1122 readonly fetch?: typeof globalThis.fetch;
1080};1123};
1081 1124
1125const base64BasicCredentials = (clientId: string, clientSecret: string): string => {
1126 const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
1127 let binary = "";
1128 for (const byte of bytes) binary += String.fromCharCode(byte);
1129 return globalThis.btoa(binary);
1130};
1131
1132const jsonTokenEndpointRequest = async (input: {
1133 readonly tokenUrl: string;
1134 readonly clientId: string;
1135 readonly clientSecret?: string | null;
1136 readonly clientAuth: ClientAuthMethod;
1137 readonly grantType: "authorization_code" | "refresh_token";
1138 readonly parameters: Readonly<Record<string, string>>;
1139 readonly timeoutMs?: number;
1140 readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1141 readonly fetch?: typeof globalThis.fetch;
1142}): Promise<Response> => {
1143 const tokenUrl = assertSupportedOAuthEndpointUrl(
1144 input.tokenUrl,
1145 "Token URL",
1146 input.endpointUrlPolicy,
1147 );
1148 const headers = new Headers({
1149 accept: "application/json",
1150 "content-type": "application/json",
1151 });
1152 const confidential = Boolean(input.clientSecret);
1153 if (confidential && input.clientAuth === "basic") {
1154 headers.set(
1155 "authorization",
1156 `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`,
1157 );
1158 }
1159 const body = {
1160 grant_type: input.grantType,
1161 ...input.parameters,
1162 ...(confidential && input.clientAuth === "basic"
1163 ? {}
1164 : {
1165 client_id: input.clientId,
1166 ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}),
1167 }),
1168 };
1169 // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam
1170 return await (input.fetch ?? globalThis.fetch)(tokenUrl, {
1171 method: "POST",
1172 headers,
1173 body: JSON.stringify(body),
1174 signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
1175 });
1176};
1177
1082export const exchangeAuthorizationCode = (1178export const exchangeAuthorizationCode = (
1083 input: ExchangeAuthorizationCodeInput,1179 input: ExchangeAuthorizationCodeInput,
1084): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>1180): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
@@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines
1106 if (input.resource) {1202 if (input.resource) {
1107 params.set("resource", input.resource);1203 params.set("resource", input.resource);
1108 }1204 }
1109 const response = await oauth.genericTokenEndpointRequest(1205 const response =
1110 as,1206 input.requestFormat === "json"
1111 client,1207 ? await jsonTokenEndpointRequest({
1112 clientAuth,1208 tokenUrl: input.tokenUrl,
1113 "authorization_code",1209 clientId: input.clientId,
1114 params,1210 clientSecret: input.clientSecret,
1115 oauth4webapiRequestOptions(1211 clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1116 input.tokenUrl,1212 grantType: "authorization_code",
1117 input.timeoutMs,1213 parameters: Object.fromEntries(params),
1118 input.endpointUrlPolicy,1214 timeoutMs: input.timeoutMs,
1119 input.fetch,1215 endpointUrlPolicy: input.endpointUrlPolicy,
1120 ),1216 fetch: input.fetch,
1121 );1217 })
1218 : await oauth.genericTokenEndpointRequest(
1219 as,
1220 client,
1221 clientAuth,
1222 "authorization_code",
1223 params,
1224 oauth4webapiRequestOptions(
1225 input.tokenUrl,
1226 input.timeoutMs,
1227 input.endpointUrlPolicy,
1228 input.fetch,
1229 ),
1230 );
1122 return await processTokenEndpointResponse(as, client, response);1231 return await processTokenEndpointResponse(as, client, response);
1123 },1232 },
1124 catch: (cause) => cause,1233 catch: (cause) => cause,
1125 }).pipe(1234 }).pipe(
1126 Effect.catch(failOAuth2WithHttpSummary),1235 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1127 withTokenRequestSpan({1236 withTokenRequestSpan({
1128 grantType: "authorization_code",1237 grantType: "authorization_code",
1129 tokenUrl: input.tokenUrl,1238 tokenUrl: input.tokenUrl,
@@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines
1186 },1295 },
1187 catch: (cause) => cause,1296 catch: (cause) => cause,
1188 }).pipe(1297 }).pipe(
1189 Effect.catch(failOAuth2WithHttpSummary),1298 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1190 withTokenRequestSpan({1299 withTokenRequestSpan({
1191 grantType: "client_credentials",1300 grantType: "client_credentials",
1192 tokenUrl: input.tokenUrl,1301 tokenUrl: input.tokenUrl,
@@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines
1208 readonly scopes?: readonly string[];1317 readonly scopes?: readonly string[];
1209 readonly scopeSeparator?: string;1318 readonly scopeSeparator?: string;
1210 readonly clientAuth?: ClientAuthMethod;1319 readonly clientAuth?: ClientAuthMethod;
1320 /** Encoding required by the provider's token endpoint. OAuth defaults to
1321 * URL-encoded form; a small set of providers require a JSON object. */
1322 readonly requestFormat?: "form" | "json";
1211 readonly idTokenSigningAlgValuesSupported?: readonly string[];1323 readonly idTokenSigningAlgValuesSupported?: readonly string[];
1212 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on1324 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
1213 * refresh requests so the new access token's audience is bound to1325 * refresh requests so the new access token's audience is bound to
@@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines
1241 }1353 }
1242 const additionalParameters =1354 const additionalParameters =
1243 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;1355 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
1356 if (input.requestFormat === "json") {
1357 const response = await jsonTokenEndpointRequest({
1358 tokenUrl: input.tokenUrl,
1359 clientId: input.clientId,
1360 clientSecret: input.clientSecret,
1361 clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1362 grantType: "refresh_token",
1363 parameters: {
1364 refresh_token: input.refreshToken,
1365 ...(input.scopes && input.scopes.length > 0
1366 ? { scope: input.scopes.join(input.scopeSeparator ?? " ") }
1367 : {}),
1368 ...(input.resource ? { resource: input.resource } : {}),
1369 },
1370 timeoutMs: input.timeoutMs,
1371 endpointUrlPolicy: input.endpointUrlPolicy,
1372 fetch: input.fetch,
1373 });
1374 return await processTokenEndpointResponse(as, client, response);
1375 }
1244 const response = await oauth.refreshTokenGrantRequest(1376 const response = await oauth.refreshTokenGrantRequest(
1245 as,1377 as,
1246 client,1378 client,
@@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines
1265 },1397 },
1266 catch: (cause) => cause,1398 catch: (cause) => cause,
1267 }).pipe(1399 }).pipe(
1268 Effect.catch(failOAuth2WithHttpSummary),1400 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1269 withTokenRequestSpan({1401 withTokenRequestSpan({
1270 grantType: "refresh_token",1402 grantType: "refresh_token",
1271 tokenUrl: input.tokenUrl,1403 tokenUrl: input.tokenUrl,
@@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines
1375 );1507 );
1376 },1508 },
1377 catch: (cause) => cause,1509 catch: (cause) => cause,
1378 }).pipe(Effect.catch(failOAuth2WithHttpSummary));1510 }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)));
1379 1511
1380 if (!response.ok) {1512 if (!response.ok) {
1381 return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected");1513 return yield* failOAuth2FromErrorResponse(
1514 response,
1515 "ID-JAG token exchange was rejected",
1516 input.clientSecret,
1517 );
1382 }1518 }
1383 1519
1384 // Nothing else reads this body, so it is consumed directly. A read failure1520 // Nothing else reads this body, so it is consumed directly. A read failure
@@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines
1485 },1621 },
1486 catch: (cause) => cause,1622 catch: (cause) => cause,
1487 }).pipe(1623 }).pipe(
1488 Effect.catch(failOAuth2WithHttpSummary),1624 Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
1489 withTokenRequestSpan({1625 withTokenRequestSpan({
1490 grantType: JWT_BEARER_GRANT_TYPE,1626 grantType: JWT_BEARER_GRANT_TYPE,
1491 tokenUrl: input.tokenUrl,1627 tokenUrl: input.tokenUrl,
packages/core/sdk/src/oauth-service.ts+23−3
@@ -516,6 +516,8 @@ interface LoadedOAuthClient { · 515 unmodified lines
516 /** Resolved literal secret (read from the provider via the stored item id). */516 /** Resolved literal secret (read from the provider via the stored item id). */
517 readonly clientSecret: string;517 readonly clientSecret: string;
518 readonly resource: string | null;518 readonly resource: string | null;
519 readonly tokenEndpointAuthMethod?: "body" | "basic";
520 readonly tokenRequestFormat?: "form" | "json";
519}521}
520 522
521/** Where an OAuth app's client secret is stored in the default writable523/** Where an OAuth app's client secret is stored in the default writable
@@ -618,6 +620,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines
618 readonly clientId: string;620 readonly clientId: string;
619 readonly clientSecret: string;621 readonly clientSecret: string;
620 readonly resource: string | null;622 readonly resource: string | null;
623 readonly tokenEndpointAuthMethod?: "body" | "basic";
624 readonly tokenRequestFormat?: "form" | "json";
621} => ({625} => ({
622 slug: String(firstPartyOAuthClientSlug(config.name)),626 slug: String(firstPartyOAuthClientSlug(config.name)),
623 authorizationUrl: config.authorizationUrl,627 authorizationUrl: config.authorizationUrl,
@@ -626,6 +630,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines
626 clientId: config.clientId,630 clientId: config.clientId,
627 clientSecret: config.clientSecret,631 clientSecret: config.clientSecret,
628 resource: config.resource ?? null,632 resource: config.resource ?? null,
633 ...(config.tokenEndpointAuthMethod === undefined
634 ? {}
635 : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }),
636 ...(config.tokenRequestFormat === undefined
637 ? {}
638 : { tokenRequestFormat: config.tokenRequestFormat }),
629});639});
630 640
631export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {641export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1697,6 +1707,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1065 unmodified lines
1697 : scopePolicy.kind === "discover"1707 : scopePolicy.kind === "discover"
1698 ? requestedScopes1708 ? requestedScopes
1699 : yield* filterAuthorizationCodeScopes(client, requestedScopes);1709 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1710 const completeAuthorizationScopes = dedupeScopes([
1711 ...authorizationRequestedScopes,
1712 ...(firstParty?.additionalAuthorizationScopes ?? []),
1713 ]);
1700 1714
1701 // authorization_code: persist a session + build the authorize URL.1715 // authorization_code: persist a session + build the authorize URL.
1702 const verifier = createPkceCodeVerifier();1716 const verifier = createPkceCodeVerifier();
@@ -1758,7 +1772,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
1758 payload: {1772 payload: {
1759 owner: input.owner,1773 owner: input.owner,
1760 clientOwner: input.clientOwner,1774 clientOwner: input.clientOwner,
1761 requestedScopes: authorizationRequestedScopes,1775 requestedScopes: completeAuthorizationScopes,
1762 },1776 },
1763 expires_at: expiresAt,1777 expires_at: expiresAt,
1764 created_at: now,1778 created_at: now,
@@ -1771,14 +1785,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines
1771 authorizationUrl: client.authorizationUrl,1785 authorizationUrl: client.authorizationUrl,
1772 clientId: client.clientId,1786 clientId: client.clientId,
1773 redirectUrl: flowRedirectUri,1787 redirectUrl: flowRedirectUri,
1774 scopes: authorizationRequestedScopes,1788 scopes: completeAuthorizationScopes,
1775 state: providerState,1789 state: providerState,
1776 codeChallenge: challenge,1790 codeChallenge: challenge,
1791 scopeSeparator: firstParty?.authorizationScopeSeparator,
1777 resource: client.resource ?? undefined,1792 resource: client.resource ?? undefined,
1778 // Provider quirks (Google: access_type=offline + prompt=consent) —1793 // Provider quirks (Google: access_type=offline + prompt=consent) —
1779 // without these Google returns no refresh token and won't re-consent1794 // without these Google returns no refresh token and won't re-consent
1780 // to widen scopes on reconnect.1795 // to widen scopes on reconnect.
1781 extraParams: providerAuthorizeExtras(client.authorizationUrl),1796 extraParams: {
1797 ...providerAuthorizeExtras(client.authorizationUrl),
1798 ...(firstParty?.authorizationExtraParams ?? {}),
1799 },
1782 endpointUrlPolicy: deps.endpointUrlPolicy,1800 endpointUrlPolicy: deps.endpointUrlPolicy,
1783 }),1801 }),
1784 catch: (cause) =>1802 catch: (cause) =>
@@ -1897,6 +1915,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines
1897 redirectUrl: session.redirectUrl,1915 redirectUrl: session.redirectUrl,
1898 codeVerifier: session.pkceVerifier,1916 codeVerifier: session.pkceVerifier,
1899 code: input.code,1917 code: input.code,
1918 clientAuth: client.tokenEndpointAuthMethod,
1919 requestFormat: client.tokenRequestFormat,
1900 resource: client.resource ?? undefined,1920 resource: client.resource ?? undefined,
1901 endpointUrlPolicy: deps.endpointUrlPolicy,1921 endpointUrlPolicy: deps.endpointUrlPolicy,
1902 fetch,1922 fetch,
packages/core/sdk/src/shared.ts+1−0
@@ -134,6 +134,7 @@ export { · 133 unmodified lines
134 HealthCheckCandidate,134 HealthCheckCandidate,
135 HealthCheckCandidateParameter,135 HealthCheckCandidateParameter,
136 classifyHttpStatus,136 classifyHttpStatus,
137 classifyProbeResponse,
137 extractIdentity,138 extractIdentity,
138 compareHealthCheckCandidates,139 compareHealthCheckCandidates,
139 candidateIdentityTier,140 candidateIdentityTier,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+1−13
@@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines
41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";41import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";42import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";43import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44import { parseStdioArgs } from "./stdio-fields";
44import { isProbableMcpEndpoint } from "./probe-url";45import { isProbableMcpEndpoint } from "./probe-url";
45import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";46import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
46import { mcpPresets, type McpPreset } from "../sdk/presets";47import { mcpPresets, type McpPreset } from "../sdk/presets";
@@ -62,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 15 unmodified lines
62 return mcpPresets.find((p) => p.id === id);63 return mcpPresets.find((p) => p.id === id);
63}64}
64 65
65// Splits the raw args field into tokens, honoring double-quoted groups so an
66// argument with spaces stays intact.
67function parseStdioArgs(raw: string): string[] {
68 if (!raw.trim()) return [];
69 const args: string[] = [];
70 const regex = /[^\s"]+|"([^"]*)"/g;
71 let match;
72 while ((match = regex.exec(raw)) !== null) {
73 args.push(match[1] ?? match[0]);
74 }
75 return args;
76}
77
78// ---------------------------------------------------------------------------66// ---------------------------------------------------------------------------
79// State machine (remote flow)67// State machine (remote flow)
80// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
13 type AuthMethodRow,13 type AuthMethodRow,
14 type AuthMethodSeed,14 type AuthMethodSeed,
15} from "@executor-js/react/components/auth-method-list-editor";15} from "@executor-js/react/components/auth-method-list-editor";
16import { Badge } from "@executor-js/react/components/badge";16import {
17import { FormErrorAlert } from "@executor-js/react/lib/integration-add";17 CardStack,
18 CardStackContent,
19 CardStackEntryField,
20} from "@executor-js/react/components/card-stack";
21import { Input } from "@executor-js/react/components/input";
22import { Textarea } from "@executor-js/react/components/textarea";
23import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
18 24
19import { configureMcpAuth, mcpServerAtom } from "./atoms";25import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
20import type {26import type {
21 McpAuthMethod,27 McpAuthMethod,
22 McpCanonicalAuthMethodInput,28 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
27 mcpAuthMethodInputFromEditorValue,33 mcpAuthMethodInputFromEditorValue,
28 mcpWireAuthInput,34 mcpWireAuthInput,
29} from "./auth-method-config";35} from "./auth-method-config";
36import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
30 37
31type McpServer = {38type McpServer = {
32 readonly slug: IntegrationSlug;39 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174}181}
175 182
176// ---------------------------------------------------------------------------183// ---------------------------------------------------------------------------
177// Stdio read-only view184// Stdio edit — the command, its arguments, the working directory, and the
185// DECLARED static environment. Secret env vars are not edited here: a stdio
186// server declares them as a `stdio_env` auth method and their values live on
187// the connection, managed from the integration page's accounts hub. Changes
188// are staged and applied by the sheet's Save, like the remote editor above.
178// ---------------------------------------------------------------------------189// ---------------------------------------------------------------------------
179 190
180function StdioReadOnly(props: {191type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
181 server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };192
193/** Order-independent identity for a declared env map, so re-ordering the lines
194 * in the field is not reported as a change. */
195const envIdentity = (env: Readonly<Record<string, string>> | undefined): string =>
196 Object.entries(env ?? {})
197 .sort(([a], [b]) => a.localeCompare(b))
198 .map(([key, value]) => `${key}=${value}`)
199 .join("\n");
200
201function StdioEdit(props: {
202 server: McpServer & { config: McpStdioConfig };
203 onPendingChange?: EditSheetSectionProps["onPendingChange"];
182}) {204}) {
183 const { command, args } = props.server.config;205 const { server } = props;
206 const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
207
208 const [command, setCommand] = useState(server.config.command);
209 const [args, setArgs] = useState(() => formatStdioArgs(server.config.args));
210 const [cwd, setCwd] = useState(server.config.cwd ?? "");
211 const [env, setEnv] = useState(() => formatStdioEnv(server.config.env));
212 const [error, setError] = useState<string | null>(null);
213
214 // The edited config. `configureServer` replaces the whole blob, so anything
215 // this form does not surface (`versionNegotiation`, `authenticationTemplate`)
216 // is carried through untouched. Empty optional fields are omitted rather than
217 // written as `undefined`.
218 const edited = useMemo<McpStdioConfig>(() => {
219 const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config;
220 const nextArgs = parseStdioArgs(args);
221 const nextEnv = parseStdioEnv(env);
222 const nextCwd = cwd.trim();
223 return {
224 ...rest,
225 command: command.trim(),
226 ...(nextArgs.length > 0 ? { args: nextArgs } : {}),
227 ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
228 ...(nextCwd !== "" ? { cwd: nextCwd } : {}),
229 };
230 }, [args, command, cwd, env, server.config]);
231
232 const changed =
233 edited.command !== server.config.command ||
234 formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) ||
235 (edited.cwd ?? "") !== (server.config.cwd ?? "") ||
236 envIdentity(edited.env) !== envIdentity(server.config.env);
237
238 // Staged apply, run by the sheet's Save. Persisting the config re-runs
239 // discovery on every connection, so the tool catalog matches the new command.
240 const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
241 setError(null);
242 if (edited.command === "") {
243 setError("A command is required.");
244 return { ok: false };
245 }
246 const exit = await doConfigure({
247 params: { slug: server.slug },
248 payload: { config: edited },
249 reactivityKeys: integrationWriteKeys,
250 });
251 if (Exit.isFailure(exit)) {
252 setError(errorMessageFromExit(exit, "Failed to update the server command"));
253 return { ok: false };
254 }
255 return { ok: true, summary: "Server command updated." };
256 }, [doConfigure, edited, server.slug]);
257
258 const onPendingChangeRef = useRef(props.onPendingChange);
259 onPendingChangeRef.current = props.onPendingChange;
260 useEffect(() => {
261 onPendingChangeRef.current?.(changed ? applyStaged : null);
262 return () => onPendingChangeRef.current?.(null);
263 }, [changed, applyStaged]);
264
184 return (265 return (
185 <div className="space-y-3 border-t border-border/60 pt-5">266 <div className="space-y-4 border-t border-border/60 pt-5">
186 <div className="space-y-1">267 <div className="space-y-1">
187 <p className="text-sm font-medium text-foreground">Server command</p>268 <p className="text-sm font-medium text-foreground">Server command</p>
188 <p className="text-xs text-muted-foreground">269 <p className="text-xs text-muted-foreground">
189 Stdio MCP integrations cannot be edited. Remove and recreate the integration with the270 Changes apply when you save. The server's tools are then rediscovered with the new
190 updated command.271 command.
191 </p>272 </p>
192 </div>273 </div>
193 <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">274
194 <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">275 <CardStack>
195 {command} {(args ?? []).join(" ")}276 <CardStackContent className="border-t-0">
196 </p>277 <CardStackEntryField
197 <Badge variant="secondary" className="text-xs">278 label="Command"
198 stdio279 description="- The executable to run (e.g. npx, uvx, node)."
199 </Badge>280 >
200 </div>281 <Input
282 value={command}
283 aria-label="Command"
284 onChange={(e) => setCommand((e.target as HTMLInputElement).value)}
285 placeholder="npx"
286 className="font-mono text-sm"
287 />
288 </CardStackEntryField>
289
290 <CardStackEntryField
291 label="Arguments"
292 description="- Space-separated arguments passed to the command."
293 >
294 <Input
295 value={args}
296 aria-label="Arguments"
297 onChange={(e) => setArgs((e.target as HTMLInputElement).value)}
298 placeholder="-y chrome-devtools-mcp@latest"
299 className="font-mono text-sm"
300 />
301 </CardStackEntryField>
302
303 <CardStackEntryField
304 label="Working directory"
305 description="- Optional. Where the command runs."
306 >
307 <Input
308 value={cwd}
309 aria-label="Working directory"
310 onChange={(e) => setCwd((e.target as HTMLInputElement).value)}
311 placeholder="/path/to/server"
312 className="font-mono text-sm"
313 />
314 </CardStackEntryField>
315
316 <CardStackEntryField
317 label="Environment variables"
318 description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment."
319 >
320 <Textarea
321 value={env}
322 aria-label="Environment variables"
323 onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)}
324 placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"}
325 className="font-mono text-sm"
326 />
327 </CardStackEntryField>
328 </CardStackContent>
329 </CardStack>
330
331 <p className="text-xs text-muted-foreground">
332 Secret values do not belong here. A server that needs an API key declares its variable name
333 as an authentication method, and the value is entered per connection from the accounts
334 panel.
335 </p>
336
337 {error && <FormErrorAlert message={error} />}
201 </div>338 </div>
202 );339 );
203}340}
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219 356
220 if (server.config.transport === "stdio") {357 if (server.config.transport === "stdio") {
221 return (358 return (
222 <StdioReadOnly359 <StdioEdit
223 server={360 server={server as McpServer & { config: McpStdioConfig }}
224 server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }361 {...(onPendingChange ? { onPendingChange } : {})}
225 }
226 />362 />
227 );363 );
228 }364 }
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4
5describe("parseStdioArgs", () => {
6 it("splits on whitespace", () => {
7 expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([
8 "-y",
9 "chrome-devtools-mcp@latest",
10 ]);
11 });
12
13 it("keeps a double-quoted group intact", () => {
14 expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([
15 "--dir",
16 "/Users/a b/mcp",
17 "--port",
18 "3000",
19 ]);
20 });
21
22 it("returns nothing for a blank field", () => {
23 expect(parseStdioArgs(" ")).toEqual([]);
24 });
25});
26
27describe("formatStdioArgs", () => {
28 it("re-quotes tokens containing whitespace so the round trip is lossless", () => {
29 const args = ["--dir", "/Users/a b/mcp", "--port", "3000"];
30 expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args);
31 });
32
33 it("renders an absent list as an empty field", () => {
34 expect(formatStdioArgs(undefined)).toBe("");
35 });
36});
37
38describe("parseStdioEnv", () => {
39 it("reads one KEY=value per line", () => {
40 expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({
41 LOG_LEVEL: "debug",
42 REGION: "eu-west-1",
43 });
44 });
45
46 it("splits on the first = only, so a value may contain =", () => {
47 expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({
48 DSN: "postgres://u:p@host/db?a=b",
49 });
50 });
51
52 it("keeps a declared-but-empty value", () => {
53 expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" });
54 });
55
56 it("drops blank lines, comments, and lines with no key", () => {
57 expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" });
58 });
59});
60
61describe("formatStdioEnv", () => {
62 it("round-trips a declared map", () => {
63 const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" };
64 expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env);
65 });
66
67 it("renders an absent map as an empty field", () => {
68 expect(formatStdioEnv(undefined)).toBe("");
69 });
70});
packages/plugins/mcp/src/react/stdio-fields.ts+51−0
@@ -0,0 +1,51 @@
1// ---------------------------------------------------------------------------
2// Text ↔ config codecs for the stdio fields, shared by the add flow
3// (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render
4// the same single-line arguments field and the same KEY=value environment
5// block, so the parsing lives in one place and round-trips: a config that is
6// formatted and re-parsed is unchanged.
7// ---------------------------------------------------------------------------
8
9/** Split the raw arguments field into tokens, honoring double-quoted groups so
10 * an argument with spaces stays intact. */
11export function parseStdioArgs(raw: string): string[] {
12 if (!raw.trim()) return [];
13 const args: string[] = [];
14 const regex = /[^\s"]+|"([^"]*)"/g;
15 let match;
16 while ((match = regex.exec(raw)) !== null) {
17 args.push(match[1] ?? match[0]);
18 }
19 return args;
20}
21
22/** Render stored arguments back into the single-line field, re-quoting any
23 * token that contains whitespace so `parseStdioArgs` recovers it. */
24export function formatStdioArgs(args: readonly string[] | undefined): string {
25 return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
26}
27
28/** Parse the KEY=value block into the declared static env map. Splits on the
29 * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#`
30 * comments, and lines without a key are dropped; an empty value is kept
31 * (declaring a variable as empty is meaningful to a subprocess). */
32export function parseStdioEnv(raw: string): Record<string, string> {
33 const env: Record<string, string> = {};
34 for (const line of raw.split("\n")) {
35 const trimmed = line.trim();
36 if (trimmed === "" || trimmed.startsWith("#")) continue;
37 const separator = trimmed.indexOf("=");
38 if (separator <= 0) continue;
39 const key = trimmed.slice(0, separator).trim();
40 if (key === "") continue;
41 env[key] = trimmed.slice(separator + 1).trim();
42 }
43 return env;
44}
45
46/** Render the stored env map back into the KEY=value block. */
47export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48 return Object.entries(env ?? {})
49 .map(([key, value]) => `${key}=${value}`)
50 .join("\n");
51}
packages/plugins/openapi/src/sdk/backing.ts+4−2
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
7 ToolName,7 ToolName,
8 ToolResult,8 ToolResult,
9 authToolFailure,9 authToolFailure,
10 classifyHttpStatus,10 classifyProbeResponse,
11 detectInsufficientScope,11 detectInsufficientScope,
12 sortHealthCheckCandidatesByIdentity,12 sortHealthCheckCandidatesByIdentity,
13 extractIdentity,13 extractIdentity,
@@ -973,7 +973,9 @@ export const checkHealthOpenApi = (input: { · 959 unmodified lines
973 } satisfies HealthCheckResult;973 } satisfies HealthCheckResult;
974 }974 }
975 975
976 const status = classifyHttpStatus(probe.result.status);976 // Body-aware: a configuration 403 (Google accessNotConfigured /
977 // SERVICE_DISABLED) reads misconfigured, not expired.
978 const status = classifyProbeResponse(probe.result.status, probe.result.error);
977 const rawIdentity =979 const rawIdentity =
978 status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;980 status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;
979 // The identity is read straight off the raw body, so unlike the sample it981 // The identity is read straight off the raw body, so unlike the sample it
packages/plugins/openapi/src/sdk/presets.ts+2−2
@@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines
40 "file_dev_resources:write",40 "file_dev_resources:write",
41 "file_metadata:read",41 "file_metadata:read",
42 "file_versions:read",42 "file_versions:read",
43 "folder_metadata:read",
44 "folders:read",
43 "library_assets:read",45 "library_assets:read",
44 "library_content:read",46 "library_content:read",
45 "project_metadata:read",
46 "projects:read",
47 "team_library_content:read",47 "team_library_content:read",
48 "webhooks:read",48 "webhooks:read",
49 "webhooks:write",49 "webhooks:write",
packages/react/src/components/accounts-section.tsx+57−0
@@ -70,6 +70,35 @@ import { · 69 unmodified lines
70 70
71const OWNERS: readonly Owner[] = ["org", "user"];71const OWNERS: readonly Owner[] = ["org", "user"];
72 72
73/** Render a health-check detail with any bare https URL as a clickable link.
74 * Exists for the misconfigured verdict, whose detail is the provider's own
75 * remediation text (Google's includes the console URL that enables the
76 * disabled API); the rest of the string stays plain text. */
77function DetailWithLinks(props: { readonly text: string }) {
78 const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g);
79 return (
80 <>
81 {parts.map((part, index) =>
82 part.startsWith("https://") ? (
83 <a
84 // Stable for a given detail string: parts are positional.
85 // oxlint-disable-next-line react/no-array-index-key
86 key={index}
87 href={part}
88 target="_blank"
89 rel="noreferrer"
90 className="underline underline-offset-2 hover:text-foreground"
91 >
92 {part}
93 </a>
94 ) : (
95 part
96 ),
97 )}
98 </>
99 );
100}
101
73// The confirm dialog names the connection the way the row does — stored102// The confirm dialog names the connection the way the row does — stored
74// identity label first, connection name otherwise. (No live probe identity103// identity label first, connection name otherwise. (No live probe identity
75// here; that state lives inside the row.)104// here; that state lives inside the row.)
@@ -115,6 +144,10 @@ function AccountRow(props: { · 39 unmodified lines
115 const displayLabel = identity ?? String(connection.name);144 const displayLabel = identity ?? String(connection.name);
116 145
117 const expired = status === "expired";146 const expired = status === "expired";
147 // `misconfigured` is deliberately NOT folded into `needsHealthAttention`: it
148 // gets its own amber "API disabled" badge and its own link-rendered detail
149 // below, because the remediation is a console visit, not a reconnect.
150 const misconfigured = status === "misconfigured";
118 const needsHealthAttention = status === "expired" || status === "degraded";151 const needsHealthAttention = status === "expired" || status === "degraded";
119 const healthDetail = needsHealthAttention ? probe?.detail : undefined;152 const healthDetail = needsHealthAttention ? probe?.detail : undefined;
120 const missingOAuthScopes = connection.missingOAuthScopes ?? [];153 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
@@ -135,6 +168,13 @@ function AccountRow(props: { · 14 unmodified lines
135 );168 );
136 } else if (exit.value.status === "expired") {169 } else if (exit.value.status === "expired") {
137 toast.error("Connection expired, reconnect to restore access");170 toast.error("Connection expired, reconnect to restore access");
171 } else if (exit.value.status === "misconfigured") {
172 // NOT a reconnect prompt: the credential is fine; the upstream API is
173 // disabled where the OAuth client lives. The detail carries the
174 // provider's own instruction (with a console link for Google).
175 toast.warning(
176 exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client",
177 );
138 } else if (exit.value.status === "degraded") {178 } else if (exit.value.status === "degraded") {
139 toast.warning(exit.value.detail ?? "Connection check returned an error");179 toast.warning(exit.value.detail ?? "Connection check returned an error");
140 } else {180 } else {
@@ -157,6 +197,14 @@ function AccountRow(props: { · 16 unmodified lines
157 {HEALTH_STATUS_LABEL[status]}197 {HEALTH_STATUS_LABEL[status]}
158 </Badge>198 </Badge>
159 ) : null}199 ) : null}
200 {misconfigured ? (
201 <Badge
202 variant="outline"
203 className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"
204 >
205 API disabled
206 </Badge>
207 ) : null}
160 {needsReconsent ? (208 {needsReconsent ? (
161 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">209 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
162 Reconnect to grant access210 Reconnect to grant access
@@ -168,6 +216,15 @@ function AccountRow(props: { · 5 unmodified lines
168 {connection.description}216 {connection.description}
169 </CardStackEntryDescription>217 </CardStackEntryDescription>
170 ) : null}218 ) : null}
219 {misconfigured && probe?.detail ? (
220 // Not CardStackEntryDescription: that truncates to one line, and this
221 // text IS the remediation (the enable-API console link must stay
222 // visible in full). Wrap instead; break anywhere so the long URL
223 // cannot overflow the row.
224 <p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]">
225 <DetailWithLinks text={probe.detail} />
226 </p>
227 ) : null}
171 {healthDetail ? (228 {healthDetail ? (
172 <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground">229 <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground">
173 {healthDetail}230 {healthDetail}
packages/react/src/components/add-account-modal.tsx+7−1
@@ -1048,7 +1048,13 @@ function HealthStatusLine(props: { · 1047 unmodified lines
1048}) {1048}) {
1049 const { status, identity, detail, httpStatus } = props.result;1049 const { status, identity, detail, httpStatus } = props.result;
1050 const healthy = status === "healthy";1050 const healthy = status === "healthy";
1051 const tone = healthy ? "text-muted-foreground" : "text-destructive";1051 // Misconfigured is amber everywhere (see health-display.ts): the credential
1052 // being validated is fine, so the verdict must not read as a key failure.
1053 const tone = healthy
1054 ? "text-muted-foreground"
1055 : status === "misconfigured"
1056 ? "text-amber-600 dark:text-amber-500"
1057 : "text-destructive";
1052 const font = props.variant === "response" ? "font-mono" : "";1058 const font = props.variant === "response" ? "font-mono" : "";
1053 return (1059 return (
1054 <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>1060 <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>
packages/react/src/lib/health-display.ts+20−7
@@ -6,19 +6,26 @@ · 5 unmodified lines
6 6
7import type { HealthStatus } from "@executor-js/sdk/shared";7import type { HealthStatus } from "@executor-js/sdk/shared";
8 8
9/** State form: badge text, indicator tooltips, "what is the current state". */9/** State form: badge text, indicator tooltips, "what is the current state".
10 * `misconfigured` reads "API disabled": the one concrete cause it detects
11 * today (an upstream API not enabled in the OAuth client's project), named
12 * plainly so the user looks at the provider console, not the credential. */
10export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {13export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {
11 healthy: "Healthy",14 healthy: "Healthy",
12 expired: "Expired",15 expired: "Expired",
16 misconfigured: "API disabled",
13 degraded: "Degraded",17 degraded: "Degraded",
14 unknown: "Unchecked",18 unknown: "Unchecked",
15};19};
16 20
17/** Text tone per status, for verdict lines and previews. Same per-status color21/** Text tone per status, for verdict lines and previews. Same per-status color
18 * decision as the indicator dots below; change them together. */22 * decision as the indicator dots below; change them together. `misconfigured`
23 * shares degraded's amber: it needs attention, but the credential is fine, so
24 * it must not carry expired's destructive red. */
19export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = {25export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = {
20 healthy: "text-emerald-600 dark:text-emerald-400",26 healthy: "text-emerald-600 dark:text-emerald-400",
21 expired: "text-destructive",27 expired: "text-destructive",
28 misconfigured: "text-amber-600 dark:text-amber-500",
22 degraded: "text-amber-600 dark:text-amber-500",29 degraded: "text-amber-600 dark:text-amber-500",
23 unknown: "text-muted-foreground",30 unknown: "text-muted-foreground",
24};31};
@@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< · 7 unmodified lines
32> = {39> = {
33 healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" },40 healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" },
34 expired: { dot: "bg-destructive", ring: "ring-destructive/70" },41 expired: { dot: "bg-destructive", ring: "ring-destructive/70" },
42 misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" },
35 degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" },43 degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" },
36 unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" },44 unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" },
37};45};
38 46
39/** Severity for worst-of aggregation. `unknown` is excluded on purpose: a47/** Severity for worst-of aggregation. `unknown` is excluded on purpose: a
40 * never-probed connection carries no signal, so it must not drag a group48 * never-probed connection carries no signal, so it must not drag a group
41 * verdict in either direction. */49 * verdict in either direction. `misconfigured` outranks `degraded` (a named
50 * configuration break beats a generic error) but not `expired` (a dead
51 * credential blocks everything). */
42const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = {52const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = {
43 healthy: 1,53 healthy: 1,
44 degraded: 2,54 degraded: 2,
45 expired: 3,55 misconfigured: 3,
56 expired: 4,
46};57};
47 58
48/** Collapse many connection statuses to the group's worst: expired > degraded59/** Collapse many connection statuses to the group's worst: expired >
49 * > healthy. `unknown` entries are ignored; when nothing else remains (empty60 * misconfigured > degraded > healthy. `unknown` entries are ignored; when
50 * input or all unknown) there is no verdict and the caller renders nothing. */61 * nothing else remains (empty input or all unknown) there is no verdict and
62 * the caller renders nothing. */
51export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => {63export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => {
52 let worst: Exclude<HealthStatus, "unknown"> | null = null;64 let worst: Exclude<HealthStatus, "unknown"> | null = null;
53 for (const status of statuses) {65 for (const status of statuses) {
@@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< · 10 unmodified lines
64> = {76> = {
65 healthy: "secondary",77 healthy: "secondary",
66 expired: "destructive",78 expired: "destructive",
79 misconfigured: "outline",
67 degraded: "outline",80 degraded: "outline",
68 unknown: "outline",81 unknown: "outline",
69};82};