@stigmer/cli 3.1.6 → 3.1.7
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/package.json +5 -5
- package/resources/connect/connect.d.ts.map +1 -1
- package/resources/connect/connect.js +76 -8
- package/resources/connect/connect.js.map +1 -1
- package/resources/connect/discover.d.ts.map +1 -1
- package/resources/connect/discover.js +12 -4
- package/resources/connect/discover.js.map +1 -1
- package/resources/mcp/placeholder-resolver.d.ts +19 -0
- package/resources/mcp/placeholder-resolver.d.ts.map +1 -0
- package/resources/mcp/placeholder-resolver.js +52 -0
- package/resources/mcp/placeholder-resolver.js.map +1 -0
- package/resources/mcp/runtime-env.d.ts +16 -0
- package/resources/mcp/runtime-env.d.ts.map +1 -1
- package/resources/mcp/runtime-env.js +29 -8
- package/resources/mcp/runtime-env.js.map +1 -1
- package/src/resources/connect/__fixtures__/stdio-server.mjs +8 -3
- package/src/resources/connect/connect.integration.test.ts +91 -0
- package/src/resources/connect/connect.ts +86 -8
- package/src/resources/connect/discover.test.ts +40 -1
- package/src/resources/connect/discover.ts +16 -4
- package/src/resources/mcp/placeholder-resolver.test.ts +71 -0
- package/src/resources/mcp/placeholder-resolver.ts +63 -0
- package/src/resources/mcp/runtime-env.test.ts +35 -1
- package/src/resources/mcp/runtime-env.ts +35 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/cli",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Stigmer command-line interface — manage agents, workflows, MCP servers, skills, and executions from the terminal",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"@connectrpc/connect": "^2.0.0",
|
|
36
36
|
"@connectrpc/connect-node": "^2.1.1",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
38
|
-
"@stigmer/ink": "3.1.
|
|
39
|
-
"@stigmer/mcp-server": "3.1.
|
|
40
|
-
"@stigmer/protos": "3.1.
|
|
41
|
-
"@stigmer/sdk": "3.1.
|
|
38
|
+
"@stigmer/ink": "3.1.7",
|
|
39
|
+
"@stigmer/mcp-server": "3.1.7",
|
|
40
|
+
"@stigmer/protos": "3.1.7",
|
|
41
|
+
"@stigmer/sdk": "3.1.7",
|
|
42
42
|
"commander": "^12.1.0",
|
|
43
43
|
"fflate": "^0.8.3",
|
|
44
44
|
"ink": "^7.0.0",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../../../src/resources/connect/connect.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wDAAwD,CAAC;AAExF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,2DAA2D,CAAC;AAExG,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"connect.d.ts","sourceRoot":"","sources":["../../../src/resources/connect/connect.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wDAAwD,CAAC;AAExF,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,2DAA2D,CAAC;AAExG,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAQ1D,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;IAClC,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;CAC/B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAC3B,QAAQ,CAAC,YAAY,EAAE,sBAAsB,GAAG,SAAS,CAAC;IAC1D,iFAAiF;IACjF,QAAQ,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC;CACzC;AAED,gFAAgF;AAChF,wBAAsB,gBAAgB,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA+BpG"}
|
|
@@ -12,6 +12,7 @@ import { create } from "@bufbuild/protobuf";
|
|
|
12
12
|
import { ConnectInputSchema, GetOAuthGrantStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
13
13
|
import { UsageError } from "../../errors/index.js";
|
|
14
14
|
import { defaultRegistry } from "../../registry/index.js";
|
|
15
|
+
import { PlaceholderResolutionError } from "../mcp/placeholder-resolver.js";
|
|
15
16
|
import { buildRuntimeEnv } from "../mcp/runtime-env.js";
|
|
16
17
|
import { parseReference } from "../reference.js";
|
|
17
18
|
import { localDiscover } from "./discover.js";
|
|
@@ -21,8 +22,23 @@ export async function connectMcpServer(client, opts) {
|
|
|
21
22
|
if (opts.dryRun) {
|
|
22
23
|
if (server.spec === undefined)
|
|
23
24
|
throw new UsageError("MCP server has no spec; cannot discover capabilities");
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// An oauth_only endpoint rejects static tokens, and the OAuth token lives in
|
|
26
|
+
// the backend's managed environment — never on the caller's machine. So local
|
|
27
|
+
// discovery cannot authenticate it; say so plainly instead of failing on a 401.
|
|
28
|
+
if (isOAuthOnly(server))
|
|
29
|
+
throw oauthOnlyDryRunError(server, opts.reference);
|
|
30
|
+
try {
|
|
31
|
+
const capabilities = await localDiscover(server.spec, opts.envOverrides, opts.timeoutMs);
|
|
32
|
+
return { server, capabilities, updated: undefined };
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// A ${VAR} placeholder that could not be resolved is a configuration
|
|
36
|
+
// problem, not a discovery failure — surface it as actionable guidance
|
|
37
|
+
// instead of a raw resolver error or a cryptic subprocess crash.
|
|
38
|
+
if (err instanceof PlaceholderResolutionError)
|
|
39
|
+
throw unresolvedEnvError(server, err);
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
26
42
|
}
|
|
27
43
|
await ensureOAuthSatisfied(client, server, opts);
|
|
28
44
|
const updated = await client.mcpServer.connect(create(ConnectInputSchema, {
|
|
@@ -46,8 +62,18 @@ async function resolveMcpServer(client, reference, org) {
|
|
|
46
62
|
// flow and wait for the grant; otherwise stop with actionable guidance so
|
|
47
63
|
// scripted callers get a clean, stable failure instead of a 5-minute block.
|
|
48
64
|
async function ensureOAuthSatisfied(client, server, opts) {
|
|
49
|
-
if (!oauthRequired(server)
|
|
65
|
+
if (!oauthRequired(server))
|
|
50
66
|
return;
|
|
67
|
+
const oauthOnly = isOAuthOnly(server);
|
|
68
|
+
// A manually supplied token is a valid bypass for a normal OAuth server (many
|
|
69
|
+
// vendors also accept a PAT), but an oauth_only endpoint rejects static tokens
|
|
70
|
+
// outright — so --env cannot connect it. Fail with guidance rather than push a
|
|
71
|
+
// token the endpoint will reject.
|
|
72
|
+
if (opts.envOverrides.length > 0) {
|
|
73
|
+
if (!oauthOnly)
|
|
74
|
+
return;
|
|
75
|
+
throw oauthOnlyEnvError(server, opts.reference);
|
|
76
|
+
}
|
|
51
77
|
const status = await client.mcpServer.getOAuthGrantStatus(create(GetOAuthGrantStatusInputSchema, {
|
|
52
78
|
resourceId: server.metadata?.id ?? "",
|
|
53
79
|
org: opts.org,
|
|
@@ -55,18 +81,60 @@ async function ensureOAuthSatisfied(client, server, opts) {
|
|
|
55
81
|
if (status.connected)
|
|
56
82
|
return;
|
|
57
83
|
if (!opts.interactive)
|
|
58
|
-
throw oauthGuidanceError(server, opts.reference);
|
|
84
|
+
throw oauthGuidanceError(server, opts.reference, oauthOnly);
|
|
59
85
|
const { runOAuthFlow } = await import("./oauth.js");
|
|
60
86
|
await runOAuthFlow({ client, server, org: opts.org, backendType: opts.backendType });
|
|
61
87
|
}
|
|
62
|
-
|
|
88
|
+
// Turn a strict-resolution failure into actionable guidance. A declared-but-unset
|
|
89
|
+
// variable is the user's to provide; an undeclared one is a bug in the server
|
|
90
|
+
// definition. Both are far clearer than the cryptic subprocess crash (ENOENT on a
|
|
91
|
+
// literal "${VAR}" path) that motivated issue #141.
|
|
92
|
+
function unresolvedEnvError(server, err) {
|
|
93
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? server.metadata?.id ?? "this server";
|
|
94
|
+
const decl = server.spec?.env?.[err.variableName];
|
|
95
|
+
if (decl) {
|
|
96
|
+
const hint = decl.description !== "" ? ` (${decl.description})` : "";
|
|
97
|
+
return new UsageError(`MCP server '${slug}' needs environment variable ${err.variableName}${hint}, but it is not set.\n` +
|
|
98
|
+
`Provide it with --env ${err.variableName}=<value> or export it in your shell before running --dry-run.`);
|
|
99
|
+
}
|
|
100
|
+
const where = err.context !== undefined ? ` in its ${err.context}` : "";
|
|
101
|
+
return new UsageError(`MCP server '${slug}' references \${${err.variableName}}${where} but does not declare ${err.variableName} under spec.env.\n` +
|
|
102
|
+
`This is a problem with the server definition — declare ${err.variableName} in the server's env, or remove the placeholder.`);
|
|
103
|
+
}
|
|
104
|
+
function oauthGuidanceError(server, reference, oauthOnly) {
|
|
63
105
|
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
106
|
+
const choices = [
|
|
107
|
+
" - Re-run this command in an interactive terminal to complete OAuth in your browser",
|
|
108
|
+
];
|
|
109
|
+
// Only offer the manual-token route for servers that actually accept one.
|
|
110
|
+
// Suggesting it for an oauth_only endpoint would send the user down a dead end.
|
|
111
|
+
if (!oauthOnly) {
|
|
112
|
+
choices.push(` - Provide credentials directly: stigmer connect mcp-server ${slug} --env TOKEN=...`);
|
|
113
|
+
}
|
|
64
114
|
return new UsageError(`MCP server '${slug}' requires OAuth authentication, which needs an interactive terminal.\n\n` +
|
|
65
|
-
|
|
66
|
-
"
|
|
67
|
-
|
|
115
|
+
`To connect${oauthOnly ? "" : ", choose one of"}:\n` +
|
|
116
|
+
choices.join("\n"));
|
|
117
|
+
}
|
|
118
|
+
// An oauth_only server whose endpoint rejects static tokens was given a manual
|
|
119
|
+
// token via --env. Explain that OAuth is the only path rather than pushing a
|
|
120
|
+
// credential the endpoint will reject with an opaque 401.
|
|
121
|
+
function oauthOnlyEnvError(server, reference) {
|
|
122
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
123
|
+
return new UsageError(`MCP server '${slug}' requires OAuth and rejects manually-entered tokens, so --env cannot connect it.\n` +
|
|
124
|
+
`Re-run without --env in an interactive terminal to sign in: stigmer connect mcp-server ${slug}`);
|
|
125
|
+
}
|
|
126
|
+
// --dry-run discovers locally, but an oauth_only endpoint needs an OAuth token
|
|
127
|
+
// that only the backend can obtain and store — there is nothing valid to send
|
|
128
|
+
// from the caller's machine. Say so plainly instead of attempting a doomed 401.
|
|
129
|
+
function oauthOnlyDryRunError(server, reference) {
|
|
130
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
131
|
+
return new UsageError(`MCP server '${slug}' requires OAuth, so --dry-run cannot discover it locally — its endpoint only accepts an OAuth token that the connected backend obtains for you.\n` +
|
|
132
|
+
`Run it for real and sign in when prompted: stigmer connect mcp-server ${slug}`);
|
|
68
133
|
}
|
|
69
134
|
function oauthRequired(server) {
|
|
70
135
|
return (server.spec?.auth?.targetEnvVar ?? "") !== "";
|
|
71
136
|
}
|
|
137
|
+
function isOAuthOnly(server) {
|
|
138
|
+
return server.spec?.auth?.oauthOnly === true;
|
|
139
|
+
}
|
|
72
140
|
//# sourceMappingURL=connect.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connect.js","sourceRoot":"","sources":["../../../src/resources/connect/connect.ts"],"names":[],"mappings":"AAAA,mFAAmF;AACnF,4EAA4E;AAC5E,gFAAgF;AAChF,iEAAiE;AACjE,EAAE;AACF,+EAA+E;AAC/E,+EAA+E;AAC/E,4EAA4E;AAC5E,6EAA6E;AAC7E,8EAA8E;AAE9E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,OAAO,EAAE,kBAAkB,EAAE,8BAA8B,EAAE,MAAM,uDAAuD,CAAC;AAK3H,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAqB9C,gFAAgF;AAChF,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,MAAe,EAAE,IAAoB;IAC1E,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IAExE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS;YAAE,MAAM,IAAI,UAAU,CAAC,sDAAsD,CAAC,CAAC;QAC5G,MAAM,YAAY,GAAG,MAAM,aAAa,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"connect.js","sourceRoot":"","sources":["../../../src/resources/connect/connect.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../../src/resources/connect/discover.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yDAAyD,CAAC;AAC7F,OAAO,EACL,KAAK,sBAAsB,EAI5B,MAAM,2DAA2D,CAAC;
|
|
1
|
+
{"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../../../src/resources/connect/discover.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,yDAAyD,CAAC;AAC7F,OAAO,EACL,KAAK,sBAAsB,EAI5B,MAAM,2DAA2D,CAAC;AAOnE,uFAAuF;AACvF,wBAAsB,aAAa,CACjC,IAAI,EAAE,aAAa,EACnB,YAAY,EAAE,SAAS,MAAM,EAAE,EAC/B,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,sBAAsB,CAAC,CA0CjC"}
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { create } from "@bufbuild/protobuf";
|
|
13
13
|
import { DiscoveredCapabilitiesSchema, DiscoveredResourceTemplateSchema, DiscoveredToolSchema, } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/status_pb";
|
|
14
14
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
15
|
-
import { mergeProcessEnv } from "../mcp/runtime-env.js";
|
|
15
|
+
import { mergeProcessEnv, resolveDeclaredEnvValues } from "../mcp/runtime-env.js";
|
|
16
|
+
import { resolveHeaders, resolvePlaceholders } from "../mcp/placeholder-resolver.js";
|
|
16
17
|
/** Discover an MCP server's capabilities locally without persisting to the backend. */
|
|
17
18
|
export async function localDiscover(spec, envOverrides, timeoutMs) {
|
|
18
19
|
const { transport, readStderr } = await buildTransport(spec, envOverrides);
|
|
@@ -53,14 +54,21 @@ export async function localDiscover(spec, envOverrides, timeoutMs) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
async function buildTransport(spec, envOverrides) {
|
|
57
|
+
// ${VAR} placeholders in args/headers resolve against the exact same env the
|
|
58
|
+
// backend hands the runner for real connect (declared keys from the OS env +
|
|
59
|
+
// --env overrides) — so dry-run is a faithful preview. Resolution is strict:
|
|
60
|
+
// an unresolved placeholder throws before any subprocess is spawned, matching
|
|
61
|
+
// the proto contract (never pass a literal "${VAR}" to the server).
|
|
62
|
+
const resolutionEnv = resolveDeclaredEnvValues(spec.env ?? {}, envOverrides);
|
|
56
63
|
if (spec.serverType?.case === "stdio") {
|
|
57
64
|
const { command, args, workingDir } = spec.serverType.value;
|
|
58
65
|
if (command === "")
|
|
59
66
|
throw new Error("stdio transport requires a command");
|
|
67
|
+
const resolvedArgs = args.map((arg, i) => resolvePlaceholders(arg, resolutionEnv, `stdio arg[${i}]`));
|
|
60
68
|
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
61
69
|
const transport = new StdioClientTransport({
|
|
62
70
|
command,
|
|
63
|
-
args:
|
|
71
|
+
args: resolvedArgs,
|
|
64
72
|
cwd: workingDir !== "" ? workingDir : undefined,
|
|
65
73
|
env: mergeProcessEnv([...envOverrides, ...goRunEnvOverrides(command, args)]),
|
|
66
74
|
// "pipe" exposes the child stderr as a PassThrough immediately, so we can
|
|
@@ -78,8 +86,8 @@ async function buildTransport(spec, envOverrides) {
|
|
|
78
86
|
if (url === "")
|
|
79
87
|
throw new Error("HTTP transport requires a URL");
|
|
80
88
|
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
81
|
-
const
|
|
82
|
-
const transport = new StreamableHTTPClientTransport(new URL(url),
|
|
89
|
+
const resolvedHeaders = Object.keys(headers).length > 0 ? resolveHeaders(headers, resolutionEnv) : undefined;
|
|
90
|
+
const transport = new StreamableHTTPClientTransport(new URL(url), resolvedHeaders ? { requestInit: { headers: resolvedHeaders } } : undefined);
|
|
83
91
|
return { transport, readStderr: () => "" };
|
|
84
92
|
}
|
|
85
93
|
throw new Error("MCP server has no transport configured (expected stdio or http)");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../../../src/resources/connect/discover.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,EAAE;AACF,gFAAgF;AAChF,2EAA2E;AAC3E,iFAAiF;AACjF,gFAAgF;AAChF,4EAA4E;AAC5E,EAAE;AACF,iFAAiF;AACjF,4EAA4E;AAC5E,mDAAmD;AAEnD,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,OAAO,EAEL,4BAA4B,EAC5B,gCAAgC,EAChC,oBAAoB,GACrB,MAAM,2DAA2D,CAAC;AAEnE,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAEnE,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;
|
|
1
|
+
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../../../src/resources/connect/discover.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare class PlaceholderResolutionError extends Error {
|
|
2
|
+
readonly variableName: string;
|
|
3
|
+
readonly context?: string | undefined;
|
|
4
|
+
constructor(variableName: string, context?: string | undefined);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Resolve all ${VAR_NAME} placeholders in a template string.
|
|
8
|
+
*
|
|
9
|
+
* @throws PlaceholderResolutionError if any placeholder cannot be resolved.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolvePlaceholders(template: string, envVars: Record<string, string>, context?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Resolve placeholders in every value of a headers map.
|
|
14
|
+
*
|
|
15
|
+
* @throws PlaceholderResolutionError if any header value contains an
|
|
16
|
+
* unresolvable placeholder.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveHeaders(headers: Record<string, string>, envVars: Record<string, string>): Record<string, string>;
|
|
19
|
+
//# sourceMappingURL=placeholder-resolver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"placeholder-resolver.d.ts","sourceRoot":"","sources":["../../../src/resources/mcp/placeholder-resolver.ts"],"names":[],"mappings":"AAeA,qBAAa,0BAA2B,SAAQ,KAAK;aAEjC,YAAY,EAAE,MAAM;aACpB,OAAO,CAAC,EAAE,MAAM;gBADhB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,MAAM,YAAA;CASnC;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,CAAC,EAAE,MAAM,GACf,MAAM,CAOR;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMxB"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Strict placeholder resolver for ${VAR_NAME} syntax in MCP server configs.
|
|
2
|
+
//
|
|
3
|
+
// The MCP server spec (StdioServerConfig.args, HttpServerConfig.headers) allows
|
|
4
|
+
// values to reference declared env vars with ${VAR_NAME}. The proto contract is
|
|
5
|
+
// explicit that resolution is strict: a missing variable must produce a clear
|
|
6
|
+
// error rather than passing a literal "${VAR}" to the subprocess/server.
|
|
7
|
+
//
|
|
8
|
+
// This mirrors the runner's shared resolver (backend/services/runner/src/shared/
|
|
9
|
+
// placeholder-resolver.ts) so `connect --dry-run` (which discovers locally)
|
|
10
|
+
// behaves identically to real connect (which resolves on the runner). Kept
|
|
11
|
+
// intentionally dependency-free and semantically identical so the two can be
|
|
12
|
+
// collapsed into one shared package later without any behavior change.
|
|
13
|
+
const PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
14
|
+
export class PlaceholderResolutionError extends Error {
|
|
15
|
+
variableName;
|
|
16
|
+
context;
|
|
17
|
+
constructor(variableName, context) {
|
|
18
|
+
const where = context ? ` in ${context}` : "";
|
|
19
|
+
super(`Unresolved placeholder \${${variableName}}${where}: ` +
|
|
20
|
+
`variable is not present in the execution environment`);
|
|
21
|
+
this.variableName = variableName;
|
|
22
|
+
this.context = context;
|
|
23
|
+
this.name = "PlaceholderResolutionError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve all ${VAR_NAME} placeholders in a template string.
|
|
28
|
+
*
|
|
29
|
+
* @throws PlaceholderResolutionError if any placeholder cannot be resolved.
|
|
30
|
+
*/
|
|
31
|
+
export function resolvePlaceholders(template, envVars, context) {
|
|
32
|
+
return template.replace(PLACEHOLDER_RE, (_match, varName) => {
|
|
33
|
+
if (varName in envVars) {
|
|
34
|
+
return envVars[varName];
|
|
35
|
+
}
|
|
36
|
+
throw new PlaceholderResolutionError(varName, context);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve placeholders in every value of a headers map.
|
|
41
|
+
*
|
|
42
|
+
* @throws PlaceholderResolutionError if any header value contains an
|
|
43
|
+
* unresolvable placeholder.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveHeaders(headers, envVars) {
|
|
46
|
+
const resolved = {};
|
|
47
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
48
|
+
resolved[key] = resolvePlaceholders(value, envVars, `header "${key}"`);
|
|
49
|
+
}
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=placeholder-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"placeholder-resolver.js","sourceRoot":"","sources":["../../../src/resources/mcp/placeholder-resolver.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,EAAE;AACF,gFAAgF;AAChF,gFAAgF;AAChF,8EAA8E;AAC9E,yEAAyE;AACzE,EAAE;AACF,iFAAiF;AACjF,4EAA4E;AAC5E,2EAA2E;AAC3E,6EAA6E;AAC7E,uEAAuE;AAEvE,MAAM,cAAc,GAAG,iCAAiC,CAAC;AAEzD,MAAM,OAAO,0BAA2B,SAAQ,KAAK;IAEjC;IACA;IAFlB,YACkB,YAAoB,EACpB,OAAgB;QAEhC,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,OAAO,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9C,KAAK,CACH,6BAA6B,YAAY,IAAI,KAAK,IAAI;YACpD,sDAAsD,CACzD,CAAC;QAPc,iBAAY,GAAZ,YAAY,CAAQ;QACpB,YAAO,GAAP,OAAO,CAAS;QAOhC,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;IAC3C,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,QAAgB,EAChB,OAA+B,EAC/B,OAAgB;IAEhB,OAAO,QAAQ,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC,MAAM,EAAE,OAAe,EAAE,EAAE;QAClE,IAAI,OAAO,IAAI,OAAO,EAAE,CAAC;YACvB,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC;QAC1B,CAAC;QACD,MAAM,IAAI,0BAA0B,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,OAA+B,EAC/B,OAA+B;IAE/B,MAAM,QAAQ,GAA2B,EAAE,CAAC;IAC5C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,QAAQ,CAAC,GAAG,CAAC,GAAG,mBAAmB,CAAC,KAAK,EAAE,OAAO,EAAE,WAAW,GAAG,GAAG,CAAC,CAAC;IACzE,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
|
|
@@ -8,6 +8,22 @@ type ExecutionValue = ReturnType<typeof create<typeof ExecutionValueSchema>>;
|
|
|
8
8
|
* clear usage error rather than a silently-ignored credential.
|
|
9
9
|
*/
|
|
10
10
|
export declare function parseEnvOverrides(overrides: readonly string[]): Record<string, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the values behind a server's declared env vars: declared keys pulled
|
|
13
|
+
* from the OS environment (non-empty only), with `--env` overrides layered on
|
|
14
|
+
* top (override wins; an undeclared override is still included).
|
|
15
|
+
*
|
|
16
|
+
* This is the single source of truth for "which env value is in play". It backs
|
|
17
|
+
* both the runtime_env sent to the backend (buildRuntimeEnv) and the ${VAR}
|
|
18
|
+
* placeholder resolution done locally for `--dry-run` (discover.ts), so the two
|
|
19
|
+
* can never drift: dry-run resolves against the exact same map the backend hands
|
|
20
|
+
* the runner (minus platform-injected infra vars, which a local machine cannot
|
|
21
|
+
* know).
|
|
22
|
+
*
|
|
23
|
+
* `declarations` is the server's `spec.env` map; only its keys (the declared
|
|
24
|
+
* variable names) are read, so any map keyed by those names is accepted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveDeclaredEnvValues(declarations: Record<string, unknown>, envOverrides?: readonly string[]): Record<string, string>;
|
|
11
27
|
/**
|
|
12
28
|
* Build the runtime_env map for the Connect RPC from the OS environment and the
|
|
13
29
|
* supplied --env overrides. Only keys with a non-empty value are emitted.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-env.d.ts","sourceRoot":"","sources":["../../../src/resources/mcp/runtime-env.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,gEAAgE,CAAC;AACtG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wDAAwD,CAAC;AAGxF,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC,CAAC;AAE7E;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUtF;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,EACjB,YAAY,GAAE,SAAS,MAAM,EAAO,GACnC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,
|
|
1
|
+
{"version":3,"file":"runtime-env.d.ts","sourceRoot":"","sources":["../../../src/resources/mcp/runtime-env.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,gEAAgE,CAAC;AACtG,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wDAAwD,CAAC;AAGxF,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,MAAM,CAAC,OAAO,oBAAoB,CAAC,CAAC,CAAC;AAE7E;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUtF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,YAAY,GAAE,SAAS,MAAM,EAAO,GACnC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAaxB;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,SAAS,EACjB,YAAY,GAAE,SAAS,MAAM,EAAO,GACnC,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAUhC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvF"}
|
|
@@ -30,21 +30,42 @@ export function parseEnvOverrides(overrides) {
|
|
|
30
30
|
}
|
|
31
31
|
return result;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve the values behind a server's declared env vars: declared keys pulled
|
|
35
|
+
* from the OS environment (non-empty only), with `--env` overrides layered on
|
|
36
|
+
* top (override wins; an undeclared override is still included).
|
|
37
|
+
*
|
|
38
|
+
* This is the single source of truth for "which env value is in play". It backs
|
|
39
|
+
* both the runtime_env sent to the backend (buildRuntimeEnv) and the ${VAR}
|
|
40
|
+
* placeholder resolution done locally for `--dry-run` (discover.ts), so the two
|
|
41
|
+
* can never drift: dry-run resolves against the exact same map the backend hands
|
|
42
|
+
* the runner (minus platform-injected infra vars, which a local machine cannot
|
|
43
|
+
* know).
|
|
44
|
+
*
|
|
45
|
+
* `declarations` is the server's `spec.env` map; only its keys (the declared
|
|
46
|
+
* variable names) are read, so any map keyed by those names is accepted.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveDeclaredEnvValues(declarations, envOverrides = []) {
|
|
49
|
+
const values = {};
|
|
50
|
+
for (const key of Object.keys(declarations)) {
|
|
51
|
+
const value = process.env[key];
|
|
52
|
+
if (value !== undefined && value !== "")
|
|
53
|
+
values[key] = value;
|
|
54
|
+
}
|
|
55
|
+
for (const [key, value] of Object.entries(parseEnvOverrides(envOverrides))) {
|
|
56
|
+
values[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return values;
|
|
59
|
+
}
|
|
33
60
|
/**
|
|
34
61
|
* Build the runtime_env map for the Connect RPC from the OS environment and the
|
|
35
62
|
* supplied --env overrides. Only keys with a non-empty value are emitted.
|
|
36
63
|
*/
|
|
37
64
|
export function buildRuntimeEnv(server, envOverrides = []) {
|
|
38
65
|
const declarations = server.spec?.env ?? {};
|
|
39
|
-
const
|
|
66
|
+
const values = resolveDeclaredEnvValues(declarations, envOverrides);
|
|
40
67
|
const runtime = {};
|
|
41
|
-
for (const [key,
|
|
42
|
-
const value = process.env[key];
|
|
43
|
-
if (value !== undefined && value !== "") {
|
|
44
|
-
runtime[key] = create(ExecutionValueSchema, { value, isSecret: decl.isSecret });
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
68
|
+
for (const [key, value] of Object.entries(values)) {
|
|
48
69
|
runtime[key] = create(ExecutionValueSchema, { value, isSecret: declarations[key]?.isSecret ?? false });
|
|
49
70
|
}
|
|
50
71
|
return runtime;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-env.js","sourceRoot":"","sources":["../../../src/resources/mcp/runtime-env.ts"],"names":[],"mappings":"AAAA,gEAAgE;AAChE,EAAE;AACF,8EAA8E;AAC9E,4EAA4E;AAC5E,+EAA+E;AAC/E,2EAA2E;AAC3E,EAAE;AACF,gDAAgD;AAChD,0EAA0E;AAC1E,uEAAuE;AACvE,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAE3E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,gEAAgE,CAAC;AAEtG,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAInD;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,SAA4B;IAC5D,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;QAC9B,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC;YACZ,MAAM,IAAI,UAAU,CAAC,wBAAwB,KAAK,uBAAuB,CAAC,CAAC;QAC7E,CAAC;QACD,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;IACnD,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED
|
|
1
|
+
{"version":3,"file":"runtime-env.js","sourceRoot":"","sources":["../../../src/resources/mcp/runtime-env.ts"],"names":[],"mappings":"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"}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// Spawned as a subprocess by discover.ts via StdioClientTransport. Advertises a
|
|
4
4
|
// single tool and a single resource template using the low-level Server API
|
|
5
5
|
// (the most version-stable surface of @modelcontextprotocol/sdk). It also echoes
|
|
6
|
-
// an --env-provided value
|
|
7
|
-
//
|
|
6
|
+
// an --env-provided value AND the CLI args it received (process.argv beyond the
|
|
7
|
+
// script path) through the tool description, so tests can assert env propagation
|
|
8
|
+
// and ${VAR} argument expansion.
|
|
8
9
|
|
|
9
10
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
10
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -18,11 +19,15 @@ const server = new Server(
|
|
|
18
19
|
{ capabilities: { tools: {}, resources: {} } },
|
|
19
20
|
);
|
|
20
21
|
|
|
22
|
+
const receivedArgs = process.argv.slice(2);
|
|
23
|
+
|
|
21
24
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
22
25
|
tools: [
|
|
23
26
|
{
|
|
24
27
|
name: "echo",
|
|
25
|
-
description:
|
|
28
|
+
description:
|
|
29
|
+
`echo a message (token=${process.env.FIXTURE_TOKEN ?? "unset"}) ` +
|
|
30
|
+
`(args=${JSON.stringify(receivedArgs)})`,
|
|
26
31
|
inputSchema: { type: "object", properties: { text: { type: "string" } } },
|
|
27
32
|
},
|
|
28
33
|
{ name: "noop", description: "", inputSchema: { type: "object" } },
|
|
@@ -168,6 +168,66 @@ describe("OAuth guidance gate", () => {
|
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
+
describe("oauth_only servers reject the manual-token routes", () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
servedSpec.spec!.auth = create(McpServerAuthSchema, {
|
|
174
|
+
targetEnvVar: "GITHUB_TOKEN",
|
|
175
|
+
oauthOnly: true,
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rejects --env (which cannot satisfy an OAuth-only endpoint) instead of pushing a doomed token", async () => {
|
|
180
|
+
const err = await connectMcpServer(client, {
|
|
181
|
+
reference: "github",
|
|
182
|
+
org: "acme",
|
|
183
|
+
timeoutMs: 30_000,
|
|
184
|
+
dryRun: false,
|
|
185
|
+
envOverrides: ["GITHUB_TOKEN=ghp-x"],
|
|
186
|
+
backendType: "cloud",
|
|
187
|
+
interactive: false,
|
|
188
|
+
}).catch((e: unknown) => e);
|
|
189
|
+
|
|
190
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
191
|
+
expect((err as UsageError).message).toMatch(/requires OAuth/i);
|
|
192
|
+
// The guidance must NOT recommend the manual-token route for an oauth_only server.
|
|
193
|
+
expect((err as UsageError).message).not.toContain("--env TOKEN=");
|
|
194
|
+
expect(connectCalls).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("omits the --env suggestion from the non-interactive OAuth guidance", async () => {
|
|
198
|
+
const err = await connectMcpServer(client, {
|
|
199
|
+
reference: "github",
|
|
200
|
+
org: "acme",
|
|
201
|
+
timeoutMs: 30_000,
|
|
202
|
+
dryRun: false,
|
|
203
|
+
envOverrides: [],
|
|
204
|
+
backendType: "cloud",
|
|
205
|
+
interactive: false,
|
|
206
|
+
}).catch((e: unknown) => e);
|
|
207
|
+
|
|
208
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
209
|
+
expect((err as UsageError).message).not.toContain("--env TOKEN=");
|
|
210
|
+
expect(connectCalls).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("refuses --dry-run local discovery (no locally-obtainable OAuth token) with a clear message", async () => {
|
|
214
|
+
const err = await connectMcpServer(client, {
|
|
215
|
+
reference: "github",
|
|
216
|
+
org: "acme",
|
|
217
|
+
timeoutMs: 10_000,
|
|
218
|
+
dryRun: true,
|
|
219
|
+
envOverrides: [],
|
|
220
|
+
backendType: "cloud",
|
|
221
|
+
interactive: false,
|
|
222
|
+
}).catch((e: unknown) => e);
|
|
223
|
+
|
|
224
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
225
|
+
expect((err as UsageError).message).toMatch(/requires OAuth/i);
|
|
226
|
+
expect((err as UsageError).message).toContain("--dry-run");
|
|
227
|
+
expect(connectCalls).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
171
231
|
describe("dry-run path", () => {
|
|
172
232
|
it("discovers locally and never calls the Connect RPC", async () => {
|
|
173
233
|
const result = await connectMcpServer(client, {
|
|
@@ -192,4 +252,35 @@ describe("dry-run path", () => {
|
|
|
192
252
|
expect(text).toContain("Tools (2):");
|
|
193
253
|
expect(text).toContain("Dry run — results not saved");
|
|
194
254
|
}, 15_000);
|
|
255
|
+
|
|
256
|
+
it("maps an unresolved ${VAR} arg to actionable guidance and never calls Connect", async () => {
|
|
257
|
+
// Server references a declared env var in its args, but it is not set.
|
|
258
|
+
delete process.env.NEEDED_DIR;
|
|
259
|
+
servedSpec = create(McpServerSchema, {
|
|
260
|
+
metadata: { id: "mcp_1", name: "filesystem", slug: "filesystem", org: "acme" },
|
|
261
|
+
spec: {
|
|
262
|
+
serverType: {
|
|
263
|
+
case: "stdio",
|
|
264
|
+
value: { command: process.execPath, args: [FIXTURE, "${NEEDED_DIR}"] },
|
|
265
|
+
},
|
|
266
|
+
env: { NEEDED_DIR: { isSecret: false, description: "Root directory the server may access" } },
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const err = await connectMcpServer(client, {
|
|
271
|
+
reference: "filesystem",
|
|
272
|
+
org: "acme",
|
|
273
|
+
timeoutMs: 10_000,
|
|
274
|
+
dryRun: true,
|
|
275
|
+
envOverrides: [],
|
|
276
|
+
backendType: "cloud",
|
|
277
|
+
interactive: false,
|
|
278
|
+
}).catch((e: unknown) => e);
|
|
279
|
+
|
|
280
|
+
expect(err).toBeInstanceOf(UsageError);
|
|
281
|
+
const message = (err as UsageError).message;
|
|
282
|
+
expect(message).toContain("NEEDED_DIR");
|
|
283
|
+
expect(message).toContain("--env");
|
|
284
|
+
expect(connectCalls).toHaveLength(0);
|
|
285
|
+
}, 15_000);
|
|
195
286
|
});
|
|
@@ -18,6 +18,7 @@ import type { Stigmer } from "@stigmer/sdk";
|
|
|
18
18
|
import type { BackendType } from "../../config/config.js";
|
|
19
19
|
import { UsageError } from "../../errors/index.js";
|
|
20
20
|
import { defaultRegistry } from "../../registry/index.js";
|
|
21
|
+
import { PlaceholderResolutionError } from "../mcp/placeholder-resolver.js";
|
|
21
22
|
import { buildRuntimeEnv } from "../mcp/runtime-env.js";
|
|
22
23
|
import { parseReference } from "../reference.js";
|
|
23
24
|
import { localDiscover } from "./discover.js";
|
|
@@ -47,8 +48,20 @@ export async function connectMcpServer(client: Stigmer, opts: ConnectOptions): P
|
|
|
47
48
|
|
|
48
49
|
if (opts.dryRun) {
|
|
49
50
|
if (server.spec === undefined) throw new UsageError("MCP server has no spec; cannot discover capabilities");
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// An oauth_only endpoint rejects static tokens, and the OAuth token lives in
|
|
52
|
+
// the backend's managed environment — never on the caller's machine. So local
|
|
53
|
+
// discovery cannot authenticate it; say so plainly instead of failing on a 401.
|
|
54
|
+
if (isOAuthOnly(server)) throw oauthOnlyDryRunError(server, opts.reference);
|
|
55
|
+
try {
|
|
56
|
+
const capabilities = await localDiscover(server.spec, opts.envOverrides, opts.timeoutMs);
|
|
57
|
+
return { server, capabilities, updated: undefined };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// A ${VAR} placeholder that could not be resolved is a configuration
|
|
60
|
+
// problem, not a discovery failure — surface it as actionable guidance
|
|
61
|
+
// instead of a raw resolver error or a cryptic subprocess crash.
|
|
62
|
+
if (err instanceof PlaceholderResolutionError) throw unresolvedEnvError(server, err);
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
52
65
|
}
|
|
53
66
|
|
|
54
67
|
await ensureOAuthSatisfied(client, server, opts);
|
|
@@ -77,7 +90,18 @@ async function resolveMcpServer(client: Stigmer, reference: string, org: string)
|
|
|
77
90
|
// flow and wait for the grant; otherwise stop with actionable guidance so
|
|
78
91
|
// scripted callers get a clean, stable failure instead of a 5-minute block.
|
|
79
92
|
async function ensureOAuthSatisfied(client: Stigmer, server: McpServer, opts: ConnectOptions): Promise<void> {
|
|
80
|
-
if (!oauthRequired(server)
|
|
93
|
+
if (!oauthRequired(server)) return;
|
|
94
|
+
|
|
95
|
+
const oauthOnly = isOAuthOnly(server);
|
|
96
|
+
|
|
97
|
+
// A manually supplied token is a valid bypass for a normal OAuth server (many
|
|
98
|
+
// vendors also accept a PAT), but an oauth_only endpoint rejects static tokens
|
|
99
|
+
// outright — so --env cannot connect it. Fail with guidance rather than push a
|
|
100
|
+
// token the endpoint will reject.
|
|
101
|
+
if (opts.envOverrides.length > 0) {
|
|
102
|
+
if (!oauthOnly) return;
|
|
103
|
+
throw oauthOnlyEnvError(server, opts.reference);
|
|
104
|
+
}
|
|
81
105
|
|
|
82
106
|
const status = await client.mcpServer.getOAuthGrantStatus(
|
|
83
107
|
create(GetOAuthGrantStatusInputSchema, {
|
|
@@ -87,22 +111,76 @@ async function ensureOAuthSatisfied(client: Stigmer, server: McpServer, opts: Co
|
|
|
87
111
|
);
|
|
88
112
|
if (status.connected) return;
|
|
89
113
|
|
|
90
|
-
if (!opts.interactive) throw oauthGuidanceError(server, opts.reference);
|
|
114
|
+
if (!opts.interactive) throw oauthGuidanceError(server, opts.reference, oauthOnly);
|
|
91
115
|
|
|
92
116
|
const { runOAuthFlow } = await import("./oauth.js");
|
|
93
117
|
await runOAuthFlow({ client, server, org: opts.org, backendType: opts.backendType });
|
|
94
118
|
}
|
|
95
119
|
|
|
96
|
-
|
|
120
|
+
// Turn a strict-resolution failure into actionable guidance. A declared-but-unset
|
|
121
|
+
// variable is the user's to provide; an undeclared one is a bug in the server
|
|
122
|
+
// definition. Both are far clearer than the cryptic subprocess crash (ENOENT on a
|
|
123
|
+
// literal "${VAR}" path) that motivated issue #141.
|
|
124
|
+
function unresolvedEnvError(server: McpServer, err: PlaceholderResolutionError): UsageError {
|
|
125
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? server.metadata?.id ?? "this server";
|
|
126
|
+
const decl = server.spec?.env?.[err.variableName];
|
|
127
|
+
if (decl) {
|
|
128
|
+
const hint = decl.description !== "" ? ` (${decl.description})` : "";
|
|
129
|
+
return new UsageError(
|
|
130
|
+
`MCP server '${slug}' needs environment variable ${err.variableName}${hint}, but it is not set.\n` +
|
|
131
|
+
`Provide it with --env ${err.variableName}=<value> or export it in your shell before running --dry-run.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const where = err.context !== undefined ? ` in its ${err.context}` : "";
|
|
135
|
+
return new UsageError(
|
|
136
|
+
`MCP server '${slug}' references \${${err.variableName}}${where} but does not declare ${err.variableName} under spec.env.\n` +
|
|
137
|
+
`This is a problem with the server definition — declare ${err.variableName} in the server's env, or remove the placeholder.`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function oauthGuidanceError(server: McpServer, reference: string, oauthOnly: boolean): UsageError {
|
|
97
142
|
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
143
|
+
const choices = [
|
|
144
|
+
" - Re-run this command in an interactive terminal to complete OAuth in your browser",
|
|
145
|
+
];
|
|
146
|
+
// Only offer the manual-token route for servers that actually accept one.
|
|
147
|
+
// Suggesting it for an oauth_only endpoint would send the user down a dead end.
|
|
148
|
+
if (!oauthOnly) {
|
|
149
|
+
choices.push(` - Provide credentials directly: stigmer connect mcp-server ${slug} --env TOKEN=...`);
|
|
150
|
+
}
|
|
98
151
|
return new UsageError(
|
|
99
152
|
`MCP server '${slug}' requires OAuth authentication, which needs an interactive terminal.\n\n` +
|
|
100
|
-
|
|
101
|
-
"
|
|
102
|
-
|
|
153
|
+
`To connect${oauthOnly ? "" : ", choose one of"}:\n` +
|
|
154
|
+
choices.join("\n"),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// An oauth_only server whose endpoint rejects static tokens was given a manual
|
|
159
|
+
// token via --env. Explain that OAuth is the only path rather than pushing a
|
|
160
|
+
// credential the endpoint will reject with an opaque 401.
|
|
161
|
+
function oauthOnlyEnvError(server: McpServer, reference: string): UsageError {
|
|
162
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
163
|
+
return new UsageError(
|
|
164
|
+
`MCP server '${slug}' requires OAuth and rejects manually-entered tokens, so --env cannot connect it.\n` +
|
|
165
|
+
`Re-run without --env in an interactive terminal to sign in: stigmer connect mcp-server ${slug}`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --dry-run discovers locally, but an oauth_only endpoint needs an OAuth token
|
|
170
|
+
// that only the backend can obtain and store — there is nothing valid to send
|
|
171
|
+
// from the caller's machine. Say so plainly instead of attempting a doomed 401.
|
|
172
|
+
function oauthOnlyDryRunError(server: McpServer, reference: string): UsageError {
|
|
173
|
+
const slug = server.metadata?.slug ?? server.metadata?.name ?? reference;
|
|
174
|
+
return new UsageError(
|
|
175
|
+
`MCP server '${slug}' requires OAuth, so --dry-run cannot discover it locally — its endpoint only accepts an OAuth token that the connected backend obtains for you.\n` +
|
|
176
|
+
`Run it for real and sign in when prompted: stigmer connect mcp-server ${slug}`,
|
|
103
177
|
);
|
|
104
178
|
}
|
|
105
179
|
|
|
106
180
|
function oauthRequired(server: McpServer): boolean {
|
|
107
181
|
return (server.spec?.auth?.targetEnvVar ?? "") !== "";
|
|
108
182
|
}
|
|
183
|
+
|
|
184
|
+
function isOAuthOnly(server: McpServer): boolean {
|
|
185
|
+
return server.spec?.auth?.oauthOnly === true;
|
|
186
|
+
}
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
import { create } from "@bufbuild/protobuf";
|
|
9
9
|
import { McpServerSpecSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { describe, expect, it } from "vitest";
|
|
11
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
12
12
|
import { localDiscover } from "./discover.js";
|
|
13
|
+
import { PlaceholderResolutionError } from "../mcp/placeholder-resolver.js";
|
|
13
14
|
|
|
14
15
|
const FIXTURE = fileURLToPath(new URL("./__fixtures__/stdio-server.mjs", import.meta.url));
|
|
15
16
|
const CRASH_FIXTURE = fileURLToPath(new URL("./__fixtures__/stdio-crash.mjs", import.meta.url));
|
|
@@ -20,6 +21,15 @@ function stdioSpec(scriptPath: string) {
|
|
|
20
21
|
});
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
// A spec whose args reference ${ALLOWED_DIR}, declared under spec.env — the shape
|
|
25
|
+
// the seedpack filesystem server uses (issue #141).
|
|
26
|
+
function stdioSpecWithPlaceholderArg(scriptPath: string) {
|
|
27
|
+
return create(McpServerSpecSchema, {
|
|
28
|
+
serverType: { case: "stdio", value: { command: process.execPath, args: [scriptPath, "${ALLOWED_DIR}"] } },
|
|
29
|
+
env: { ALLOWED_DIR: { isSecret: false, description: "Directory the server may access" } },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
describe("localDiscover (stdio subprocess)", () => {
|
|
24
34
|
it("discovers tools and resource templates and converts them to proto", async () => {
|
|
25
35
|
const caps = await localDiscover(stdioSpec(FIXTURE), [], 10_000);
|
|
@@ -43,3 +53,32 @@ describe("localDiscover (stdio subprocess)", () => {
|
|
|
43
53
|
await expect(localDiscover(stdioSpec(CRASH_FIXTURE), [], 10_000)).rejects.toThrow(/fixture boom: missing CONFIG/);
|
|
44
54
|
}, 15_000);
|
|
45
55
|
});
|
|
56
|
+
|
|
57
|
+
describe("localDiscover (${VAR} argument expansion — issue #141)", () => {
|
|
58
|
+
const original = process.env;
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
process.env = original;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("expands a declared ${VAR} arg from --env into the spawned subprocess", async () => {
|
|
64
|
+
const caps = await localDiscover(stdioSpecWithPlaceholderArg(FIXTURE), ["ALLOWED_DIR=/tmp/allowed"], 10_000);
|
|
65
|
+
// The fixture echoes the argv it actually received; the resolved value must
|
|
66
|
+
// reach the subprocess, never the literal placeholder.
|
|
67
|
+
expect(caps.tools[0].description).toContain('args=["/tmp/allowed"]');
|
|
68
|
+
expect(caps.tools[0].description).not.toContain("${ALLOWED_DIR}");
|
|
69
|
+
}, 15_000);
|
|
70
|
+
|
|
71
|
+
it("expands a declared ${VAR} arg from the exported shell environment", async () => {
|
|
72
|
+
process.env = { ...original, ALLOWED_DIR: "/tmp/from-shell" };
|
|
73
|
+
const caps = await localDiscover(stdioSpecWithPlaceholderArg(FIXTURE), [], 10_000);
|
|
74
|
+
expect(caps.tools[0].description).toContain('args=["/tmp/from-shell"]');
|
|
75
|
+
}, 15_000);
|
|
76
|
+
|
|
77
|
+
it("rejects with a placeholder error before spawning when the declared var is unset", async () => {
|
|
78
|
+
process.env = { ...original };
|
|
79
|
+
delete process.env.ALLOWED_DIR;
|
|
80
|
+
await expect(localDiscover(stdioSpecWithPlaceholderArg(FIXTURE), [], 10_000)).rejects.toBeInstanceOf(
|
|
81
|
+
PlaceholderResolutionError,
|
|
82
|
+
);
|
|
83
|
+
}, 15_000);
|
|
84
|
+
});
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
import type { JsonObject } from "@bufbuild/protobuf";
|
|
22
22
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
23
23
|
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
24
|
-
import { mergeProcessEnv } from "../mcp/runtime-env.js";
|
|
24
|
+
import { mergeProcessEnv, resolveDeclaredEnvValues } from "../mcp/runtime-env.js";
|
|
25
|
+
import { resolveHeaders, resolvePlaceholders } from "../mcp/placeholder-resolver.js";
|
|
25
26
|
|
|
26
27
|
/** Discover an MCP server's capabilities locally without persisting to the backend. */
|
|
27
28
|
export async function localDiscover(
|
|
@@ -79,13 +80,21 @@ interface BuiltTransport {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
async function buildTransport(spec: McpServerSpec, envOverrides: readonly string[]): Promise<BuiltTransport> {
|
|
83
|
+
// ${VAR} placeholders in args/headers resolve against the exact same env the
|
|
84
|
+
// backend hands the runner for real connect (declared keys from the OS env +
|
|
85
|
+
// --env overrides) — so dry-run is a faithful preview. Resolution is strict:
|
|
86
|
+
// an unresolved placeholder throws before any subprocess is spawned, matching
|
|
87
|
+
// the proto contract (never pass a literal "${VAR}" to the server).
|
|
88
|
+
const resolutionEnv = resolveDeclaredEnvValues(spec.env ?? {}, envOverrides);
|
|
89
|
+
|
|
82
90
|
if (spec.serverType?.case === "stdio") {
|
|
83
91
|
const { command, args, workingDir } = spec.serverType.value;
|
|
84
92
|
if (command === "") throw new Error("stdio transport requires a command");
|
|
93
|
+
const resolvedArgs = args.map((arg, i) => resolvePlaceholders(arg, resolutionEnv, `stdio arg[${i}]`));
|
|
85
94
|
const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
86
95
|
const transport = new StdioClientTransport({
|
|
87
96
|
command,
|
|
88
|
-
args:
|
|
97
|
+
args: resolvedArgs,
|
|
89
98
|
cwd: workingDir !== "" ? workingDir : undefined,
|
|
90
99
|
env: mergeProcessEnv([...envOverrides, ...goRunEnvOverrides(command, args)]),
|
|
91
100
|
// "pipe" exposes the child stderr as a PassThrough immediately, so we can
|
|
@@ -104,8 +113,11 @@ async function buildTransport(spec: McpServerSpec, envOverrides: readonly string
|
|
|
104
113
|
const { url, headers } = spec.serverType.value;
|
|
105
114
|
if (url === "") throw new Error("HTTP transport requires a URL");
|
|
106
115
|
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
107
|
-
const
|
|
108
|
-
const transport = new StreamableHTTPClientTransport(
|
|
116
|
+
const resolvedHeaders = Object.keys(headers).length > 0 ? resolveHeaders(headers, resolutionEnv) : undefined;
|
|
117
|
+
const transport = new StreamableHTTPClientTransport(
|
|
118
|
+
new URL(url),
|
|
119
|
+
resolvedHeaders ? { requestInit: { headers: resolvedHeaders } } : undefined,
|
|
120
|
+
);
|
|
109
121
|
return { transport, readStderr: () => "" };
|
|
110
122
|
}
|
|
111
123
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Unit tests for the strict ${VAR} placeholder resolver.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { PlaceholderResolutionError, resolveHeaders, resolvePlaceholders } from "./placeholder-resolver.js";
|
|
5
|
+
|
|
6
|
+
describe("resolvePlaceholders", () => {
|
|
7
|
+
it("returns a string with no placeholders unchanged", () => {
|
|
8
|
+
expect(resolvePlaceholders("/tmp/data.sqlite", {})).toBe("/tmp/data.sqlite");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("resolves a single placeholder from the env map", () => {
|
|
12
|
+
expect(resolvePlaceholders("${DIR}", { DIR: "/home/me" })).toBe("/home/me");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("resolves a placeholder embedded in surrounding text", () => {
|
|
16
|
+
expect(resolvePlaceholders("${DB_PATH}/data.sqlite", { DB_PATH: "/var/lib" })).toBe("/var/lib/data.sqlite");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("resolves multiple and adjacent placeholders", () => {
|
|
20
|
+
expect(resolvePlaceholders("${A}${B}-${A}", { A: "x", B: "y" })).toBe("xy-x");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves to an empty string when the value is empty", () => {
|
|
24
|
+
// An empty-but-present value is a resolved value, not a missing variable.
|
|
25
|
+
expect(resolvePlaceholders("[${X}]", { X: "" })).toBe("[]");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("ignores $VAR without braces (only ${VAR} is a placeholder)", () => {
|
|
29
|
+
expect(resolvePlaceholders("$HOME/bin", { HOME: "/root" })).toBe("$HOME/bin");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("throws PlaceholderResolutionError for an unresolved variable, carrying name + context", () => {
|
|
33
|
+
let caught: unknown;
|
|
34
|
+
try {
|
|
35
|
+
resolvePlaceholders("${MISSING}", { OTHER: "1" }, "stdio arg[2]");
|
|
36
|
+
} catch (err) {
|
|
37
|
+
caught = err;
|
|
38
|
+
}
|
|
39
|
+
expect(caught).toBeInstanceOf(PlaceholderResolutionError);
|
|
40
|
+
const e = caught as PlaceholderResolutionError;
|
|
41
|
+
expect(e.variableName).toBe("MISSING");
|
|
42
|
+
expect(e.context).toBe("stdio arg[2]");
|
|
43
|
+
expect(e.message).toContain("${MISSING}");
|
|
44
|
+
expect(e.message).toContain("stdio arg[2]");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("throws on the first unresolved variable even when others resolve", () => {
|
|
48
|
+
expect(() => resolvePlaceholders("${A}-${B}", { A: "ok" })).toThrow(PlaceholderResolutionError);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("resolveHeaders", () => {
|
|
53
|
+
it("resolves placeholders in every header value", () => {
|
|
54
|
+
const resolved = resolveHeaders(
|
|
55
|
+
{ Authorization: "Bearer ${TOKEN}", "X-Static": "v1" },
|
|
56
|
+
{ TOKEN: "abc123" },
|
|
57
|
+
);
|
|
58
|
+
expect(resolved).toEqual({ Authorization: "Bearer abc123", "X-Static": "v1" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws with the offending header name in the context", () => {
|
|
62
|
+
let caught: unknown;
|
|
63
|
+
try {
|
|
64
|
+
resolveHeaders({ Authorization: "Bearer ${API_KEY}" }, {});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
caught = err;
|
|
67
|
+
}
|
|
68
|
+
expect(caught).toBeInstanceOf(PlaceholderResolutionError);
|
|
69
|
+
expect((caught as PlaceholderResolutionError).message).toContain('header "Authorization"');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Strict placeholder resolver for ${VAR_NAME} syntax in MCP server configs.
|
|
2
|
+
//
|
|
3
|
+
// The MCP server spec (StdioServerConfig.args, HttpServerConfig.headers) allows
|
|
4
|
+
// values to reference declared env vars with ${VAR_NAME}. The proto contract is
|
|
5
|
+
// explicit that resolution is strict: a missing variable must produce a clear
|
|
6
|
+
// error rather than passing a literal "${VAR}" to the subprocess/server.
|
|
7
|
+
//
|
|
8
|
+
// This mirrors the runner's shared resolver (backend/services/runner/src/shared/
|
|
9
|
+
// placeholder-resolver.ts) so `connect --dry-run` (which discovers locally)
|
|
10
|
+
// behaves identically to real connect (which resolves on the runner). Kept
|
|
11
|
+
// intentionally dependency-free and semantically identical so the two can be
|
|
12
|
+
// collapsed into one shared package later without any behavior change.
|
|
13
|
+
|
|
14
|
+
const PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
15
|
+
|
|
16
|
+
export class PlaceholderResolutionError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public readonly variableName: string,
|
|
19
|
+
public readonly context?: string,
|
|
20
|
+
) {
|
|
21
|
+
const where = context ? ` in ${context}` : "";
|
|
22
|
+
super(
|
|
23
|
+
`Unresolved placeholder \${${variableName}}${where}: ` +
|
|
24
|
+
`variable is not present in the execution environment`,
|
|
25
|
+
);
|
|
26
|
+
this.name = "PlaceholderResolutionError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve all ${VAR_NAME} placeholders in a template string.
|
|
32
|
+
*
|
|
33
|
+
* @throws PlaceholderResolutionError if any placeholder cannot be resolved.
|
|
34
|
+
*/
|
|
35
|
+
export function resolvePlaceholders(
|
|
36
|
+
template: string,
|
|
37
|
+
envVars: Record<string, string>,
|
|
38
|
+
context?: string,
|
|
39
|
+
): string {
|
|
40
|
+
return template.replace(PLACEHOLDER_RE, (_match, varName: string) => {
|
|
41
|
+
if (varName in envVars) {
|
|
42
|
+
return envVars[varName];
|
|
43
|
+
}
|
|
44
|
+
throw new PlaceholderResolutionError(varName, context);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve placeholders in every value of a headers map.
|
|
50
|
+
*
|
|
51
|
+
* @throws PlaceholderResolutionError if any header value contains an
|
|
52
|
+
* unresolvable placeholder.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveHeaders(
|
|
55
|
+
headers: Record<string, string>,
|
|
56
|
+
envVars: Record<string, string>,
|
|
57
|
+
): Record<string, string> {
|
|
58
|
+
const resolved: Record<string, string> = {};
|
|
59
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
60
|
+
resolved[key] = resolvePlaceholders(value, envVars, `header "${key}"`);
|
|
61
|
+
}
|
|
62
|
+
return resolved;
|
|
63
|
+
}
|
|
@@ -4,12 +4,18 @@ import { create } from "@bufbuild/protobuf";
|
|
|
4
4
|
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
6
|
import { UsageError } from "../../errors/index.js";
|
|
7
|
-
import { buildRuntimeEnv, mergeProcessEnv, parseEnvOverrides } from "./runtime-env.js";
|
|
7
|
+
import { buildRuntimeEnv, mergeProcessEnv, parseEnvOverrides, resolveDeclaredEnvValues } from "./runtime-env.js";
|
|
8
8
|
|
|
9
9
|
function serverWithEnv(env: Record<string, { isSecret: boolean }>) {
|
|
10
10
|
return create(McpServerSchema, { metadata: { id: "mcp_1" }, spec: { env } });
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// resolveDeclaredEnvValues takes the raw declarations map; the tests only rely on
|
|
14
|
+
// keys being present, so a minimal declaration object is sufficient.
|
|
15
|
+
function decls(...keys: string[]) {
|
|
16
|
+
return Object.fromEntries(keys.map((k) => [k, { isSecret: false }]));
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
describe("parseEnvOverrides", () => {
|
|
14
20
|
it("parses KEY=VALUE pairs, preserving '=' in the value", () => {
|
|
15
21
|
expect(parseEnvOverrides(["A=1", "B=x=y"])).toEqual({ A: "1", B: "x=y" });
|
|
@@ -66,3 +72,31 @@ describe("mergeProcessEnv", () => {
|
|
|
66
72
|
expect(merged.NEW).toBe("1");
|
|
67
73
|
});
|
|
68
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("resolveDeclaredEnvValues", () => {
|
|
77
|
+
const original = process.env;
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
process.env = { ...original, ALLOWED_DIR: "/from-os", EMPTY_VAR: "" };
|
|
80
|
+
});
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
process.env = original;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("pulls declared keys from the OS environment", () => {
|
|
86
|
+
expect(resolveDeclaredEnvValues(decls("ALLOWED_DIR"))).toEqual({ ALLOWED_DIR: "/from-os" });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("skips declared keys that are unset or empty in the environment", () => {
|
|
90
|
+
expect(resolveDeclaredEnvValues(decls("EMPTY_VAR", "MISSING"))).toEqual({});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("lets --env overrides win over the OS environment", () => {
|
|
94
|
+
expect(resolveDeclaredEnvValues(decls("ALLOWED_DIR"), ["ALLOWED_DIR=/override"])).toEqual({
|
|
95
|
+
ALLOWED_DIR: "/override",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("includes an --env override even when the key is undeclared (matches buildRuntimeEnv)", () => {
|
|
100
|
+
expect(resolveDeclaredEnvValues(decls(), ["EXTRA=1"])).toEqual({ EXTRA: "1" });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -36,6 +36,39 @@ export function parseEnvOverrides(overrides: readonly string[]): Record<string,
|
|
|
36
36
|
return result;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the values behind a server's declared env vars: declared keys pulled
|
|
41
|
+
* from the OS environment (non-empty only), with `--env` overrides layered on
|
|
42
|
+
* top (override wins; an undeclared override is still included).
|
|
43
|
+
*
|
|
44
|
+
* This is the single source of truth for "which env value is in play". It backs
|
|
45
|
+
* both the runtime_env sent to the backend (buildRuntimeEnv) and the ${VAR}
|
|
46
|
+
* placeholder resolution done locally for `--dry-run` (discover.ts), so the two
|
|
47
|
+
* can never drift: dry-run resolves against the exact same map the backend hands
|
|
48
|
+
* the runner (minus platform-injected infra vars, which a local machine cannot
|
|
49
|
+
* know).
|
|
50
|
+
*
|
|
51
|
+
* `declarations` is the server's `spec.env` map; only its keys (the declared
|
|
52
|
+
* variable names) are read, so any map keyed by those names is accepted.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveDeclaredEnvValues(
|
|
55
|
+
declarations: Record<string, unknown>,
|
|
56
|
+
envOverrides: readonly string[] = [],
|
|
57
|
+
): Record<string, string> {
|
|
58
|
+
const values: Record<string, string> = {};
|
|
59
|
+
|
|
60
|
+
for (const key of Object.keys(declarations)) {
|
|
61
|
+
const value = process.env[key];
|
|
62
|
+
if (value !== undefined && value !== "") values[key] = value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [key, value] of Object.entries(parseEnvOverrides(envOverrides))) {
|
|
66
|
+
values[key] = value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
/**
|
|
40
73
|
* Build the runtime_env map for the Connect RPC from the OS environment and the
|
|
41
74
|
* supplied --env overrides. Only keys with a non-empty value are emitted.
|
|
@@ -45,17 +78,10 @@ export function buildRuntimeEnv(
|
|
|
45
78
|
envOverrides: readonly string[] = [],
|
|
46
79
|
): Record<string, ExecutionValue> {
|
|
47
80
|
const declarations = server.spec?.env ?? {};
|
|
48
|
-
const
|
|
81
|
+
const values = resolveDeclaredEnvValues(declarations, envOverrides);
|
|
49
82
|
const runtime: Record<string, ExecutionValue> = {};
|
|
50
83
|
|
|
51
|
-
for (const [key,
|
|
52
|
-
const value = process.env[key];
|
|
53
|
-
if (value !== undefined && value !== "") {
|
|
54
|
-
runtime[key] = create(ExecutionValueSchema, { value, isSecret: decl.isSecret });
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
for (const [key, value] of Object.entries(overrides)) {
|
|
84
|
+
for (const [key, value] of Object.entries(values)) {
|
|
59
85
|
runtime[key] = create(ExecutionValueSchema, { value, isSecret: declarations[key]?.isSecret ?? false });
|
|
60
86
|
}
|
|
61
87
|
|