diff --git a/docs/superpowers/plans/2026-04-24-mcp-server-support.md b/docs/superpowers/plans/2026-04-24-mcp-server-support.md new file mode 100644 index 0000000000..ba4e46c511 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-mcp-server-support.md @@ -0,0 +1,2622 @@ +# MCP Server Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new `@electric-ax/agents-mcp` package that lets Electric Agents entities use MCP servers as tool/resource providers, with OAuth auth, stdio + Streamable HTTP transports, connection pooling, and conversational config management via Horton. + +**Architecture:** New package `packages/agents-mcp` bridges the official MCP TypeScript SDK into the existing `AgentTool` interface. An `McpClientPool` manages lazy connections with idle timeout. Config is stored in `.electric-agents/mcp.json` (project-scoped). The `agents` package wires MCP into Horton's tool set at bootstrap. + +**Tech Stack:** `@modelcontextprotocol/sdk` (MCP client + transports + auth), `vitest` (testing), `tsdown` (build), `zod` (validation) + +**Spec:** `docs/superpowers/specs/2026-04-24-mcp-server-support-design.md` + +--- + +## File Map + +### New package: `packages/agents-mcp/` + +| File | Responsibility | +|------|---------------| +| `package.json` | Package metadata, dependencies, scripts | +| `tsconfig.json` | TypeScript config (Bundler module resolution, ES2022) | +| `tsdown.config.ts` | Build config (ESM + CJS, dts) | +| `vitest.config.ts` | Test config (v8 coverage, junit) | +| `src/index.ts` | Public exports | +| `src/types.ts` | `McpServerConfig`, `McpConfig`, `McpOverrides`, internal types | +| `src/config/env-expand.ts` | `${VAR}` and `${VAR:-default}` expansion in config values | +| `src/config/config-store.ts` | Read/write `.electric-agents/mcp.json`, auto-create `.gitignore` | +| `src/auth/token-store.ts` | Read/write `.electric-agents/mcp-auth.json` | +| `src/auth/oauth-provider.ts` | `OAuthClientProvider` implementation backed by token-store | +| `src/client.ts` | `McpClient` — wraps SDK Client, handles transport + auth setup | +| `src/pool.ts` | `McpClientPool` — lazy connect, idle timeout, reconnect backoff | +| `src/bridge/tool-bridge.ts` | MCP Tool -> `AgentTool` adapter with `mcp__` namespacing | +| `src/bridge/resource-bridge.ts` | `mcp__list_resources` and `mcp__read_resource` tools | +| `src/config/config-tools.ts` | Horton tools: `mcp__manage__add_server`, `remove`, `list_servers`, `list_tools` | +| `src/integration.ts` | `createMcpIntegration()` factory — main public API | +| `test/env-expand.test.ts` | Tests for env var expansion | +| `test/config-store.test.ts` | Tests for config read/write | +| `test/token-store.test.ts` | Tests for auth token persistence | +| `test/tool-bridge.test.ts` | Tests for MCP Tool -> AgentTool bridging | +| `test/resource-bridge.test.ts` | Tests for resource tools | +| `test/pool.test.ts` | Tests for connection pool lifecycle | +| `test/config-tools.test.ts` | Tests for config management tools | +| `test/integration.test.ts` | End-to-end integration test | + +### Modified files in existing packages + +| File | Change | +|------|--------| +| `packages/agents/package.json` | Add `@electric-ax/agents-mcp` dependency | +| `packages/agents/src/bootstrap.ts` | Wire MCP integration into runtime handler | +| `packages/agents/src/agents/horton.ts` | Accept + inject MCP tools, update system prompt | + +--- + +## Task 1: Package Scaffolding + +**Files:** +- Create: `packages/agents-mcp/package.json` +- Create: `packages/agents-mcp/tsconfig.json` +- Create: `packages/agents-mcp/tsdown.config.ts` +- Create: `packages/agents-mcp/vitest.config.ts` +- Create: `packages/agents-mcp/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "@electric-ax/agents-mcp", + "version": "0.0.1", + "description": "MCP server integration for Electric Agents — tools, resources, OAuth auth", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "pnpm exec vitest --coverage", + "typecheck": "tsc --noEmit", + "stylecheck": "eslint . --quiet" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@mariozechner/pi-agent-core": ">=0.57.0" + }, + "peerDependenciesMeta": { + "@mariozechner/pi-agent-core": { + "optional": false + } + }, + "devDependencies": { + "@mariozechner/pi-agent-core": "^0.57.1", + "@vitest/coverage-v8": "^4.1.0", + "tsdown": "^0.9.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "license": "Apache-2.0" +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "isolatedDeclarations": false, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ESNext"], + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "outDir": "./dist", + "baseUrl": "." + }, + "include": ["src/**/*", "test/**/*", "*.ts"], + "exclude": ["node_modules", "dist"] +} +``` + +- [ ] **Step 3: Create tsdown.config.ts** + +```ts +import type { Options } from 'tsdown' + +const config: Options = { + entry: [`src/index.ts`], + format: [`esm`, `cjs`], + platform: `node`, + dts: true, + clean: true, + external: [/^@modelcontextprotocol\//, /^@mariozechner\//], +} + +export default config +``` + +- [ ] **Step 4: Create vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: `v8`, + reporter: [`text`, `json`, `html`, `lcov`], + include: [`src/**/*.{ts,tsx}`], + }, + reporters: [`default`, `junit`], + outputFile: `./junit/test-report.junit.xml`, + }, +}) +``` + +- [ ] **Step 5: Create src/index.ts with placeholder export** + +```ts +export {} +``` + +- [ ] **Step 6: Install dependencies and verify build** + +Run: `cd packages/agents-mcp && pnpm install && pnpm build` +Expected: Build succeeds with empty output + +- [ ] **Step 7: Commit** + +```bash +git add packages/agents-mcp/ +git commit -m "chore: scaffold @electric-ax/agents-mcp package" +``` + +--- + +## Task 2: Types + +**Files:** +- Create: `packages/agents-mcp/src/types.ts` +- Modify: `packages/agents-mcp/src/index.ts` + +- [ ] **Step 1: Create src/types.ts** + +```ts +import type { AgentTool } from '@mariozechner/pi-agent-core' + +// ── Config ────────────────────────────────────────────────── + +export interface McpServerConfig { + /** Stdio transport */ + command?: string + args?: string[] + env?: Record + cwd?: string + + /** Streamable HTTP transport */ + url?: string + + /** Auth: OAuth flow, static token, or env var reference */ + auth?: `oauth` | { token: string } | { tokenEnvVar: string } + /** Arbitrary static headers (values support ${VAR} expansion) */ + headers?: Record + + /** OAuth specifics (only when auth is 'oauth') */ + oauth?: { + clientId?: string + scopes?: string[] + callbackPort?: number + } + + /** Toggle without removing config (default: true) */ + enabled?: boolean + /** Connection timeout in ms (default: 10_000) */ + startupTimeoutMs?: number + /** Per-tool-call timeout in ms (default: 60_000) */ + toolTimeoutMs?: number + /** Idle time before disconnect in ms (default: 300_000) */ + idleTimeoutMs?: number + /** Max chars in tool output before truncation (default: 25_000) */ + maxOutputChars?: number +} + +export interface McpConfig { + servers: Record +} + +export type McpOverrides = Record + +// ── Defaults ──────────────────────────────────────────────── + +export const MCP_DEFAULTS = { + startupTimeoutMs: 10_000, + toolTimeoutMs: 60_000, + idleTimeoutMs: 300_000, + maxOutputChars: 25_000, +} as const + +// ── Pool ──────────────────────────────────────────────────── + +export type McpServerStatus = `idle` | `connecting` | `connected` | `failed` + +export interface McpServerState { + name: string + config: McpServerConfig + status: McpServerStatus + tools: Array + resources: Array + instructions?: string + error?: string + sessionId?: string + protocolVersion?: string +} + +export interface McpDiscoveredTool { + name: string + description?: string + inputSchema: Record +} + +export interface McpDiscoveredResource { + uri: string + name: string + description?: string + mimeType?: string +} + +// ── Integration ───────────────────────────────────────────── + +export interface McpIntegration { + /** Tools for managing MCP config (for Horton) */ + configTools: Array + /** Get all bridged MCP tools, applying overrides */ + getTools: (overrides?: McpOverrides) => Promise> + /** Get server instructions for system prompt injection */ + getServerInstructions: () => Record + /** Get server summaries for system prompt */ + getServerSummary: () => Promise + /** Shut down all connections */ + close: () => Promise +} +``` + +- [ ] **Step 2: Update src/index.ts to re-export types** + +```ts +export type { + McpServerConfig, + McpConfig, + McpOverrides, + McpIntegration, + McpServerStatus, + McpServerState, + McpDiscoveredTool, + McpDiscoveredResource, +} from './types' + +export { MCP_DEFAULTS } from './types' +``` + +- [ ] **Step 3: Run typecheck** + +Run: `cd packages/agents-mcp && pnpm typecheck` +Expected: PASS, no errors + +- [ ] **Step 4: Commit** + +```bash +git add packages/agents-mcp/src/ +git commit -m "feat(agents-mcp): add config and integration types" +``` + +--- + +## Task 3: Environment Variable Expansion + +**Files:** +- Create: `packages/agents-mcp/src/config/env-expand.ts` +- Create: `packages/agents-mcp/test/env-expand.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, expect, it } from 'vitest' +import { expandEnvVars } from '../src/config/env-expand' + +describe(`expandEnvVars`, () => { + it(`expands ${`\${VAR}`} with env value`, () => { + const result = expandEnvVars(`hello \${MY_VAR} world`, { MY_VAR: `test` }) + expect(result).toBe(`hello test world`) + }) + + it(`expands ${`\${VAR:-default}`} to default when var missing`, () => { + const result = expandEnvVars(`\${MISSING:-fallback}`, {}) + expect(result).toBe(`fallback`) + }) + + it(`expands ${`\${VAR:-default}`} to var value when present`, () => { + const result = expandEnvVars(`\${MY_VAR:-fallback}`, { MY_VAR: `actual` }) + expect(result).toBe(`actual`) + }) + + it(`throws when required var is missing (no default)`, () => { + expect(() => expandEnvVars(`\${REQUIRED_VAR}`, {})).toThrow( + /REQUIRED_VAR/ + ) + }) + + it(`handles multiple expansions in one string`, () => { + const result = expandEnvVars(`\${A}-\${B}`, { A: `1`, B: `2` }) + expect(result).toBe(`1-2`) + }) + + it(`returns strings without variables unchanged`, () => { + expect(expandEnvVars(`plain text`, {})).toBe(`plain text`) + }) + + it(`expands empty string default`, () => { + const result = expandEnvVars(`\${X:-}`, {}) + expect(result).toBe(``) + }) +}) + +describe(`expandConfigValues`, () => { + const { expandConfigValues } = await import(`../src/config/env-expand`) + + it(`recursively expands env vars in an object`, () => { + const config = { + command: `npx`, + env: { TOKEN: `\${GH_TOKEN}` }, + url: `https://\${HOST:-localhost}:3000`, + } + const result = expandConfigValues(config, { GH_TOKEN: `abc` }) + expect(result).toEqual({ + command: `npx`, + env: { TOKEN: `abc` }, + url: `https://localhost:3000`, + }) + }) + + it(`does not expand non-string values`, () => { + const config = { enabled: true, timeout: 5000 } + expect(expandConfigValues(config, {})).toEqual(config) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/env-expand.test.ts` +Expected: FAIL — modules not found + +- [ ] **Step 3: Implement env-expand.ts** + +```ts +export function expandEnvVars( + template: string, + env: Record +): string { + return template.replace( + /\$\{([^}:]+?)(?::-(.*?))?\}/g, + (_match, name: string, defaultValue?: string) => { + const value = env[name] + if (value !== undefined) return value + if (defaultValue !== undefined) return defaultValue + throw new Error( + `Environment variable \${${name}} is required but not set` + ) + } + ) +} + +export function expandConfigValues( + obj: T, + env: Record +): T { + if (typeof obj === `string`) { + return expandEnvVars(obj, env) as T + } + if (Array.isArray(obj)) { + return obj.map((item) => expandConfigValues(item, env)) as T + } + if (obj !== null && typeof obj === `object`) { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = expandConfigValues(value, env) + } + return result as T + } + return obj +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/env-expand.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/config/env-expand.ts packages/agents-mcp/test/env-expand.test.ts +git commit -m "feat(agents-mcp): add env var expansion for config values" +``` + +--- + +## Task 4: Config Store + +**Files:** +- Create: `packages/agents-mcp/src/config/config-store.ts` +- Create: `packages/agents-mcp/test/config-store.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ConfigStore } from '../src/config/config-store' + +describe(`ConfigStore`, () => { + let workDir: string + let store: ConfigStore + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-test-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + store = new ConfigStore(workDir) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`returns empty config when no file exists`, () => { + const config = store.load() + expect(config.servers).toEqual({}) + }) + + it(`reads config from .electric-agents/mcp.json`, () => { + const dir = join(workDir, `.electric-agents`) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, `mcp.json`), + JSON.stringify({ + servers: { + github: { command: `npx`, args: [`-y`, `@mcp/server-github`] }, + }, + }) + ) + const config = store.load() + expect(config.servers.github).toBeDefined() + expect(config.servers.github!.command).toBe(`npx`) + }) + + it(`saves config and creates .gitignore`, () => { + store.save({ + servers: { test: { command: `echo`, args: [`hi`] } }, + }) + const dir = join(workDir, `.electric-agents`) + expect(existsSync(join(dir, `mcp.json`))).toBe(true) + const gitignore = readFileSync(join(dir, `.gitignore`), `utf-8`) + expect(gitignore).toContain(`mcp-auth.json`) + }) + + it(`adds a server to existing config`, () => { + store.save({ servers: { a: { command: `a` } } }) + store.addServer(`b`, { command: `b` }) + const config = store.load() + expect(Object.keys(config.servers)).toEqual([`a`, `b`]) + }) + + it(`removes a server from config`, () => { + store.save({ servers: { a: { command: `a` }, b: { command: `b` } } }) + store.removeServer(`a`) + const config = store.load() + expect(Object.keys(config.servers)).toEqual([`b`]) + }) + + it(`expands env vars when loading`, () => { + const dir = join(workDir, `.electric-agents`) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, `mcp.json`), + JSON.stringify({ + servers: { s: { command: `echo`, env: { TOKEN: `\${TEST_TOKEN}` } } }, + }) + ) + process.env.TEST_TOKEN = `secret123` + const config = store.load({ expandEnv: true }) + expect(config.servers.s!.env!.TOKEN).toBe(`secret123`) + delete process.env.TEST_TOKEN + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/config-store.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement config-store.ts** + +```ts +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expandConfigValues } from './env-expand' +import type { McpConfig, McpServerConfig } from '../types' + +const CONFIG_DIR = `.electric-agents` +const CONFIG_FILE = `mcp.json` +const GITIGNORE_FILE = `.gitignore` +const GITIGNORE_CONTENT = `mcp-auth.json\n` + +const EMPTY_CONFIG: McpConfig = { servers: {} } + +export class ConfigStore { + private readonly configDir: string + private readonly configPath: string + + constructor(private readonly workingDirectory: string) { + this.configDir = join(workingDirectory, CONFIG_DIR) + this.configPath = join(this.configDir, CONFIG_FILE) + } + + load(opts?: { expandEnv?: boolean }): McpConfig { + if (!existsSync(this.configPath)) return { ...EMPTY_CONFIG } + + const raw = readFileSync(this.configPath, `utf-8`) + const parsed = JSON.parse(raw) as McpConfig + + if (!parsed.servers) return { ...EMPTY_CONFIG } + + if (opts?.expandEnv) { + return expandConfigValues(parsed, process.env as Record) + } + return parsed + } + + save(config: McpConfig): void { + mkdirSync(this.configDir, { recursive: true }) + writeFileSync(this.configPath, JSON.stringify(config, null, 2) + `\n`) + this.ensureGitignore() + } + + addServer(name: string, serverConfig: McpServerConfig): void { + const config = this.load() + config.servers[name] = serverConfig + this.save(config) + } + + removeServer(name: string): boolean { + const config = this.load() + if (!(name in config.servers)) return false + delete config.servers[name] + this.save(config) + return true + } + + private ensureGitignore(): void { + const gitignorePath = join(this.configDir, GITIGNORE_FILE) + if (existsSync(gitignorePath)) { + const content = readFileSync(gitignorePath, `utf-8`) + if (!content.includes(`mcp-auth.json`)) { + writeFileSync(gitignorePath, content + GITIGNORE_CONTENT) + } + return + } + writeFileSync(gitignorePath, GITIGNORE_CONTENT) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/config-store.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/config/config-store.ts packages/agents-mcp/test/config-store.test.ts +git commit -m "feat(agents-mcp): add config store for .electric-agents/mcp.json" +``` + +--- + +## Task 5: Token Store + +**Files:** +- Create: `packages/agents-mcp/src/auth/token-store.ts` +- Create: `packages/agents-mcp/test/token-store.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TokenStore } from '../src/auth/token-store' + +describe(`TokenStore`, () => { + let workDir: string + let store: TokenStore + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-token-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + store = new TokenStore(workDir) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`returns undefined when no tokens exist`, () => { + expect(store.getTokens(`honeycomb`)).toBeUndefined() + }) + + it(`saves and reads tokens`, () => { + const tokens = { + access_token: `abc`, + refresh_token: `def`, + expires_at: 9999999999, + token_type: `Bearer` as const, + } + store.saveTokens(`honeycomb`, tokens) + const loaded = store.getTokens(`honeycomb`) + expect(loaded).toEqual(tokens) + }) + + it(`stores tokens for multiple servers independently`, () => { + store.saveTokens(`a`, { access_token: `1`, token_type: `Bearer` as const }) + store.saveTokens(`b`, { access_token: `2`, token_type: `Bearer` as const }) + expect(store.getTokens(`a`)!.access_token).toBe(`1`) + expect(store.getTokens(`b`)!.access_token).toBe(`2`) + }) + + it(`removes tokens for a server`, () => { + store.saveTokens(`x`, { access_token: `t`, token_type: `Bearer` as const }) + store.removeTokens(`x`) + expect(store.getTokens(`x`)).toBeUndefined() + }) + + it(`saves and reads code verifier`, () => { + store.saveCodeVerifier(`honeycomb`, `verifier123`) + expect(store.getCodeVerifier(`honeycomb`)).toBe(`verifier123`) + }) + + it(`saves and reads client info`, () => { + const info = { client_id: `cid`, client_secret: `csec` } + store.saveClientInfo(`honeycomb`, info) + expect(store.getClientInfo(`honeycomb`)).toEqual(info) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/token-store.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement token-store.ts** + +```ts +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const CONFIG_DIR = `.electric-agents` +const AUTH_FILE = `mcp-auth.json` + +export interface StoredTokens { + access_token: string + refresh_token?: string + expires_at?: number + token_type: `Bearer` +} + +interface AuthData { + tokens?: Record + verifiers?: Record + clients?: Record> +} + +export class TokenStore { + private readonly authPath: string + private readonly configDir: string + + constructor(workingDirectory: string) { + this.configDir = join(workingDirectory, CONFIG_DIR) + this.authPath = join(this.configDir, AUTH_FILE) + } + + getTokens(serverName: string): StoredTokens | undefined { + return this.readAll().tokens?.[serverName] + } + + saveTokens(serverName: string, tokens: StoredTokens): void { + const data = this.readAll() + data.tokens ??= {} + data.tokens[serverName] = tokens + this.writeAll(data) + } + + removeTokens(serverName: string): void { + const data = this.readAll() + if (data.tokens) { + delete data.tokens[serverName] + this.writeAll(data) + } + } + + getCodeVerifier(serverName: string): string | undefined { + return this.readAll().verifiers?.[serverName] + } + + saveCodeVerifier(serverName: string, verifier: string): void { + const data = this.readAll() + data.verifiers ??= {} + data.verifiers[serverName] = verifier + this.writeAll(data) + } + + getClientInfo(serverName: string): Record | undefined { + return this.readAll().clients?.[serverName] + } + + saveClientInfo(serverName: string, info: Record): void { + const data = this.readAll() + data.clients ??= {} + data.clients[serverName] = info + this.writeAll(data) + } + + private readAll(): AuthData { + if (!existsSync(this.authPath)) return {} + return JSON.parse(readFileSync(this.authPath, `utf-8`)) as AuthData + } + + private writeAll(data: AuthData): void { + mkdirSync(this.configDir, { recursive: true }) + writeFileSync(this.authPath, JSON.stringify(data, null, 2) + `\n`) + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/token-store.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/auth/token-store.ts packages/agents-mcp/test/token-store.test.ts +git commit -m "feat(agents-mcp): add token store for OAuth token persistence" +``` + +--- + +## Task 6: OAuth Provider + +**Files:** +- Create: `packages/agents-mcp/src/auth/oauth-provider.ts` + +- [ ] **Step 1: Implement OAuthClientProvider backed by TokenStore** + +This implements the MCP SDK's `OAuthClientProvider` interface. It delegates token persistence to `TokenStore` and starts a temporary local HTTP server for the OAuth redirect. + +```ts +import http from 'node:http' +import type { OAuthClientProvider, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/client/auth.js' +import type { TokenStore } from './token-store' +import type { McpServerConfig } from '../types' + +export class ElectricOAuthProvider implements OAuthClientProvider { + private verifier = `` + private authResolve?: (code: string) => void + + constructor( + private readonly serverName: string, + private readonly serverConfig: McpServerConfig, + private readonly tokenStore: TokenStore, + private readonly onAuthUrl?: (url: string) => void + ) {} + + get redirectUrl(): string { + const port = this.serverConfig.oauth?.callbackPort ?? 0 + return `http://127.0.0.1:${port}/oauth/callback` + } + + get clientMetadata(): OAuthClientMetadata { + return { + client_name: `Electric Agents MCP Client`, + redirect_uris: [this.redirectUrl], + grant_types: [`authorization_code`, `refresh_token`], + response_types: [`code`], + scope: this.serverConfig.oauth?.scopes?.join(` `), + } + } + + clientInformation(): Record | undefined { + const stored = this.tokenStore.getClientInfo(this.serverName) + if (stored) return stored + if (this.serverConfig.oauth?.clientId) { + return { client_id: this.serverConfig.oauth.clientId } + } + return undefined + } + + saveClientInformation(info: Record): void { + this.tokenStore.saveClientInfo(this.serverName, info) + } + + async tokens(): Promise { + const stored = this.tokenStore.getTokens(this.serverName) + if (!stored) return undefined + return { + access_token: stored.access_token, + refresh_token: stored.refresh_token, + token_type: stored.token_type, + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + this.tokenStore.saveTokens(this.serverName, { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + token_type: `Bearer`, + }) + } + + async saveCodeVerifier(verifier: string): Promise { + this.verifier = verifier + this.tokenStore.saveCodeVerifier(this.serverName, verifier) + } + + async codeVerifier(): Promise { + return this.verifier || this.tokenStore.getCodeVerifier(this.serverName) || `` + } + + async redirectToAuthorization(authUrl: URL): Promise { + const urlString = authUrl.toString() + + if (this.onAuthUrl) { + this.onAuthUrl(urlString) + } else { + console.log(`\n[mcp] Authorize ${this.serverName}: ${urlString}\n`) + } + + const port = this.serverConfig.oauth?.callbackPort ?? 0 + await this.startCallbackServer(port) + } + + private startCallbackServer(port: number): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? `/`, `http://127.0.0.1`) + const code = url.searchParams.get(`code`) + if (code) { + res.writeHead(200, { 'content-type': `text/html` }) + res.end(`

