@ian-pascoe/pi-mcp 0.1.1 → 0.3.0

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.
@@ -26,8 +26,56 @@ export const MCP_COMMAND_NAMES = [
26
26
  "subscribe",
27
27
  "unsubscribe",
28
28
  "logs",
29
+ "help",
29
30
  ] as const;
30
31
 
32
+ /** One command name accepted by the MCP command parser. */
33
+ export type McpCommandName = (typeof MCP_COMMAND_NAMES)[number];
34
+
35
+ /** Value options valid only for local stdio Server Definitions. */
36
+ export const MCP_ADD_LOCAL_VALUE_OPTIONS = ["cwd", "environment"] as const;
37
+
38
+ /** OAuth credential options accepted by remote Server Definitions. */
39
+ export const MCP_ADD_OAUTH_VALUE_OPTIONS = [
40
+ "client-id",
41
+ "client-secret",
42
+ "redirect-uri",
43
+ "scope",
44
+ ] as const;
45
+
46
+ /** Value options valid only for remote HTTP or SSE Server Definitions. */
47
+ export const MCP_ADD_REMOTE_VALUE_OPTIONS = [
48
+ "auth",
49
+ ...MCP_ADD_OAUTH_VALUE_OPTIONS,
50
+ "header",
51
+ "token",
52
+ ] as const;
53
+
54
+ /** Option names accepted by the MCP command parser, without leading dashes. */
55
+ export const MCP_COMMAND_OPTIONS = {
56
+ add: {
57
+ flags: ["local"],
58
+ values: [...MCP_ADD_LOCAL_VALUE_OPTIONS, ...MCP_ADD_REMOTE_VALUE_OPTIONS, "transport"],
59
+ },
60
+ auth: { flags: ["no-open"], values: ["callback", "code", "state"] },
61
+ disable: { flags: ["local"], values: [] },
62
+ enable: { flags: ["local"], values: [] },
63
+ help: { flags: [], values: [] },
64
+ list: { flags: ["json"], values: [] },
65
+ logout: { flags: ["all", "force"], values: [] },
66
+ logs: { flags: [], values: [] },
67
+ prompt: { flags: [], values: ["arg"] },
68
+ reconnect: { flags: [], values: [] },
69
+ remove: { flags: ["local", "logout"], values: [] },
70
+ status: { flags: [], values: [] },
71
+ subscribe: { flags: [], values: [] },
72
+ test: { flags: ["all", "json"], values: [] },
73
+ unsubscribe: { flags: [], values: [] },
74
+ } as const satisfies Record<
75
+ McpCommandName,
76
+ { readonly flags: readonly string[]; readonly values: readonly string[] }
77
+ >;
78
+
31
79
  /** Persistent and offline commands available through the standalone executable. */
32
80
  export const MCP_STANDALONE_COMMAND_NAMES = MCP_COMMAND_NAMES.slice(0, 8);
33
81
 
@@ -119,7 +167,8 @@ export type McpCommand =
119
167
  }
120
168
  | { readonly kind: "subscribe"; readonly server: string; readonly uri: string }
121
169
  | { readonly kind: "unsubscribe"; readonly server: string; readonly uri: string }
122
- | { readonly kind: "logs"; readonly level?: McpLoggingLevel; readonly server?: string };
170
+ | { readonly kind: "logs"; readonly server?: string }
171
+ | { readonly kind: "help" };
123
172
 
124
173
  /** Usage failure returned when command tokens cannot be parsed. */
