privateer-agent 0.14.6 → 0.15.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.
@@ -44,8 +44,8 @@ export interface CatalogEntry {
44
44
  * flag rather than falling out of the URL: every other http entry here is a remote
45
45
  * service the user authorizes, so "must authenticate" is derived from
46
46
  * `transport === "http"` alone. That is false here — there is nothing to authorize
47
- * — so the flag is what makes draftFromCatalog emit `auth: "none"`. Without it the
48
- * adapter goes hunting for an authorization server that does not exist.
47
+ * — so the flag is what makes draftFromCatalog emit `auth: "none"`, and the app
48
+ * doesn't tell the user to sign in to something with no sign-in.
49
49
  *
50
50
  * Always pair with `hosted: false`: a hosted enclave has no route to the user's
51
51
  * loopback, and hostedCapable()'s derived rule keys on `oauth`, not on this.
@@ -199,7 +199,8 @@ export const MCP_CATALOG: CatalogEntry[] = [
199
199
  label: "Jira & Confluence",
200
200
  blurb: "Atlassian issues and pages. Sign in via browser.",
201
201
  transport: "http",
202
- url: "https://mcp.atlassian.com/v1/sse",
202
+ // Streamable HTTP; /v1/sse is the legacy SSE endpoint, which Pi does not speak.
203
+ url: "https://mcp.atlassian.com/v1/mcp",
203
204
  oauth: true,
204
205
  needs: "oauth",
205
206
  },
@@ -219,7 +220,9 @@ export const MCP_CATALOG: CatalogEntry[] = [
219
220
  label: "Asana",
220
221
  blurb: "Tasks and projects. Sign in via browser.",
221
222
  transport: "http",
222
- url: "https://mcp.asana.com/sse",
223
+ // Streamable HTTP on the same OAuth server as the legacy /sse endpoint, which Pi does
224
+ // not speak. (/v2/mcp is a separate app-registration flow.)
225
+ url: "https://mcp.asana.com/mcp",
223
226
  oauth: true,
224
227
  needs: "oauth",
225
228
  },
@@ -453,11 +456,9 @@ export function draftFromCatalog(
453
456
  draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
454
457
  } else {
455
458
  draft.url = input.url?.trim() || e.url;
456
- // Emit the adapter's own vocabulary (`auth`) rather than the legacy boolean, so
457
- // the projection carries a string and not a bogus boolean in the adapter's
458
- // OAuthConfig slot. A localHttp entry authenticates to nothing — saying "oauth"
459
- // there would send the adapter looking for an authorization server that does not
460
- // exist, which fails at connect time rather than at save time.
459
+ // Emit our `auth` vocabulary rather than the legacy boolean. A localHttp entry
460
+ // authenticates to nothing — saying "oauth" there would put a sign-in prompt in
461
+ // front of a server that has no sign-in.
461
462
  draft.auth = e.localHttp ? "none" : (e.oauth ?? true) ? "oauth" : "none";
462
463
  }
463
464
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Pi's MCP machinery for callers OUTSIDE a session: sign in to a connector, sign out,
3
+ * ask whether it is signed in, and test that it answers.
4
+ *
5
+ * The desktop's connector manager is the caller (treeview desktop/src/main/
6
+ * mcpService.ts). It used to reach into pi-mcp-adapter's OAuth flow for this; with
7
+ * Pi's built-in MCP support, the tokens a session uses live in Pi's own store
8
+ * (`agent/mcp-auth.json`, keyed by server name and URL), so anything that signs in has
9
+ * to go through Pi or the session will not find the result.
10
+ *
11
+ * Sign-in and sign-out run Pi's own `mcp login` / `mcp logout` (runMcpCommand) — the
12
+ * whole flow: connect, read the server's challenge, browser authorization against a
13
+ * loopback callback, store, reconnect. The test builds the same McpServerConnection a
14
+ * session builds, so "the test passed" means what a session will see, stored OAuth
15
+ * tokens included.
16
+ *
17
+ * These are not in pi-coding-agent's package exports, so they are imported by file,
18
+ * resolved from the package's own entry (which follows a hoisted install). Every deep
19
+ * import of Pi's MCP internals lives HERE, behind small functions, and
20
+ * tests/piMcp.test.ts pins each one against the installed Pi.
21
+ */
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { agentDir } from "../config/paths.ts";
25
+
26
+ // Pi finds mcp-auth.json, mcp.log and its refresh locks through getAgentDir(), which
27
+ // reads this variable. Every Privateer entrypoint sets it in boot.ts; the desktop's
28
+ // main process, which calls these without running a session, may not have.
29
+ process.env.PI_CODING_AGENT_DIR ??= agentDir();
30
+
31
+ const PI_ENTRY = import.meta.resolve("@earendil-works/pi-coding-agent");
32
+ const piFile = (rel: string) => import(new URL(rel, PI_ENTRY).href);
33
+
34
+ export type McpAuthStatus = "not_supported" | "not_authenticated" | "authenticated";
35
+
36
+ /** Run one `pi mcp …` command against our agent dir; collect what it printed. */
37
+ async function mcpCommand(
38
+ args: string[],
39
+ opts: { openUrl?: (url: string) => void } = {},
40
+ ): Promise<{ code: number; out: string[]; err: string[] }> {
41
+ const { runMcpCommand } = await piFile("./extensions/mcp/cli.js");
42
+ const out: string[] = [];
43
+ const err: string[] = [];
44
+ const code = await runMcpCommand(args, {
45
+ // The user-level config only: these commands manage the connectors the user added,
46
+ // never a project's.
47
+ cwd: homedir(),
48
+ agentDir: agentDir(),
49
+ openUrl: opts.openUrl,
50
+ log: (line: string) => out.push(line),
51
+ error: (line: string) => err.push(line),
52
+ });
53
+ return { code, out, err };
54
+ }
55
+
56
+ /**
57
+ * Sign in to an OAuth connector from mcp.json. `onUrl` gets the authorization URL —
58
+ * the caller opens the browser and shows it. Already signed in → ok at once.
59
+ */
60
+ export async function mcpSignIn(
61
+ name: string,
62
+ onUrl: (url: string) => void,
63
+ timeoutMs = 5 * 60_000,
64
+ ): Promise<{ ok: boolean; error?: string }> {
65
+ const r = await mcpCommand(["login", name, "--timeout", String(Math.round(timeoutMs / 1000))], { openUrl: onUrl });
66
+ return r.code === 0 ? { ok: true } : { ok: false, error: r.err.join("\n") || r.out.join("\n") || "sign-in failed" };
67
+ }
68
+
69
+ /** Delete the stored credentials of an OAuth connector. */
70
+ export async function mcpSignOut(name: string): Promise<void> {
71
+ await mcpCommand(["logout", name]);
72
+ }
73
+
74
+ /**
75
+ * Whether a connector has stored OAuth credentials. "not_supported" for a server Pi
76
+ * would never start OAuth for: stdio, or HTTP carrying its own Authorization header.
77
+ * A stored but expired access token still counts — Pi refreshes it on the next call.
78
+ */
79
+ export async function mcpAuthStatus(server: {
80
+ name: string;
81
+ url?: string;
82
+ headers?: Record<string, string>;
83
+ }): Promise<McpAuthStatus> {
84
+ if (!server.url) return "not_supported";
85
+ if (Object.keys(server.headers ?? {}).some((h) => h.toLowerCase() === "authorization")) return "not_supported";
86
+ const { McpOAuthCredentialStore } = await piFile("./extensions/mcp/oauth.js");
87
+ const tokens = new McpOAuthCredentialStore(undefined, undefined).tokens(server.name, server.url);
88
+ return tokens ? "authenticated" : "not_authenticated";
89
+ }
90
+
91
+ export interface McpHandshake {
92
+ ok: boolean;
93
+ tools?: Array<{ name: string; description?: string }>;
94
+ /** True when the server answered but wants a sign-in first. */
95
+ needsAuth?: boolean;
96
+ error?: string;
97
+ }
98
+
99
+ /**
100
+ * Connect to one server exactly as a session would, list its tools, hang up. `config`
101
+ * is a Pi `mcpServers` entry — what mcpControl / mcpService project into mcp.json.
102
+ */
103
+ export async function mcpHandshake(name: string, config: Record<string, unknown>, timeoutMs = 20_000): Promise<McpHandshake> {
104
+ const { McpServerConnection, createDefaultTransport, McpOAuthCredentialStore, McpServerLog } = await piFile(
105
+ "./extensions/mcp/runtime.js",
106
+ );
107
+ const connection = new McpServerConnection({
108
+ entry: { name, config, source: join(agentDir(), "mcp.json"), scope: "global" },
109
+ cwd: homedir(),
110
+ createTransport: createDefaultTransport,
111
+ credentials: new McpOAuthCredentialStore(),
112
+ log: new McpServerLog(join(agentDir(), "mcp.log")),
113
+ onTools: () => {},
114
+ });
115
+ let timer: ReturnType<typeof setTimeout> | undefined;
116
+ try {
117
+ await Promise.race([
118
+ connection.getClient(),
119
+ new Promise((_, reject) => {
120
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
121
+ }),
122
+ ]);
123
+ const tools = (connection.tools as Array<{ name: string; description?: string }>).map((t) => ({
124
+ name: t.name,
125
+ description: t.description,
126
+ }));
127
+ return { ok: true, tools };
128
+ } catch (e) {
129
+ if (connection.state === "needs-auth") return { ok: false, needsAuth: true, error: "sign-in required" };
130
+ return { ok: false, error: connection.error ?? (e instanceof Error ? e.message : String(e)) };
131
+ } finally {
132
+ clearTimeout(timer);
133
+ try {
134
+ await connection.close();
135
+ } catch {
136
+ /* already gone */
137
+ }
138
+ }
139
+ }
@@ -1,8 +1,7 @@
1
1
  /**
2
- * Translating a routine's MCP selectors into the tool names Pi will actually honour.
2
+ * Translating a routine's MCP selectors into the tool names Pi registers.
3
3
  *
4
- * There are TWO vocabularies here and conflating them is why per-routine connector
5
- * allow-lists silently granted nothing:
4
+ * There are TWO vocabularies here:
6
5
  *
7
6
  * • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
8
7
  * server. This is what a routine stores, what the app writes, and what the AI
@@ -10,100 +9,50 @@
10
9
  * splitRoutineTools can tell a connector selector from a builtin name without a
11
10
  * lookup table. This vocabulary is STABLE — routines on disk depend on it.
12
11
  *
13
- * • The REGISTERED vocabulary — what pi-mcp-adapter actually names a tool when it
14
- * registers it with Pi: `formatToolName()` → "<serverPrefix>_<tool>", ONE
15
- * underscore, with dashes in the server name folded to underscores. And Pi's
16
- * `tools:` option is an exact-match Set (`allowedToolNames`), so a literal
17
- * "github__*" or even "github__create_issue" handed to it matches nothing at all.
12
+ * • The REGISTERED vocabulary — what Pi's built-in MCP support names a tool:
13
+ * "mcp__<server>__<tool>", with every character other than letters, digits and "_"
14
+ * replaced by "_" (pi-coding-agent docs/mcp.md, "Configuration rules").
18
15
  *
19
- * This module is the translation layer, mirroring pi-mcp-adapter's `getServerPrefix`
20
- * / `formatToolName` rather than importing them — the adapter ships as .ts in
21
- * node_modules and pulling it into our typecheck is the thing `harbor/index.ts`
22
- * already dodges with a variable import specifier. The mirror is four lines and is
16
+ * Pi's `tools:` option takes `*` patterns, and once any entry starts with "mcp__" it
17
+ * keeps ONLY the MCP tools that match — so a selector translates to one allow-list
18
+ * entry, wildcard included, with no tool inventory to consult and nothing to warm.
19
+ * Mirrors Pi's naming rather than importing it so this stays Pi-free and testable;
23
20
  * pinned by tests/toolSelect.test.ts.
24
- *
25
- * The other half of the problem: per-tool names only EXIST when direct tools are
26
- * enabled. Otherwise the adapter exposes MCP through a single proxy tool named "mcp"
27
- * — all servers, all tools, one grant, which is precisely what a per-routine
28
- * allow-list is meant to avoid. So callers pair `names` with `directToolsEnv`, which
29
- * scopes MCP_DIRECT_TOOLS to exactly the selected server/tool pairs for that one run.
30
21
  */
