diff --git a/README.md b/README.md index e12537e..6a0ae84 100644 --- a/README.md +++ b/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__`). + +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`: diff --git a/docs/pi-extension.md b/docs/pi-extension.md new file mode 100644 index 0000000..3ca45b3 --- /dev/null +++ b/docs/pi-extension.md @@ -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//mcp +Accepts: application/json, text/event-stream +Authorization: Bearer +``` + +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__`. 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. diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 44330ee..ca826b2 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -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, 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 ', 'Project name to make active') + .option('--extension-dir ', 'Path to the src/pi-ext sources to install (default: this checkout)') + .option('--skip-skills', 'Skip the initial skills sync') + .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') + .option('--pi-dir ', '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)) }, + ); + 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) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 627799d..b507154 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -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)); }; - 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 ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--agent ', '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 }, ); diff --git a/src/cli/src/utils/pi-settings.ts b/src/cli/src/utils/pi-settings.ts new file mode 100644 index 0000000..4739d17 --- /dev/null +++ b/src/cli/src/utils/pi-settings.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/src/cli/tests/commands/config-pi.test.ts b/src/cli/tests/commands/config-pi.test.ts new file mode 100644 index 0000000..fe0d673 --- /dev/null +++ b/src/cli/tests/commands/config-pi.test.ts @@ -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; + 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'); + }); +}); diff --git a/src/cli/tests/utils/pi-settings.test.ts b/src/cli/tests/utils/pi-settings.test.ts new file mode 100644 index 0000000..2a9b127 --- /dev/null +++ b/src/cli/tests/utils/pi-settings.test.ts @@ -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'); + }); +}); diff --git a/src/pi-ext/mcp-http.ts b/src/pi-ext/mcp-http.ts new file mode 100644 index 0000000..c6df53d --- /dev/null +++ b/src/pi-ext/mcp-http.ts @@ -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; + 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; + body?: string; + timeoutMs?: number; +} + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function rawHttpRequest(opts: HttpArgs): Promise { + 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 = {}): Record { + const headers: Record = { + '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 = {}): Promise { + 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 = {}): Promise { + const res = await this.sendFull(method, params); + return res.result; + } + + async sendNotification(method: string, params: Record = {}): Promise { + 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 { + 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 = {}): Promise { + return this.send('tools/call', { name, arguments: args }) as ToolCallResult; + } + + /** Close the session (DELETE). Safe when no session has been negotiated. */ + async close(): Promise { + if (this.sessionId === undefined) return; + await rawHttpRequest({ + url: this.url, + method: 'DELETE', + headers: this.buildHeaders(), + timeoutMs: 5_000, + }).catch(() => { /* best-effort */ }); + this.sessionId = undefined; + } +} diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts new file mode 100644 index 0000000..2eb726d --- /dev/null +++ b/src/pi-ext/mcpctl-pi.ts @@ -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(path: string): Promise { + 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 { + return (await readJson(PiStatePath)) ?? {}; +} + +async function writePiState(state: PiState): Promise { + 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 { + 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 { + const url = `${mcplocalUrl}/api/v1/projects`; + const headers: Record = { 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; + required?: string[]; + items?: unknown; + enum?: unknown[]; + }; + + const required = new Set(Array.isArray(s.required) ? s.required : []); + const props = s.properties ?? {}; + const fields: Record = {}; + + 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; + }; + + 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(); + 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 { + 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 { + 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) { + 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 { + 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(); + }); +}