argsbarg 1.4.0 → 1.4.1

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/src/index.test.ts CHANGED
@@ -9,19 +9,23 @@ shell output regressions.
9
9
 
10
10
  import { completionBashScript, completionZshScript } from "./completion.ts";
11
11
  import { cliHelpRender } from "./help.ts";
12
- import { CliCommand, CliFallbackMode, CliOptionKind } from "./index.ts";
12
+ import { CliCommand, CliFallbackMode, CliOptionKind, cliInvoke } from "./index.ts";
13
13
  import {
14
+ allMcpResources,
14
15
  collectMcpTools,
15
16
  mcpToolCallToArgv,
16
17
  mcpToolDescription,
17
18
  sanitizeToolSegment,
18
19
  } from "./mcp/tools.ts";
20
+ import { applyShellEnv, loadEnvFile } from "./mcp/env.ts";
19
21
  import { buildToolCallSuccess } from "./mcp/result.ts";
20
22
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
21
23
  import { cliSchemaJson } from "./schema.ts";
22
24
  import { cliValidateRoot } from "./validate.ts";
23
25
  import { expect, test } from "bun:test";
24
26
  import { $ } from "bun";
27
+ import { mkdtempSync, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
25
29
  import { join } from "node:path";
26
30
 
27
31
  test("bundled short presence flags", () => {
@@ -718,11 +722,16 @@ const nestedMcpFixture: CliCommand = {
718
722
  };
719
723
 
720
724
  /** Sends NDJSON MCP requests to a subprocess and collects responses by id. */
721
- async function mcpRequest(requests: object[]): Promise<Map<string | number, object>> {
722
- const proc = Bun.spawn(["bun", "run", "examples/nested.ts", "mcp"], {
725
+ async function mcpRequest(
726
+ requests: object[],
727
+ opts?: { script?: string; env?: Record<string, string> },
728
+ ): Promise<Map<string | number, object>> {
729
+ const script = opts?.script ?? "examples/nested.ts";
730
+ const proc = Bun.spawn(["bun", "run", script, "mcp"], {
723
731
  stdin: "pipe",
724
732
  stdout: "pipe",
725
733
  stderr: "pipe",
734
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
726
735
  });
727
736
 
728
737
  const input = requests.map((r) => JSON.stringify(r) + "\n").join("");
@@ -992,4 +1001,333 @@ test("minimal.ts mcp without opt-in fails", async () => {
992
1001
  const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
993
1002
  expect(exitCode).toBe(1);
994
1003
  expect(stderr.toString()).toContain("mcp");
1004
+ });
1005
+
1006
+ test("ctx.invocation is cli via cliRun", async () => {
1007
+ const indexPath = join(import.meta.dir, "index.ts");
1008
+ const { stdout } = await $`bun -e ${`
1009
+ import { cliRun, CliCommand } from ${JSON.stringify(indexPath)};
1010
+ const cli = { key: "t", description: "d", handler: (ctx) => console.log(ctx.invocation) };
1011
+ await cliRun(cli, []);
1012
+ `}`.quiet();
1013
+ expect(stdout.toString().trim()).toBe("cli");
1014
+ });
1015
+
1016
+ test("ctx.invocation is mcp via cliInvoke", async () => {
1017
+ let seen = "";
1018
+ const root: CliCommand = {
1019
+ key: "app",
1020
+ description: "",
1021
+ handler: (ctx) => {
1022
+ seen = ctx.invocation;
1023
+ },
1024
+ };
1025
+ cliValidateRoot(root);
1026
+ const result = await cliInvoke(root, []);
1027
+ expect(result.kind).toBe("ok");
1028
+ expect(seen).toBe("mcp");
1029
+ });
1030
+
1031
+ const enumMcpFixture: CliCommand = {
1032
+ key: "app",
1033
+ description: "",
1034
+ mcpServer: {},
1035
+ commands: [
1036
+ {
1037
+ key: "run",
1038
+ description: "Run with mode.",
1039
+ options: [
1040
+ {
1041
+ name: "mode",
1042
+ description: "Mode.",
1043
+ kind: CliOptionKind.Enum,
1044
+ choices: ["dev", "prod"],
1045
+ required: true,
1046
+ },
1047
+ ],
1048
+ handler: () => {},
1049
+ },
1050
+ ],
1051
+ };
1052
+
1053
+ test("Enum option inputSchema includes enum array", () => {
1054
+ const tools = collectMcpTools(enumMcpFixture);
1055
+ const run = tools.find((t) => t.name === "run")!;
1056
+ const schema = run.inputSchema as { properties: { mode: { enum?: string[] } } };
1057
+ expect(schema.properties.mode.enum).toEqual(["dev", "prod"]);
1058
+ });
1059
+
1060
+ test("cliInvoke rejects invalid Enum value", async () => {
1061
+ const root: CliCommand = {
1062
+ key: "app",
1063
+ description: "",
1064
+ handler: () => {},
1065
+ options: [
1066
+ {
1067
+ name: "mode",
1068
+ description: "Mode.",
1069
+ kind: CliOptionKind.Enum,
1070
+ choices: ["dev", "prod"],
1071
+ required: true,
1072
+ },
1073
+ ],
1074
+ };
1075
+ cliValidateRoot(root);
1076
+ const result = await cliInvoke(root, ["--mode", "staging"]);
1077
+ expect(result.kind).toBe("error");
1078
+ expect(result.errorMsg).toContain("not one of");
1079
+ });
1080
+
1081
+ test("cliInvoke accepts valid Enum value", async () => {
1082
+ const root: CliCommand = {
1083
+ key: "app",
1084
+ description: "",
1085
+ handler: (ctx) => {
1086
+ console.log(ctx.stringOpt("mode"));
1087
+ },
1088
+ options: [
1089
+ {
1090
+ name: "mode",
1091
+ description: "Mode.",
1092
+ kind: CliOptionKind.Enum,
1093
+ choices: ["dev", "prod"],
1094
+ required: true,
1095
+ },
1096
+ ],
1097
+ };
1098
+ cliValidateRoot(root);
1099
+ const result = await cliInvoke(root, ["--mode", "dev"]);
1100
+ expect(result.kind).toBe("ok");
1101
+ expect(result.stdout.trim()).toBe("dev");
1102
+ });
1103
+
1104
+ test("cliValidateRoot rejects Enum with no choices", () => {
1105
+ const root: CliCommand = {
1106
+ key: "app",
1107
+ description: "",
1108
+ handler: () => {},
1109
+ options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: [] }],
1110
+ };
1111
+ expect(() => cliValidateRoot(root)).toThrow(/requires non-empty choices/);
1112
+ });
1113
+
1114
+ test("cliValidateRoot rejects Enum with duplicate choices", () => {
1115
+ const root: CliCommand = {
1116
+ key: "app",
1117
+ description: "",
1118
+ handler: () => {},
1119
+ options: [{ name: "mode", description: "", kind: CliOptionKind.Enum, choices: ["a", "a"] }],
1120
+ };
1121
+ expect(() => cliValidateRoot(root)).toThrow(/choices must be distinct/);
1122
+ });
1123
+
1124
+ test("mcpTool.description override wins without requiresEnv suffix", () => {
1125
+ const root: CliCommand = {
1126
+ key: "app",
1127
+ description: "",
1128
+ mcpServer: {},
1129
+ commands: [
1130
+ {
1131
+ key: "x",
1132
+ description: "Leaf desc.",
1133
+ mcpTool: { description: "custom", requiresEnv: ["TOKEN"] },
1134
+ handler: () => {},
1135
+ },
1136
+ ],
1137
+ };
1138
+ const tools = collectMcpTools(root);
1139
+ expect(tools[0]!.description).toBe("custom");
1140
+ });
1141
+
1142
+ test("mcpTool.requiresEnv appended to auto description", () => {
1143
+ const root: CliCommand = {
1144
+ key: "app",
1145
+ description: "",
1146
+ mcpServer: {},
1147
+ commands: [
1148
+ {
1149
+ key: "x",
1150
+ description: "Leaf desc.",
1151
+ mcpTool: { requiresEnv: ["TOKEN"] },
1152
+ handler: () => {},
1153
+ },
1154
+ ],
1155
+ };
1156
+ const tools = collectMcpTools(root);
1157
+ expect(tools[0]!.description).toContain("[requires env: TOKEN]");
1158
+ });
1159
+
1160
+ test("cliValidateRoot rejects duplicate mcpResources URIs", () => {
1161
+ const root: CliCommand = {
1162
+ key: "app",
1163
+ description: "",
1164
+ mcpServer: {
1165
+ resources: [
1166
+ { uri: "a://1", name: "a", load: () => "a" },
1167
+ { uri: "a://1", name: "b", load: () => "b" },
1168
+ ],
1169
+ },
1170
+ commands: [{ key: "x", description: "", handler: () => {} }],
1171
+ };
1172
+ expect(() => cliValidateRoot(root)).toThrow(/URIs must be unique/);
1173
+ });
1174
+
1175
+ test("cliValidateRoot rejects resource URI matching schemaResourceUri", () => {
1176
+ const root: CliCommand = {
1177
+ key: "app",
1178
+ description: "",
1179
+ mcpServer: {
1180
+ schemaResourceUri: "custom://schema",
1181
+ resources: [{ uri: "custom://schema", name: "dup", load: () => "" }],
1182
+ },
1183
+ commands: [{ key: "x", description: "", handler: () => {} }],
1184
+ };
1185
+ expect(() => cliValidateRoot(root)).toThrow(/conflicts with the built-in schema resource/);
1186
+ });
1187
+
1188
+ test("allMcpResources includes custom resources", () => {
1189
+ const root: CliCommand = {
1190
+ key: "app",
1191
+ description: "",
1192
+ mcpServer: {
1193
+ resources: [{ uri: "test://x", name: "x", load: () => "body" }],
1194
+ },
1195
+ commands: [{ key: "leaf", description: "", handler: () => {} }],
1196
+ };
1197
+ const resources = allMcpResources(root);
1198
+ expect(resources.map((r) => r.uri)).toContain("argsbarg://schema");
1199
+ expect(resources.map((r) => r.uri)).toContain("test://x");
1200
+ });
1201
+
1202
+ test("applyShellEnv merges PATH and preserves host vars", () => {
1203
+ const origPath = process.env.PATH ?? "";
1204
+ const origHome = process.env.HOME;
1205
+ process.env.PATH = "/host/bin";
1206
+ process.env.HOME = "host-home";
1207
+ applyShellEnv({ PATH: "/shell/bin:/host/bin", HOME: "shell-home", NEWVAR: "yes" });
1208
+ expect(process.env.PATH?.startsWith("/shell/bin:")).toBe(true);
1209
+ expect(process.env.PATH).toContain("/host/bin");
1210
+ expect(process.env.HOME).toBe("host-home");
1211
+ expect(process.env.NEWVAR).toBe("yes");
1212
+ process.env.PATH = origPath;
1213
+ if (origHome === undefined) {
1214
+ delete process.env.HOME;
1215
+ } else {
1216
+ process.env.HOME = origHome;
1217
+ }
1218
+ delete process.env.NEWVAR;
1219
+ });
1220
+
1221
+ test("loadEnvFile overwrites existing keys", () => {
1222
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-env-"));
1223
+ const file = join(dir, ".env");
1224
+ writeFileSync(file, "FOO=fromfile\n", "utf8");
1225
+ process.env.FOO = "original";
1226
+ loadEnvFile(file);
1227
+ expect(process.env.FOO).toBe("fromfile");
1228
+ delete process.env.FOO;
1229
+ });
1230
+
1231
+ test("Enum completions list choices in bash script", () => {
1232
+ const root: CliCommand = {
1233
+ key: "app",
1234
+ description: "",
1235
+ commands: [
1236
+ {
1237
+ key: "run",
1238
+ description: "",
1239
+ options: [
1240
+ { name: "mode", description: "m", kind: CliOptionKind.Enum, choices: ["dev", "prod"] },
1241
+ ],
1242
+ handler: () => {},
1243
+ },
1244
+ ],
1245
+ };
1246
+ const bash = completionBashScript(root);
1247
+ expect(bash).toContain("--mode) COMPREPLY=");
1248
+ expect(bash).toContain("dev");
1249
+ expect(bash).toContain("prod");
1250
+ });
1251
+
1252
+ test("MCP resources/list includes custom resource", async () => {
1253
+ const responses = await mcpRequest(
1254
+ [{ jsonrpc: "2.0", id: 10, method: "resources/list", params: {} }],
1255
+ { script: "examples/mcp-test.ts" },
1256
+ );
1257
+ const res = responses.get(10) as { result: { resources: { uri: string }[] } };
1258
+ const uris = res.result.resources.map((r) => r.uri);
1259
+ expect(uris).toContain("argsbarg://schema");
1260
+ expect(uris).toContain("test://hello");
1261
+ });
1262
+
1263
+ test("MCP resources/read returns custom resource body", async () => {
1264
+ const responses = await mcpRequest(
1265
+ [{ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "test://hello" } }],
1266
+ { script: "examples/mcp-test.ts" },
1267
+ );
1268
+ const res = responses.get(11) as { result: { contents: { text: string }[] } };
1269
+ expect(res.result.contents[0]!.text).toBe("hello resource");
1270
+ });
1271
+
1272
+ test("MCP resources/read unknown URI returns error", async () => {
1273
+ const responses = await mcpRequest(
1274
+ [{ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "missing://nope" } }],
1275
+ { script: "examples/mcp-test.ts" },
1276
+ );
1277
+ const res = responses.get(12) as { error: { code: number } };
1278
+ expect(res.error.code).toBe(-32602);
1279
+ });
1280
+
1281
+ test("MCP requiresEnv fails when env missing", async () => {
1282
+ const responses = await mcpRequest(
1283
+ [
1284
+ {
1285
+ jsonrpc: "2.0",
1286
+ id: 13,
1287
+ method: "tools/call",
1288
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1289
+ },
1290
+ ],
1291
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "" } },
1292
+ );
1293
+ const res = responses.get(13) as { result: { isError: boolean; content: { text: string }[] } };
1294
+ expect(res.result.isError).toBe(true);
1295
+ expect(res.result.content[0]!.text).toContain("ARGS_TEST_SECRET");
1296
+ });
1297
+
1298
+ test("MCP requiresEnv succeeds when env present", async () => {
1299
+ const responses = await mcpRequest(
1300
+ [
1301
+ {
1302
+ jsonrpc: "2.0",
1303
+ id: 14,
1304
+ method: "tools/call",
1305
+ params: { name: "echo_env", arguments: { name: "ARGS_TEST_SECRET" } },
1306
+ },
1307
+ ],
1308
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "sekrit" } },
1309
+ );
1310
+ const res = responses.get(14) as { result: { isError: boolean; content: { text: string }[] } };
1311
+ expect(res.result.isError).toBe(false);
1312
+ expect(res.result.content[0]!.text.trim()).toBe("sekrit");
1313
+ });
1314
+
1315
+ test("MCP envFile loads vars for tool handlers", async () => {
1316
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-mcp-"));
1317
+ const envFile = join(dir, "mcp.env");
1318
+ writeFileSync(envFile, "ARGS_FILE_TOKEN=file-value\n", "utf8");
1319
+ const responses = await mcpRequest(
1320
+ [
1321
+ {
1322
+ jsonrpc: "2.0",
1323
+ id: 15,
1324
+ method: "tools/call",
1325
+ params: { name: "echo_env", arguments: { name: "ARGS_FILE_TOKEN" } },
1326
+ },
1327
+ ],
1328
+ { script: "examples/mcp-test.ts", env: { ARGS_TEST_ENV_FILE: envFile, ARGS_TEST_SECRET: "present" } },
1329
+ );
1330
+ const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
1331
+ expect(res.result.isError).toBe(false);
1332
+ expect(res.result.content[0]!.text.trim()).toBe("file-value");
995
1333
  });
