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/.cursor/plans/mcp_v1.2_invocation_and_extensions_a4f82c1e.plan.md +647 -0
- package/CHANGELOG.md +15 -1
- package/README.md +6 -5
- package/docs/mcp.md +95 -6
- package/examples/mcp-test.ts +66 -0
- package/index.d.ts +98 -23
- package/package.json +1 -1
- package/src/completion.ts +55 -1
- package/src/context.ts +4 -1
- package/src/help.ts +12 -2
- package/src/index.test.ts +341 -3
- package/src/index.ts +12 -1
- package/src/invoke.ts +1 -1
- package/src/mcp/env.ts +99 -0
- package/src/mcp/server.ts +34 -22
- package/src/mcp/tools.ts +46 -1
- package/src/mcp.ts +4 -0
- package/src/parse.ts +15 -0
- package/src/runtime.ts +1 -1
- package/src/types.ts +57 -1
- package/src/validate.ts +38 -0
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(
|
|
722
|
-
|
|
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 {
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
180
|
-
|
|
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:
|
|
191
|
-
mimeType:
|
|
192
|
-
text
|
|
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:
|
|
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) {
|