@stigmer/cli 3.1.5 → 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.
Files changed (40) hide show
  1. package/commands/apply.js +1 -1
  2. package/commands/apply.js.map +1 -1
  3. package/commands/connect.js +11 -0
  4. package/commands/connect.js.map +1 -1
  5. package/package.json +5 -5
  6. package/resources/apply/declarative.js +1 -1
  7. package/resources/apply/declarative.js.map +1 -1
  8. package/resources/apply/discovery.d.ts +9 -2
  9. package/resources/apply/discovery.d.ts.map +1 -1
  10. package/resources/apply/discovery.js +10 -2
  11. package/resources/apply/discovery.js.map +1 -1
  12. package/resources/connect/connect.d.ts.map +1 -1
  13. package/resources/connect/connect.js +77 -8
  14. package/resources/connect/connect.js.map +1 -1
  15. package/resources/connect/discover.d.ts.map +1 -1
  16. package/resources/connect/discover.js +12 -4
  17. package/resources/connect/discover.js.map +1 -1
  18. package/resources/mcp/placeholder-resolver.d.ts +19 -0
  19. package/resources/mcp/placeholder-resolver.d.ts.map +1 -0
  20. package/resources/mcp/placeholder-resolver.js +52 -0
  21. package/resources/mcp/placeholder-resolver.js.map +1 -0
  22. package/resources/mcp/runtime-env.d.ts +16 -0
  23. package/resources/mcp/runtime-env.d.ts.map +1 -1
  24. package/resources/mcp/runtime-env.js +29 -8
  25. package/resources/mcp/runtime-env.js.map +1 -1
  26. package/src/commands/apply.ts +1 -1
  27. package/src/commands/connect.test.ts +73 -0
  28. package/src/commands/connect.ts +14 -0
  29. package/src/resources/apply/declarative.ts +1 -1
  30. package/src/resources/apply/discovery.integration.test.ts +99 -0
  31. package/src/resources/apply/discovery.ts +10 -1
  32. package/src/resources/connect/__fixtures__/stdio-server.mjs +8 -3
  33. package/src/resources/connect/connect.integration.test.ts +103 -2
  34. package/src/resources/connect/connect.ts +87 -8
  35. package/src/resources/connect/discover.test.ts +40 -1
  36. package/src/resources/connect/discover.ts +16 -4
  37. package/src/resources/mcp/placeholder-resolver.test.ts +71 -0
  38. package/src/resources/mcp/placeholder-resolver.ts +63 -0
  39. package/src/resources/mcp/runtime-env.test.ts +35 -1
  40. package/src/resources/mcp/runtime-env.ts +35 -9
@@ -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 overrides = parseEnvOverrides(envOverrides);
66
+ const values = resolveDeclaredEnvValues(declarations, envOverrides);
40
67
  const runtime = {};
41
- for (const [key, decl] of Object.entries(declarations)) {
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;;;GAGG;AACH,MAAM,UAAU,eAAe,CAC7B,MAAiB,EACjB,eAAkC,EAAE;IAEpC,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,EAAE,GAAG,IAAI,EAAE,CAAC;IAC5C,MAAM,SAAS,GAAG,iBAAiB,CAAC,YAAY,CAAC,CAAC;IAClD,MAAM,OAAO,GAAmC,EAAE,CAAC;IAEnD,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACvD,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE,EAAE,CAAC;YACxC,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,oBAAoB,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;QAClF,CAAC;IACH,CAAC;IAED,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;QACrD,OAAO,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,oBAAoB,EAAE,EAAE,KAAK,EAAE,QAAQ,EAAE,YAAY,CAAC,GAAG,CAAC,EAAE,QAAQ,IAAI,KAAK,EAAE,CAAC,CAAC;IACzG,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,YAA+B;IAC7D,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QACvD,IAAI,KAAK,KAAK,SAAS;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC/C,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,iBAAiB,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC;QAC3E,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACtB,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC"}
1
+ {"version":3,"file":"runtime-env.js","sourceRoot":"","sources":["../../../src/resources/mcp/runtime-env.ts"],"names":[],"mappings":"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"}
@@ -184,7 +184,7 @@ async function runFileApply(
184
184
  if (outcome.appliedMcpServer !== undefined) appliedMcpServers.push(outcome.appliedMcpServer);
185
185
  }
186
186
 
187
- await discoverAppliedMcpServers(client.stigmer, appliedMcpServers, (line) => process.stderr.write(`${line}\n`));
187
+ await discoverAppliedMcpServers(client.stigmer, appliedMcpServers, org, (line) => process.stderr.write(`${line}\n`));
188
188
  }
