@stigmer/server 3.18.0 → 3.18.1-dev.20260919070736

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 (92) 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/mcpserver/complete-endpoint-auth.d.ts +16 -0
  13. package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
  14. package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
  15. package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
  16. package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
  17. package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
  18. package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
  19. package/dist/domain/mcpserver/connect.d.ts +25 -0
  20. package/dist/domain/mcpserver/connect.d.ts.map +1 -1
  21. package/dist/domain/mcpserver/connect.js +8 -23
  22. package/dist/domain/mcpserver/connect.js.map +1 -1
  23. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  24. package/dist/domain/mcpserver/controller.js +12 -0
  25. package/dist/domain/mcpserver/controller.js.map +1 -1
  26. package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
  27. package/dist/domain/mcpserver/initiate-oauth-connect.js +16 -13
  28. package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
  29. package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
  30. package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
  31. package/dist/domain/mcpserver/oauth/dcr.js +1 -9
  32. package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
  33. package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
  34. package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
  35. package/dist/domain/mcpserver/oauth/discovery.js +69 -54
  36. package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
  37. package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
  38. package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
  39. package/dist/domain/mcpserver/oauth/preflight.js +1 -26
  40. package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
  41. package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
  42. package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
  43. package/dist/domain/mcpserver/oauth/refresh.js +1 -1
  44. package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
  45. package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
  46. package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
  47. package/dist/domain/mcpserver/oauth/token.js +2 -8
  48. package/dist/domain/mcpserver/oauth/token.js.map +1 -1
  49. package/dist/extensions/drivers.d.ts +17 -0
  50. package/dist/extensions/drivers.d.ts.map +1 -1
  51. package/dist/extensions/outbound-egress.d.ts +49 -0
  52. package/dist/extensions/outbound-egress.d.ts.map +1 -0
  53. package/dist/extensions/outbound-egress.js +51 -0
  54. package/dist/extensions/outbound-egress.js.map +1 -0
  55. package/dist/extensions/registry.d.ts +6 -0
  56. package/dist/extensions/registry.d.ts.map +1 -1
  57. package/dist/extensions/registry.js +10 -0
  58. package/dist/extensions/registry.js.map +1 -1
  59. package/dist/index.d.ts +2 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1 -0
  62. package/dist/index.js.map +1 -1
  63. package/dist/pipeline/apiresource-labels.d.ts +14 -0
  64. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  65. package/dist/pipeline/apiresource-labels.js +14 -0
  66. package/dist/pipeline/apiresource-labels.js.map +1 -1
  67. package/package.json +6 -5
  68. package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
  69. package/src/boot/compose.ts +47 -8
  70. package/src/boot/config.ts +6 -2
  71. package/src/boot/oauth-redirect-uri.ts +53 -0
  72. package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
  73. package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
  74. package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
  75. package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
  76. package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
  77. package/src/domain/mcpserver/connect.ts +21 -15
  78. package/src/domain/mcpserver/controller.ts +16 -0
  79. package/src/domain/mcpserver/initiate-oauth-connect.ts +16 -12
  80. package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
  81. package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
  82. package/src/domain/mcpserver/oauth/dcr.ts +2 -1
  83. package/src/domain/mcpserver/oauth/discovery.ts +120 -89
  84. package/src/domain/mcpserver/oauth/preflight.ts +2 -1
  85. package/src/domain/mcpserver/oauth/refresh.ts +2 -1
  86. package/src/domain/mcpserver/oauth/token.ts +4 -3
  87. package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
  88. package/src/extensions/drivers.ts +17 -0
  89. package/src/extensions/outbound-egress.ts +55 -0
  90. package/src/extensions/registry.ts +19 -0
  91. package/src/index.ts +10 -0
  92. package/src/pipeline/apiresource-labels.ts +16 -0
@@ -40,6 +40,7 @@ import { authorizationPostureOf } from "../authorization/posture.js";
40
40
  import { newBuiltInScheduleFireCaller } from "../authorization/schedule-fire-caller.js";
41
41
  import type { Authorizer } from "../extensions/authorizer.js";
42
42
  import { ABSENT_LICENSE_STATUS } from "../extensions/license-status.js";
43
+ import { relaxedEgressPolicy } from "../extensions/outbound-egress.js";
43
44
  import type { ListReadScope } from "../extensions/list-read-scope.js";
44
45
  import type { OrganizationDirectory } from "../extensions/organization-directory.js";
45
46
  import type { ScheduleFireCallerMint } from "../extensions/schedule-fire-caller.js";
@@ -86,6 +87,7 @@ import { newDirectAccountProvisioner } from "../domain/identityaccount/provision
86
87
  import { newResourceIdentityAccountStore } from "../domain/identityaccount/resource-store.js";
87
88
  import { registerIamPolicyServices } from "../domain/iampolicy/controller.js";
88
89
  import { newIamPolicyGrantPath } from "../domain/iampolicy/grant-path.js";
90
+ import { asFetch, guardedFetch, nodeLookup } from "@stigmer/outbound/egress";
89
91
  import { newOrganizationOnlyGrantScope } from "../domain/iampolicy/grant-scope.js";
90
92
  import { newMembershipRules } from "../domain/iampolicy/membership.js";
91
93
  import { newResourceIamPolicyStore } from "../domain/iampolicy/resource-store.js";
@@ -164,6 +166,7 @@ import { PostgresStore } from "../store/postgres/store.js";
164
166
  import { SqliteStore } from "../store/sqlite/store.js";
165
167
  import type { Store } from "../store/interface.js";
166
168
  import { resolveExtensions } from "../extensions/registry.js";
