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.
@@ -5,13 +5,13 @@
5
5
  * relay (the harbor today; an interactive terminal by the same shape).
6
6
  *
7
7
  * The client itself can NEVER run MCP — a browser tab / RN runtime can't spawn a
8
- * stdio child or hold the adapter. So "serving MCP to phone/web" means MANAGING the
8
+ * stdio child or hold a server connection. So "serving MCP to phone/web" means MANAGING the
9
9
  * config here, on a host that executes it. This control owns that config.
10
10
  *
11
11
  * SAME FILE MODEL AS THE DESKTOP (treeview/desktop/src/main/mcpService.ts): the
12
12
  * source of truth is `${agentDir}/mcp-desktop.json` — every server with an `enabled`
13
13
  * flag — and from it we PROJECT the standard `${agentDir}/mcp.json` (enabled servers
14
- * only, `{mcpServers:{}}` shape) that pi-mcp-adapter reads. Sharing those two files
14
+ * only, `{mcpServers:{}}` shape) that Pi's built-in MCP support reads. Sharing those two files
15
15
  * means a machine has ONE coherent MCP config whether it was edited from the desktop
16
16
  * over IPC or from the phone over the relay.
17
17
  *
@@ -43,15 +43,16 @@ import { writeFileAtomic } from "../util/atomicWrite.ts";
43
43
  export type McpTransport = "stdio" | "http";
44
44
 
45
45
  /**
46
- * How an HTTP connector authenticates — our vocabulary, projected onto the adapter's
47
- * `auth` field. "none" is a server that needs no credential at all (or carries one
48
- * entirely in custom headers); stdio connectors are always "none".
46
+ * How an HTTP connector authenticates — our vocabulary, kept in mcp-desktop.json and on
47
+ * the relay, and projected onto Pi's config (see toPiServerEntry). "none" is a server that
48
+ * needs no credential at all (or carries one entirely in custom headers); stdio
49
+ * connectors are always "none".
49
50
  */
50
51
  export type McpAuth = "oauth" | "bearer" | "none";
51
52
 
52
53
  // One server as stored in the source file (mcp-desktop.json). Mirrors the desktop's
