@stigmer/server 3.18.0 → 3.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/compose.d.ts.map +1 -1
  3. package/dist/boot/compose.js +42 -6
  4. package/dist/boot/compose.js.map +1 -1
  5. package/dist/boot/config.d.ts +6 -2
  6. package/dist/boot/config.d.ts.map +1 -1
  7. package/dist/boot/config.js.map +1 -1
  8. package/dist/boot/oauth-redirect-uri.d.ts +54 -0
  9. package/dist/boot/oauth-redirect-uri.d.ts.map +1 -0
  10. package/dist/boot/oauth-redirect-uri.js +37 -0
  11. package/dist/boot/oauth-redirect-uri.js.map +1 -0
  12. package/dist/domain/agentexecution/lifecycle.js +1 -1
  13. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  14. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts +16 -0
  15. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
  16. package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
  17. package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
  18. package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
  19. package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
  20. package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
  21. package/dist/domain/mcpserver/connect.d.ts +25 -0
  22. package/dist/domain/mcpserver/connect.d.ts.map +1 -1
  23. package/dist/domain/mcpserver/connect.js +8 -23
  24. package/dist/domain/mcpserver/connect.js.map +1 -1
  25. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  26. package/dist/domain/mcpserver/controller.js +12 -0
  27. package/dist/domain/mcpserver/controller.js.map +1 -1
  28. package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
  29. package/dist/domain/mcpserver/initiate-oauth-connect.js +38 -14
  30. package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
  31. package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
  32. package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
  33. package/dist/domain/mcpserver/oauth/dcr.js +1 -9
  34. package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
  35. package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
  36. package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
  37. package/dist/domain/mcpserver/oauth/discovery.js +69 -54
  38. package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
  39. package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
  40. package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
  41. package/dist/domain/mcpserver/oauth/preflight.js +1 -26
  42. package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
  43. package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
  44. package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
  45. package/dist/domain/mcpserver/oauth/refresh.js +1 -1
  46. package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
  47. package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
  48. package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
  49. package/dist/domain/mcpserver/oauth/token.js +2 -8
  50. package/dist/domain/mcpserver/oauth/token.js.map +1 -1
  51. package/dist/domain/workflowexecution/lifecycle.js +1 -1
  52. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  53. package/dist/extensions/drivers.d.ts +17 -0
  54. package/dist/extensions/drivers.d.ts.map +1 -1
  55. package/dist/extensions/outbound-egress.d.ts +49 -0
  56. package/dist/extensions/outbound-egress.d.ts.map +1 -0
  57. package/dist/extensions/outbound-egress.js +51 -0
  58. package/dist/extensions/outbound-egress.js.map +1 -0
  59. package/dist/extensions/registry.d.ts +6 -0
  60. package/dist/extensions/registry.d.ts.map +1 -1
  61. package/dist/extensions/registry.js +10 -0
  62. package/dist/extensions/registry.js.map +1 -1
  63. package/dist/index.d.ts +2 -0
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -0
  66. package/dist/index.js.map +1 -1
  67. package/dist/pipeline/apiresource-labels.d.ts +14 -0
  68. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  69. package/dist/pipeline/apiresource-labels.js +14 -0
  70. package/dist/pipeline/apiresource-labels.js.map +1 -1
  71. package/dist/sandbox/kubernetes.d.ts.map +1 -1
  72. package/dist/sandbox/kubernetes.js +8 -1
  73. package/dist/sandbox/kubernetes.js.map +1 -1
  74. package/dist/sandbox/provisioner.d.ts +20 -5
  75. package/dist/sandbox/provisioner.d.ts.map +1 -1
  76. package/dist/sandbox/provisioner.js.map +1 -1
  77. package/dist/sandbox/steps.d.ts +22 -8
  78. package/dist/sandbox/steps.d.ts.map +1 -1
  79. package/dist/sandbox/steps.js +17 -12
  80. package/dist/sandbox/steps.js.map +1 -1
  81. package/package.json +6 -5
  82. package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
  83. package/src/boot/compose.ts +47 -8
  84. package/src/boot/config.ts +6 -2
  85. package/src/boot/oauth-redirect-uri.ts +53 -0
  86. package/src/domain/agentexecution/lifecycle.ts +1 -1
  87. package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
  88. package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
  89. package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
  90. package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +1 -1
  91. package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
  92. package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
  93. package/src/domain/mcpserver/connect.ts +21 -15
  94. package/src/domain/mcpserver/controller.ts +16 -0
  95. package/src/domain/mcpserver/initiate-oauth-connect.ts +37 -13
  96. package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
  97. package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
  98. package/src/domain/mcpserver/oauth/dcr.ts +2 -1
  99. package/src/domain/mcpserver/oauth/discovery.ts +120 -89
  100. package/src/domain/mcpserver/oauth/preflight.ts +2 -1
  101. package/src/domain/mcpserver/oauth/refresh.ts +2 -1
  102. package/src/domain/mcpserver/oauth/token.ts +4 -3
  103. package/src/domain/workflowexecution/lifecycle.ts +1 -1
  104. package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
  105. package/src/extensions/drivers.ts +17 -0
  106. package/src/extensions/outbound-egress.ts +55 -0
  107. package/src/extensions/registry.ts +19 -0
  108. package/src/index.ts +10 -0
  109. package/src/pipeline/apiresource-labels.ts +16 -0
  110. package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
  111. package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
  112. package/src/sandbox/__tests__/local-process.integration.test.ts +3 -0
  113. package/src/sandbox/__tests__/steps.test.ts +72 -19
  114. package/src/sandbox/kubernetes.ts +8 -1
  115. package/src/sandbox/provisioner.ts +20 -5
  116. package/src/sandbox/steps.ts +29 -12
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Where an MCP server's OAuth Sign in comes back to, on a server that was
3
+ * not told.
4
+ *
5
+ * `STIGMER_OAUTH_REDIRECT_URI` is the callback URL the McpServer OAuth
6
+ * Connect flows hand the vendor's login server; the cloud sets it to its
7
+ * console's callback page. A local install (`stigmer up`, the desktop app,
8
+ * the all-in-one image) never set it, and `initiateOAuthConnect` refuses
9
+ * without it, so every Sign in surface the console offers was dead on a
10
+ * local install. The posture that already decides how a served console
11
+ * signs its users in (`trusted-local` when no issuer is configured) answers
12
+ * this too: a server that serves the console on its unified port is
13
+ * reached by a browser on the machine it runs on, so its callback is the
14
+ * console's callback page at `http://localhost:<port>`, the same host the
15
+ * config already assumes for `SKILL_TRANSFER_BASE_URL`.
16
+ *
17
+ * The rules, in order: a configured value always wins; a server that
18
+ * serves no console derives nothing (the CLI and the SDKs need no
19
+ * callback, and today's warn and refusal stand); a server whose port is
20
+ * not yet known (an ephemeral `0`, the test harness's shape) derives
21
+ * nothing either. The console's callback route is the web app's
22
+ * `app/auth/oauth/callback/page.tsx`; its path is a constant here because
23
+ * this is the one place the server names it.
24
+ *
25
+ * Proven by __tests__/oauth-redirect-uri.test.ts.
26
+ */
27
+
28
+ /** The console's OAuth callback page, relative to the served console's origin. */
29
+ export const CONSOLE_OAUTH_CALLBACK_PATH = "/auth/oauth/callback";
30
+
31
+ export type OAuthRedirectUriResolution =
32
+ /** The operator's value, as configured. */
33
+ | { readonly kind: "configured"; readonly uri: string }
34
+ /** Derived from the served console's own origin. */
35
+ | { readonly kind: "derived"; readonly uri: string }
36
+ /** Nothing to hand the login server; initiate refuses with the pinned copy. */
37
+ | { readonly kind: "absent" };
38
+
39
+ export interface OAuthRedirectUriInputs {
40
+ /** `STIGMER_OAUTH_REDIRECT_URI` as loaded; empty when unset. */
41
+ readonly configured: string;
42
+ /** Whether this server serves the web console on its unified port. */
43
+ readonly servesConsole: boolean;
44
+ /** The port the unified listener binds; 0 means "not known until listen". */
45
+ readonly port: number;
46
+ }
47
+
48
+ /** The callback URL the McpServer OAuth flows use, and where it came from. */
49
+ export function resolveOAuthRedirectUri(inputs: OAuthRedirectUriInputs): OAuthRedirectUriResolution {
50
+ if (inputs.configured !== "") return { kind: "configured", uri: inputs.configured };
51
+ if (!inputs.servesConsole || inputs.port === 0) return { kind: "absent" };
52
+ return { kind: "derived", uri: `http://localhost:${inputs.port}${CONSOLE_OAUTH_CALLBACK_PATH}` };
53
+ }
@@ -720,7 +720,7 @@ export async function recoverExecution(
720
720
  temporalConfig: deps.temporalConfig,
721
721
  },
722
722
  loadedExecution(ctx),
723
- ctx.callerIdentity.identityId,
723
+ ctx.callerIdentity,
724
724
  );
725
725
  },
