@stigmer/cli 3.1.6 → 3.1.8
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/commands/share.d.ts +3 -0
- package/commands/share.d.ts.map +1 -0
- package/commands/share.js +75 -0
- package/commands/share.js.map +1 -0
- package/config/index.d.ts +1 -1
- package/config/index.d.ts.map +1 -1
- package/config/index.js +1 -1
- package/config/index.js.map +1 -1
- package/config/resolve.d.ts +19 -2
- package/config/resolve.d.ts.map +1 -1
- package/config/resolve.js +40 -2
- package/config/resolve.js.map +1 -1
- package/local/runtime/node.d.ts +9 -2
- package/local/runtime/node.d.ts.map +1 -1
- package/local/runtime/node.js +38 -23
- package/local/runtime/node.js.map +1 -1
- package/package.json +6 -6
- package/program.d.ts.map +1 -1
- package/program.js +2 -0
- package/program.js.map +1 -1
- 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/connect/oauth.d.ts +0 -9
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +1 -17
- package/resources/connect/oauth.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/resources/share.d.ts +37 -0
- package/resources/share.d.ts.map +1 -0
- package/resources/share.js +144 -0
- package/resources/share.js.map +1 -0
- package/resources/stream/convert.d.ts +6 -2
- package/resources/stream/convert.d.ts.map +1 -1
- package/resources/stream/convert.js +8 -2
- package/resources/stream/convert.js.map +1 -1
- package/resources/stream/events.d.ts +14 -2
- package/resources/stream/events.d.ts.map +1 -1
- package/resources/stream/events.js.map +1 -1
- package/resources/stream/render-ndjson.js +2 -0
- package/resources/stream/render-ndjson.js.map +1 -1
- package/resources/stream/render-plaintext.d.ts.map +1 -1
- package/resources/stream/render-plaintext.js +3 -0
- package/resources/stream/render-plaintext.js.map +1 -1
- package/resources/stream/snapshot.js +6 -1
- package/resources/stream/snapshot.js.map +1 -1
- package/resources/stream/tool-state.d.ts +1 -1
- package/resources/stream/tool-state.d.ts.map +1 -1
- package/resources/stream/tool-state.js +13 -4
- package/resources/stream/tool-state.js.map +1 -1
- package/src/commands/connect.test.ts +26 -4
- package/src/commands/share.ts +99 -0
- package/src/config/index.ts +2 -0
- package/src/config/resolve.test.ts +38 -0
- package/src/config/resolve.ts +42 -3
- package/src/local/runtime/node.ts +43 -25
- package/src/local/runtime/runtime.test.ts +5 -4
- package/src/program.test.ts +7 -0
- package/src/program.ts +2 -0
- 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/connect/oauth.test.ts +1 -24
- package/src/resources/connect/oauth.ts +1 -17
- 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/src/resources/read-verbs.integration.test.ts +1 -1
- package/src/resources/share.test.ts +389 -0
- package/src/resources/share.ts +227 -0
- package/src/resources/stream/convert.ts +17 -3
- package/src/resources/stream/diff.test.ts +22 -0
- package/src/resources/stream/events.ts +15 -1
- package/src/resources/stream/render-ndjson.test.ts +10 -0
- package/src/resources/stream/render-ndjson.ts +2 -0
- package/src/resources/stream/render-plaintext.test.ts +8 -0
- package/src/resources/stream/render-plaintext.ts +3 -0
- package/src/resources/stream/snapshot.test.ts +14 -0
- package/src/resources/stream/snapshot.ts +5 -1
- package/src/resources/stream/tool-state.ts +13 -4
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Resolution of the Node.js runtime used to launch the runner subprocess.
|
|
2
2
|
//
|
|
3
3
|
// We reuse the very Node that is running the CLI (process.execPath). The CLI is
|
|
4
|
-
// itself an npm package with `engines: node >=
|
|
5
|
-
// present by construction — there is deliberately no hermetic Node
|
|
6
|
-
// (DD-002: keep the base install lean; nothing to acquire that the host
|
|
7
|
-
// guarantees). This is exactly what the conformance harness does
|
|
4
|
+
// itself an npm package with `engines: node >= 22.13`, so a suitable Node is
|
|
5
|
+
// always present by construction — there is deliberately no hermetic Node
|
|
6
|
+
// download (DD-002: keep the base install lean; nothing to acquire that the host
|
|
7
|
+
// already guarantees). This is exactly what the conformance harness does
|
|
8
8
|
// (`spawn(process.execPath, ...)`). An explicit STIGMER_NODE_BIN override is
|
|
9
9
|
// honored and version-checked for advanced/multi-runtime setups.
|
|
10
10
|
|
|
@@ -12,8 +12,20 @@ import { execFileSync } from "node:child_process";
|
|
|
12
12
|
import { CliExitError } from "../../errors/cli-exit-error.js";
|
|
13
13
|
import { ExitCode } from "../../errors/exit-codes.js";
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Minimum Node version the runner requires: 22.13. The runner's durable local
|
|
17
|
+
* checkpointer imports Node's built-in `node:sqlite`, which is only available
|
|
18
|
+
* WITHOUT the --experimental-sqlite flag from v22.13 (and v23.4) onward. A
|
|
19
|
+
* major-only gate would let 22.0-22.12 pass and then crash at checkpointer
|
|
20
|
+
* creation, so the minor is enforced when the major is exactly 22.
|
|
21
|
+
*/
|
|
22
|
+
export const MIN_NODE_MAJOR = 22;
|
|
23
|
+
export const MIN_NODE_MINOR_ON_MAJOR = 13;
|
|
24
|
+
|
|
25
|
+
interface NodeVersion {
|
|
26
|
+
major: number;
|
|
27
|
+
minor: number;
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
/**
|
|
19
31
|
* Resolve the Node binary to launch the runner with. Honors STIGMER_NODE_BIN
|
|
@@ -22,46 +34,52 @@ export const MIN_NODE_MAJOR = 20;
|
|
|
22
34
|
export function resolveNode(): string {
|
|
23
35
|
const override = process.env.STIGMER_NODE_BIN;
|
|
24
36
|
if (override !== undefined && override !== "") {
|
|
25
|
-
assertVersion(override,
|
|
37
|
+
assertVersion(override, probeNodeVersion(override));
|
|
26
38
|
return override;
|
|
27
39
|
}
|
|
28
|
-
// The CLI's own runtime:
|
|
29
|
-
assertVersion(process.execPath,
|
|
40
|
+
// The CLI's own runtime: version is readable directly, no subprocess needed.
|
|
41
|
+
assertVersion(process.execPath, parseVersion(process.versions.node));
|
|
30
42
|
return process.execPath;
|
|
31
43
|
}
|
|
32
44
|
|
|
33
|
-
function
|
|
34
|
-
return parseMajor(process.versions.node);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function probeNodeMajor(bin: string): number | null {
|
|
45
|
+
function probeNodeVersion(bin: string): NodeVersion | null {
|
|
38
46
|
try {
|
|
39
47
|
const out = execFileSync(bin, ["--version"], { encoding: "utf8" });
|
|
40
|
-
return
|
|
48
|
+
return parseVersion(out.trim());
|
|
41
49
|
} catch {
|
|
42
50
|
return null;
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// Parses "v22.22.2" or "22.22.2" to its major
|
|
47
|
-
function
|
|
48
|
-
const match = /^v?(\d+)\./.exec(version.trim());
|
|
54
|
+
// Parses "v22.22.2" or "22.22.2" to its major/minor numbers.
|
|
55
|
+
function parseVersion(version: string): NodeVersion | null {
|
|
56
|
+
const match = /^v?(\d+)\.(\d+)\./.exec(version.trim());
|
|
49
57
|
if (match === null) return null;
|
|
50
58
|
const major = Number.parseInt(match[1], 10);
|
|
51
|
-
|
|
59
|
+
const minor = Number.parseInt(match[2], 10);
|
|
60
|
+
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null;
|
|
61
|
+
return { major, minor };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// True when the version is at or above the 22.13 floor.
|
|
65
|
+
function meetsFloor(v: NodeVersion): boolean {
|
|
66
|
+
if (v.major > MIN_NODE_MAJOR) return true;
|
|
67
|
+
return v.major === MIN_NODE_MAJOR && v.minor >= MIN_NODE_MINOR_ON_MAJOR;
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
function assertVersion(bin: string,
|
|
55
|
-
|
|
70
|
+
function assertVersion(bin: string, version: NodeVersion | null): void {
|
|
71
|
+
const floor = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR_ON_MAJOR}`;
|
|
72
|
+
if (version === null) {
|
|
56
73
|
throw new CliExitError(`could not determine the Node version of ${bin}`, ExitCode.General, [
|
|
57
|
-
`Ensure ${bin} is a working Node >= ${
|
|
74
|
+
`Ensure ${bin} is a working Node >= ${floor} runtime.`,
|
|
58
75
|
]);
|
|
59
76
|
}
|
|
60
|
-
if (
|
|
77
|
+
if (!meetsFloor(version)) {
|
|
61
78
|
throw new CliExitError(
|
|
62
|
-
`Node >= ${
|
|
79
|
+
`Node >= ${floor} is required to run the runner ` +
|
|
80
|
+
`(found ${version.major}.${version.minor} at ${bin})`,
|
|
63
81
|
ExitCode.General,
|
|
64
|
-
[`Upgrade Node to ${
|
|
82
|
+
[`Upgrade Node to ${floor} or newer, or point STIGMER_NODE_BIN at one.`],
|
|
65
83
|
);
|
|
66
84
|
}
|
|
67
85
|
}
|
|
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { CliExitError } from "../../errors/cli-exit-error.js";
|
|
6
|
-
import { MIN_NODE_MAJOR, resolveNode } from "./node.js";
|
|
6
|
+
import { MIN_NODE_MAJOR, MIN_NODE_MINOR_ON_MAJOR, resolveNode } from "./node.js";
|
|
7
7
|
import { acquireRunner, resolveRunner } from "./runner.js";
|
|
8
8
|
import { resolveServerBinary } from "./server.js";
|
|
9
9
|
import { which } from "./which.js";
|
|
@@ -57,7 +57,7 @@ describe("resolveNode", () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it("honors a valid override", () => {
|
|
60
|
-
process.env.STIGMER_NODE_BIN = process.execPath; // a real Node >=
|
|
60
|
+
process.env.STIGMER_NODE_BIN = process.execPath; // a real Node >= 22.13
|
|
61
61
|
expect(resolveNode()).toBe(process.execPath);
|
|
62
62
|
});
|
|
63
63
|
|
|
@@ -66,8 +66,9 @@ describe("resolveNode", () => {
|
|
|
66
66
|
expect(() => resolveNode()).toThrow(CliExitError);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
it("requires Node >=
|
|
70
|
-
expect(MIN_NODE_MAJOR).toBe(
|
|
69
|
+
it("requires Node >= 22.13 (the node:sqlite floor)", () => {
|
|
70
|
+
expect(MIN_NODE_MAJOR).toBe(22);
|
|
71
|
+
expect(MIN_NODE_MINOR_ON_MAJOR).toBe(13);
|
|
71
72
|
});
|
|
72
73
|
});
|
|
73
74
|
|
package/src/program.test.ts
CHANGED
|
@@ -43,6 +43,13 @@ describe("buildProgram", () => {
|
|
|
43
43
|
expect(message?.required).toBe(true);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
it("exposes the share subcommands", () => {
|
|
47
|
+
const program = buildProgram();
|
|
48
|
+
const share = program.commands.find((command) => command.name() === "share");
|
|
49
|
+
const subs = share?.commands.map((command) => command.name());
|
|
50
|
+
expect(subs).toEqual(["agent"]);
|
|
51
|
+
});
|
|
52
|
+
|
|
46
53
|
it("exposes the usage subcommands", () => {
|
|
47
54
|
const program = buildProgram();
|
|
48
55
|
const usage = program.commands.find((command) => command.name() === "usage");
|
package/src/program.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { registerRun } from "./commands/run.js";
|
|
|
31
31
|
import { registerSearch } from "./commands/search.js";
|
|
32
32
|
import { registerSeedpack } from "./commands/seedpack.js";
|
|
33
33
|
import { registerSetup } from "./commands/setup.js";
|
|
34
|
+
import { registerShare } from "./commands/share.js";
|
|
34
35
|
import { registerStatus } from "./commands/status.js";
|
|
35
36
|
import { registerTag } from "./commands/tag.js";
|
|
36
37
|
import { registerUp } from "./commands/up.js";
|
|
@@ -76,6 +77,7 @@ export function buildProgram(): Command {
|
|
|
76
77
|
registerValidate(program);
|
|
77
78
|
registerDelete(program);
|
|
78
79
|
registerTag(program);
|
|
80
|
+
registerShare(program);
|
|
79
81
|
registerDiff(program);
|
|
80
82
|
registerUsage(program);
|
|
81
83
|
registerPush(program);
|
|
@@ -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
|
|
|
@@ -3,14 +3,7 @@ import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1
|
|
|
3
3
|
import type { Stigmer } from "@stigmer/sdk";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { UsageError } from "../../errors/index.js";
|
|
6
|
-
import {
|
|
7
|
-
browserCommand,
|
|
8
|
-
DEFAULT_CLOUD_CONSOLE_URL,
|
|
9
|
-
resolveConsoleURL,
|
|
10
|
-
runOAuthFlow,
|
|
11
|
-
waitForOAuthGrant,
|
|
12
|
-
type OAuthFlowDeps,
|
|
13
|
-
} from "./oauth.js";
|
|
6
|
+
import { browserCommand, runOAuthFlow, waitForOAuthGrant, type OAuthFlowDeps } from "./oauth.js";
|
|
14
7
|
|
|
15
8
|
const server = create(McpServerSchema, {
|
|
16
9
|
metadata: { id: "mcp_1", slug: "github", name: "GitHub" },
|
|
@@ -33,22 +26,6 @@ function fakeClient(connectOnCall: number): { client: Stigmer; calls: () => numb
|
|
|
33
26
|
|
|
34
27
|
const noopSleep = async (): Promise<void> => {};
|
|
35
28
|
|
|
36
|
-
describe("resolveConsoleURL", () => {
|
|
37
|
-
it("prefers the STIGMER_CONSOLE_URL override", () => {
|
|
38
|
-
expect(resolveConsoleURL("cloud", { STIGMER_CONSOLE_URL: "https://console.example" } as NodeJS.ProcessEnv)).toBe(
|
|
39
|
-
"https://console.example",
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("uses the local web-console port for the local backend", () => {
|
|
44
|
-
expect(resolveConsoleURL("local", {} as NodeJS.ProcessEnv)).toBe("http://localhost:8234");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("uses the cloud console URL for the cloud backend", () => {
|
|
48
|
-
expect(resolveConsoleURL("cloud", {} as NodeJS.ProcessEnv)).toBe(DEFAULT_CLOUD_CONSOLE_URL);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
29
|
describe("browserCommand", () => {
|
|
53
30
|
it("maps each supported platform to its opener", () => {
|
|
54
31
|
expect(browserCommand("darwin", "https://x")).toEqual(["open", ["https://x"]]);
|
|
@@ -16,11 +16,8 @@ import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/
|
|
|
16
16
|
import { GetOAuthGrantStatusInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
|
|
17
17
|
import type { Stigmer } from "@stigmer/sdk";
|
|
18
18
|
import type { BackendType } from "../../config/config.js";
|
|
19
|
+
import { resolveConsoleURL } from "../../config/index.js";
|
|
19
20
|
import { UsageError } from "../../errors/index.js";
|
|
20
|
-
import { WEB_CONSOLE_PORT } from "../../local/constants.js";
|
|
21
|
-
|
|
22
|
-
/** Well-known URL for the Stigmer Cloud web console. */
|
|
23
|
-
export const DEFAULT_CLOUD_CONSOLE_URL = "https://app.stigmer.ai";
|
|
24
21
|
|
|
25
22
|
const POLL_INTERVAL_MS = 3000;
|
|
26
23
|
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -109,19 +106,6 @@ export async function checkOAuthGrant(client: Stigmer, mcpServerId: string, org:
|
|
|
109
106
|
return status.connected;
|
|
110
107
|
}
|
|
111
108
|
|
|
112
|
-
/**
|
|
113
|
-
* Resolve the web console URL:
|
|
114
|
-
* 1. `STIGMER_CONSOLE_URL` (explicit override)
|
|
115
|
-
* 2. local backend → `http://localhost:{WEB_CONSOLE_PORT}`
|
|
116
|
-
* 3. cloud backend → {@link DEFAULT_CLOUD_CONSOLE_URL}
|
|
117
|
-
*/
|
|
118
|
-
export function resolveConsoleURL(backendType: BackendType, env: NodeJS.ProcessEnv = process.env): string {
|
|
119
|
-
const override = env.STIGMER_CONSOLE_URL;
|
|
120
|
-
if (override !== undefined && override !== "") return override;
|
|
121
|
-
if (backendType === "local") return `http://localhost:${WEB_CONSOLE_PORT}`;
|
|
122
|
-
return DEFAULT_CLOUD_CONSOLE_URL;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
109
|
// Probe the local web console with a short-timeout GET. Any HTTP response (even
|
|
126
110
|
// an error status) means it's serving; a network/timeout failure means it isn't.
|
|
127
111
|
async function probeWebConsole(url: string): Promise<boolean> {
|