53
- // SourceEntry: the standard fields the adapter needs plus our `enabled` flag.
54
- interface SourceEntry {
54
+ // SourceEntry: the standard server fields plus our auth vocabulary and `enabled` flag.
55
+ export interface SourceEntry {
55
56
  transport?: McpTransport;
56
57
  command?: string;
57
58
  args?: string[];
@@ -63,7 +64,7 @@ interface SourceEntry {
63
64
  bearerTokenEnv?: string;
64
65
  // LEGACY, read-only: entries written before `auth` existed carry a boolean here.
65
66
  // authOf() folds it in; nothing new ever writes it, and project() never emits it
66
- // (the adapter's own `oauth` field is an OAuthConfig object, not a boolean).
67
+ // (Pi's own `oauth` field is an OAuthConfig object, not a boolean).
67
68
  oauth?: boolean;
68
69
  enabled?: boolean;
69
70
  }
@@ -78,14 +79,10 @@ function isAuth(v: unknown): v is McpAuth {
78
79
 
79
80
  // What this entry will actually do when it connects. Explicit `auth` wins; a bearer
80
81
  // token implies bearer; a legacy `oauth: false` means no auth; otherwise HTTP servers
81
- // auto-detect OAuth, which is the adapter's own default.
82
- //
83
- // The headers rule mirrors pi-mcp-adapter's supportsOAuth(): "configured custom headers
84
- // take precedence over implicit OAuth auto-detection." Without this we'd project an
85
- // EXPLICIT auth:"oauth" for a headers-carrying entry, and the adapter checks
86
- // auth === "oauth" BEFORE its headers check — so we'd force OAuth on exactly the
87
- // connectors it means to skip it for. Only implicit auth defers to headers; an explicit
88
- // `auth` from the user still wins.
82
+ // auto-detect OAuth, which is Pi's own behaviour (sign-in is offered when the server
83
+ // answers 401). Only implicit auth defers to custom headers — an entry that carries its
84
+ // credential in a header isn't an OAuth connector — while an explicit `auth` from the
85
+ // user still wins.
89
86
  function authOf(e: SourceEntry, transport: McpTransport): McpAuth {
90
87
  if (transport !== "http") return "none";
91
88
  if (isAuth(e.auth)) return e.auth;
@@ -139,6 +136,10 @@ export interface McpDraft {
139
136
  }
140
137
 
141
138
  export interface McpControl {
139
+ // Re-write mcp.json from mcp-desktop.json. Run once at launch by
140
+ // migrateMcpProjection, for a file an older release wrote in pi-mcp-adapter's format.
141
+ // `adopt`: projection entries to take into mcp-desktop.json first when it lacks them.
142
+ reproject(adopt?: Record<string, unknown>): void;
142
143
  // Every managed server, non-secret projection. Enabled or not — the app shows
143
144
  // disabled connectors so they can be toggled back on.
144
145
  list(): RemoteMcpServer[];
@@ -169,6 +170,139 @@ function cleanArgs(v: unknown): string[] | undefined {
169
170
  return v.map((x) => String(x ?? "")).filter((s) => s.length > 0);
170
171
  }
171
172
 
173
+ // Pi's server-name rule (core/mcp-servers.js), and an environment variable name.
174
+ const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
175
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
176
+
177
+ function isSseUrl(url: string): boolean {
178
+ try {
179
+ return /\/sse\/?$/i.test(new URL(url).pathname);
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Carry a value written for pi-mcp-adapter into Pi's config-value syntax, meaning
187
+ * exactly what it meant before.
188
+ *
189
+ * Every value here — env, headers, a bearer token — was typed for the adapter, and
190
+ * the app, /connect and the desktop all still write that vocabulary:
191
+ * • `${VAR}`, `$env:VAR` and `{env:VAR}` read the environment; any other `$` is a
192
+ * literal `$`.
193
+ * • A leading `!` runs the rest as a shell command; `!!` is a literal `!`.
194
+ * Pi (core/resolve-config-value.js) reads `${VAR}` and also bare `$VAR`, runs a
195
+ * leading `!`, and escapes with `$$` and `$!`. So the references become `${VAR}`,
196
+ * every other `$` is doubled (a token like `pa$word` would otherwise lose `$word`),
197
+ * and `!!x` becomes `$!x`. A `!command` stays a command, as it was.
198
+ *
199
+ * One difference stays: an unset variable was "" for the adapter, and makes Pi report
200
+ * the server as misconfigured — a louder failure for the same broken config.
201
+ */
202
+ export function toPiConfigValue(value: string): string {
203
+ // "!!x" is a literal "!" then x, with x's references still read (never a command).
204
+ if (value.startsWith("!!")) return `$!${references(value.slice(2))}`;
205
+ if (value.startsWith("!")) return value;
206
+ return references(value);
207
+ }
208
+
209
+ // The adapter's three reference forms as Pi's `${VAR}`; every other `$` literal.
210
+ function references(value: string): string {
211
+ const literal = (text: string) => text.replace(/\$/g, "$$$$");
212
+ let out = "";
213
+ let last = 0;
214
+ for (const m of value.matchAll(/\$\{(\w+)\}|\$env:(\w+)|\{env:(\w+)\}/g)) {
215
+ out += literal(value.slice(last, m.index)) + "${" + (m[1] ?? m[2] ?? m[3]) + "}";
216
+ last = m.index! + m[0].length;
217
+ }
218
+ return out + literal(value.slice(last));
219
+ }
220
+
221
+ function piConfigValues(values: Record<string, string>): Record<string, string> {
222
+ const out: Record<string, string> = {};
223
+ for (const [k, v] of Object.entries(values)) out[k] = toPiConfigValue(String(v ?? ""));
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * A bearer token as a whole Authorization header value. A command token (`!cmd`)
229
+ * printed only the token for the adapter, which added "Bearer "; Pi runs a command
230
+ * only as the WHOLE value, so the prefix moves inside it.
231
+ */
232
+ function bearerHeader(token: string): string {
233
+ if (token.startsWith("!") && !token.startsWith("!!")) return `!printf 'Bearer %s' "$(${token.slice(1)})"`;
234
+ return `Bearer ${toPiConfigValue(token)}`;
235
+ }
236
+
237
+ /**
238
+ * One-time upgrade of a projection written for pi-mcp-adapter. Its marker is the
239
+ * `settings.toolPrefix` pin every earlier release wrote; such a file carries `auth`
240
+ * strings and `bearerToken` fields that Pi rejects, so every HTTP connector in it would
241
+ * vanish. Re-projecting from mcp-desktop.json rewrites it in Pi's format. A file
242
+ * without the marker — Pi's own, or hand-written — is left alone.
243
+ */
244
+ export function migrateMcpProjection(dir: () => string = agentDir): boolean {
245
+ let raw: any;
246
+ try {
247
+ raw = JSON.parse(readFileSync(join(dir(), "mcp.json"), "utf8"));
248
+ if (raw?.settings?.toolPrefix === undefined) return false;
249
+ } catch {
250
+ return false;
251
+ }
252
+ // A server someone added to mcp.json by hand never reached mcp-desktop.json, and
253
+ // re-projecting from that file alone would drop it. Adopt those first.
254
+ makeMcpControl({ dir }).reproject(raw?.mcpServers ?? {});
255
+ return true;
256
+ }
257
+
258
+ // Legacy SSE endpoints of servers that also serve streamable HTTP, which is all Pi
259
+ // speaks. A connector saved with one of these (the catalog shipped them) is moved to
260
+ // the streamable URL wherever it is projected, so it keeps working without a re-save.
261
+ const LEGACY_SSE_URLS: Record<string, string> = {
262
+ "https://mcp.atlassian.com/v1/sse": "https://mcp.atlassian.com/v1/mcp",
263
+ "https://mcp.asana.com/sse": "https://mcp.asana.com/mcp",
264
+ "https://mcp.linear.app/sse": "https://mcp.linear.app/mcp",
265
+ };
266
+
267
+ export function upgradeLegacyUrl(url: string): string {
268
+ return LEGACY_SSE_URLS[url.replace(/\/$/, "")] ?? url;
269
+ }
270
+
271
+ // One managed entry as a Pi `mcpServers` entry. Exported for the desktop's connector
272
+ // manager (treeview desktop/src/main/mcpService.ts), which writes the same files and
273
+ // must write them the same way. Built field by field rather than
274
+ // spread, because our vocabulary and Pi's differ:
275
+ //
276
+ // • Pi has no bearer field. A static token becomes an `Authorization` header, and a
277
+ // token read from the environment becomes "Bearer ${VAR}". A header named
278
+ // Authorization is also what tells Pi NOT to start OAuth for the server.
279
+ // • Pi's `auth` is `{ provider }` — a Pi /login provider's token — not our
280
+ // "oauth" | "bearer" | "none". Any other value makes Pi reject the whole entry, so
281
+ // ours never reaches the file. OAuth needs no field at all: Pi offers sign-in when
282
+ // a server without an Authorization header answers 401.
283
+ // • Pi and the adapter both expand env and header values, with different syntax:
284
+ // each value is carried over so it means what it meant (toPiConfigValue). Copied
285
+ // verbatim, a token containing "$" would be mangled by Pi's bare-$VAR rule.
286
+ export function toPiServerEntry(e: SourceEntry): Record<string, unknown> {
287
+ const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
288
+ const std: Record<string, unknown> = {};
289
+ if (transport === "stdio") {
290
+ if (e.command) std.command = e.command;
291
+ if (e.args?.length) std.args = e.args;
292
+ if (e.env && Object.keys(e.env).length > 0) std.env = piConfigValues(e.env);
293
+ } else {
294
+ if (e.url) std.url = upgradeLegacyUrl(e.url);
295
+ const headers: Record<string, string> = piConfigValues(e.headers ?? {});
296
+ if (authOf(e, transport) === "bearer") {
297
+ // A stored token wins over the env var name.
298
+ if (e.bearerToken) headers.Authorization = bearerHeader(e.bearerToken);
299
+ else if (e.bearerTokenEnv && ENV_NAME.test(e.bearerTokenEnv)) headers.Authorization = `Bearer \${${e.bearerTokenEnv}}`;
300
+ }
301
+ if (Object.keys(headers).length > 0) std.headers = headers;
302
+ }
303
+ return std;
304
+ }
305
+
172
306
  export function makeMcpControl(opts?: {
173
307
  // Override the source/projection dir (tests). Defaults to the shared agent dir, so
174
308
  // this control and the desktop's mcpService edit the SAME two files.
@@ -205,59 +339,21 @@ export function makeMcpControl(opts?: {
205
339
  project(src);
206
340
  }
207
341
 
208
- // Project the enabled servers into the standard mcp.json the adapter reads. An
209
- // entry with no explicit transport is treated as stdio if it has a command, http
210
- // if it has a url — matching the adapter's own inference.
342
+ // Project the servers into the standard mcp.json Pi reads. An entry with no
343
+ // explicit transport is treated as stdio if it has a command, http if it has a url —
344
+ // matching Pi's own inference.
211
345
  //
212
- // `toolPrefix` is pinned rather than left to the adapter's default because a
213
- // routine's "<server>__<tool>" selector is translated into a REGISTERED tool name
214
- // before it can grant anything (src/mcp/toolNames.ts), and that translation depends
215
- // on the mode. "server" is the adapter's own default, so pinning it changes nothing
216
- // today and stops a future default flip from silently voiding every allow-list.
346
+ // A disabled server is written with Pi's own `enabled: false` rather than left out:
347
+ // Pi keeps it listed (and skips connecting it), and `mcp login` can still find it —
348
+ // a desktop window may activate a connector that is off by default and need to sign
349
+ // in to it.
217
350
  function project(src: SourceFile): void {
218
351
  const mcpServers: Record<string, unknown> = {};
219
352
  for (const [name, e] of Object.entries(src.servers)) {
220
- if (e.enabled === false) continue;
221
- mcpServers[name] = toStandard(e);
353
+ mcpServers[name] = e.enabled === false ? { ...toPiServerEntry(e), enabled: false } : toPiServerEntry(e);
222
354
  }
223
355
  mkdirSync(dirname(projectionPath()), { recursive: true });
224
- writeFileAtomic(
225
- projectionPath(),
226
- JSON.stringify({ mcpServers, settings: { toolPrefix: "server" } }, null, 2) + "\n",
227
- );
228
- }
229
-
230
- // One managed entry as pi-mcp-adapter's ServerEntry. Built field by field rather
231
- // than spread, because our vocabulary and the adapter's differ in two places that
232
- // matter: our `auth: "none"` is its `auth: false`, and our legacy boolean `oauth`
233
- // collides with its `oauth` (an OAuthConfig OBJECT) and must never reach the file.
234
- //
235
- // Getting `auth` right is load-bearing, not cosmetic. The adapter only attaches an
236
- // Authorization header when `auth === "bearer"` (server-manager.ts), and its
237
- // supportsOAuth() refuses OAuth outright once custom headers are configured — so an
238
- // omitted `auth` silently means "no bearer token was ever sent".
239
- function toStandard(e: SourceEntry): Record<string, unknown> {
240
- const transport: McpTransport = e.transport ?? (e.url ? "http" : "stdio");
241
- const std: Record<string, unknown> = {};
242
- if (transport === "stdio") {
243
- if (e.command) std.command = e.command;
244
- if (e.args?.length) std.args = e.args;
245
- } else {
246
- if (e.url) std.url = e.url;
247
- if (e.headers && Object.keys(e.headers).length > 0) std.headers = e.headers;
248
- const auth = authOf(e, transport);
249
- if (auth === "bearer") {
250
- std.auth = "bearer";
251
- if (e.bearerToken) std.bearerToken = e.bearerToken;
252
- if (e.bearerTokenEnv) std.bearerTokenEnv = e.bearerTokenEnv;
253
- } else if (auth === "oauth") {
254
- std.auth = "oauth";
255
- } else {
256
- std.auth = false;
257
- }
258
- }
259
- if (e.env && Object.keys(e.env).length > 0) std.env = e.env;
260
- return std;
356
+ writeFileAtomic(projectionPath(), JSON.stringify({ mcpServers }, null, 2) + "\n");
261
357
  }
262
358
 
263
359
  function toRemote(name: string, e: SourceEntry): RemoteMcpServer {
@@ -306,6 +402,18 @@ export function makeMcpControl(opts?: {
306
402
  }
307
403
 
308
404
  return {
405
+ reproject(adopt?: Record<string, unknown>): void {
406
+ const src = readSource();
407
+ let added = false;
408
+ for (const [name, entry] of Object.entries(adopt ?? {})) {
409
+ if (src.servers[name] || !entry || typeof entry !== "object") continue;
410
+ src.servers[name] = { ...(entry as SourceEntry), enabled: true };
411
+ added = true;
412
+ }
413
+ if (added) writeSource(src);
414
+ else project(src);
415
+ },
416
+
309
417
  list(): RemoteMcpServer[] {
310
418
  const src = readSource();
311
419
  return Object.entries(src.servers).map(([name, e]) => toRemote(name, e));
@@ -314,6 +422,9 @@ export function makeMcpControl(opts?: {
314
422
  save(draft: McpDraft): { ok: boolean; message?: string } {
315
423
  const name = String(draft?.name ?? "").trim();
316
424
  if (!name) return { ok: false, message: "A connector needs a name." };
425
+ // Pi's rule for server names; anything else is skipped as invalid at connect time.
426
+ if (!SERVER_NAME.test(name))
427
+ return { ok: false, message: "A connector name may use only letters, digits, \"-\" and \"_\"." };
317
428
  if (draft.transport !== undefined && !isTransport(draft.transport))
318
429
  return { ok: false, message: "Unknown transport." };
319
430
  if (draft.auth !== undefined && !isAuth(draft.auth))
@@ -342,6 +453,9 @@ export function makeMcpControl(opts?: {
342
453
  if (!entry.command) return { ok: false, message: "A local (stdio) connector needs a command." };
343
454
  } else {
344
455
  if (draft.url !== undefined) entry.url = String(draft.url).trim();
456
+ // A connector saved with a legacy SSE endpoint before Pi: move it on, rather
457
+ // than refuse every later edit of it below.
458
+ if (entry.url) entry.url = upgradeLegacyUrl(entry.url);
345
459
  // `auth` is authoritative; the legacy boolean is honoured only when it isn't
346
460
  // sent, so an old app build keeps working without being able to override.
347
461
  if (draft.auth !== undefined) entry.auth = draft.auth;
@@ -362,9 +476,9 @@ export function makeMcpControl(opts?: {
362
476
  else delete entry.headers;
363
477
  }
364
478
  // A token that arrived without an explicit `auth` means bearer — otherwise the
365
- // adapter stores the token and never sends it (it only sets the Authorization
366
- // header when auth === "bearer"), which reads to the user as "my token is
367
- // saved and the connector still 401s".
479
+ // token is stored and never projected into an Authorization header (toPiServerEntry
480
+ // only sends it for bearer), which reads to the user as "my token is saved and
481
+ // the connector still 401s".
368
482
  if (draft.auth === undefined && (entry.bearerToken || entry.bearerTokenEnv)) entry.auth = "bearer";
369
483
  // Once `auth` is set, the legacy boolean is noise that authOf would have to
370
484
  // keep tie-breaking. Drop it.
@@ -372,6 +486,15 @@ export function makeMcpControl(opts?: {
372
486
  delete entry.command;
373
487
  delete entry.args;
374
488
  if (!entry.url) return { ok: false, message: "A remote (http) connector needs a URL." };
489
+ // Pi speaks streamable HTTP only. An SSE endpoint would save and then fail on
490
+ // every connect, so say so now — most servers serve streamable HTTP at /mcp.
491
+ if (isSseUrl(entry.url))
492
+ return {
493
+ ok: false,
494
+ message: "That looks like a legacy SSE endpoint, which isn't supported. Use the server's streamable HTTP URL (often ending in /mcp).",
495
+ };
496
+ if (entry.bearerTokenEnv && !ENV_NAME.test(entry.bearerTokenEnv))
497
+ return { ok: false, message: "The token variable must be an environment variable name, like GITHUB_TOKEN." };
375
498
  if (authOf(entry, "http") === "bearer" && !entry.bearerToken && !entry.bearerTokenEnv)
376
499
  return { ok: false, message: "A bearer connector needs a token, or the name of an env var holding one." };
377
500
  }
@@ -1303,7 +1303,7 @@ export class RelayClient {
1303
1303
  // `builtIn` + `managed` describe the MOAT, which is deliberately absent from
1304
1304
  // `installed` (it never enters settings "packages"). Without them the app's Browse
1305
1305
  // tab — an unfiltered npm `keywords:pi-package` search — offers Add on packages we
1306
- // already ship: pi-mcp-adapter, pi-subagents and the rpiv packs are on its first
1306
+ // already ship (or retired): pi-mcp-adapter, pi-subagents and the rpiv packs are on its first
1307
1307
  // page. Every one of those round-trips to extensionsControl.add() only to come back
1308
1308
  // refused ("managed by Privateer"), and until it does the row reads as *not*
1309
1309
  // installed, which is the opposite of the truth. So:
@@ -4,10 +4,10 @@
4
4
  // or a per-server wildcard "<server>__*" — everything else is a builtin allow-list
5
5
  // entry.
6
6
  //
7
- // NOTE the selector is NOT the name Pi registers. pi-mcp-adapter names a tool
8
- // "<serverPrefix>_<tool>" (one underscore), and Pi's `tools:` option is an exact-match
9
- // Set — so a selector must be TRANSLATED before it can grant anything. That
10
- // translation lives in ../mcp/toolNames.ts; this module only splits and matches.
7
+ // NOTE the selector is NOT the name Pi registers. Pi names an MCP tool
8
+ // "mcp__<server>__<tool>", so a selector must be TRANSLATED before it can grant
9
+ // anything. That translation lives in ../mcp/toolNames.ts; this module only splits and
10
+ // matches.
11
11
 
12
12
  export interface RoutineToolSplit {
13
13
  // Builtin tool names (read, glob, ...). Empty → caller falls back to the safe set.
@@ -1,19 +0,0 @@
1
- // Session CONSTRUCTION is serialized across the whole process. pi-mcp-adapter decides
2
- // which MCP tools to register directly at extension-activation time, and the only knob
3
- // for that is the MCP_DIRECT_TOOLS env var — process-global state we set per run to
4
- // keep each unattended session to its own connector allow-list. Serializing the
5
- // (short) build window is what stops two concurrent builds from reading each other's
6
- // value. Prompting is NOT serialized — only the build.
7
- //
8
- // Lifted out of harbor/index.ts so the channels runtime can share the SAME lock once
9
- // it runs inside the harbor: two owners each with their own lock would serialize
10
- // against themselves and race against each other, which is exactly the bug the lock
11
- // exists to prevent.
12
-
13
- let buildLock: Promise<unknown> = Promise.resolve();
14
-
15
- export function serializeBuild<T>(fn: () => Promise<T>): Promise<T> {
16
- const run = buildLock.then(fn, fn);
17
- buildLock = run.catch(() => {});
18
- return run;
19
- }