31
- import { readFileSync } from "node:fs";
32
- import { join } from "node:path";
33
- import { agentDir } from "../config/paths.ts";
34
-
35
- export type PrefixMode = "server" | "none" | "short";
36
-
37
- /** Mirrors pi-mcp-adapter's getServerPrefix. */
38
- export function serverPrefix(serverName: string, mode: PrefixMode): string {
39
- if (mode === "none") return "";
40
- if (mode === "short") {
41
- const short = serverName.replace(/-?mcp$/i, "").replace(/-/g, "_");
42
- return short || "mcp";
43
- }
44
- return serverName.replace(/-/g, "_");
45
- }
46
22
 
47
- /** Mirrors pi-mcp-adapter's formatToolName. */
48
- export function formatToolName(toolName: string, serverName: string, mode: PrefixMode): string {
49
- const p = serverPrefix(serverName, mode);
50
- return p ? `${p}_${toolName}` : toolName;
51
- }
23
+ import { createHash } from "node:crypto";
52
24
 
53
- /** The cached tool inventory the adapter builds after connecting: server → tool names. */
54
- export type McpInventory = Record<string, string[]>;
25
+ const NON_IDENT = /[^A-Za-z0-9_]/g;
26
+ // Pi's cap (extensions/mcp/tools.js MAX_TOOL_NAME_LENGTH); longer names get a hash.
27
+ const MAX_TOOL_NAME_LENGTH = 64;
55
28
 