package/src/index.ts CHANGED
@@ -7,8 +7,19 @@ It gives consumers one stable import path without forcing them to know the inter
7
7
  module layout.
8
8
  */
9
9
 
10
+ export { cliInvoke } from "./invoke.ts";
11
+ export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
10
12
  export { CliContext } from "./context.ts";
11
13
  export { cliErrWithHelp, cliRun } from "./runtime";
12
14
  export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
13
- export type { CliCommand, CliHandler, CliMcpServerConfig, CliMcpToolConfig, CliOption, CliPositional } from "./types.ts";
15
+ export type {
16
+ CliCommand,
17
+ CliHandler,
18
+ CliInvocation,
19
+ CliMcpResource,
20
+ CliMcpServerConfig,
21
+ CliMcpToolConfig,
22
+ CliOption,
23
+ CliPositional,
24
+ } from "./types.ts";
14
25
  export { isInteractiveTty } from "./utils.ts";
package/src/invoke.ts CHANGED
@@ -108,7 +108,7 @@ export async function cliInvoke(root: CliCommand, argv: string[]): Promise<CliIn
108
108
  }
109
109
 
110
110
  const handler = current.handler;
111
- const ctx = new CliContext(root.key, pr.path, pr.args, pr.opts, root);
111
+ const ctx = new CliContext(root.key, pr.path, pr.args, pr.opts, root, "mcp");
112
112
 
