@ikuma.cloud/pix-mcp 0.0.9 → 0.0.11

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/README.md CHANGED
@@ -28,28 +28,45 @@ Review the file first; `npx` downloads and runs the pinned package.
28
28
 
29
29
  ## Configuration
30
30
 
31
- By default, read only `.mcp.json` in Pi's working directory. There is no ancestor
32
- search, global config merge, automatic import, or persistent metadata cache.
33
- Relative `--mcp-config` paths resolve from that working directory; a stdio
34
- server's `cwd` resolves from the config directory and defaults to that directory.
31
+ Without an explicit selection, the adapter reads these files in order:
35
32
 
36
- The adapter automatically loads the selected file and starts its enabled servers
37
- in all modes, without a confirmation prompt. Use `--mcp-config <path>` to select
38
- a different file.
33
+ 1. `<agent-dir>/mcp.json` for user-level servers. Pi's agent directory defaults
34
+ to `~/.pi/agent` and can be changed with `PI_CODING_AGENT_DIR`.
35
+ 2. `.mcp.json` in Pi's working directory for project-level servers.
39
36
 
40
- **Breaking change:** `--mcp-trust-config` has been removed. Remove it from existing
41
- launch commands; print and JSON sessions also load the default file automatically.
37
+ Missing files are ignored. Project declarations replace global declarations by
38
+ server name as complete entries; fields are not merged. A project entry with
39
+ `"disabled": true` masks the corresponding global server. An invalid project
40
+ entry also masks its global counterpart and reports the validation issue instead
41
+ of starting the global definition. Relative stdio `cwd` values resolve from the
42
+ file that declares the server and default to that file's directory. There is no
43
+ ancestor search, cross-client import, or persistent metadata cache.
44
+
45
+ Use `--mcp-config <path>` to read only that file for the session, without global
46
+ or project fallback. A relative explicit path resolves from Pi's working
47
+ directory. The adapter automatically starts every effective enabled server in all
48
+ modes without a confirmation prompt.
42
49
 
43
- Review the file before starting Pi: it can launch arbitrary local programs and
44
- contact remote services. A bare `.mcp.json` is not protected by Pi's project-trust
45
- mechanism, and the adapter is not an OS sandbox. Set a server's `disabled` field
46
- to `true` to prevent it from starting.
50
+ `getAgentDir()` cannot observe an `agentDir` supplied only through Pi 0.87's SDK
51
+ because extensions do not receive it. Embedded users should also set
52
+ `PI_CODING_AGENT_DIR` when they want the global file to follow an SDK override.
47
53
 
48
- At session startup, interactive and RPC sessions receive an `MCP config: <path>`
49
- notice with the resolved absolute path after the file is read. The notice
50
- identifies the configuration file, not whether every server connected; it never
51
- includes configuration contents. Missing, unreadable, or malformed files produce
52
- no notice. Print and JSON sessions remain silent.
54
+ **Breaking change:** `--mcp-trust-config` has been removed. Remove it from existing
55
+ launch commands; print and JSON sessions also load the default files
56
+ automatically.
57
+
58
+ Review both files before starting Pi: they can launch arbitrary local programs
59
+ and contact remote services. Global servers start in every working directory. A
60
+ bare project `.mcp.json` is not protected by Pi's project-trust mechanism, and
61
+ the adapter is not an OS sandbox. Set a server's `disabled` field to `true` to
62
+ prevent it from starting.
63
+
64
+ At session startup, interactive and RPC sessions display every successfully
65
+ loaded file as `MCP config (<scope>): <absolute-path>`, in precedence order. They
66
+ display `MCP config: none found` when no applicable file exists, or a path-only
67
+ error notice when a file fails to load. These notices occur before server startup
68
+ and never include configuration contents or indicate that every server connected.
69
+ Print and JSON sessions remain silent.
53
70
 
