@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.
- 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/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 +16 -13
- 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/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/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/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/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 +16 -12
- 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/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/boot/compose.ts
CHANGED
|
@@ -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
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
//
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
|
1455
|
+
oauthRedirectUri,
|
|
1456
|
+
outboundFetch,
|
|
1418
1457
|
},
|
|
1419
1458
|
});
|
|
1420
1459
|
registerSkillServices(router, {
|
package/src/boot/config.ts
CHANGED
|
@@ -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
|
|
113
|
-
*
|
|
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
|
});
|