113
113
  let stdout = "";
114
114
  let stderr = "";
package/src/mcp/env.ts ADDED
@@ -0,0 +1,99 @@
1
+ /*
2
+ This module bootstraps process.env for MCP servers from login shell and .env files.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { spawnSync } from "node:child_process";
7
+
8
+ /** Parses `env` stdout from a login shell into a key/value map. */
9
+ export function captureShellEnv(shell: string): Record<string, string> {
10
+ const result = spawnSync(shell, ["-l", "-c", "env"], {
11
+ encoding: "utf8",
12
+ timeout: 5000,
13
+ });
14
+ if (result.error || result.status !== 0) {
15
+ return {};
16
+ }
17
+ const env: Record<string, string> = {};
18
+ for (const line of result.stdout.split("\n")) {
19
+ const eq = line.indexOf("=");
20
+ if (eq > 0) {
21
+ env[line.slice(0, eq)] = line.slice(eq + 1);
22
+ }
23
+ }
24
+ return env;
25
+ }
26
+
27
+ /** Merges captured shell env into process.env (PATH merged; host wins for other keys). */
28
+ export function applyShellEnv(env: Record<string, string>): void {
29
+ for (const [key, val] of Object.entries(env)) {
30
+ if (key === "PATH") {
31
+ const existing = process.env.PATH ?? "";
32
+ const existingParts = new Set(existing.split(":"));
33
+ const shellOnly = val.split(":").filter((p) => p.length > 0 && !existingParts.has(p));
34
+ if (shellOnly.length > 0) {
35
+ process.env.PATH = [...shellOnly, existing].join(":");
36
+ }
37
+ } else if (process.env[key] === undefined) {
38
+ process.env[key] = val;
39
+ }
40
+ }
41
+ }
42
+
43
+ /** Loads a .env file into process.env (always overwrites). Warns on stderr if missing. */
44
+ export function loadEnvFile(envFile: string): void {
45
+ const resolved = envFile.startsWith("~")
46
+ ? envFile.replace("~", process.env.HOME ?? "")
47
+ : envFile;
48
+ let text: string;
49
+ try {
50
+ text = readFileSync(resolved, "utf8");
51
+ } catch {
52
+ process.stderr.write(`[argsbarg] envFile not found: ${envFile}\n`);
53
+ return;
54
+ }
55
+ for (const line of text.split("\n")) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed || trimmed.startsWith("#")) {
58
+ continue;
59
+ }
60
+ const eq = trimmed.indexOf("=");
61
+ if (eq < 1) {
62
+ continue;
63
+ }
64
+ const key = trimmed.slice(0, eq).trim();
65
+ let val = trimmed.slice(eq + 1).trim();
66
+ if (
67
+ (val.startsWith('"') && val.endsWith('"')) ||
68
+ (val.startsWith("'") && val.endsWith("'"))
69
+ ) {
70
+ val = val.slice(1, -1);
71
+ }
72
+ if (key) {
73
+ process.env[key] = val;
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Applies mcpServer shellEnv and envFile bootstrap in order. */
79
+ export function bootstrapMcpEnv(config: {
80
+ shellEnv?: boolean | string;
81
+ envFile?: string;
82
+ }): void {
83
+ const shellEnvCfg = config.shellEnv;
84
+ if (shellEnvCfg) {
85
+ const shell =
86
+ typeof shellEnvCfg === "string"
87
+ ? shellEnvCfg
88
+ : (process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"));
89
+ const captured = captureShellEnv(shell);
90
+ if (Object.keys(captured).length === 0) {
91
+ process.stderr.write(`[argsbarg] shellEnv: failed to capture shell environment from ${shell}\n`);
92
+ } else {
93
+ applyShellEnv(captured);
94
+ }
95
+ }
96
+ if (config.envFile) {
97
+ loadEnvFile(config.envFile);
98
+ }
99
+ }
package/src/mcp/server.ts CHANGED
@@ -4,13 +4,12 @@ resources, and ping. Responses are newline-delimited JSON on stdout only.
4
4
  */
5
5
 
6
6
  import { cliInvoke } from "../invoke.ts";
7
- import { cliSchemaJson } from "../schema.ts";
8
7
  import { CliCommand } from "../types.ts";
9
8
  import { buildToolCallSuccess } from "./result.ts";
10
9
  import {
10
+ allMcpResources,
11
11
  collectMcpTools,
12
12
  mcpToolCallToArgv,
13
- resolveMcpSchemaUri,
14
13
  resolveMcpServerInfo,
15
14
  } from "./tools.ts";
16
15
 
@@ -118,6 +117,18 @@ async function handleRequestLine(root: CliCommand, line: string): Promise<void>
118
117
  writeError(id, -32602, `Unknown tool: ${name}`);
119
118
  return;
120
119
  }
120
+ const missingEnv = (tool.leaf.mcpTool?.requiresEnv ?? []).filter((k) => !process.env[k]);
121
+ if (missingEnv.length > 0) {
122
+ writeResponse({
123
+ jsonrpc: "2.0",
124
+ id,
125
+ result: {
126
+ content: [{ type: "text", text: `Missing required env: ${missingEnv.join(", ")}` }],
127
+ isError: true,
128
+ },
129
+ });
130
+ return;
131
+ }
121
132
  const argvResult = mcpToolCallToArgv(root, tool, (rawArgs ?? {}) as Record<string, unknown>);
122
133
  if ("error" in argvResult) {
123
134
  writeResponse({
@@ -152,21 +163,13 @@ async function handleRequestLine(root: CliCommand, line: string): Promise<void>
152
163
  }
153
164
 
154
165
  if (method === "resources/list") {
155
- const uri = resolveMcpSchemaUri(root);
156
- writeResponse({
157
- jsonrpc: "2.0",
158
- id,
159
- result: {
160
- resources: [
161
- {
162
- uri,
163
- name: "cli-schema",
164
- description: "Full CLI command tree (same as --schema).",
165
- mimeType: "application/json",
166
- },
167
- ],
168
- },
169
- });
166
+ const resources = allMcpResources(root).map((r) => ({
167
+ uri: r.uri,
168
+ name: r.name,
169
+ description: r.description,
170
+ mimeType: r.mimeType,
171
+ }));
172
+ writeResponse({ jsonrpc: "2.0", id, result: { resources } });
170
173
  return;
171
174
  }
172
175
 
@@ -176,20 +179,29 @@ async function handleRequestLine(root: CliCommand, line: string): Promise<void>
176
179
  writeError(id, -32602, "Invalid params: uri required");
177
180
  return;
178
181
  }
179
- const expected = resolveMcpSchemaUri(root);
180
- if (uri !== expected) {
182
+ const all = allMcpResources(root);
183
+ const found = all.find((r) => r.uri === uri);
184
+ if (!found) {
181
185
  writeError(id, -32602, `Unknown resource: ${uri}`);
182
186
  return;
183
187
  }
188
+ let text: string;
189
+ try {
190
+ text = found.load();
191
+ } catch (err) {
192
+ const message = err instanceof Error ? err.message : String(err);
193
+ writeError(id, -32603, `Resource load failed: ${message}`);
194
+ return;
195
+ }
184
196
  writeResponse({
185
197
  jsonrpc: "2.0",
186
198
  id,
187
199
  result: {
188
200
  contents: [
189
201
  {
190
- uri: expected,
191
- mimeType: "application/json",
192
- text: cliSchemaJson(root).trimEnd(),
202
+ uri: found.uri,
203
+ mimeType: found.mimeType,
204
+ text,
193
205
  },
194
206
  ],
195
207
  },
package/src/mcp/tools.ts CHANGED
@@ -6,6 +6,7 @@ flat JSON tool arguments into argv for cliInvoke.
6
6
  import { readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { collectOptionDefs } from "../parse.ts";
9
+ import { cliSchemaJson } from "../schema.ts";
9
10
  import { CliCommand, CliOption, CliOptionKind, CliPositional } from "../types.ts";
10
11
 
11
12
  /** Default URI for the CLI schema MCP resource. */
@@ -54,6 +55,8 @@ function optionProperty(opt: CliOption): Record<string, unknown> {
54
55
  return { type: "string", ...base };
55
56
  case CliOptionKind.Number:
56
57
  return { type: "number", ...base };
58
+ case CliOptionKind.Enum:
59
+ return { type: "string", enum: opt.choices, ...base };
57
60
  }
58
61
  }
59
62
 
@@ -98,6 +101,48 @@ function buildInputSchema(root: CliCommand, path: string[], leaf: CliCommand): R
98
101
  return schema;
99
102
  }
100
103
 
104
+ /** Resolves MCP tool description with optional override and requiresEnv suffix. */
105
+ function resolveToolDescription(root: CliCommand, path: string[], leaf: CliCommand): string {
106
+ if (leaf.mcpTool?.description) {
107
+ return leaf.mcpTool.description;
108
+ }
109
+ let desc = mcpToolDescription(path, root.key, leaf.description);
110
+ const env = leaf.mcpTool?.requiresEnv;
111
+ if (env && env.length > 0) {
112
+ desc += ` [requires env: ${env.join(", ")}]`;
113
+ }
114
+ return desc;
115
+ }
116
+
117
+ /** One resolved MCP resource (built-in or user-defined). */
118
+ export interface McpResourceEntry {
119
+ uri: string;
120
+ name: string;
121
+ description?: string;
122
+ mimeType: string;
123
+ load: () => string;
124
+ }
125
+
126
+ /** Returns built-in schema resource plus user mcpServer.resources. */
127
+ export function allMcpResources(root: CliCommand): McpResourceEntry[] {
128
+ const schemaUri = resolveMcpSchemaUri(root);
129
+ const builtIn: McpResourceEntry = {
130
+ uri: schemaUri,
131
+ name: "cli-schema",
132
+ description: "Full CLI command tree (same as --schema).",
133
+ mimeType: "application/json",
134
+ load: () => cliSchemaJson(root),
135
+ };
136
+ const user = (root.mcpServer?.resources ?? []).map((r) => ({
137
+ uri: r.uri,
138
+ name: r.name,
139
+ description: r.description,
140
+ mimeType: r.mimeType ?? "text/plain",
141
+ load: r.load,
142
+ }));
143
+ return [builtIn, ...user];
144
+ }
145
+
101
146
  /** Recursively collects MCP tool definitions from user leaf commands. */
102
147
  export function collectMcpTools(root: CliCommand): McpToolDef[] {
103
148
  const out: McpToolDef[] = [];
@@ -113,7 +158,7 @@ export function collectMcpTools(root: CliCommand): McpToolDef[] {
113
158
  }
114
159
  out.push({
115
160
  name: mcpToolName(root, path),
116
- description: mcpToolDescription(path, root.key, cmd.description),
161
+ description: resolveToolDescription(root, path, cmd),
117
162
  path,
118
163
  leaf: cmd,
119
164
  inputSchema: buildInputSchema(root, path, cmd),
package/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@ This module starts the ArgsBarg MCP stdio server for opt-in program roots.
3
3
  */
4
4
 
5
5
  import { mcpServeStdioLoop } from "./mcp/server.ts";
6
+ import { bootstrapMcpEnv } from "./mcp/env.ts";
6
7
  import { CliCommand } from "./types.ts";
7
8
 
8
9
  /**
@@ -11,6 +12,9 @@ import { CliCommand } from "./types.ts";
11
12
  */
12
13
  export async function cliMcpServeStdio(root: CliCommand): Promise<never> {
13
14
  try {
15
+ if (root.mcpServer) {
16
+ bootstrapMcpEnv(root.mcpServer);
17
+ }
14
18
  await mcpServeStdioLoop(root);
15
19
  process.exit(0);
16
20
  } catch (err) {