54
71
  ```json
55
72
  {
@@ -110,15 +127,17 @@ Unknown fields invalidate their server entry rather than being silently ignored.
110
127
 
111
128
  ### Configuration errors
112
129
 
113
- An invalid server entry is skipped without hiding healthy servers. Discovery's
114
- `servers` list includes a safe diagnostic for each skipped entry; a valid server
115
- name can still be used with `mcp({ action: "list", server: "name" })` to inspect
130
+ An invalid server entry is skipped without hiding unrelated healthy servers.
131
+ Discovery's `servers` list includes a safe diagnostic for each skipped entry; a
132
+ valid server name can still be used with `mcp({ action: "list", server: "name" })` to inspect
116
133
  its status. Invalid names are replaced with their one-based entry positions.
117
134
  Diagnostics identify supported fields or migration steps without echoing URLs,
118
135
  commands, headers, argument values, or environment-variable names.
119
136
 
120
137
  Malformed JSON, invalid root structure, unknown root fields, and the file/server
121
- count limits remain fatal for the whole file. An invalid-only configuration
138
+ count limits remain fatal. A fatal error in either default file prevents servers
139
+ from the other file from starting. Each file may declare at most 32 entries, and
140
+ the merged result may enable at most 32 servers. An invalid-only configuration
122
141
  reports that no valid servers remain. Disabled entries are omitted, not reported
123
142
  as failed connections. Correct the file and reload Pi to retry.
124
143
 
@@ -282,8 +301,9 @@ commands behind:
282
301
 
283
302
  In TUI mode, `/mcp-prompt` without arguments opens a native prompt selector
284
303
  that shows the focused prompt's title and description, followed by native input
285
- dialogs. Required and optional arguments are requested in declaration order;
286
- leaving an optional input empty omits it. The rendered prompt is placed in Pi's
304
+ dialogs. Each dialog shows the argument description when the server provides
305
+ one. Required and optional arguments are requested in declaration order; leaving
306
+ an optional input empty omits it. The rendered prompt is placed in Pi's
287
307
  editor so you can review or modify it before sending. Image blocks are stored in
288
308
  private temporary files and inserted as `@` references. Escape cancels without
289
309
  retrieving the prompt. The explicit `list` and `run` forms remain available in
@@ -384,8 +404,9 @@ These artifacts may contain sensitive data and are **not deleted at session
384
404
  shutdown**; remove them when no
385
405
  longer needed. Output limits are not a complete memory or security sandbox.
386
406
 
387
- Configuration is limited to 256 KiB and 32 servers. Startup connects at most four
388
- servers concurrently. Each tool or prompt catalog is limited to 1000 entries,
407
+ Each configuration file is limited to 256 KiB and 32 entries; the merged result
408
+ can enable at most 32 servers. Startup connects at most four servers concurrently.
409
+ Each tool or prompt catalog is limited to 1000 entries,
389
410
  100 pagination cursors, and 2 MiB of metadata. Direct resources and templates
390
411
  share a 1000-entry and 2 MiB limit, with up to 100 cursors for each endpoint.
391
412
  Individual input/output schemas are limited to 64 KiB. Prompt arguments and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikuma.cloud/pix-mcp",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/config.ts CHANGED
@@ -31,6 +31,19 @@ export interface Config {
31
31
  servers: ServerConfig[];
32
32
  issues: ConfigIssue[];
33
33
  }
34
+ export interface MergedConfig {
35
+ servers: ServerConfig[];
36
+ issues: ConfigIssue[];
37
+ }
38
+
39
+ type ConfigDeclaration = ServerConfig | ConfigIssue | undefined;
40
+ // Raw names can contain credentials. Keep precedence identities private while
41
+ // exposing only validated server names or positional issue labels.
42
+ const declarationsByConfig = new WeakMap<
43
+ Config,
44
+ Map<string, ConfigDeclaration>
45
+ >();
46
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,48}$/;
34
47
 
35
48
  class ConfigError extends Error {
36
49
  readonly field: string | undefined;
@@ -121,7 +134,7 @@ function parseServer(
121
134
  path: string,
122
135
  env: NodeJS.ProcessEnv,
123
136
  ): ServerConfig | undefined {
124
- if (!/^[A-Za-z0-9_-]{1,48}$/.test(name))
137
+ if (!SERVER_NAME_PATTERN.test(name))
125
138
  invalid(
126
139
  undefined,
127
140
  "Server names must contain 1–48 ASCII letters, digits, underscores, or hyphens.",
@@ -268,15 +281,17 @@ export async function readConfig(
268
281
  invalid("mcpServers", "At most 32 servers are supported.");
269
282
  const servers: ServerConfig[] = [];
270
283
  const issues: ConfigIssue[] = [];
284
+ const declarations = new Map<string, ConfigDeclaration>();
271
285
  for (const [index, [name, raw]] of entries.entries()) {
272
286
  try {
273
287
  const server = parseServer(name, raw, path, env);
288
+ declarations.set(name, server);
274
289
  if (server) servers.push(server);
275
290
  } catch (error) {
276
291
  // Invalid names and unknown keys can themselves contain credentials. Only
277
292
  // validated names and our fixed validation messages reach discovery.
278
- issues.push({
279
- name: /^[A-Za-z0-9_-]{1,48}$/.test(name)
293
+ const issue: ConfigIssue = {
294
+ name: SERVER_NAME_PATTERN.test(name)
280
295
  ? name
281
296
  : `Invalid server #${index + 1}`,