125
174
  export interface McpCommandParseFailure {
@@ -208,23 +257,6 @@ const EXIT_CODES = {
208
257
 
209
258
  const GENERAL_USAGE = `Usage: pi-mcp <command> [options]
210
259
  Commands: ${MCP_STANDALONE_COMMAND_NAMES.join(", ")}`;
211
- const MCP_LOG_LEVELS = [
212
- "debug",
213
- "info",
214
- "notice",
215
- "warning",
216
- "error",
217
- "critical",
218
- "alert",
219
- "emergency",
220
- ] as const;
221
-
222
- /** MCP logging levels accepted by the shared `/mcp logs` grammar. */
223
- export type McpLoggingLevel = (typeof MCP_LOG_LEVELS)[number];
224
-
225
- function isMcpLoggingLevel(value: string): value is McpLoggingLevel {
226
- return MCP_LOG_LEVELS.some((candidate) => candidate === value);
227
- }
228
260
  const RUNTIME_HELP = `Commands: ${MCP_COMMAND_NAMES.join(", ")}`;
229
261
 
230
262
  const COMMAND_USAGE = {
@@ -234,7 +266,8 @@ const COMMAND_USAGE = {
234
266
  enable: "Usage: pi-mcp enable [-l|--local] <server>",
235
267
  list: "Usage: pi-mcp list [--json]",
236
268
  logout: "Usage: pi-mcp logout <server> | --all --force",
237
- logs: "Usage: /mcp logs [server] [--level LEVEL]",
269
+ logs: "Usage: /mcp logs [server]",
270
+ help: "Usage: /mcp help",
238
271
  prompt: "Usage: /mcp prompt <server> <prompt> [--arg NAME=VALUE]...",
239
272
  reconnect: "Usage: /mcp reconnect <server>",
240
273
  remove: "Usage: pi-mcp remove [-l|--local] [--logout] <server>",
@@ -256,13 +289,39 @@ function usageFailure(
256
289
  };
257
290
  }
258
291
 
259
- interface ParsedOptions {
292
+ /** Tokenized command options, including an unfinished completion value when requested. */
293
+ export interface McpCommandScannedOptions {
294
+ /** Normalized flag names present before the stdio delimiter. */
260
295
  readonly flags: ReadonlySet<string>;
296
+ /** Value option awaiting the current completion token. */
297
+ readonly pendingValue?: string;
298
+ /** Non-option tokens present before the stdio delimiter. */
261
299
  readonly positionals: readonly string[];
300
+ /** Unparsed stdio command tokens after `--`, or `undefined` when no delimiter exists. */
262
301
  readonly tail: readonly string[] | undefined;
302
+ /** Normalized value-option names and their supplied values. */
263
303
  readonly values: ReadonlyMap<string, readonly string[]>;
264
304
  }
265
305
 
306
+ /** Failed command option scan before semantic validation. */
307
+ export interface McpCommandOptionScanFailure {
308
+ /** Human-readable grammar failure. */
309
+ readonly message: string;
310
+ /** Discriminant for a failed scan. */
311
+ readonly ok: false;
312
+ }
313
+
314
+ /** Successful command option scan before semantic validation. */
315
+ export interface McpCommandOptionScanSuccess {
316
+ /** Discriminant for a successful scan. */
317
+ readonly ok: true;
318
+ /** Parsed option structure. */
319
+ readonly options: McpCommandScannedOptions;
320
+ }
321
+
322
+ /** Result of scanning command options before command-specific semantic validation. */
323
+ export type McpCommandOptionScanResult = McpCommandOptionScanFailure | McpCommandOptionScanSuccess;
324
+
266
325
  function normalizeOptionName(rawName: string): string {
267
326
  switch (rawName) {
268
327
  case "-l":
@@ -275,21 +334,24 @@ function normalizeOptionName(rawName: string): string {
275
334
  }
276
335
  }
277
336
 
278
- function parseOptions(
337
+ /** Scan MCP command options in strict parser mode or tolerant completion mode. */
338
+ export function scanMcpCommandOptions(
279
339
  tokens: readonly string[],
280
- flagNames: ReadonlySet<string>,
281
- valueNames: ReadonlySet<string>,
282
- allowDelimiter = false,
283
- ): ParsedOptions | string {
340
+ command: McpCommandName,
341
+ mode: "completion" | "strict",
342
+ ): McpCommandOptionScanResult {
284
343
  const flags = new Set<string>();
285
344
  const positionals: string[] = [];
286
345
  const values = new Map<string, string[]>();
346
+ const accepted = MCP_COMMAND_OPTIONS[command];
347
+ const flagNames = new Set<string>(accepted.flags);
348
+ const valueNames = new Set<string>(accepted.values);
287
349
  for (let index = 0; index < tokens.length; index += 1) {
288
350
  const token = tokens[index];
289
351
  if (token === undefined) continue;
290
352
  if (token === "--") {
291
- if (!allowDelimiter) return "unexpected -- delimiter";
292
- return { flags, positionals, tail: tokens.slice(index + 1), values };
353
+ if (command !== "add") return { message: "unexpected -- delimiter", ok: false };
354
+ return { ok: true, options: { flags, positionals, tail: tokens.slice(index + 1), values } };
293
355
  }
294
356
  if (!token.startsWith("-")) {
295
357
  positionals.push(token);
@@ -299,23 +361,37 @@ function parseOptions(
299
361
  const rawName = equalsIndex < 0 ? token : token.slice(0, equalsIndex);
300
362
  const name = normalizeOptionName(rawName);
301
363
  if (flagNames.has(name)) {
302
- if (equalsIndex >= 0) return `option ${rawName} does not accept a value`;
364
+ if (equalsIndex >= 0)
365
+ return { message: `option ${rawName} does not accept a value`, ok: false };
303
366
  flags.add(name);
304
367
  continue;
305
368
  }
306
- if (!valueNames.has(name)) return `unknown option ${rawName}`;
369
+ if (!valueNames.has(name)) return { message: `unknown option ${rawName}`, ok: false };
307
370
  const value = equalsIndex < 0 ? tokens[index + 1] : token.slice(equalsIndex + 1);
308
371
  if (value === undefined || (equalsIndex < 0 && value.startsWith("--"))) {
309
- return `option ${rawName} requires a value`;
372
+ return mode === "completion" && value === undefined
373
+ ? {
374
+ ok: true,
375
+ options: { flags, pendingValue: name, positionals, tail: undefined, values },
376
+ }
377
+ : { message: `option ${rawName} requires a value`, ok: false };
310
378
  }
311
379
  if (equalsIndex < 0) index += 1;
312
380
  const existing = values.get(name) ?? [];
313
381
  values.set(name, [...existing, value]);
314
382
  }
315
- return { flags, positionals, tail: undefined, values };
383
+ return { ok: true, options: { flags, positionals, tail: undefined, values } };
316
384
  }
317
385
 
318
- function oneValue(options: ParsedOptions, name: string): string | undefined {
386
+ function parseOptions(
387
+ tokens: readonly string[],
388
+ command: McpCommandName,
389
+ ): McpCommandScannedOptions | string {
390
+ const result = scanMcpCommandOptions(tokens, command, "strict");
391
+ return result.ok ? result.options : result.message;
392
+ }
393
+
394
+ function oneValue(options: McpCommandScannedOptions, name: string): string | undefined {
319
395
  return options.values.get(name)?.at(-1);
320
396
  }
321
397
 
@@ -337,7 +413,7 @@ function parseAssignments(
337
413
  return result;
338
414
  }
339
415
 
340
- function parseScope(options: ParsedOptions): McpCommandSettingsScope {
416
+ function parseScope(options: McpCommandScannedOptions): McpCommandSettingsScope {
341
417
  return options.flags.has("local") ? "project" : "global";
342
418
  }
343
419
 
@@ -350,57 +426,107 @@ function isHttpUrl(value: string): boolean {
350
426
  }
351
427
  }
352
428
 
429
+ /** Authentication mode inferred from `add` options. */
430
+ export type McpAddAuthenticationMode = "bearer" | "none" | "oauth" | undefined;
431
+
432
+ /** Invalid combination of MCP `add` authentication options. */
433
+ export interface McpAddAuthenticationFailure {
434
+ /** Parser-compatible explanation of the incompatible options. */
435
+ readonly message: string;
436
+ /** Discriminant for incompatible authentication options. */
437
+ readonly ok: false;
438
+ }
439
+
440
+ /** Compatible MCP `add` authentication options. */
441
+ export interface McpAddAuthenticationSuccess {
442
+ /** Discriminant for compatible authentication options. */
443
+ readonly ok: true;
444
+ /** Explicit or inferred authentication mode. */
445
+ readonly type: McpAddAuthenticationMode;
446
+ }
447
+
448
+ /** Compatibility result for MCP `add` authentication options. */
449
+ export type McpAddAuthenticationResult = McpAddAuthenticationFailure | McpAddAuthenticationSuccess;
450
+
451
+ /** Classify MCP `add` authentication options for strict parsing or partial completion. */
452
+ export function classifyMcpAddAuthentication(
453
+ options: McpCommandScannedOptions,
454
+ mode: "completion" | "strict",
455
+ ): McpAddAuthenticationResult {
456
+ const configuredType = oneValue(options, "auth");
457
+ const token = oneValue(options, "token");
458
+ const oauth = MCP_ADD_OAUTH_VALUE_OPTIONS.some((name) => options.values.has(name));
459
+ const type = configuredType ?? (token !== undefined ? "bearer" : oauth ? "oauth" : undefined);
460
+ if (type === undefined) return { ok: true, type };
461
+ if (type === "none") {
462
+ return token === undefined && !oauth
463
+ ? { ok: true, type }
464
+ : { message: "auth type none cannot include credential options", ok: false };
465
+ }
466
+ if (type === "bearer") {
467
+ if (mode === "strict" && (token === undefined || token.length === 0))
468
+ return { message: "bearer auth requires --token", ok: false };
469
+ if (oauth) return { message: "bearer auth cannot include OAuth options", ok: false };
470
+ return { ok: true, type };
471
+ }
472
+ if (type === "oauth") {
473
+ return token === undefined
474
+ ? { ok: true, type }
475
+ : { message: "OAuth auth cannot include --token", ok: false };
476
+ }
477
+ return { message: "--auth must be none, bearer, or oauth", ok: false };
478
+ }
479
+
480
+ /** Local, remote, undecided, or incompatible MCP `add` transport form. */
481
+ export type McpAddTransportMode = "both" | "invalid" | "local" | "remote";
482
+
483
+ /** Classify local and remote MCP `add` option signals without performing I/O. */
484
+ export function classifyMcpAddTransportMode(
485
+ options: McpCommandScannedOptions,
486
+ ): McpAddTransportMode {
487
+ const transport = oneValue(options, "transport");
488
+ if (
489
+ transport !== undefined &&
490
+ transport !== "http" &&
491
+ transport !== "sse" &&
492
+ transport !== "stdio"
493
+ )
494
+ return "invalid";
495
+ if (options.positionals.length > 2) return "invalid";
496
+ const local =
497
+ options.tail !== undefined ||
498
+ transport === "stdio" ||
499
+ MCP_ADD_LOCAL_VALUE_OPTIONS.some((name) => options.values.has(name));
500
+ const remote =
501
+ options.positionals.length > 1 ||
502
+ transport === "http" ||
503
+ transport === "sse" ||
504
+ MCP_ADD_REMOTE_VALUE_OPTIONS.some((name) => options.values.has(name));
505
+ return local && remote ? "invalid" : local ? "local" : remote ? "remote" : "both";
506
+ }
507
+
353
508
  function parseRemoteAuth(
354
- options: ParsedOptions,
509
+ options: McpCommandScannedOptions,
355
510
  ): McpAddServerDefinition extends infer _Definition
356
511
  ?
357
512
  | Exclude<Extract<McpAddServerDefinition, { url: string }>["auth"], undefined>
358
513
  | undefined
359
514
  | string
360
515
  : never {
361
- const configuredType = oneValue(options, "auth");
362
516
  const token = oneValue(options, "token");
363
517
  const clientId = oneValue(options, "client-id");
364
518
  const clientSecret = oneValue(options, "client-secret");
365
519
  const redirectUri = oneValue(options, "redirect-uri");
366
520
  const scopes = options.values.get("scope") ?? [];
367
- const inferredType =
368
- token !== undefined
369
- ? "bearer"
370
- : clientId !== undefined ||
371
- clientSecret !== undefined ||
372
- redirectUri !== undefined ||
373
- scopes.length > 0
374
- ? "oauth"
375
- : undefined;
376
- const type = configuredType ?? inferredType;
521
+ const compatibility = classifyMcpAddAuthentication(options, "strict");
522
+ if (!compatibility.ok) return compatibility.message;
523
+ const type = compatibility.type;
377
524
  if (type === undefined) return undefined;
378
- if (type === "none") {
379
- if (
380
- token !== undefined ||
381
- clientId !== undefined ||
382
- clientSecret !== undefined ||
383
- redirectUri !== undefined ||
384
- scopes.length > 0
385
- ) {
386
- return "auth type none cannot include credential options";
387
- }
388
- return { type: "none" };
389
- }
525
+ if (type === "none") return { type: "none" };
390
526
  if (type === "bearer") {
391
- if (token === undefined || token.length === 0) return "bearer auth requires --token";
392
- if (
393
- clientId !== undefined ||
394
- clientSecret !== undefined ||
395
- redirectUri !== undefined ||
396
- scopes.length > 0
397
- ) {
398
- return "bearer auth cannot include OAuth options";
399
- }
527
+ if (token === undefined) return "bearer auth requires --token";
400
528
  return { token, type: "bearer" };
401
529
  }
402
- if (type !== "oauth") return "--auth must be none, bearer, or oauth";
403
- if (token !== undefined) return "OAuth auth cannot include --token";
404
530
  return {
405
531
  ...(clientId === undefined ? {} : { clientId }),
406
532
  ...(clientSecret === undefined ? {} : { clientSecret }),
@@ -411,47 +537,24 @@ function parseRemoteAuth(
411
537
  }
412
538
 
413
539
  function parseAdd(args: readonly string[]): McpCommandParseResult {
414
- const options = parseOptions(
415
- args,
416
- new Set(["local"]),
417
- new Set([
418
- "auth",
419
- "client-id",
420
- "client-secret",
421
- "cwd",
422
- "environment",
423
- "header",
424
- "redirect-uri",
425
- "scope",
426
- "token",
427
- "transport",
428
- ]),
429
- true,
430
- );
540
+ const options = parseOptions(args, "add");
431
541
  if (typeof options === "string") return usageFailure("add", options);
432
542
  const name = options.positionals[0];
433
543
  if (name === undefined || name.length === 0)
434
544
  return usageFailure("add", "server name is required");
545
+ const transportMode = classifyMcpAddTransportMode(options);
435
546
  if (options.tail !== undefined) {
436
547
  if (options.positionals.length !== 1)
437
548
  return usageFailure("add", "a local add cannot also include a URL");
438
549
  const command = options.tail[0];
439
550
  if (command === undefined || command.length === 0)
440
551
  return usageFailure("add", "local command is required after --");
441
- for (const remoteOption of [
442
- "auth",
443
- "client-id",
444
- "client-secret",
445
- "header",
446
- "redirect-uri",
447
- "scope",
448
- "token",
449
- ]) {
552
+ for (const remoteOption of MCP_ADD_REMOTE_VALUE_OPTIONS) {
450
553
  if (options.values.has(remoteOption))
451
554
  return usageFailure("add", `local add cannot include --${remoteOption}`);
452
555
  }
453
556
  const transport = oneValue(options, "transport");
454
- if (transport !== undefined && transport !== "stdio") {
557
+ if (transport !== undefined && transportMode !== "local") {
455
558
  return usageFailure("add", "local transport must be stdio");
456
559
  }
457
560
  const environment = parseAssignments(options.values.get("environment") ?? [], "environment");
@@ -476,13 +579,13 @@ function parseAdd(args: readonly string[]): McpCommandParseResult {
476
579
  }
477
580
  if (options.positionals.length !== 2)
478
581
  return usageFailure("add", "remote add requires a name and URL");
479
- if (options.values.has("cwd") || options.values.has("environment")) {
582
+ if (MCP_ADD_LOCAL_VALUE_OPTIONS.some((name) => options.values.has(name))) {
480
583
  return usageFailure("add", "remote add cannot include local process options");
481
584
  }
482
585
  const url = options.positionals[1] ?? "";
483
586
  if (!isHttpUrl(url)) return usageFailure("add", "remote URL must be absolute HTTP or HTTPS");
484
587
  const transport = oneValue(options, "transport") ?? "http";
485
- if (transport !== "http" && transport !== "sse")
588
+ if (transportMode !== "remote" || (transport !== "http" && transport !== "sse"))
486
589
  return usageFailure("add", "remote transport must be http or sse");
487
590
  const headers = parseAssignments(options.values.get("header") ?? [], "header");
488
591
  if (typeof headers === "string") return usageFailure("add", headers);
@@ -509,11 +612,7 @@ function parseScopedServer(
509
612
  kind: "remove" | "enable" | "disable",
510
613
  args: readonly string[],
511
614
  ): McpCommandParseResult {
512
- const options = parseOptions(
513
- args,
514
- new Set(kind === "remove" ? ["local", "logout"] : ["local"]),
515
- new Set(),
516
- );
615
+ const options = parseOptions(args, kind);
517
616
  if (typeof options === "string") return usageFailure(kind, options);
518
617
  if (options.positionals.length !== 1)
519
618
  return usageFailure(kind, "exactly one server name is required");
@@ -553,7 +652,7 @@ export function parseMcpCommand(
553
652
  if (commandName === "remove" || commandName === "enable" || commandName === "disable")
554
653
  return parseScopedServer(commandName, rest);
555
654
  if (commandName === "list") {
556
- const options = parseOptions(rest, new Set(["json"]), new Set());
655
+ const options = parseOptions(rest, "list");
557
656
  if (typeof options === "string" || options.positionals.length > 0)
558
657
  return usageFailure(
559
658
  "list",
@@ -564,11 +663,7 @@ export function parseMcpCommand(
564
663
  return { command: { json: options.flags.has("json"), kind: "list" }, ok: true };
565
664
  }
566
665
  if (commandName === "auth") {
567
- const options = parseOptions(
568
- rest,
569
- new Set(["no-open"]),
570
- new Set(["callback", "code", "state"]),
571
- );
666
+ const options = parseOptions(rest, "auth");
572
667
  if (typeof options === "string") return usageFailure("auth", options);
573
668
  if (options.positionals.length !== 1)
574
669
  return usageFailure(
@@ -595,7 +690,7 @@ export function parseMcpCommand(
595
690
  };
596
691
  }
597
692
  if (commandName === "logout") {
598
- const options = parseOptions(rest, new Set(["all", "force"]), new Set());
693
+ const options = parseOptions(rest, "logout");
599
694
  if (typeof options === "string") return usageFailure("logout", options);
600
695
  const all = options.flags.has("all");
601
696
  const force = options.flags.has("force");
@@ -612,7 +707,7 @@ export function parseMcpCommand(
612
707
  };
613
708
  }
614
709
  if (commandName === "test") {
615
- const options = parseOptions(rest, new Set(["all", "json"]), new Set());
710
+ const options = parseOptions(rest, "test");
616
711
  if (typeof options === "string") return usageFailure("test", options);
617
712
  if (surface === "runtime" && options.flags.has("json"))
618
713
  return usageFailure("test", "--json is standalone-only");
@@ -633,12 +728,16 @@ export function parseMcpCommand(
633
728
  if (rest.length > 0) return usageFailure("status", "status accepts no arguments");
634
729
  return { command: { includeHelp: false, kind: "status" }, ok: true };
635
730
  }
731
+ if (commandName === "help") {
732
+ if (rest.length > 0) return usageFailure("help", "help accepts no arguments");
733
+ return { command: { kind: "help" }, ok: true };
734
+ }
636
735
  if (commandName === "reconnect") {
637
736
  if (rest.length !== 1) return usageFailure("reconnect", "exactly one server name is required");
638
737
  return { command: { kind: "reconnect", server: rest[0] ?? "" }, ok: true };
639
738
  }
640
739
  if (commandName === "prompt") {
641
- const options = parseOptions(rest, new Set(), new Set(["arg"]));
740
+ const options = parseOptions(rest, "prompt");
642
741
  if (typeof options === "string") return usageFailure("prompt", options);
643
742
  if (options.positionals.length !== 2)
644
743
  return usageFailure("prompt", "server and prompt names are required");
@@ -658,20 +757,15 @@ export function parseMcpCommand(
658
757
  if (rest.length !== 2) return usageFailure(commandName, "server and resource URI are required");
659
758
  return { command: { kind: commandName, server: rest[0] ?? "", uri: rest[1] ?? "" }, ok: true };
660
759
  }
661
- const options = parseOptions(rest, new Set(), new Set(["level"]));
760
+ const options = parseOptions(rest, "logs");
662
761
  if (typeof options === "string" || options.positionals.length > 1)
663
762
  return usageFailure(
664
763
  "logs",
665
764
  typeof options === "string" ? options : "logs accepts at most one server name",
666
765
  );
667
- const level = oneValue(options, "level");
668
- if (level !== undefined && !isMcpLoggingLevel(level)) {
669
- return usageFailure("logs", `unknown logging level ${level}`);
670
- }
671
766
  return {
672
767
  command: {
673
768
  kind: "logs",
674
- ...(level === undefined ? {} : { level }),
675
769
  ...(options.positionals[0] === undefined ? {} : { server: options.positionals[0] }),
676
770
  },
677
771
  ok: true,
@@ -813,12 +907,11 @@ export async function executeMcpCommand(
813
907
  case "logs": {
814
908
  const live = liveAdapter(adapters);
815
909
  if ("exitCode" in live) return live;
816
- result = await live.logs({
817
- ...(command.level === undefined ? {} : { level: command.level }),
818
- ...(command.server === undefined ? {} : { server: command.server }),
819
- });
910
+ result = await live.logs(command.server === undefined ? {} : { server: command.server });
820
911
  break;
821
912
  }
913
+ case "help":
914
+ return successResult({ message: RUNTIME_HELP, ok: true }, false);
822
915
  }
823
916
  if (!result.ok) return adapterFailure(result.category, result.message);
824
917
  const json = (command.kind === "list" || command.kind === "test") && command.json;
@@ -829,43 +922,91 @@ export async function executeMcpCommand(
829
922
  }
830
923
  }
831
924
 
832
- /** Split a `/mcp` argument string without invoking a shell or expanding variables. */
833
- export function tokenizeMcpCommandLine(line: string): string[] {
834
- const tokens: string[] = [];
925
+ /** Current token and replacement boundary for an unfinished MCP command. */
926
+ export interface McpCommandCompletionToken {
927
+ /** Opening quote style to preserve when replacing the token. */
928
+ readonly quote: "'" | '"' | undefined;
929
+ /** Zero-based source offset where replacement begins. */
930
+ readonly start: number;
931
+ /** Decoded token value before the cursor. */
932
+ readonly value: string;
933
+ }
934
+
935
+ /** Tolerant tokenization of a complete or unfinished MCP command prefix. */
936
+ export interface McpCommandCompletionPrefix {
937
+ /** Source text preserved before replacing the current token. */
938
+ readonly beforeCurrent: string;
939
+ /** Decoded tokens completed before the current token. */
940
+ readonly completed: readonly string[];
941
+ /** Token being completed at the cursor. */
942
+ readonly current: McpCommandCompletionToken;
943
+ /** Whether the source ends inside a token rather than after whitespace. */
944
+ readonly currentStarted: boolean;
945
+ /** Whether the source ends before its opening quote closes. */
946
+ readonly incompleteQuote: boolean;
947
+ }
948
+
949
+ /** Tokenize an unfinished MCP command while retaining its current replacement boundary. */
950
+ export function tokenizeMcpCommandCompletionPrefix(line: string): McpCommandCompletionPrefix {
951
+ const tokens: McpCommandCompletionToken[] = [];
835
952
  let current = "";
836
953
  let quote: "'" | '"' | undefined;
954
+ let quoteAtStart: "'" | '"' | undefined;
837
955
  let escaping = false;
956
+ let start = line.length;
838
957
  let tokenStarted = false;
839
- for (const character of line) {
958
+ for (let index = 0; index < line.length; index += 1) {
959
+ const character = line[index] ?? "";
840
960
  if (escaping) {
841
961
  current += character;
842
- tokenStarted = true;
843
962
  escaping = false;
844
963
  } else if (character === "\\" && quote !== "'") {
964
+ if (!tokenStarted) start = index;
845
965
  escaping = true;
846
966
  tokenStarted = true;
847
967
  } else if (quote !== undefined) {
848
968
  if (character === quote) quote = undefined;
849
969
  else current += character;
850
- tokenStarted = true;
851
970
  } else if (character === "'" || character === '"') {
971
+ if (!tokenStarted) {
972
+ quoteAtStart = character;
973
+ start = index;
974
+ }
852
975
  quote = character;
853
976
  tokenStarted = true;
854
977
  } else if (/\s/u.test(character)) {
855
978
  if (tokenStarted) {
856
- tokens.push(current);
979
+ tokens.push({ quote: quoteAtStart, start, value: current });
857
980
  current = "";
981
+ quoteAtStart = undefined;
858
982
  tokenStarted = false;
859
983
  }
860
984
  } else {
985
+ if (!tokenStarted) start = index;
861
986
  current += character;
862
987
  tokenStarted = true;
863
988
  }
864
989
  }
865
- if (quote !== undefined) throw new Error("MCP command has an unterminated quote");
866
990
  if (escaping) current += "\\";
867
- if (tokenStarted) tokens.push(current);
868
- return tokens;
991
+ if (tokenStarted) tokens.push({ quote: quoteAtStart, start, value: current });
992
+ else tokens.push({ quote: undefined, start: line.length, value: "" });
993
+ const active = tokens.at(-1) ?? { quote: undefined, start: line.length, value: "" };
994
+ return {
995
+ beforeCurrent: line.slice(0, active.start),
996
+ completed: tokens.slice(0, -1).map(({ value }) => value),
997
+ current: active,
998
+ currentStarted: tokenStarted,
999
+ incompleteQuote: quote !== undefined,
1000
+ };
1001
+ }
1002
+
1003
+ /** Split a `/mcp` argument string without invoking a shell or expanding variables. */
1004
+ export function tokenizeMcpCommandLine(line: string): string[] {
1005
+ const prefix = tokenizeMcpCommandCompletionPrefix(line);
1006
+ if (prefix.incompleteQuote) throw new Error("MCP command has an unterminated quote");
1007
+ return prefix.currentStarted
1008
+ ? [...prefix.completed, prefix.current.value]
1009
+ : [...prefix.completed];
869
1010
  }
870
1011
 
871
1012
  /** Parse and execute one pre-tokenized MCP command without reinterpreting argument contents. */