@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.
- package/README.md +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +42 -6
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +6 -2
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js.map +1 -1
- package/dist/boot/oauth-redirect-uri.d.ts +54 -0
- package/dist/boot/oauth-redirect-uri.d.ts.map +1 -0
- package/dist/boot/oauth-redirect-uri.js +37 -0
- package/dist/boot/oauth-redirect-uri.js.map +1 -0
- package/dist/domain/agentexecution/lifecycle.js +1 -1
- package/dist/domain/agentexecution/lifecycle.js.map +1 -1
- package/dist/domain/mcpserver/complete-endpoint-auth.d.ts +16 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.d.ts.map +1 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.js +260 -0
- package/dist/domain/mcpserver/complete-endpoint-auth.js.map +1 -0
- package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/connect.d.ts +25 -0
- package/dist/domain/mcpserver/connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/connect.js +8 -23
- package/dist/domain/mcpserver/connect.js.map +1 -1
- package/dist/domain/mcpserver/controller.d.ts.map +1 -1
- package/dist/domain/mcpserver/controller.js +12 -0
- package/dist/domain/mcpserver/controller.js.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.js +38 -14
- package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/oauth/dcr.d.ts +10 -1
- package/dist/domain/mcpserver/oauth/dcr.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/dcr.js +1 -9
- package/dist/domain/mcpserver/oauth/dcr.js.map +1 -1
- package/dist/domain/mcpserver/oauth/discovery.d.ts +43 -20
- package/dist/domain/mcpserver/oauth/discovery.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/discovery.js +69 -54
- package/dist/domain/mcpserver/oauth/discovery.js.map +1 -1
- package/dist/domain/mcpserver/oauth/preflight.d.ts +27 -1
- package/dist/domain/mcpserver/oauth/preflight.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/preflight.js +1 -26
- package/dist/domain/mcpserver/oauth/preflight.js.map +1 -1
- package/dist/domain/mcpserver/oauth/refresh.d.ts +2 -1
- package/dist/domain/mcpserver/oauth/refresh.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/refresh.js +1 -1
- package/dist/domain/mcpserver/oauth/refresh.js.map +1 -1
- package/dist/domain/mcpserver/oauth/token.d.ts +9 -2
- package/dist/domain/mcpserver/oauth/token.d.ts.map +1 -1
- package/dist/domain/mcpserver/oauth/token.js +2 -8
- package/dist/domain/mcpserver/oauth/token.js.map +1 -1
- package/dist/domain/workflowexecution/lifecycle.js +1 -1
- package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
- package/dist/extensions/drivers.d.ts +17 -0
- package/dist/extensions/drivers.d.ts.map +1 -1
- package/dist/extensions/outbound-egress.d.ts +49 -0
- package/dist/extensions/outbound-egress.d.ts.map +1 -0
- package/dist/extensions/outbound-egress.js +51 -0
- package/dist/extensions/outbound-egress.js.map +1 -0
- package/dist/extensions/registry.d.ts +6 -0
- package/dist/extensions/registry.d.ts.map +1 -1
- package/dist/extensions/registry.js +10 -0
- package/dist/extensions/registry.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/pipeline/apiresource-labels.d.ts +14 -0
- package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
- package/dist/pipeline/apiresource-labels.js +14 -0
- package/dist/pipeline/apiresource-labels.js.map +1 -1
- package/dist/sandbox/kubernetes.d.ts.map +1 -1
- package/dist/sandbox/kubernetes.js +8 -1
- package/dist/sandbox/kubernetes.js.map +1 -1
- package/dist/sandbox/provisioner.d.ts +20 -5
- package/dist/sandbox/provisioner.d.ts.map +1 -1
- package/dist/sandbox/provisioner.js.map +1 -1
- package/dist/sandbox/steps.d.ts +22 -8
- package/dist/sandbox/steps.d.ts.map +1 -1
- package/dist/sandbox/steps.js +17 -12
- package/dist/sandbox/steps.js.map +1 -1
- package/package.json +6 -5
- package/src/boot/__tests__/oauth-redirect-uri.test.ts +38 -0
- package/src/boot/compose.ts +47 -8
- package/src/boot/config.ts +6 -2
- package/src/boot/oauth-redirect-uri.ts +53 -0
- package/src/domain/agentexecution/lifecycle.ts +1 -1
- package/src/domain/mcpserver/__tests__/complete-endpoint-auth.test.ts +270 -0
- package/src/domain/mcpserver/__tests__/connect-authorization.test.ts +3 -0
- package/src/domain/mcpserver/__tests__/connect.test.ts +31 -0
- package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +1 -1
- package/src/domain/mcpserver/complete-endpoint-auth.ts +277 -0
- package/src/domain/mcpserver/complete-oauth-connect.ts +1 -0
- package/src/domain/mcpserver/connect.ts +21 -15
- package/src/domain/mcpserver/controller.ts +16 -0
- package/src/domain/mcpserver/initiate-oauth-connect.ts +37 -13
- package/src/domain/mcpserver/oauth/__tests__/discovery.test.ts +120 -91
- package/src/domain/mcpserver/oauth/__tests__/token-refresh.test.ts +3 -1
- package/src/domain/mcpserver/oauth/dcr.ts +2 -1
- package/src/domain/mcpserver/oauth/discovery.ts +120 -89
- package/src/domain/mcpserver/oauth/preflight.ts +2 -1
- package/src/domain/mcpserver/oauth/refresh.ts +2 -1
- package/src/domain/mcpserver/oauth/token.ts +4 -3
- package/src/domain/workflowexecution/lifecycle.ts +1 -1
- package/src/extensions/__tests__/outbound-egress-points.test.ts +58 -0
- package/src/extensions/drivers.ts +17 -0
- package/src/extensions/outbound-egress.ts +55 -0
- package/src/extensions/registry.ts +19 -0
- package/src/index.ts +10 -0
- package/src/pipeline/apiresource-labels.ts +16 -0
- package/src/sandbox/__tests__/docker.integration.test.ts +2 -0
- package/src/sandbox/__tests__/kubernetes.test.ts +10 -2
- package/src/sandbox/__tests__/local-process.integration.test.ts +3 -0
- package/src/sandbox/__tests__/steps.test.ts +72 -19
- package/src/sandbox/kubernetes.ts +8 -1
- package/src/sandbox/provisioner.ts +20 -5
- 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
|
+
}
|
|
@@ -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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
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 {
|
|
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
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 =
|
|
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,
|