282
297
  ...(error instanceof ConfigError && error.field
@@ -286,8 +301,38 @@ export async function readConfig(
286
301
  error instanceof ConfigError
287
302
  ? error.message
288
303
  : "Invalid MCP configuration. Server could not be validated.",
289
- });
304
+ };
305
+ declarations.set(name, issue);
306
+ issues.push(issue);
290
307
  }
291
308
  }
292
- return { path, servers, issues };
309
+ const config = { path, servers, issues };
310
+ declarationsByConfig.set(config, declarations);
311
+ return config;
312
+ }
313
+
314
+ export function mergeConfigs(configs: Config[]): MergedConfig {
315
+ const declarations = new Map<string, ConfigDeclaration>();
316
+ for (const config of configs) {
317
+ const sourceDeclarations = declarationsByConfig.get(config);
318
+ if (!sourceDeclarations)
319
+ invalid(undefined, "Configuration declarations are unavailable.");
320
+ for (const [name, declaration] of sourceDeclarations) {
321
+ // Reinsertion makes effective declaration order follow source precedence.
322
+ declarations.delete(name);
323
+ declarations.set(name, declaration);
324
+ }
325
+ }
326
+ const merged: MergedConfig = { servers: [], issues: [] };
327
+ for (const declaration of declarations.values()) {
328
+ if (!declaration) continue;
329
+ if ("message" in declaration) merged.issues.push(declaration);
330
+ else merged.servers.push(declaration);
331
+ }
332
+ if (merged.servers.length > 32)
333
+ invalid(
334
+ "mcpServers",
335
+ "At most 32 enabled servers are supported across merged configurations.",
336
+ );
337
+ return merged;
293
338
  }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { resolve } from "node:path";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