189
189
 
190
190
  function buildAtomicGuidance(): CommandResult {
@@ -0,0 +1,73 @@
1
+ // Command-level contract for the `connect mcp-server` org guard (issue #140).
2
+ //
3
+ // Connecting pushes to the backend, which requires an org. Rather than let the
4
+ // backend reject the request with the cryptic "org value length must be at least
5
+ // 1" validation error, the command fails fast with actionable guidance — but
6
+ // only when it is actually going to push. `--dry-run` discovers locally and must
7
+ // stay usable with no org configured.
8
+ //
9
+ // Runs in standalone mode so `load()` returns the default (local, org-less)
10
+ // config without reading the user's real config file. The guard runs before any
11
+ // network call, so the non-dry-run case is fully deterministic and offline.
12
+
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { classify, ExitCode } from "../errors/index.js";
15
+ import { buildProgram } from "../program.js";
16
+
17
+ interface RunOutcome {
18
+ readonly exitCode: number;
19
+ readonly message: string;
20
+ }
21
+
22
+ // Runs `connect mcp-server <ref> [flags]` in standalone mode with output
23
+ // suppressed, returning the thrown error's classified exit code and message (or
24
+ // a success sentinel). `--standalone` is a program-global flag, so it must
25
+ // precede the subcommand (commander's enablePositionalOptions).
26
+ async function runConnect(ref: string, ...flags: string[]): Promise<RunOutcome> {
27
+ const program = buildProgram();
28
+ program.exitOverride();
29
+ const outSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true);
30
+ const errSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
31
+ try {
32
+ await program.parseAsync(["node", "stigmer", "--standalone", "connect", "mcp-server", ref, ...flags]);
33
+ return { exitCode: ExitCode.Success, message: "" };
34
+ } catch (err) {
35
+ return { exitCode: classify(err)?.exitCode ?? -1, message: err instanceof Error ? err.message : String(err) };
36
+ } finally {
37
+ outSpy.mockRestore();
38
+ errSpy.mockRestore();
39
+ }
40
+ }
41
+
42
+ let savedOrg: string | undefined;
43
+ let savedApiKey: string | undefined;
44
+
45
+ beforeEach(() => {
46
+ savedOrg = process.env.STIGMER_ORG_ID;
47
+ savedApiKey = process.env.STIGMER_API_KEY;
48
+ delete process.env.STIGMER_ORG_ID;
49
+ delete process.env.STIGMER_API_KEY;
50
+ });
51
+
52
+ afterEach(() => {
53
+ if (savedOrg === undefined) delete process.env.STIGMER_ORG_ID;
54
+ else process.env.STIGMER_ORG_ID = savedOrg;
55
+ if (savedApiKey === undefined) delete process.env.STIGMER_API_KEY;
56
+ else process.env.STIGMER_API_KEY = savedApiKey;
57
+ });
58
+
59
+ describe("connect mcp-server org guard", () => {
60
+ it("fails fast with actionable guidance when no org is set (non-dry-run)", async () => {
61
+ const outcome = await runConnect("mcp_test");
62
+ expect(outcome.message).toContain("organization not set");
63
+ expect(outcome.exitCode).toBe(ExitCode.Usage);
64
+ });
65
+
66
+ it("does not apply the org guard in dry-run mode (offline dry-run stays usable)", async () => {
67
+ // Dry-run skips the push, so the guard must not fire even with no org. The
68
+ // command proceeds past the guard and fails later for an unrelated reason
69
+ // (no reachable backend) — never with the org guidance error.
70
+ const outcome = await runConnect("mcp_test", "--dry-run");
71
+ expect(outcome.message).not.toContain("organization not set");
72
+ });
73
+ });
@@ -49,6 +49,20 @@ async function runConnect(reference: string, options: ConnectFlags, command: Com
49
49
  ensureAuthenticated(client.config);
50
50
  const org = resolveOrganization(client.config, globalOrg(command));
51
51
 
52
+ // Connecting pushes to the backend, which requires an org for credential
53
+ // resolution. Fail with actionable guidance instead of the backend's cryptic
54
+ // "org value length must be at least 1" validation error. Dry-run discovers
55
+ // locally (no backend push) and needs no org for an id reference, so it is
56
+ // exempt — mirrors the org guard in run/draft/resume.
57
+ if (options.dryRun !== true && org === "") {
58
+ throw new UsageError(
59
+ "organization not set\n\n" +
60
+ "Set it with:\n" +
61
+ " stigmer config context set --org <org>\n" +
62
+ " stigmer connect mcp-server <server> --org <org>",
63
+ );
64
+ }
65
+
52
66
  const result = await connectMcpServer(client.stigmer, {
53
67
  reference,
54
68
  org,
@@ -311,7 +311,7 @@ export async function reconcileProjectMembers(
311
311
  }
312
312
  }
313
313
 
314
- await discoverAppliedMcpServers(deps.stigmer, appliedMcpServers, deps.info);
314
+ await discoverAppliedMcpServers(deps.stigmer, appliedMcpServers, deps.org, deps.info);
315
315
 
316
316
  injectOrg(project, deps.org);
317
317
  if (project.spec === undefined) project.spec = create(ProjectSpecSchema, {});
@@ -0,0 +1,99 @@
1
+ // In-process integration test for post-apply MCP discovery.
2
+ //
3
+ // Stands up a Connect backend serving McpServerCommandController.connect, points
4
+ // an SDK node client at it, and drives discoverAppliedMcpServers. The mock
5
+ // mirrors the backend's protovalidate rule (org min_len=1) by rejecting an empty
6
+ // org, so a regression that drops org from ConnectInput is caught here rather
7
+ // than silently passing (issue #140: the CLI used to omit org entirely).
8
+
9
+ import { create } from "@bufbuild/protobuf";
10
+ import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
11
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
12
+ import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
13
+ import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
14
+ import type { ConnectInput } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
15
+ import type { Stigmer } from "@stigmer/sdk";
16
+ import { createNodeClient, normalizeEndpoint } from "@stigmer/sdk/node";
17
+ import { createServer as createHttp2Server, type Http2Server, type ServerHttp2Session } from "node:http2";
18
+ import type { AddressInfo } from "node:net";
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
20
+ import { discoverAppliedMcpServers } from "./discovery.js";
21
+
22
+ let backend: Http2Server;
23
+ let client: Stigmer;
24
+ const openSessions = new Set<ServerHttp2Session>();
25
+
26
+ let connectCalls: ConnectInput[] = [];
27
+
28
+ beforeEach(() => {
29
+ connectCalls = [];
30
+ });
31
+
32
+ // A stdio MCP server with no env declarations, so discovery does not skip it and
33
+ // buildRuntimeEnv stays empty (no local process needed — connect is mocked).
34
+ function stdioServer(id: string, org: string) {
35
+ return create(McpServerSchema, {
36
+ metadata: { id, name: id, slug: id, org },
37
+ spec: { serverType: { case: "stdio", value: { command: "noop" } } },
38
+ });
39
+ }
40
+
41
+ beforeAll(async () => {
42
+ const routes = (router: ConnectRouter) => {
43
+ router.service(McpServerCommandController, {
44
+ connect: (req) => {
45
+ if (req.org === "") {
46
+ throw new ConnectError("org – value length must be at least 1 characters", Code.InvalidArgument);
47
+ }
48
+ connectCalls.push(req);
49
+ return create(McpServerSchema, { metadata: { id: req.mcpServerId } });
50
+ },
51
+ });
52
+ };
53
+
54
+ backend = createHttp2Server(connectNodeAdapter({ routes }));
55
+ backend.on("session", (session) => {
56
+ openSessions.add(session);
57
+ session.on("close", () => openSessions.delete(session));
58
+ });
59
+ await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
60
+ const port = (backend.address() as AddressInfo).port;
61
+ client = createNodeClient({ baseUrl: normalizeEndpoint(`127.0.0.1:${port}`) });
62
+ });
63
+
64
+ afterAll(async () => {
65
+ for (const session of openSessions) session.destroy();
66
+ await new Promise<void>((resolve) => backend.close(() => resolve()));
67
+ });
68
+
69
+ describe("discoverAppliedMcpServers", () => {
70
+ it("forwards the resolved org on ConnectInput for each applied stdio server", async () => {
71
+ const lines: string[] = [];
72
+ await discoverAppliedMcpServers(client, [stdioServer("mcp_1", "acme")], "acme", (l) => lines.push(l));
73
+
74
+ expect(connectCalls).toHaveLength(1);
75
+ expect(connectCalls[0].mcpServerId).toBe("mcp_1");
76
+ expect(connectCalls[0].org).toBe("acme");
77
+ expect(lines.join("\n")).toContain("Discovered capabilities for mcp_1");
78
+ });
79
+
80
+ it("surfaces the backend's org validation as a per-server warning when org is empty (regression guard)", async () => {
81
+ const lines: string[] = [];
82
+ await discoverAppliedMcpServers(client, [stdioServer("mcp_1", "acme")], "", (l) => lines.push(l));
83
+
84
+ // Best-effort: an empty org must never crash the apply — it degrades to a
85
+ // warning. This proves the mock (and thus the backend) rejects empty org, so
86
+ // the happy-path org assertion above is a real guard, not a tautology.
87
+ expect(connectCalls).toHaveLength(0);
88
+ expect(lines.join("\n")).toContain("Discovery failed for mcp_1");
89
+ });
90
+
91
+ it("skips non-stdio servers without calling connect", async () => {
92
+ const httpServer = create(McpServerSchema, {
93
+ metadata: { id: "mcp_http", name: "http", slug: "http", org: "acme" },
94
+ spec: { serverType: { case: "http", value: { url: "https://example.com/mcp" } } },
95
+ });
96
+ await discoverAppliedMcpServers(client, [httpServer], "acme");
97
+ expect(connectCalls).toHaveLength(0);
98
+ });
99
+ });
@@ -15,10 +15,18 @@ import { buildRuntimeEnv } from "../mcp/runtime-env.js";
15
15
  /** A sink for human progress/warning lines (discovery output is not parity). */
16
16
  export type DiscoverySink = (line: string) => void;
17
17
 
18
- /** Connect each applied stdio MCP server to trigger capability discovery. */
18
+ /**
19
+ * Connect each applied stdio MCP server to trigger capability discovery.
20
+ *
21
+ * `org` is the resolved apply organization. The backend requires it on every
22
+ * ConnectInput, and it is guaranteed non-empty here: `applyMessage` injects org
23
+ * into each resource and the backend rejects an org-less MCP server apply, so
24
+ * any server that reached `servers` was applied under this org.
25
+ */
19
26
  export async function discoverAppliedMcpServers(
20
27
  client: Stigmer,
21
28
  servers: readonly McpServer[],
29
+ org: string,
22
30
  sink?: DiscoverySink,
23
31
  ): Promise<void> {
24
32
  if (servers.length === 0) return;
@@ -40,6 +48,7 @@ export async function discoverAppliedMcpServers(
40
48
  await client.mcpServer.connect(
41
49
  create(ConnectInputSchema, {
42
50
  mcpServerId: server.metadata?.id ?? "",
51
+ org,
43
52
  runtimeEnv: buildRuntimeEnv(server),
44
53
  }),
45
54
  );
@@ -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" } },
@@ -6,7 +6,7 @@
6
6
  // guidance gate, and the dry-run path (local discovery, no Connect RPC).
7
7
 
8
8
  import { create } from "@bufbuild/protobuf";
9
- import type { ConnectRouter } from "@connectrpc/connect";
9
+ import { Code, ConnectError, type ConnectRouter } from "@connectrpc/connect";
10
10
  import { connectNodeAdapter } from "@connectrpc/connect-node";
11
11
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
12
12
  import { McpServerCommandController } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/command_pb";
@@ -69,7 +69,14 @@ beforeAll(async () => {
69
69
  getOAuthGrantStatus: () => create(GetOAuthGrantStatusOutputSchema, { connected: grantConnected }),
70
70
  });
71
71
  router.service(McpServerCommandController, {
72
- connect: (req) => (connectCalls.push(req), updatedServer),
72
+ // Mirror the backend's protovalidate rule (org min_len=1): reject an empty
73
+ // org so a regression that drops org fails loudly here instead of silently
74
+ // passing (the mock adapter does not run protovalidate on its own).
75
+ connect: (req) => {
76
+ if (req.org === "") throw new ConnectError("org – value length must be at least 1 characters", Code.InvalidArgument);
77
+ connectCalls.push(req);
78
+ return updatedServer;
79
+ },
73
80
  });
74
81
  };
75
82
 
@@ -102,6 +109,9 @@ describe("connect push path", () => {
102
109
 
103
110
  expect(connectCalls).toHaveLength(1);
104
111
  expect(connectCalls[0].mcpServerId).toBe("mcp_1");
112
+ // The org resolved by the command must ride along on ConnectInput — the
113
+ // backend requires it (issue #140: the CLI used to drop it entirely).
114
+ expect(connectCalls[0].org).toBe("acme");
105
115
  expect(connectCalls[0].runtimeEnv.GITHUB_TOKEN.value).toBe("ghp-override");
106
116
  expect(connectCalls[0].runtimeEnv.GITHUB_TOKEN.isSecret).toBe(true);
107
117
 
@@ -158,6 +168,66 @@ describe("OAuth guidance gate", () => {
158
168
  });
159
169
  });
160
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
+
161
231
  describe("dry-run path", () => {
162
232
  it("discovers locally and never calls the Connect RPC", async () => {
163
233
  const result = await connectMcpServer(client, {
@@ -182,4 +252,35 @@ describe("dry-run path", () => {
182
252
  expect(text).toContain("Tools (2):");
183
253
  expect(text).toContain("Dry run — results not saved");
184
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);
185
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);
@@ -56,6 +69,7 @@ export async function connectMcpServer(client: Stigmer, opts: ConnectOptions): P
56
69
  const updated = await client.mcpServer.connect(
57
70
  create(ConnectInputSchema, {
58
71
  mcpServerId: server.metadata?.id ?? "",
72
+ org: opts.org,
59
73
  runtimeEnv: buildRuntimeEnv(server, opts.envOverrides),
60
74
  }),
61
75
  );
@@ -76,7 +90,18 @@ async function resolveMcpServer(client: Stigmer, reference: string, org: string)
76
90
  // flow and wait for the grant; otherwise stop with actionable guidance so
77
91
  // scripted callers get a clean, stable failure instead of a 5-minute block.
78
92
  async function ensureOAuthSatisfied(client: Stigmer, server: McpServer, opts: ConnectOptions): Promise<void> {
79
- 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
+ }
80
105
 
81
106
  const status = await client.mcpServer.getOAuthGrantStatus(
82
107
  create(GetOAuthGrantStatusInputSchema, {
@@ -86,22 +111,76 @@ async function ensureOAuthSatisfied(client: Stigmer, server: McpServer, opts: Co
86
111
  );
87
112
  if (status.connected) return;
88
113
 
89
- if (!opts.interactive) throw oauthGuidanceError(server, opts.reference);
114
+ if (!opts.interactive) throw oauthGuidanceError(server, opts.reference, oauthOnly);
90
115
 
91
116
  const { runOAuthFlow } = await import("./oauth.js");
92
117
  await runOAuthFlow({ client, server, org: opts.org, backendType: opts.backendType });
93
118
  }
94
119
 
95
- 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 {
96
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
+ }
97
151
  return new UsageError(
98
152
  `MCP server '${slug}' requires OAuth authentication, which needs an interactive terminal.\n\n` +
99
- "To connect, choose one of:\n" +
100
- " - Re-run this command in an interactive terminal to complete OAuth in your browser\n" +
101
- ` - 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}`,
102
177
  );
103
178
  }
104
179
 
105
180
  function oauthRequired(server: McpServer): boolean {
106
181
  return (server.spec?.auth?.targetEnvVar ?? "") !== "";
107
182
  }
183
+
184
+ function isOAuthOnly(server: McpServer): boolean {
185
+ return server.spec?.auth?.oauthOnly === true;
186
+ }