726
726
  },
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Pins CompleteEndpointAuth over a fake fetch: the author's shape and
3
+ * nothing else is probed; an OAuth challenge completes exactly the trio
4
+ * plus the provenance label and the completed spec passes the proto
5
+ * validator; every other outcome and a throwing fetch leave the spec as
6
+ * sent; carry-over reuses an earlier completion with no network; the
7
+ * console's exact echo keeps the label; the author's own auth drops it; a
8
+ * changed URL probes again; the Bearer default serves a hand-written auth;
9
+ * the label is recorded as stamped so GuardReservedLabels admits it.
10
+ */
11
+ import { create } from "@bufbuild/protobuf";
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
15
+ import { EnvVarDeclarationSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
16
+ import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
17
+ import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
18
+ import { McpServerAuthSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
19
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
20
+
21
+ import { createLogger } from "../../../boot/logger.js";
22
+ import { MCP_AUTH_ENDPOINT, MCP_AUTH_LABEL } from "../../../pipeline/apiresource-labels.js";
23
+ import { RequestContext } from "../../../pipeline/request-context.js";
24
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
25
+ import { EXISTING_RESOURCE_KEY } from "../../../pipeline/steps/load-existing.js";
26
+ import { serverStampedReservedLabels } from "../../../pipeline/steps/server-stamped-reserved-labels.js";
27
+ import { validator } from "../../../pipeline/steps/validation.js";
28
+ import { accessTokenVariable, newCompleteEndpointAuthStep } from "../complete-endpoint-auth.js";
29
+
30
+ const silentLogger = createLogger({ level: "error", pretty: false, write: () => {} });
31
+
32
+ const URL_ONLY = "https://mcp.vendor.test/mcp";
33
+ const CHALLENGE = 'Bearer realm="OAuth", resource_metadata="https://mcp.vendor.test/.well-known/oauth-protected-resource/mcp"';
34
+ const VARIABLE = "LINEAR_ACCESS_TOKEN";
35
+
36
+ interface FakeFetch {
37
+ readonly fetchImpl: OutboundFetch;
38
+ readonly calls: { url: string; init: RequestInit | undefined }[];
39
+ }
40
+
41
+ function answering(status: number, headers: Record<string, string> = {}): FakeFetch {
42
+ const calls: FakeFetch["calls"] = [];
43
+ return {
44
+ calls,
45
+ fetchImpl: async (url, init) => {
46
+ calls.push({ url: String(url), init });
47
+ return new Response(null, { status, headers });
48
+ },
49
+ };
50
+ }
51
+
52
+ function throwing(): FakeFetch {
53
+ const calls: FakeFetch["calls"] = [];
54
+ return {
55
+ calls,
56
+ fetchImpl: async (url, init) => {
57
+ calls.push({ url: String(url), init });
58
+ throw new Error("connect ECONNREFUSED");
59
+ },
60
+ };
61
+ }
62
+
63
+ function server(overrides: { url?: string; headers?: Record<string, string>; env?: Record<string, { isSecret: boolean; optional?: boolean; description?: string }>; auth?: { targetEnvVar?: string; oauthOnly?: boolean; discoveryUrl?: string }; labels?: Record<string, string>; stdio?: boolean } = {}): McpServer {
64
+ return create(McpServerSchema, {
65
+ apiVersion: "agentic.stigmer.ai/v1",
66
+ kind: "McpServer",
67
+ metadata: { id: "mcps_1", name: "Linear", slug: "linear", org: "acme", labels: overrides.labels ?? {} },
68
+ spec: {
69
+ description: "Linear's hosted MCP server",
70
+ serverType: overrides.stdio === true ? { case: "stdio", value: { command: "npx" } } : { case: "http", value: { url: overrides.url ?? URL_ONLY, headers: overrides.headers ?? {} } },
71
+ env: Object.fromEntries(Object.entries(overrides.env ?? {}).map(([k, v]) => [k, create(EnvVarDeclarationSchema, { isSecret: v.isSecret, optional: v.optional ?? false, description: v.description ?? "" })])),
72
+ ...(overrides.auth !== undefined ? { auth: create(McpServerAuthSchema, overrides.auth) } : {}),
73
+ },
74
+ });
75
+ }
76
+
77
+ /** A row as an earlier save completed it: the trio and the label. */
78
+ function completedRow(url = URL_ONLY): McpServer {
79
+ return server({
80
+ url,
81
+ headers: { Authorization: `Bearer \${${VARIABLE}}` },
82
+ env: { [VARIABLE]: { isSecret: true, description: "OAuth access token for the 'linear' MCP server; Sign in fills it" } },
83
+ auth: { targetEnvVar: VARIABLE, oauthOnly: true },
84
+ labels: { [MCP_AUTH_LABEL]: MCP_AUTH_ENDPOINT },
85
+ });
86
+ }
87
+
88
+ async function run(incoming: McpServer, fake: FakeFetch, existing?: McpServer): Promise<RequestContext<typeof McpServerSchema>> {
89
+ const ctx = new RequestContext(McpServerSchema, incoming, testCallerIdentity(), ApiResourceKind.mcp_server);
90
+ if (existing !== undefined) ctx.set(EXISTING_RESOURCE_KEY, existing);
91
+ await newCompleteEndpointAuthStep({ outboundFetch: fake.fetchImpl, logger: silentLogger }).execute(ctx);
92
+ return ctx;
93
+ }
94
+
95
+ function expectCompleted(state: McpServer): void {
96
+ expect(state.spec?.auth?.targetEnvVar).toBe(VARIABLE);
97
+ expect(state.spec?.auth?.oauthOnly).toBe(true);
98
+ expect(state.spec?.auth?.discoveryUrl).toBe("");
99
+ expect(state.spec?.auth?.scopeHints).toEqual([]);
100
+ expect(state.spec?.env[VARIABLE]).toMatchObject({ isSecret: true, optional: false });
101
+ expect(state.spec?.env[VARIABLE]?.description).toContain("Sign in fills it");
102
+ expect(state.spec?.serverType.case === "http" ? state.spec.serverType.value.headers : {}).toEqual({ Authorization: `Bearer \${${VARIABLE}}` });
103
+ expect(state.metadata?.labels[MCP_AUTH_LABEL]).toBe(MCP_AUTH_ENDPOINT);
104
+ }
105
+
106
+ function expectUntouched(state: McpServer, incoming: McpServer): void {
107
+ expect(state.spec).toEqual(incoming.spec);
108
+ expect(state.metadata?.labels[MCP_AUTH_LABEL]).toBeUndefined();
109
+ }
110
+
111
+ describe("CompleteEndpointAuth: the author's shape is probed with one complete initialize", () => {
112
+ it("completes exactly the trio plus the label on an OAuth challenge, and the completed spec validates", async () => {
113
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
114
+ const ctx = await run(server(), fake);
115
+ expectCompleted(ctx.newState);
116
+ expect(fake.calls).toHaveLength(1);
117
+ expect(fake.calls[0]?.url).toBe(URL_ONLY);
118
+ const body = JSON.parse(String(fake.calls[0]?.init?.body)) as { method: string; params: { protocolVersion: string; clientInfo: { name: string } } };
119
+ expect(body.method).toBe("initialize");
120
+ expect(body.params.protocolVersion).toBe("2025-06-18");
121
+ expect(body.params.clientInfo.name).toBe("stigmer-server");
122
+ expect(serverStampedReservedLabels(ctx).has(MCP_AUTH_LABEL)).toBe(true);
123
+ expect(validator().validate(McpServerSchema, ctx.newState).kind).toBe("valid");
124
+ });
125
+
126
+ it("sends the author's literal headers with the probe", async () => {
127
+ const fake = answering(200);
128
+ await run(server({ headers: { "X-Vendor-Region": "eu" } }), fake);
129
+ expect((fake.calls[0]?.init?.headers as Record<string, string>)["X-Vendor-Region"]).toBe("eu");
130
+ });
131
+
132
+ it.each([
133
+ ["a 2xx (an open endpoint)", answering(200)],
134
+ ["a non-OAuth 401 (a static-token API)", answering(401, { "www-authenticate": 'Bearer error="invalid_token"' })],
135
+ ["a 401 without a challenge header", answering(401)],
136
+ ["any other status", answering(405)],
137
+ ["no answer at all", throwing()],
138
+ ])("leaves the spec as sent on %s", async (_label, fake) => {
139
+ const incoming = server();
140
+ const ctx = await run(incoming, fake);
141
+ expectUntouched(ctx.newState, incoming);
142
+ expect(serverStampedReservedLabels(ctx).has(MCP_AUTH_LABEL)).toBe(false);
143
+ });
144
+
145
+ it("never fails the save when the fetch it was handed throws synchronously", async () => {
146
+ const fetchImpl = (() => {
147
+ throw new Error("defective fetch");
148
+ }) as unknown as OutboundFetch;
149
+ const incoming = server();
150
+ const ctx = await run(incoming, { fetchImpl, calls: [] });
151
+ expectUntouched(ctx.newState, incoming);
152
+ });
153
+ });
154
+
155
+ describe("CompleteEndpointAuth: anything the author says about auth is left alone, unprobed", () => {
156
+ it.each([
157
+ ["a declared env variable", server({ headers: { Authorization: "Bearer ${TOKEN}" }, env: { TOKEN: { isSecret: true } } })],
158
+ ["a placeholder header without an env declaration", server({ headers: { "X-Api-Key": "${KEY}" } })],
159
+ ["a literal Authorization header, in any case", server({ headers: { authorization: "Bearer pasted-token" } })],
160
+ ["an auth block of the author's own", server({ auth: { targetEnvVar: "MY_TOKEN" }, env: { MY_TOKEN: { isSecret: true } } })],
161
+ ["a stdio server", server({ stdio: true })],
162
+ ])("does not probe %s", async (_label, incoming) => {
163
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
164
+ const ctx = await run(incoming, fake);
165
+ expect(fake.calls).toHaveLength(0);
166
+ expect(ctx.newState.metadata?.labels[MCP_AUTH_LABEL]).toBeUndefined();
167
+ });
168
+
169
+ it("completes the token path for a hand-written auth: the header when no header sends the token, the declaration when none exists", async () => {
170
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
171
+ const bare = await run(server({ auth: { targetEnvVar: "MY_TOKEN" }, env: { MY_TOKEN: { isSecret: true } } }), fake);
172
+ expect(bare.newState.spec?.serverType.case === "http" ? bare.newState.spec.serverType.value.headers : {}).toEqual({ Authorization: "Bearer ${MY_TOKEN}" });
173
+ const custom = await run(server({ auth: { targetEnvVar: "MY_TOKEN" }, env: { MY_TOKEN: { isSecret: true } }, headers: { "X-Token": "${MY_TOKEN}" } }), fake);
174
+ expect(custom.newState.spec?.serverType.case === "http" ? custom.newState.spec.serverType.value.headers : {}).toEqual({ "X-Token": "${MY_TOKEN}" });
175
+ // An auth whose variable was never declared could never receive its
176
+ // token (connect and execution inject only declared variables).
177
+ const undeclared = await run(server({ auth: { targetEnvVar: "MY_TOKEN" } }), fake);
178
+ expect(undeclared.newState.spec?.env["MY_TOKEN"]).toMatchObject({ isSecret: true, optional: false });
179
+ expect(undeclared.newState.spec?.env["MY_TOKEN"]?.description).toContain("Sign in fills it");
180
+ // A declaration the author wrote is left as written.
181
+ const declared = await run(server({ auth: { targetEnvVar: "MY_TOKEN" }, env: { MY_TOKEN: { isSecret: false, optional: true, description: "mine" } } }), fake);
182
+ expect(declared.newState.spec?.env["MY_TOKEN"]).toMatchObject({ isSecret: false, optional: true, description: "mine" });
183
+ expect(fake.calls).toHaveLength(0);
184
+ });
185
+
186
+ it("declares the variable for a stdio server's hand-written auth and adds no header", async () => {
187
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
188
+ const ctx = await run(server({ stdio: true, auth: { targetEnvVar: "MY_TOKEN" } }), fake);
189
+ expect(ctx.newState.spec?.env["MY_TOKEN"]).toMatchObject({ isSecret: true });
190
+ expect(ctx.newState.spec?.serverType.case).toBe("stdio");
191
+ expect(fake.calls).toHaveLength(0);
192
+ });
193
+ });
194
+
195
+ describe("CompleteEndpointAuth: carry-over on update, three questions in order", () => {
196
+ it("reuses an earlier completion for the author's shape at the same URL, with no network", async () => {
197
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
198
+ const ctx = await run(server(), fake, completedRow());
199
+ expectCompleted(ctx.newState);
200
+ expect(fake.calls).toHaveLength(0);
201
+ expect(serverStampedReservedLabels(ctx).has(MCP_AUTH_LABEL)).toBe(true);
202
+ });
203
+
204
+ it("keeps the label on an exact echo of the derived trio (the console's per-field save), with no network", async () => {
205
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
206
+ const echo = completedRow();
207
+ echo.spec!.description = "edited in the console";
208
+ const ctx = await run(echo, fake, completedRow());
209
+ expect(ctx.newState.metadata?.labels[MCP_AUTH_LABEL]).toBe(MCP_AUTH_ENDPOINT);
210
+ expect(ctx.newState.spec?.description).toBe("edited in the console");
211
+ expect(fake.calls).toHaveLength(0);
212
+ expect(serverStampedReservedLabels(ctx).has(MCP_AUTH_LABEL)).toBe(true);
213
+ });
214
+
215
+ it("drops the label when the author alters the derived trio: their auth is theirs now", async () => {
216
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
217
+ const altered = completedRow();
218
+ altered.spec!.auth!.discoveryUrl = "https://login.vendor.test";
219
+ const ctx = await run(altered, fake, completedRow());
220
+ expect(ctx.newState.spec?.auth?.discoveryUrl).toBe("https://login.vendor.test");
221
+ expect(ctx.newState.metadata?.labels[MCP_AUTH_LABEL]).toBeUndefined();
222
+ expect(fake.calls).toHaveLength(0);
223
+ });
224
+
225
+ it("drops the label when the author declares their own env on a completed row", async () => {
226
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
227
+ const own = server({ headers: { Authorization: "Bearer ${TOKEN}" }, env: { TOKEN: { isSecret: true } }, labels: { [MCP_AUTH_LABEL]: MCP_AUTH_ENDPOINT } });
228
+ const ctx = await run(own, fake, completedRow());
229
+ expect(ctx.newState.spec?.auth).toBeUndefined();
230
+ expect(ctx.newState.metadata?.labels[MCP_AUTH_LABEL]).toBeUndefined();
231
+ expect(fake.calls).toHaveLength(0);
232
+ });
233
+
234
+ it("probes again when the URL changed, and completes from the new answer", async () => {
235
+ const fake = answering(401, { "www-authenticate": CHALLENGE });
236
+ const moved = server({ url: "https://mcp2.vendor.test/mcp" });
237
+ const ctx = await run(moved, fake, completedRow());
238
+ expect(fake.calls).toHaveLength(1);
239
+ expect(fake.calls[0]?.url).toBe("https://mcp2.vendor.test/mcp");
240
+ expectCompleted(ctx.newState);
241
+ });
242
+
243
+ it("probes again when the URL changed, and drops the label when the new endpoint is open", async () => {
244
+ const fake = answering(200);
245
+ const moved = server({ url: "https://open.vendor.test/mcp", labels: { [MCP_AUTH_LABEL]: MCP_AUTH_ENDPOINT } });
246
+ const ctx = await run(moved, fake, completedRow());
247
+ expect(fake.calls).toHaveLength(1);
248
+ expect(ctx.newState.spec?.auth).toBeUndefined();
249
+ expect(ctx.newState.metadata?.labels[MCP_AUTH_LABEL]).toBeUndefined();
250
+ });
251
+
252
+ it("does not reuse a completion from a row that lacks the label (an author's own auth is not a completion)", async () => {
253
+ const fake = answering(200);
254
+ const authored = completedRow();
255
+ delete authored.metadata!.labels[MCP_AUTH_LABEL];
256
+ const incoming = server();
257
+ const ctx = await run(incoming, fake, authored);
258
+ expect(fake.calls).toHaveLength(1);
259
+ expectUntouched(ctx.newState, incoming);
260
+ });
261
+ });
262
+
263
+ describe("accessTokenVariable", () => {
264
+ it("upper-cases the slug and folds every non-alphanumeric run into one underscore", () => {
265
+ expect(accessTokenVariable("linear")).toBe("LINEAR_ACCESS_TOKEN");
266
+ expect(accessTokenVariable("my-plugin.github-mcp")).toBe("MY_PLUGIN_GITHUB_MCP_ACCESS_TOKEN");
267
+ expect(accessTokenVariable("--odd--")).toBe("ODD_ACCESS_TOKEN");
268
+ expect(accessTokenVariable("")).toBe("MCP_ACCESS_TOKEN");
269
+ });
270
+ });
@@ -118,6 +118,9 @@ function deps(authorizer: Authorizer): McpServerConnectDeps {
118
118
  pendingOAuthStates: store.pendingOAuthStates,
119
119
  secretService: unreachable("secretService"),
120
120
  oauthRedirectUri: "http://localhost:7233/oauth/callback",
121
+ outboundFetch: async () => {
122
+ throw new Error("no outbound fetch in this harness");
123
+ },
121
124
  };
122
125
  }
123
126
 
@@ -210,6 +210,10 @@ function makeHarness(options: FakeEngineOptions = {}): Harness {
210
210
  pendingOAuthStates: store.pendingOAuthStates,
211
211
  secretService: SecretService.create(undefined),
212
212
  oauthRedirectUri: "http://127.0.0.1:8234/auth/oauth/callback",
213
+ // No arm in this harness dials out; a call is a test bug, not a network.
214
+ outboundFetch: async () => {
215
+ throw new Error("no outbound fetch in this harness");
216
+ },
213
217
  },
214
218
  };
