@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.
Files changed (93) hide show
  1. package/commands/share.d.ts +3 -0
  2. package/commands/share.d.ts.map +1 -0
  3. package/commands/share.js +75 -0
  4. package/commands/share.js.map +1 -0
  5. package/config/index.d.ts +1 -1
  6. package/config/index.d.ts.map +1 -1
  7. package/config/index.js +1 -1
  8. package/config/index.js.map +1 -1
  9. package/config/resolve.d.ts +19 -2
  10. package/config/resolve.d.ts.map +1 -1
  11. package/config/resolve.js +40 -2
  12. package/config/resolve.js.map +1 -1
  13. package/local/runtime/node.d.ts +9 -2
  14. package/local/runtime/node.d.ts.map +1 -1
  15. package/local/runtime/node.js +38 -23
  16. package/local/runtime/node.js.map +1 -1
  17. package/package.json +6 -6
  18. package/program.d.ts.map +1 -1
  19. package/program.js +2 -0
  20. package/program.js.map +1 -1
  21. package/resources/connect/connect.d.ts.map +1 -1
  22. package/resources/connect/connect.js +76 -8
  23. package/resources/connect/connect.js.map +1 -1
  24. package/resources/connect/discover.d.ts.map +1 -1
  25. package/resources/connect/discover.js +12 -4
  26. package/resources/connect/discover.js.map +1 -1
  27. package/resources/connect/oauth.d.ts +0 -9
  28. package/resources/connect/oauth.d.ts.map +1 -1
  29. package/resources/connect/oauth.js +1 -17
  30. package/resources/connect/oauth.js.map +1 -1
  31. package/resources/mcp/placeholder-resolver.d.ts +19 -0
  32. package/resources/mcp/placeholder-resolver.d.ts.map +1 -0
  33. package/resources/mcp/placeholder-resolver.js +52 -0
  34. package/resources/mcp/placeholder-resolver.js.map +1 -0
  35. package/resources/mcp/runtime-env.d.ts +16 -0
  36. package/resources/mcp/runtime-env.d.ts.map +1 -1
  37. package/resources/mcp/runtime-env.js +29 -8
  38. package/resources/mcp/runtime-env.js.map +1 -1
  39. package/resources/share.d.ts +37 -0
  40. package/resources/share.d.ts.map +1 -0
  41. package/resources/share.js +144 -0
  42. package/resources/share.js.map +1 -0
  43. package/resources/stream/convert.d.ts +6 -2
  44. package/resources/stream/convert.d.ts.map +1 -1
  45. package/resources/stream/convert.js +8 -2
  46. package/resources/stream/convert.js.map +1 -1
  47. package/resources/stream/events.d.ts +14 -2
  48. package/resources/stream/events.d.ts.map +1 -1
  49. package/resources/stream/events.js.map +1 -1
  50. package/resources/stream/render-ndjson.js +2 -0
  51. package/resources/stream/render-ndjson.js.map +1 -1
  52. package/resources/stream/render-plaintext.d.ts.map +1 -1
  53. package/resources/stream/render-plaintext.js +3 -0
  54. package/resources/stream/render-plaintext.js.map +1 -1
  55. package/resources/stream/snapshot.js +6 -1
  56. package/resources/stream/snapshot.js.map +1 -1
  57. package/resources/stream/tool-state.d.ts +1 -1
  58. package/resources/stream/tool-state.d.ts.map +1 -1
  59. package/resources/stream/tool-state.js +13 -4
  60. package/resources/stream/tool-state.js.map +1 -1
  61. package/src/commands/connect.test.ts +26 -4
  62. package/src/commands/share.ts +99 -0
  63. package/src/config/index.ts +2 -0
  64. package/src/config/resolve.test.ts +38 -0
  65. package/src/config/resolve.ts +42 -3
  66. package/src/local/runtime/node.ts +43 -25
  67. package/src/local/runtime/runtime.test.ts +5 -4
  68. package/src/program.test.ts +7 -0
  69. package/src/program.ts +2 -0
  70. package/src/resources/connect/__fixtures__/stdio-server.mjs +8 -3
  71. package/src/resources/connect/connect.integration.test.ts +91 -0
  72. package/src/resources/connect/connect.ts +86 -8
  73. package/src/resources/connect/discover.test.ts +40 -1
  74. package/src/resources/connect/discover.ts +16 -4
  75. package/src/resources/connect/oauth.test.ts +1 -24
  76. package/src/resources/connect/oauth.ts +1 -17
  77. package/src/resources/mcp/placeholder-resolver.test.ts +71 -0
  78. package/src/resources/mcp/placeholder-resolver.ts +63 -0
  79. package/src/resources/mcp/runtime-env.test.ts +35 -1
  80. package/src/resources/mcp/runtime-env.ts +35 -9
  81. package/src/resources/read-verbs.integration.test.ts +1 -1
  82. package/src/resources/share.test.ts +389 -0
  83. package/src/resources/share.ts +227 -0
  84. package/src/resources/stream/convert.ts +17 -3
  85. package/src/resources/stream/diff.test.ts +22 -0
  86. package/src/resources/stream/events.ts +15 -1
  87. package/src/resources/stream/render-ndjson.test.ts +10 -0
  88. package/src/resources/stream/render-ndjson.ts +2 -0
  89. package/src/resources/stream/render-plaintext.test.ts +8 -0
  90. package/src/resources/stream/render-plaintext.ts +3 -0
  91. package/src/resources/stream/snapshot.test.ts +14 -0
  92. package/src/resources/stream/snapshot.ts +5 -1
  93. 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 >= 20`, so a suitable Node is always
5
- // present by construction — there is deliberately no hermetic Node download
6
- // (DD-002: keep the base install lean; nothing to acquire that the host already
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
- /** Minimum Node major version the runner requires. */
16
- export const MIN_NODE_MAJOR = 20;
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, probeNodeMajor(override));
37
+ assertVersion(override, probeNodeVersion(override));
26
38
  return override;
27
39
  }
28
- // The CLI's own runtime: major is readable directly, no subprocess needed.
29
- assertVersion(process.execPath, currentMajor());
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 currentMajor(): number | null {
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 parseMajor(out.trim());
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 number.
47
- function parseMajor(version: string): number | null {
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
- return Number.isInteger(major) ? major : null;
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, major: number | null): void {
55
- if (major === null) {
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 >= ${MIN_NODE_MAJOR} runtime.`,
74
+ `Ensure ${bin} is a working Node >= ${floor} runtime.`,
58
75
  ]);