56
- export interface ResolveInput {
57
- /** "<server>__<tool>" / "<server>__*" selectors, as stored on the routine. */
58
- selectors: string[];
59
- /** Server → the tool names it exposes, from the adapter's metadata cache. */
60
- inventory: McpInventory;
61
- /** The adapter's tool-prefix mode (mcp.json → settings.toolPrefix). */
62
- prefix: PrefixMode;
29
+ /**
30
+ * Pi's registered name for one MCP tool — createMcpToolName, including its shortening
31
+ * of a name over 64 characters to a prefix plus a hash of server and tool. (Pi also
32
+ * hashes two tools whose sanitized names collide; that depends on the live tool list
33
+ * and can't be predicted from a selector.)
34
+ */
35
+ export function mcpToolName(server: string, tool: string): string {
36
+ const name = `mcp__${server}__${tool}`.replace(NON_IDENT, "_");
37
+ if (name.length <= MAX_TOOL_NAME_LENGTH) return name;
38
+ const hash = createHash("sha256").update(`${server}\0${tool}`).digest("hex").slice(0, 8);
39
+ return `${name.slice(0, MAX_TOOL_NAME_LENGTH - hash.length - 1)}_${hash}`;
63
40
  }
64
41
 
65
42
  export interface ResolvedMcpTools {
66
- /** Exact registered tool names to hand to Pi's `tools:` allow-list. */
67
- names: string[];
68
- /** Servers touched by the selectors — the set that must be reachable this run. */
43
+ /** Entries for Pi's `tools:` allow-list — exact names, or "mcp__<server>__*". */
44
+ patterns: string[];
45
+ /** Servers touched by the selectors, in selector order. */
69
46
  servers: string[];
70
- /** MCP_DIRECT_TOOLS entries ("server/tool", or bare "server" for a wildcard). */
71
- directToolsEnv: string[];
72
- /**
73
- * Servers a selector named that the metadata cache knows nothing about. A wildcard
74
- * over one of these expands to NOTHING, so the caller must warm the cache before
75
- * building the session — see harbor/index.ts. Never silently ignore this.
76
- */
77
- coldServers: string[];
78
- /**
79
- * Exact selectors whose server DID report an inventory that doesn't contain that
80
- * tool — a typo, or a tool the connector dropped. The name is still granted (it
81
- * simply never registers), but the caller should say so rather than let the run
82
- * quietly come back thinner than asked for.
83
- */
84
- unknownTools: string[];
85
47
  }
86
48
 
87
- /**
88
- * Expand selectors against a known inventory. Pure — the file reads live in
89
- * `resolveMcpSelection` below so this stays trivially testable.
90
- *
91
- * An EXACT selector resolves without the inventory (we can compute the registered
92
- * name from the server + tool alone), so a connector whose cache entry is stale still
93
- * works. A WILDCARD needs the inventory to enumerate, which is why `coldServers`
94
- * exists.
95
- */
96
- export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput): ResolvedMcpTools {
97
- const names: string[] = [];
49
+ /** Translate stored selectors into Pi allow-list patterns. Pure. */
50
+ export function resolveMcpTools(selectors: string[]): ResolvedMcpTools {
51
+ const patterns: string[] = [];
98
52
  const servers: string[] = [];
99
- const directToolsEnv: string[] = [];
100
- const coldServers: string[] = [];
101
- const unknownTools: string[] = [];
102
-
103
- const push = <T>(arr: T[], v: T) => {
53
+ const push = (arr: string[], v: string) => {
104
54
  if (!arr.includes(v)) arr.push(v);
105
55
  };
106
-
107
56
  for (const selector of selectors) {
108
57
  const sep = selector.indexOf("__");
109
58
  if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
@@ -111,67 +60,17 @@ export function resolveMcpTools({ selectors, inventory, prefix }: ResolveInput):
111
60
  const tool = selector.slice(sep + 2);
112
61
  if (!tool) continue;
113
62
  push(servers, server);
114
-
115
- if (tool === "*") {
116
- const known = inventory[server];
117
- if (!known || known.length === 0) {
118
- push(coldServers, server);
119
- continue;
120
- }
121
- // Bare server name = "every tool on this server" to MCP_DIRECT_TOOLS.
122
- push(directToolsEnv, server);
123
- for (const t of known) push(names, formatToolName(t, server, prefix));
124
- continue;
125
- }
126
-
127
- push(names, formatToolName(tool, server, prefix));
128
- // MCP_DIRECT_TOOLS matches the ORIGINAL (unprefixed) tool name.
129
- push(directToolsEnv, `${server}/${tool}`);
130
- const known = inventory[server];
131
- if (!known || known.length === 0) push(coldServers, server);
132
- else if (!known.includes(tool)) push(unknownTools, selector);
63
+ push(patterns, tool === "*" ? `${serverPrefix(server)}*` : mcpToolName(server, tool));
133
64
  }
134
-
135
- return { names, servers, directToolsEnv, coldServers, unknownTools };
65
+ return { patterns, servers };
136
66
  }
137
67
 
138
- /** The adapter's metadata cache, as it lands on disk (agent/mcp-cache.json). */
139
- export function readMcpInventory(dir: string = agentDir()): McpInventory {
140
- const out: McpInventory = {};
141
- try {
142
- const raw = JSON.parse(readFileSync(join(dir, "mcp-cache.json"), "utf8"));
143
- for (const [server, entry] of Object.entries<any>(raw?.servers ?? {})) {
144
- const tools = Array.isArray(entry?.tools)
145
- ? entry.tools.map((t: any) => String(t?.name ?? "")).filter(Boolean)
146
- : [];
147
- out[server] = tools;
148
- }
149
- } catch {
150
- /* no cache yet — every selected server is cold */
151
- }
152
- return out;
153
- }
154
-
155
- /**
156
- * The adapter's prefix mode. `mcpControl.project()` pins "server" into the file it
157
- * writes, so this is really a guard for a hand-written mcp.json.
158
- */
159
- export function readPrefixMode(dir: string = agentDir()): PrefixMode {
160
- try {
161
- const raw = JSON.parse(readFileSync(join(dir, "mcp.json"), "utf8"));
162
- const mode = raw?.settings?.toolPrefix;
163
- if (mode === "server" || mode === "none" || mode === "short") return mode;
164
- } catch {
165
- /* no config — the adapter's own default */
166
- }
167
- return "server";
68
+ /** "mcp__<server>__", the start of every tool name of `server`. */
69
+ function serverPrefix(server: string): string {
70
+ return `mcp__${server.replace(NON_IDENT, "_")}__`;
168
71
  }
169
72
 
170
- /** Read-from-disk wrapper around resolveMcpTools. */
171
- export function resolveMcpSelection(selectors: string[], dir: string = agentDir()): ResolvedMcpTools {
172
- return resolveMcpTools({
173
- selectors,
174
- inventory: readMcpInventory(dir),
175
- prefix: readPrefixMode(dir),
176
- });
73
+ /** Does a registered tool name belong to `server`? */
74
+ export function isToolOfServer(registered: string, server: string): boolean {
75
+ return registered.startsWith(serverPrefix(server));
177
76
  }
@@ -235,6 +235,10 @@ const MEDIA_TITLES: Record<string, string> = {
235
235
  const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
236
236
  const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
237
237
 
238
+ // Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
239
+ // every connected server — network reads from connectors the user configured.
240
+ const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
241
+
238
242
  export function classifyToolCall(
239
243
  toolName: string,
240
244
  input: unknown,
@@ -246,6 +250,41 @@ export function classifyToolCall(
246
250
 
247
251
  if (NON_GATED.has(name)) return null;
248
252
 
253
+ // codemode runs a model-written script in a QuickJS sandbox with no file system, no
254
+ // network and no timers: everything it does to the world is a tool call, and Pi
255
+ // sends each one through tool_call like a direct call (parentToolCallId set). Those
256
+ // nested calls ARE the actions, and each is classified on its own merits right here.
257
+ // Gating the script as well would put two prompts in front of every action, the
258
+ // outer one showing only source code. (Its `models` global is turned off where we
259
+ // create it — config/moat.ts and extensions/privateer-codemode.ts.)
260
+ //
261
+ // tool_search only changes which tools are declared to the model; it calls nothing.
262
+ if (name === "codemode" || name === "tool_search") return null;
263
+
264
+ if (MCP_RESOURCE_TOOLS.has(name)) {
265
+ return {
266
+ tool: toolName,
267
+ kind: "fetch",
268
+ title: name === "read_mcp_resource" ? `Read a resource from ${str(obj.server) || "a connector"}` : "List connector resources",
269
+ detail: str(obj.uri) || str(obj.server) || "all connectors",
270
+ };
271
+ }
272
+
273
+ // An MCP server tool, "mcp__<server>__<tool>". Matched before the name heuristics
274
+ // below, which would otherwise read a tool called fetch_url or web_search on some
275
+ // server as a plain network fetch. Bash-kind, exactly as every MCP call was before
276
+ // (asks in default, denies in plan, allows under bypass). Not reclassified from the
277
+ // server's own readOnlyHint: that hint is self-reported, and a fetch-kind "always"
278
+ // relaxes the whole session to acceptEdits (ModeGate) rather than remembering the
279
+ // one call.
280
+ if (name.startsWith("mcp__")) {
281
+ const rest = toolName.slice("mcp__".length);
282
+ const sep = rest.indexOf("__");
283
+ const server = sep > 0 ? rest.slice(0, sep) : "a connector";
284
+ const tool = sep > 0 ? rest.slice(sep + 2) : rest;
285
+ return { tool: toolName, kind: "bash", title: `Run ${tool} on ${server}`, detail: safeJson(obj) };
286
+ }
287
+
249
288
  // Creating a routine is a persistent mutation; surface the trigger + delivery, and
250
289
  // force a human decision (alwaysAsk) when it grants off-machine egress.
251
290
  if (name === "create_routine" || name === "routine") {
@@ -688,10 +727,9 @@ export function classifyToolCall(
688
727
  };
689
728
  }
690
729
 
691
- // Unknown / custom / MCP tool: we can't prove it's side-effect-free, so
692
- // safe-by-default is to prompt. Classified as a bash-kind action (asks in
693
- // default/acceptEdits, denies in plan, allows only under bypass). Phase 5 refines
694
- // this with MCP destructiveHint → alwaysAsk and a per-tool policy map.
730
+ // Unknown / custom tool: we can't prove it's side-effect-free, so safe-by-default
731
+ // is to prompt. Classified as a bash-kind action (asks in default/acceptEdits,
732
+ // denies in plan, allows only under bypass). MCP tools are handled above.
695
733
  return {
696
734
  tool: toolName,
697
735
  kind: "bash",
@@ -26,8 +26,15 @@ function looksSensitive(name: string): boolean {
26
26
  return /^\.env(\..+)?$/.test(name) || /secret|credential/i.test(name);
27
27
  }
28
28
 
29
+ // A project's own MCP config: Pi reads `.pi/mcp.json` once the project is trusted and
30
+ // launches every stdio `command` in it, and expands `!command` values in env and
31
+ // headers — so a write there is code that runs at the next session start, not a
32
+ // settings tweak.
33
+ const PROJECT_CONFIG_DIRS = new Set([".pi", ".privateer"]);
34
+
29
35
  export function isProtectedPath(p: string): boolean {
30
36
  const name = basename(p);
37
+ if (name === "mcp.json" && PROJECT_CONFIG_DIRS.has(basename(dirname(p)))) return true;
31
38
  return PROTECTED_BASENAMES.has(name) || looksSensitive(name);
32
39
  }
33
40
 
@@ -872,7 +872,12 @@ export function registerAccountModels(pi: {
872
872
  // after the picker built its list, and privateer models would be missing until reopen.
873
873
  // registerProvider replaces the provider's models on the second call, and the picker's
874
874
  // refresh() re-applies registered providers, so the full list appears once fetched.
875
- export function makeAccountProvider() {
875
+ // `armOnStart: false` is for a host that owns the credential itself. The harbor leases
876
+ // ONE account credential across its concurrent runs (harbor/accountLease.ts) and now
877
+ // binds every run's extensions — Pi's MCP support connects servers on session_start —
878
+ // so the launch seed below would mint a second credential per daemon for runs that
879
+ // never use the account channel, a device nothing revokes.
880
+ export function makeAccountProvider(opts: { armOnStart?: boolean } = {}) {
876
881
  return (pi: {
877
882
  registerProvider?: (name: string, config: unknown) => void;
878
883
  on?: (event: string, handler: (e: unknown, ctx: unknown) => unknown) => void;
@@ -918,7 +923,7 @@ export function makeAccountProvider() {
918
923
  // all, and the first prompt dead-ends on "No API key found for privateer." — even
919
924
  // though the banner says "connected". The REPL (cli/chat.ts) and the harbor
920
925
  // already spawn one at startup; this gives the TUI the same seed.
921
- pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
926
+ if (opts.armOnStart !== false) pi.on?.("session_start", (_e, ctx) => void armAccountCredential(ctx));
922
927
 
923
928
  // Two safety nets around the account channel, both OUTSIDE the request path —
924
929
  // they read Pi's in-memory auth map and its finished messages, and never touch the