215
219
  return harness;
@@ -641,4 +645,31 @@ describe("startBestEffortConnect (apply tail)", () => {
641
645
  startBestEffortConnect(harness.deps, server),
642
646
  ).resolves.toBeUndefined();
643
647
  });
648
+
649
+ it("records a failed run through the same mapping as the blocking lane, so the page says what the runner found", async () => {
650
+ const harness = makeHarness({
651
+ outcome: {
652
+ ok: false,
653
+ failure: {
654
+ kind: "application",
655
+ message: "MCP server 'test' requires OAuth: its endpoint returned an authentication challenge (HTTP 401).",
656
+ },
657
+ },
658
+ });
659
+ const server = await seedServer({ stdio: false });
660
+ await startBestEffortConnect(harness.deps, server);
661
+ const after = await store.getResource(
662
+ ApiResourceKind.mcp_server,
663
+ server.metadata!.id,
664
+ McpServerSchema,
665
+ );
666
+ expect(after.status?.connectStatus?.phase).toBe(ConnectPhase.failed);
667
+ expect(after.status?.connectStatus?.failureCode).toBe("FailedPrecondition");
668
+ // The runner's "requires OAuth" sentence rides through verbatim
669
+ // (buildConnectFailureMessage's pass-through), never a generic
670
+ // "best-effort connect did not complete".
671
+ expect(after.status?.connectStatus?.failureMessage).toBe(
672
+ "MCP server 'test' requires OAuth: its endpoint returned an authentication challenge (HTTP 401).",
673
+ );
674
+ });
644
675
  });
@@ -343,7 +343,7 @@ describe("initiateOAuthConnect", () => {
343
343
  await expectCode(
344
344
  command.initiateOAuthConnect({ mcpServerId: id, org: ORG }),
345
345
  Code.FailedPrecondition,
346
- "does not advertise a registration_endpoint for DCR",
346
+ "does not advertise a registration_endpoint for DCR: 127.0.0.1:",
347
347
  );
348
348
  mockAs.reset();
349
349
  });