- import type {
4
- ExtensionAPI,
5
- ExtensionCommandContext,
6
- ExtensionContext,
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionCommandContext,
7
+ type ExtensionContext,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
  import type {
9
10
  Prompt,
@@ -13,7 +14,12 @@ import type {
13
14
  } from "@modelcontextprotocol/sdk/types.js";
14
15
  import { compact, entry, search, summary, type Entry } from "./catalog.ts";
15
16
  import { Connection } from "./client.ts";
16
- import { readConfig, type ConfigIssue, type ServerConfig } from "./config.ts";
17
+ import {
18
+ mergeConfigs,
19
+ readConfig,
20
+ type ConfigIssue,
21
+ type ServerConfig,
22
+ } from "./config.ts";
17
23
  import {
18
24
  formatEditableContent,
19
25
  formatResult,
@@ -69,6 +75,12 @@ interface ResourceSelection {
69
75
  item: ResourceEntry;
70
76
  }
71
77
 
78
+ type ConfigScope = "explicit" | "global" | "project";
79
+ interface ConfigSource {
80
+ scope: ConfigScope;
81
+ path: string;
82
+ }
83
+
72
84
  interface State {
73
85
  alive: boolean;
74
86
  status: string;
@@ -111,7 +123,7 @@ export default function mcp(pi: ExtensionAPI) {
111
123
  pi.registerFlag("mcp-config", {
112
124
  type: "string",
113
125
  description:
114
- "Read this MCP config file for this session (default: .mcp.json).",
126
+ "Read only this MCP config file for this session (otherwise merge the global and project configs).",
115
127
  });
116
128
  pi.registerCommand("mcp-prompt", {
117
129
  description: "Select, list, or run a user-controlled MCP prompt",
@@ -448,9 +460,14 @@ export default function mcp(pi: ExtensionAPI) {
448
460
  const dialogOptions = ctx.signal ? { signal: ctx.signal } : undefined;
449
461
  const argumentTokens: PromptArgumentToken[] = [];
450
462
  for (const argument of item.prompt.arguments ?? []) {
463
+ const description = compact(argument.description ?? "", 160);
464
+ const label = `${argument.name} (${argument.required ? "required" : "optional"})`;
465
+ const guidance = argument.required
466
+ ? "Enter a value"
467
+ : "Leave empty to omit";
451
468
  const value = await ctx.ui.input(
452
- `${argument.name} (${argument.required ? "required" : "optional"})`,
453
- argument.required ? "Enter a value" : "Leave empty to omit",
469
+ [label, description, guidance].filter(Boolean).join("\n"),
470
+ undefined,
454
471
  dialogOptions,
455
472
  );
456
473
  if (value === undefined) return;
@@ -810,6 +827,8 @@ export default function mcp(pi: ExtensionAPI) {
810
827
 
811
828
  async function start(ctx: ExtensionContext) {
812
829
  await stop();
830
+ const showConfigNotices =
831
+ ctx.hasUI && (ctx.mode === "tui" || ctx.mode === "rpc");
813
832
  const owner: State = {
814
833
  alive: true,
815
834
  status: "No MCP servers configured.",
@@ -824,19 +843,51 @@ export default function mcp(pi: ExtensionAPI) {
824
843
  draftGuards: new Set(),
825
844
  };
826
845
  state = owner;
846
+ let failureNotice: string | undefined;
827
847
  try {
828
848
  const flag = pi.getFlag("mcp-config");
829
- const explicit = typeof flag === "string" && flag.length > 0;
830
- const path = resolve(ctx.cwd, explicit ? flag : ".mcp.json");
831
- const config = await readConfig(path);
849
+ if (flag === "") {
850
+ failureNotice = "MCP config (explicit) failed: no path provided";
851
+ throw new Error(
852
+ "The explicitly selected MCP configuration path is empty.",
853
+ );
854
+ }
855
+ const explicit = typeof flag === "string";
856
+ const sources: ConfigSource[] = explicit
857
+ ? [{ scope: "explicit", path: resolve(ctx.cwd, flag) }]
858
+ : [
859
+ {
860
+ scope: "global",
861
+ path: resolve(getAgentDir(), "mcp.json"),
862
+ },
863
+ { scope: "project", path: resolve(ctx.cwd, ".mcp.json") },
864
+ ];
865
+ const loaded = [];
866
+ for (const source of sources) {
867
+ failureNotice = `MCP config (${source.scope}) failed: ${source.path}`;
868
+ const config = await readConfig(source.path);
869
+ current(owner);
870
+ failureNotice = undefined;
871
+ if (config) {
872
+ loaded.push({ ...source, config });
873
+ if (showConfigNotices)
874
+ ctx.ui.notify(
875
+ `MCP config (${source.scope}): ${source.path}`,
876
+ "info",
877
+ );
878
+ }
879
+ }
832
880
  current(owner);
833
- if (!config) {
881
+ if (loaded.length === 0) {
834
882
  if (explicit)
835
883
  owner.status =
836
884
  "The explicitly selected MCP configuration does not exist.";
885
+ if (showConfigNotices) ctx.ui.notify("MCP config: none found", "info");
837
886
  return;
838
887
  }
839
- if (ctx.hasUI) ctx.ui.notify(`MCP config: ${path}`, "info");
888
+ failureNotice = "MCP configuration merge failed.";
889
+ const config = mergeConfigs(loaded.map((source) => source.config));
890
+ failureNotice = undefined;
840
891
  owner.configIssues = config.issues;
841
892
  owner.status =
842
893
  config.issues.length > 0
@@ -870,9 +921,12 @@ export default function mcp(pi: ExtensionAPI) {
870
921
  }),
871
922
  );
872
923
  } catch (error) {
873
- if (owner.alive)
924
+ if (owner.alive) {
874
925
  owner.status =
875
926
  error instanceof Error ? error.message : "MCP initialization failed.";
927
+ if (showConfigNotices)
928
+ ctx.ui.notify(failureNotice ?? "MCP initialization failed.", "error");
929
+ }
876
930
  } finally {
877
931
  if (owner.alive && state === owner) registerDiscovery();
878
932
  }