privateer-agent 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/privateer-brand.ts +24 -11
- package/extensions/privateer-connect.ts +135 -10
- package/extensions/privateer-privacy.ts +20 -8
- package/package.json +2 -2
- package/patches/@earendil-works+pi-coding-agent+0.80.3.patch +20 -1
- package/src/auth/privateer.ts +24 -5
- package/src/channels/run.ts +18 -1
- package/src/cli/chat.ts +11 -2
- package/src/config/hosted.ts +21 -0
- package/src/harbor/index.ts +317 -77
- package/src/harbor/ipc.ts +50 -0
- package/src/harbor/service.ts +56 -8
- package/src/mcp/catalog.ts +32 -1
- package/src/mcp/toolNames.ts +177 -0
- package/src/providers/account.ts +364 -28
- package/src/remote/liveTaskSession.ts +11 -3
- package/src/remote/mcpControl.ts +224 -28
- package/src/remote/relayClient.ts +124 -8
- package/src/remote/routinesControl.ts +1 -1
- package/src/routines/schema.ts +2 -0
- package/src/routines/store.ts +1 -1
- package/src/routines/toolSelect.ts +13 -19
- package/src/tools/web.ts +236 -0
package/src/harbor/ipc.ts
CHANGED
|
@@ -22,6 +22,28 @@ export type IpcRequest =
|
|
|
22
22
|
| { cmd: "run-now"; idOrName: string }
|
|
23
23
|
| { cmd: "reload" };
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The harbor's view of its own relay connection, reported by `status`.
|
|
27
|
+
*
|
|
28
|
+
* A harbor answering on this socket is running; that is NOT the same as being
|
|
29
|
+
* reachable from the app, which needs the relay socket up (the server drops a
|
|
30
|
+
* terminal from its presence registry ~60s after it stops hearing from it). The two
|
|
31
|
+
* used to be conflated — "running (answering IPC)" while the app showed the same
|
|
32
|
+
* harbor as offline — so every liveness report carries both now.
|
|
33
|
+
*/
|
|
34
|
+
export interface RelayStatus {
|
|
35
|
+
/** The relay terminal id the app looks for ("routines-…"). */
|
|
36
|
+
termId: string;
|
|
37
|
+
/** Socket open right now, i.e. the app can see and drive this harbor. */
|
|
38
|
+
connected: boolean;
|
|
39
|
+
/** Seconds the current connection has been up. */
|
|
40
|
+
upSec?: number;
|
|
41
|
+
/** Seconds since the server last sent anything (frame, ping or pong). */
|
|
42
|
+
quietSec?: number;
|
|
43
|
+
/** Why it isn't connected, when we know: signed out, turned off from the app, … */
|
|
44
|
+
detail?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
25
47
|
export interface IpcResponse {
|
|
26
48
|
ok: boolean;
|
|
27
49
|
message?: string;
|
|
@@ -29,6 +51,8 @@ export interface IpcResponse {
|
|
|
29
51
|
// Harbor liveness/uptime for `status`.
|
|
30
52
|
pid?: number;
|
|
31
53
|
uptimeSec?: number;
|
|
54
|
+
// Relay reachability for `status` — see RelayStatus.
|
|
55
|
+
relay?: RelayStatus;
|
|
32
56
|
}
|
|
33
57
|
|
|
34
58
|
export type IpcHandler = (req: IpcRequest) => Promise<IpcResponse> | IpcResponse;
|
|
@@ -164,6 +188,32 @@ export class HarborAlreadyRunningError extends Error {
|
|
|
164
188
|
}
|
|
165
189
|
}
|
|
166
190
|
|
|
191
|
+
// One-line, human rendering of a status reply's relay block — shared by
|
|
192
|
+
// `privateer harbor status` and the second-instance notice so both tell the same
|
|
193
|
+
// story. `undefined` means the harbor answering us predates this field.
|
|
194
|
+
export function describeRelay(relay?: RelayStatus): string {
|
|
195
|
+
if (!relay) return "unknown (this harbor is an older build)";
|
|
196
|
+
if (!relay.connected) {
|
|
197
|
+
return `NOT connected — the app shows this Harbor as inactive${relay.detail ? ` (${relay.detail})` : ""}`;
|
|
198
|
+
}
|
|
199
|
+
const up = typeof relay.upSec === "number" ? `, up ${formatDuration(relay.upSec)}` : "";
|
|
200
|
+
// A connected socket the server hasn't spoken on in a while is the half-open shape;
|
|
201
|
+
// the watchdog drops it within ~75s, so say so rather than reporting a flat "connected".
|
|
202
|
+
const quiet = typeof relay.quietSec === "number" && relay.quietSec > 40 ? `, quiet for ${relay.quietSec}s — checking` : "";
|
|
203
|
+
return `connected — drivable from the Privateer app (${relay.termId}${up}${quiet})`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatDuration(totalSec: number): string {
|
|
207
|
+
const s = Math.max(0, Math.round(totalSec));
|
|
208
|
+
const d = Math.floor(s / 86400);
|
|
209
|
+
const h = Math.floor((s % 86400) / 3600);
|
|
210
|
+
const m = Math.floor((s % 3600) / 60);
|
|
211
|
+
if (d) return `${d}d ${h}h`;
|
|
212
|
+
if (h) return `${h}h ${m}m`;
|
|
213
|
+
if (m) return `${m}m`;
|
|
214
|
+
return `${s}s`;
|
|
215
|
+
}
|
|
216
|
+
|
|
167
217
|
// Convenience: is the harbor reachable right now?
|
|
168
218
|
export async function harborIsRunning(): Promise<boolean> {
|
|
169
219
|
try {
|
package/src/harbor/service.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { join, dirname, resolve } from "node:path";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
import { globalDir } from "../config/paths.ts";
|
|
15
|
-
import { harborIsRunning } from "./ipc.ts";
|
|
15
|
+
import { harborIsRunning, sendToHarbor, describeRelay, formatDuration, type IpcResponse } from "./ipc.ts";
|
|
16
16
|
|
|
17
17
|
const LABEL = "pro.privateer.harbor"; // launchd label / reverse-dns id
|
|
18
18
|
const UNIT = "privateer-harbor.service"; // systemd --user unit name
|
|
@@ -62,7 +62,13 @@ function xmlEscape(s: string): string {
|
|
|
62
62
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// KeepAlive is `{ SuccessfulExit: false }`, NOT plain `true` — restart on a crash,
|
|
66
|
+
// but leave a CLEAN exit alone. A bare `true` restarts unconditionally, which turns
|
|
67
|
+
// the two clean-exit paths into loops: the harbor that finds another one already
|
|
68
|
+
// holding the machine lock (exit 0 every ~10s, appending the same line to harbor.log
|
|
69
|
+
// forever — this is what produced a 7 MB log of "already running"), and a deliberate
|
|
70
|
+
// shutdown, which launchd would undo. Matches the systemd unit's Restart=on-failure.
|
|
71
|
+
export function launchdPlist(): string {
|
|
66
72
|
const args = [nodeBinaryPath(), harborLauncherPath(), "run"];
|
|
67
73
|
const envVars = forwardedEnv();
|
|
68
74
|
const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join("\n");
|
|
@@ -83,7 +89,10 @@ ${argXml}
|
|
|
83
89
|
${envVars.PRIVATEER_HOME || envVars.PRIVATEER_SERVER_URL ? ` <key>EnvironmentVariables</key>\n <dict>\n${envXml}\n </dict>\n` : ""} <key>RunAtLoad</key>
|
|
84
90
|
<true/>
|
|
85
91
|
<key>KeepAlive</key>
|
|
86
|
-
<
|
|
92
|
+
<dict>
|
|
93
|
+
<key>SuccessfulExit</key>
|
|
94
|
+
<false/>
|
|
95
|
+
</dict>
|
|
87
96
|
<key>StandardOutPath</key>
|
|
88
97
|
<string>${log}</string>
|
|
89
98
|
<key>StandardErrorPath</key>
|
|
@@ -199,6 +208,24 @@ export interface ServiceInfo {
|
|
|
199
208
|
installed: boolean;
|
|
200
209
|
unitPath: string;
|
|
201
210
|
logPath: string;
|
|
211
|
+
/** Installed unit predates a fix and should be rewritten — see needsRefresh(). */
|
|
212
|
+
needsRefresh: boolean;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Does the INSTALLED unit need rewriting? Deliberately narrow: a full text compare
|
|
216
|
+
// against what we'd generate today would flag every service installed by a different
|
|
217
|
+
// copy of the CLI (a dev checkout resolves a different launcher path), which is not a
|
|
218
|
+
// problem and not something the user should be nagged about. The one thing worth
|
|
219
|
+
// flagging is the pre-fix launchd `KeepAlive: true`, which restarts the harbor even
|
|
220
|
+
// after a clean exit — the log-spam loop described above launchdPlist().
|
|
221
|
+
export function unitNeedsRefresh(platform: NodeJS.Platform, unitPath: string): boolean {
|
|
222
|
+
if (platform !== "darwin" || !existsSync(unitPath)) return false;
|
|
223
|
+
try {
|
|
224
|
+
const plist = readFileSync(unitPath, "utf8");
|
|
225
|
+
return /<key>KeepAlive<\/key>\s*<true\s*\/>/.test(plist);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
function unitPathFor(platform: NodeJS.Platform): string {
|
|
@@ -216,6 +243,7 @@ export function serviceInfo(): ServiceInfo {
|
|
|
216
243
|
installed: !!unitPath && existsSync(unitPath),
|
|
217
244
|
unitPath,
|
|
218
245
|
logPath: harborLogPath(),
|
|
246
|
+
needsRefresh: unitNeedsRefresh(platform, unitPath),
|
|
219
247
|
};
|
|
220
248
|
}
|
|
221
249
|
|
|
@@ -236,20 +264,40 @@ export function uninstallService(): ServiceInfo {
|
|
|
236
264
|
return serviceInfo();
|
|
237
265
|
}
|
|
238
266
|
|
|
239
|
-
// Human-readable status
|
|
240
|
-
// installed
|
|
267
|
+
// Human-readable status for `privateer harbor status`: whether the service is
|
|
268
|
+
// installed, whether a harbor is answering on the IPC socket, and — the part that
|
|
269
|
+
// actually answers "why does the app say inactive?" — whether that harbor is
|
|
270
|
+
// connected to the relay. Answering IPC only proves a local process is alive; the
|
|
271
|
+
// app lists a harbor from the server's presence registry, which a dead relay socket
|
|
272
|
+
// drops within ~60s. Reporting the first as if it implied the second is what made a
|
|
273
|
+
// stale harbor look healthy from the terminal and offline from the phone.
|
|
241
274
|
export async function statusReport(): Promise<string> {
|
|
242
275
|
const info = serviceInfo();
|
|
243
|
-
|
|
276
|
+
let status: IpcResponse | null = null;
|
|
277
|
+
try {
|
|
278
|
+
status = await sendToHarbor({ cmd: "status" }, 3_000);
|
|
279
|
+
} catch {
|
|
280
|
+
status = null; // not running, or wedged — harborIsRunning() below tells them apart
|
|
281
|
+
}
|
|
282
|
+
const live = status ? status.ok : await harborIsRunning();
|
|
283
|
+
const up = status && typeof status.uptimeSec === "number" ? `, up ${formatDuration(status.uptimeSec)}` : "";
|
|
284
|
+
const pid = status?.pid ? `pid ${status.pid}${up}` : "answering IPC";
|
|
244
285
|
const lines = [
|
|
245
286
|
`platform: ${info.platform}${info.supported ? "" : " (auto-start unsupported — run `privateer harbor` manually)"}`,
|
|
246
287
|
`service: ${info.installed ? `installed (${info.unitPath})` : "not installed"}`,
|
|
247
|
-
`harbor: ${live ?
|
|
248
|
-
`logs: ${info.logPath}`,
|
|
288
|
+
`harbor: ${live ? `running (${pid})` : "not reachable"}`,
|
|
249
289
|
];
|
|
290
|
+
if (live) lines.push(`relay: ${describeRelay(status?.relay)}`);
|
|
291
|
+
lines.push(`logs: ${info.logPath}`);
|
|
250
292
|
// Surface a stale-unit hint: file present but nothing answering usually means it
|
|
251
293
|
// failed to boot — the log path above is where to look.
|
|
252
294
|
if (info.installed && !live) lines.push("hint: service is installed but not answering — check the log for a boot error.");
|
|
295
|
+
if (live && status?.relay && !status.relay.connected) {
|
|
296
|
+
lines.push("hint: Harbor is running but not reachable from the app. Restart it (`privateer harbor uninstall && privateer harbor install`) once the cause above is resolved.");
|
|
297
|
+
}
|
|
298
|
+
if (info.needsRefresh) {
|
|
299
|
+
lines.push("hint: the installed login service restarts Harbor even after a clean exit (older install) — run `privateer harbor install` to refresh it.");
|
|
300
|
+
}
|
|
253
301
|
return lines.join("\n");
|
|
254
302
|
}
|
|
255
303
|
|
package/src/mcp/catalog.ts
CHANGED
|
@@ -36,6 +36,34 @@ export interface CatalogEntry {
|
|
|
36
36
|
fill?: string;
|
|
37
37
|
// Where to get the credential, shown as a hint in the form.
|
|
38
38
|
credUrl?: string;
|
|
39
|
+
// Can this connector run on a HOSTED (Harbor) agent? Leave unset to take the derived
|
|
40
|
+
// answer from hostedCapable() below; set it explicitly only to say "no" to something
|
|
41
|
+
// that would otherwise qualify.
|
|
42
|
+
hosted?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether an entry can run on a hosted agent, as opposed to a local daemon/desktop.
|
|
47
|
+
*
|
|
48
|
+
* The rule is not a preference, it is the runtime: a Harbor tenant is `--read-only`,
|
|
49
|
+
* `--cap-drop ALL`, has no `uv`/`uvx`/Python/browser, and its home is tmpfs wiped on
|
|
50
|
+
* every suspend. A stdio entry would have to download and execute unmeasured
|
|
51
|
+
* third-party code inside an attested enclave at runtime, which defeats the point of
|
|
52
|
+
* the measurement; and a token-bearing entry would need a durable secret at rest,
|
|
53
|
+
* which we decided against (Option B — see treeview/docs/HARBOR_CONNECTORS_PLAN.md §2).
|
|
54
|
+
* What is left is remote HTTP + OAuth.
|
|
55
|
+
*
|
|
56
|
+
* The `/connect` picker filters on this (extensions/privateer-connect.ts →
|
|
57
|
+
* catalogRows) rather than showing 21 options of which 16 cannot work. It shapes the
|
|
58
|
+
* custom-connector form there too: no local command, no stored token.
|
|
59
|
+
*
|
|
60
|
+
* It does NOT gate mcpControl.save() — the app-over-relay path can still write a
|
|
61
|
+
* connector this returns false for. Fixing that means teaching mcpControl about
|
|
62
|
+
* hosted mode, which is a bigger change than a picker filter.
|
|
63
|
+
*/
|
|
64
|
+
export function hostedCapable(e: CatalogEntry): boolean {
|
|
65
|
+
if (e.hosted !== undefined) return e.hosted;
|
|
66
|
+
return e.transport === "http" && e.oauth === true;
|
|
39
67
|
}
|
|
40
68
|
|
|
41
69
|
export const MCP_CATALOG: CatalogEntry[] = [
|
|
@@ -327,7 +355,10 @@ export function draftFromCatalog(
|
|
|
327
355
|
draft.args = (e.args ?? []).map((a) => (e.fill && a === e.fill && filled ? filled : a));
|
|
328
356
|
} else {
|
|
329
357
|
draft.url = e.url;
|
|
330
|
-
|
|
358
|
+
// Every http entry in this catalog is an OAuth connector. Emit the adapter's own
|
|
359
|
+
// vocabulary (`auth`) rather than the legacy boolean, so the projection carries
|
|
360
|
+
// `auth: "oauth"` and not a bogus boolean in the adapter's OAuthConfig slot.
|
|
361
|
+
draft.auth = (e.oauth ?? true) ? "oauth" : "none";
|
|
331
362
|
}
|
|
332
363
|
|
|
333
364
|
const keys = Object.keys(e.env ?? {});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translating a routine's MCP selectors into the tool names Pi will actually honour.
|
|
3
|
+
*
|
|
4
|
+
* There are TWO vocabularies here and conflating them is why per-routine connector
|
|
5
|
+
* allow-lists silently granted nothing:
|
|
6
|
+
*
|
|
7
|
+
* • The SELECTOR vocabulary — "<server>__<tool>" exact, or "<server>__*" for a whole
|
|
8
|
+
* server. This is what a routine stores, what the app writes, and what the AI
|
|
9
|
+
* drafts. The double underscore is the point: no Pi builtin contains "__", so
|
|
10
|
+
* splitRoutineTools can tell a connector selector from a builtin name without a
|
|
11
|
+
* lookup table. This vocabulary is STABLE — routines on disk depend on it.
|
|
12
|
+
*
|
|
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.
|
|
18
|
+
*
|
|
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
|
|
23
|
+
* 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
|
+
*/
|
|
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
|
+
|
|
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
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The cached tool inventory the adapter builds after connecting: server → tool names. */
|
|
54
|
+
export type McpInventory = Record<string, string[]>;
|
|
55
|
+
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
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. */
|
|
69
|
+
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
|
+
}
|
|
86
|
+
|
|
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[] = [];
|
|
98
|
+
const servers: string[] = [];
|
|
99
|
+
const directToolsEnv: string[] = [];
|
|
100
|
+
const coldServers: string[] = [];
|
|
101
|
+
const unknownTools: string[] = [];
|
|
102
|
+
|
|
103
|
+
const push = <T>(arr: T[], v: T) => {
|
|
104
|
+
if (!arr.includes(v)) arr.push(v);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const selector of selectors) {
|
|
108
|
+
const sep = selector.indexOf("__");
|
|
109
|
+
if (sep <= 0) continue; // not a selector; splitRoutineTools already routed it
|
|
110
|
+
const server = selector.slice(0, sep);
|
|
111
|
+
const tool = selector.slice(sep + 2);
|
|
112
|
+
if (!tool) continue;
|
|
113
|
+
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);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { names, servers, directToolsEnv, coldServers, unknownTools };
|
|
136
|
+
}
|
|
137
|
+
|
|
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";
|
|
168
|
+
}
|
|
169
|
+
|
|
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
|
+
});
|
|
177
|
+
}
|