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
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: MCP v1.2 — invocation context and schema extensions
|
|
3
|
+
overview: "Seven targeted additions: ctx.invocation for CLI/MCP branching, public cliInvoke export, CliOptionKind.Enum with choices, mcpTool description override + requiresEnv metadata, pluggable mcpResources on the root, mcpServer.envFile for explicit secrets, and mcpServer.shellEnv to auto-capture the user's full login shell environment (PATH, toolchains, exports)."
|
|
4
|
+
todos:
|
|
5
|
+
- id: types
|
|
6
|
+
content: "Step 1: Add CliInvocation type, CliOptionKind.Enum + choices on CliOption, description/requiresEnv on CliMcpToolConfig, CliMcpResource interface + resources on CliMcpServerConfig, envFile on CliMcpServerConfig"
|
|
7
|
+
status: completed
|
|
8
|
+
- id: validate
|
|
9
|
+
content: "Step 2: Validate Enum choices (non-empty, distinct), mcpResources URI uniqueness vs built-in schema URI"
|
|
10
|
+
status: completed
|
|
11
|
+
- id: context-invocation
|
|
12
|
+
content: "Step 3: Add invocation param to CliContext constructor; set 'cli' in cliRun, 'mcp' in cliInvoke; export cliInvoke from index.ts"
|
|
13
|
+
status: completed
|
|
14
|
+
- id: enum-kind
|
|
15
|
+
content: "Step 4: Wire CliOptionKind.Enum — parser validation, help rendering (choice label), completions (choice list), MCP inputSchema (enum array)"
|
|
16
|
+
status: completed
|
|
17
|
+
- id: mcp-tool-meta
|
|
18
|
+
content: "Step 5: Wire mcpTool.description override and requiresEnv note in collectMcpTools"
|
|
19
|
+
status: completed
|
|
20
|
+
- id: mcp-resources
|
|
21
|
+
content: "Step 6: Wire CliMcpResource into resources/list and resources/read in server.ts"
|
|
22
|
+
status: completed
|
|
23
|
+
- id: env-bootstrap
|
|
24
|
+
content: "Step 7: Env bootstrapping — shellEnv captures login shell (PATH, toolchains, exports); envFile overrides specific keys; applied in order at cliMcpServeStdio startup"
|
|
25
|
+
status: completed
|
|
26
|
+
- id: tests
|
|
27
|
+
content: "Step 8: Unit + subprocess tests for all six features"
|
|
28
|
+
status: completed
|
|
29
|
+
- id: docs-typegen
|
|
30
|
+
content: "Step 9: Update docs/mcp.md, README.md, CHANGELOG.md; run just typegen and just test"
|
|
31
|
+
status: completed
|
|
32
|
+
isProject: false
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
# MCP v1.2 — invocation context and schema extensions
|
|
36
|
+
|
|
37
|
+
Seven additive improvements driven by consumer app feedback (`qa`, `idp-trees`). No breaking changes, no new runtime dependencies, no new public MCP internals.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Implementation order
|
|
42
|
+
|
|
43
|
+
1. Types (all in one pass — all downstream steps depend on these)
|
|
44
|
+
2. Validation
|
|
45
|
+
3. `ctx.invocation` + `cliInvoke` export
|
|
46
|
+
4. `CliOptionKind.Enum`
|
|
47
|
+
5. `mcpTool` metadata wiring
|
|
48
|
+
6. `mcpResources` wiring
|
|
49
|
+
7. Env bootstrapping (`shellEnv` then `envFile`)
|
|
50
|
+
8. Tests
|
|
51
|
+
9. Docs + typegen
|
|
52
|
+
|
|
53
|
+
Do not wire runtime or MCP server changes before their types exist.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Step 1: Types ([`src/types.ts`](src/types.ts))
|
|
58
|
+
|
|
59
|
+
### `CliInvocation` type
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
/** How a leaf handler was dispatched. */
|
|
63
|
+
export type CliInvocation = "cli" | "mcp";
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Place before `CliOptionKind`. Import in [`src/context.ts`](src/context.ts).
|
|
67
|
+
|
|
68
|
+
### `CliOptionKind.Enum`
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
export enum CliOptionKind {
|
|
72
|
+
Presence = "presence",
|
|
73
|
+
String = "string",
|
|
74
|
+
Number = "number",
|
|
75
|
+
/** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
|
|
76
|
+
Enum = "enum",
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `choices` on `CliOption`
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
export interface CliOption {
|
|
84
|
+
// ...existing fields...
|
|
85
|
+
/**
|
|
86
|
+
* Allowed values. Required when kind === Enum; ignored otherwise.
|
|
87
|
+
* Must be a non-empty array of distinct non-empty strings.
|
|
88
|
+
*/
|
|
89
|
+
choices?: string[];
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `CliMcpToolConfig` additions
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
export interface CliMcpToolConfig {
|
|
97
|
+
/** When `false`, omit from `tools/list` (default: exposed). */
|
|
98
|
+
enabled?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Override the generated MCP tool description.
|
|
101
|
+
* Default: auto-generated from command path and description.
|
|
102
|
+
*/
|
|
103
|
+
description?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Environment variable names required at runtime.
|
|
106
|
+
* Appended to the MCP tool description so agents can fail fast.
|
|
107
|
+
*/
|
|
108
|
+
requiresEnv?: string[];
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `CliMcpResource` interface (new)
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
/**
|
|
116
|
+
* A custom MCP resource exposed under resources/list and resources/read.
|
|
117
|
+
* Added to CliMcpServerConfig.resources.
|
|
118
|
+
*/
|
|
119
|
+
export interface CliMcpResource {
|
|
120
|
+
/** Resource URI (must be unique; must not equal schemaResourceUri). */
|
|
121
|
+
uri: string;
|
|
122
|
+
/** Short display name for resources/list. */
|
|
123
|
+
name: string;
|
|
124
|
+
/** Optional human description for resources/list. */
|
|
125
|
+
description?: string;
|
|
126
|
+
/** MIME type (default: "text/plain"). */
|
|
127
|
+
mimeType?: string;
|
|
128
|
+
/** Called at resources/read time; must return the resource body. */
|
|
129
|
+
load: () => string;
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### `CliMcpServerConfig` additions
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
export interface CliMcpServerConfig {
|
|
137
|
+
// ...existing fields...
|
|
138
|
+
/**
|
|
139
|
+
* Capture the user's login shell environment at MCP server start and merge it
|
|
140
|
+
* into process.env. Solves missing PATH, nvm/rbenv shims, Homebrew binaries,
|
|
141
|
+
* and shell exports that MCP hosts (e.g. Cursor) don't inherit.
|
|
142
|
+
*
|
|
143
|
+
* `true` — use $SHELL, falling back to /bin/zsh on macOS or /bin/bash elsewhere.
|
|
144
|
+
* `string` — explicit shell path (e.g. "/bin/bash").
|
|
145
|
+
*
|
|
146
|
+
* Merge semantics: shell env is the baseline; host-provided process.env wins for
|
|
147
|
+
* all keys except PATH, which is always merged (shell PATH prepended to host PATH).
|
|
148
|
+
* Silently skipped if the shell spawn fails or times out (5 s).
|
|
149
|
+
*/
|
|
150
|
+
shellEnv?: boolean | string;
|
|
151
|
+
/**
|
|
152
|
+
* Path to a .env file loaded into process.env at MCP server start, after shellEnv.
|
|
153
|
+
* Supports `~` expansion. Silently skipped if the file does not exist.
|
|
154
|
+
* Always overwrites — envFile is authoritative for its keys.
|
|
155
|
+
*/
|
|
156
|
+
envFile?: string;
|
|
157
|
+
/**
|
|
158
|
+
* Custom MCP resources exposed alongside the built-in argsbarg://schema resource.
|
|
159
|
+
* URIs must be unique and must not equal schemaResourceUri.
|
|
160
|
+
*/
|
|
161
|
+
resources?: CliMcpResource[];
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Step 2: Validation ([`src/validate.ts`](src/validate.ts))
|
|
168
|
+
|
|
169
|
+
### `CliOptionKind.Enum` — in `walkCommand`, for each option on a command
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
if (opt.kind === CliOptionKind.Enum) {
|
|
173
|
+
if (!opt.choices || opt.choices.length === 0)
|
|
174
|
+
throw CliSchemaValidationError: "Option '${opt.name}' on '${cmd.key}': Enum kind requires non-empty choices"
|
|
175
|
+
if (new Set(opt.choices).size !== opt.choices.length)
|
|
176
|
+
throw CliSchemaValidationError: "Option '${opt.name}' on '${cmd.key}': Enum choices must be distinct"
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Also throw if `opt.choices` is set on a non-Enum kind (defensive).
|
|
181
|
+
|
|
182
|
+
### `mcpResources` URI uniqueness — in `walkCommand` at the root node
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
if (isRoot && root.mcpServer?.resources) {
|
|
186
|
+
const schemaUri = root.mcpServer.schemaResourceUri ?? MCP_SCHEMA_URI_DEFAULT;
|
|
187
|
+
const uris = root.mcpServer.resources.map(r => r.uri);
|
|
188
|
+
if (uris.includes(schemaUri))
|
|
189
|
+
throw: "mcpServer.resources URI '${schemaUri}' conflicts with the built-in schema resource"
|
|
190
|
+
if (new Set(uris).size !== uris.length)
|
|
191
|
+
throw: "mcpServer.resources URIs must be unique"
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Import `MCP_SCHEMA_URI_DEFAULT` from `../mcp/tools.ts` — or extract the constant to a shared location if that creates a circular dep. If circular: inline the default string `"argsbarg://schema"` in validate.ts.
|
|
196
|
+
|
|
197
|
+
**No validation** needed for `envFile` (file-not-found is a runtime warning, not a schema error) or for `mcpTool.description`/`requiresEnv` (no constraints beyond type).
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## Step 3: `ctx.invocation` + `cliInvoke` export
|
|
202
|
+
|
|
203
|
+
### [`src/context.ts`](src/context.ts)
|
|
204
|
+
|
|
205
|
+
Add `invocation` as the **last** constructor parameter with default `"cli"` to preserve backwards compatibility for any direct `new CliContext(...)` callers (tests, etc.):
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import type { CliCommand, CliInvocation } from "./types.ts";
|
|
209
|
+
|
|
210
|
+
export class CliContext {
|
|
211
|
+
// ...existing fields...
|
|
212
|
+
readonly invocation: CliInvocation;
|
|
213
|
+
|
|
214
|
+
constructor(
|
|
215
|
+
appName: string,
|
|
216
|
+
commandPath: string[],
|
|
217
|
+
args: string[],
|
|
218
|
+
opts: Record<string, string>,
|
|
219
|
+
schema: CliCommand,
|
|
220
|
+
invocation: CliInvocation = "cli",
|
|
221
|
+
) {
|
|
222
|
+
// ...existing assignments...
|
|
223
|
+
this.invocation = invocation;
|
|
224
|
+
}
|
|
225
|
+
// ...existing methods unchanged...
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### [`src/runtime.ts`](src/runtime.ts) — line 115
|
|
230
|
+
|
|
231
|
+
Change:
|
|
232
|
+
```typescript
|
|
233
|
+
const ctx = new CliContext(parseRoot.key, pr.path, pr.args, pr.opts, parseRoot);
|
|
234
|
+
```
|
|
235
|
+
To:
|
|
236
|
+
```typescript
|
|
237
|
+
const ctx = new CliContext(parseRoot.key, pr.path, pr.args, pr.opts, parseRoot, "cli");
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### [`src/invoke.ts`](src/invoke.ts) — line 111
|
|
241
|
+
|
|
242
|
+
Change:
|
|
243
|
+
```typescript
|
|
244
|
+
const ctx = new CliContext(root.key, pr.path, pr.args, pr.opts, root);
|
|
245
|
+
```
|
|
246
|
+
To:
|
|
247
|
+
```typescript
|
|
248
|
+
const ctx = new CliContext(root.key, pr.path, pr.args, pr.opts, root, "mcp");
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### [`src/index.ts`](src/index.ts) — export
|
|
252
|
+
|
|
253
|
+
Add to public exports:
|
|
254
|
+
```typescript
|
|
255
|
+
export { cliInvoke } from "./invoke.ts";
|
|
256
|
+
export type { CliInvokeKind, CliInvokeResult } from "./invoke.ts";
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Also export the new types added in Step 1:
|
|
260
|
+
```typescript
|
|
261
|
+
export type { CliInvocation, CliMcpResource } from "./types.ts";
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Step 4: `CliOptionKind.Enum`
|
|
267
|
+
|
|
268
|
+
Four touchpoints — handle all four or leave the type dead.
|
|
269
|
+
|
|
270
|
+
### Parser validation ([`src/parse.ts`](src/parse.ts) or `postParseValidate`)
|
|
271
|
+
|
|
272
|
+
After an Enum option's value is consumed, check `choices.includes(value)`. On failure, set parse error: `"Option --${name}: '${value}' is not one of: ${choices.join(', ')}"`. Do this in `postParseValidate` alongside existing validation, not deep in the tokenizer.
|
|
273
|
+
|
|
274
|
+
### Help rendering ([`src/help.ts`](src/help.ts))
|
|
275
|
+
|
|
276
|
+
Option value label: currently String shows `<value>`, Number shows `<number>`. Enum should show `<choice1|choice2|…>` (truncate display if choices > 4: `<a|b|c|…>`).
|
|
277
|
+
|
|
278
|
+
### Shell completions ([`src/completion.ts`](src/completion.ts))
|
|
279
|
+
|
|
280
|
+
Where String options emit a generic word completion, Enum options should emit each choice as a discrete completion candidate.
|
|
281
|
+
|
|
282
|
+
### MCP `inputSchema` ([`src/mcp/tools.ts`](src/mcp/tools.ts))
|
|
283
|
+
|
|
284
|
+
In `optionProperty`:
|
|
285
|
+
```typescript
|
|
286
|
+
case CliOptionKind.Enum:
|
|
287
|
+
return { type: "string", enum: opt.choices, description: opt.description };
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Step 5: `mcpTool` metadata ([`src/mcp/tools.ts`](src/mcp/tools.ts))
|
|
293
|
+
|
|
294
|
+
In `collectMcpTools`, when building `McpToolDef.description` for a leaf:
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
function resolveToolDescription(
|
|
298
|
+
root: CliCommand,
|
|
299
|
+
path: string[],
|
|
300
|
+
leaf: CliCommand,
|
|
301
|
+
): string {
|
|
302
|
+
// Author-supplied override wins
|
|
303
|
+
if (leaf.mcpTool?.description) {
|
|
304
|
+
return leaf.mcpTool.description;
|
|
305
|
+
}
|
|
306
|
+
// Auto-generate: path + leaf description
|
|
307
|
+
let desc = mcpToolDescription(root, path, leaf.description);
|
|
308
|
+
// Append env requirements
|
|
309
|
+
const env = leaf.mcpTool?.requiresEnv;
|
|
310
|
+
if (env && env.length > 0) {
|
|
311
|
+
desc += ` [requires env: ${env.join(", ")}]`;
|
|
312
|
+
}
|
|
313
|
+
return desc;
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Replace the inline description construction in `collectMcpTools` with a call to `resolveToolDescription`.
|
|
318
|
+
|
|
319
|
+
`mcpTool.enabled === false` filter is already in place — no change there.
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Step 6: `mcpResources` ([`src/mcp/server.ts`](src/mcp/server.ts))
|
|
324
|
+
|
|
325
|
+
### Helper (can live in `server.ts` or `tools.ts`)
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
function allMcpResources(root: CliCommand): Array<{
|
|
329
|
+
uri: string; name: string; description?: string; mimeType: string; load: () => string;
|
|
330
|
+
}> {
|
|
331
|
+
const schemaUri = resolveMcpSchemaUri(root);
|
|
332
|
+
const builtIn = [{
|
|
333
|
+
uri: schemaUri,
|
|
334
|
+
name: "cli-schema",
|
|
335
|
+
description: "Full CLI command tree (same as --schema).",
|
|
336
|
+
mimeType: "application/json",
|
|
337
|
+
load: () => cliSchemaJson(root),
|
|
338
|
+
}];
|
|
339
|
+
const user = (root.mcpServer?.resources ?? []).map(r => ({
|
|
340
|
+
uri: r.uri,
|
|
341
|
+
name: r.name,
|
|
342
|
+
description: r.description,
|
|
343
|
+
mimeType: r.mimeType ?? "text/plain",
|
|
344
|
+
load: r.load,
|
|
345
|
+
}));
|
|
346
|
+
return [...builtIn, ...user];
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### `tools/call` — `requiresEnv` enforcement
|
|
351
|
+
|
|
352
|
+
Before dispatching to `cliInvoke`, check that all env vars declared on the matched tool's leaf are present. Fail fast with an MCP error result rather than letting the handler surface an opaque runtime error:
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
const missing = (tool.leaf.mcpTool?.requiresEnv ?? [])
|
|
356
|
+
.filter((k) => !process.env[k]);
|
|
357
|
+
if (missing.length > 0) {
|
|
358
|
+
writeResponse({
|
|
359
|
+
jsonrpc: "2.0",
|
|
360
|
+
id,
|
|
361
|
+
result: {
|
|
362
|
+
content: [{ type: "text", text: `Missing required env: ${missing.join(", ")}` }],
|
|
363
|
+
isError: true,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Do this **after** resolving the tool by name (already errors with `-32602` if unknown) and **before** `mcpToolCallToArgv` / `cliInvoke`. No new types needed.
|
|
371
|
+
|
|
372
|
+
### `resources/list`
|
|
373
|
+
|
|
374
|
+
Replace hardcoded single-resource response with:
|
|
375
|
+
```typescript
|
|
376
|
+
const resources = allMcpResources(root).map(r => ({
|
|
377
|
+
uri: r.uri,
|
|
378
|
+
name: r.name,
|
|
379
|
+
description: r.description,
|
|
380
|
+
mimeType: r.mimeType,
|
|
381
|
+
}));
|
|
382
|
+
writeResponse({ jsonrpc: "2.0", id, result: { resources } });
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### `resources/read`
|
|
386
|
+
|
|
387
|
+
Replace hardcoded URI check with:
|
|
388
|
+
```typescript
|
|
389
|
+
const all = allMcpResources(root);
|
|
390
|
+
const found = all.find(r => r.uri === params.uri);
|
|
391
|
+
if (!found) {
|
|
392
|
+
writeError(id, -32602, `Unknown resource: ${params.uri}`);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
let text: string;
|
|
396
|
+
try {
|
|
397
|
+
text = found.load();
|
|
398
|
+
} catch (err) {
|
|
399
|
+
writeError(id, -32603, `Resource load failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
writeResponse({
|
|
403
|
+
jsonrpc: "2.0", id,
|
|
404
|
+
result: { contents: [{ uri: found.uri, mimeType: found.mimeType, text }] },
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Step 7: Env bootstrapping ([`src/mcp.ts`](src/mcp.ts) — `cliMcpServeStdio`)
|
|
411
|
+
|
|
412
|
+
Both loaders run at the top of `cliMcpServeStdio`, before the read loop, in this order: **shellEnv first** (sets the baseline), **envFile second** (overrides specific keys). All output from loaders goes to **stderr only**.
|
|
413
|
+
|
|
414
|
+
### `shellEnv` — `captureShellEnv` + `applyShellEnv`
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
import { spawnSync } from "node:child_process";
|
|
418
|
+
|
|
419
|
+
function captureShellEnv(shell: string): Record<string, string> {
|
|
420
|
+
const result = spawnSync(shell, ["-l", "-c", "env"], {
|
|
421
|
+
encoding: "utf8",
|
|
422
|
+
timeout: 5000,
|
|
423
|
+
});
|
|
424
|
+
if (result.error || result.status !== 0) return {};
|
|
425
|
+
const env: Record<string, string> = {};
|
|
426
|
+
for (const line of result.stdout.split("\n")) {
|
|
427
|
+
const eq = line.indexOf("=");
|
|
428
|
+
if (eq > 0) {
|
|
429
|
+
env[line.slice(0, eq)] = line.slice(eq + 1);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return env;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function applyShellEnv(env: Record<string, string>): void {
|
|
436
|
+
for (const [key, val] of Object.entries(env)) {
|
|
437
|
+
if (key === "PATH") {
|
|
438
|
+
// Always merge PATH: prepend shell-only segments before host PATH
|
|
439
|
+
const existing = process.env.PATH ?? "";
|
|
440
|
+
const existingParts = new Set(existing.split(":"));
|
|
441
|
+
const shellOnly = val.split(":").filter((p) => !existingParts.has(p));
|
|
442
|
+
if (shellOnly.length > 0) {
|
|
443
|
+
process.env.PATH = [...shellOnly, existing].join(":");
|
|
444
|
+
}
|
|
445
|
+
} else if (process.env[key] === undefined) {
|
|
446
|
+
// Shell env is baseline; host-provided vars win
|
|
447
|
+
process.env[key] = val;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
In `cliMcpServeStdio`:
|
|
454
|
+
```typescript
|
|
455
|
+
const shellEnvCfg = root.mcpServer?.shellEnv;
|
|
456
|
+
if (shellEnvCfg) {
|
|
457
|
+
const shell =
|
|
458
|
+
typeof shellEnvCfg === "string"
|
|
459
|
+
? shellEnvCfg
|
|
460
|
+
: (process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"));
|
|
461
|
+
try {
|
|
462
|
+
applyShellEnv(captureShellEnv(shell));
|
|
463
|
+
} catch {
|
|
464
|
+
process.stderr.write(`[argsbarg] shellEnv: failed to capture shell environment from ${shell}\n`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
**Why `-l` (login) not `-i` (interactive):** `-i` requires a tty; `-l` (login shell) sources profile files without one and is the right flag for env capture. Never use `-i` here.
|
|
470
|
+
|
|
471
|
+
### `envFile` — `loadEnvFile`
|
|
472
|
+
|
|
473
|
+
Runs after `shellEnv`, so it can override anything the shell set:
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
function loadEnvFile(envFile: string): void {
|
|
477
|
+
const resolved = envFile.startsWith("~")
|
|
478
|
+
? envFile.replace("~", process.env.HOME ?? "")
|
|
479
|
+
: envFile;
|
|
480
|
+
let text: string;
|
|
481
|
+
try {
|
|
482
|
+
text = readFileSync(resolved, "utf8");
|
|
483
|
+
} catch {
|
|
484
|
+
return; // silently skip if not found
|
|
485
|
+
}
|
|
486
|
+
for (const line of text.split("\n")) {
|
|
487
|
+
const trimmed = line.trim();
|
|
488
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
489
|
+
const eq = trimmed.indexOf("=");
|
|
490
|
+
if (eq < 1) continue;
|
|
491
|
+
const key = trimmed.slice(0, eq).trim();
|
|
492
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
493
|
+
if (
|
|
494
|
+
(val.startsWith('"') && val.endsWith('"')) ||
|
|
495
|
+
(val.startsWith("'") && val.endsWith("'"))
|
|
496
|
+
) {
|
|
497
|
+
val = val.slice(1, -1);
|
|
498
|
+
}
|
|
499
|
+
if (key) process.env[key] = val; // always overwrite — envFile is authoritative
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
In `cliMcpServeStdio`:
|
|
505
|
+
```typescript
|
|
506
|
+
if (root.mcpServer?.envFile) {
|
|
507
|
+
loadEnvFile(root.mcpServer.envFile);
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Priority summary (lowest → highest)
|
|
512
|
+
|
|
513
|
+
| Source | Wins over |
|
|
514
|
+
|--------|-----------|
|
|
515
|
+
| Shell env (`shellEnv`) | nothing — baseline only |
|
|
516
|
+
| `envFile` | shell env |
|
|
517
|
+
| Host `process.env` at startup | shell env (not envFile) |
|
|
518
|
+
| PATH | always merged — no single source wins |
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## Step 8: Tests ([`src/index.test.ts`](src/index.test.ts))
|
|
523
|
+
|
|
524
|
+
### Unit tests
|
|
525
|
+
|
|
526
|
+
1. **`ctx.invocation` via cliRun** — invoke `cliRun` with a spy handler; assert `ctx.invocation === "cli"`.
|
|
527
|
+
2. **`ctx.invocation` via cliInvoke** — call `cliInvoke`; pass handler that reads `ctx.invocation`; assert `"mcp"`.
|
|
528
|
+
3. **`CliOptionKind.Enum` inputSchema** — fixture leaf with Enum option; assert `inputSchema.properties.mode.enum` equals choices array.
|
|
529
|
+
4. **`CliOptionKind.Enum` argv validation** — `cliInvoke` with invalid choice value → `kind: "error"`.
|
|
530
|
+
5. **`CliOptionKind.Enum` valid value** — valid choice → `kind: "ok"`.
|
|
531
|
+
6. **`cliValidateRoot` rejects Enum with no choices** — `choices: []` → `CliSchemaValidationError`.
|
|
532
|
+
7. **`cliValidateRoot` rejects Enum with duplicate choices** — throw.
|
|
533
|
+
8. **`mcpTool.description` override** — fixture leaf with `mcpTool: { description: "custom" }`; assert `collectMcpTools` tool description equals `"custom"`.
|
|
534
|
+
9. **`mcpTool.requiresEnv`** — fixture leaf; assert description includes `[requires env: TOKEN]`.
|
|
535
|
+
10. **`mcpResources` in resources/list** — subprocess test: `resources/list` response includes custom resource URI.
|
|
536
|
+
11. **`mcpResources` read** — subprocess test: `resources/read` with custom URI calls `load()` and returns body.
|
|
537
|
+
12. **`mcpResources` wrong URI** — returns `-32602`.
|
|
538
|
+
13. **`cliValidateRoot` rejects resources with duplicate URIs** — throw.
|
|
539
|
+
14. **`cliValidateRoot` rejects resource URI matching schemaResourceUri** — throw.
|
|
540
|
+
|
|
541
|
+
15. **`requiresEnv` enforcement — missing var** — subprocess test: tool with `requiresEnv: ['ARGSBARG_TEST_SECRET']`; call without that var set; assert `isError: true`, content mentions the var name.
|
|
542
|
+
16. **`requiresEnv` enforcement — var present** — same tool, set the var in `process.env` before spawning; assert `isError: false`.
|
|
543
|
+
17. **`shellEnv` PATH merge** — unit test `applyShellEnv` directly: call with a mock env where PATH has extra segments not in `process.env.PATH`; assert those segments are prepended and original PATH is preserved.
|
|
544
|
+
16. **`shellEnv` host wins for non-PATH vars** — unit test `applyShellEnv`: call with a key already in `process.env`; assert existing value is unchanged.
|
|
545
|
+
17. **`shellEnv` sets missing vars** — unit test `applyShellEnv`: call with a key absent from `process.env`; assert it is set.
|
|
546
|
+
18. **`envFile` overwrites** — unit test `loadEnvFile`: key already in `process.env`; assert it is overwritten.
|
|
547
|
+
19. **`envFile` priority over shellEnv** — call `applyShellEnv` then `loadEnvFile` with the same key; assert `envFile` value wins.
|
|
548
|
+
|
|
549
|
+
### Subprocess tests
|
|
550
|
+
|
|
551
|
+
- `mcpResources/list` and `mcpResources/read` require updating the `nested.ts` fixture or using a test-local fixture.
|
|
552
|
+
- Preferred: add `resources` to `nestedMcpFixture` in the test file (not `examples/nested.ts`) to avoid coupling the demo app to test-specific URIs.
|
|
553
|
+
- `envFile` loading: write a temp `.env` file in the test, start MCP server, call a tool whose handler reads `process.env`; assert the value is present. Use `Bun.file` + a temp path under `os.tmpdir()`.
|
|
554
|
+
- **Do not** write a subprocess test for `shellEnv` that asserts specific PATH contents — shell env varies per machine and makes CI fragile. The unit tests for `captureShellEnv`/`applyShellEnv` are sufficient; trust that `spawnSync` works.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Step 9: Docs + typegen
|
|
559
|
+
|
|
560
|
+
### [`docs/mcp.md`](docs/mcp.md)
|
|
561
|
+
|
|
562
|
+
Add or update sections:
|
|
563
|
+
|
|
564
|
+
- **Invocation context**: `ctx.invocation === "mcp"` in MCP calls; safe subprocess pattern (`Bun.spawn({ stdout: 'pipe' })` when `ctx.invocation === 'mcp'`; note that `Bun.spawn({ stdout: 'inherit' })` bypasses capture and corrupts the MCP wire).
|
|
565
|
+
- **`cliInvoke` (public)**: headless testing without subprocess.
|
|
566
|
+
- **`mcpTool.description`**: per-leaf description override.
|
|
567
|
+
- **`mcpTool.requiresEnv`**: surfaces as `[requires env: …]` in tool description.
|
|
568
|
+
- **`mcpServer.resources`**: custom resources with code example.
|
|
569
|
+
- **`mcpServer.shellEnv`**: spawns `$SHELL -l -c env` at startup; baseline merge (PATH always merged, other vars only when absent from host env); use when Cursor or other hosts lack toolchain access; warn on stderr if shell fails.
|
|
570
|
+
- **`mcpServer.envFile`**: path to `.env` file; `~` supported; runs after `shellEnv`; always overwrites; use for tokens and secrets.
|
|
571
|
+
- **Priority table**: shell → envFile → host env (PATH always merged).
|
|
572
|
+
- **Enum options** (brief cross-ref to README).
|
|
573
|
+
|
|
574
|
+
### [`README.md`](README.md)
|
|
575
|
+
|
|
576
|
+
- Add `Enum` row to the `CliOptionKind` table.
|
|
577
|
+
- One-line note in the MCP section pointing to `docs/mcp.md`.
|
|
578
|
+
|
|
579
|
+
### [`CHANGELOG.md`](CHANGELOG.md) — `[Unreleased]` → Added
|
|
580
|
+
|
|
581
|
+
- `ctx.invocation` (`"cli"` or `"mcp"`) on `CliContext`
|
|
582
|
+
- `cliInvoke` and `CliInvokeResult` exported from public API
|
|
583
|
+
- `CliOptionKind.Enum` with `choices` — JSON Schema `enum`, completions, parse validation
|
|
584
|
+
- `mcpTool.description` — per-leaf MCP tool description override
|
|
585
|
+
- `mcpTool.requiresEnv` — env variable requirements surfaced in tool description and enforced at `tools/call` time (missing vars return `isError: true` before the handler runs)
|
|
586
|
+
- `mcpServer.resources` — pluggable `CliMcpResource` items in `resources/list` and `resources/read`
|
|
587
|
+
- `mcpServer.shellEnv` — login shell env captured at MCP server start; PATH always merged, other vars fill gaps in host env
|
|
588
|
+
- `mcpServer.envFile` — `.env` file loaded into `process.env` at MCP server start, after `shellEnv`
|
|
589
|
+
|
|
590
|
+
### Typegen
|
|
591
|
+
|
|
592
|
+
```
|
|
593
|
+
just typegen
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Run after all type changes are complete. Verify `index.d.ts` exports `CliInvocation`, `CliMcpResource`, `CliOptionKind.Enum`, `cliInvoke`, `CliInvokeResult`, `CliInvokeKind`.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Pitfalls (do NOT)
|
|
601
|
+
|
|
602
|
+
- Export MCP server internals (`cliMcpServeStdio`, `collectMcpTools`, `buildToolCallSuccess`) from `index.ts`
|
|
603
|
+
- Use `Bun.file()` async API in `loadEnvFile` — use `readFileSync` from `node:fs` (sync, already imported in `tools.ts`)
|
|
604
|
+
- Allow `resources/read` to call `load()` before checking the URI — always find first, then load
|
|
605
|
+
- Validate `mcpResources` at runtime instead of schema validation time — catch it in `cliValidateRoot`
|
|
606
|
+
- Use `-i` (interactive) flag for `shellEnv` shell spawn — requires a tty; use `-l` (login) instead
|
|
607
|
+
- Write `shellEnv` failure messages to stdout — corrupts the MCP wire; stderr only
|
|
608
|
+
- Spawn the shell async — `captureShellEnv` must be sync (`spawnSync`) since it runs before the NDJSON loop starts
|
|
609
|
+
- Check `requiresEnv` after `cliInvoke` — check it before, so agents get a clean error rather than a handler-level failure
|
|
610
|
+
- Write a subprocess test that asserts specific PATH entries from `shellEnv` — shell env is machine-specific; unit-test `applyShellEnv` instead
|
|
611
|
+
|
|
612
|
+
---
|
|
613
|
+
|
|
614
|
+
## File change summary
|
|
615
|
+
|
|
616
|
+
| File | Action |
|
|
617
|
+
|------|--------|
|
|
618
|
+
| [`src/types.ts`](src/types.ts) | `CliInvocation`, `CliOptionKind.Enum`, `choices` on `CliOption`, `CliMcpToolConfig` additions, `CliMcpResource`, `CliMcpServerConfig` additions |
|
|
619
|
+
| [`src/context.ts`](src/context.ts) | `invocation` param + field |
|
|
620
|
+
| [`src/validate.ts`](src/validate.ts) | Enum choices validation, `mcpResources` URI uniqueness |
|
|
621
|
+
| [`src/parse.ts`](src/parse.ts) | Enum value validation in `postParseValidate` |
|
|
622
|
+
| [`src/help.ts`](src/help.ts) | Enum choice label in option display |
|
|
623
|
+
| [`src/completion.ts`](src/completion.ts) | Enum choices in completion candidates |
|
|
624
|
+
| [`src/runtime.ts`](src/runtime.ts) | Pass `"cli"` to `CliContext` |
|
|
625
|
+
| [`src/invoke.ts`](src/invoke.ts) | Pass `"mcp"` to `CliContext` |
|
|
626
|
+
| [`src/index.ts`](src/index.ts) | Export `cliInvoke`, `CliInvokeKind`, `CliInvokeResult`, `CliInvocation`, `CliMcpResource` |
|
|
627
|
+
| [`src/mcp/tools.ts`](src/mcp/tools.ts) | `resolveToolDescription`, Enum in `optionProperty` |
|
|
628
|
+
| [`src/mcp/server.ts`](src/mcp/server.ts) | `allMcpResources` helper, updated `resources/list` + `resources/read` |
|
|
629
|
+
| [`src/mcp.ts`](src/mcp.ts) | `captureShellEnv`, `applyShellEnv`, `loadEnvFile`; both called at startup in order |
|
|
630
|
+
| [`src/index.test.ts`](src/index.test.ts) | All new unit + subprocess tests |
|
|
631
|
+
| [`docs/mcp.md`](docs/mcp.md) | New sections |
|
|
632
|
+
| [`README.md`](README.md) | Enum row, MCP blurb |
|
|
633
|
+
| [`CHANGELOG.md`](CHANGELOG.md) | Unreleased entries |
|
|
634
|
+
| [`index.d.ts`](index.d.ts) | Regenerated via `just typegen` |
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## Out of scope (explicitly deferred)
|
|
639
|
+
|
|
640
|
+
- Streaming `tools/call` (requires MCP progress notifications)
|
|
641
|
+
- Output truncation in MCP results
|
|
642
|
+
- Nested `fallbackCommand` on routing nodes
|
|
643
|
+
- GNU-style tail option parsing (`--flag` after positionals)
|
|
644
|
+
- Shared option groups / `extends` on commands
|
|
645
|
+
- `mcpTool.group` / `risk` annotations
|
|
646
|
+
- `structuredError` for JSON on exit(1)
|
|
647
|
+
- Hiding `mcpEnabled: false` commands from `--schema`
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.1] - 2026-06-19
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`ctx.invocation`** (`"cli"` or `"mcp"`) on `CliContext` for handler branching.
|
|
15
|
+
- **`cliInvoke`** and `CliInvokeResult` exported from the public API.
|
|
16
|
+
- **`CliOptionKind.Enum`** with `choices` — JSON Schema `enum`, shell completions, parse validation, help labels.
|
|
17
|
+
- **`mcpTool.description`** — per-leaf MCP tool description override.
|
|
18
|
+
- **`mcpTool.requiresEnv`** — env requirements in auto-generated descriptions; enforced at `tools/call` (empty string counts as absent).
|
|
19
|
+
- **`mcpServer.resources`** — pluggable `CliMcpResource` items in `resources/list` and `resources/read`.
|
|
20
|
+
- **`mcpServer.shellEnv`** — login-shell env captured at MCP server start; `PATH` always merged, other vars fill gaps in host env.
|
|
21
|
+
- **`mcpServer.envFile`** — `.env` file loaded into `process.env` after `shellEnv` (warns on stderr if missing).
|
|
22
|
+
|
|
10
23
|
## [1.4.0] - 2026-06-19
|
|
11
24
|
|
|
12
25
|
### Added
|
|
@@ -98,7 +111,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
98
111
|
- Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
|
|
99
112
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
100
113
|
|
|
101
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.4.
|
|
114
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.4.1...HEAD
|
|
115
|
+
[1.4.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.4.1
|
|
102
116
|
[1.4.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.4.0
|
|
103
117
|
[1.3.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.3.1
|
|
104
118
|
[1.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.3.0
|