@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,277 @@
1
+ /**
2
+ * CompleteEndpointAuth — the McpServer create and update chains' step that
3
+ * makes a URL enough: a server saved with only an `http.url` is asked once
4
+ * whether it wants OAuth, and when it does, the spec is completed so Sign
5
+ * in exists from the moment the row does.
6
+ *
7
+ * Why the domain and not the plugin. A plugin is one producer of servers
8
+ * among three (a plugin install, `stigmer apply` of a YAML, the console's
9
+ * form), and every producer's server runs these two chains. A step here
10
+ * reaches all three; a behaviour in the plugin's materialisation would
11
+ * make the same YAML come alive from a plugin and stay dead by hand.
12
+ *
13
+ * What the step reads: the author's shape only. An `http` server whose
14
+ * `auth` is unset, whose `env` is empty, and whose headers carry neither a
15
+ * `${VAR}` placeholder nor an `Authorization` header. Anything else is the
16
+ * author speaking about authentication (a declared token variable, a
17
+ * pasted literal token, an `auth` block, an `oauth_app_ref`) and the step
18
+ * leaves the state exactly as sent.
19
+ *
20
+ * What the step writes, and only on an OAuth challenge: the least that
21
+ * makes Sign in possible and the page honest. `auth.target_env_var`
22
+ * (`<SLUG>_ACCESS_TOKEN`), the secret `env` declaration for it, the
23
+ * `Authorization: Bearer ${VAR}` header, `auth.oauth_only` (the reading the
24
+ * runner's "requires OAuth" sentence and the console's credential hook
25
+ * already apply to a 401 challenge: manual token entry is a dead end), and
26
+ * the provenance label `stigmer.ai/mcp-auth: endpoint`, vouched through
27
+ * the server-stamped mechanism so GuardReservedLabels admits it for an
28
+ * ordinary caller. Not written: `discovery_url` (the author's override;
29
+ * Sign in resolves the login server from `http.url` every time through
30
+ * the RFC 9728 walk) and `scope_hints` (initiate reads the login server's
31
+ * `scopes_supported` when hints are empty). A cached fact drifts; the
32
+ * endpoint restates these on every Sign in.
33
+ *
34
+ * Every other outcome (a 2xx, a non-OAuth 401, any other status, no
35
+ * answer) leaves the spec as sent and writes no status: `ConnectStatus`
36
+ * records connect operations, and a probe is not one. The best-effort
37
+ * connect that follows an apply then runs for these servers as before.
38
+ *
39
+ * Carry-over on update, three questions in order:
40
+ *
41
+ * 1. Is the author speaking about auth? An incoming `auth`, `env`,
42
+ * placeholder or `Authorization` header that is NOT exactly the trio
43
+ * this step derived on the existing row means yes: the state is left
44
+ * as sent and the label is dropped (a removal passes the guard by
45
+ * design). An exact echo of the derived trio (the console's per-field
46
+ * save re-sends the whole spec) is not the author speaking: the label
47
+ * is kept and nothing is probed.
48
+ * 2. Is it the same endpoint? A changed URL with the author's shape is
49
+ * probed again.
50
+ * 3. Has it already answered? The author's shape at the same URL on a
51
+ * row carrying the label reuses the existing completion with no
52
+ * network: a plugin re-push and a `stigmer apply` re-run cost nothing
53
+ * and change nothing.
54
+ *
55
+ * The token-path default applies to every producer: whenever
56
+ * `auth.target_env_var` is set (by hand, by a plugin overlay, or by this
57
+ * step), the variable is declared in `env` if it is not, and the
58
+ * `Authorization: Bearer ${VAR}` header is added if no header references
59
+ * it. The chain is `auth.target_env_var` names the variable, `env` declares
60
+ * it (connect and execution inject a grant's token only into DECLARED
61
+ * variables), and a header sends it; break one link and no token is sent.
62
+ * An `auth` block written without the other two links used to sit silently
63
+ * dead; this makes it work.
64
+ *
65
+ * The probe: one complete `initialize` to `http.url` with the author's
66
+ * literal headers, through the slice's egress-guarded `outboundFetch`,
67
+ * under a 3 s deadline that covers DNS (the save waits on it; a hosted
68
+ * endpoint answers in well under a second, an unreachable host fails at
69
+ * once, and only a host that accepts and stalls reaches the ceiling). No
70
+ * tool is called, no metadata document is fetched, no redirect is
71
+ * followed. A thrown probe is logged and read as unreachable; the save
72
+ * never fails on it.
73
+ *
74
+ * Placement: after BuildNewState (create) or BuildUpdateState (update),
75
+ * immediately before GuardReservedLabels, the lineage step's law: the
76
+ * labels the guard diffs are the ones this step stamped.
77
+ *
78
+ * Proven by __tests__/complete-endpoint-auth.test.ts and the McpServer
79
+ * conformance suite's URL-only arms (CONFORMANCE_TARGET=local).
80
+ */
81
+ import { create } from "@bufbuild/protobuf";
82
+
83
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
84
+ import { probeEndpointAuth } from "@stigmer/outbound/mcp-oauth";
85
+ import { EnvVarDeclarationSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb";
86
+ import type { McpServer, McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
87
+ import { McpServerAuthSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
88
+ import type { McpServerSpec } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
89
+
90
+ import type { Logger } from "../../boot/logger.js";
91
+ import { MCP_AUTH_ENDPOINT, MCP_AUTH_LABEL } from "../../pipeline/apiresource-labels.js";
92
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
93
+ import type { RequestContext } from "../../pipeline/request-context.js";
94
+ import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
95
+ import { recordServerStampedReservedLabels } from "../../pipeline/steps/server-stamped-reserved-labels.js";
96
+
97
+ /** The save waits on the probe; a real endpoint answers well within this, DNS included. */
98
+ export const ENDPOINT_PROBE_TIMEOUT_MS = 3_000;
99
+
100
+ /** How the control plane names itself in the handshake's `clientInfo`. */
101
+ const PROBE_CLIENT_NAME = "stigmer-server";
102
+
103
+ /** The suffix of the token variable the step declares for a completed server. */
104
+ export const ACCESS_TOKEN_VAR_SUFFIX = "_ACCESS_TOKEN";
105
+
106
+ const AUTHORIZATION_HEADER = "authorization";
107
+
108
+ export interface CompleteEndpointAuthDeps {
109
+ readonly outboundFetch: OutboundFetch;
110
+ readonly logger: Logger;
111
+ }
112
+
113
+ export function newCompleteEndpointAuthStep(deps: CompleteEndpointAuthDeps): PipelineStep<typeof McpServerSchema> {
114
+ return {
115
+ name: "CompleteEndpointAuth",
116
+ async execute(ctx: RequestContext<typeof McpServerSchema>): Promise<void> {
117
+ const server = ctx.newState;
118
+ const spec = server.spec;
119
+ if (spec === undefined) return;
120
+
121
+ applyTokenPathDefault(spec, server.metadata?.slug ?? "");
122
+
123
+ const existing = ctx.get(EXISTING_RESOURCE_KEY) as McpServer | undefined;
124
+ const derived = existing !== undefined ? derivedCompletionOf(existing) : undefined;
125
+
126
+ const url = authorShapeUrl(spec);
127
+ if (url === undefined) {
128
+ // The author speaks about auth. An exact echo of what this step
129
+ // derived is the console re-sending what it loaded: keep the label.
130
+ // Anything else is the author's own; the label goes.
131
+ if (derived !== undefined && echoesDerived(spec, derived)) {
132
+ stamp(ctx, server);
133
+ } else {
134
+ dropLabel(server);
135
+ }
136
+ return;
137
+ }
138
+
139
+ if (derived !== undefined && derived.url === url) {
140
+ applyCompletion(spec, derived.variable, derived.description);
141
+ stamp(ctx, server);
142
+ return;
143
+ }
144
+
145
+ const outcome = await probe(url, spec, deps);
146
+ if (outcome !== "oauth") {
147
+ dropLabel(server);
148
+ return;
149
+ }
150
+ const variable = accessTokenVariable(server.metadata?.slug ?? "");
151
+ applyCompletion(spec, variable, tokenDescription(server.metadata?.slug ?? ""));
152
+ stamp(ctx, server);
153
+ },
154
+ };
155
+ }
156
+
157
+ /**
158
+ * The URL when the spec is the author's shape (an `http` server that says
159
+ * nothing about authentication); undefined otherwise.
160
+ */
161
+ function authorShapeUrl(spec: McpServerSpec): string | undefined {
162
+ if (spec.serverType.case !== "http") return undefined;
163
+ if (spec.auth !== undefined) return undefined;
164
+ if (Object.keys(spec.env).length > 0) return undefined;
165
+ for (const [name, value] of Object.entries(spec.serverType.value.headers)) {
166
+ if (name.toLowerCase() === AUTHORIZATION_HEADER) return undefined;
167
+ if (value.includes("${")) return undefined;
168
+ }
169
+ return spec.serverType.value.url;
170
+ }
171
+
172
+ /** The completion an earlier save derived, read from a row that carries the label. */
173
+ interface DerivedCompletion {
174
+ readonly url: string;
175
+ readonly variable: string;
176
+ readonly description: string;
177
+ }
178
+
179
+ function derivedCompletionOf(existing: McpServer): DerivedCompletion | undefined {
180
+ if (existing.metadata?.labels[MCP_AUTH_LABEL] !== MCP_AUTH_ENDPOINT) return undefined;
181
+ const spec = existing.spec;
182
+ if (spec === undefined || spec.serverType.case !== "http") return undefined;
183
+ const variable = spec.auth?.targetEnvVar ?? "";
184
+ if (variable === "") return undefined;
185
+ return { url: spec.serverType.value.url, variable, description: spec.env[variable]?.description ?? "" };
186
+ }
187
+
188
+ /** Whether the incoming spec carries exactly the trio the step derived, and nothing more about auth. */
189
+ function echoesDerived(spec: McpServerSpec, derived: DerivedCompletion): boolean {
190
+ if (spec.serverType.case !== "http" || spec.serverType.value.url !== derived.url) return false;
191
+ const auth = spec.auth;
192
+ if (auth === undefined) return false;
193
+ if (auth.targetEnvVar !== derived.variable || !auth.oauthOnly) return false;
194
+ if ((auth.oauthAppRef?.slug ?? "") !== "" || auth.discoveryUrl !== "" || auth.scopeHints.length > 0 || auth.tokenLifetimeHint !== "") return false;
195
+ const envKeys = Object.keys(spec.env);
196
+ if (envKeys.length !== 1 || envKeys[0] !== derived.variable) return false;
197
+ const declaration = spec.env[derived.variable];
198
+ if (declaration === undefined || !declaration.isSecret || declaration.optional) return false;
199
+ const headers = Object.entries(spec.serverType.value.headers);
200
+ const authorization = headers.filter(([name]) => name.toLowerCase() === AUTHORIZATION_HEADER);
201
+ if (authorization.length !== 1 || authorization[0]?.[1] !== bearerHeader(derived.variable)) return false;
202
+ return headers.every(([name, value]) => name.toLowerCase() === AUTHORIZATION_HEADER || !value.includes("${"));
203
+ }
204
+
205
+ function applyCompletion(spec: McpServerSpec, variable: string, description: string): void {
206
+ spec.auth = create(McpServerAuthSchema, { targetEnvVar: variable, oauthOnly: true });
207
+ spec.env[variable] = create(EnvVarDeclarationSchema, { isSecret: true, optional: false, description });
208
+ applyTokenPathDefault(spec, "");
209
+ }
210
+
211
+ /**
212
+ * Whenever `auth.target_env_var` names a variable, the variable is declared
213
+ * (secret, required) if the author did not, and the header that sends it is
214
+ * added if no header references it; a declaration or a header the author
215
+ * wrote is left as written. Only an `http` server carries headers; a stdio
216
+ * server's token reaches it through the declared variable alone.
217
+ */
218
+ function applyTokenPathDefault(spec: McpServerSpec, slug: string): void {
219
+ const variable = spec.auth?.targetEnvVar ?? "";
220
+ if (variable === "") return;
221
+ if (spec.env[variable] === undefined) {
222
+ spec.env[variable] = create(EnvVarDeclarationSchema, { isSecret: true, optional: false, description: tokenDescription(slug) });
223
+ }
224
+ if (spec.serverType.case !== "http") return;
225
+ const headers = spec.serverType.value.headers;
226
+ const reference = `\${${variable}}`;
227
+ if (Object.values(headers).some((value) => value.includes(reference))) return;
228
+ headers["Authorization"] = bearerHeader(variable);
229
+ }
230
+
231
+ function bearerHeader(variable: string): string {
232
+ return `Bearer \${${variable}}`;
233
+ }
234
+
235
+ /** `<SLUG>_ACCESS_TOKEN`, the slug upper-cased with every non-alphanumeric run as one underscore. */
236
+ export function accessTokenVariable(slug: string): string {
237
+ const stem = slug.toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
238
+ return `${stem === "" ? "MCP" : stem}${ACCESS_TOKEN_VAR_SUFFIX}`;
239
+ }
240
+
241
+ function tokenDescription(slug: string): string {
242
+ return `OAuth access token for the '${slug}' MCP server; Sign in fills it`;
243
+ }
244
+
245
+ function stamp(ctx: RequestContext<typeof McpServerSchema>, server: McpServer): void {
246
+ if (server.metadata === undefined) return;
247
+ server.metadata.labels[MCP_AUTH_LABEL] = MCP_AUTH_ENDPOINT;
248
+ recordServerStampedReservedLabels(ctx, MCP_AUTH_LABEL);
249
+ }
250
+
251
+ function dropLabel(server: McpServer): void {
252
+ if (server.metadata === undefined) return;
253
+ delete server.metadata.labels[MCP_AUTH_LABEL];
254
+ }
255
+
256
+ async function probe(url: string, spec: McpServerSpec, deps: CompleteEndpointAuthDeps): Promise<"oauth" | "other"> {
257
+ const headers = spec.serverType.case === "http" ? spec.serverType.value.headers : {};
258
+ try {
259
+ const outcome = await probeEndpointAuth(url, headers, {
260
+ fetchImpl: deps.outboundFetch,
261
+ timeoutMs: ENDPOINT_PROBE_TIMEOUT_MS,
262
+ clientName: PROBE_CLIENT_NAME,
263
+ });
264
+ if (outcome.kind === "unreachable") {
265
+ deps.logger.debug("MCP endpoint probe got no answer; saving the spec as sent", { url, error: outcome.error });
266
+ }
267
+ return outcome.kind === "oauth" ? "oauth" : "other";
268
+ } catch (error) {
269
+ // probeEndpointAuth never throws by contract; a throw here is a defect
270
+ // in the fetch it was handed, and a save must not fail on it.
271
+ deps.logger.warn("MCP endpoint probe threw; saving the spec as sent", {
272
+ url,
273
+ error: error instanceof Error ? error.message : String(error),
274
+ });
275
+ return "other";
276
+ }
277
+ }
@@ -131,6 +131,7 @@ export async function completeOAuthConnect(
131
131
  pendingState.clientId,
132
132
  pendingState.clientSecret,
133
133
  pendingState.tokenAuthMethod,
134
+ deps.outboundFetch,
134
135
  );
135
136
  } catch (error) {
136
137
  throw unavailableError(
@@ -14,6 +14,7 @@
14
14
  * Proven by mcpserver-connect.conformance.test.ts
15
15
  * (CONFORMANCE_TARGET=local-execution) and __tests__/connect.test.ts.
16
16
  */
17
+ import type { OutboundFetch } from "@stigmer/outbound/egress";
17
18
  import { create } from "@bufbuild/protobuf";
18
19
  import type { MessageInitShape } from "@bufbuild/protobuf";
19
20
  import { Code, ConnectError } from "@connectrpc/connect";
@@ -218,6 +219,14 @@ export interface McpServerConnectDeps {
218
219
  readonly pendingOAuthStates: PendingOAuthStateStore;
219
220
  readonly secretService: SecretService;
220
221
  readonly oauthRedirectUri: string;
222
+ /**
223
+ * The ONE fetch this slice dials user-supplied URLs with: the MCP
224
+ * endpoint probed at save time and the login server reached on Sign in
225
+ * (discovery, registration, the authorize preflight, token exchange and
226
+ * refresh). Composed at the root under the edition's egress policy
227
+ * (`drivers.outboundEgress`); no module here holds a `fetch` of its own.
228
+ */
229
+ readonly outboundFetch: OutboundFetch;
221
230
  }
222
231
 
223
232
  /**
@@ -1001,6 +1010,7 @@ export async function refreshOAuthTokenIfNeeded(
1001
1010
  clientSecret,
1002
1011
  tokenAuthMethod,
1003
1012
  deps.logger,
1013
+ deps.outboundFetch,
1004
1014
  );
1005
1015
  } catch (error) {
1006
1016
  throw failedPreconditionError(
@@ -1185,23 +1195,19 @@ export async function startBestEffortConnect(
1185
1195
  CONNECT_TIMEOUT.ms + BEST_EFFORT_CONNECT_GET_BUFFER_MS,
1186
1196
  );
1187
1197
  if (!outcome.ok) {
1188
- deps.logger.warn(
1189
- "Best-effort connect workflow did not complete (non-fatal)",
1190
- {
1191
- workflow_id: run.workflowId,
1192
- mcp_server_id: mcpServerId,
1193
- failure_kind: outcome.failure.kind,
1194
- },
1195
- );
1196
- await persistConnectFailure(
1197
- deps.store,
1198
+ // The same mapping the blocking lane and the async settle apply, so
1199
+ // the page of a freshly applied server says what the runner found
1200
+ // ("requires OAuth", "Missing Cursor credential") rather than that a
1201
+ // background task did not complete. ConnectStatus promises the lanes
1202
+ // record alike; this arm once recorded a generic sentence instead.
1203
+ const failure = mapConnectFailure(
1198
1204
  deps.logger,
1199
- mcpServerId,
1200
- internalError(
1201
- new Error(`connect run failed: ${outcome.failure.kind}`),
1202
- "best-effort connect did not complete",
1203
- ),
1205
+ mcpServer,
1206
+ run.workflowId,
1207
+ outcome.failure,
1208
+ CONNECT_TIMEOUT,
1204
1209
  );
1210
+ await persistConnectFailure(deps.store, deps.logger, mcpServerId, failure);
1205
1211
  return;
1206
1212
  }
1207
1213
 
@@ -107,6 +107,10 @@ import { completeOAuthConnect } from "./complete-oauth-connect.js";
107
107
  import { connect, startBestEffortConnect } from "./connect.js";
108
108
  import type { McpServerConnectDeps } from "./connect.js";
109
109
  import { disconnectOAuth } from "./disconnect-oauth.js";
110
+ import {
111
+ newCompleteEndpointAuthStep,
112
+ type CompleteEndpointAuthDeps,
113
+ } from "./complete-endpoint-auth.js";
110
114
  import { getOAuthGrantStatus } from "./get-oauth-grant-status.js";
111
115
  import { initiateOAuthConnect } from "./initiate-oauth-connect.js";
112
116
  import { mcpServerSearchExtractor } from "./search-extractor.js";
@@ -202,6 +206,11 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
202
206
  return ctx.values.get(apiResourceKindKey);
203
207
  }
204
208
 
209
+ /** The endpoint-auth step's slice of the deps: the guarded fetch the connect slice holds. */
210
+ function endpointAuthDeps(deps: McpServerControllerDeps): CompleteEndpointAuthDeps {
211
+ return { outboundFetch: deps.connect.outboundFetch, logger: deps.logger };
212
+ }
213
+
205
214
  /** Create — chain per Go buildCreatePipeline. */
206
215
  async function createMcpServer(
207
216
  deps: McpServerControllerDeps,
@@ -227,6 +236,10 @@ async function createMcpServer(
227
236
  .addStep(newCheckDuplicateStep(deps.store))
228
237
  .addStep(newBuildNewStateStep())
229
238
  .addStep(newGuardPublicVisibilityStep(deps.authorizer))
239
+ // A URL-only server is asked once whether it wants OAuth and completed
240
+ // when it does; immediately before GuardReservedLabels because the
241
+ // provenance label it stamps is one the guard diffs.
242
+ .addStep(newCompleteEndpointAuthStep(endpointAuthDeps(deps)))
230
243
  .addStep(newGuardReservedLabelsStep(deps.authorizer))
231
244
  .addStep(newNormalizeReferencesStep())
232
245
  .addStep(newPersistStep(deps.store))
@@ -275,6 +288,9 @@ async function update(
275
288
  // STORED labels and the plugin row's existence).
276
289
  .addStep(newGuardPluginManagedStep(deps.store))
277
290
  .addStep(newBuildUpdateStateStep())
291
+ // Reads the existing row LoadExisting set, to reuse a completion or to
292
+ // tell an author's own auth from the derived one (its header).
293
+ .addStep(newCompleteEndpointAuthStep(endpointAuthDeps(deps)))
278
294
  .addStep(newGuardReservedLabelsStep(deps.authorizer))
279
295
  .addStep(newValidateDefaultEnabledToolsStep())
280
296
  .addStep(newNormalizeReferencesStep())
@@ -45,7 +45,7 @@ import { tokenAuthMethodFromSpec } from "./connect.js";
45
45
  import type { McpServerConnectDeps } from "./connect.js";
46
46
  import { generatePkce } from "./oauth/pkce.js";
47
47
  import type { PkcePair } from "./oauth/pkce.js";
48
- import { discoverAuthorizationServer } from "./oauth/discovery.js";
48
+ import { discoverAtIssuer, discoverForResource } from "./oauth/discovery.js";
49
49
  import { registerClient } from "./oauth/dcr.js";
50
50
  import { preflightAuthorize } from "./oauth/preflight.js";
51
51
  import type { AuthorizeRejection } from "./oauth/preflight.js";
@@ -172,15 +172,15 @@ async function initiateDcr(
172
172
  stateParam: string,
173
173
  ): Promise<InitiateResult> {
174
174
  // Resolve the URL for OAuth authorization server discovery. Priority:
175
- // auth.discovery_url > http.url — discovery_url enables DCR for stdio
176
- // servers that have no HTTP URL.
177
- let serverUrl = mcpServer.spec?.auth?.discoveryUrl ?? "";
178
- if (serverUrl === "") {
179
- const serverType = mcpServer.spec?.serverType;
180
- if (serverType?.case === "http") {
181
- serverUrl = serverType.value.url;
182
- }
183
- }
175
+ // auth.discovery_url > http.url — discovery_url is the author naming the
176
+ // login server itself (and the only route for a stdio server, which has
177
+ // no HTTP URL) and is read as an issuer; http.url is the protected
178
+ // resource, whose login server the RFC 9728 walk finds
179
+ // (oauth/discovery.ts).
180
+ const discoveryUrl = mcpServer.spec?.auth?.discoveryUrl ?? "";
181
+ const serverType = mcpServer.spec?.serverType;
182
+ const resourceUrl = serverType?.case === "http" ? serverType.value.url : "";
183
+ const serverUrl = discoveryUrl !== "" ? discoveryUrl : resourceUrl;
184
184
  if (serverUrl === "") {
185
185
  throw failedPreconditionError(
186
186
  `DCR requires a discoverable URL. MCP server '${mcpServer.metadata?.id ?? ""}' has no http.url and no auth.discovery_url. ` +
@@ -190,7 +190,10 @@ async function initiateDcr(
190
190
 
191
191
  let metadata;
192
192
  try {
193
- metadata = await discoverAuthorizationServer(serverUrl);
193
+ metadata =
194
+ discoveryUrl !== ""
195
+ ? await discoverAtIssuer(discoveryUrl, deps.outboundFetch)
196
+ : await discoverForResource(resourceUrl, deps.outboundFetch);
194
197
  } catch (error) {
195
198
  throw failedPreconditionError(
196
199
  `OAuth authorization server discovery failed for ${serverUrl}: ${error instanceof Error ? error.message : String(error)}`,
@@ -198,8 +201,15 @@ async function initiateDcr(
198
201
  }
199
202
 
200
203
  if (metadata.registrationEndpoint === "") {
204
+ // A login server without RFC 7591 registration cannot take a client
205
+ // Stigmer mints on the spot; the sentence keeps its opening (the
206
+ // conformance suite's pin before this clause joined it) and tells the
207
+ // user the one thing that helps: an OAuth app registered with the
208
+ // vendor, referenced from the server's definition.
201
209
  throw failedPreconditionError(
202
- `MCP server at ${serverUrl} does not advertise a registration_endpoint for DCR`,
210
+ `MCP server at ${serverUrl} does not advertise a registration_endpoint for DCR: ` +
211
+ `${loginServerHost(metadata, serverUrl)} does not allow automatic client registration, so this server needs ` +
212
+ "an OAuth app registered with the vendor and referenced from its definition (auth.oauth_app_ref)",
203
213
  );
204
214
  }
205
215
 
@@ -210,6 +220,7 @@ async function initiateDcr(
210
220
  metadata.registrationEndpoint,
211
221
  deps.oauthRedirectUri,
212
222
  clientName,
223
+ deps.outboundFetch,
213
224
  );
214
225
  } catch (error) {
215
226
  throw failedPreconditionError(
@@ -239,7 +250,7 @@ async function initiateDcr(
239
250
  // Fail-open by contract: only a definite rejection blocks initiate.
240
251
  let rejection: AuthorizeRejection | undefined;
241
252
  try {
242
- rejection = await preflightAuthorize(authUrl);
253
+ rejection = await preflightAuthorize(authUrl, deps.outboundFetch);
243
254
  } catch (probeError) {
244
255
  deps.logger.debug("authorize pre-flight probe inconclusive; proceeding", {
245
256
  mcp_server_id: mcpServer.metadata?.id ?? "",
@@ -276,6 +287,19 @@ async function initiateDcr(
276
287
  };
277
288
  }
278
289
 
290
+ /** The login server a user must register with: its metadata's issuer, else its authorization endpoint, else the URL we asked. */
291
+ function loginServerHost(metadata: { issuer: string; authorizationEndpoint: string }, serverUrl: string): string {
292
+ for (const candidate of [metadata.issuer, metadata.authorizationEndpoint, serverUrl]) {
293
+ try {
294
+ const host = new URL(candidate).host;
295
+ if (host !== "") return host;
296
+ } catch {
297
+ // Not a URL; try the next candidate.
298
+ }
299
+ }
300
+ return serverUrl;
301
+ }
302
+
279
303
  async function initiateVendorOAuth(
280
304
  deps: McpServerConnectDeps,
281
305
  mcpServer: McpServer,