2026-08-08 16:23:02 +01:00
|
|
|
/**
|
|
|
|
|
* mcpctl pi extension — native integration (no MCP client, no Claude).
|
|
|
|
|
*
|
|
|
|
|
* Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers
|
|
|
|
|
* each project tool as a native pi tool.
|
|
|
|
|
*
|
|
|
|
|
* KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a
|
|
|
|
|
* `mcp-session-id`. A gated project only exposes `begin_session` until it is
|
|
|
|
|
* called, after which the full tool set becomes callable — but only within
|
|
|
|
|
* THAT session. So we keep ONE persistent `McpHttpSession` per active project
|
|
|
|
|
* and route every tools/call through it, re-listing after each call to pick up
|
|
|
|
|
* the post-begin_session tool additions.
|
|
|
|
|
*
|
|
|
|
|
* Features:
|
|
|
|
|
* - project gating auto-ungates when begin_session is called
|
|
|
|
|
* - switching the active project from the pi TUI (/mcpctl)
|
|
|
|
|
* - a status line
|
|
|
|
|
*
|
|
|
|
|
* Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts
|
|
|
|
|
* or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"]
|
|
|
|
|
*
|
|
|
|
|
* Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.
|
fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
The extension failed to load outright on older pi installs:
Failed to load extension ".../mcpctl-pi.ts":
Cannot find module '@earendil-works/pi-ai'
pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.
The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.
Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.
Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
|
|
|
*
|
|
|
|
|
* RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers
|
|
|
|
|
* through a fixed alias table in its own loader, and that table differs between
|
|
|
|
|
* pi distributions — `@earendil-works/*` exists only in the newer packages,
|
|
|
|
|
* while `@mariozechner/*` installs alias only the old names. `typebox` is the
|
|
|
|
|
* one specifier every published pi aliases, so it is the ONLY runtime import
|
|
|
|
|
* allowed here. Anything else must be `import type` (erased before jiti runs)
|
|
|
|
|
* or inlined — see `stringEnum` below.
|
2026-08-08 16:23:02 +01:00
|
|
|
*/
|
|
|
|
|
import { Type, type TSchema } from "typebox";
|
|
|
|
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
|
|
import {
|
|
|
|
|
McpHttpSession,
|
|
|
|
|
type ToolInfo,
|
|
|
|
|
type McpHttpOptions,
|
|
|
|
|
} from "./mcp-http.js";
|
|
|
|
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
|
|
|
import { join, dirname } from "node:path";
|
|
|
|
|
import { homedir } from "node:os";
|
|
|
|
|
|
|
|
|
|
// ── config / state ───────────────────────────────────────────────────────────
|
|
|
|
|
const McpctlDir = join(homedir(), ".mcpctl");
|
|
|
|
|
const ConfigPath = join(McpctlDir, "config.json");
|
|
|
|
|
const CredPath = join(McpctlDir, "credentials");
|
|
|
|
|
const PiStatePath = join(McpctlDir, "pi-state.json");
|
|
|
|
|
const ProjectMarker = ".mcpctl-project";
|
|
|
|
|
|
|
|
|
|
interface PiState {
|
|
|
|
|
project?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readJson<T>(path: string): Promise<T | null> {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(await readFile(path, "utf-8")) as T;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readConfig(): Promise<{ mcplocalUrl: string }> {
|
|
|
|
|
const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);
|
|
|
|
|
return { mcplocalUrl: cfg?.mcplocalUrl ?? "http://localhost:3200" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await readFile(CredPath, "utf-8");
|
|
|
|
|
const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };
|
|
|
|
|
if (!parsed.token) return null;
|
|
|
|
|
return parsed;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function readPiState(): Promise<PiState> {
|
|
|
|
|
return (await readJson<PiState>(PiStatePath)) ?? {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function writePiState(state: PiState): Promise<void> {
|
|
|
|
|
await mkdir(McpctlDir, { recursive: true });
|
|
|
|
|
await writeFile(PiStatePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Walk up directories looking for a `.mcpctl-project` marker. */
|
|
|
|
|
async function findProjectMarker(start = process.cwd()): Promise<string | null> {
|
|
|
|
|
const home = homedir();
|
|
|
|
|
let dir = start;
|
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
|
|
|
try {
|
|
|
|
|
const raw = await readFile(join(dir, ProjectMarker), "utf-8");
|
|
|
|
|
const project = raw.split("\n")[0]?.trim() ?? "";
|
|
|
|
|
if (project) return project;
|
|
|
|
|
} catch {
|
|
|
|
|
// continue walking
|
|
|
|
|
}
|
|
|
|
|
if (dir === home || dir === "/") break;
|
|
|
|
|
const parent = dirname(dir);
|
|
|
|
|
if (parent === dir) break;
|
|
|
|
|
dir = parent;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Request the list of projects from mcpd (via mcplocal API passthrough). */
|
|
|
|
|
async function listProjects(mcplocalUrl: string, token?: string): Promise<string[]> {
|
|
|
|
|
const url = `${mcplocalUrl}/api/v1/projects`;
|
|
|
|
|
const headers: Record<string, string> = { Accept: "application/json" };
|
|
|
|
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
|
|
|
const res = await fetch(url, { headers });
|
|
|
|
|
if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);
|
|
|
|
|
const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };
|
|
|
|
|
const arr = Array.isArray(body) ? body : (body.data ?? []);
|
|
|
|
|
return arr.map((p) => p.name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── JSON Schema → TypeBox ────────────────────────────────────────────────────
|
fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
The extension failed to load outright on older pi installs:
Failed to load extension ".../mcpctl-pi.ts":
Cannot find module '@earendil-works/pi-ai'
pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.
The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.
Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.
Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* `{ type: "string", enum: [...] }` rather than a union of literals: Google's
|
|
|
|
|
* API (and other providers that reject anyOf/const) only accept the flat form.
|
|
|
|
|
*
|
|
|
|
|
* Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in
|
|
|
|
|
* `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the
|
|
|
|
|
* whole extension then failed to load. See the import note at the top.
|
|
|
|
|
*/
|
|
|
|
|
function stringEnum(values: string[], description?: string): TSchema {
|
|
|
|
|
return Type.Unsafe<string>({
|
|
|
|
|
type: "string",
|
|
|
|
|
enum: values,
|
|
|
|
|
...(description ? { description } : {}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
function convertSchema(inputSchema: unknown): TSchema {
|
|
|
|
|
if (!inputSchema || typeof inputSchema !== "object") {
|
|
|
|
|
return Type.Object({});
|
|
|
|
|
}
|
|
|
|
|
const s = inputSchema as {
|
|
|
|
|
type?: string;
|
|
|
|
|
properties?: Record<string, unknown>;
|
|
|
|
|
required?: string[];
|
|
|
|
|
items?: unknown;
|
|
|
|
|
enum?: unknown[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const required = new Set(Array.isArray(s.required) ? s.required : []);
|
|
|
|
|
const props = s.properties ?? {};
|
|
|
|
|
const fields: Record<string, TSchema> = {};
|
|
|
|
|
|
|
|
|
|
for (const [name, raw] of Object.entries(props)) {
|
|
|
|
|
const schema = convertProp(raw);
|
|
|
|
|
fields[name] = required.has(name) ? schema : Type.Optional(schema);
|
|
|
|
|
}
|
|
|
|
|
return Type.Object(fields);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function convertProp(raw: unknown): TSchema {
|
|
|
|
|
if (!raw || typeof raw !== "object") return Type.Unknown();
|
|
|
|
|
const s = raw as {
|
|
|
|
|
type?: string | string[];
|
|
|
|
|
enum?: unknown[];
|
|
|
|
|
description?: string;
|
|
|
|
|
items?: unknown;
|
|
|
|
|
properties?: Record<string, unknown>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const desc = typeof s.description === "string" ? s.description : undefined;
|
|
|
|
|
const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;
|
|
|
|
|
|
|
|
|
|
if (enumVals && enumVals.every((v) => typeof v === "string")) {
|
fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
The extension failed to load outright on older pi installs:
Failed to load extension ".../mcpctl-pi.ts":
Cannot find module '@earendil-works/pi-ai'
pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.
The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.
Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.
Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
|
|
|
return stringEnum(enumVals as string[], desc);
|
2026-08-08 16:23:02 +01:00
|
|
|
}
|
|
|
|
|
if (enumVals && enumVals.every((v) => typeof v === "number")) {
|
|
|
|
|
const literals = enumVals.map((v) => Type.Literal(v));
|
|
|
|
|
return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let type = s.type;
|
|
|
|
|
if (Array.isArray(type)) type = type.find((t) => t !== "null");
|
|
|
|
|
type = type ?? (s.properties ? "object" : s.items ? "array" : "string");
|
|
|
|
|
if (type === "integer") type = "number";
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
case "string":
|
|
|
|
|
return desc ? Type.String({ description: desc }) : Type.String();
|
|
|
|
|
case "number":
|
|
|
|
|
return desc ? Type.Number({ description: desc }) : Type.Number();
|
|
|
|
|
case "boolean":
|
|
|
|
|
return desc ? Type.Boolean({ description: desc }) : Type.Boolean();
|
|
|
|
|
case "array": {
|
|
|
|
|
const inner = s.items ? convertProp(s.items) : Type.Unknown();
|
|
|
|
|
return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);
|
|
|
|
|
}
|
|
|
|
|
case "object":
|
|
|
|
|
return convertSchema(s as unknown);
|
|
|
|
|
default:
|
|
|
|
|
return desc ? Type.Unknown({ description: desc }) : Type.Unknown();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:51:36 +01:00
|
|
|
/**
|
|
|
|
|
* Order and filter the project list for the picker.
|
|
|
|
|
*
|
|
|
|
|
* Ordering: the active project first (most likely pick), then alphabetical.
|
|
|
|
|
* Filtering: space-separated terms, ALL of which must appear as
|
|
|
|
|
* case-insensitive substrings — so `home auto` finds `homeautomation`. A blank
|
|
|
|
|
* query keeps everything.
|
|
|
|
|
*
|
|
|
|
|
* Exported so the behaviour is unit-tested rather than eyeballed through a TUI.
|
|
|
|
|
*/
|
|
|
|
|
export function filterProjects(projects: string[], query: string, active: string | null): string[] {
|
|
|
|
|
const ordered = [...projects].sort((a, b) => {
|
|
|
|
|
if (a === active) return -1;
|
|
|
|
|
if (b === active) return 1;
|
|
|
|
|
return a.localeCompare(b);
|
|
|
|
|
});
|
|
|
|
|
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
|
|
|
|
|
if (terms.length === 0) return ordered;
|
|
|
|
|
return ordered.filter((p) => {
|
|
|
|
|
const name = p.toLowerCase();
|
|
|
|
|
return terms.every((t) => name.includes(t));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
fix(pi): correct the model's tool context after a project switch
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.
Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".
Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.
The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.
`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
|
|
|
/**
|
|
|
|
|
* The message injected into the conversation after a project switch, telling
|
|
|
|
|
* the model which mcpctl tools are live now.
|
|
|
|
|
*
|
|
|
|
|
* Exported so its wording is unit-tested; it is the only thing standing between
|
|
|
|
|
* the model and a stale tool list it will otherwise keep calling.
|
|
|
|
|
*/
|
|
|
|
|
export function toolChangeAnnouncement(project: string, tools: string[]): string {
|
|
|
|
|
const gate = tools.find((n) => n.endsWith("_begin_session"));
|
|
|
|
|
const parts = [
|
|
|
|
|
`[mcpctl] The active project is now '${project}'.`,
|
|
|
|
|
"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.",
|
|
|
|
|
tools.length > 0
|
|
|
|
|
? `Currently available mcpctl tools: ${tools.join(", ")}.`
|
|
|
|
|
: "No mcpctl tools are currently available for this project.",
|
|
|
|
|
];
|
|
|
|
|
if (gate !== undefined) {
|
|
|
|
|
parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);
|
|
|
|
|
}
|
|
|
|
|
return parts.join(" ");
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */
|
|
|
|
|
function safeSegment(name: string): string {
|
|
|
|
|
return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function slugify(name: string): string {
|
|
|
|
|
return safeSegment(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */
|
|
|
|
|
function piToolName(project: string, mcpTool: string): string {
|
|
|
|
|
const proj = slugify(project);
|
|
|
|
|
const tool = mcpTool.replace(/^[a-z0-9_-]+\//i, (m) => slugify(m.slice(0, -1)) + "_") || mcpTool;
|
|
|
|
|
return `mc_${proj}_${safeSegment(tool)}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── the extension ────────────────────────────────────────────────────────────
|
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
|
|
|
let mcplocalUrl = "http://localhost:3200";
|
|
|
|
|
let token: string | undefined;
|
|
|
|
|
let activeProject: string | null = null;
|
|
|
|
|
/** Persistent session for the active project — MUST be reused to keep gate state. */
|
|
|
|
|
let session: McpHttpSession | null = null;
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
/** Registered pi tool name → the MCP tool it forwards to, for the active project. */
|
|
|
|
|
const projectToolNames = new Map<string, string>();
|
2026-08-08 16:23:02 +01:00
|
|
|
let activeCtx: ExtensionContext | null = null;
|
|
|
|
|
|
|
|
|
|
function endpointUrl(): string {
|
|
|
|
|
return `${mcplocalUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(activeProject ?? "")}/mcp`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Build (or reuse) the persistent session for the active project. */
|
|
|
|
|
async function ensureSession(): Promise<McpHttpSession> {
|
|
|
|
|
if (session) return session;
|
|
|
|
|
const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };
|
|
|
|
|
const sess = new McpHttpSession(endpointUrl(), opts);
|
|
|
|
|
await sess.initialize();
|
|
|
|
|
await sess.sendNotification("notifications/initialized");
|
|
|
|
|
session = sess;
|
|
|
|
|
return sess;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function closeSession(): Promise<void> {
|
|
|
|
|
const s = session;
|
|
|
|
|
session = null;
|
|
|
|
|
if (s) await s.close();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* (Re)discover the active project's current tool set and register any new pi
|
|
|
|
|
* tools. Called on load and after every tool call (to pick up ungating).
|
|
|
|
|
*/
|
|
|
|
|
async function reconcileTools(project: string): Promise<{ tools: string[] }> {
|
|
|
|
|
const sess = await ensureSession();
|
|
|
|
|
const { tools } = await sess.listTools();
|
|
|
|
|
const names: string[] = [];
|
|
|
|
|
for (const t of tools) {
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
const piName = uniqueToolName(project, t.name);
|
|
|
|
|
if (piName === null) continue; // already registered for this same MCP tool
|
|
|
|
|
pi.registerTool(createTool(piName, t, project));
|
|
|
|
|
projectToolNames.set(piName, t.name);
|
2026-08-08 16:23:02 +01:00
|
|
|
names.push(piName);
|
|
|
|
|
}
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
// Names already registered for tools still in the list stay active.
|
|
|
|
|
for (const [piName, mcpName] of projectToolNames) {
|
|
|
|
|
if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);
|
|
|
|
|
}
|
2026-08-08 16:23:02 +01:00
|
|
|
// Activate built-ins/other extensions + all tools discovered in this project.
|
|
|
|
|
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
|
|
|
|
|
pi.setActiveTools([...new Set([...active, ...names])]);
|
|
|
|
|
return { tools: names };
|
|
|
|
|
}
|
|
|
|
|
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
/**
|
|
|
|
|
* The pi tool name to register `mcpTool` under, or null if it is already
|
|
|
|
|
* registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.
|
|
|
|
|
* `docs.search` and `docs-search`); without a suffix the second tool would
|
|
|
|
|
* silently never be registered while still being reported as available, and
|
|
|
|
|
* its calls would be forwarded to the first tool instead.
|
|
|
|
|
*/
|
|
|
|
|
function uniqueToolName(project: string, mcpTool: string): string | null {
|
|
|
|
|
const base = piToolName(project, mcpTool);
|
|
|
|
|
if (projectToolNames.get(base) === mcpTool) return null;
|
|
|
|
|
if (!projectToolNames.has(base)) return base;
|
|
|
|
|
for (let i = 2; i < 100; i++) {
|
|
|
|
|
const candidate = `${base}_${String(i)}`;
|
|
|
|
|
if (projectToolNames.get(candidate) === mcpTool) return null;
|
|
|
|
|
if (!projectToolNames.has(candidate)) return candidate;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
function createTool(piName: string, t: ToolInfo, project: string) {
|
|
|
|
|
const mcpName = t.name;
|
|
|
|
|
return {
|
|
|
|
|
name: piName,
|
|
|
|
|
label: `${project} :: ${mcpName}`,
|
|
|
|
|
description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,
|
|
|
|
|
parameters: convertSchema(t.inputSchema),
|
|
|
|
|
async execute(_toolCallId: string, params: Record<string, unknown>) {
|
|
|
|
|
const sess = await ensureSession();
|
|
|
|
|
const res = await sess.callTool(mcpName, params ?? {});
|
|
|
|
|
// Re-list after every call — picks up post-begin_session tool additions.
|
|
|
|
|
try {
|
|
|
|
|
await reconcileTools(project);
|
|
|
|
|
} catch {
|
|
|
|
|
// non-fatal
|
|
|
|
|
}
|
|
|
|
|
const text = (res.content ?? [])
|
|
|
|
|
.filter((c) => c.type === "text")
|
|
|
|
|
.map((c) => c.text ?? "")
|
|
|
|
|
.join("\n");
|
|
|
|
|
if (res.isError) {
|
|
|
|
|
throw new Error(text || `${mcpName} returned an error`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
// `type` must be the literal "text" — pi's AgentToolResult content is
|
|
|
|
|
// a TextContent | ImageContent union, not { type: string }.
|
|
|
|
|
content: [{ type: "text" as const, text: text || "(no text content)" }],
|
2026-08-08 16:23:02 +01:00
|
|
|
details: { mcpTool: mcpName, project, raw: res },
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setStatus(msg?: string): void {
|
|
|
|
|
try {
|
|
|
|
|
activeCtx?.ui?.setStatus?.("mcpctl", msg ?? (activeProject ? `project: ${activeProject}` : "no project"));
|
|
|
|
|
} catch {
|
|
|
|
|
/* non-fatal */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── /mcpctl command ──
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
/**
|
|
|
|
|
* Menu labels → actions. `ctx.ui.select` takes plain strings and returns the
|
|
|
|
|
* chosen string, so the mapping has to live here rather than in the options.
|
|
|
|
|
*/
|
|
|
|
|
const MENU: ReadonlyArray<readonly [label: string, action: string]> = [
|
|
|
|
|
["Status", "status"],
|
|
|
|
|
["Switch project", "switch"],
|
|
|
|
|
["Refresh tools", "refresh"],
|
|
|
|
|
["Sync skills into pi's skills dir", "sync"],
|
|
|
|
|
["Close", "close"],
|
|
|
|
|
];
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
pi.registerCommand("mcpctl", {
|
|
|
|
|
description: "mcpctl integration: status, switch project, refresh tools",
|
|
|
|
|
handler: async (_args, ctx) => {
|
|
|
|
|
if (!ctx.hasUI) {
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
// A command handler resolves to void — pi ignores a returned string, so
|
|
|
|
|
// headless status has to go through notify.
|
|
|
|
|
ctx.ui.notify(
|
|
|
|
|
`mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`,
|
|
|
|
|
"info",
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-08-08 16:23:02 +01:00
|
|
|
}
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
const picked = await ctx.ui.select("mcpctl", MENU.map(([label]) => label));
|
|
|
|
|
if (!picked) return;
|
|
|
|
|
const choice = MENU.find(([label]) => label === picked)?.[1];
|
|
|
|
|
if (!choice || choice === "close") return;
|
2026-08-08 16:23:02 +01:00
|
|
|
|
|
|
|
|
if (choice === "status") {
|
|
|
|
|
const cs = await readConfig();
|
|
|
|
|
const creds = await readCreds();
|
|
|
|
|
const info =
|
|
|
|
|
`mcplocal: ${cs.mcplocalUrl}\n` +
|
|
|
|
|
`user: ${creds?.mcpdUrl ?? "not logged in"}\n` +
|
|
|
|
|
`active project: ${activeProject ?? "none"}\n` +
|
|
|
|
|
`tools registered: ${projectToolNames.size}\n` +
|
|
|
|
|
` ${[...projectToolNames].join("\n ")}\n`;
|
|
|
|
|
await ctx.ui.editor("mcpctl status", info);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (choice === "switch") {
|
|
|
|
|
await switchProject(ctx);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (choice === "refresh") {
|
|
|
|
|
if (!activeProject) {
|
|
|
|
|
ctx.ui.notify("No active project — switch project first", "warning");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const r = await reconcileTools(activeProject);
|
|
|
|
|
ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, "info");
|
|
|
|
|
setStatus();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, "error");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
if (choice === "sync") {
|
2026-08-08 16:23:02 +01:00
|
|
|
try {
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
// `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it
|
|
|
|
|
// this syncs into ~/.claude/skills, which the whole point of this
|
|
|
|
|
// integration is to avoid depending on.
|
|
|
|
|
const args = ["skills", "sync", "--agent", "pi", "--quiet"];
|
|
|
|
|
if (activeProject) args.push("-p", activeProject);
|
|
|
|
|
// execFile, not execSync+stdio:"inherit": a shell would need the
|
|
|
|
|
// project name quoted, and inherited stdio writes raw output over
|
|
|
|
|
// pi's TUI. Capture instead and report through notify.
|
|
|
|
|
const { execFile } = await import("node:child_process");
|
|
|
|
|
const stderr = await new Promise<string>((resolve, reject) => {
|
|
|
|
|
execFile("mcpctl", args, { timeout: 120_000 }, (err, _stdout, errOut) => {
|
|
|
|
|
if (err) reject(new Error((errOut || err.message).trim()));
|
|
|
|
|
else resolve(errOut);
|
|
|
|
|
});
|
2026-08-08 16:23:02 +01:00
|
|
|
});
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 17:30:42 +01:00
|
|
|
const detail = stderr.trim().split("\n").pop();
|
|
|
|
|
ctx.ui.notify(
|
|
|
|
|
`mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : ""}. Run /reload to pick them up.`,
|
|
|
|
|
"info",
|
|
|
|
|
);
|
2026-08-08 16:23:02 +01:00
|
|
|
} catch (e) {
|
|
|
|
|
ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-08-08 17:51:36 +01:00
|
|
|
/** Above this many projects, arrowing through the list stops being usable. */
|
|
|
|
|
const FILTER_THRESHOLD = 20;
|
2026-08-08 18:34:10 +01:00
|
|
|
/**
|
|
|
|
|
* Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()
|
|
|
|
|
* renders *every* option with no windowing, so an uncapped list scrolls the
|
|
|
|
|
* whole screen away. prime-agent's selector windows to ~20 itself; this
|
|
|
|
|
* matches that height so both hosts feel the same.
|
|
|
|
|
*/
|
|
|
|
|
const MAX_SHOWN = 20;
|
2026-08-08 17:51:36 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Choose a project, asking for a filter first when the list is long.
|
|
|
|
|
*
|
|
|
|
|
* pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no
|
|
|
|
|
* search and `ExtensionSelectorComponent` ignores typed characters — so the
|
|
|
|
|
* filtering has to happen before the list is handed over. Real installs run
|
|
|
|
|
* to hundreds of projects (smoke-test leftovers included), where scrolling is
|
|
|
|
|
* hopeless.
|
|
|
|
|
*
|
|
|
|
|
* Terms are space-separated and ALL must match, case-insensitively, as
|
|
|
|
|
* substrings: `home auto` finds `homeautomation`. Blank shows everything.
|
|
|
|
|
*/
|
|
|
|
|
async function pickProject(ctx: ExtensionContext, projects: string[]): Promise<string | undefined> {
|
|
|
|
|
let candidates = filterProjects(projects, "", activeProject);
|
|
|
|
|
if (candidates.length > FILTER_THRESHOLD) {
|
|
|
|
|
const query = await ctx.ui.input(
|
|
|
|
|
`Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,
|
|
|
|
|
"e.g. home auto",
|
|
|
|
|
);
|
|
|
|
|
if (query === undefined) return undefined; // cancelled
|
|
|
|
|
candidates = filterProjects(projects, query, activeProject);
|
|
|
|
|
if (candidates.length === 0) {
|
|
|
|
|
ctx.ui.notify(`No project matches '${query}'`, "warning");
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const truncated = candidates.length > MAX_SHOWN;
|
|
|
|
|
const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;
|
|
|
|
|
const title = truncated
|
|
|
|
|
// Say what was dropped: a silently capped list reads as "that's all of them".
|
|
|
|
|
? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`
|
|
|
|
|
: `Switch to project (${String(shown.length)})`;
|
|
|
|
|
return ctx.ui.select(title, shown);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
async function switchProject(ctx: ExtensionContext): Promise<void> {
|
|
|
|
|
let projects: string[] = [];
|
|
|
|
|
try {
|
|
|
|
|
projects = await listProjects(mcplocalUrl, token);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, "error");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (projects.length === 0) {
|
|
|
|
|
ctx.ui.notify("No projects returned by mcpd", "warning");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-08-08 17:51:36 +01:00
|
|
|
const picked = await pickProject(ctx, projects);
|
2026-08-08 16:23:02 +01:00
|
|
|
if (!picked) return;
|
|
|
|
|
|
|
|
|
|
// Tear down old project's session + active tools.
|
|
|
|
|
await closeSession();
|
|
|
|
|
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
|
|
|
|
|
pi.setActiveTools([...active]);
|
|
|
|
|
projectToolNames.clear();
|
|
|
|
|
|
|
|
|
|
activeProject = picked;
|
|
|
|
|
await writePiState({ project: picked });
|
|
|
|
|
try {
|
|
|
|
|
const r = await reconcileTools(picked);
|
fix(pi): correct the model's tool context after a project switch
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.
Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".
Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.
The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.
`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
|
|
|
const gate = r.tools.find((n) => n.endsWith("_begin_session"));
|
|
|
|
|
ctx.ui.notify(
|
|
|
|
|
`Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : ""}`,
|
|
|
|
|
"info",
|
|
|
|
|
);
|
2026-08-08 16:23:02 +01:00
|
|
|
setStatus();
|
fix(pi): correct the model's tool context after a project switch
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.
Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".
Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.
The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.
`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
|
|
|
announceToolChange(picked, r.tools);
|
2026-08-08 16:23:02 +01:00
|
|
|
} catch (e) {
|
|
|
|
|
ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
fix(pi): correct the model's tool context after a project switch
Switching projects twice left the agent believing it had lost MCP access
entirely: it kept calling the previous project's tool names, got
"Tool mc_<old>_begin_session not found", and concluded no MCP tools existed.
Root cause is a pi constraint, not a bug in the switch. pi has no way to
unregister a tool — `registerTool` only ever does `extension.tools.set(name)`
— so the previous project's `mc_*` tools stay registered and merely go
inactive. `setActiveTools` correctly drops them from the live set, but the
conversation still contains the old project's tool listing, so the model
keeps calling names that now answer "not found".
Nothing was telling the model the tool set had changed. Now the switch
injects a custom message naming the active project, stating that previously
listed mcpctl tool names are dead, and listing what is actually callable.
Custom messages are converted to user-role messages by `convertToLlm`, so
they do reach the model (unlike `appendEntry`, which is explicitly excluded
from context). `display: false` keeps it out of the transcript — the
notification is what the human reads.
The gate is also called out explicitly, in both the notification and the
injected message. A freshly switched project gets a new mcp-session-id and is
therefore gated again, so "1 tool(s) ready" is correct but reads like a
failure; it now says which begin_session call unlocks the rest.
`toolChangeAnnouncement` is exported and unit-tested rather than left as
wording only reachable through a TUI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
2026-08-08 18:05:33 +01:00
|
|
|
/**
|
|
|
|
|
* Tell the *model* that the tool set changed.
|
|
|
|
|
*
|
|
|
|
|
* pi cannot unregister a tool — `registerTool` only ever adds to the
|
|
|
|
|
* extension's tool map — so the previous project's `mc_*` tools stay
|
|
|
|
|
* registered and merely go inactive. Meanwhile the conversation still
|
|
|
|
|
* contains the old project's tool list, so the model keeps calling names that
|
|
|
|
|
* now answer "Tool ... not found" and concludes it has lost MCP access
|
|
|
|
|
* entirely, which is what happens in practice on the second switch.
|
|
|
|
|
*
|
|
|
|
|
* A custom message is converted to a user-role message and does reach the
|
|
|
|
|
* LLM (unlike `appendEntry`, which is explicitly excluded from context), so
|
|
|
|
|
* this corrects the stale context instead of leaving the model to guess.
|
|
|
|
|
* `display: false` keeps it out of the transcript — the notify above is what
|
|
|
|
|
* the human reads.
|
|
|
|
|
*/
|
|
|
|
|
function announceToolChange(project: string, tools: string[]): void {
|
|
|
|
|
try {
|
|
|
|
|
pi.sendMessage(
|
|
|
|
|
{ customType: "mcpctl-project-switch", content: toolChangeAnnouncement(project, tools), display: false },
|
|
|
|
|
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
|
|
|
);
|
|
|
|
|
} catch {
|
|
|
|
|
// Older pi without sendMessage — the notify above still informs the user.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 16:23:02 +01:00
|
|
|
// ── session lifecycle ──
|
|
|
|
|
pi.on("session_start", async (_event, ctx) => {
|
|
|
|
|
activeCtx = ctx;
|
|
|
|
|
try { ctx.ui?.setStatus?.("mcpctl", "connecting..."); } catch { /* ignore */ }
|
|
|
|
|
const cfg = await readConfig();
|
|
|
|
|
mcplocalUrl = cfg.mcplocalUrl;
|
|
|
|
|
const creds = await readCreds();
|
|
|
|
|
token = creds?.token ?? undefined;
|
|
|
|
|
|
|
|
|
|
const piState = await readPiState();
|
|
|
|
|
const project = piState.project ?? (await findProjectMarker());
|
|
|
|
|
if (!project) {
|
|
|
|
|
try { ctx.ui?.setStatus?.("mcpctl", "no project (use /mcpctl)"); } catch { /* ignore */ }
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
activeProject = project;
|
|
|
|
|
try {
|
|
|
|
|
const r = await reconcileTools(project);
|
|
|
|
|
if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, "info");
|
|
|
|
|
setStatus();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, "error");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pi.on("session_shutdown", () => {
|
|
|
|
|
void closeSession();
|
|
|
|
|
});
|
|
|
|
|
}
|