169
+ import { resolveOAuthRedirectUri } from "./oauth-redirect-uri.js";
167
170
  import type { ServerExtension } from "../extensions/registry.js";
168
171
  import { registerWorkflowServices } from "../domain/workflow/controller.js";
169
172
  import {
@@ -1101,14 +1104,46 @@ export async function composeServer(
1101
1104
  config: temporalConfig,
1102
1105
  logger,
1103
1106
  });
1104
- // WARN-degrade, not boot-fatal (Go server.go:722-729): every OAuth RPC
1105
- // except initiateOAuthConnect works without the redirect URI, and
1106
- // initiate refuses with the pinned FailedPrecondition copy.
1107
- if (config.oauthRedirectUri === "") {
1108
- logger.warn(
1109
- "STIGMER_OAUTH_REDIRECT_URI is not set — OAuth Connect flows for MCP servers are unavailable (initiateOAuthConnect will refuse)",
1110
- );
1107
+ // The OAuth callback: configured, or derived from the served console's
1108
+ // own origin when it serves one on a known port (boot/oauth-redirect-uri.ts
1109
+ // carries the posture). Absent is WARN-degrade, not boot-fatal (Go
1110
+ // server.go:722-729): every OAuth RPC except initiateOAuthConnect works
1111
+ // without the redirect URI, and initiate refuses with the pinned
1112
+ // FailedPrecondition copy.
1113
+ const oauthRedirect = resolveOAuthRedirectUri({
1114
+ configured: config.oauthRedirectUri,
1115
+ servesConsole: consoleAssets !== undefined,
1116
+ port: options.portOverride ?? config.grpcPort,
1117
+ });
1118
+ switch (oauthRedirect.kind) {
1119
+ case "configured":
1120
+ break;
1121
+ case "derived":
1122
+ logger.info("STIGMER_OAUTH_REDIRECT_URI is not set — deriving the served console's callback for MCP server OAuth Connect", {
1123
+ redirectUri: oauthRedirect.uri,
1124
+ });
1125
+ break;
1126
+ case "absent":
1127
+ logger.warn(
1128
+ "STIGMER_OAUTH_REDIRECT_URI is not set — OAuth Connect flows for MCP servers are unavailable (initiateOAuthConnect will refuse)",
1129
+ );
1130
+ break;
1131
+ default: {
1132
+ const exhaustive: never = oauthRedirect;
1133
+ throw new Error(String(exhaustive));
1134
+ }
1111
1135
  }
1136
+ const oauthRedirectUri = oauthRedirect.kind === "absent" ? "" : oauthRedirect.uri;
1137
+ // The ONE fetch the McpServer domain dials user-supplied URLs with: the
1138
+ // endpoint it probes at save time and the login server it reaches on
1139
+ // Sign in. Every hop is judged under the edition's egress policy
1140
+ // (`drivers.outboundEgress`; the default is open source's relaxed
1141
+ // posture, installed at this `??` site like the permissive Authorizer).
1142
+ // `options.fetchImpl` is the test seam under the guard.
1143
+ const outboundFetch = guardedFetch(
1144
+ extensions.drivers.outboundEgress ?? relaxedEgressPolicy(),
1145
+ { fetchImpl: options.fetchImpl ?? fetch, lookup: nodeLookup() },
1146
+ );
1112
1147
  // The SAME `routes` function registers every service on BOTH the serving
1113
1148
  // router and the in-process router transport (createInProcessClients).
1114
1149
  // Handlers are stateless over the same store, so the two routers behave
@@ -1334,6 +1369,9 @@ export async function composeServer(
1334
1369
  executionContextCreator: () =>
1335
1370
  requireInProcess().executionContextCreator,
1336
1371
  managedEnvService,
1372
+ // The run-start token refresh dials the vendor's token endpoint;
1373
+ // it rides the same egress-guarded fetch as every other OAuth call.
1374
+ fetchImpl: asFetch(outboundFetch),
1337
1375
  },
1338
1376
  });
1339
1377
  registerWorkflowServices(router, {
@@ -1414,7 +1452,8 @@ export async function composeServer(
1414
1452
  oauthGrants: store.oauthGrants,
1415
1453
  pendingOAuthStates: store.pendingOAuthStates,
1416
1454
  secretService,
1417
- oauthRedirectUri: config.oauthRedirectUri,
1455
+ oauthRedirectUri,
1456
+ outboundFetch,
1418
1457
  },
1419
1458
  });
1420
1459
  registerSkillServices(router, {
@@ -109,8 +109,12 @@ export interface ServerConfig {
109
109
  readonly gitHubOAuthClientSecret: string;
110
110
  /**
111
111
  * The OAuth callback URL for the McpServer OAuth Connect flows
112
- * (STIGMER_OAUTH_REDIRECT_URI; Go config.go OAuthRedirectURI). Unset is
113
- * a WARN at wiring time, not a boot failure — every RPC except
112
+ * (STIGMER_OAUTH_REDIRECT_URI; Go config.go OAuthRedirectURI). Unset, a
113
+ * server that serves the web console on its unified port derives the
114
+ * console's own callback page at `http://localhost:<port>` (the
115
+ * `trusted-local` reading; boot/oauth-redirect-uri.ts), so a local
116
+ * install signs in without being told. Unset on a server that serves no
117
+ * console is a WARN at wiring time, not a boot failure — every RPC except
114
118
  * initiateOAuthConnect works without it, and initiate refuses with a
115
119
  * FailedPrecondition naming the variable (the pinned copy).
116
120
  */
@@ -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
+ }
@@ -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
  });