Authorization successful

You can close this tab.

`) + server.close() + resolve(code) + } else { + res.writeHead(400) + res.end(`Missing code parameter`) + } + }) + + server.listen(port, `127.0.0.1`, () => { + const addr = server.address() + if (addr && typeof addr === `object`) { + // Update redirect URL if port was 0 (random) + } + }) + + server.on(`error`, reject) + setTimeout(() => { + server.close() + reject(new Error(`OAuth callback timed out after 5 minutes`)) + }, 300_000) + }) + } +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd packages/agents-mcp && pnpm typecheck` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add packages/agents-mcp/src/auth/oauth-provider.ts +git commit -m "feat(agents-mcp): add OAuthClientProvider implementation" +``` + +--- + +## Task 7: MCP Client + +**Files:** +- Create: `packages/agents-mcp/src/client.ts` + +- [ ] **Step 1: Implement McpClient** + +This wraps the MCP SDK `Client` with transport-aware construction, auth injection, and session state tracking. + +```ts +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { Tool, Resource } from '@modelcontextprotocol/sdk/types.js' +import { ElectricOAuthProvider } from './auth/oauth-provider' +import type { TokenStore } from './auth/token-store' +import type { McpServerConfig, McpDiscoveredTool, McpDiscoveredResource } from './types' +import { MCP_DEFAULTS } from './types' +import { expandEnvVars } from './config/env-expand' + +export interface McpClientOptions { + serverName: string + config: McpServerConfig + tokenStore: TokenStore + workingDirectory: string + onAuthUrl?: (url: string) => void + onToolsChanged?: (tools: McpDiscoveredTool[]) => void + onResourcesChanged?: (resources: McpDiscoveredResource[]) => void +} + +export class McpClient { + private client: Client + private transport: Transport | null = null + + readonly serverName: string + private readonly config: McpServerConfig + private readonly tokenStore: TokenStore + private readonly workingDirectory: string + private readonly opts: McpClientOptions + + tools: McpDiscoveredTool[] = [] + resources: McpDiscoveredResource[] = [] + instructions?: string + sessionId?: string + protocolVersion?: string + + constructor(opts: McpClientOptions) { + this.serverName = opts.serverName + this.config = opts.config + this.tokenStore = opts.tokenStore + this.workingDirectory = opts.workingDirectory + this.opts = opts + + this.client = new Client( + { name: `electric-agents`, version: `0.1.0` }, + { + capabilities: { roots: { listChanged: false } }, + listChanged: { + tools: { + onChanged: (_err, tools) => { + if (tools) { + this.tools = tools.map(mapTool) + opts.onToolsChanged?.(this.tools) + } + }, + }, + resources: { + onChanged: (_err, resources) => { + if (resources) { + this.resources = resources.map(mapResource) + opts.onResourcesChanged?.(this.resources) + } + }, + }, + }, + } + ) + } + + async connect(signal?: AbortSignal): Promise { + this.transport = this.createTransport() + + await this.client.connect(this.transport, { + timeout: this.config.startupTimeoutMs ?? MCP_DEFAULTS.startupTimeoutMs, + signal, + }) + + this.instructions = this.client.getInstructions() ?? undefined + this.sessionId = this.transport.sessionId + this.protocolVersion = this.client.getNegotiatedProtocolVersion() ?? undefined + + await this.discover() + } + + async discover(): Promise { + const caps = this.client.getServerCapabilities() + + if (caps?.tools) { + const result = await this.client.listTools() + this.tools = result.tools.map(mapTool) + } + + if (caps?.resources) { + const result = await this.client.listResources() + this.resources = result.resources.map(mapResource) + } + } + + async callTool( + name: string, + args: Record, + opts?: { timeout?: number; signal?: AbortSignal } + ): Promise<{ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError?: boolean }> { + const result = await this.client.callTool( + { name, arguments: args }, + undefined, + { + timeout: opts?.timeout ?? this.config.toolTimeoutMs ?? MCP_DEFAULTS.toolTimeoutMs, + signal: opts?.signal, + } + ) + return { + content: (result.content ?? []) as Array<{ type: string; text?: string; data?: string; mimeType?: string }>, + isError: result.isError as boolean | undefined, + } + } + + async listResources(): Promise { + const result = await this.client.listResources() + return result.resources.map(mapResource) + } + + async readResource(uri: string): Promise> { + const result = await this.client.readResource({ uri }) + return (result.contents ?? []) as Array<{ type: string; text?: string; blob?: string; mimeType?: string }> + } + + async close(): Promise { + await this.client.close() + this.transport = null + } + + private createTransport(): Transport { + const config = this.config + + if (config.command) { + const env: Record = { + ...process.env as Record, + HOME: this.workingDirectory, + } + if (config.env) { + for (const [k, v] of Object.entries(config.env)) { + env[k] = v + } + } + return new StdioClientTransport({ + command: config.command, + args: config.args, + env, + cwd: config.cwd ?? this.workingDirectory, + }) + } + + if (config.url) { + const url = new URL(config.url) + const transportOpts: Record = {} + + if (config.auth === `oauth`) { + const provider = new ElectricOAuthProvider( + this.serverName, + config, + this.tokenStore, + this.opts.onAuthUrl + ) + transportOpts.authProvider = provider + } else if (config.auth && typeof config.auth === `object`) { + let token: string + if (`token` in config.auth) { + token = config.auth.token + } else { + const envVar = config.auth.tokenEnvVar + token = process.env[envVar] ?? `` + if (!token) { + throw new Error( + `Environment variable ${envVar} is required for MCP server "${this.serverName}" but not set` + ) + } + } + transportOpts.requestInit = { + headers: { + Authorization: `Bearer ${token}`, + ...config.headers, + }, + } + } else if (config.headers) { + transportOpts.requestInit = { headers: config.headers } + } + + if (this.sessionId) { + transportOpts.sessionId = this.sessionId + } + + return new StreamableHTTPClientTransport(url, transportOpts as any) + } + + throw new Error( + `MCP server "${this.serverName}" must have either "command" (stdio) or "url" (Streamable HTTP)` + ) + } +} + +function mapTool(tool: Tool): McpDiscoveredTool { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as Record, + } +} + +function mapResource(resource: Resource): McpDiscoveredResource { + return { + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + } +} +``` + +- [ ] **Step 2: Run typecheck** + +Run: `cd packages/agents-mcp && pnpm typecheck` +Expected: PASS (may need to adjust SDK import paths based on actual package exports) + +- [ ] **Step 3: Commit** + +```bash +git add packages/agents-mcp/src/client.ts +git commit -m "feat(agents-mcp): add McpClient with transport + auth setup" +``` + +--- + +## Task 8: Connection Pool + +**Files:** +- Create: `packages/agents-mcp/src/pool.ts` +- Create: `packages/agents-mcp/test/pool.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock(`../src/client`, () => ({ + McpClient: vi.fn().mockImplementation((opts: any) => ({ + serverName: opts.serverName, + tools: [], + resources: [], + instructions: undefined, + connect: vi.fn().mockResolvedValue(undefined), + discover: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + })), +})) + +import { McpClientPool } from '../src/pool' +import type { McpConfig } from '../src/types' + +describe(`McpClientPool`, () => { + const config: McpConfig = { + servers: { + test: { command: `echo`, args: [`hello`], enabled: true }, + disabled: { command: `echo`, enabled: false }, + }, + } + + let pool: McpClientPool + + beforeEach(() => { + pool = new McpClientPool(config, { workingDirectory: `/tmp` }) + }) + + it(`creates a client on first acquire`, async () => { + const client = await pool.acquire(`test`) + expect(client).toBeDefined() + expect(client.serverName).toBe(`test`) + }) + + it(`returns the same client on second acquire`, async () => { + const first = await pool.acquire(`test`) + const second = await pool.acquire(`test`) + expect(first).toBe(second) + }) + + it(`throws for unknown server`, async () => { + await expect(pool.acquire(`unknown`)).rejects.toThrow(/unknown/) + }) + + it(`throws for disabled server`, async () => { + await expect(pool.acquire(`disabled`)).rejects.toThrow(/disabled/) + }) + + it(`getServerStatus returns idle for unconnected, connected after acquire`, async () => { + expect(pool.getServerStatus(`test`)).toBe(`idle`) + await pool.acquire(`test`) + expect(pool.getServerStatus(`test`)).toBe(`connected`) + }) + + it(`close disconnects all clients`, async () => { + await pool.acquire(`test`) + await pool.close() + expect(pool.getServerStatus(`test`)).toBe(`idle`) + }) + + it(`getEnabledServers excludes disabled servers`, () => { + const enabled = pool.getEnabledServers() + expect(enabled.map((s) => s.name)).toEqual([`test`]) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/pool.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement pool.ts** + +```ts +import { McpClient } from './client' +import { TokenStore } from './auth/token-store' +import type { + McpConfig, + McpServerConfig, + McpServerStatus, + McpServerState, + McpDiscoveredTool, + McpDiscoveredResource, + McpOverrides, + MCP_DEFAULTS, +} from './types' +import { MCP_DEFAULTS as DEFAULTS } from './types' +import type { AgentTool } from '@mariozechner/pi-agent-core' +import { bridgeMcpTools } from './bridge/tool-bridge' +import { createResourceTools } from './bridge/resource-bridge' + +interface PoolEntry { + name: string + config: McpServerConfig + client: McpClient | null + status: McpServerStatus + idleTimer: ReturnType | null + connectAttempts: number + lastError?: string +} + +export class McpClientPool { + private entries = new Map() + private readonly tokenStore: TokenStore + private readonly workingDirectory: string + private onAuthUrl?: (serverName: string, url: string) => void + + constructor( + config: McpConfig, + opts: { + workingDirectory: string + onAuthUrl?: (serverName: string, url: string) => void + } + ) { + this.workingDirectory = opts.workingDirectory + this.tokenStore = new TokenStore(opts.workingDirectory) + this.onAuthUrl = opts.onAuthUrl + + for (const [name, serverConfig] of Object.entries(config.servers)) { + this.entries.set(name, { + name, + config: serverConfig, + client: null, + status: `idle`, + idleTimer: null, + connectAttempts: 0, + }) + } + } + + async acquire(serverName: string): Promise { + const entry = this.entries.get(serverName) + if (!entry) { + throw new Error(`MCP server "${serverName}" is not configured`) + } + if (entry.config.enabled === false) { + throw new Error(`MCP server "${serverName}" is disabled`) + } + + if (entry.client && entry.status === `connected`) { + this.clearIdleTimer(entry) + return entry.client + } + + return this.connect(entry) + } + + release(serverName: string): void { + const entry = this.entries.get(serverName) + if (!entry || !entry.client) return + this.startIdleTimer(entry) + } + + getServerStatus(serverName: string): McpServerStatus { + return this.entries.get(serverName)?.status ?? `idle` + } + + getEnabledServers(): Array<{ name: string; config: McpServerConfig }> { + return Array.from(this.entries.values()) + .filter((e) => e.config.enabled !== false) + .map((e) => ({ name: e.name, config: e.config })) + } + + getServerStates(): McpServerState[] { + return Array.from(this.entries.values()).map((e) => ({ + name: e.name, + config: e.config, + status: e.status, + tools: e.client?.tools ?? [], + resources: e.client?.resources ?? [], + instructions: e.client?.instructions, + error: e.lastError, + sessionId: e.client?.sessionId, + protocolVersion: e.client?.protocolVersion, + })) + } + + getInstructions(): Record { + const result: Record = {} + for (const entry of this.entries.values()) { + if (entry.client?.instructions) { + result[entry.name] = entry.client.instructions + } + } + return result + } + + async getTools(overrides?: McpOverrides): Promise { + const allTools: AgentTool[] = [] + + // Connect enabled servers that have overrides or are globally enabled + const serverNames = this.getEffectiveServers(overrides) + + for (const name of serverNames) { + try { + const client = await this.acquire(name) + const config = this.entries.get(name)!.config + allTools.push(...bridgeMcpTools(name, client.tools, this, config)) + this.release(name) + } catch { + // Server unavailable — skip its tools + } + } + + allTools.push(...createResourceTools(this)) + return allTools + } + + async close(): Promise { + const closeOps = Array.from(this.entries.values()).map(async (entry) => { + this.clearIdleTimer(entry) + if (entry.client) { + await entry.client.close().catch(() => {}) + entry.client = null + entry.status = `idle` + } + }) + await Promise.all(closeOps) + } + + addServer(name: string, config: McpServerConfig): void { + this.entries.set(name, { + name, + config, + client: null, + status: `idle`, + idleTimer: null, + connectAttempts: 0, + }) + } + + async removeServer(name: string): Promise { + const entry = this.entries.get(name) + if (!entry) return + this.clearIdleTimer(entry) + if (entry.client) { + await entry.client.close().catch(() => {}) + } + this.entries.delete(name) + } + + private getEffectiveServers(overrides?: McpOverrides): string[] { + const result = new Set() + + for (const [name, entry] of this.entries) { + if (overrides && name in overrides) { + if (overrides[name] === false) continue + } + if (entry.config.enabled !== false) { + result.add(name) + } + } + + if (overrides) { + for (const [name, override] of Object.entries(overrides)) { + if (override === false) continue + if (!this.entries.has(name)) { + this.addServer(name, override) + } + result.add(name) + } + } + + return Array.from(result) + } + + private async connect(entry: PoolEntry): Promise { + entry.status = `connecting` + entry.connectAttempts++ + + const client = new McpClient({ + serverName: entry.name, + config: entry.config, + tokenStore: this.tokenStore, + workingDirectory: this.workingDirectory, + onAuthUrl: this.onAuthUrl + ? (url) => this.onAuthUrl!(entry.name, url) + : undefined, + onToolsChanged: () => {}, + onResourcesChanged: () => {}, + }) + + const timeoutMs = entry.config.startupTimeoutMs ?? DEFAULTS.startupTimeoutMs + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + + try { + await client.connect(controller.signal) + entry.client = client + entry.status = `connected` + entry.connectAttempts = 0 + entry.lastError = undefined + return client + } catch (err) { + entry.status = `failed` + entry.lastError = err instanceof Error ? err.message : String(err) + throw err + } finally { + clearTimeout(timer) + } + } + + private startIdleTimer(entry: PoolEntry): void { + this.clearIdleTimer(entry) + const timeout = entry.config.idleTimeoutMs ?? DEFAULTS.idleTimeoutMs + entry.idleTimer = setTimeout(async () => { + if (entry.client) { + await entry.client.close().catch(() => {}) + entry.client = null + entry.status = `idle` + } + }, timeout) + } + + private clearIdleTimer(entry: PoolEntry): void { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + entry.idleTimer = null + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/pool.test.ts` +Expected: PASS (the test mocks McpClient, so no real connections) + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/pool.ts packages/agents-mcp/test/pool.test.ts +git commit -m "feat(agents-mcp): add McpClientPool with lazy connect + idle timeout" +``` + +--- + +## Task 9: Tool Bridge + +**Files:** +- Create: `packages/agents-mcp/src/bridge/tool-bridge.ts` +- Create: `packages/agents-mcp/test/tool-bridge.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, expect, it, vi } from 'vitest' +import { bridgeMcpTools, truncateOutput } from '../src/bridge/tool-bridge' +import type { McpDiscoveredTool } from '../src/types' + +describe(`bridgeMcpTools`, () => { + const mockPool = { + acquire: vi.fn().mockResolvedValue({ + callTool: vi.fn().mockResolvedValue({ + content: [{ type: `text`, text: `result` }], + isError: false, + }), + }), + release: vi.fn(), + } + + const tools: McpDiscoveredTool[] = [ + { + name: `create_issue`, + description: `Create a GitHub issue`, + inputSchema: { + type: `object`, + properties: { title: { type: `string` } }, + }, + }, + ] + + it(`creates AgentTools with mcp__ prefix`, () => { + const bridged = bridgeMcpTools(`github`, tools, mockPool as any, {}) + expect(bridged).toHaveLength(1) + expect(bridged[0]!.name).toBe(`mcp__github__create_issue`) + expect(bridged[0]!.label).toBe(`create_issue`) + expect(bridged[0]!.description).toBe(`Create a GitHub issue`) + }) + + it(`calls the correct MCP tool name (without prefix)`, async () => { + const bridged = bridgeMcpTools(`github`, tools, mockPool as any, {}) + await bridged[0]!.execute(`call-1`, { title: `Bug` }) + + const client = await mockPool.acquire.mock.results[0]!.value + expect(client.callTool).toHaveBeenCalledWith( + `create_issue`, + { title: `Bug` }, + expect.objectContaining({ timeout: 60_000 }) + ) + expect(mockPool.release).toHaveBeenCalledWith(`github`) + }) + + it(`releases pool even on error`, async () => { + const failPool = { + acquire: vi.fn().mockResolvedValue({ + callTool: vi.fn().mockRejectedValue(new Error(`fail`)), + }), + release: vi.fn(), + } + const bridged = bridgeMcpTools(`s`, tools, failPool as any, {}) + await expect(bridged[0]!.execute(`c`, {})).rejects.toThrow(`fail`) + expect(failPool.release).toHaveBeenCalledWith(`s`) + }) +}) + +describe(`truncateOutput`, () => { + it(`returns content unchanged when under limit`, () => { + const result = truncateOutput( + { content: [{ type: `text`, text: `short` }], details: {} }, + 100 + ) + expect((result.content[0] as any).text).toBe(`short`) + }) + + it(`truncates text content exceeding limit`, () => { + const longText = `x`.repeat(200) + const result = truncateOutput( + { content: [{ type: `text`, text: longText }], details: {} }, + 100 + ) + const text = (result.content[0] as any).text as string + expect(text.length).toBeLessThan(200) + expect(text).toContain(`[Output truncated`) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/tool-bridge.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement tool-bridge.ts** + +```ts +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { McpClientPool } from '../pool' +import type { McpDiscoveredTool, McpServerConfig } from '../types' +import { MCP_DEFAULTS } from '../types' + +export function bridgeMcpTools( + serverName: string, + tools: McpDiscoveredTool[], + pool: McpClientPool, + config: McpServerConfig +): AgentTool[] { + return tools.map((mcpTool) => bridgeSingleTool(serverName, mcpTool, pool, config)) +} + +function bridgeSingleTool( + serverName: string, + mcpTool: McpDiscoveredTool, + pool: McpClientPool, + config: McpServerConfig +): AgentTool { + const maxOutput = config.maxOutputChars ?? MCP_DEFAULTS.maxOutputChars + const toolTimeout = config.toolTimeoutMs ?? MCP_DEFAULTS.toolTimeoutMs + + return { + name: `mcp__${serverName}__${mcpTool.name}`, + label: mcpTool.name, + description: mcpTool.description ?? ``, + parameters: mcpTool.inputSchema as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const client = await pool.acquire(serverName) + try { + const result = await client.callTool( + mcpTool.name, + params as Record, + { timeout: toolTimeout } + ) + const output = formatMcpResult(result) + return truncateOutput(output, maxOutput) + } finally { + pool.release(serverName) + } + }, + } +} + +function formatMcpResult(result: { + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> + isError?: boolean +}): { content: Array<{ type: string; text: string }>; details: Record } { + const content = result.content.map((block) => { + if (block.type === `text` && block.text !== undefined) { + return { type: `text` as const, text: block.text } + } + if (block.type === `image`) { + return { type: `text` as const, text: `[Image: ${block.mimeType ?? `unknown`}]` } + } + return { type: `text` as const, text: JSON.stringify(block) } + }) + + return { + content: content.length > 0 ? content : [{ type: `text`, text: `(no output)` }], + details: { isError: result.isError ?? false }, + } +} + +export function truncateOutput( + output: { content: Array<{ type: string; text: string }>; details: Record }, + maxChars: number +): { content: Array<{ type: string; text: string }>; details: Record } { + let totalChars = 0 + for (const block of output.content) { + totalChars += block.text.length + } + + if (totalChars <= maxChars) return output + + const truncated = output.content.map((block) => { + if (block.text.length > maxChars) { + return { + type: block.type, + text: + block.text.slice(0, maxChars) + + `\n\n[Output truncated at ${maxChars} chars. Original size: ${block.text.length} chars]`, + } + } + return block + }) + + return { content: truncated, details: { ...output.details, truncated: true } } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/tool-bridge.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/bridge/tool-bridge.ts packages/agents-mcp/test/tool-bridge.test.ts +git commit -m "feat(agents-mcp): add MCP Tool -> AgentTool bridge with namespacing" +``` + +--- + +## Task 10: Resource Bridge + +**Files:** +- Create: `packages/agents-mcp/src/bridge/resource-bridge.ts` +- Create: `packages/agents-mcp/test/resource-bridge.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { describe, expect, it, vi } from 'vitest' +import { createResourceTools } from '../src/bridge/resource-bridge' + +describe(`createResourceTools`, () => { + const mockPool = { + getEnabledServers: vi.fn().mockReturnValue([ + { name: `github`, config: {} }, + ]), + acquire: vi.fn().mockResolvedValue({ + listResources: vi.fn().mockResolvedValue([ + { uri: `repo://org/repo`, name: `repo`, description: `A repo` }, + ]), + readResource: vi.fn().mockResolvedValue([ + { type: `text`, text: `file content here` }, + ]), + }), + release: vi.fn(), + } + + it(`creates two tools: mcp__list_resources and mcp__read_resource`, () => { + const tools = createResourceTools(mockPool as any) + expect(tools).toHaveLength(2) + expect(tools.map((t) => t.name)).toEqual([ + `mcp__list_resources`, + `mcp__read_resource`, + ]) + }) + + it(`mcp__list_resources returns resources from connected servers`, async () => { + const tools = createResourceTools(mockPool as any) + const listTool = tools.find((t) => t.name === `mcp__list_resources`)! + const result = await listTool.execute(`c1`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`github`) + expect(text).toContain(`repo://org/repo`) + }) + + it(`mcp__read_resource reads a resource by server + URI`, async () => { + const tools = createResourceTools(mockPool as any) + const readTool = tools.find((t) => t.name === `mcp__read_resource`)! + const result = await readTool.execute(`c2`, { + server: `github`, + uri: `repo://org/repo`, + }) + const text = (result.content[0] as any).text as string + expect(text).toContain(`file content here`) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/resource-bridge.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement resource-bridge.ts** + +```ts +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { McpClientPool } from '../pool' + +export function createResourceTools(pool: McpClientPool): AgentTool[] { + return [createListResourcesTool(pool), createReadResourceTool(pool)] +} + +function createListResourcesTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__list_resources`, + label: `List MCP Resources`, + description: `List all available resources from connected MCP servers. Returns resource URIs, names, and descriptions.`, + parameters: { + type: `object`, + properties: { + server: { + type: `string`, + description: `Filter by server name (optional)`, + }, + }, + } as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const filter = (params as { server?: string }).server + const servers = pool.getEnabledServers() + const lines: string[] = [] + + for (const { name } of servers) { + if (filter && name !== filter) continue + try { + const client = await pool.acquire(name) + const resources = await client.listResources() + pool.release(name) + + if (resources.length === 0) continue + + lines.push(`## ${name}`) + for (const r of resources) { + lines.push(`- **${r.name}** (\`${r.uri}\`)${r.description ? `: ${r.description}` : ``}`) + } + lines.push(``) + } catch { + // Server unavailable + } + } + + const text = lines.length > 0 ? lines.join(`\n`) : `No resources available.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createReadResourceTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__read_resource`, + label: `Read MCP Resource`, + description: `Read a specific resource from a connected MCP server by server name and resource URI.`, + parameters: { + type: `object`, + properties: { + server: { type: `string`, description: `MCP server name` }, + uri: { type: `string`, description: `Resource URI` }, + }, + required: [`server`, `uri`], + } as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { server, uri } = params as { server: string; uri: string } + const client = await pool.acquire(server) + try { + const contents = await client.readResource(uri) + const text = contents + .map((c) => c.text ?? `[binary: ${c.mimeType ?? `unknown`}]`) + .join(`\n`) + return { + content: [{ type: `text`, text: text || `(empty resource)` }], + details: {}, + } + } finally { + pool.release(server) + } + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/resource-bridge.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/bridge/resource-bridge.ts packages/agents-mcp/test/resource-bridge.test.ts +git commit -m "feat(agents-mcp): add resource bridge tools (list + read)" +``` + +--- + +## Task 11: Config Management Tools (for Horton) + +**Files:** +- Create: `packages/agents-mcp/src/config/config-tools.ts` +- Create: `packages/agents-mcp/test/config-tools.test.ts` + +- [ ] **Step 1: Write failing tests** + +```ts +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createConfigTools } from '../src/config/config-tools' +import { ConfigStore } from '../src/config/config-store' + +describe(`config management tools`, () => { + let workDir: string + let configStore: ConfigStore + let mockPool: any + let tools: ReturnType + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-cfgtools-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + configStore = new ConfigStore(workDir) + mockPool = { + addServer: vi.fn(), + removeServer: vi.fn().mockResolvedValue(undefined), + acquire: vi.fn().mockResolvedValue({ tools: [], resources: [] }), + release: vi.fn(), + getServerStates: vi.fn().mockReturnValue([]), + getEnabledServers: vi.fn().mockReturnValue([]), + } + tools = createConfigTools(configStore, mockPool) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`mcp__manage__add_server saves config and adds to pool`, async () => { + const addTool = tools.find((t) => t.name === `mcp__manage__add_server`)! + const result = await addTool.execute(`c1`, { + name: `github`, + command: `npx`, + args: [`-y`, `@mcp/server-github`], + }) + const text = (result.content[0] as any).text as string + expect(text).toContain(`github`) + expect(mockPool.addServer).toHaveBeenCalledWith(`github`, expect.objectContaining({ command: `npx` })) + + const config = configStore.load() + expect(config.servers.github).toBeDefined() + }) + + it(`mcp__manage__remove_server removes from config and pool`, async () => { + configStore.save({ servers: { github: { command: `npx` } } }) + const removeTool = tools.find((t) => t.name === `mcp__manage__remove_server`)! + await removeTool.execute(`c2`, { name: `github` }) + expect(mockPool.removeServer).toHaveBeenCalledWith(`github`) + const config = configStore.load() + expect(config.servers.github).toBeUndefined() + }) + + it(`mcp__manage__list_servers returns server states`, async () => { + mockPool.getServerStates.mockReturnValue([ + { name: `gh`, status: `connected`, tools: [{ name: `t1` }], resources: [] }, + ]) + const listTool = tools.find((t) => t.name === `mcp__manage__list_servers`)! + const result = await listTool.execute(`c3`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`gh`) + expect(text).toContain(`connected`) + }) + + it(`mcp__manage__list_tools returns tools from all servers`, async () => { + mockPool.getEnabledServers.mockReturnValue([{ name: `gh`, config: {} }]) + mockPool.acquire.mockResolvedValue({ tools: [{ name: `create_issue`, description: `Create issue` }] }) + const listToolsTool = tools.find((t) => t.name === `mcp__manage__list_tools`)! + const result = await listToolsTool.execute(`c4`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`create_issue`) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/agents-mcp && pnpm test -- test/config-tools.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement config-tools.ts** + +```ts +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { ConfigStore } from './config-store' +import type { McpClientPool } from '../pool' +import type { McpServerConfig } from '../types' + +export function createConfigTools( + configStore: ConfigStore, + pool: McpClientPool +): AgentTool[] { + return [ + createAddServerTool(configStore, pool), + createRemoveServerTool(configStore, pool), + createListServersTool(pool), + createListToolsTool(pool), + ] +} + +function createAddServerTool(configStore: ConfigStore, pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__add_server`, + label: `Add MCP Server`, + description: `Add a new MCP server to the project config. Provide either command (for stdio) or url (for Streamable HTTP). The server will be saved to .electric-agents/mcp.json and connected.`, + parameters: { + type: `object`, + properties: { + name: { type: `string`, description: `Unique server name` }, + command: { type: `string`, description: `Command to run (stdio transport)` }, + args: { type: `array`, items: { type: `string` }, description: `Command arguments` }, + env: { type: `object`, description: `Environment variables` }, + url: { type: `string`, description: `Server URL (Streamable HTTP transport)` }, + auth: { + description: `Auth type: "oauth" for OAuth flow, or omit for no auth`, + oneOf: [ + { type: `string`, enum: [`oauth`] }, + { type: `object`, properties: { token: { type: `string` } } }, + { type: `object`, properties: { tokenEnvVar: { type: `string` } } }, + ], + }, + }, + required: [`name`], + } as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { name, ...rest } = params as { name: string } & McpServerConfig + const serverConfig: McpServerConfig = {} + + if (rest.command) serverConfig.command = rest.command + if (rest.args) serverConfig.args = rest.args + if (rest.env) serverConfig.env = rest.env as Record + if (rest.url) serverConfig.url = rest.url + if (rest.auth) serverConfig.auth = rest.auth as McpServerConfig[`auth`] + + configStore.addServer(name, serverConfig) + pool.addServer(name, serverConfig) + + try { + const client = await pool.acquire(name) + pool.release(name) + const toolCount = client.tools.length + const resourceCount = client.resources.length + return { + content: [{ + type: `text`, + text: `MCP server "${name}" added and connected. Discovered ${toolCount} tool(s) and ${resourceCount} resource(s).`, + }], + details: { toolCount, resourceCount }, + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { + content: [{ + type: `text`, + text: `MCP server "${name}" saved to config but failed to connect: ${msg}`, + }], + details: { error: msg }, + } + } + }, + } +} + +function createRemoveServerTool(configStore: ConfigStore, pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__remove_server`, + label: `Remove MCP Server`, + description: `Remove an MCP server from the project config and disconnect it.`, + parameters: { + type: `object`, + properties: { + name: { type: `string`, description: `Server name to remove` }, + }, + required: [`name`], + } as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { name } = params as { name: string } + const removed = configStore.removeServer(name) + await pool.removeServer(name) + const text = removed + ? `MCP server "${name}" removed and disconnected.` + : `MCP server "${name}" was not found in config.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createListServersTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__list_servers`, + label: `List MCP Servers`, + description: `List all configured MCP servers with their connection status, tools, and resources.`, + parameters: { type: `object`, properties: {} } as AgentTool[`parameters`], + execute: async () => { + const states = pool.getServerStates() + if (states.length === 0) { + return { content: [{ type: `text`, text: `No MCP servers configured.` }], details: {} } + } + + const lines = states.map((s) => { + const transport = s.config.command ? `stdio` : s.config.url ? `http` : `unknown` + const toolNames = s.tools.map((t) => t.name).join(`, `) || `none` + return `- **${s.name}** [${s.status}] (${transport}) — ${s.tools.length} tools (${toolNames})${s.error ? ` — error: ${s.error}` : ``}` + }) + + return { + content: [{ type: `text`, text: lines.join(`\n`) }], + details: {}, + } + }, + } +} + +function createListToolsTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__list_tools`, + label: `List MCP Tools`, + description: `List all tools available from connected MCP servers.`, + parameters: { + type: `object`, + properties: { + server: { type: `string`, description: `Filter by server name (optional)` }, + }, + } as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const filter = (params as { server?: string }).server + const servers = pool.getEnabledServers() + const lines: string[] = [] + + for (const { name } of servers) { + if (filter && name !== filter) continue + try { + const client = await pool.acquire(name) + pool.release(name) + if (client.tools.length === 0) continue + lines.push(`## ${name}`) + for (const t of client.tools) { + lines.push(`- **${t.name}**: ${t.description ?? `(no description)`}`) + } + lines.push(``) + } catch { + // skip unavailable + } + } + + const text = lines.length > 0 ? lines.join(`\n`) : `No MCP tools available.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/agents-mcp && pnpm test -- test/config-tools.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/src/config/config-tools.ts packages/agents-mcp/test/config-tools.test.ts +git commit -m "feat(agents-mcp): add config management tools for Horton" +``` + +--- + +## Task 12: Integration Factory + +**Files:** +- Create: `packages/agents-mcp/src/integration.ts` +- Modify: `packages/agents-mcp/src/index.ts` + +- [ ] **Step 1: Implement integration.ts** + +```ts +import type { AgentTool } from '@mariozechner/pi-agent-core' +import { ConfigStore } from './config/config-store' +import { McpClientPool } from './pool' +import { createConfigTools } from './config/config-tools' +import type { McpConfig, McpIntegration, McpOverrides } from './types' + +export function createMcpIntegration(opts: { + workingDirectory: string + onAuthUrl?: (serverName: string, url: string) => void +}): McpIntegration { + const configStore = new ConfigStore(opts.workingDirectory) + const config = configStore.load({ expandEnv: true }) + + const pool = new McpClientPool(config, { + workingDirectory: opts.workingDirectory, + onAuthUrl: opts.onAuthUrl, + }) + + const configTools = createConfigTools(configStore, pool) + + return { + configTools, + + async getTools(overrides?: McpOverrides): Promise { + return pool.getTools(overrides) + }, + + getServerInstructions(): Record { + return pool.getInstructions() + }, + + async getServerSummary(): Promise { + const states = pool.getServerStates() + const connected = states.filter((s) => s.status === `connected`) + if (connected.length === 0) return `` + + const sections = connected.map((s) => { + const toolNames = s.tools.map((t) => `mcp__${s.name}__${t.name}`).join(`, `) + const header = `## ${s.name}` + const instructions = s.instructions ? `Instructions: ${s.instructions}\n` : `` + const tools = s.tools.length > 0 ? `Tools: ${toolNames}` : `No tools` + return `${header}\n${instructions}${tools}` + }) + + return `# MCP Servers\nThe following external tool servers are connected:\n\n${sections.join(`\n\n`)}\n\nUse mcp__list_resources to discover available resources from these servers.` + }, + + async close(): Promise { + await pool.close() + }, + } +} +``` + +- [ ] **Step 2: Update src/index.ts with all public exports** + +```ts +// Types +export type { + McpServerConfig, + McpConfig, + McpOverrides, + McpIntegration, + McpServerStatus, + McpServerState, + McpDiscoveredTool, + McpDiscoveredResource, +} from './types' + +export { MCP_DEFAULTS } from './types' + +// Main entry point +export { createMcpIntegration } from './integration' + +// Sub-modules (for advanced usage) +export { McpClientPool } from './pool' +export { McpClient } from './client' +export { ConfigStore } from './config/config-store' +export { TokenStore } from './auth/token-store' +export { bridgeMcpTools } from './bridge/tool-bridge' +export { createResourceTools } from './bridge/resource-bridge' +export { createConfigTools } from './config/config-tools' +export { expandEnvVars, expandConfigValues } from './config/env-expand' +``` + +- [ ] **Step 3: Run typecheck and build** + +Run: `cd packages/agents-mcp && pnpm typecheck && pnpm build` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add packages/agents-mcp/src/integration.ts packages/agents-mcp/src/index.ts +git commit -m "feat(agents-mcp): add createMcpIntegration factory and public exports" +``` + +--- + +## Task 13: Wire MCP into Horton (agents package) + +**Files:** +- Modify: `packages/agents/package.json` +- Modify: `packages/agents/src/bootstrap.ts` +- Modify: `packages/agents/src/agents/horton.ts` + +- [ ] **Step 1: Add @electric-ax/agents-mcp dependency** + +In `packages/agents/package.json`, add to `dependencies`: + +```json +"@electric-ax/agents-mcp": "workspace:*" +``` + +Run: `pnpm install` + +- [ ] **Step 2: Modify bootstrap.ts to create MCP integration** + +In `packages/agents/src/bootstrap.ts`, add the MCP integration creation and pass it through to Horton registration. + +Add import: +```ts +import { createMcpIntegration } from '@electric-ax/agents-mcp' +import type { McpIntegration } from '@electric-ax/agents-mcp' +``` + +Update `createBuiltinAgentHandler` to create the integration and pass it to `registerHorton`: + +```ts +// After: const cwd = workingDirectory ?? process.cwd() +const mcp = createMcpIntegration({ workingDirectory: cwd }) + +// Change registerHorton call: +const typeNames = registerHorton(registry, { + workingDirectory: cwd, + streamFn, + mcp, +}) +``` + +Update `AgentHandlerResult` to include `mcp`: +```ts +export interface AgentHandlerResult { + handler: (req: IncomingMessage, res: ServerResponse) => Promise + runtime: RuntimeHandler + registry: EntityRegistry + typeNames: Array + mcp: McpIntegration +} +``` + +Return `mcp` in the result object. + +- [ ] **Step 3: Modify horton.ts to accept and inject MCP tools** + +In `packages/agents/src/agents/horton.ts`: + +Add import: +```ts +import type { McpIntegration } from '@electric-ax/agents-mcp' +``` + +Update `registerHorton` signature: +```ts +export function registerHorton( + registry: EntityRegistry, + options: { workingDirectory: string; streamFn?: StreamFn; mcp?: McpIntegration } +): Array { +``` + +Update `createAssistantHandler` to accept `mcp`: +```ts +function createAssistantHandler(options: { + workingDirectory: string + streamFn?: StreamFn + docsSupport: HortonDocsSupport | null + docsSearchTool?: AgentTool + mcp?: McpIntegration +}) { +``` + +Inside `assistantHandler`, inject MCP tools: +```ts +const mcpTools = options.mcp ? await options.mcp.getTools() : [] +const mcpSummary = options.mcp ? await options.mcp.getServerSummary() : `` + +const tools = [ + ...ctx.electricTools, + ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }), + ...(options.mcp?.configTools ?? []), + ...mcpTools, +] +``` + +Update `buildHortonSystemPrompt` to accept and include MCP summary: +```ts +export function buildHortonSystemPrompt( + workingDirectory: string, + opts: { hasDocsSupport?: boolean; mcpSummary?: string } = {} +): string { +``` + +Add near the end of the system prompt, before the working directory line: +```ts +const mcpSection = opts.mcpSummary ? `\n${opts.mcpSummary}\n` : `` +``` + +And include `${mcpSection}` in the prompt template. + +Pass `mcpSummary` when calling `buildHortonSystemPrompt`: +```ts +ctx.useAgent({ + systemPrompt: buildHortonSystemPrompt(workingDirectory, { + hasDocsSupport: Boolean(docsSupport), + mcpSummary, + }), + model: HORTON_MODEL, + tools, + ...(streamFn && { streamFn }), +}) +``` + +- [ ] **Step 4: Run typecheck across both packages** + +Run: `cd packages/agents && pnpm typecheck` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents/package.json packages/agents/src/bootstrap.ts packages/agents/src/agents/horton.ts +git commit -m "feat(agents): wire MCP integration into Horton bootstrap" +``` + +--- + +## Task 14: Integration Test + +**Files:** +- Create: `packages/agents-mcp/test/integration.test.ts` + +- [ ] **Step 1: Write integration test with a mock MCP stdio server** + +Create a simple test that verifies the full flow: config -> pool -> tool bridge -> tool execution. + +```ts +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMcpIntegration } from '../src/integration' + +// Mock the McpClient to avoid real subprocess/network calls +vi.mock(`../src/client`, () => ({ + McpClient: vi.fn().mockImplementation((opts: any) => ({ + serverName: opts.serverName, + tools: [ + { + name: `echo`, + description: `Echoes input`, + inputSchema: { type: `object`, properties: { text: { type: `string` } } }, + }, + ], + resources: [], + instructions: `Use echo to test`, + sessionId: `session-1`, + protocolVersion: `2025-06-18`, + connect: vi.fn().mockResolvedValue(undefined), + discover: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + callTool: vi.fn().mockImplementation(async (_name: string, args: any) => ({ + content: [{ type: `text`, text: `echo: ${args.text}` }], + isError: false, + })), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue([]), + })), +})) + +describe(`createMcpIntegration (mocked)`, () => { + let workDir: string + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-int-${randomUUID()}`) + mkdirSync(join(workDir, `.electric-agents`), { recursive: true }) + writeFileSync( + join(workDir, `.electric-agents`, `mcp.json`), + JSON.stringify({ + servers: { + test: { command: `echo`, args: [`hello`] }, + }, + }) + ) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`loads config and bridges tools`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const tools = await mcp.getTools() + + const mcpTools = tools.filter((t) => t.name.startsWith(`mcp__test__`)) + expect(mcpTools).toHaveLength(1) + expect(mcpTools[0]!.name).toBe(`mcp__test__echo`) + + // Execute the bridged tool + const result = await mcpTools[0]!.execute(`c1`, { text: `hello` }) + expect((result.content[0] as any).text).toBe(`echo: hello`) + + await mcp.close() + }) + + it(`includes config management tools`, () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const configToolNames = mcp.configTools.map((t) => t.name) + expect(configToolNames).toContain(`mcp__manage__add_server`) + expect(configToolNames).toContain(`mcp__manage__remove_server`) + expect(configToolNames).toContain(`mcp__manage__list_servers`) + expect(configToolNames).toContain(`mcp__manage__list_tools`) + }) + + it(`generates server summary with instructions`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + await mcp.getTools() // triggers lazy connect + const summary = await mcp.getServerSummary() + expect(summary).toContain(`# MCP Servers`) + expect(summary).toContain(`test`) + expect(summary).toContain(`mcp__test__echo`) + + await mcp.close() + }) + + it(`applies overrides to exclude servers`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const tools = await mcp.getTools({ test: false }) + const mcpTools = tools.filter((t) => t.name.startsWith(`mcp__test__`)) + expect(mcpTools).toHaveLength(0) + + await mcp.close() + }) +}) +``` + +- [ ] **Step 2: Run integration test** + +Run: `cd packages/agents-mcp && pnpm test -- test/integration.test.ts` +Expected: PASS + +- [ ] **Step 3: Run full test suite** + +Run: `cd packages/agents-mcp && pnpm test` +Expected: All tests pass + +- [ ] **Step 4: Build** + +Run: `cd packages/agents-mcp && pnpm build` +Expected: Build succeeds + +- [ ] **Step 5: Commit** + +```bash +git add packages/agents-mcp/test/integration.test.ts +git commit -m "test(agents-mcp): add integration test for full MCP flow" +``` + +--- + +## Task 15: Final Verification + +- [ ] **Step 1: Run full monorepo typecheck for affected packages** + +Run: `cd packages/agents-mcp && pnpm typecheck && cd ../agents && pnpm typecheck` +Expected: PASS + +- [ ] **Step 2: Run all tests in agents-mcp** + +Run: `cd packages/agents-mcp && pnpm test` +Expected: All tests pass + +- [ ] **Step 3: Build agents-mcp** + +Run: `cd packages/agents-mcp && pnpm build` +Expected: Build produces `dist/index.js`, `dist/index.cjs`, `dist/index.d.ts` + +- [ ] **Step 4: Verify exports** + +Run: `node -e "import('@electric-ax/agents-mcp').then(m => console.log(Object.keys(m)))"` +Expected: Lists all public exports + +- [ ] **Step 5: Final commit with any remaining fixes** + +```bash +git add -A +git commit -m "chore(agents-mcp): final verification and cleanup" +``` diff --git a/docs/superpowers/specs/2026-04-24-mcp-server-support-design.md b/docs/superpowers/specs/2026-04-24-mcp-server-support-design.md new file mode 100644 index 0000000000..c600aeb0cc --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-mcp-server-support-design.md @@ -0,0 +1,444 @@ +# MCP Server Support for Electric Agents + +**Date:** 2026-04-24 +**Status:** Approved (revised after Claude Code & Codex CLI comparison) + +## Goal + +Add MCP (Model Context Protocol) server support so entities can use remote and local MCP servers the same way they use built-in tools. Cover tools, resources, OAuth auth flow, stdio and Streamable HTTP transports. + +## Approach + +New package `@electric-ax/agents-mcp` that plugs into `agents-runtime` via the existing `AgentTool` interface. The runtime stays MCP-agnostic; MCP is opt-in. + +## Package Structure + +``` +packages/agents-mcp/ +├── package.json # @electric-ax/agents-mcp +├── tsconfig.json +├── src/ +│ ├── index.ts # Public exports +│ ├── types.ts # Config types, MCP server definitions +│ ├── pool.ts # McpClientPool — connection lifecycle + idle timeout +│ ├── client.ts # McpClient — wraps a single MCP server connection +│ ├── transports/ +│ │ ├── stdio.ts # Stdio transport (spawn child process) +│ │ └── streamable-http.ts # Streamable HTTP transport +│ ├── auth/ +│ │ ├── oauth.ts # OAuth 2.1 + PKCE flow via SDK's OAuthClientProvider +│ │ └── token-store.ts # Token persistence (.electric-agents/mcp-auth.json) +│ ├── bridge/ +│ │ ├── tool-bridge.ts # MCP Tool -> AgentTool adapter +│ │ └── resource-bridge.ts # MCP Resource -> read tools +│ ├── config/ +│ │ ├── config-store.ts # Read/write .electric-agents/mcp.json +│ │ ├── config-tools.ts # AgentTools for Horton to manage MCP config +│ │ └── env-expand.ts # ${VAR} and ${VAR:-default} expansion +│ └── integration.ts # createMcpIntegration() factory +``` + +### Dependencies + +- `@modelcontextprotocol/sdk` — official MCP TypeScript SDK (Client, transports, types, auth) +- `@electric-ax/agents-runtime` — peer dependency for `AgentTool` type + +## Configuration + +### Global config: `.electric-agents/mcp.json` + +Supports `${VAR}` and `${VAR:-default}` environment variable expansion in `command`, `args`, `env`, `url`, and `headers` values. + +```json +{ + "servers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" } + }, + "honeycomb": { + "url": "https://api.honeycomb.io/mcp", + "auth": "oauth", + "oauth": { + "scopes": ["read:data", "write:annotations"] + } + }, + "internal-api": { + "url": "https://internal.example.com/mcp", + "auth": { "tokenEnvVar": "INTERNAL_API_KEY" }, + "headers": { "X-Team": "platform" } + } + } +} +``` + +### Auth tokens: `.electric-agents/mcp-auth.json` (gitignored) + +```json +{ + "honeycomb": { + "access_token": "...", + "refresh_token": "...", + "expires_at": 1719500000, + "token_type": "Bearer" + } +} +``` + +### File conventions + +- `.electric-agents/mcp.json` — server config (can be committed if secrets use env var refs) +- `.electric-agents/mcp-auth.json` — tokens/secrets (gitignored) +- `.electric-agents/.gitignore` — auto-created with `mcp-auth.json` entry + +### Per-entity-instance overrides + +Overrides are passed via spawn args: + +```ts +ctx.spawn('horton', 'my-agent', { + mcpServers: { + github: false, // disable globally-configured server + honeycomb: { url: 'https://...' } // add instance-specific server + } +}) +``` + +The handler reads `ctx.args.mcpServers` and passes it as `McpOverrides` to `mcp.getTools(overrides)`. + +## Types + +```ts +interface McpServerConfig { + /** Stdio transport */ + command?: string + args?: string[] + env?: Record + cwd?: string // working directory for stdio process + + /** Streamable HTTP transport */ + url?: string + + /** Auth */ + auth?: 'oauth' | { token: string } | { tokenEnvVar: string } + headers?: Record // arbitrary static headers (env-expandable) + + /** OAuth specifics (only when auth: 'oauth') */ + oauth?: { + clientId?: string // optional if server supports DCR + scopes?: string[] + callbackPort?: number + } + + /** Lifecycle */ + enabled?: boolean // toggle without removing config (default: true) + startupTimeoutMs?: number // connection timeout (default: 10_000) + toolTimeoutMs?: number // per-tool-call timeout (default: 60_000) + idleTimeoutMs?: number // idle before disconnect (default: 300_000) + maxOutputChars?: number // cap tool output size (default: 25_000) +} + +interface McpConfig { + servers: Record +} + +type McpOverrides = Record +``` + +## McpClient + +Thin wrapper around `@modelcontextprotocol/sdk`'s `Client` class. Adds: + +- Transport-agnostic construction (pass config, get a connected client) +- Implements SDK's `OAuthClientProvider` interface for OAuth auth (backed by token-store) +- Typed accessors for `listTools()`, `callTool()`, `listResources()`, `readResource()` +- Stores `sessionId` and `protocolVersion` for efficient reconnection + +This is not a significant abstraction — it exists to keep transport setup and auth handling out of the pool. + +## MCP Client Pool + +Manages lazy connection lifecycle with idle timeout. + +```ts +class McpClientPool { + constructor(config: McpConfig, opts: { workingDirectory: string }) + acquire(serverName: string): Promise + release(serverName: string): void + getTools(filter?: { servers?: string[] }): Promise + getResources(filter?: { servers?: string[] }): Promise + getServerInstructions(): Record + reload(): Promise + close(): Promise +} +``` + +### Connection lifecycle + +1. `idle` — no connection, no process +2. `acquire()` — triggers connect (spawn process / HTTP connect) +3. `connecting` — transport initializing, capability negotiation, tool/resource discovery +4. `connected` — ready for tool calls and resource reads +5. `release()` — starts idle timer +6. Idle timer expires — disconnect (kill process / drop HTTP) +7. Next `acquire()` — reconnect transparently + +### Reconnection strategy + +- On connection failure during `acquire()`, use exponential backoff: 5 attempts, 1s base delay, 2x growth, 30s max delay. +- For stdio: process crash detected via `onclose` callback — mark as disconnected, reconnect on next `acquire()`. +- For Streamable HTTP: store `sessionId` and `protocolVersion` — pass to new transport on reconnect to skip re-initialization. If server returns 404 (session expired), do full re-init. + +### Graceful stdio shutdown + +Follow the MCP SDK pattern: close stdin → wait 2s → SIGTERM → wait 2s → SIGKILL. + +### Notifications + +Use the SDK's `listChanged` constructor option for automatic re-discovery: + +```ts +new Client(clientInfo, { + listChanged: { + tools: { onChanged: (err, tools) => { /* update cached tools */ } }, + resources: { onChanged: (err, resources) => { /* update cached resources */ } }, + } +}) +``` + +### Output size limiting + +Tool call results exceeding `maxOutputChars` (default: 25,000) are truncated with a notice appended: `[Output truncated at 25000 chars. Original size: N chars]`. + +## Transports + +### Stdio + +- Spawns child process with `command` + `args` via SDK's `StdioClientTransport` +- `env` merged with `process.env` + `{ HOME: workingDirectory }` +- `cwd` defaults to `workingDirectory` if not specified +- Process killed on disconnect (graceful shutdown sequence), respawned on reconnect +- `startupTimeoutMs` applied as AbortSignal timeout on `client.connect()` + +### Streamable HTTP + +- Uses SDK's `StreamableHTTPClientTransport` +- Auth via SDK's `AuthProvider` interface — `token()` reads from token store, `onUnauthorized()` triggers OAuth flow +- `headers` from config passed via `requestInit` +- SDK handles `mcp-session-id` and `mcp-protocol-version` headers automatically +- Built-in SSE reconnection with exponential backoff (SDK-provided) + +## Auth Flow (OAuth 2.1 + PKCE) + +Implemented via the MCP SDK's `OAuthClientProvider` interface backed by our token store. + +### OAuthClientProvider implementation + +```ts +class ElectricOAuthProvider implements OAuthClientProvider { + // Reads/writes .electric-agents/mcp-auth.json + tokens(): Promise + saveTokens(tokens: OAuthTokens): Promise + + // Stores code verifier for PKCE + saveCodeVerifier(verifier: string): Promise + codeVerifier(): Promise + + // Client registration (supports DCR if server allows) + clientInformation(): Promise + saveClientInformation(info: OAuthClientInformation): Promise + + // Auth redirect — starts local HTTP server, prints URL + redirectToAuthorization(authUrl: URL): Promise +} +``` + +### Flow + +1. `acquire()` calls `client.connect(transport)` with our `OAuthClientProvider` +2. On 401, SDK calls `redirectToAuthorization(authUrl)` +3. Our implementation starts a temporary local HTTP server on `oauth.callbackPort` (or random port) +4. Auth URL printed to logs. If Horton triggered it via `mcp_add_server`, returned as tool result +5. User authorizes in browser → redirect to local callback → SDK exchanges code for tokens +6. `saveTokens()` persists to `.electric-agents/mcp-auth.json` +7. SDK retries the connection with the new token +8. Auto-refresh handled by SDK — calls `tokens()` before each request + +### Static token auth + +For servers with `auth: { token: "..." }` or `auth: { tokenEnvVar: "VAR" }`, use a simple `AuthProvider`: + +```ts +{ token: async () => resolvedToken } +``` + +No OAuth flow needed. + +## Tool Bridge + +MCP tools are adapted to the `AgentTool` interface (from `@mariozechner/pi-agent-core`). + +### Namespacing + +Tools are prefixed with `mcp__` + server name: `mcp__github__create_issue`, `mcp__honeycomb__query`. This matches the Claude Code convention and distinguishes MCP tools from built-in tools. The prefix is stripped before forwarding calls to the MCP server. + +### Adapter + +```ts +function bridgeMcpTool(serverName: string, mcpTool: MCP.Tool, pool: McpClientPool, config: McpServerConfig): AgentTool { + return { + name: `mcp__${serverName}__${mcpTool.name}`, + label: mcpTool.name, + description: mcpTool.description ?? '', + parameters: mcpTool.inputSchema, + execute: async (_toolCallId, params) => { + const client = await pool.acquire(serverName) + try { + const result = await client.callTool(mcpTool.name, params, { + timeout: config.toolTimeoutMs ?? 60_000, + }) + const output = formatMcpResult(result) + return truncateOutput(output, config.maxOutputChars ?? 25_000) + } finally { + pool.release(serverName) + } + }, + } +} +``` + +## Resource Bridge + +Resources are exposed as tools rather than eagerly loaded into context (resources can be large and numerous). + +### Tools + +- **`mcp__list_resources`** — lists all resources from connected MCP servers with URIs and descriptions +- **`mcp__read_resource`** — reads a specific resource by server name + URI + +This lets the agent decide when to read resources, avoiding context bloat. + +## Config Management Tools (for Horton) + +Tools added to Horton's tool array so users can configure MCP servers conversationally: + +- **`mcp__manage__add_server`** — adds an MCP server to `.electric-agents/mcp.json`. Takes `name`, transport config (`command`/`url`), `auth`. Triggers pool connection + OAuth if needed. Returns success or auth URL. +- **`mcp__manage__remove_server`** — removes a server from config, disconnects from pool. +- **`mcp__manage__list_servers`** — lists configured servers with connection status. +- **`mcp__manage__list_tools`** — lists all tools from connected MCP servers (or filter by server). + +## Integration Wiring + +### Main export from `@electric-ax/agents-mcp` + +```ts +function createMcpIntegration(opts: { + workingDirectory: string +}): McpIntegration + +interface McpIntegration { + /** Tools for managing MCP config (for Horton) */ + configTools: AgentTool[] + /** Get all bridged MCP tools for an entity, applying overrides */ + getTools(overrides?: McpOverrides): Promise + /** Get server instructions for system prompt injection */ + getServerInstructions(): Record + /** Pool access for resource reads */ + pool: McpClientPool + /** Shut down */ + close(): Promise +} +``` + +### Bootstrap wiring in `packages/agents` + +```ts +// bootstrap.ts +import { createMcpIntegration } from '@electric-ax/agents-mcp' + +const mcp = createMcpIntegration({ workingDirectory: cwd }) + +// In Horton's handler: +const tools = [ + ...ctx.electricTools, + ...createHortonTools(workingDirectory, ctx, readSet), + ...mcp.configTools, + ...(await mcp.getTools()), +] +``` + +### Per-instance override wiring + +```ts +// In entity handler: +const overrides = ctx.args.mcpServers as McpOverrides | undefined +const mcpTools = await mcp.getTools(overrides) +``` + +## System Prompt Injection + +Connected MCP servers are summarized dynamically in the agent's system prompt. Two sources of information: + +### Server instructions + +MCP servers can provide instructions during initialization (`client.getInstructions()`). These are included in the system prompt to help the agent understand server-specific guidance. + +### Tool summary + +``` +# MCP Servers +The following external tool servers are connected: + +## honeycomb +Instructions: Use natural language time ranges like "last 2 hours". +Tools: mcp__honeycomb__query, mcp__honeycomb__list_datasets, ... + +## github +Tools: mcp__github__create_issue, mcp__github__search_repos, ... + +Use mcp__list_resources to discover available resources from these servers. +``` + +Generated from the pool's discovered tools and instructions at handler time. + +## Error Handling + +- **Connection failures** — logged, exponential backoff retry (5 attempts). Tools from that server excluded from the entity's tool set until reconnected. +- **Startup timeout** — `startupTimeoutMs` (default: 10s) applied via AbortSignal. Server marked as failed if exceeded. +- **Auth failures** — if stored tokens are expired and refresh fails, tool calls return an error asking the user to re-authenticate via `mcp__manage__add_server`. +- **Tool call failures** — MCP errors passed through as `isError: true` in the tool result. The agent can retry or report to the user. +- **Tool call timeout** — `toolTimeoutMs` (default: 60s) applied via AbortSignal on each `callTool()`. +- **Output overflow** — results exceeding `maxOutputChars` are truncated with notice. +- **Process crashes (stdio)** — pool detects process exit via `onclose`, marks server as disconnected, reconnects with backoff on next `acquire()`. +- **Env var expansion failures** — missing required env vars (no default) cause a config load error with clear message naming the missing variable. + +## Scope Boundaries + +### In scope + +- Stdio and Streamable HTTP transports +- Tools and Resources +- OAuth 2.1 + PKCE auth flow via SDK's OAuthClientProvider (including DCR support) +- Connection pooling with idle timeout and exponential backoff reconnection +- Config management via Horton tools +- Per-entity-instance overrides +- Tool namespacing (`mcp__server__tool`) +- Dynamic tool/resource list change notifications +- Environment variable expansion in config +- Server instructions in system prompt +- Output size limiting +- Configurable startup and tool call timeouts +- Graceful stdio shutdown +- `enabled` toggle per server + +### Out of scope (future work) + +- SSE transport (deprecated in MCP spec) +- MCP Prompts capability +- MCP Sampling capability (reverse LLM calls) +- Exposing entities as MCP servers +- `headersHelper` (shell command for dynamic header generation) +- Tool deferred loading / search (load tool schemas on demand) +- System keychain for token storage (currently file-based) +- DCR (Dynamic Client Registration) — SDK supports it; we pass through but don't configure diff --git a/docs/superpowers/specs/2026-04-24-sandbox-agents-design.md b/docs/superpowers/specs/2026-04-24-sandbox-agents-design.md new file mode 100644 index 0000000000..7239ad16dc --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-sandbox-agents-design.md @@ -0,0 +1,362 @@ +# Sandbox Agents Design + +Run coding agents (Claude Code, Codex, etc.) inside isolated sandbox environments (Docker, E2B, etc.) with full session streaming via durable streams and agent-session-protocol parsing. + +## Goals + +1. **Generic sandbox abstraction** — pluggable providers for Docker, E2B, Firecracker, etc. +2. **Full session streaming** — every agent event (tool calls, assistant messages, thinking, errors) captured and rendered in the UI via entity stream collections. +3. **Durable stream I/O** — a single shared durable stream for both input (prompts) and output (agent events). No direct stdin/stdout coupling. +4. **Configurable initial state** — API keys, env vars, volumes injected into the sandbox. +5. **Entity model** — sandbox agents are regular entities, spawned and observed like any other. + +## Architecture + +### Layered Design + +Three independent layers composed by a generic sandbox entity handler: + +| Layer | Responsibility | +|---|---| +| **SandboxProvider** | Container lifecycle only — create, destroy. Receives stream URL as config. | +| **SessionProtocolBridge** | Runtime-side: tails the durable stream, parses agent-session-protocol JSONL, writes to entity stream collections. Sends prompts by appending `user_message` events. | +| **SandboxEntity handler** | Composes provider + bridge. Manages lifecycle across wakes. | + +A fourth component, the **stream bridge**, runs inside the container and connects the agent CLI's stdio to the durable stream. + +### Data Flow + +``` +Parent Entity Runtime Container + │ │ │ + │ spawn('sandbox-claude', │ │ + │ 'sc-1', { prompt }) │ │ + │─────────────────────────────>│ │ + │ │ 1. Create durable stream │ + │ │ 2. provider.create(config) │ + │ │───────────────────────────────>│ + │ │ │ + │ │ stream-bridge starts │ + │ │ tails stream for prompts │ + │ │ │ + │ │ 3. bridge.sendPrompt(text) │ + │ │ (appends user_message │ + │ │ to durable stream) │ + │ │ │ + │ │ stream-bridge│ + │ │ reads prompt │ + │ │ pipes to │ + │ │ agent stdin │ + │ │ │ + │ │ agent writes │ + │ │ JSONL stdout │ + │ │ │ + │ │ stream-bridge│ + │ │ appends to │ + │ │<──────────────── durable stream│ + │ │ │ + │ │ 4. bridge parses events │ + │ │ writes to entity stream │ + │ │ collections (runs, steps, │ + │ │ toolCalls, texts, etc.) │ + │ │ │ + │ observe → wake on │ │ + │ runFinished │ │ + │<─────────────────────────────│ │ +``` + +## Component Specifications + +### SandboxProvider Interface + +```typescript +interface SandboxConfig { + /** Container image (e.g., "my-claude-agent:latest") */ + image: string + /** Environment variables injected into the container (API keys, etc.) */ + env?: Record + /** Volume mounts for workspace/artifacts */ + volumes?: Array<{ host: string; container: string; mode?: 'ro' | 'rw' }> + /** Command to run inside the container (e.g., ["claude", "--output-format", "stream-json"]) */ + command: Array + /** Working directory inside the container */ + workdir?: string + /** Resource limits */ + limits?: { + memoryMb?: number + cpus?: number + timeoutMs?: number + } + /** Single durable stream URL for all I/O (agent-session-protocol JSONL) */ + streamUrl: string +} + +interface SandboxInstance { + /** Unique instance identifier (container ID, VM ID, etc.) */ + id: string + /** Current status */ + status(): 'running' | 'stopped' | 'failed' + /** Stop and clean up */ + destroy(): Promise +} + +interface SandboxProvider { + readonly name: string + /** Create and start a sandbox container */ + create(config: SandboxConfig): Promise +} +``` + +The provider passes `streamUrl` to the container via the `AGENT_STREAM_URL` environment variable. The container's stream bridge uses this to connect. + +### SessionProtocolBridge + +Runtime-side component that tails the shared durable stream and maps agent-session-protocol events to entity stream collections. + +```typescript +import type { NormalizedEvent, AgentType } from 'agent-session-protocol' + +interface SessionProtocolBridgeConfig { + /** Shared durable stream URL */ + streamUrl: string + /** The entity's stream DB to write parsed events into */ + db: EntityStreamDBWithActions + /** Agent type for normalization (claude, codex, etc.) */ + agentType: AgentType +} + +interface SessionProtocolBridge { + /** Start tailing and parsing agent output events */ + start(): Promise + /** Stop tailing */ + stop(): Promise + /** Send a prompt — appends a user_message event to the stream */ + sendPrompt(text: string): Promise +} +``` + +#### Event Mapping + +| Protocol Event | Entity Collection | Notes | +|---|---|---| +| `session_init` | `runs` (status: `started`) | New run created | +| `assistant_message` | `texts` + `textDeltas` | Text output streaming | +| `thinking` | `reasoning` | Extended thinking content | +| `tool_call` | `toolCalls` (status: `started`) | Tool name + args | +| `tool_result` | `toolCalls` (status: `completed`/`failed`) | Updates matching tool call by `callId` | +| `permission_request` | `toolCalls` (status update) | Could surface to parent entity | +| `turn_complete` | `steps` (status: `completed`) + usage metadata | Step finalized | +| `turn_aborted` | `steps` + `errors` | Abnormal end | +| `error` | `errors` | Error recorded | +| `session_end` | `runs` (status: `completed`) | Run finalized | + +The bridge ignores `user_message` events (those are prompts the runtime itself wrote). + +### Sandbox Entity Handler + +Factory function that returns an `EntityDefinition`: + +```typescript +interface SandboxEntityConfig { + /** Which provider to use */ + provider: SandboxProvider + /** Container image */ + image: string + /** Agent type running inside (claude, codex, etc.) */ + agentType: AgentType + /** Command to start the agent */ + command: Array + /** Environment variables (API keys, etc.) */ + env?: Record + /** Volume mounts for workspace/artifacts */ + volumes?: Array<{ host: string; container: string; mode?: 'ro' | 'rw' }> + /** Resource limits */ + limits?: SandboxConfig['limits'] +} + +function createSandboxEntity(config: SandboxEntityConfig): EntityDefinition +``` + +#### Entity Lifecycle + +**First wake:** +1. Create durable stream for sandbox I/O +2. Start container via `provider.create()` (stream URL passed in config) +3. Start `SessionProtocolBridge` (tail stream, parse events into entity collections) +4. Send initial prompt from `ctx.args.prompt` via `bridge.sendPrompt()` + +**Subsequent wakes (inbox message):** +1. Extract prompt text from inbox message +2. Call `bridge.sendPrompt(text)` (appends `user_message` to the shared stream) + +**Shutdown (session_end detected or entity destroyed):** +1. `bridge.stop()` +2. `sandbox.destroy()` +3. Mark run as `completed` in entity stream + +#### Registration + +```typescript +import { defineEntity } from '@electric-sql/agents-runtime' +import { createSandboxEntity } from '@electric-sql/agents-runtime/sandbox' +import { DockerProvider } from '@electric-sql/agents-runtime/sandbox/docker' + +defineEntity('sandbox-claude', createSandboxEntity({ + provider: new DockerProvider(), + image: 'my-claude-agent:latest', + agentType: 'claude', + command: ['claude', '--output-format', 'stream-json'], + env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, + volumes: [{ host: '/tmp/workspaces', container: '/workspace', mode: 'rw' }], +})) +``` + +#### Usage from Parent Entity + +```typescript +const sandbox = await ctx.spawn('sandbox-claude', 'task-1', { + prompt: 'Fix the authentication bug in src/auth.ts', +}) + +// Observe for completion +await ctx.observe(sandbox.entityUrl, { + wake: { on: 'runFinished' }, +}) + +// Send follow-up prompt +ctx.send(sandbox.entityUrl, { text: 'Also add tests for the fix' }) +``` + +### Stream Bridge (In-Container Component) + +Runs inside the container as the entrypoint. Bridges the agent CLI's stdio to the durable stream. + +``` +┌─── Container ─────────────────────────────────┐ +│ │ +│ stream-bridge (entrypoint) │ +│ ├── tails stream for user_message events │ +│ │ └── pipes prompt text to agent stdin │ +│ ├── reads agent stdout (JSONL) │ +│ │ └── appends events to stream │ +│ └── reads agent stderr │ +│ └── appends error events to stream │ +│ │ +│ wraps: claude / codex / any CLI agent │ +│ │ +└───────────────────────────────────────────────┘ +``` + +**Configuration:** The bridge reads `AGENT_STREAM_URL` from its environment. The agent command is passed as arguments: + +```bash +stream-bridge claude --output-format stream-json +``` + +**Responsibilities:** +- Spawn the agent process with the given command +- Tail the durable stream via SSE for `user_message` events, pipe their `text` to agent stdin +- Read agent stdout line-by-line. If the agent outputs its native format (e.g., Claude Code JSONL), normalize to agent-session-protocol events via `normalize()` before appending to the stream. If the agent already outputs agent-session-protocol JSONL, pass through directly. +- Read agent stderr, emit as `error` events to the stream +- On agent process exit, append `session_end` event + +### Docker Provider + +First concrete `SandboxProvider` implementation. + +```typescript +import Docker from 'dockerode' + +interface DockerProviderConfig { + /** Docker socket path (default: /var/run/docker.sock) */ + socketPath?: string + /** Docker host URL (alternative to socket) */ + host?: string + /** Network to attach containers to */ + network?: string + /** Auto-pull images if not present */ + autoPull?: boolean +} + +class DockerProvider implements SandboxProvider { + readonly name = 'docker' + + constructor(config?: DockerProviderConfig) + + async create(config: SandboxConfig): Promise { + // 1. Pull image if autoPull && not present locally + // 2. Create container: + // - Env: config.env + { AGENT_STREAM_URL: config.streamUrl } + // - Binds: config.volumes as bind mounts + // - Cmd: config.command (passed to stream-bridge) + // - WorkingDir: config.workdir + // - HostConfig.Memory: config.limits.memoryMb * 1024 * 1024 + // - HostConfig.NanoCpus: config.limits.cpus * 1e9 + // - NetworkingConfig: provider network if configured + // 3. Start container + // 4. Return DockerSandboxInstance wrapping container reference + } +} +``` + +## Package Structure + +``` +packages/agents-runtime/src/sandbox/ + ├── types.ts # SandboxProvider, SandboxConfig, SandboxInstance + ├── sandbox-entity.ts # createSandboxEntity() — handler factory + ├── session-bridge.ts # SessionProtocolBridge — stream tailing + event mapping + ├── providers/ + │ └── docker.ts # DockerProvider + └── index.ts # Public exports + +packages/stream-bridge/ # Runs inside containers + ├── src/ + │ ├── cli.ts # Entrypoint: stream-bridge + │ ├── agent-io.ts # Spawn agent process, manage stdin/stdout/stderr + │ └── stream-io.ts # Durable stream read (SSE tail) / write (HTTP append) + ├── package.json + └── Dockerfile.base # Base image with bridge pre-installed +``` + +### Dependencies + +- `agent-session-protocol` — normalize/denormalize agent JSONL, event types +- `dockerode` — Docker provider (optional peer dependency, only needed if using Docker) +- `@electric-sql/client` — durable stream client (used by both runtime bridge and stream-bridge) + +### Exports + +```typescript +// @electric-sql/agents-runtime/sandbox +export { createSandboxEntity } from './sandbox-entity' +export { SessionProtocolBridge } from './session-bridge' +export type { SandboxProvider, SandboxConfig, SandboxInstance, SandboxEntityConfig } from './types' + +// @electric-sql/agents-runtime/sandbox/docker +export { DockerProvider } from './providers/docker' +export type { DockerProviderConfig } from './providers/docker' +``` + +## Base Docker Image + +Users build sandbox images on top of a base that has the stream bridge pre-installed: + +```dockerfile +FROM electric-sql/stream-bridge:latest + +# Install the coding agent +RUN npm install -g @anthropic-ai/claude-code + +# API key injected at runtime via env +ENV ANTHROPIC_API_KEY="" +``` + +The base image's entrypoint is `stream-bridge`, so the user only needs to ensure their agent CLI is available in PATH. The `command` in `SandboxEntityConfig` is passed as arguments to the bridge. + +## Testing Strategy + +- **SandboxProvider**: Mock provider for unit tests — returns a fake `SandboxInstance` without Docker. +- **SessionProtocolBridge**: Feed pre-recorded agent-session-protocol JSONL fixtures into the bridge, assert correct entity collection writes. +- **Stream bridge**: Unit test stdin/stdout/stream wiring with a mock agent process and mock durable stream. +- **Integration**: Docker provider + real container + test agent that emits known JSONL → verify end-to-end entity stream population. diff --git a/packages/agents-mcp/package.json b/packages/agents-mcp/package.json new file mode 100644 index 0000000000..2d81e9c160 --- /dev/null +++ b/packages/agents-mcp/package.json @@ -0,0 +1,54 @@ +{ + "name": "@electric-ax/agents-mcp", + "version": "0.0.1", + "description": "MCP server integration for Electric Agents — tools, resources, OAuth auth", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest run", + "test:watch": "vitest", + "coverage": "pnpm exec vitest --coverage", + "typecheck": "tsc --noEmit", + "stylecheck": "eslint . --quiet" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1" + }, + "peerDependencies": { + "@mariozechner/pi-agent-core": ">=0.57.0" + }, + "peerDependenciesMeta": { + "@mariozechner/pi-agent-core": { + "optional": false + } + }, + "devDependencies": { + "@mariozechner/pi-agent-core": "^0.57.1", + "@vitest/coverage-v8": "^4.1.0", + "tsdown": "^0.9.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" + }, + "files": [ + "dist" + ], + "sideEffects": false, + "license": "Apache-2.0" +} diff --git a/packages/agents-mcp/src/auth/oauth-provider.ts b/packages/agents-mcp/src/auth/oauth-provider.ts new file mode 100644 index 0000000000..ded7424c1c --- /dev/null +++ b/packages/agents-mcp/src/auth/oauth-provider.ts @@ -0,0 +1,243 @@ +import { createServer, type Server } from 'node:http' +import type { + OAuthClientProvider, + OAuthDiscoveryState, +} from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientMetadata, + OAuthClientInformationMixed, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import type { McpServerConfig } from '../types.js' +import type { TokenStore } from './token-store.js' + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1_000 // 5 minutes + +export interface OAuthProviderOptions { + serverName: string + serverConfig: McpServerConfig + tokenStore: TokenStore + onAuthUrl?: (url: string) => void +} + +/** + * OAuthClientProvider backed by TokenStore for persistent credential storage. + * + * When authorization is required the provider spins up a temporary HTTP server + * on a local port, prints (or calls back with) the authorization URL, and + * waits up to 5 minutes for the redirect callback carrying the auth code. + */ +export class McpOAuthProvider implements OAuthClientProvider { + private readonly serverName: string + private readonly serverConfig: McpServerConfig + private readonly tokenStore: TokenStore + private readonly onAuthUrl?: (url: string) => void + private readonly callbackPort: number + private authCodeResolve?: (code: string) => void + + constructor(opts: OAuthProviderOptions) { + this.serverName = opts.serverName + this.serverConfig = opts.serverConfig + this.tokenStore = opts.tokenStore + this.onAuthUrl = opts.onAuthUrl + this.callbackPort = opts.serverConfig.oauth?.callbackPort ?? 0 + } + + // ── redirect URL ──────────────────────────────────────────── + + get redirectUrl(): string { + // The actual port may differ (when callbackPort is 0 the OS picks a random + // port), but the SDK calls this *before* the redirect so we return a + // template that matches what we will listen on. The port is patched inside + // `redirectToAuthorization` once the server is actually listening. + const port = this.callbackPort || 9876 + return `http://127.0.0.1:${String(port)}/oauth/callback` + } + + // ── client metadata ───────────────────────────────────────── + + get clientMetadata(): OAuthClientMetadata { + const meta: OAuthClientMetadata = { + redirect_uris: [this.redirectUrl], + client_name: `electric-agents (${this.serverName})`, + grant_types: [`authorization_code`, `refresh_token`], + response_types: [`code`], + token_endpoint_auth_method: `none`, + } + + if (this.serverConfig.oauth?.scopes?.length) { + meta.scope = this.serverConfig.oauth.scopes.join(` `) + } + + return meta + } + + // ── client information (DCR) ──────────────────────────────── + + async clientInformation(): Promise { + const info = this.tokenStore.getClientInfo(this.serverName) + if (!info) return undefined + return info as unknown as OAuthClientInformationMixed + } + + async saveClientInformation( + clientInformation: OAuthClientInformationMixed + ): Promise { + this.tokenStore.saveClientInfo( + this.serverName, + clientInformation as unknown as Record + ) + } + + // ── tokens ────────────────────────────────────────────────── + + async tokens(): Promise { + const stored = this.tokenStore.getTokens(this.serverName) + if (!stored) return undefined + return { + access_token: stored.access_token, + token_type: stored.token_type, + refresh_token: stored.refresh_token, + expires_in: stored.expires_at + ? Math.max(0, Math.floor((stored.expires_at - Date.now()) / 1_000)) + : undefined, + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + this.tokenStore.saveTokens(this.serverName, { + access_token: tokens.access_token, + token_type: `Bearer`, + refresh_token: tokens.refresh_token, + expires_at: tokens.expires_in + ? Date.now() + tokens.expires_in * 1_000 + : undefined, + }) + } + + // ── code verifier (PKCE) ──────────────────────────────────── + + async saveCodeVerifier(codeVerifier: string): Promise { + this.tokenStore.saveCodeVerifier(this.serverName, codeVerifier) + } + + async codeVerifier(): Promise { + const v = this.tokenStore.getCodeVerifier(this.serverName) + if (!v) + throw new Error(`No PKCE code verifier stored for ${this.serverName}`) + return v + } + + // ── redirect to authorization ─────────────────────────────── + + async redirectToAuthorization(authorizationUrl: URL): Promise { + const code = await this.waitForAuthCode(authorizationUrl) + // The SDK drives the token exchange after we return; we just need to + // make sure it has the auth code. Unfortunately the SDK's `auth()` + // orchestrator handles the exchange itself when called with the code, + // so we store a pending code that callers can retrieve. + this.authCodeResolve?.(code) + } + + /** + * Starts a temporary HTTP server, prints the auth URL (or calls the + * `onAuthUrl` callback), and waits for the OAuth redirect to arrive. + * Returns the authorization code extracted from the callback. + */ + private waitForAuthCode(authorizationUrl: URL): Promise { + return new Promise((resolve, reject) => { + let server: Server | undefined + + const cleanup = () => { + try { + server?.close() + } catch { + // ignore + } + } + + const timeout = setTimeout(() => { + cleanup() + reject( + new Error( + `OAuth callback timed out after ${String(CALLBACK_TIMEOUT_MS / 1_000)}s` + ) + ) + }, CALLBACK_TIMEOUT_MS) + + server = createServer((req, res) => { + const url = new URL(req.url ?? `/`, `http://127.0.0.1`) + if (!url.pathname.endsWith(`/oauth/callback`)) { + res.writeHead(404) + res.end(`Not found`) + return + } + + const code = url.searchParams.get(`code`) + const error = url.searchParams.get(`error`) + + if (error) { + const desc = url.searchParams.get(`error_description`) ?? error + res.writeHead(200, { 'Content-Type': `text/html` }) + res.end( + `

Authorization failed

${desc}

` + ) + clearTimeout(timeout) + cleanup() + reject(new Error(`OAuth authorization failed: ${desc}`)) + return + } + + if (!code) { + res.writeHead(400) + res.end(`Missing authorization code`) + return + } + + res.writeHead(200, { 'Content-Type': `text/html` }) + res.end( + `

Authorization successful

You can close this tab.

` + ) + clearTimeout(timeout) + cleanup() + resolve(code) + }) + + server.listen(this.callbackPort, `127.0.0.1`, () => { + const addr = server!.address() + if (typeof addr === `object` && addr) { + // Patch the actual redirect URL into the authorization URL in case + // the port was auto-assigned (callbackPort === 0). + const actualPort = addr.port + const actualRedirect = `http://127.0.0.1:${String(actualPort)}/oauth/callback` + authorizationUrl.searchParams.set(`redirect_uri`, actualRedirect) + } + + const urlStr = authorizationUrl.toString() + if (this.onAuthUrl) { + this.onAuthUrl(urlStr) + } else { + console.log( + `\nOpen this URL to authorize MCP server "${this.serverName}":\n${urlStr}\n` + ) + } + }) + + server.on(`error`, (err) => { + clearTimeout(timeout) + reject(err) + }) + }) + } + + // ── discovery state (optional persistence) ────────────────── + + async saveDiscoveryState(_state: OAuthDiscoveryState): Promise { + // Discovery state is not persisted in this implementation; the SDK + // will re-discover on each new session. + } + + async discoveryState(): Promise { + return undefined + } +} diff --git a/packages/agents-mcp/src/auth/token-store.ts b/packages/agents-mcp/src/auth/token-store.ts new file mode 100644 index 0000000000..8e95e094b9 --- /dev/null +++ b/packages/agents-mcp/src/auth/token-store.ts @@ -0,0 +1,81 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +const CONFIG_DIR = `.electric-agents` +const AUTH_FILE = `mcp-auth.json` + +export interface StoredTokens { + access_token: string + refresh_token?: string + expires_at?: number + token_type: `Bearer` +} + +interface AuthData { + tokens?: Record + verifiers?: Record + clients?: Record> +} + +export class TokenStore { + private readonly authPath: string + private readonly configDir: string + + constructor(workingDirectory: string) { + this.configDir = join(workingDirectory, CONFIG_DIR) + this.authPath = join(this.configDir, AUTH_FILE) + } + + getTokens(serverName: string): StoredTokens | undefined { + return this.readAll().tokens?.[serverName] + } + + saveTokens(serverName: string, tokens: StoredTokens): void { + const data = this.readAll() + data.tokens ??= {} + data.tokens[serverName] = tokens + this.writeAll(data) + } + + removeTokens(serverName: string): void { + const data = this.readAll() + if (data.tokens) { + delete data.tokens[serverName] + this.writeAll(data) + } + } + + getCodeVerifier(serverName: string): string | undefined { + return this.readAll().verifiers?.[serverName] + } + + saveCodeVerifier(serverName: string, verifier: string): void { + const data = this.readAll() + data.verifiers ??= {} + data.verifiers[serverName] = verifier + this.writeAll(data) + } + + getClientInfo(serverName: string): Record | undefined { + return this.readAll().clients?.[serverName] + } + + saveClientInfo(serverName: string, info: Record): void { + const data = this.readAll() + data.clients ??= {} + data.clients[serverName] = info + this.writeAll(data) + } + + private readAll(): AuthData { + if (!existsSync(this.authPath)) return {} + return JSON.parse(readFileSync(this.authPath, `utf-8`)) as AuthData + } + + private writeAll(data: AuthData): void { + mkdirSync(this.configDir, { recursive: true }) + writeFileSync(this.authPath, JSON.stringify(data, null, 2) + `\n`, { + mode: 0o600, + }) + } +} diff --git a/packages/agents-mcp/src/bridge/resource-bridge.ts b/packages/agents-mcp/src/bridge/resource-bridge.ts new file mode 100644 index 0000000000..ecc8d5cce9 --- /dev/null +++ b/packages/agents-mcp/src/bridge/resource-bridge.ts @@ -0,0 +1,85 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { McpClientPool } from '../pool.js' + +export function createResourceTools(pool: McpClientPool): AgentTool[] { + return [createListResourcesTool(pool), createReadResourceTool(pool)] +} + +function createListResourcesTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__list_resources`, + label: `List MCP Resources`, + description: `List all available resources from connected MCP servers. Returns resource URIs, names, and descriptions.`, + parameters: { + type: `object`, + properties: { + server: { + type: `string`, + description: `Filter by server name (optional)`, + }, + }, + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const filter = (params as { server?: string }).server + const servers = pool.getEnabledServers() + const lines: string[] = [] + + for (const { name } of servers) { + if (filter && name !== filter) continue + try { + const client = await pool.acquire(name) + const resources = await client.listResources() + pool.release(name) + + if (resources.length === 0) continue + + lines.push(`## ${name}`) + for (const r of resources) { + lines.push( + `- **${r.name}** (\`${r.uri}\`)${r.description ? `: ${r.description}` : ``}` + ) + } + lines.push(``) + } catch { + // Server unavailable + } + } + + const text = + lines.length > 0 ? lines.join(`\n`) : `No resources available.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createReadResourceTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__read_resource`, + label: `Read MCP Resource`, + description: `Read a specific resource from a connected MCP server by server name and resource URI.`, + parameters: { + type: `object`, + properties: { + server: { type: `string`, description: `MCP server name` }, + uri: { type: `string`, description: `Resource URI` }, + }, + required: [`server`, `uri`], + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { server, uri } = params as { server: string; uri: string } + const client = await pool.acquire(server) + try { + const contents = await client.readResource(uri) + const text = contents + .map((c) => c.text ?? `[binary: ${c.mimeType ?? `unknown`}]`) + .join(`\n`) + return { + content: [{ type: `text`, text: text || `(empty resource)` }], + details: {}, + } + } finally { + pool.release(server) + } + }, + } +} diff --git a/packages/agents-mcp/src/bridge/tool-bridge.ts b/packages/agents-mcp/src/bridge/tool-bridge.ts new file mode 100644 index 0000000000..37d01fe1be --- /dev/null +++ b/packages/agents-mcp/src/bridge/tool-bridge.ts @@ -0,0 +1,111 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { McpClientPool } from '../pool.js' +import type { McpDiscoveredTool, McpServerConfig } from '../types.js' +import { MCP_DEFAULTS } from '../types.js' + +export function bridgeMcpTools( + serverName: string, + tools: McpDiscoveredTool[], + pool: McpClientPool, + config: McpServerConfig +): AgentTool[] { + return tools.map((mcpTool) => + bridgeSingleTool(serverName, mcpTool, pool, config) + ) +} + +function bridgeSingleTool( + serverName: string, + mcpTool: McpDiscoveredTool, + pool: McpClientPool, + config: McpServerConfig +): AgentTool { + const maxOutput = config.maxOutputChars ?? MCP_DEFAULTS.maxOutputChars + + return { + name: `mcp__${serverName}__${mcpTool.name}`, + label: mcpTool.name, + description: mcpTool.description ?? ``, + parameters: mcpTool.inputSchema as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const client = await pool.acquire(serverName) + try { + const result = await client.callTool( + mcpTool.name, + params as Record + ) + const output = formatMcpResult(result) + return truncateOutput(output, maxOutput) + } finally { + pool.release(serverName) + } + }, + } +} + +interface TextBlock { + type: `text` + text: string +} + +interface ToolOutput { + content: TextBlock[] + details: Record +} + +function formatMcpResult(result: { + content: Array<{ + type: string + text?: string + data?: string + mimeType?: string + }> + isError?: boolean +}): ToolOutput { + const content: TextBlock[] = result.content.map((block) => { + if (block.type === `text` && block.text !== undefined) { + return { type: `text` as const, text: block.text } + } + if (block.type === `image`) { + return { + type: `text` as const, + text: `[Image: ${block.mimeType ?? `unknown`}]`, + } + } + return { type: `text` as const, text: JSON.stringify(block) } + }) + + return { + content: + content.length > 0 + ? content + : [{ type: `text` as const, text: `(no output)` }], + details: { isError: result.isError ?? false }, + } +} + +export function truncateOutput( + output: ToolOutput, + maxChars: number +): ToolOutput { + let totalChars = 0 + for (const block of output.content) { + totalChars += block.text.length + } + + if (totalChars <= maxChars) return output + + const truncated: TextBlock[] = output.content.map((block) => { + if (block.text.length > maxChars) { + return { + type: `text` as const, + text: + block.text.slice(0, maxChars) + + `\n\n[Output truncated at ${maxChars} chars. Original size: ${block.text.length} chars]`, + } + } + return block + }) + + return { content: truncated, details: { ...output.details, truncated: true } } +} diff --git a/packages/agents-mcp/src/client.ts b/packages/agents-mcp/src/client.ts new file mode 100644 index 0000000000..40ae4b4e84 --- /dev/null +++ b/packages/agents-mcp/src/client.ts @@ -0,0 +1,352 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { + McpServerConfig, + McpDiscoveredTool, + McpDiscoveredResource, +} from './types.js' +import { MCP_DEFAULTS } from './types.js' +import { TokenStore } from './auth/token-store.js' +import { McpOAuthProvider } from './auth/oauth-provider.js' +import { expandConfigValues } from './config/env-expand.js' + +// ── Public types ────────────────────────────────────────────── + +export interface McpClientOptions { + serverName: string + config: McpServerConfig + tokenStore: TokenStore + workingDirectory: string + onAuthUrl?: (url: string) => void + onToolsChanged?: (tools: McpDiscoveredTool[]) => void + onResourcesChanged?: (resources: McpDiscoveredResource[]) => void +} + +// ── McpClient ───────────────────────────────────────────────── + +export class McpClient { + private readonly serverName: string + private readonly config: McpServerConfig + private readonly tokenStore: TokenStore + private readonly workingDirectory: string + private readonly onAuthUrl?: (url: string) => void + private readonly onToolsChanged?: (tools: McpDiscoveredTool[]) => void + private readonly onResourcesChanged?: ( + resources: McpDiscoveredResource[] + ) => void + + private client?: Client + private transport?: Transport + + private _tools: McpDiscoveredTool[] = [] + private _resources: McpDiscoveredResource[] = [] + private _sessionId?: string + private _protocolVersion?: string + private _instructions?: string + + constructor(opts: McpClientOptions) { + this.serverName = opts.serverName + this.config = opts.config + this.tokenStore = opts.tokenStore + this.workingDirectory = opts.workingDirectory + this.onAuthUrl = opts.onAuthUrl + this.onToolsChanged = opts.onToolsChanged + this.onResourcesChanged = opts.onResourcesChanged + } + + // ── Accessors ─────────────────────────────────────────────── + + get tools(): McpDiscoveredTool[] { + return this._tools + } + + get resources(): McpDiscoveredResource[] { + return this._resources + } + + get sessionId(): string | undefined { + return this._sessionId + } + + get protocolVersion(): string | undefined { + return this._protocolVersion + } + + get instructions(): string | undefined { + return this._instructions + } + + // ── Connect ───────────────────────────────────────────────── + + async connect(): Promise { + const timeoutMs = + this.config.startupTimeoutMs ?? MCP_DEFAULTS.startupTimeoutMs + + this.transport = this.createTransport() + + this.client = new Client( + { name: `electric-agents`, version: `0.0.1` }, + { + capabilities: {}, + listChanged: { + tools: { + onChanged: (_err, items) => { + if (items) { + this._tools = items.map(mapTool) + this.onToolsChanged?.(this._tools) + } + }, + }, + resources: { + onChanged: (_err, items) => { + if (items) { + this._resources = items.map(mapResource) + this.onResourcesChanged?.(this._resources) + } + }, + }, + }, + } + ) + + await this.client.connect(this.transport, { + timeout: timeoutMs, + }) + + // Capture session metadata + const serverVersion = this.client.getServerVersion() + this._protocolVersion = serverVersion?.version + this._instructions = this.client.getInstructions() + + // Capture sessionId from the transport (StreamableHTTP only) + if (this.transport instanceof StreamableHTTPClientTransport) { + this._sessionId = this.transport.sessionId + } + + // Initial discovery + await this.discoverCapabilities() + } + + // ── Discovery ─────────────────────────────────────────────── + + private async discoverCapabilities(): Promise { + const caps = this.client?.getServerCapabilities() + + if (caps?.tools) { + const result = await this.client!.listTools() + this._tools = result.tools.map(mapTool) + } + + if (caps?.resources) { + const result = await this.client!.listResources() + this._resources = result.resources.map(mapResource) + } + } + + // ── Tool calling ──────────────────────────────────────────── + + async callTool( + name: string, + args?: Record + ): Promise<{ + content: Array<{ type: string; text?: string; [key: string]: unknown }> + isError?: boolean + }> { + if (!this.client) { + throw new Error(`Client not connected to MCP server "${this.serverName}"`) + } + + const timeoutMs = this.config.toolTimeoutMs ?? MCP_DEFAULTS.toolTimeoutMs + + const result = await this.client.callTool( + { name, arguments: args }, + undefined, + { timeout: timeoutMs } + ) + + return result as { + content: Array<{ type: string; text?: string; [key: string]: unknown }> + isError?: boolean + } + } + + // ── Resources ─────────────────────────────────────────────── + + async listResources(): Promise { + if (!this.client) { + throw new Error(`Client not connected to MCP server "${this.serverName}"`) + } + const result = await this.client.listResources() + this._resources = result.resources.map(mapResource) + return this._resources + } + + async readResource(uri: string): Promise< + Array<{ + uri: string + text?: string + blob?: string + mimeType?: string + }> + > { + if (!this.client) { + throw new Error(`Client not connected to MCP server "${this.serverName}"`) + } + const result = await this.client.readResource({ uri }) + return result.contents as Array<{ + uri: string + text?: string + blob?: string + mimeType?: string + }> + } + + // ── Close ─────────────────────────────────────────────────── + + async close(): Promise { + try { + await this.client?.close() + } catch { + // ignore errors during close + } + this.client = undefined + this.transport = undefined + this._tools = [] + this._resources = [] + this._sessionId = undefined + } + + // ── Transport creation ────────────────────────────────────── + + private createTransport(): Transport { + if (this.config.command) { + return this.createStdioTransport() + } + if (this.config.url) { + return this.createHttpTransport() + } + throw new Error( + `MCP server "${this.serverName}" must specify either "command" (stdio) or "url" (HTTP)` + ) + } + + private createStdioTransport(): StdioClientTransport { + const expanded = expandConfigValues( + { + command: this.config.command!, + args: this.config.args, + env: this.config.env, + }, + process.env as Record + ) + + return new StdioClientTransport({ + command: expanded.command, + args: expanded.args, + env: { + ...process.env, + ...expanded.env, + } as Record, + cwd: this.config.cwd ?? this.workingDirectory, + }) + } + + private createHttpTransport(): StreamableHTTPClientTransport { + const url = new URL(this.config.url!) + + const opts: { + authProvider?: McpOAuthProvider + requestInit?: RequestInit + sessionId?: string + } = {} + + // Restore previous sessionId for reconnection + if (this._sessionId) { + opts.sessionId = this._sessionId + } + + // ── Auth wiring ─────────────────────────────────────────── + + if (this.config.auth === `oauth`) { + opts.authProvider = new McpOAuthProvider({ + serverName: this.serverName, + serverConfig: this.config, + tokenStore: this.tokenStore, + onAuthUrl: this.onAuthUrl, + }) + } else { + // Build static headers for Bearer token or custom headers + const headers = this.buildStaticHeaders() + if (Object.keys(headers).length > 0) { + opts.requestInit = { headers } + } + } + + return new StreamableHTTPClientTransport(url, opts) + } + + private buildStaticHeaders(): Record { + const headers: Record = {} + + // Static token auth + if (this.config.auth && typeof this.config.auth === `object`) { + let token: string | undefined + + if (`token` in this.config.auth) { + token = this.config.auth.token + } else if (`tokenEnvVar` in this.config.auth) { + token = process.env[this.config.auth.tokenEnvVar] + if (!token) { + throw new Error( + `Environment variable "${this.config.auth.tokenEnvVar}" is not set for MCP server "${this.serverName}"` + ) + } + } + + if (token) { + headers[`Authorization`] = `Bearer ${token}` + } + } + + // Custom headers with env var expansion + if (this.config.headers) { + const expanded = expandConfigValues( + this.config.headers, + process.env as Record + ) + Object.assign(headers, expanded) + } + + return headers + } +} + +// ── Helpers ─────────────────────────────────────────────────── + +function mapTool(tool: { + name: string + description?: string + inputSchema: Record +}): McpDiscoveredTool { + return { + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema as Record, + } +} + +function mapResource(resource: { + uri: string + name: string + description?: string + mimeType?: string +}): McpDiscoveredResource { + return { + uri: resource.uri, + name: resource.name, + description: resource.description, + mimeType: resource.mimeType, + } +} diff --git a/packages/agents-mcp/src/config/config-store.ts b/packages/agents-mcp/src/config/config-store.ts new file mode 100644 index 0000000000..818cf265e2 --- /dev/null +++ b/packages/agents-mcp/src/config/config-store.ts @@ -0,0 +1,67 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expandConfigValues } from './env-expand' +import type { McpConfig, McpServerConfig } from '../types' + +const CONFIG_DIR = `.electric-agents` +const CONFIG_FILE = `mcp.json` +const GITIGNORE_FILE = `.gitignore` +const GITIGNORE_CONTENT = `mcp-auth.json\n` + +const EMPTY_CONFIG: McpConfig = { servers: {} } + +export class ConfigStore { + private readonly configDir: string + private readonly configPath: string + + constructor(private readonly workingDirectory: string) { + this.configDir = join(workingDirectory, CONFIG_DIR) + this.configPath = join(this.configDir, CONFIG_FILE) + } + + load(opts?: { expandEnv?: boolean }): McpConfig { + if (!existsSync(this.configPath)) return { ...EMPTY_CONFIG } + + const raw = readFileSync(this.configPath, `utf-8`) + const parsed = JSON.parse(raw) as McpConfig + + if (!parsed.servers) return { ...EMPTY_CONFIG } + + if (opts?.expandEnv) { + return expandConfigValues(parsed, process.env as Record) + } + return parsed + } + + save(config: McpConfig): void { + mkdirSync(this.configDir, { recursive: true }) + writeFileSync(this.configPath, JSON.stringify(config, null, 2) + `\n`) + this.ensureGitignore() + } + + addServer(name: string, serverConfig: McpServerConfig): void { + const config = this.load() + config.servers[name] = serverConfig + this.save(config) + } + + removeServer(name: string): boolean { + const config = this.load() + if (!(name in config.servers)) return false + delete config.servers[name] + this.save(config) + return true + } + + private ensureGitignore(): void { + const gitignorePath = join(this.configDir, GITIGNORE_FILE) + if (existsSync(gitignorePath)) { + const content = readFileSync(gitignorePath, `utf-8`) + if (!content.includes(`mcp-auth.json`)) { + writeFileSync(gitignorePath, content + GITIGNORE_CONTENT) + } + return + } + writeFileSync(gitignorePath, GITIGNORE_CONTENT) + } +} diff --git a/packages/agents-mcp/src/config/config-tools.ts b/packages/agents-mcp/src/config/config-tools.ts new file mode 100644 index 0000000000..342f2e3928 --- /dev/null +++ b/packages/agents-mcp/src/config/config-tools.ts @@ -0,0 +1,195 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' +import type { ConfigStore } from './config-store.js' +import type { McpClientPool } from '../pool.js' +import type { McpServerConfig } from '../types.js' + +export function createConfigTools( + configStore: ConfigStore, + pool: McpClientPool +): AgentTool[] { + return [ + createAddServerTool(configStore, pool), + createRemoveServerTool(configStore, pool), + createListServersTool(pool), + createListToolsTool(pool), + ] +} + +function createAddServerTool( + configStore: ConfigStore, + pool: McpClientPool +): AgentTool { + return { + name: `mcp__manage__add_server`, + label: `Add MCP Server`, + description: `Add a new MCP server to the configuration and attempt to connect to it.`, + parameters: { + type: `object`, + properties: { + name: { + type: `string`, + description: `Unique name for the server`, + }, + command: { + type: `string`, + description: `Command to run (for stdio transport)`, + }, + args: { + type: `array`, + items: { type: `string` }, + description: `Arguments for the command`, + }, + url: { + type: `string`, + description: `URL for streamable HTTP transport`, + }, + env: { + type: `object`, + additionalProperties: { type: `string` }, + description: `Environment variables for the server process`, + }, + }, + required: [`name`], + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { name, ...rest } = params as { name: string } & McpServerConfig + + const serverConfig: McpServerConfig = rest + + configStore.addServer(name, serverConfig) + pool.addServer(name, serverConfig) + + let connectionNote = `` + try { + await pool.acquire(name) + pool.release(name) + connectionNote = ` and connected successfully` + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + connectionNote = ` (connection failed: ${msg})` + } + + const text = `Added server "${name}"${connectionNote}.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createRemoveServerTool( + configStore: ConfigStore, + pool: McpClientPool +): AgentTool { + return { + name: `mcp__manage__remove_server`, + label: `Remove MCP Server`, + description: `Remove an MCP server from the configuration and disconnect it.`, + parameters: { + type: `object`, + properties: { + name: { + type: `string`, + description: `Name of the server to remove`, + }, + }, + required: [`name`], + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const { name } = params as { name: string } + + await pool.removeServer(name) + configStore.removeServer(name) + + const text = `Removed server "${name}".` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createListServersTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__list_servers`, + label: `List MCP Servers`, + description: `List all configured MCP servers with their current connection status and available tools.`, + parameters: { + type: `object`, + properties: {}, + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, _params) => { + const states = pool.getServerStates() + + if (states.length === 0) { + return { + content: [{ type: `text`, text: `No MCP servers configured.` }], + details: {}, + } + } + + const lines: string[] = [] + for (const state of states) { + lines.push(`## ${state.name}`) + lines.push(`Status: ${state.status}`) + if (state.error) { + lines.push(`Error: ${state.error}`) + } + if (state.tools.length > 0) { + lines.push(`Tools: ${state.tools.map((t) => t.name).join(`, `)}`) + } else { + lines.push(`Tools: none`) + } + if (state.instructions) { + lines.push(`Instructions: ${state.instructions}`) + } + lines.push(``) + } + + const text = lines.join(`\n`).trim() + return { content: [{ type: `text`, text }], details: {} } + }, + } +} + +function createListToolsTool(pool: McpClientPool): AgentTool { + return { + name: `mcp__manage__list_tools`, + label: `List MCP Tools`, + description: `List all tools available from connected MCP servers.`, + parameters: { + type: `object`, + properties: { + server: { + type: `string`, + description: `Filter by server name (optional)`, + }, + }, + } as unknown as AgentTool[`parameters`], + execute: async (_toolCallId, params) => { + const filter = (params as { server?: string }).server + const servers = pool.getEnabledServers() + const lines: string[] = [] + + for (const { name } of servers) { + if (filter && name !== filter) continue + try { + const client = await pool.acquire(name) + const tools = client.tools + pool.release(name) + + if (tools.length === 0) continue + + lines.push(`## ${name}`) + for (const tool of tools) { + const desc = tool.description ? `: ${tool.description}` : `` + lines.push(`- **${tool.name}**${desc}`) + } + lines.push(``) + } catch { + // Server unavailable, skip + } + } + + const text = + lines.length > 0 ? lines.join(`\n`).trim() : `No tools available.` + return { content: [{ type: `text`, text }], details: {} } + }, + } +} diff --git a/packages/agents-mcp/src/config/env-expand.ts b/packages/agents-mcp/src/config/env-expand.ts new file mode 100644 index 0000000000..636a3ef6a6 --- /dev/null +++ b/packages/agents-mcp/src/config/env-expand.ts @@ -0,0 +1,36 @@ +export function expandEnvVars( + template: string, + env: Record +): string { + return template.replace( + /\$\{([^}:]+?)(?::-(.*?))?\}/g, + (_match, name: string, defaultValue?: string) => { + const value = env[name] + if (value !== undefined) return value + if (defaultValue !== undefined) return defaultValue + throw new Error( + `Environment variable \${${name}} is required but not set` + ) + } + ) +} + +export function expandConfigValues( + obj: T, + env: Record +): T { + if (typeof obj === `string`) { + return expandEnvVars(obj, env) as T + } + if (Array.isArray(obj)) { + return obj.map((item) => expandConfigValues(item, env)) as T + } + if (obj !== null && typeof obj === `object`) { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = expandConfigValues(value, env) + } + return result as T + } + return obj +} diff --git a/packages/agents-mcp/src/index.ts b/packages/agents-mcp/src/index.ts new file mode 100644 index 0000000000..376711fae3 --- /dev/null +++ b/packages/agents-mcp/src/index.ts @@ -0,0 +1,26 @@ +// Types +export type { + McpServerConfig, + McpConfig, + McpOverrides, + McpIntegration, + McpServerStatus, + McpServerState, + McpDiscoveredTool, + McpDiscoveredResource, +} from './types' + +export { MCP_DEFAULTS } from './types' + +// Main entry point +export { createMcpIntegration } from './integration' + +// Sub-modules (for advanced usage) +export { McpClientPool } from './pool' +export { McpClient } from './client' +export { ConfigStore } from './config/config-store' +export { TokenStore } from './auth/token-store' +export { bridgeMcpTools } from './bridge/tool-bridge' +export { createResourceTools } from './bridge/resource-bridge' +export { createConfigTools } from './config/config-tools' +export { expandEnvVars, expandConfigValues } from './config/env-expand' diff --git a/packages/agents-mcp/src/integration.ts b/packages/agents-mcp/src/integration.ts new file mode 100644 index 0000000000..c46e392b4d --- /dev/null +++ b/packages/agents-mcp/src/integration.ts @@ -0,0 +1,56 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' +import { ConfigStore } from './config/config-store.js' +import { McpClientPool } from './pool.js' +import { createConfigTools } from './config/config-tools.js' +import type { McpIntegration, McpOverrides } from './types.js' + +export function createMcpIntegration(opts: { + workingDirectory: string + onAuthUrl?: (serverName: string, url: string) => void +}): McpIntegration { + const configStore = new ConfigStore(opts.workingDirectory) + const config = configStore.load({ expandEnv: true }) + + const pool = new McpClientPool(config, { + workingDirectory: opts.workingDirectory, + onAuthUrl: opts.onAuthUrl, + }) + + const configTools = createConfigTools(configStore, pool) + + return { + configTools, + + async getTools(overrides?: McpOverrides): Promise { + return pool.getTools(overrides) + }, + + getServerInstructions(): Record { + return pool.getInstructions() + }, + + async getServerSummary(): Promise { + const states = pool.getServerStates() + const connected = states.filter((s) => s.status === `connected`) + if (connected.length === 0) return `` + + const sections = connected.map((s) => { + const toolNames = s.tools + .map((t) => `mcp__${s.name}__${t.name}`) + .join(`, `) + const header = `## ${s.name}` + const instructions = s.instructions + ? `Instructions: ${s.instructions}\n` + : `` + const tools = s.tools.length > 0 ? `Tools: ${toolNames}` : `No tools` + return `${header}\n${instructions}${tools}` + }) + + return `# MCP Servers\nThe following external tool servers are connected:\n\n${sections.join(`\n\n`)}\n\nUse mcp__list_resources to discover available resources from these servers.` + }, + + async close(): Promise { + await pool.close() + }, + } +} diff --git a/packages/agents-mcp/src/pool.ts b/packages/agents-mcp/src/pool.ts new file mode 100644 index 0000000000..bc2afd7b61 --- /dev/null +++ b/packages/agents-mcp/src/pool.ts @@ -0,0 +1,260 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' +import { McpClient } from './client.js' +import type { + McpConfig, + McpOverrides, + McpServerConfig, + McpServerState, + McpServerStatus, +} from './types.js' +import { MCP_DEFAULTS } from './types.js' +import { TokenStore } from './auth/token-store.js' +import { bridgeMcpTools } from './bridge/tool-bridge.js' +import { createResourceTools } from './bridge/resource-bridge.js' + +// ── Pool entry ───────────────────────────────────────────── + +interface PoolEntry { + config: McpServerConfig + client?: McpClient + status: McpServerStatus + idleTimer?: ReturnType + error?: string +} + +// ── Options ──────────────────────────────────────────────── + +export interface McpClientPoolOptions { + workingDirectory: string + onAuthUrl?: (serverName: string, url: string) => void +} + +// ── McpClientPool ────────────────────────────────────────── + +export class McpClientPool { + private readonly entries: Map = new Map() + private readonly tokenStore: TokenStore + private readonly workingDirectory: string + private readonly onAuthUrl?: (serverName: string, url: string) => void + + constructor(config: McpConfig, options: McpClientPoolOptions) { + this.workingDirectory = options.workingDirectory + this.onAuthUrl = options.onAuthUrl + this.tokenStore = new TokenStore(this.workingDirectory) + + for (const [name, serverConfig] of Object.entries(config.servers)) { + this.entries.set(name, { + config: serverConfig, + status: `idle`, + }) + } + } + + // ── Acquire / Release ────────────────────────────────────── + + async acquire(serverName: string): Promise { + const entry = this.entries.get(serverName) + if (!entry) { + throw new Error(`Unknown MCP server: "${serverName}"`) + } + if (entry.config.enabled === false) { + throw new Error(`MCP server "${serverName}" is disabled`) + } + + // Clear idle timer + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + + // Return existing connected client + if (entry.client && entry.status === `connected`) { + return entry.client + } + + // Create and connect + entry.status = `connecting` + try { + const client = new McpClient({ + serverName, + config: entry.config, + tokenStore: this.tokenStore, + workingDirectory: this.workingDirectory, + onAuthUrl: this.onAuthUrl + ? (url: string) => this.onAuthUrl!(serverName, url) + : undefined, + }) + + await client.connect() + + entry.client = client + entry.status = `connected` + return client + } catch (err) { + entry.status = `failed` + entry.error = err instanceof Error ? err.message : String(err) + throw err + } + } + + release(serverName: string): void { + const entry = this.entries.get(serverName) + if (!entry || !entry.client) return + + const idleTimeout = entry.config.idleTimeoutMs ?? MCP_DEFAULTS.idleTimeoutMs + + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + } + + entry.idleTimer = setTimeout(async () => { + await this.disconnectEntry(serverName, entry) + }, idleTimeout) + } + + // ── Tools ────────────────────────────────────────────────── + + async getTools(overrides?: McpOverrides): Promise { + const allTools: AgentTool[] = [] + const effectiveServers = this.resolveOverrides(overrides) + + for (const { name, config } of effectiveServers) { + try { + const client = await this.acquire(name) + const tools = bridgeMcpTools(name, client.tools, this, config) + allTools.push(...tools) + this.release(name) + } catch { + // Server unavailable, skip its tools + } + } + + // Add resource tools + allTools.push(...createResourceTools(this)) + + return allTools + } + + // ── Server info ──────────────────────────────────────────── + + getEnabledServers(): Array<{ name: string; config: McpServerConfig }> { + const result: Array<{ name: string; config: McpServerConfig }> = [] + for (const [name, entry] of this.entries) { + if (entry.config.enabled !== false) { + result.push({ name, config: entry.config }) + } + } + return result + } + + getServerStatus(serverName: string): McpServerStatus { + const entry = this.entries.get(serverName) + return entry?.status ?? `idle` + } + + getServerStates(): McpServerState[] { + const states: McpServerState[] = [] + for (const [name, entry] of this.entries) { + states.push({ + name, + config: entry.config, + status: entry.status, + tools: entry.client?.tools ?? [], + resources: entry.client?.resources ?? [], + instructions: entry.client?.instructions, + error: entry.error, + sessionId: entry.client?.sessionId, + protocolVersion: entry.client?.protocolVersion, + }) + } + return states + } + + getInstructions(): Record { + const instructions: Record = {} + for (const [name, entry] of this.entries) { + if (entry.client?.instructions) { + instructions[name] = entry.client.instructions + } + } + return instructions + } + + // ── Dynamic config ───────────────────────────────────────── + + addServer(name: string, config: McpServerConfig): void { + this.entries.set(name, { + config, + status: `idle`, + }) + } + + async removeServer(name: string): Promise { + const entry = this.entries.get(name) + if (entry) { + await this.disconnectEntry(name, entry) + this.entries.delete(name) + } + } + + // ── Close ────────────────────────────────────────────────── + + async close(): Promise { + const promises: Promise[] = [] + for (const [name, entry] of this.entries) { + promises.push(this.disconnectEntry(name, entry)) + } + await Promise.all(promises) + } + + // ── Private helpers ──────────────────────────────────────── + + private async disconnectEntry(name: string, entry: PoolEntry): Promise { + if (entry.idleTimer) { + clearTimeout(entry.idleTimer) + entry.idleTimer = undefined + } + if (entry.client) { + try { + await entry.client.close() + } catch { + // ignore errors during close + } + entry.client = undefined + } + entry.status = `idle` + entry.error = undefined + } + + private resolveOverrides( + overrides?: McpOverrides + ): Array<{ name: string; config: McpServerConfig }> { + const result: Array<{ name: string; config: McpServerConfig }> = [] + + // Start with enabled servers from base config + for (const [name, entry] of this.entries) { + if (overrides && name in overrides) { + const override = overrides[name] + if (override === false) continue // Disabled by override + // Merge override config + result.push({ name, config: { ...entry.config, ...override } }) + } else if (entry.config.enabled !== false) { + result.push({ name, config: entry.config }) + } + } + + // Add new servers from overrides that don't exist in base config + if (overrides) { + for (const [name, override] of Object.entries(overrides)) { + if (override === false) continue + if (!this.entries.has(name)) { + // Dynamically add the server + this.addServer(name, override) + result.push({ name, config: override }) + } + } + } + + return result + } +} diff --git a/packages/agents-mcp/src/types.ts b/packages/agents-mcp/src/types.ts new file mode 100644 index 0000000000..3b3709ac03 --- /dev/null +++ b/packages/agents-mcp/src/types.ts @@ -0,0 +1,96 @@ +import type { AgentTool } from '@mariozechner/pi-agent-core' + +// ── Config ────────────────────────────────────────────────── + +export interface McpServerConfig { + /** Stdio transport */ + command?: string + args?: string[] + env?: Record + cwd?: string + + /** Streamable HTTP transport */ + url?: string + + /** Auth: OAuth flow, static token, or env var reference */ + auth?: `oauth` | { token: string } | { tokenEnvVar: string } + /** Arbitrary static headers (values support ${VAR} expansion) */ + headers?: Record + + /** OAuth specifics (only when auth is 'oauth') */ + oauth?: { + clientId?: string + scopes?: string[] + callbackPort?: number + } + + /** Toggle without removing config (default: true) */ + enabled?: boolean + /** Connection timeout in ms (default: 10_000) */ + startupTimeoutMs?: number + /** Per-tool-call timeout in ms (default: 60_000) */ + toolTimeoutMs?: number + /** Idle time before disconnect in ms (default: 300_000) */ + idleTimeoutMs?: number + /** Max chars in tool output before truncation (default: 25_000) */ + maxOutputChars?: number +} + +export interface McpConfig { + servers: Record +} + +export type McpOverrides = Record + +// ── Defaults ──────────────────────────────────────────────── + +export const MCP_DEFAULTS = { + startupTimeoutMs: 10_000, + toolTimeoutMs: 60_000, + idleTimeoutMs: 300_000, + maxOutputChars: 25_000, +} as const + +// ── Pool ──────────────────────────────────────────────────── + +export type McpServerStatus = `idle` | `connecting` | `connected` | `failed` + +export interface McpServerState { + name: string + config: McpServerConfig + status: McpServerStatus + tools: Array + resources: Array + instructions?: string + error?: string + sessionId?: string + protocolVersion?: string +} + +export interface McpDiscoveredTool { + name: string + description?: string + inputSchema: Record +} + +export interface McpDiscoveredResource { + uri: string + name: string + description?: string + mimeType?: string +} + +// ── Integration ───────────────────────────────────────────── + +export interface McpIntegration { + /** Tools for managing MCP config (for Horton) */ + configTools: Array + /** Get all bridged MCP tools, applying overrides */ + getTools: (overrides?: McpOverrides) => Promise> + /** Get server instructions for system prompt injection */ + getServerInstructions: () => Record + /** Get server summaries for system prompt */ + getServerSummary: () => Promise + /** Shut down all connections */ + close: () => Promise +} diff --git a/packages/agents-mcp/test/config-store.test.ts b/packages/agents-mcp/test/config-store.test.ts new file mode 100644 index 0000000000..7eb23bccfe --- /dev/null +++ b/packages/agents-mcp/test/config-store.test.ts @@ -0,0 +1,89 @@ +import { + existsSync, + mkdirSync, + rmSync, + writeFileSync, + readFileSync, +} from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ConfigStore } from '../src/config/config-store' + +describe(`ConfigStore`, () => { + let workDir: string + let store: ConfigStore + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-test-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + store = new ConfigStore(workDir) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`returns empty config when no file exists`, () => { + const config = store.load() + expect(config.servers).toEqual({}) + }) + + it(`reads config from .electric-agents/mcp.json`, () => { + const dir = join(workDir, `.electric-agents`) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, `mcp.json`), + JSON.stringify({ + servers: { + github: { command: `npx`, args: [`-y`, `@mcp/server-github`] }, + }, + }) + ) + const config = store.load() + expect(config.servers.github).toBeDefined() + expect(config.servers.github!.command).toBe(`npx`) + }) + + it(`saves config and creates .gitignore`, () => { + store.save({ + servers: { test: { command: `echo`, args: [`hi`] } }, + }) + const dir = join(workDir, `.electric-agents`) + expect(existsSync(join(dir, `mcp.json`))).toBe(true) + const gitignore = readFileSync(join(dir, `.gitignore`), `utf-8`) + expect(gitignore).toContain(`mcp-auth.json`) + }) + + it(`adds a server to existing config`, () => { + store.save({ servers: { a: { command: `a` } } }) + store.addServer(`b`, { command: `b` }) + const config = store.load() + expect(Object.keys(config.servers)).toEqual([`a`, `b`]) + }) + + it(`removes a server from config`, () => { + store.save({ servers: { a: { command: `a` }, b: { command: `b` } } }) + store.removeServer(`a`) + const config = store.load() + expect(Object.keys(config.servers)).toEqual([`b`]) + }) + + it(`expands env vars when loading`, () => { + const dir = join(workDir, `.electric-agents`) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, `mcp.json`), + JSON.stringify({ + servers: { + s: { command: `echo`, env: { TOKEN: `\${TEST_TOKEN_CFG}` } }, + }, + }) + ) + process.env.TEST_TOKEN_CFG = `secret123` + const config = store.load({ expandEnv: true }) + expect(config.servers.s!.env!.TOKEN).toBe(`secret123`) + delete process.env.TEST_TOKEN_CFG + }) +}) diff --git a/packages/agents-mcp/test/config-tools.test.ts b/packages/agents-mcp/test/config-tools.test.ts new file mode 100644 index 0000000000..cc6bbf1243 --- /dev/null +++ b/packages/agents-mcp/test/config-tools.test.ts @@ -0,0 +1,91 @@ +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createConfigTools } from '../src/config/config-tools' +import { ConfigStore } from '../src/config/config-store' + +describe(`config management tools`, () => { + let workDir: string + let configStore: ConfigStore + let mockPool: any + let tools: ReturnType + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-cfgtools-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + configStore = new ConfigStore(workDir) + mockPool = { + addServer: vi.fn(), + removeServer: vi.fn().mockResolvedValue(undefined), + acquire: vi.fn().mockResolvedValue({ tools: [], resources: [] }), + release: vi.fn(), + getServerStates: vi.fn().mockReturnValue([]), + getEnabledServers: vi.fn().mockReturnValue([]), + } + tools = createConfigTools(configStore, mockPool) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`mcp__manage__add_server saves config and adds to pool`, async () => { + const addTool = tools.find((t) => t.name === `mcp__manage__add_server`)! + const result = await addTool.execute(`c1`, { + name: `github`, + command: `npx`, + args: [`-y`, `@mcp/server-github`], + }) + const text = (result.content[0] as any).text as string + expect(text).toContain(`github`) + expect(mockPool.addServer).toHaveBeenCalledWith( + `github`, + expect.objectContaining({ command: `npx` }) + ) + const config = configStore.load() + expect(config.servers.github).toBeDefined() + }) + + it(`mcp__manage__remove_server removes from config and pool`, async () => { + configStore.save({ servers: { github: { command: `npx` } } }) + const removeTool = tools.find( + (t) => t.name === `mcp__manage__remove_server` + )! + await removeTool.execute(`c2`, { name: `github` }) + expect(mockPool.removeServer).toHaveBeenCalledWith(`github`) + const config = configStore.load() + expect(config.servers.github).toBeUndefined() + }) + + it(`mcp__manage__list_servers returns server states`, async () => { + mockPool.getServerStates.mockReturnValue([ + { + name: `gh`, + status: `connected`, + config: { command: `npx` }, + tools: [{ name: `t1` }], + resources: [], + }, + ]) + const listTool = tools.find((t) => t.name === `mcp__manage__list_servers`)! + const result = await listTool.execute(`c3`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`gh`) + expect(text).toContain(`connected`) + }) + + it(`mcp__manage__list_tools returns tools from all servers`, async () => { + mockPool.getEnabledServers.mockReturnValue([{ name: `gh`, config: {} }]) + mockPool.acquire.mockResolvedValue({ + tools: [{ name: `create_issue`, description: `Create issue` }], + }) + const listToolsTool = tools.find( + (t) => t.name === `mcp__manage__list_tools` + )! + const result = await listToolsTool.execute(`c4`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`create_issue`) + }) +}) diff --git a/packages/agents-mcp/test/env-expand.test.ts b/packages/agents-mcp/test/env-expand.test.ts new file mode 100644 index 0000000000..ecf58ee97b --- /dev/null +++ b/packages/agents-mcp/test/env-expand.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { expandEnvVars, expandConfigValues } from '../src/config/env-expand' + +describe(`expandEnvVars`, () => { + it(`expands \${VAR} with env value`, () => { + const result = expandEnvVars(`hello \${MY_VAR} world`, { MY_VAR: `test` }) + expect(result).toBe(`hello test world`) + }) + + it(`expands \${VAR:-default} to default when var missing`, () => { + const result = expandEnvVars(`\${MISSING:-fallback}`, {}) + expect(result).toBe(`fallback`) + }) + + it(`expands \${VAR:-default} to var value when present`, () => { + const result = expandEnvVars(`\${MY_VAR:-fallback}`, { MY_VAR: `actual` }) + expect(result).toBe(`actual`) + }) + + it(`throws when required var is missing (no default)`, () => { + expect(() => expandEnvVars(`\${REQUIRED_VAR}`, {})).toThrow(/REQUIRED_VAR/) + }) + + it(`handles multiple expansions in one string`, () => { + const result = expandEnvVars(`\${A}-\${B}`, { A: `1`, B: `2` }) + expect(result).toBe(`1-2`) + }) + + it(`returns strings without variables unchanged`, () => { + expect(expandEnvVars(`plain text`, {})).toBe(`plain text`) + }) + + it(`expands empty string default`, () => { + const result = expandEnvVars(`\${X:-}`, {}) + expect(result).toBe(``) + }) +}) + +describe(`expandConfigValues`, () => { + it(`recursively expands env vars in an object`, () => { + const config = { + command: `npx`, + env: { TOKEN: `\${GH_TOKEN}` }, + url: `https://\${HOST:-localhost}:3000`, + } + const result = expandConfigValues(config, { GH_TOKEN: `abc` }) + expect(result).toEqual({ + command: `npx`, + env: { TOKEN: `abc` }, + url: `https://localhost:3000`, + }) + }) + + it(`does not expand non-string values`, () => { + const config = { enabled: true, timeout: 5000 } + expect(expandConfigValues(config, {})).toEqual(config) + }) +}) diff --git a/packages/agents-mcp/test/integration.test.ts b/packages/agents-mcp/test/integration.test.ts new file mode 100644 index 0000000000..c858783826 --- /dev/null +++ b/packages/agents-mcp/test/integration.test.ts @@ -0,0 +1,104 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMcpIntegration } from '../src/integration' + +// Mock the McpClient to avoid real subprocess/network calls +vi.mock(`../src/client`, () => { + const McpClient = vi.fn().mockImplementation(function (this: any, opts: any) { + this.serverName = opts.serverName + this.tools = [ + { + name: `echo`, + description: `Echoes input`, + inputSchema: { + type: `object`, + properties: { text: { type: `string` } }, + }, + }, + ] + this.resources = [] + this.instructions = `Use echo to test` + this.sessionId = `session-1` + this.protocolVersion = `2025-06-18` + this.connect = vi.fn().mockResolvedValue(undefined) + this.discover = vi.fn().mockResolvedValue(undefined) + this.close = vi.fn().mockResolvedValue(undefined) + this.callTool = vi + .fn() + .mockImplementation(async (_name: string, args: any) => ({ + content: [{ type: `text`, text: `echo: ${args.text}` }], + isError: false, + })) + this.listResources = vi.fn().mockResolvedValue([]) + this.readResource = vi.fn().mockResolvedValue([]) + }) + return { McpClient } +}) + +describe(`createMcpIntegration (mocked)`, () => { + let workDir: string + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-int-${randomUUID()}`) + mkdirSync(join(workDir, `.electric-agents`), { recursive: true }) + writeFileSync( + join(workDir, `.electric-agents`, `mcp.json`), + JSON.stringify({ + servers: { + test: { command: `echo`, args: [`hello`] }, + }, + }) + ) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`loads config and bridges tools`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const tools = await mcp.getTools() + + const mcpTools = tools.filter((t) => t.name.startsWith(`mcp__test__`)) + expect(mcpTools).toHaveLength(1) + expect(mcpTools[0]!.name).toBe(`mcp__test__echo`) + + // Execute the bridged tool + const result = await mcpTools[0]!.execute(`c1`, { text: `hello` }) + expect((result.content[0] as any).text).toBe(`echo: hello`) + + await mcp.close() + }) + + it(`includes config management tools`, () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const configToolNames = mcp.configTools.map((t) => t.name) + expect(configToolNames).toContain(`mcp__manage__add_server`) + expect(configToolNames).toContain(`mcp__manage__remove_server`) + expect(configToolNames).toContain(`mcp__manage__list_servers`) + expect(configToolNames).toContain(`mcp__manage__list_tools`) + }) + + it(`generates server summary with instructions`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + await mcp.getTools() // triggers lazy connect + const summary = await mcp.getServerSummary() + expect(summary).toContain(`# MCP Servers`) + expect(summary).toContain(`test`) + expect(summary).toContain(`mcp__test__echo`) + + await mcp.close() + }) + + it(`applies overrides to exclude servers`, async () => { + const mcp = createMcpIntegration({ workingDirectory: workDir }) + const tools = await mcp.getTools({ test: false }) + const mcpTools = tools.filter((t) => t.name.startsWith(`mcp__test__`)) + expect(mcpTools).toHaveLength(0) + + await mcp.close() + }) +}) diff --git a/packages/agents-mcp/test/pool.test.ts b/packages/agents-mcp/test/pool.test.ts new file mode 100644 index 0000000000..480f864d89 --- /dev/null +++ b/packages/agents-mcp/test/pool.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +vi.mock(`../src/client`, () => { + const McpClient = vi.fn().mockImplementation(function (this: any, opts: any) { + this.serverName = opts.serverName + this.tools = [] + this.resources = [] + this.instructions = undefined + this.connect = vi.fn().mockResolvedValue(undefined) + this.discover = vi.fn().mockResolvedValue(undefined) + this.close = vi.fn().mockResolvedValue(undefined) + this.callTool = vi.fn() + this.listResources = vi.fn().mockResolvedValue([]) + this.readResource = vi.fn().mockResolvedValue([]) + }) + return { McpClient } +}) + +import { McpClientPool } from '../src/pool' +import type { McpConfig } from '../src/types' + +describe(`McpClientPool`, () => { + const config: McpConfig = { + servers: { + test: { command: `echo`, args: [`hello`], enabled: true }, + disabled: { command: `echo`, enabled: false }, + }, + } + + let pool: McpClientPool + + beforeEach(() => { + pool = new McpClientPool(config, { workingDirectory: `/tmp` }) + }) + + it(`creates a client on first acquire`, async () => { + const client = await pool.acquire(`test`) + expect(client).toBeDefined() + expect((client as any).serverName).toBe(`test`) + }) + + it(`returns the same client on second acquire`, async () => { + const first = await pool.acquire(`test`) + const second = await pool.acquire(`test`) + expect(first).toBe(second) + }) + + it(`throws for unknown server`, async () => { + await expect(pool.acquire(`unknown`)).rejects.toThrow(/unknown/) + }) + + it(`throws for disabled server`, async () => { + await expect(pool.acquire(`disabled`)).rejects.toThrow(/disabled/) + }) + + it(`getServerStatus returns idle for unconnected, connected after acquire`, async () => { + expect(pool.getServerStatus(`test`)).toBe(`idle`) + await pool.acquire(`test`) + expect(pool.getServerStatus(`test`)).toBe(`connected`) + }) + + it(`close disconnects all clients`, async () => { + await pool.acquire(`test`) + await pool.close() + expect(pool.getServerStatus(`test`)).toBe(`idle`) + }) + + it(`getEnabledServers excludes disabled servers`, () => { + const enabled = pool.getEnabledServers() + expect(enabled.map((s) => s.name)).toEqual([`test`]) + }) +}) diff --git a/packages/agents-mcp/test/resource-bridge.test.ts b/packages/agents-mcp/test/resource-bridge.test.ts new file mode 100644 index 0000000000..1cf370b41d --- /dev/null +++ b/packages/agents-mcp/test/resource-bridge.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' +import { createResourceTools } from '../src/bridge/resource-bridge' + +describe(`createResourceTools`, () => { + const mockPool = { + getEnabledServers: vi + .fn() + .mockReturnValue([{ name: `github`, config: {} }]), + acquire: vi.fn().mockResolvedValue({ + listResources: vi + .fn() + .mockResolvedValue([ + { uri: `repo://org/repo`, name: `repo`, description: `A repo` }, + ]), + readResource: vi + .fn() + .mockResolvedValue([{ type: `text`, text: `file content here` }]), + }), + release: vi.fn(), + } + + it(`creates two tools: mcp__list_resources and mcp__read_resource`, () => { + const tools = createResourceTools(mockPool as any) + expect(tools).toHaveLength(2) + expect(tools.map((t) => t.name)).toEqual([ + `mcp__list_resources`, + `mcp__read_resource`, + ]) + }) + + it(`mcp__list_resources returns resources from connected servers`, async () => { + const tools = createResourceTools(mockPool as any) + const listTool = tools.find((t) => t.name === `mcp__list_resources`)! + const result = await listTool.execute(`c1`, {}) + const text = (result.content[0] as any).text as string + expect(text).toContain(`github`) + expect(text).toContain(`repo://org/repo`) + }) + + it(`mcp__read_resource reads a resource by server + URI`, async () => { + const tools = createResourceTools(mockPool as any) + const readTool = tools.find((t) => t.name === `mcp__read_resource`)! + const result = await readTool.execute(`c2`, { + server: `github`, + uri: `repo://org/repo`, + }) + const text = (result.content[0] as any).text as string + expect(text).toContain(`file content here`) + }) +}) diff --git a/packages/agents-mcp/test/token-store.test.ts b/packages/agents-mcp/test/token-store.test.ts new file mode 100644 index 0000000000..e6bc4c4460 --- /dev/null +++ b/packages/agents-mcp/test/token-store.test.ts @@ -0,0 +1,61 @@ +import { mkdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { randomUUID } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { TokenStore } from '../src/auth/token-store' + +describe(`TokenStore`, () => { + let workDir: string + let store: TokenStore + + beforeEach(() => { + workDir = join(tmpdir(), `agents-mcp-token-${randomUUID()}`) + mkdirSync(workDir, { recursive: true }) + store = new TokenStore(workDir) + }) + + afterEach(() => { + rmSync(workDir, { recursive: true, force: true }) + }) + + it(`returns undefined when no tokens exist`, () => { + expect(store.getTokens(`honeycomb`)).toBeUndefined() + }) + + it(`saves and reads tokens`, () => { + const tokens = { + access_token: `abc`, + refresh_token: `def`, + expires_at: 9999999999, + token_type: `Bearer` as const, + } + store.saveTokens(`honeycomb`, tokens) + const loaded = store.getTokens(`honeycomb`) + expect(loaded).toEqual(tokens) + }) + + it(`stores tokens for multiple servers independently`, () => { + store.saveTokens(`a`, { access_token: `1`, token_type: `Bearer` as const }) + store.saveTokens(`b`, { access_token: `2`, token_type: `Bearer` as const }) + expect(store.getTokens(`a`)!.access_token).toBe(`1`) + expect(store.getTokens(`b`)!.access_token).toBe(`2`) + }) + + it(`removes tokens for a server`, () => { + store.saveTokens(`x`, { access_token: `t`, token_type: `Bearer` as const }) + store.removeTokens(`x`) + expect(store.getTokens(`x`)).toBeUndefined() + }) + + it(`saves and reads code verifier`, () => { + store.saveCodeVerifier(`honeycomb`, `verifier123`) + expect(store.getCodeVerifier(`honeycomb`)).toBe(`verifier123`) + }) + + it(`saves and reads client info`, () => { + const info = { client_id: `cid`, client_secret: `csec` } + store.saveClientInfo(`honeycomb`, info) + expect(store.getClientInfo(`honeycomb`)).toEqual(info) + }) +}) diff --git a/packages/agents-mcp/test/tool-bridge.test.ts b/packages/agents-mcp/test/tool-bridge.test.ts new file mode 100644 index 0000000000..d05c3d05dc --- /dev/null +++ b/packages/agents-mcp/test/tool-bridge.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest' +import { bridgeMcpTools, truncateOutput } from '../src/bridge/tool-bridge' +import type { McpDiscoveredTool } from '../src/types' + +describe(`bridgeMcpTools`, () => { + const mockPool = { + acquire: vi.fn().mockResolvedValue({ + callTool: vi.fn().mockResolvedValue({ + content: [{ type: `text`, text: `result` }], + isError: false, + }), + }), + release: vi.fn(), + } + + const tools: McpDiscoveredTool[] = [ + { + name: `create_issue`, + description: `Create a GitHub issue`, + inputSchema: { + type: `object`, + properties: { title: { type: `string` } }, + }, + }, + ] + + it(`creates AgentTools with mcp__ prefix`, () => { + const bridged = bridgeMcpTools(`github`, tools, mockPool as any, {}) + expect(bridged).toHaveLength(1) + expect(bridged[0]!.name).toBe(`mcp__github__create_issue`) + expect(bridged[0]!.label).toBe(`create_issue`) + expect(bridged[0]!.description).toBe(`Create a GitHub issue`) + }) + + it(`calls the correct MCP tool name (without prefix)`, async () => { + const bridged = bridgeMcpTools(`github`, tools, mockPool as any, {}) + await bridged[0]!.execute(`call-1`, { title: `Bug` }) + + const client = await mockPool.acquire.mock.results[0]!.value + expect(client.callTool).toHaveBeenCalledWith(`create_issue`, { + title: `Bug`, + }) + expect(mockPool.release).toHaveBeenCalledWith(`github`) + }) + + it(`releases pool even on error`, async () => { + const failPool = { + acquire: vi.fn().mockResolvedValue({ + callTool: vi.fn().mockRejectedValue(new Error(`fail`)), + }), + release: vi.fn(), + } + const bridged = bridgeMcpTools(`s`, tools, failPool as any, {}) + await expect(bridged[0]!.execute(`c`, {})).rejects.toThrow(`fail`) + expect(failPool.release).toHaveBeenCalledWith(`s`) + }) +}) + +describe(`truncateOutput`, () => { + it(`returns content unchanged when under limit`, () => { + const result = truncateOutput( + { content: [{ type: `text`, text: `short` }], details: {} }, + 100 + ) + expect((result.content[0] as any).text).toBe(`short`) + }) + + it(`truncates text content exceeding limit`, () => { + const longText = `x`.repeat(200) + const result = truncateOutput( + { content: [{ type: `text`, text: longText }], details: {} }, + 100 + ) + const text = (result.content[0] as any).text as string + expect(text.length).toBeLessThan(250) + expect(text).toContain(`[Output truncated`) + }) +}) diff --git a/packages/agents-mcp/tsconfig.json b/packages/agents-mcp/tsconfig.json new file mode 100644 index 0000000000..1e82a399ad --- /dev/null +++ b/packages/agents-mcp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "isolatedDeclarations": false, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ES2022", + "lib": ["ESNext"], + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "outDir": "./dist", + "baseUrl": "." + }, + "include": ["src/**/*", "test/**/*", "*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/agents-mcp/tsdown.config.ts b/packages/agents-mcp/tsdown.config.ts new file mode 100644 index 0000000000..c4b4dc1753 --- /dev/null +++ b/packages/agents-mcp/tsdown.config.ts @@ -0,0 +1,12 @@ +import type { Options } from 'tsdown' + +const config: Options = { + entry: [`src/index.ts`], + format: [`esm`, `cjs`], + platform: `node`, + dts: true, + clean: true, + external: [/^@modelcontextprotocol\//, /^@mariozechner\//], +} + +export default config diff --git a/packages/agents-mcp/vitest.config.ts b/packages/agents-mcp/vitest.config.ts new file mode 100644 index 0000000000..0992163d60 --- /dev/null +++ b/packages/agents-mcp/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: `v8`, + reporter: [`text`, `json`, `html`, `lcov`], + include: [`src/**/*.{ts,tsx}`], + }, + reporters: [`default`, `junit`], + outputFile: `./junit/test-report.junit.xml`, + }, +}) diff --git a/packages/agents/package.json b/packages/agents/package.json index f39d29f299..5652e49e49 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.78.0", + "@electric-ax/agents-mcp": "workspace:*", "@durable-streams/state": "npm:@electric-ax/durable-streams-state-beta@^0.3.0", "@electric-ax/agents-runtime": "workspace:*", "@mariozechner/pi-agent-core": "^0.57.1", diff --git a/packages/agents/src/agents/horton.ts b/packages/agents/src/agents/horton.ts index 7f095cce14..1595ceadc9 100644 --- a/packages/agents/src/agents/horton.ts +++ b/packages/agents/src/agents/horton.ts @@ -14,6 +14,7 @@ import type { HandlerContext, WakeEvent, } from '@electric-ax/agents-runtime' +import type { McpIntegration } from '@electric-ax/agents-mcp' import type { ChangeEvent } from '@durable-streams/state' const TITLE_MODEL = `claude-haiku-4-5-20251001` @@ -143,7 +144,7 @@ export async function generateTitle( export function buildHortonSystemPrompt( workingDirectory: string, - opts: { hasDocsSupport?: boolean } = {} + opts: { hasDocsSupport?: boolean; mcpSummary?: string } = {} ): string { const docsTools = opts.hasDocsSupport ? `\n- search_durable_agents_docs: hybrid search over the built-in Durable Agents docs index` @@ -151,6 +152,7 @@ export function buildHortonSystemPrompt( const docsGuidance = opts.hasDocsSupport ? `\n- You have built-in Durable Agents docs context plus a docs search tool. Use that before broad web search when the question is about this repo, Electric Agents, or Durable Agents.\n- The docs TOC and docs search results include concrete file paths under the docs tree. Use the normal read tool with those returned paths.\n- Use repo read/bash tools for non-doc files or when you need to inspect exact implementation code in the workspace.` : `` + const mcpSection = opts.mcpSummary ? `\n${opts.mcpSummary}\n` : `` return `You are Horton, a friendly and capable assistant. You can chat, research the web, read and edit code, run shell commands, and dispatch subagents (workers) for isolated subtasks. Be warm and engaging in conversation; be precise and concrete when working with code. # Tools @@ -190,7 +192,7 @@ After spawning, end your turn (optionally with a brief "I've dispatched a worker # Reporting Report outcomes faithfully. If a command failed, say so with the relevant output. If you didn't run a verification step, say that rather than implying you did. Don't hedge confirmed results with unnecessary disclaimers. - +${mcpSection} Working directory: ${workingDirectory} The current year is ${new Date().getFullYear()}.` } @@ -236,6 +238,7 @@ function createAssistantHandler(options: { streamFn?: StreamFn docsSupport: HortonDocsSupport | null docsSearchTool?: AgentTool + mcp?: McpIntegration }) { const { workingDirectory, streamFn, docsSupport, docsSearchTool } = options @@ -244,9 +247,13 @@ function createAssistantHandler(options: { wake: WakeEvent ): Promise { const readSet = new Set() + const mcpTools = options.mcp ? await options.mcp.getTools() : [] + const mcpSummary = options.mcp ? await options.mcp.getServerSummary() : `` const tools = [ ...ctx.electricTools, ...createHortonTools(workingDirectory, ctx, readSet, { docsSearchTool }), + ...(options.mcp?.configTools ?? []), + ...mcpTools, ] if (docsSupport) { @@ -279,6 +286,7 @@ function createAssistantHandler(options: { ctx.useAgent({ systemPrompt: buildHortonSystemPrompt(workingDirectory, { hasDocsSupport: Boolean(docsSupport), + mcpSummary: mcpSummary || undefined, }), model: HORTON_MODEL, tools, @@ -314,9 +322,9 @@ function createAssistantHandler(options: { export function registerHorton( registry: EntityRegistry, - options: { workingDirectory: string; streamFn?: StreamFn } + options: { workingDirectory: string; streamFn?: StreamFn; mcp?: McpIntegration } ): Array { - const { workingDirectory, streamFn } = options + const { workingDirectory, streamFn, mcp } = options const docsSupport = createHortonDocsSupport(workingDirectory) const docsSearchTool = docsSupport?.createSearchTool() @@ -331,6 +339,7 @@ export function registerHorton( streamFn, docsSupport, docsSearchTool, + mcp, }) registry.define(`horton`, { diff --git a/packages/agents/src/bootstrap.ts b/packages/agents/src/bootstrap.ts index 6c6994e5db..1fd2ef4781 100644 --- a/packages/agents/src/bootstrap.ts +++ b/packages/agents/src/bootstrap.ts @@ -6,6 +6,7 @@ import { createEntityRegistry, createRuntimeHandler, } from '@electric-ax/agents-runtime' +import { createMcpIntegration } from '@electric-ax/agents-mcp' import { serverLog } from './log' import { registerHorton } from './agents/horton' import { registerWorker } from './agents/worker' @@ -15,6 +16,7 @@ import type { EntityStreamDBWithActions, RuntimeHandler, } from '@electric-ax/agents-runtime' +import type { McpIntegration } from '@electric-ax/agents-mcp' import type { ChangeEvent } from '@durable-streams/state' import type { StreamFn } from '@mariozechner/pi-agent-core' import type { IncomingMessage, ServerResponse } from 'node:http' @@ -26,6 +28,7 @@ export interface AgentHandlerResult { runtime: RuntimeHandler registry: EntityRegistry typeNames: Array + mcp: McpIntegration } export interface BuiltinAgentHandlerOptions { @@ -78,10 +81,12 @@ export function createBuiltinAgentHandler( } const cwd = workingDirectory ?? process.cwd() + const mcp = createMcpIntegration({ workingDirectory: cwd }) const registry = createEntityRegistry() const typeNames = registerHorton(registry, { workingDirectory: cwd, streamFn, + mcp, }) registerWorker(registry, { workingDirectory: cwd, streamFn }) @@ -101,6 +106,7 @@ export function createBuiltinAgentHandler( runtime, registry, typeNames, + mcp, } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20b5b72ee5..46e1524868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1382,15 +1382,18 @@ importers: '@durable-streams/state': specifier: npm:@electric-ax/durable-streams-state-beta@^0.3.0 version: '@electric-ax/durable-streams-state-beta@0.3.0(typescript@5.8.3)' + '@electric-ax/agents-mcp': + specifier: workspace:* + version: link:../agents-mcp '@electric-ax/agents-runtime': specifier: workspace:* version: link:../agents-runtime '@mariozechner/pi-agent-core': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -1447,6 +1450,31 @@ importers: specifier: ^4.1.0 version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@7.1.7(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + packages/agents-mcp: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.12.1 + version: 1.29.0(zod@4.3.6) + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@mariozechner/pi-agent-core': + specifier: ^0.57.1 + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) + '@vitest/coverage-v8': + specifier: ^4.1.0 + version: 4.1.5(vitest@4.1.5) + tsdown: + specifier: ^0.9.0 + version: 0.9.9(typescript@5.8.3) + typescript: + specifier: ^5.7.0 + version: 5.8.3 + vitest: + specifier: ^4.1.0 + version: 4.1.5(@opentelemetry/api@1.9.1)(@types/node@22.19.17)(@vitest/coverage-v8@4.1.5)(jsdom@28.1.0(@noble/hashes@2.0.1))(vite@7.1.7(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.20.3)(yaml@2.8.1)) + packages/agents-runtime: dependencies: '@durable-streams/client': @@ -1457,10 +1485,10 @@ importers: version: '@electric-ax/durable-streams-state-beta@0.3.0(typescript@5.8.3)' '@mariozechner/pi-agent-core': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mariozechner/pi-ai': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -1527,7 +1555,7 @@ importers: version: link:../typescript-client '@mariozechner/pi-agent-core': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -1628,7 +1656,7 @@ importers: version: link:../agents-server '@mariozechner/pi-agent-core': specifier: ^0.57.1 - version: 0.57.1(ws@8.18.3)(zod@4.3.6) + version: 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) tsdown: specifier: ^0.9.0 version: 0.9.9(typescript@5.8.3) @@ -5028,6 +5056,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -5245,6 +5279,16 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@modelcontextprotocol/sdk@1.6.1': resolution: {integrity: sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA==} engines: {node: '>=18'} @@ -9926,6 +9970,10 @@ packages: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -11889,6 +11937,12 @@ packages: peerDependencies: express: '>= 4.11' + express-rate-limit@8.4.0: + resolution: {integrity: sha512-gDK8yiqKxrGta+3WtON59arrrw6GLmadA1qoFgYXzdcch8fmKDID2XqO8itsi3f1wufXYPT51387dN6cvVBS3Q==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.21.1: resolution: {integrity: sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==} engines: {node: '>= 0.10.0'} @@ -11897,6 +11951,10 @@ packages: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -12442,6 +12500,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} + engines: {node: '>=16.9.0'} + hono@4.6.13: resolution: {integrity: sha512-haV0gaMdSjy9URCRN9hxBPlqHa7fMm/T72kAImIxvw4eQLbNz1rgjN4hHElLJSieDiNuiIAXC//cC6YGz2KCbg==} engines: {node: '>=16.9.0'} @@ -12484,6 +12546,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -12522,6 +12588,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -13044,6 +13114,9 @@ packages: jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -13147,6 +13220,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -14781,6 +14857,10 @@ packages: resolution: {integrity: sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ==} engines: {node: '>=16.20.0'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.2.1: resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} @@ -15157,6 +15237,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -15219,6 +15303,10 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -16343,6 +16431,10 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -21949,12 +22041,14 @@ snapshots: '@fontsource/alegreya-sans@5.1.1': {} - '@google/genai@1.50.1': + '@google/genai@1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.5 ws: 8.18.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -21973,6 +22067,10 @@ snapshots: dependencies: hono: 4.6.13 + '@hono/node-server@1.19.14(hono@4.12.15)': + dependencies: + hono: 4.12.15 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -22207,9 +22305,9 @@ snapshots: '@marijn/find-cluster-break@1.0.0': {} - '@mariozechner/pi-agent-core@0.57.1(ws@8.18.3)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.57.1(ws@8.18.3)(zod@4.3.6) + '@mariozechner/pi-ai': 0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -22219,11 +22317,11 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.57.1(ws@8.18.3)(zod@4.3.6)': + '@mariozechner/pi-ai@0.57.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1034.0 - '@google/genai': 1.50.1 + '@google/genai': 1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@mistralai/mistralai': 1.14.1 '@sinclair/typebox': 0.34.49 ajv: 8.18.0 @@ -22260,6 +22358,28 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.15) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.4.0(express@5.2.1) + hono: 4.12.15 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.0 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.6.1': dependencies: content-type: 1.0.5 @@ -27991,6 +28111,20 @@ snapshots: transitivePeerDependencies: - supports-color + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.0 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bowser@2.14.1: {} @@ -30203,6 +30337,11 @@ snapshots: dependencies: express: 5.1.0 + express-rate-limit@8.4.0(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + express@4.21.1: dependencies: accepts: 1.3.8 @@ -30271,6 +30410,39 @@ snapshots: transitivePeerDependencies: - supports-color + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + exsolve@1.0.7: {} extend-shallow@2.0.1: @@ -30937,6 +31109,8 @@ snapshots: dependencies: react-is: 16.13.1 + hono@4.12.15: {} + hono@4.6.13: {} hono@4.7.4: {} @@ -30983,6 +31157,14 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -31022,6 +31204,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + idb@7.1.1: {} ieee754@1.1.13: {} @@ -31572,6 +31758,8 @@ snapshots: jose@6.1.0: {} + jose@6.2.2: {} + joycon@3.1.1: {} js-levenshtein@1.1.6: {} @@ -31733,6 +31921,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -33848,6 +34038,8 @@ snapshots: pkce-challenge@4.1.0: {} + pkce-challenge@5.0.1: {} + pkg-types@1.2.1: dependencies: confbox: 0.1.8 @@ -34251,6 +34443,10 @@ snapshots: dependencies: side-channel: 1.1.0 + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + quansync@1.0.0: {} query-string@7.1.3: @@ -34366,6 +34562,13 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -35035,9 +35238,9 @@ snapshots: rolldown-plugin-dts@0.9.11(rolldown@1.0.0-beta.8-commit.151352b(typescript@5.8.3))(typescript@5.8.3): dependencies: - '@babel/generator': 7.28.5 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 ast-kit: 1.4.3 debug: 4.4.3 dts-resolver: 1.2.0 @@ -35733,6 +35936,8 @@ snapshots: statuses@2.0.1: {} + statuses@2.0.2: {} + std-env@3.10.0: {} std-env@4.1.0: {}