feat(pi): add native pi integration — extension, config pi, skills --agent pi
- src/pi-ext/: self-contained pi extension (mcpctl-pi.ts + vendored mcp-http client) that talks JSON-RPC directly to mcplocal and registers project MCP tools as native pi tools. No MCP client, no ~/.claude. - Persistent per-project session so gated projects ungate on begin_session. - /mcpctl command: status, switch project (GUI), refresh tools, sync skills. - mcpctl config pi: installs extension, wires pi settings, persists active project, syncs skills into ~/.pi/agent/skills. - skills sync: add --agent pi (target install root). - docs + tests.
This commit is contained in:
24
README.md
24
README.md
@@ -113,6 +113,30 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re
|
||||
mcpctl console monitoring # Preview what Claude sees
|
||||
```
|
||||
|
||||
### 7. Use mcpctl with pi (no MCP client, no Claude)
|
||||
|
||||
[pi](https://github.com/earendil-works/pi) does not support MCP, but it supports
|
||||
**extensions** and **skills**. mcpctl ships a native pi integration that talks
|
||||
JSON-RPC directly to mcplocal over HTTP — no MCP library, no `~/.claude`
|
||||
dependency, so it keeps working even if you drop Claude entirely.
|
||||
|
||||
```bash
|
||||
mcpctl config pi --project monitoring
|
||||
```
|
||||
|
||||
This installs the `mcpctl` pi extension into `~/.pi/agent/extensions/mcpctl/`,
|
||||
registers it (plus a pi skills dir) in `~/.pi/agent/settings.json`, persists the
|
||||
active project in `~/.mcpctl/pi-state.json`, and runs an initial skills sync into
|
||||
`~/.pi/agent/skills/`. Then start pi and the project's MCP tools are registered as
|
||||
native pi tools (`mc_<project>_<tool>`).
|
||||
|
||||
Inside pi:
|
||||
|
||||
- `/mcpctl` — status, **switch project** (from the GUI), refresh tools, sync skills
|
||||
- gated projects auto-ungate: call `begin_session` once and the full tool set opens
|
||||
|
||||
See [docs/pi-extension.md](docs/pi-extension.md) for full details.
|
||||
|
||||
## Declarative Configuration
|
||||
|
||||
Everything can be defined in YAML and applied with `mcpctl apply`:
|
||||
|
||||
97
docs/pi-extension.md
Normal file
97
docs/pi-extension.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# mcpctl × pi — native integration (no MCP, no Claude)
|
||||
|
||||
## Motivation
|
||||
|
||||
With Claude Code, `mcpctl config claude --project X` wires a project's MCP
|
||||
servers into the agent two ways:
|
||||
|
||||
1. a `.mcp.json` entry running `mcpctl mcp -p X` (an MCP **stdio bridge**), and
|
||||
2. a `SessionStart` hook + `mcpctl skills sync` that materialises server-side
|
||||
**skills** under `~/.claude/skills/`.
|
||||
|
||||
Claude Code supports MCP natively, so tools travel over MCP. **pi does not
|
||||
support MCP.** But pi has two native mechanisms that cover the same ground:
|
||||
|
||||
- **Extensions** — TypeScript modules that can register tools, commands, and
|
||||
custom UI, and can talk HTTP directly.
|
||||
- **Skills** — pi implements the [Agent Skills standard](https://agentskills.io/specification)
|
||||
and loads `SKILL.md` trees (the exact format mcpctl already syncs).
|
||||
|
||||
This addon exploits both so mcpctl works with **pi only**, with **no MCP** and
|
||||
**no dependency on `~/.claude/`** (so it keeps working if you drop Claude).
|
||||
|
||||
## How it works
|
||||
|
||||
The MCP layer is not magic. `mcpctl mcp` is just a JSON-RPC ↔ stdio bridge that
|
||||
forwards to mcplocal's Streamable-HTTP endpoint:
|
||||
|
||||
```
|
||||
POST {mcplocalUrl}/projects/<project>/mcp
|
||||
Accepts: application/json, text/event-stream
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The protocol is JSON-RPC over HTTP:
|
||||
- `initialize` → returns an `mcp-session-id` header
|
||||
- `notifications/initialized`
|
||||
- `tools/list` → `{ tools: [{ name, description, inputSchema }] }`
|
||||
- `tools/call` → `{ name, arguments }` → `{ content, isError }`
|
||||
|
||||
The pi extension is a **direct JSON-RPC client** to that same endpoint. It
|
||||
replaces the MCP transport with a plain function call, so pi never needs an MCP
|
||||
client library.
|
||||
|
||||
### Gating
|
||||
|
||||
Some projects are *gated*: until `begin_session` is called, `tools/list` returns
|
||||
only `begin_session`. After it is called the full tool set appears.
|
||||
|
||||
**Critical subtlety:** mcplocal's gate state is bound to a `mcp-session-id`. A
|
||||
fresh session is gated again even after another session was ungated — so the
|
||||
extension keeps **one persistent `McpHttpSession` per active project** and
|
||||
routes every `tools/call` through it. After each call it re-runs `tools/list`
|
||||
and reconciles the active pi tool set, so calling `begin_session` automatically
|
||||
opens the gate and reveals the rest — no push listener required (mcplocal's HTTP
|
||||
responses are request/response, not a persistent push stream).
|
||||
|
||||
## Deliverables
|
||||
|
||||
| Artifact | Purpose |
|
||||
|----------|---------|
|
||||
| `src/pi-ext/mcpctl-pi.ts` | Self-contained pi extension (load via `pi -e` or settings `extensions`) |
|
||||
| `mcpctl config pi` | CLI wiring: registers the extension with pi + initial skill sync |
|
||||
| `docs/pi-extension.md` | This document |
|
||||
|
||||
## Project switching
|
||||
|
||||
The active project is persisted in `~/.mcpctl/pi-state.json` (settable, immune to
|
||||
your shell's cwd) and is also inferred from a `.mcpctl-project` marker walk-up.
|
||||
`/mcpctl` opens an interactive menu; **switch project** lists projects via
|
||||
`ctx.ui.select()` and reloads the tool set for the new project, namespaced so
|
||||
projects never collide.
|
||||
|
||||
### Tool naming
|
||||
|
||||
To allow multiple projects and avoid schema collisions, registered pi tools are
|
||||
namespaced: `mc_<project-slug>_<mcp-tool-name>`. Only the **current project's**
|
||||
tools are in the active set at any time.
|
||||
|
||||
## Skills
|
||||
|
||||
Skills already sync in the Agent Skills `SKILL.md` format. `mcpctl config pi`
|
||||
(when given a project) writes them under pi's own directory
|
||||
`~/.pi/agent/skills/` and registers that directory in `~/.pi/agent/settings.json`
|
||||
— no Claude involvement. `mcpctl pi sync-skills` (a thin wrapper) re-runs the
|
||||
existing `skills` code path against the pi target directory.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/pi-ext/
|
||||
mcpctl-pi.ts # the extension (self-contained)
|
||||
mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps)
|
||||
```
|
||||
|
||||
The extension imports only from pi-bundled packages
|
||||
(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it
|
||||
loads standalone.
|
||||
@@ -11,6 +11,17 @@ import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { writeProjectMarker } from '../utils/project-marker.js';
|
||||
import { installManagedSessionHook } from '../utils/sessionhook.js';
|
||||
import {
|
||||
installExtensionFiles,
|
||||
registerWithPi,
|
||||
piSkillsDir,
|
||||
piSettingsPath,
|
||||
piExtensionDir,
|
||||
piExtensionMainPath,
|
||||
piStatePath,
|
||||
withPiAgentDir,
|
||||
writePiProjectState,
|
||||
} from '../utils/pi-settings.js';
|
||||
import { runSkillsSync } from './skills.js';
|
||||
|
||||
interface McpConfig {
|
||||
@@ -196,6 +207,98 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
registerClaudeCommand('claude', false);
|
||||
registerClaudeCommand('claude-generate', true); // backward compat
|
||||
|
||||
// ── pi: wire the mcpctl pi extension (no MCP, no Claude) ──
|
||||
config
|
||||
.command('pi')
|
||||
.description('Install the pi extension + sync skills (native, no MCP client, no Claude)')
|
||||
.option('-p, --project <name>', 'Project name to make active')
|
||||
.option('--extension-dir <path>', 'Path to the src/pi-ext sources to install (default: this checkout)')
|
||||
.option('--skip-skills', 'Skip the initial skills sync')
|
||||
.option('--settings <path>', 'pi settings.json path (default: ~/.pi/agent/settings.json)')
|
||||
.option('--pi-dir <path>', 'Override the pi agent home (default: ~/.pi/agent)')
|
||||
.action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => {
|
||||
if (!opts.project) {
|
||||
log('Error: --project is required for mcpctl config pi');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve target paths, honouring an optional --pi-dir override.
|
||||
const piHome = opts.piDir ? withPiAgentDir(opts.piDir) : undefined;
|
||||
const settingsPath = opts.settings ?? piHome?.settingsPath() ?? piSettingsPath();
|
||||
const extDest = piHome?.extensionDir() ?? piExtensionDir();
|
||||
const skillsInstall = piHome?.skillsDir() ?? piSkillsDir();
|
||||
// Isolate the persisted active-project state under the pi home when a
|
||||
// custom --pi-dir is used (also keeps tests off the real ~/.mcpctl).
|
||||
const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath();
|
||||
|
||||
// 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/
|
||||
let srcDir: string = opts.extensionDir!;
|
||||
if (!srcDir) {
|
||||
// best-effort: locate this checkout's src/pi-ext from the running
|
||||
// script (dist compile) or from cwd (tsx/dev).
|
||||
const candidates = [
|
||||
resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', 'src', 'pi-ext'),
|
||||
resolve(import.meta.dirname ?? process.cwd(), '..', '..', '..', '..', '..', 'src', 'pi-ext'),
|
||||
resolve(process.cwd(), 'src', 'pi-ext'),
|
||||
];
|
||||
const { existsSync } = await import('node:fs');
|
||||
srcDir = candidates.find((c) => existsSync(join(c, 'mcpctl-pi.ts'))) ?? candidates[0]!;
|
||||
}
|
||||
try {
|
||||
const written = await installExtensionFiles(srcDir, extDest);
|
||||
log(`Installed extension files:`);
|
||||
for (const w of written) log(` ${w}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Error: could not install extension from '${srcDir}': ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Register with pi settings (extensions + skills dir).
|
||||
try {
|
||||
const extMain = piHome?.extensionMainPath() ?? piExtensionMainPath();
|
||||
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extMain, skillsInstall);
|
||||
log(`Updated ${settingsPath}`);
|
||||
log(` extensions: ${addedExtensions.length ? addedExtensions.join(', ') : 'already registered'}`);
|
||||
log(` skills: ${addedSkills.length ? addedSkills.join(', ') : 'already registered'}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: could not update pi settings: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 3. Persist the active project (survives across sessions/cwd).
|
||||
try {
|
||||
const p = await writePiProjectState(opts.project, statePath);
|
||||
log(`Wrote active project to ${p}`);
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: could not write pi state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
// 4. Optional initial skills sync into pi's skills dir.
|
||||
if (!opts.skipSkills && skillsClient) {
|
||||
try {
|
||||
const result = await runSkillsSync(
|
||||
{ project: opts.project, installRoot: skillsInstall },
|
||||
{ client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
|
||||
);
|
||||
const total = result.installed.length + result.updated.length + result.removed.length;
|
||||
if (total > 0) {
|
||||
log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`);
|
||||
} else {
|
||||
log('Skills: no changes (already up to date)');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
} else if (!opts.skipSkills) {
|
||||
log('Warning: skipping initial skills sync (no API client provided)');
|
||||
}
|
||||
|
||||
log('');
|
||||
log('Next: restart pi (or start a new session) — the mcpctl tools for the active');
|
||||
log(`project will be registered automatically. Use /mcpctl to switch projects.`);
|
||||
});
|
||||
|
||||
config.addCommand(createConfigSetupCommand({ configDeps }));
|
||||
|
||||
if (apiDeps) {
|
||||
|
||||
@@ -435,30 +435,52 @@ export interface SkillsCommandDeps {
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an agent target to its on-disk skills install root.
|
||||
* "claude" → ~/.claude/skills
|
||||
* "prime-agent" → ~/.prime/agent/skills
|
||||
* "pi" → ~/.pi/agent/skills
|
||||
* Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree.
|
||||
*/
|
||||
export function agentInstallRoot(agent: string | undefined): string {
|
||||
switch (agent) {
|
||||
case 'prime-agent':
|
||||
return join(homedir(), '.prime', 'agent', 'skills');
|
||||
case 'pi':
|
||||
return join(homedir(), '.pi', 'agent', 'skills');
|
||||
case 'claude':
|
||||
default:
|
||||
return join(homedir(), '.claude', 'skills');
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkillsCommand(deps: SkillsCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
const warn = (...args: unknown[]): void => {
|
||||
console.error(...(args as Parameters<typeof console.error>));
|
||||
};
|
||||
|
||||
const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd');
|
||||
const cmd = new Command('skills').description('Manage Agent-skill bundles synced from mcpd');
|
||||
|
||||
cmd.command('sync')
|
||||
.description('Sync skills from mcpd onto disk under ~/.claude/skills/')
|
||||
.description('Sync skills from mcpd onto disk')
|
||||
.option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
|
||||
.option('--agent <name>', 'Sync target install root: claude (default), prime-agent, or pi', 'claude')
|
||||
.option('--dry-run', 'Print what would change without writing anything')
|
||||
.option('--force', 'Overwrite locally-modified skills')
|
||||
.option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)')
|
||||
.option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)')
|
||||
.option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)')
|
||||
.option('--keep-orphans', 'Do not remove skills that are no longer in the server set')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
agent?: string;
|
||||
dryRun?: boolean;
|
||||
force?: boolean;
|
||||
quiet?: boolean;
|
||||
skipPostinstall?: boolean;
|
||||
keepOrphans?: boolean;
|
||||
}) => {
|
||||
const installRoot = agentInstallRoot(opts.agent);
|
||||
const result = await runSkillsSync(
|
||||
{
|
||||
...(opts.project !== undefined ? { project: opts.project } : {}),
|
||||
@@ -467,6 +489,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
|
||||
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
|
||||
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
|
||||
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
|
||||
installRoot,
|
||||
},
|
||||
{ client, log, warn },
|
||||
);
|
||||
|
||||
147
src/cli/src/utils/pi-settings.ts
Normal file
147
src/cli/src/utils/pi-settings.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Wiring helpers for the pi extension.
|
||||
*
|
||||
* `mcpctl config pi --project X`:
|
||||
* 1. copies `src/pi-ext/` (extension + http client) into
|
||||
* `~/.pi/agent/extensions/mcpctl/`,
|
||||
* 2. adds the extension path and the pi skills dir to
|
||||
* `~/.pi/agent/settings.json` (`extensions`, `skills` arrays),
|
||||
* 3. writes `~/.mcpctl/pi-state.json` so the active project survives across
|
||||
* sessions regardless of the shell's cwd.
|
||||
*
|
||||
* Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises';
|
||||
import { dirname, join, basename, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
export function piAgentDir(): string {
|
||||
return join(homedir(), '.pi', 'agent');
|
||||
}
|
||||
|
||||
/** Override the pi agent home (for tests / custom installs). */
|
||||
export function withPiAgentDir(base: string): {
|
||||
settingsPath: () => string;
|
||||
extensionDir: () => string;
|
||||
skillsDir: () => string;
|
||||
extensionMainPath: () => string;
|
||||
} {
|
||||
return {
|
||||
settingsPath: () => join(base, 'settings.json'),
|
||||
extensionDir: () => join(base, 'extensions', 'mcpctl'),
|
||||
skillsDir: () => join(base, 'skills'),
|
||||
extensionMainPath: () => join(base, 'extensions', 'mcpctl', 'mcpctl-pi.ts'),
|
||||
};
|
||||
}
|
||||
|
||||
export function piSettingsPath(): string {
|
||||
return join(piAgentDir(), 'settings.json');
|
||||
}
|
||||
|
||||
export function piExtensionDir(): string {
|
||||
return join(piAgentDir(), 'extensions', 'mcpctl');
|
||||
}
|
||||
|
||||
export function piSkillsDir(): string {
|
||||
return join(piAgentDir(), 'skills');
|
||||
}
|
||||
|
||||
export function piExtensionMainPath(): string {
|
||||
return join(piExtensionDir(), 'mcpctl-pi.ts');
|
||||
}
|
||||
|
||||
export function piStatePath(): string {
|
||||
return join(homedir(), '.mcpctl', 'pi-state.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the bundled extension sources (relative to this source tree's
|
||||
* `src/pi-ext/`) into the pi extensions dir. The extension imports its http
|
||||
* client via a sibling `./mcp-http.js`, so both files must land together.
|
||||
*/
|
||||
export async function installExtensionFiles(
|
||||
srcPiExtDir: string,
|
||||
dest: string = piExtensionDir(),
|
||||
): Promise<string[]> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
const files = [
|
||||
['mcpctl-pi.ts', 'mcpctl-pi.ts'],
|
||||
['mcp-http.ts', 'mcp-http.ts'],
|
||||
] as const;
|
||||
const written: string[] = [];
|
||||
for (const [from, to] of files) {
|
||||
const src = resolve(srcPiExtDir, from);
|
||||
const dst = join(dest, to);
|
||||
await copyFile(src, dst);
|
||||
written.push(dst);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
interface PiSettings {
|
||||
extensions?: string[];
|
||||
skills?: string[];
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
async function readSettings(path: string): Promise<PiSettings> {
|
||||
try {
|
||||
const raw = await readFile(path, 'utf-8');
|
||||
if (raw.trim().length === 0) return {};
|
||||
return JSON.parse(raw) as PiSettings;
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: string };
|
||||
if (e.code === 'ENOENT') return {};
|
||||
// settings.json may contain line comments; strip them defensively.
|
||||
const stripped = (await readFile(path, 'utf-8')).replace(/^\s*\/\/.*$/gm, '');
|
||||
return JSON.parse(stripped) as PiSettings;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettings(path: string, settings: PiSettings): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const tmp = `${path}.tmp.${String(process.pid)}`;
|
||||
await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
||||
await rename(tmp, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge our extension path + skills dir into `~/.pi/agent/settings.json`.
|
||||
* Idempotent — re-running doesn't duplicate entries.
|
||||
* Returns the paths that were added.
|
||||
*/
|
||||
export async function registerWithPi(
|
||||
settingsPath = piSettingsPath(),
|
||||
extensionPath = piExtensionMainPath(),
|
||||
skillsDir = piSkillsDir(),
|
||||
): Promise<{ addedExtensions: string[]; addedSkills: string[] }> {
|
||||
const settings = await readSettings(settingsPath);
|
||||
if (!Array.isArray(settings.extensions)) settings.extensions = [];
|
||||
if (!Array.isArray(settings.skills)) settings.skills = [];
|
||||
|
||||
const addedExtensions: string[] = [];
|
||||
if (!settings.extensions.includes(extensionPath)) {
|
||||
settings.extensions.push(extensionPath);
|
||||
addedExtensions.push(extensionPath);
|
||||
}
|
||||
const addedSkills: string[] = [];
|
||||
if (!settings.skills.includes(skillsDir)) {
|
||||
settings.skills.push(skillsDir);
|
||||
addedSkills.push(skillsDir);
|
||||
}
|
||||
|
||||
await writeSettings(settingsPath, settings);
|
||||
return { addedExtensions, addedSkills };
|
||||
}
|
||||
|
||||
/** Write the persisted active-project state. */
|
||||
export async function writePiProjectState(project: string, path: string = piStatePath()): Promise<string> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8');
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Debug helper: the on-disk file name of an extension path. */
|
||||
export function _base(path: string): string {
|
||||
return basename(path);
|
||||
}
|
||||
83
src/cli/tests/commands/config-pi.test.ts
Normal file
83
src/cli/tests/commands/config-pi.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
import type { ApiClient } from '../../src/api-client.js';
|
||||
|
||||
function mockClient(visibleSkills: unknown[] = []): ApiClient {
|
||||
return {
|
||||
get: vi.fn(async (path: string) => {
|
||||
if (path.endsWith('/skills/visible')) return visibleSkills;
|
||||
if (path.startsWith('/api/v1/skills/')) return {
|
||||
id: 's1', name: 'demo', description: 'd', semver: '1.0.0',
|
||||
content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null,
|
||||
};
|
||||
return {};
|
||||
}),
|
||||
post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
}
|
||||
|
||||
describe('config pi', () => {
|
||||
let client: ReturnType<typeof mockClient>;
|
||||
let output: string[];
|
||||
let tmpDir: string;
|
||||
const log = (...args: string[]) => output.push(args.join(' '));
|
||||
|
||||
beforeEach(() => {
|
||||
client = mockClient();
|
||||
output = [];
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('installs the extension + registers with a custom pi dir (skips skills)', async () => {
|
||||
// Stage a src/pi-ext source tree to install from.
|
||||
const srcPiExt = join(tmpDir, 'src-pi-ext');
|
||||
mkdirSync(srcPiExt, { recursive: true });
|
||||
writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n');
|
||||
writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n');
|
||||
|
||||
const piDir = join(tmpDir, 'pi-agent');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
|
||||
await cmd.parseAsync(
|
||||
['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'],
|
||||
{ from: 'user' },
|
||||
);
|
||||
|
||||
// Extension files landed in the pi dir.
|
||||
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true);
|
||||
expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true);
|
||||
|
||||
// settings.json updated.
|
||||
const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8'));
|
||||
expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'));
|
||||
expect(settings.skills).toContain(join(piDir, 'skills'));
|
||||
|
||||
// Active project persisted under the pi dir (fully isolated).
|
||||
const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8'));
|
||||
expect(state.project).toBe('docmost');
|
||||
|
||||
// Output mentions the write.
|
||||
expect(output.join('\n')).toContain('Wrote active project');
|
||||
});
|
||||
|
||||
it('requires a project', async () => {
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('--project is required');
|
||||
});
|
||||
});
|
||||
92
src/cli/tests/utils/pi-settings.test.ts
Normal file
92
src/cli/tests/utils/pi-settings.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { agentInstallRoot } from '../../src/commands/skills.js';
|
||||
import {
|
||||
registerWithPi,
|
||||
installExtensionFiles,
|
||||
} from '../../src/utils/pi-settings.js';
|
||||
|
||||
describe('skills agentInstallRoot', () => {
|
||||
it('maps claude (default) to ~/.claude/skills', () => {
|
||||
expect(agentInstallRoot(undefined)).toBe(join(homedir(), '.claude', 'skills'));
|
||||
expect(agentInstallRoot('claude')).toBe(join(homedir(), '.claude', 'skills'));
|
||||
});
|
||||
it('maps pi to ~/.pi/agent/skills', () => {
|
||||
expect(agentInstallRoot('pi')).toBe(join(homedir(), '.pi', 'agent', 'skills'));
|
||||
});
|
||||
it('maps prime-agent to ~/.prime/agent/skills', () => {
|
||||
expect(agentInstallRoot('prime-agent')).toBe(join(homedir(), '.prime', 'agent', 'skills'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('pi-settings util', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'mcpctl-pi-settings-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('registers extension + skills dir into an empty settings file', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
|
||||
const skillsDir = join(tmp, 'skills');
|
||||
|
||||
const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
|
||||
expect(addedExtensions).toEqual([extPath]);
|
||||
expect(addedSkills).toEqual([skillsDir]);
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.extensions).toEqual([extPath]);
|
||||
expect(written.skills).toEqual([skillsDir]);
|
||||
});
|
||||
|
||||
it('is idempotent — running twice does not duplicate entries', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
const extPath = join(tmp, 'ext', 'mcpctl-pi.ts');
|
||||
const skillsDir = join(tmp, 'skills');
|
||||
|
||||
await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
const second = await registerWithPi(settingsPath, extPath, skillsDir);
|
||||
|
||||
expect(second.addedExtensions).toEqual([]);
|
||||
expect(second.addedSkills).toEqual([]);
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.extensions).toEqual([extPath]);
|
||||
expect(written.skills).toEqual([skillsDir]);
|
||||
});
|
||||
|
||||
it('preserves existing settings keys', async () => {
|
||||
const settingsPath = join(tmp, 'settings.json');
|
||||
mkdirSync(tmp, { recursive: true });
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: 'dark', packages: ['git:foo/bar'] }, null, 2));
|
||||
|
||||
await registerWithPi(settingsPath, '/abs/ext.ts', '/abs/skills');
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.theme).toBe('dark');
|
||||
expect(written.packages).toEqual(['git:foo/bar']);
|
||||
expect(written.extensions).toEqual(['/abs/ext.ts']);
|
||||
expect(written.skills).toEqual(['/abs/skills']);
|
||||
});
|
||||
|
||||
it('copies the extension files into the pi extension dir', async () => {
|
||||
const srcDir = join(tmp, 'src-pi-ext');
|
||||
const destDir = join(tmp, 'dest-ext');
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
writeFileSync(join(srcDir, 'mcpctl-pi.ts'), '// extension\n');
|
||||
writeFileSync(join(srcDir, 'mcp-http.ts'), '// client\n');
|
||||
|
||||
const written = await installExtensionFiles(srcDir, destDir);
|
||||
|
||||
expect(written).toHaveLength(2);
|
||||
expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n');
|
||||
expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n');
|
||||
});
|
||||
});
|
||||
237
src/pi-ext/mcp-http.ts
Normal file
237
src/pi-ext/mcp-http.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Self-contained Streamable-HTTP JSON-RPC client for the pi extension.
|
||||
*
|
||||
* Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone
|
||||
* by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to
|
||||
* mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.
|
||||
*
|
||||
* Only Node builtins are imported.
|
||||
*/
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
|
||||
export interface McpHttpOptions {
|
||||
bearer?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface ToolInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
inputSchema?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
isError?: boolean;
|
||||
}
|
||||
|
||||
interface HttpArgs {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface HttpResult {
|
||||
status: number;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
}
|
||||
|
||||
function rawHttpRequest(opts: HttpArgs): Promise<HttpResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(opts.url);
|
||||
const driver = parsed.protocol === 'https:' ? https : http;
|
||||
const req = driver.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: opts.method,
|
||||
headers: opts.headers,
|
||||
timeout: opts.timeoutMs ?? 30_000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString('utf-8'),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('MCP request timed out'));
|
||||
});
|
||||
if (opts.body) req.write(opts.body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseSse(body: string): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
for (const line of body.split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
messages.push(JSON.parse(line.slice(6)));
|
||||
} catch {
|
||||
// skip malformed SSE line
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
export class McpProtocolError extends Error {
|
||||
constructor(public readonly code: number, message: string) {
|
||||
super(`MCP error ${code}: ${message}`);
|
||||
this.name = 'McpProtocolError';
|
||||
}
|
||||
}
|
||||
|
||||
export class McpTransportError extends Error {
|
||||
constructor(public readonly status: number, public readonly body: string, message?: string) {
|
||||
super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);
|
||||
this.name = 'McpTransportError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface McpResponse {
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
notifications: unknown[];
|
||||
}
|
||||
|
||||
export class McpHttpSession {
|
||||
private sessionId: string | undefined;
|
||||
private nextId = 1;
|
||||
|
||||
constructor(
|
||||
public readonly url: string,
|
||||
private readonly options: McpHttpOptions = {},
|
||||
) {}
|
||||
|
||||
private buildHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json, text/event-stream',
|
||||
...(this.options.headers ?? {}),
|
||||
...extra,
|
||||
};
|
||||
if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
|
||||
if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request, return the matched result AND any notifications
|
||||
* that were multiplexed into the same body (e.g. tools/list_changed).
|
||||
*/
|
||||
async sendFull(method: string, params: Record<string, unknown> = {}): Promise<McpResponse> {
|
||||
const id = this.nextId++;
|
||||
const request = { jsonrpc: '2.0', id, method, params };
|
||||
const result = await rawHttpRequest({
|
||||
url: this.url,
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(request),
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
});
|
||||
|
||||
if (!this.sessionId) {
|
||||
const sid = result.headers['mcp-session-id'];
|
||||
if (typeof sid === 'string') this.sessionId = sid;
|
||||
}
|
||||
|
||||
if (result.status >= 400) {
|
||||
let message = `HTTP ${result.status}`;
|
||||
try {
|
||||
const body = JSON.parse(result.body) as { error?: string | { message?: string } };
|
||||
const errField = body.error;
|
||||
if (typeof errField === 'string') message = errField;
|
||||
else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {
|
||||
message = errField.message;
|
||||
}
|
||||
} catch {
|
||||
message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;
|
||||
}
|
||||
throw new McpTransportError(result.status, result.body, message);
|
||||
}
|
||||
|
||||
const messages = result.headers['content-type']?.includes('text/event-stream')
|
||||
? parseSse(result.body)
|
||||
: [JSON.parse(result.body)];
|
||||
|
||||
const notifications: unknown[] = [];
|
||||
const matched = messages.find((m) => {
|
||||
const msg = m as { id?: unknown; method?: unknown };
|
||||
if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {
|
||||
notifications.push(msg);
|
||||
return false;
|
||||
}
|
||||
return msg.id === id;
|
||||
}) as { result?: unknown; error?: { code: number; message: string } } | undefined;
|
||||
|
||||
const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);
|
||||
if (!parsed) throw new Error(`No response for ${method}`);
|
||||
if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);
|
||||
return { result: parsed.result, error: undefined, notifications };
|
||||
}
|
||||
|
||||
/** Convenience: return just the result, throwing on error. */
|
||||
async send(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
||||
const res = await this.sendFull(method, params);
|
||||
return res.result;
|
||||
}
|
||||
|
||||
async sendNotification(method: string, params: Record<string, unknown> = {}): Promise<void> {
|
||||
const notification = { jsonrpc: '2.0', method, params };
|
||||
await rawHttpRequest({
|
||||
url: this.url,
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify(notification),
|
||||
timeoutMs: this.options.timeoutMs,
|
||||
}).catch(() => { /* best-effort */ });
|
||||
}
|
||||
|
||||
async initialize(): Promise<unknown> {
|
||||
return this.send('initialize', {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },
|
||||
});
|
||||
}
|
||||
|
||||
/** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */
|
||||
async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {
|
||||
const raw = await this.send('tools/list');
|
||||
const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];
|
||||
return { tools, raw };
|
||||
}
|
||||
|
||||
/** Call a tool. */
|
||||
async callTool(name: string, args: Record<string, unknown> = {}): Promise<ToolCallResult> {
|
||||
return this.send('tools/call', { name, arguments: args }) as ToolCallResult;
|
||||
}
|
||||
|
||||
/** Close the session (DELETE). Safe when no session has been negotiated. */
|
||||
async close(): Promise<void> {
|
||||
if (this.sessionId === undefined) return;
|
||||
await rawHttpRequest({
|
||||
url: this.url,
|
||||
method: 'DELETE',
|
||||
headers: this.buildHeaders(),
|
||||
timeoutMs: 5_000,
|
||||
}).catch(() => { /* best-effort */ });
|
||||
this.sessionId = undefined;
|
||||
}
|
||||
}
|
||||
413
src/pi-ext/mcpctl-pi.ts
Normal file
413
src/pi-ext/mcpctl-pi.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { Type, type TSchema } from "typebox";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
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 ────────────────────────────────────────────────────
|
||||
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")) {
|
||||
return StringEnum(enumVals as string[]);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** Pi tool names currently registered for the active project. */
|
||||
const projectToolNames = new Set<string>();
|
||||
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) {
|
||||
const piName = piToolName(project, t.name);
|
||||
if (!projectToolNames.has(piName)) {
|
||||
pi.registerTool(createTool(piName, t, project));
|
||||
projectToolNames.add(piName);
|
||||
}
|
||||
names.push(piName);
|
||||
}
|
||||
// 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 };
|
||||
}
|
||||
|
||||
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 {
|
||||
content: [{ type: "text", text: text || "(no text content)" }],
|
||||
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 ──
|
||||
pi.registerCommand("mcpctl", {
|
||||
description: "mcpctl integration: status, switch project, refresh tools",
|
||||
handler: async (_args, ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
return `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`;
|
||||
}
|
||||
const choice = await ctx.ui.select("mcpctl", [
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "switch", label: "Switch project" },
|
||||
{ value: "refresh", label: "Refresh tools" },
|
||||
{ value: "sync skills", label: "Sync skills (mcpctl skills sync)" },
|
||||
{ value: "close", label: "Close" },
|
||||
]);
|
||||
if (!choice) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (choice === "sync skills") {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : ""} --quiet`, {
|
||||
stdio: "inherit",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
ctx.ui.notify("mcpctl skills synced (into ~/.claude/skills)", "info");
|
||||
} catch (e) {
|
||||
ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
const picked = await ctx.ui.select("Switch to project", projects);
|
||||
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);
|
||||
ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, "info");
|
||||
setStatus();
|
||||
} catch (e) {
|
||||
ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user