59
76
  }
60
- if (major < MIN_NODE_MAJOR) {
77
+ if (!meetsFloor(version)) {
61
78
  throw new CliExitError(
62
- `Node >= ${MIN_NODE_MAJOR} is required to run the runner (found major ${major} at ${bin})`,
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 ${MIN_NODE_MAJOR} or newer, or point STIGMER_NODE_BIN at one.`],
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 >= 20
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 >= 20", () => {
70
- expect(MIN_NODE_MAJOR).toBe(20);
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
 
@@ -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 through the tool description so tests can assert env
7
- // propagation when needed.
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: `echo a message (token=${process.env.FIXTURE_TOKEN ?? "unset"})`,
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
- const capabilities = await localDiscover(server.spec, opts.envOverrides, opts.timeoutMs);
51
- return { server, capabilities, updated: undefined };
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) || opts.envOverrides.length > 0) return;
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
- function oauthGuidanceError(server: McpServer, reference: string): UsageError {
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
- "To connect, choose one of:\n" +
101
- " - Re-run this command in an interactive terminal to complete OAuth in your browser\n" +
102
- ` - Provide credentials directly: stigmer connect mcp-server ${slug} --env TOKEN=...`,
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: [...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 requestInit = Object.keys(headers).length > 0 ? { headers: { ...headers } } : undefined;
108
- const transport = new StreamableHTTPClientTransport(new URL(url), requestInit ? { requestInit } : undefined);
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> {