Compare commits
6 Commits
v1.14.1-mc
...
v2.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 006226cce5 | |||
| 62d818af23 | |||
| 531d39ace9 | |||
| f2974d6887 | |||
| 29c7d051b6 | |||
| d27a977d59 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,3 +10,4 @@ secrets/
|
||||
data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
|
||||
41
BOOCODER.md
41
BOOCODER.md
@@ -1,27 +1,32 @@
|
||||
# BooCoder
|
||||
# BooCoder — Container Guidance
|
||||
|
||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
||||
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||
|
||||
## Capabilities
|
||||
## You can
|
||||
|
||||
- Everything in `BOOCHAT.md`
|
||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
||||
- Read files (view_file, list_dir, grep, find_files)
|
||||
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
|
||||
- Apply pending changes to disk (apply_pending)
|
||||
- Revert applied changes (rewind)
|
||||
- Dispatch tasks to external agents (dispatch_external_agent)
|
||||
- Use MCP tools from configured servers
|
||||
|
||||
## Constraints
|
||||
## You cannot
|
||||
|
||||
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
|
||||
- `run_command` executes inside the session sandbox, not the host
|
||||
- No git commits, pushes, or pulls — Sam owns those
|
||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
||||
- Write outside the project root (path-guard enforced)
|
||||
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
|
||||
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
|
||||
- Push to git remotes
|
||||
- Access the internet except via configured MCP servers
|
||||
|
||||
## Pending changes discipline
|
||||
|
||||
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Show a diff preview before any write
|
||||
- Group related edits into a single `/apply` batch
|
||||
- If a tool fails, surface the error verbatim — don't paper over it
|
||||
- Show diffs clearly. Explain what you're changing and why.
|
||||
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||
- If uncertain about scope, use smaller edits and verify between steps.
|
||||
- Cite file paths + line numbers for context.
|
||||
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
||||
|
||||
## Convention: rules vs recipes
|
||||
|
||||
Always-true rules live here, in `BOOCHAT.md`, and in `CLAUDE.md` (100% present each turn). On-demand recipes live in `/data/skills/` (roughly 6% invoke rate in multi-turn per Codeminer42, 2026). Don't file workflow rules as skills — they misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v1.16.0-codesight-merge — 2026-05-24
|
||||
|
||||
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
||||
|
||||
## v1.15.0-mcp-multi — 2026-05-24
|
||||
|
||||
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
|
||||
|
||||
## v1.14.1-mcp-poc — 2026-05-23
|
||||
|
||||
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
|
||||
|
||||
28
apps/coder/Dockerfile
Normal file
28
apps/coder/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/coder/package.json ./apps/coder/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/coder ./apps/coder
|
||||
|
||||
RUN pnpm -C apps/coder build
|
||||
|
||||
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/coder ./
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
25
apps/coder/package.json
Normal file
25
apps/coder/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@boocode/coder",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"fastify": "^4.28.1",
|
||||
"postgres": "^3.4.4",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
28
apps/coder/src/config.ts
Normal file
28
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
LLAMA_SWAP_URL: z.string().url(),
|
||||
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
let cached: Config | null = null;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (cached) return cached;
|
||||
const parsed = ConfigSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment configuration:');
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
45
apps/coder/src/db.ts
Normal file
45
apps/coder/src/db.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import postgres from 'postgres';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { Config } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export type Sql = ReturnType<typeof postgres>;
|
||||
|
||||
let sqlInstance: Sql | null = null;
|
||||
|
||||
export function getSql(config: Config): Sql {
|
||||
if (sqlInstance) return sqlInstance;
|
||||
sqlInstance = postgres(config.DATABASE_URL, {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
onnotice: () => {},
|
||||
});
|
||||
return sqlInstance;
|
||||
}
|
||||
|
||||
export async function applySchema(sql: Sql): Promise<void> {
|
||||
const schemaPath = resolve(__dirname, 'schema.sql');
|
||||
const ddl = await readFile(schemaPath, 'utf8');
|
||||
await sql.unsafe(ddl);
|
||||
}
|
||||
|
||||
export async function pingDb(sql: Sql): Promise<boolean> {
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (sqlInstance) {
|
||||
await sqlInstance.end({ timeout: 5 });
|
||||
sqlInstance = null;
|
||||
}
|
||||
}
|
||||
55
apps/coder/src/index.ts
Normal file
55
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import Fastify from 'fastify';
|
||||
import { loadConfig } from './config.js';
|
||||
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.LOG_LEVEL },
|
||||
});
|
||||
|
||||
// Allow empty JSON bodies (same pattern as apps/server).
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const sql = getSql(config);
|
||||
await applySchema(sql);
|
||||
app.log.info('database schema applied');
|
||||
|
||||
// Health endpoint
|
||||
app.get('/api/health', async (_req, reply) => {
|
||||
const dbOk = await pingDb(sql);
|
||||
const status = dbOk ? 200 : 503;
|
||||
return reply.status(status).send({ ok: dbOk, db: dbOk });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
app.log.info('shutting down');
|
||||
await app.close();
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
48
apps/coder/src/schema.sql
Normal file
48
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,48 @@
|
||||
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
|
||||
-- Applied on startup by apps/coder/src/db.ts:applySchema().
|
||||
-- Lives in the same 'boochat' database as BooChat's tables.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL,
|
||||
task_id UUID,
|
||||
file_path TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
diff TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
|
||||
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL,
|
||||
parent_task_id UUID REFERENCES tasks(id),
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
input TEXT NOT NULL,
|
||||
output_summary TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
execution_path TEXT,
|
||||
worktree_path TEXT,
|
||||
cost_tokens INTEGER,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS available_agents (
|
||||
name TEXT PRIMARY KEY,
|
||||
install_path TEXT,
|
||||
version TEXT,
|
||||
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||
last_probed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Human inbox: tasks needing attention
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
15
apps/coder/tsconfig.json
Normal file
15
apps/coder/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||
}
|
||||
@@ -19,10 +19,9 @@ const ConfigSchema = z.object({
|
||||
GITEA_USER: z.string().default('indifferentketchup'),
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
// v1.14.1-mcp-poc: Context7 MCP server. Streamable HTTP transport.
|
||||
// Set to empty string or omit to disable MCP tools entirely.
|
||||
MCP_CONTEXT7_URL: z.string().optional(),
|
||||
MCP_CONTEXT7_API_KEY: z.string().optional(),
|
||||
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
||||
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
||||
MCP_CONFIG_PATH: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -24,8 +24,10 @@ import { listSkills } from './services/skills.js';
|
||||
import * as compaction from './services/compaction.js';
|
||||
import { configureModelContext } from './services/model-context.js';
|
||||
import { cleanupTruncations } from './services/truncate.js';
|
||||
import { initialize as initMcpClient, getTools as getMcpTools } from './services/mcp-client.js';
|
||||
import { loadMcpConfig } from './services/mcp-config.js';
|
||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||
import { appendMcpTools } from './services/tools.js';
|
||||
import { refreshToolNames } from './services/agents.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -71,21 +73,22 @@ async function main() {
|
||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
||||
|
||||
// v1.14.1-mcp-poc: connect to Context7 MCP server and register discovered
|
||||
// tools into ALL_TOOLS. Runs before route registration so the tool list is
|
||||
// complete when the first inference request arrives. Graceful degradation:
|
||||
// if Context7 is unreachable, zero MCP tools are registered and BooCode
|
||||
// functions normally with native tools.
|
||||
if (config.MCP_CONTEXT7_URL) {
|
||||
await initMcpClient(config, app.log);
|
||||
// v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
|
||||
// Runs before route registration so the tool list is complete when the first
|
||||
// inference request arrives. Per-server graceful degradation: one failing
|
||||
// server doesn't block others.
|
||||
const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
|
||||
const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
|
||||
if (mcpServers.length > 0) {
|
||||
await initMcp(mcpServers, app.log);
|
||||
const mcpTools = getMcpTools();
|
||||
if (mcpTools.length > 0) {
|
||||
appendMcpTools(mcpTools);
|
||||
app.log.info({ count: mcpTools.length }, 'mcp: registered Context7 tools');
|
||||
refreshToolNames();
|
||||
app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered');
|
||||
}
|
||||
} else {
|
||||
app.log.info('mcp: MCP_CONTEXT7_URL not configured, skipping');
|
||||
}
|
||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* v1.14.1-mcp-poc: unit tests for the MCP client service.
|
||||
* Pure unit tests — no live MCP server needed. Tests the tool-wrapping,
|
||||
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
|
||||
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
|
||||
* read-only guard, name prefixing, content extraction, and error handling.
|
||||
* Multi-server routing tested via wrapMcpTool's server-name prefix.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
|
||||
|
||||
describe('mcp-client', () => {
|
||||
describe('wrapMcpTool', () => {
|
||||
it('produces a ToolDef with context7_ prefix', () => {
|
||||
describe('wrapMcpTool — multi-server prefixing', () => {
|
||||
it('produces a ToolDef with <serverName>_ prefix', () => {
|
||||
const mcpTool = {
|
||||
name: 'resolve-library-id',
|
||||
description: 'Resolve a library identifier',
|
||||
@@ -19,7 +20,7 @@ describe('mcp-client', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('context7', mcpTool);
|
||||
|
||||
expect(wrapped.name).toBe('context7_resolve-library-id');
|
||||
expect(wrapped.description).toBe('Resolve a library identifier');
|
||||
@@ -29,13 +30,56 @@ describe('mcp-client', () => {
|
||||
expect(typeof wrapped.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('prefixes tools from different servers correctly', () => {
|
||||
const toolA = {
|
||||
name: 'query-docs',
|
||||
description: 'Query docs',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
const toolB = {
|
||||
name: 'overview',
|
||||
description: 'Get overview',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrappedA = wrapMcpTool('context7', toolA);
|
||||
const wrappedB = wrapMcpTool('codecontext', toolB);
|
||||
|
||||
expect(wrappedA.name).toBe('context7_query-docs');
|
||||
expect(wrappedB.name).toBe('codecontext_overview');
|
||||
});
|
||||
|
||||
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
|
||||
const serverATools = [
|
||||
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
const serverBTools = [
|
||||
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
|
||||
const allWrapped = [
|
||||
...serverATools.map((t) => wrapMcpTool('context7', t)),
|
||||
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
|
||||
];
|
||||
|
||||
expect(allWrapped).toHaveLength(4);
|
||||
expect(allWrapped.map((t) => t.name)).toEqual([
|
||||
'context7_query-docs',
|
||||
'context7_resolve-library-id',
|
||||
'codecontext_overview',
|
||||
'codecontext_search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults description to empty string when absent', () => {
|
||||
const mcpTool = {
|
||||
name: 'no-desc',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('myserver', mcpTool);
|
||||
|
||||
expect(wrapped.description).toBe('');
|
||||
expect(wrapped.jsonSchema.function.description).toBe('');
|
||||
@@ -47,9 +91,8 @@ describe('mcp-client', () => {
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
const wrapped = wrapMcpTool('s', mcpTool);
|
||||
|
||||
// z.record(z.unknown()) should accept any object
|
||||
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
@@ -73,7 +116,6 @@ describe('mcp-client', () => {
|
||||
});
|
||||
|
||||
it('accepts tools with only destructiveHint set', () => {
|
||||
// readOnlyHint is not set, so it should be accepted per D3
|
||||
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -124,18 +166,4 @@ describe('mcp-client', () => {
|
||||
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('name prefix', () => {
|
||||
it('prefixed name maps correctly in wrapped tool', () => {
|
||||
const mcpTool = {
|
||||
name: 'query-docs',
|
||||
description: 'Query documentation',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool(mcpTool);
|
||||
expect(wrapped.name).toBe('context7_query-docs');
|
||||
expect(wrapped.jsonSchema.function.name).toBe('context7_query-docs');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
|
||||
describe('matchToolGlob', () => {
|
||||
it('exact match: "grep" matches "grep"', () => {
|
||||
expect(matchToolGlob('grep', ['grep'])).toBe(true);
|
||||
});
|
||||
|
||||
it('exact match: "grep" does not match "grep2"', () => {
|
||||
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
|
||||
});
|
||||
|
||||
it('exact match: multiple tools', () => {
|
||||
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
|
||||
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
|
||||
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
|
||||
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
|
||||
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
|
||||
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "*" matches everything', () => {
|
||||
expect(matchToolGlob('anything', ['*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('deny: "!web_*" excludes "web_search"', () => {
|
||||
// With only a deny rule and no prior match, the tool is not matched
|
||||
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
|
||||
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('last-match-wins: deny then re-allow', () => {
|
||||
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
|
||||
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
|
||||
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
|
||||
});
|
||||
|
||||
it('empty patterns: nothing matches', () => {
|
||||
expect(matchToolGlob('grep', [])).toBe(false);
|
||||
expect(matchToolGlob('anything', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
|
||||
const patterns = ['grep', 'view_file'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(true);
|
||||
expect(matchToolGlob('find_files', patterns)).toBe(false);
|
||||
expect(matchToolGlob('web_search', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed glob and exact patterns', () => {
|
||||
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
|
||||
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||
// codecontext tools were missing), silently filtering valid tool names out
|
||||
// of agents that opted in. Single source of truth is tools.ts now.
|
||||
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
|
||||
export function refreshToolNames(): void {
|
||||
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
||||
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
|
||||
}
|
||||
const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
|
||||
|
||||
/**
|
||||
* Simple glob match for tool names. Supports `*` as a wildcard for any
|
||||
* characters. No `?` or `**` — tool names are flat (no path separators).
|
||||
*/
|
||||
function simpleGlobMatch(str: string, pattern: string): boolean {
|
||||
if (pattern === '*') return true;
|
||||
if (!pattern.includes('*')) return str === pattern;
|
||||
// Escape regex metacharacters, then replace escaped \* with .*
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name matches a set of glob patterns. Last-match-wins.
|
||||
* Patterns starting with `!` are deny rules.
|
||||
*
|
||||
* Examples:
|
||||
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
|
||||
* - `["context7_*"]` — all tools from the context7 MCP server
|
||||
* - `["*", "!web_*"]` — all tools except web tools
|
||||
* - `[]` — nothing matches (agent gets no tools)
|
||||
*/
|
||||
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
|
||||
let matched = false;
|
||||
for (const pattern of patterns) {
|
||||
const deny = pattern.startsWith('!');
|
||||
const glob = deny ? pattern.slice(1) : pattern;
|
||||
if (simpleGlobMatch(toolName, glob)) {
|
||||
matched = !deny;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a tools: entry is a glob pattern (contains * or starts
|
||||
* with !). Glob patterns can't be validated against the current tool list
|
||||
* since MCP tools are discovered at runtime.
|
||||
*/
|
||||
function isGlobPattern(entry: string): boolean {
|
||||
return entry.includes('*') || entry.startsWith('!');
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@@ -207,10 +259,14 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
|
||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
|
||||
// pass through unvalidated — MCP tools are discovered at runtime and can't
|
||||
// be checked against ALL_TOOL_NAMES at parse time.
|
||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||
const filteredTools = Array.isArray(fm.tools)
|
||||
? fm.tools.filter((t): t is string =>
|
||||
(ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t),
|
||||
isGlobPattern(t) ||
|
||||
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
|
||||
)
|
||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '../../types/api.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import { toolJsonSchemas, type ToolJsonSchema } from '../tools.js';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
// v1.13.16: extractToolCallBlocks replaces the inline opener-search loop and
|
||||
// recognizes both Qwen <tool_call> and Anthropic <invoke> markup in one pass.
|
||||
@@ -376,14 +377,14 @@ export async function executeStreamPhase(
|
||||
};
|
||||
|
||||
// Tool whitelist: if an agent is set, filter the global tool list to only the
|
||||
// tool names it allows. Unknown names in agent.tools are dropped silently
|
||||
// (handled here by intersection). When no agent: send all tools.
|
||||
// tool names it allows. v1.15.0-mcp-multi: uses matchToolGlob for glob
|
||||
// pattern support (e.g. `context7_*`, `!web_*`). When no agent: send all tools.
|
||||
// v1.11.8: a second filter strips web_search + web_fetch unless the chat
|
||||
// has them explicitly enabled. Counts as an opt-in security boundary: the
|
||||
// model can't summon a tool that wasn't offered to it.
|
||||
const WEB_TOOL_NAMES: ReadonlySet<string> = new Set(['web_search', 'web_fetch']);
|
||||
const effectiveTools: ToolJsonSchema[] = (agent
|
||||
? toolJsonSchemas().filter((t) => agent.tools.includes(t.function.name))
|
||||
? toolJsonSchemas().filter((t) => matchToolGlob(t.function.name, agent.tools))
|
||||
: toolJsonSchemas()
|
||||
).filter((t) => webToolsEnabled || !WEB_TOOL_NAMES.has(t.function.name));
|
||||
const effectiveTemperature = agent?.temperature;
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
/**
|
||||
* v1.14.1-mcp-poc: singleton MCP client for Context7.
|
||||
* v1.15.0-mcp-multi: multi-server MCP client registry.
|
||||
*
|
||||
* Connects via Streamable HTTP transport, discovers tools at startup,
|
||||
* wraps each as a BooCode ToolDef with a `context7_` name prefix.
|
||||
* Graceful degradation: if the server is unreachable, zero tools are
|
||||
* exposed and BooCode functions normally with native tools.
|
||||
* Connects to multiple MCP servers (Streamable HTTP or stdio transport),
|
||||
* discovers tools from each, wraps them as BooCode ToolDefs with a
|
||||
* `<serverName>_<toolName>` name prefix, and routes callTool by prefix.
|
||||
*
|
||||
* Graceful degradation: one failing server doesn't block others.
|
||||
* Read-only invariant: tools with readOnlyHint === false are rejected.
|
||||
*/
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Config } from '../config.js';
|
||||
import type { McpServerEntry, McpServerConfig } from './mcp-config.js';
|
||||
import type { ToolDef } from './tools.js';
|
||||
|
||||
// ---- Types for the MCP tool shape returned by listTools ----
|
||||
// ---- Types ----
|
||||
|
||||
interface McpToolAnnotations {
|
||||
readOnlyHint?: boolean;
|
||||
destructiveHint?: boolean;
|
||||
@@ -27,99 +31,86 @@ interface McpToolDef {
|
||||
annotations?: McpToolAnnotations;
|
||||
}
|
||||
|
||||
interface ServerState {
|
||||
client: Client;
|
||||
transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||
tools: ToolDef<Record<string, unknown>>[];
|
||||
type: 'streamableHttp' | 'stdio';
|
||||
}
|
||||
|
||||
// ---- Module-level state ----
|
||||
let client: Client | null = null;
|
||||
let tools: ToolDef<Record<string, unknown>>[] = [];
|
||||
let initialized = false;
|
||||
|
||||
const servers = new Map<string, ServerState>();
|
||||
// Reverse map: prefixed tool name → server name (built during discovery)
|
||||
const toolToServer = new Map<string, string>();
|
||||
let log: FastifyBaseLogger | null = null;
|
||||
|
||||
const NAME_PREFIX = 'context7_';
|
||||
const MAX_RESULT_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
/**
|
||||
* Connect to the Context7 MCP server, discover tools, and wrap them
|
||||
* as BooCode ToolDefs. On failure, logs a warning and exposes zero tools.
|
||||
* Connect to all configured MCP servers, discover tools, and wrap them.
|
||||
* Per-server graceful degradation: a failing server is logged and skipped.
|
||||
*/
|
||||
export async function initialize(config: Config, logger: FastifyBaseLogger): Promise<void> {
|
||||
export async function initialize(
|
||||
entries: McpServerEntry[],
|
||||
logger: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
log = logger;
|
||||
if (!config.MCP_CONTEXT7_URL) {
|
||||
log.info('mcp: MCP_CONTEXT7_URL not set, skipping Context7 initialization');
|
||||
initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
client = new Client({ name: 'boocode', version: '1.14.1' });
|
||||
|
||||
const requestInit: RequestInit = {};
|
||||
if (config.MCP_CONTEXT7_API_KEY) {
|
||||
requestInit.headers = { Authorization: `Bearer ${config.MCP_CONTEXT7_API_KEY}` };
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(config.MCP_CONTEXT7_URL),
|
||||
{ requestInit },
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.listTools();
|
||||
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||
|
||||
tools = [];
|
||||
for (const t of mcpTools) {
|
||||
// D3: read-only invariant guard. Reject tools that explicitly declare
|
||||
// readOnlyHint: false (i.e. write tools). Accept readOnlyHint: true
|
||||
// or absent annotations (fail-open — most MCP servers don't annotate).
|
||||
if (t.annotations?.readOnlyHint === false) {
|
||||
log.info({ tool: t.name }, 'mcp: skipping non-read-only tool');
|
||||
continue;
|
||||
// Connect servers in parallel — each wrapped in try/catch for isolation
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
try {
|
||||
await connectServer(entry);
|
||||
} catch (err) {
|
||||
log!.warn(
|
||||
{ err, server: entry.name },
|
||||
`mcp: failed to initialize server "${entry.name}" — its tools will be unavailable`,
|
||||
);
|
||||
}
|
||||
tools.push(wrapMcpTool(t));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (servers.size > 0) {
|
||||
const totalTools = Array.from(servers.values()).reduce((n, s) => n + s.tools.length, 0);
|
||||
log.info(
|
||||
{ count: tools.length, names: tools.map((t) => t.name) },
|
||||
'mcp: initialized Context7',
|
||||
{ servers: servers.size, tools: totalTools },
|
||||
'mcp: multi-server initialization complete',
|
||||
);
|
||||
initialized = true;
|
||||
} catch (err) {
|
||||
log.warn({ err }, 'mcp: failed to initialize Context7 — MCP tools will be unavailable');
|
||||
client = null;
|
||||
tools = [];
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP tool by its prefixed name. Strips the prefix before
|
||||
* forwarding to the MCP server. Returns a string on success or an
|
||||
* error-shaped object on failure.
|
||||
* Call an MCP tool by its prefixed name. Routes to the correct server
|
||||
* using the toolToServer reverse map.
|
||||
*/
|
||||
export async function callTool(
|
||||
prefixedName: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
if (!client) {
|
||||
return { error: true, output: 'MCP client not initialized' };
|
||||
const serverName = toolToServer.get(prefixedName);
|
||||
if (!serverName) {
|
||||
return { error: true, output: `MCP tool "${prefixedName}" not found in any server` };
|
||||
}
|
||||
|
||||
const originalName = prefixedName.startsWith(NAME_PREFIX)
|
||||
? prefixedName.slice(NAME_PREFIX.length)
|
||||
: prefixedName;
|
||||
const state = servers.get(serverName);
|
||||
if (!state) {
|
||||
return { error: true, output: `MCP server "${serverName}" not available` };
|
||||
}
|
||||
|
||||
// Strip the "<serverName>_" prefix to get the original tool name
|
||||
const originalName = prefixedName.slice(serverName.length + 1);
|
||||
|
||||
try {
|
||||
const result = await client.callTool({ name: originalName, arguments: args });
|
||||
const result = await state.client.callTool({ name: originalName, arguments: args });
|
||||
|
||||
// D8: extract content blocks
|
||||
const content = result.content as Array<{ type: string; text?: string; [key: string]: unknown }>;
|
||||
if (!content || content.length === 0) {
|
||||
return '(no output)';
|
||||
}
|
||||
|
||||
// If MCP reports an error, return error shape
|
||||
if (result.isError) {
|
||||
const joined = content
|
||||
.map((block) => (block.type === 'text' ? block.text ?? '' : JSON.stringify(block)))
|
||||
@@ -133,12 +124,12 @@ export async function callTool(
|
||||
});
|
||||
const joined = parts.join('\n');
|
||||
if (joined.length > MAX_RESULT_BYTES) {
|
||||
log?.warn({ tool: originalName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||
log?.warn({ tool: originalName, server: serverName, bytes: joined.length, cap: MAX_RESULT_BYTES }, 'mcp: result truncated');
|
||||
return joined.slice(0, MAX_RESULT_BYTES) + '\n\n[truncated — MCP result exceeded size limit]';
|
||||
}
|
||||
return joined;
|
||||
} catch (err) {
|
||||
log?.warn({ err, tool: originalName }, 'mcp: callTool failed');
|
||||
log?.warn({ err, tool: originalName, server: serverName }, 'mcp: callTool failed');
|
||||
return {
|
||||
error: true,
|
||||
output: err instanceof Error ? err.message : 'MCP server unreachable',
|
||||
@@ -146,21 +137,114 @@ export async function callTool(
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the wrapped ToolDefs discovered at initialization. */
|
||||
/** Return all wrapped ToolDefs from all connected servers, flattened. */
|
||||
export function getTools(): ToolDef<Record<string, unknown>>[] {
|
||||
return tools;
|
||||
const all: ToolDef<Record<string, unknown>>[] = [];
|
||||
for (const state of servers.values()) {
|
||||
all.push(...state.tools);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Whether initialize() has been called (even if it failed). */
|
||||
export function isInitialized(): boolean {
|
||||
return initialized;
|
||||
/** Return status of each server (for debug/status endpoints). */
|
||||
export function getMcpServers(): Array<{
|
||||
name: string;
|
||||
type: 'streamableHttp' | 'stdio';
|
||||
toolCount: number;
|
||||
connected: boolean;
|
||||
}> {
|
||||
return Array.from(servers.entries()).map(([name, state]) => ({
|
||||
name,
|
||||
type: state.type,
|
||||
toolCount: state.tools.length,
|
||||
connected: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown. For stdio servers, the SDK's transport.close() handles
|
||||
* SIGTERM + timeout. For HTTP servers, close the transport.
|
||||
*/
|
||||
export async function shutdown(): Promise<void> {
|
||||
const closePromises: Promise<void>[] = [];
|
||||
for (const [name, state] of servers) {
|
||||
closePromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
await state.transport.close();
|
||||
log?.info({ server: name }, 'mcp: server transport closed');
|
||||
} catch (err) {
|
||||
log?.warn({ err, server: name }, 'mcp: error closing server transport');
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(closePromises);
|
||||
servers.clear();
|
||||
toolToServer.clear();
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/** Exposed for unit tests. */
|
||||
export function wrapMcpTool(mcpTool: McpToolDef): ToolDef<Record<string, unknown>> {
|
||||
const prefixedName = `${NAME_PREFIX}${mcpTool.name}`;
|
||||
async function connectServer(entry: McpServerEntry): Promise<void> {
|
||||
const { name, config } = entry;
|
||||
|
||||
const client = new Client({ name: 'boocode', version: '1.15.0' });
|
||||
let transport: StreamableHTTPClientTransport | StdioClientTransport;
|
||||
|
||||
if (config.type === 'streamableHttp') {
|
||||
transport = createHttpTransport(config);
|
||||
} else {
|
||||
transport = createStdioTransport(config);
|
||||
}
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
const result = await client.listTools();
|
||||
const mcpTools = (result.tools ?? []) as McpToolDef[];
|
||||
|
||||
const tools: ToolDef<Record<string, unknown>>[] = [];
|
||||
for (const t of mcpTools) {
|
||||
if (t.annotations?.readOnlyHint === false) {
|
||||
log!.info({ tool: t.name, server: name }, 'mcp: skipping non-read-only tool');
|
||||
continue;
|
||||
}
|
||||
const wrapped = wrapMcpTool(name, t);
|
||||
tools.push(wrapped);
|
||||
toolToServer.set(wrapped.name, name);
|
||||
}
|
||||
|
||||
servers.set(name, { client, transport, tools, type: config.type });
|
||||
|
||||
log!.info(
|
||||
{ server: name, type: config.type, count: tools.length, names: tools.map((t) => t.name) },
|
||||
'mcp: server initialized',
|
||||
);
|
||||
}
|
||||
|
||||
function createHttpTransport(config: Extract<McpServerConfig, { type: 'streamableHttp' }>): StreamableHTTPClientTransport {
|
||||
const requestInit: RequestInit = {};
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers;
|
||||
}
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), { requestInit });
|
||||
}
|
||||
|
||||
function createStdioTransport(config: Extract<McpServerConfig, { type: 'stdio' }>): StdioClientTransport {
|
||||
return new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap an MCP tool as a BooCode ToolDef with a server-name prefix. */
|
||||
export function wrapMcpTool(
|
||||
serverName: string,
|
||||
mcpTool: McpToolDef,
|
||||
): ToolDef<Record<string, unknown>> {
|
||||
const prefixedName = `${serverName}_${mcpTool.name}`;
|
||||
return {
|
||||
name: prefixedName,
|
||||
description: mcpTool.description ?? '',
|
||||
@@ -200,6 +284,5 @@ export function extractContent(
|
||||
|
||||
/** Exposed for unit tests — the read-only guard predicate. */
|
||||
export function isToolReadOnly(annotations?: McpToolAnnotations): boolean {
|
||||
// Reject explicitly non-read-only tools; accept everything else
|
||||
return annotations?.readOnlyHint !== false;
|
||||
}
|
||||
|
||||
78
apps/server/src/services/mcp-config.ts
Normal file
78
apps/server/src/services/mcp-config.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: MCP config file schema + loader.
|
||||
*
|
||||
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
|
||||
* servers — their transport type, connection parameters, and enabled state.
|
||||
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// ---- Zod schema ----
|
||||
|
||||
const McpServerConfigSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('streamableHttp'),
|
||||
url: z.string().url(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('stdio'),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().default(true),
|
||||
}),
|
||||
]);
|
||||
|
||||
const McpConfigSchema = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerConfigSchema).default({}),
|
||||
});
|
||||
|
||||
export type McpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||
|
||||
export interface McpServerEntry {
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
}
|
||||
|
||||
// ---- Loader ----
|
||||
|
||||
/**
|
||||
* Read and validate the MCP config file. Returns enabled servers only.
|
||||
* File missing → log info, return []. Parse/validation error → log warn, return [].
|
||||
*/
|
||||
export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpServerEntry[] {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
log.info(`mcp: config not found at ${configPath}, skipping`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
log.warn({ err }, `mcp: failed to parse ${configPath} as JSON`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = McpConfigSchema.safeParse(json);
|
||||
if (!result.success) {
|
||||
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries: McpServerEntry[] = [];
|
||||
for (const [name, config] of Object.entries(result.data.mcpServers)) {
|
||||
if (config.enabled) {
|
||||
entries.push({ name, config });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
watchChanges,
|
||||
getSemanticNeighborhoods,
|
||||
getFrameworkAnalysis,
|
||||
getBlastRadius,
|
||||
getHotFiles,
|
||||
getRoutes,
|
||||
getMiddleware,
|
||||
} from './tools/codecontext/index.js';
|
||||
// v1.13.17-cross-repo-reads: cross-repo read grant request tool. Paired
|
||||
// with the pause-on-pending-grant branch in inference/tool-phase.ts and the
|
||||
@@ -680,6 +684,11 @@ export let ALL_TOOLS: ToolDef<unknown>[] = [
|
||||
watchChanges as ToolDef<unknown>,
|
||||
getSemanticNeighborhoods as ToolDef<unknown>,
|
||||
getFrameworkAnalysis as ToolDef<unknown>,
|
||||
// v1.16: codesight-merge tools. Backed by the same codecontext sidecar.
|
||||
getBlastRadius as ToolDef<unknown>,
|
||||
getHotFiles as ToolDef<unknown>,
|
||||
getRoutes as ToolDef<unknown>,
|
||||
getMiddleware as ToolDef<unknown>,
|
||||
// v1.13.17-cross-repo-reads: paired with the pause-on-pending-grant
|
||||
// branch in tool-phase.ts. Read-only — only ever READS files; the only
|
||||
// state change is appending to sessions.allowed_read_paths via the
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetBlastRadiusInput = z.object({
|
||||
file_path: z.string().trim().min(1),
|
||||
});
|
||||
export type GetBlastRadiusInputT = z.infer<typeof GetBlastRadiusInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Returns all files that depend (transitively) on the given file, with depth tracking. ' +
|
||||
'Use to assess the impact of changing a file — "what breaks if I modify this?" ' +
|
||||
'Traverses the import graph in reverse via BFS. Results sorted by distance (closest dependents first).';
|
||||
|
||||
export async function executeGetBlastRadius(
|
||||
input: GetBlastRadiusInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{ toolName: 'get_blast_radius', args: { file_path: input.file_path }, projectPath },
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getBlastRadius: ToolDef<GetBlastRadiusInputT> = {
|
||||
name: 'get_blast_radius',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetBlastRadiusInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_blast_radius',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: {
|
||||
type: 'string',
|
||||
description: 'Absolute or project-relative path to the file to analyze.',
|
||||
},
|
||||
},
|
||||
required: ['file_path'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetBlastRadius(input, projectRoot);
|
||||
},
|
||||
};
|
||||
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_hot_files.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetHotFilesInput = z.object({
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
export type GetHotFilesInputT = z.infer<typeof GetHotFilesInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Returns the most-imported files in the project, ranked by incoming import count. ' +
|
||||
'Hot files are high-risk change targets — many other files depend on them. ' +
|
||||
'Use to identify core modules and assess refactoring risk.';
|
||||
|
||||
export async function executeGetHotFiles(
|
||||
input: GetHotFilesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext(
|
||||
{ toolName: 'get_hot_files', args: input.limit != null ? { limit: input.limit } : {}, projectPath },
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
|
||||
export const getHotFiles: ToolDef<GetHotFilesInputT> = {
|
||||
name: 'get_hot_files',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetHotFilesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_hot_files',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of files to return (default 20, max 100).',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetHotFiles(input, projectRoot);
|
||||
},
|
||||
};
|
||||
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
41
apps/server/src/services/tools/codecontext/get_middleware.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetMiddlewareInput = z.object({});
|
||||
export type GetMiddlewareInputT = z.infer<typeof GetMiddlewareInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Detects middleware registrations in the project. Identifies auth, CORS, rate-limit, ' +
|
||||
'security-headers, error-handler, logging, and validation middleware by analyzing ' +
|
||||
'import names (@fastify/cors, helmet, etc.) and registration patterns ' +
|
||||
'(app.register, app.addHook, app.setErrorHandler).';
|
||||
|
||||
export async function executeGetMiddleware(
|
||||
_input: GetMiddlewareInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
return callCodecontext({ toolName: 'get_middleware', args: {}, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getMiddleware: ToolDef<GetMiddlewareInputT> = {
|
||||
name: 'get_middleware',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetMiddlewareInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_middleware',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetMiddleware(input, projectRoot);
|
||||
},
|
||||
};
|
||||
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
50
apps/server/src/services/tools/codecontext/get_routes.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef } from '../../tools.js';
|
||||
import { callCodecontext, type CodecontextResponse } from '../../codecontext_client.js';
|
||||
|
||||
export const GetRoutesInput = z.object({
|
||||
framework: z.string().trim().optional(),
|
||||
});
|
||||
export type GetRoutesInputT = z.infer<typeof GetRoutesInput>;
|
||||
|
||||
const DESCRIPTION =
|
||||
'Extracts HTTP routes from the project via tree-sitter AST analysis. ' +
|
||||
'Detects Fastify and Express route registrations (app.get, app.post, app.route, router.use, etc.) ' +
|
||||
'with method, path, file, line number, and inferred tags (db, auth, cache). ' +
|
||||
'Optional framework filter narrows to "fastify" or "express".';
|
||||
|
||||
export async function executeGetRoutes(
|
||||
input: GetRoutesInputT,
|
||||
projectPath: string,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<CodecontextResponse> {
|
||||
const args: Record<string, unknown> = {};
|
||||
if (input.framework) args.framework = input.framework;
|
||||
return callCodecontext({ toolName: 'get_routes', args, projectPath }, fetcher);
|
||||
}
|
||||
|
||||
export const getRoutes: ToolDef<GetRoutesInputT> = {
|
||||
name: 'get_routes',
|
||||
description: DESCRIPTION,
|
||||
inputSchema: GetRoutesInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_routes',
|
||||
description: DESCRIPTION,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
framework: {
|
||||
type: 'string',
|
||||
description: 'Filter to a specific framework: "fastify" or "express". Omit for all.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input, projectRoot) {
|
||||
return await executeGetRoutes(input, projectRoot);
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
// v1.12 Track B.2: codecontext tool registry. Re-exports the 8 ToolDefs so
|
||||
// tools.ts can pull them in one line.
|
||||
// codecontext tool registry. Re-exports ToolDefs so tools.ts can pull them
|
||||
// in one line. v1.12: 8 original tools. v1.16: +4 codesight-merge tools.
|
||||
|
||||
export { getCodebaseOverview } from './get_codebase_overview.js';
|
||||
export { getFileAnalysis } from './get_file_analysis.js';
|
||||
@@ -9,3 +9,7 @@ export { getDependencies } from './get_dependencies.js';
|
||||
export { watchChanges } from './watch_changes.js';
|
||||
export { getSemanticNeighborhoods } from './get_semantic_neighborhoods.js';
|
||||
export { getFrameworkAnalysis } from './get_framework_analysis.js';
|
||||
export { getBlastRadius } from './get_blast_radius.js';
|
||||
export { getHotFiles } from './get_hot_files.js';
|
||||
export { getRoutes } from './get_routes.js';
|
||||
export { getMiddleware } from './get_middleware.js';
|
||||
|
||||
9
data/mcp.json
Normal file
9
data/mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
environment:
|
||||
CODECONTEXT_URL: http://codecontext:8080
|
||||
CONTAINER_GUIDANCE_FILE: /app/BOOCHAT.md
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
volumes:
|
||||
- /opt:/opt
|
||||
- /opt/projects:/opt/projects:rw
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
volumes:
|
||||
- /opt:/opt:rw
|
||||
- /home/samkintop:/home/samkintop:rw
|
||||
@@ -50,6 +50,28 @@ services:
|
||||
networks:
|
||||
- boocode_net
|
||||
|
||||
boocoder:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/coder/Dockerfile
|
||||
container_name: boocoder
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "100.114.205.53:9502:3000"
|
||||
env_file: .env
|
||||
environment:
|
||||
CONTAINER_GUIDANCE_FILE: /app/BOOCODER.md
|
||||
DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boochat
|
||||
volumes:
|
||||
- /opt:/opt:rw
|
||||
- /opt/projects:/opt/projects:rw
|
||||
- ./data:/data
|
||||
- /opt/boocode/BOOCODER.md:/app/BOOCODER.md:ro
|
||||
depends_on:
|
||||
- boocode_db
|
||||
networks:
|
||||
- boocode_net
|
||||
|
||||
boocode_db:
|
||||
image: postgres:16-alpine
|
||||
container_name: boocode_db
|
||||
@@ -57,7 +79,7 @@ services:
|
||||
environment:
|
||||
POSTGRES_USER: boocode
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: boocode
|
||||
POSTGRES_DB: boochat
|
||||
ports:
|
||||
- "127.0.0.1:5500:5432"
|
||||
volumes:
|
||||
|
||||
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# v1.15.0-mcp-multi — design decisions
|
||||
|
||||
## D1. Config file path
|
||||
|
||||
`/data/mcp.json` (alongside `AGENTS.md` at `/data/AGENTS.md`). Both are bind-mounted from the host's `data/` directory. Override via `MCP_CONFIG_PATH` env var.
|
||||
|
||||
File missing = no MCP (opt-in by file presence, not by env var). Simpler than the v1.14.1 approach of always-defaulting a URL.
|
||||
|
||||
## D2. Config schema matches opencode's `mcpServers` shape
|
||||
|
||||
opencode uses `~/.opencode/config.json` with a `mcpServers` key. BooCode uses `mcp.json` with the same `mcpServers` key so server entries are copy-pasteable. Property names match: `type`, `url`, `command`, `args`, `env`, `headers`. BooCode adds `enabled` (boolean toggle per server, default true) which opencode doesn't have — harmless extra key.
|
||||
|
||||
## D3. Transport types: streamableHttp + stdio only
|
||||
|
||||
- **streamableHttp**: For remote servers (Context7, future cloud MCP services). Uses `@modelcontextprotocol/sdk`'s `StreamableHTTPClientTransport`.
|
||||
- **stdio**: For local subprocess servers (codecontext, future local tools). Uses `@modelcontextprotocol/sdk`'s `StdioClientTransport` (spawns child process, NDJSON framing over stdin/stdout).
|
||||
- **SSE**: Skipped. Streamable HTTP supersedes SSE per the MCP spec (May 2025 protocol update). If a legacy server requires SSE, it can be added later.
|
||||
|
||||
## D4. Tool name prefixing: `<serverName>_<toolName>`
|
||||
|
||||
Generalizes v1.14.1's `context7_<name>` pattern. Server name comes from the config key (e.g. `"context7"`, `"codecontext"`). Collisions between servers with the same name are impossible (config keys are unique). Collisions between an MCP tool and a native tool are possible if someone names a server entry the same as a native tool prefix — but that's a user-configuration error, not a code bug.
|
||||
|
||||
## D5. Per-agent glob patterns: last-match-wins
|
||||
|
||||
AGENTS.md `tools:` field already supports exact-match arrays. Globs extend the same field:
|
||||
|
||||
```yaml
|
||||
tools: [view_file, grep, context7_*]
|
||||
```
|
||||
|
||||
Evaluation: for each tool in `ALL_TOOLS`, scan the pattern list left-to-right. A `!` prefix denies. Last matching pattern wins. This matches the roadmap's "wildcard rule matcher" language.
|
||||
|
||||
Examples:
|
||||
- `[*]` — all tools (same as omitting `tools:` entirely)
|
||||
- `[*, !web_*]` — all tools except web
|
||||
- `[view_file, grep, context7_*]` — only view_file, grep, and all Context7 tools
|
||||
- `[*]` on Architect + `[view_file]` on Prompt Builder — each agent gets its intended scope
|
||||
|
||||
Globs use a simple `minimatch`-style check: `*` matches any characters. No `?` or `**` — tool names are flat (no path separators).
|
||||
|
||||
## D6. No DB tables in v1.15
|
||||
|
||||
The roadmap listed `permissions`, `agent_permissions`, `session_permissions`, `mcp_servers` tables. All deferred to v2.0:
|
||||
|
||||
- **Permission tables**: Enterprise multi-user pattern. BooChat is single-user behind Authelia. The read-only invariant guard is the BooChat-era defense. Formal permission rulesets land when BooCoder adds write tools.
|
||||
- **`mcp_servers` table**: In-memory registry is sufficient. No need to persist server state to DB when the config file is the source of truth and tools are re-discovered on every boot.
|
||||
|
||||
## D7. Stdio child lifecycle
|
||||
|
||||
- Spawn on `initialize()`. Persistent connection for server lifetime (not per-call).
|
||||
- On child exit (unexpected): mark server unavailable, log error. Do NOT auto-restart. BooCode continues with remaining servers.
|
||||
- On BooCode shutdown (`app.addHook('onClose')`): send SIGTERM to all stdio children. Wait up to 5s, then SIGKILL.
|
||||
- On ENOENT (command not found): skip server with a warning. Matches the graceful-degradation pattern from v1.14.1.
|
||||
|
||||
## D8. v1.14.1 env vars removed
|
||||
|
||||
`MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` are deleted from `config.ts`. They're superseded by the JSON config file's `context7` entry. The PoC was explicitly designed as throwaway.
|
||||
|
||||
Migration path for anyone who had the env vars set: add a `data/mcp.json` with the Context7 entry. The CHANGELOG entry will note this.
|
||||
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal file
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# v1.15.0-mcp-multi — multi-server MCP client + stdio transport + config file
|
||||
|
||||
Generalize the v1.14.1 single-server Context7 PoC into a multi-server MCP client. Add stdio transport (for local subprocess MCP servers like codecontext). JSON config file matching opencode's schema shape. Per-agent tool glob patterns in AGENTS.md frontmatter.
|
||||
|
||||
## Why
|
||||
|
||||
v1.14.1 proved the MCP loop works end-to-end but is hardcoded to one server (Context7) via env vars. Real value comes from multiple servers: Context7 for docs, codecontext re-wired as a proper MCP server (stdio), future local tools. The config shape should match opencode's so Sam can copy `mcp` blocks between the two without translation.
|
||||
|
||||
## Scope
|
||||
|
||||
### S1. JSON config file for MCP servers
|
||||
|
||||
New file at `/data/mcp.json` (bind-mounted like `AGENTS.md`). Env var `MCP_CONFIG_PATH` points to it (default `/data/mcp.json`).
|
||||
|
||||
Schema (matching opencode's shape):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": { "X-API-Key": "optional-key" },
|
||||
"enabled": true
|
||||
},
|
||||
"codecontext": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/codecontext",
|
||||
"args": ["--mcp"],
|
||||
"env": { "WORKSPACE": "/opt" },
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zod-validated at startup. Unknown keys silently ignored (forward-compat). Each server entry has:
|
||||
- `type`: `"streamableHttp"` | `"stdio"` (SSE deferred — Streamable HTTP supersedes it per the MCP spec)
|
||||
- `url` (HTTP) or `command` + `args` + `env` (stdio)
|
||||
- `headers` (HTTP, optional) — for API keys
|
||||
- `enabled` (boolean, default true)
|
||||
|
||||
### S2. Multi-server MCP client
|
||||
|
||||
Refactor `mcp-client.ts` from a singleton to a registry of named MCP clients. On startup:
|
||||
1. Read `/data/mcp.json` (or path from `MCP_CONFIG_PATH`)
|
||||
2. For each enabled server: create a Client + transport, connect, discover tools via `tools/list`
|
||||
3. Wrap tools with `<server-name>_<tool-name>` prefix (generalizes the `context7_` pattern)
|
||||
4. Apply read-only invariant guard per-tool (reject `readOnlyHint: false`)
|
||||
5. Append all MCP tools to `ALL_TOOLS` in a single `appendMcpTools()` call
|
||||
6. Per-server graceful degradation: one server failing doesn't block others
|
||||
|
||||
Expose: `getMcpServers(): McpServerStatus[]` for debug/status endpoint, `callTool(prefixedName, args)` routed to the correct server by prefix.
|
||||
|
||||
### S3. Stdio transport
|
||||
|
||||
For `type: "stdio"` servers: spawn a subprocess via `child_process.spawn(command, args, {env, stdio: 'pipe'})`. Use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (or implement the NDJSON framing ourselves — the SDK should have it). The subprocess runs for the lifetime of the BooCode server (persistent connection, not per-call spawn).
|
||||
|
||||
Child lifecycle:
|
||||
- Spawn on initialize. If spawn fails, log warn, skip server (graceful degradation).
|
||||
- On child exit: log error, mark server as unavailable. Do NOT restart automatically (v1.15 keeps it simple; auto-restart is a v2.0 concern).
|
||||
- On BooCode shutdown (`app.addHook('onClose')`): kill child processes.
|
||||
|
||||
### S4. Per-agent tool glob patterns in AGENTS.md
|
||||
|
||||
Currently `tools:` in AGENTS.md frontmatter is an exact-match whitelist (array of tool names). Extend to support glob patterns via a lightweight matcher:
|
||||
- `context7_*` — all tools from the context7 server
|
||||
- `view_*` — all tools starting with `view_`
|
||||
- `!web_*` — exclude web tools (deny pattern)
|
||||
- Plain names (`grep`, `view_file`) work as before (exact match)
|
||||
|
||||
Evaluation order: for each tool in `ALL_TOOLS`, check if it matches any pattern in the agent's `tools:` list. A `!` prefix means exclude. Last-match-wins.
|
||||
|
||||
Parser change in `agents.ts`: when validating `tools:`, don't reject unknown names if they contain `*` (glob patterns can't be validated against the current tool list since MCP tools are discovered at runtime). Exact names are still validated.
|
||||
|
||||
### S5. Remove v1.14.1 env-var config
|
||||
|
||||
Delete `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from `config.ts`. They're superseded by the JSON config file. The v1.14.1 PoC is throwaway-by-design (proposal said "throwaway-if-needed").
|
||||
|
||||
### S6. Read-only invariant preserved
|
||||
|
||||
BooChat's read-only guarantee stays: every MCP tool with `readOnlyHint: false` is rejected at discovery. This applies globally, not per-server. Config has no `allowWriteTools` flag — that's a v2.0 BooCoder concern.
|
||||
|
||||
## Deferred to v2.0
|
||||
|
||||
- **Permission ruleset tables** (`permissions`, `agent_permissions`, `session_permissions`). Enterprise pattern that doesn't serve until BooCoder adds write tools. The read-only invariant guard is the BooChat-era defense-in-depth.
|
||||
- **OAuth / Dynamic Client Registration.** Needs secret storage primitive first.
|
||||
- **SSE transport.** Streamable HTTP supersedes it per the MCP spec. SSE is a legacy fallback.
|
||||
- **Per-session MCP toggle.** No `session.mcp_enabled` column in v1.15. MCP servers are globally configured; agent tool globs are the scoping mechanism.
|
||||
- **`mcp_servers` DB table.** In-memory registry is sufficient for single-user. DB tracking deferred to v2.0.
|
||||
- **codecontext re-wiring to MCP.** Separate batch after v1.15 proves stdio transport works.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No frontend changes. MCP tools surface via the existing tool registry; results render as normal tool-result parts.
|
||||
- No schema changes. No new DB tables or columns.
|
||||
- No changes to the inference loop (v1.14.0 outer loop unchanged).
|
||||
- No changes to `executeToolCall` dispatch (transparent via ToolDef.execute).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commit/push. Sam commits.
|
||||
- Read-only invariant: reject any MCP tool with `readOnlyHint: false`.
|
||||
- Graceful degradation: any server down → that server's tools unavailable, rest unaffected.
|
||||
- Alpha-sort of ALL_TOOLS preserved.
|
||||
- One new dep only: none (MCP SDK already installed from v1.14.1).
|
||||
- 348+ existing tests still pass.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
- `apps/server/src/services/mcp-client.ts` — refactor from singleton to multi-server registry (~200→300 lines)
|
||||
- `apps/server/src/services/tools.ts` — no changes expected (appendMcpTools already works for multiple tools)
|
||||
- `apps/server/src/config.ts` — replace MCP env vars with `MCP_CONFIG_PATH`
|
||||
- `apps/server/src/index.ts` — startup reads config file, iterates servers
|
||||
- `apps/server/src/services/agents.ts` — glob pattern support in `tools:` whitelist
|
||||
- `data/mcp.json` — NEW, example config with Context7 (disabled by default, enabled via edit)
|
||||
- `apps/server/src/services/__tests__/mcp-client.test.ts` — update for multi-server, add stdio transport tests
|
||||
- `apps/server/src/services/__tests__/agents-glob.test.ts` — NEW, glob pattern matching tests
|
||||
|
||||
## Estimate
|
||||
|
||||
~350 LoC. The MCP SDK handles both transports; BooCode's job is config parsing, multi-server lifecycle, and glob matching.
|
||||
|
||||
## Smoke plan
|
||||
|
||||
1. Create `/data/mcp.json` with Context7 enabled. Restart. Confirm tools discovered + logged.
|
||||
2. Send a chat asking about library docs. Confirm `context7_*` tools called + results rendered.
|
||||
3. Disable Context7 in config (`"enabled": false`). Restart. Confirm zero MCP tools.
|
||||
4. Add a dummy stdio server entry pointing to `/bin/cat` (will fail). Confirm graceful degradation: Context7 works, dummy fails with a logged warning.
|
||||
5. Add `tools: [context7_*]` to the Architect agent in AGENTS.md. Confirm Architect sees only Context7 tools (via AgentPicker or by chatting with Architect selected).
|
||||
6. Stop boocode, confirm child processes are killed (no orphans).
|
||||
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal file
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# v1.15.0-mcp-multi tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [ ] `mcp-client.ts`, `config.ts`, `index.ts`, `agents.ts`, `mcp-client.test.ts`
|
||||
|
||||
## B2 — MCP config file schema + loader
|
||||
|
||||
- [ ] NEW `apps/server/src/services/mcp-config.ts` (~50 lines)
|
||||
- [ ] Zod schema for `mcp.json`: `McpServerConfig` with `type`, `url/command/args/env`, `headers`, `enabled`
|
||||
- [ ] `loadMcpConfig(configPath: string, log): McpServerConfig[]` — reads JSON, validates, returns enabled servers
|
||||
- [ ] Graceful: file missing → log info, return empty array (no MCP)
|
||||
- [ ] Graceful: parse error → log warn with details, return empty array
|
||||
|
||||
## B3 — Config.ts: replace MCP env vars
|
||||
|
||||
- [ ] Remove `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from Zod schema
|
||||
- [ ] Add `MCP_CONFIG_PATH: z.string().optional()` (no default — opt-in)
|
||||
|
||||
## B4 — Refactor mcp-client.ts to multi-server registry
|
||||
|
||||
- [ ] Replace module-level singleton with `Map<serverName, {client, transport, tools}>`
|
||||
- [ ] `initialize(servers: McpServerConfig[], log)` — iterate servers, connect each, discover tools, wrap with `<serverName>_<toolName>` prefix, apply read-only guard
|
||||
- [ ] Streamable HTTP transport: reuse existing pattern from v1.14.1
|
||||
- [ ] Stdio transport: use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (check SDK exports; fallback to `child_process.spawn` + NDJSON if SDK doesn't expose it)
|
||||
- [ ] `callTool(prefixedName, args)` — extract server name from prefix, route to correct client
|
||||
- [ ] `getTools()` — return all tools from all servers, flattened
|
||||
- [ ] `getMcpServers()` — return status of each server (name, type, toolCount, connected)
|
||||
- [ ] Per-server graceful degradation: catch per-server errors, log, skip; continue with others
|
||||
- [ ] `shutdown()` — kill stdio child processes, close HTTP clients
|
||||
- [ ] `app.addHook('onClose')` calls shutdown
|
||||
|
||||
## B5 — Startup wiring (index.ts)
|
||||
|
||||
- [ ] Read config: `const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json'`
|
||||
- [ ] `const mcpServers = loadMcpConfig(mcpConfigPath, app.log)`
|
||||
- [ ] `await mcpClient.initialize(mcpServers, app.log)`
|
||||
- [ ] `appendMcpTools(mcpClient.getTools())`
|
||||
- [ ] Log summary: "mcp: N servers connected, M tools registered"
|
||||
- [ ] `app.addHook('onClose', () => mcpClient.shutdown())`
|
||||
|
||||
## B6 — AGENTS.md glob patterns
|
||||
|
||||
- [ ] `apps/server/src/services/agents.ts` — in tool whitelist validation, skip validation for entries containing `*` (can't validate against runtime-discovered tools)
|
||||
- [ ] NEW helper `matchToolGlob(toolName: string, patterns: string[]): boolean` — supports `*` wildcard and `!` deny prefix, last-match-wins
|
||||
- [ ] Wire into `executeStreamPhase` (stream-phase.ts) where agent tools are filtered: replace exact-match `.includes()` with `matchToolGlob()`
|
||||
- [ ] Export `matchToolGlob` for test access
|
||||
|
||||
## B7 — Example config file
|
||||
|
||||
- [ ] NEW `data/mcp.json` with Context7 entry (enabled: true, with URL, no API key)
|
||||
- [ ] Comment in the file noting it's bind-mounted at `/data/mcp.json` inside the container
|
||||
|
||||
## B8 — Tests
|
||||
|
||||
- [ ] Update `mcp-client.test.ts` for multi-server wrapping (tools from two servers, prefix routing)
|
||||
- [ ] Test: server A fails, server B succeeds — only B's tools registered
|
||||
- [ ] Test: callTool routes to correct server by prefix
|
||||
- [ ] Test: shutdown kills stdio transports
|
||||
- [ ] NEW `apps/server/src/services/__tests__/mcp-glob.test.ts`
|
||||
- [ ] Test: exact match ("grep" matches "grep")
|
||||
- [ ] Test: wildcard ("context7_*" matches "context7_query-docs")
|
||||
- [ ] Test: deny ("!web_*" excludes "web_search")
|
||||
- [ ] Test: last-match-wins ("*" then "!web_*" → web tools excluded)
|
||||
- [ ] Test: empty pattern list → nothing matches (agent gets no tools — same as current behavior for explicit whitelists)
|
||||
|
||||
## B9 — Verification
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [ ] `pnpm -C apps/server test` — all passing
|
||||
- [ ] `pnpm -C apps/web build` — green (no web changes)
|
||||
|
||||
## B10 — Deploy + smoke
|
||||
|
||||
- [ ] Create `/data/mcp.json` on the host with Context7 enabled
|
||||
- [ ] Update docker-compose bind mount if needed (data/ already mounted)
|
||||
- [ ] `docker compose up --build -d`
|
||||
- [ ] Check logs for multi-server init
|
||||
- [ ] Live-smoke: Context7 tool call from chat
|
||||
- [ ] Disable Context7 in config, restart, confirm zero MCP tools
|
||||
|
||||
## B11 — Docs + tag
|
||||
|
||||
- [ ] `CHANGELOG.md` entry
|
||||
- [ ] `boocode_roadmap.md` retrospective bullet on v1.15 section
|
||||
- [ ] `CLAUDE.md` — update MCP references
|
||||
- [ ] Commit, tag `v1.15.0-mcp-multi`, push, rebuild
|
||||
413
openspec/changes/v2.0-boocoder/implementation-plan.md
Normal file
413
openspec/changes/v2.0-boocoder/implementation-plan.md
Normal file
@@ -0,0 +1,413 @@
|
||||
# v2.0 BooCoder — Implementation Plan
|
||||
|
||||
Ordered execution plan across all 4 sub-versions. Each phase is dispatchable as a single batch. Phases 1-4 are sequential (each builds on the prior); phases within a sub-version can sometimes be parallelized.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundation (v2.0.0-alpha)
|
||||
|
||||
**Goal:** Standalone BooCoder container boots, connects to DB, serves a health endpoint. No inference yet.
|
||||
|
||||
**Estimated:** ~200 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Clone lift sources** (prep, no code)
|
||||
- `cd /opt/forks && git clone agent-hub, plandex, opencode, qodo-ai/agents`
|
||||
- Read agent-hub schema, plandex pending-changes, opencode permission/evaluate.ts
|
||||
- Read RA.Aid README for three-stage pattern
|
||||
|
||||
2. **Create `apps/coder/` skeleton**
|
||||
- `apps/coder/package.json` (Fastify, postgres, zod — same deps as `apps/server`)
|
||||
- `apps/coder/tsconfig.json` (extends base, NodeNext)
|
||||
- `apps/coder/src/index.ts` (Fastify boot, health endpoint, DB connect)
|
||||
- `apps/coder/src/config.ts` (Zod config schema — DATABASE_URL, PORT, HOST, LLAMA_SWAP_URL, CONTAINER_GUIDANCE_FILE)
|
||||
- `apps/coder/src/db.ts` (postgres connection, schema apply — shared with `apps/server` or fresh)
|
||||
|
||||
3. **Create Dockerfile**
|
||||
- `apps/coder/Dockerfile` — Node 20 bookworm-slim (matches booterm for glibc compat with node-pty later)
|
||||
- Mount: `/opt:/opt:rw`
|
||||
- COPY built server + BOOCODER.md
|
||||
|
||||
4. **docker-compose.yml** — add `boocoder` service
|
||||
- Port `100.114.205.53:9502:3000`
|
||||
- Environment: `DATABASE_URL`, `LLAMA_SWAP_URL`, `CONTAINER_GUIDANCE_FILE=/app/BOOCODER.md`
|
||||
- Network: `boocode_net`
|
||||
- Depends on: `boocode_db`
|
||||
|
||||
5. **DB rename** — `boocode_db` → `boochat_db`
|
||||
- `ALTER DATABASE boocode RENAME TO boochat;` (one-time, run manually)
|
||||
- Update `DATABASE_URL` in all docker-compose services
|
||||
- Update volume name mapping
|
||||
- Verify all 3 services boot against renamed DB
|
||||
|
||||
6. **Schema migration** — new tables in `apps/coder/src/schema.sql`
|
||||
- `pending_changes` table
|
||||
- `tasks` table
|
||||
- `available_agents` table
|
||||
- `human_inbox` view
|
||||
- Applied idempotently on boot (same pattern as BooChat's `applySchema()`)
|
||||
|
||||
7. **BOOCODER.md** — container guidance file
|
||||
- Write tools enabled (unlike BOOCHAT.md which declares read-only)
|
||||
- Pending-changes queue discipline
|
||||
- Path-guard rules
|
||||
|
||||
### Verification
|
||||
- `docker compose up --build -d` — boocoder container starts
|
||||
- `curl http://100.114.205.53:9502/api/health` — 200 OK
|
||||
- `psql` confirms new tables exist
|
||||
- BooChat + BooTerm unaffected (still boot, still serve)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Write Tools + Pending Changes (v2.0.0-beta)
|
||||
|
||||
**Goal:** BooCoder can chat with the LLM, the LLM can call write tools, changes queue in `pending_changes`, user can apply/reject.
|
||||
|
||||
**Estimated:** ~400 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Write-path guard** (`apps/coder/src/services/write_guard.ts`)
|
||||
- `resolveWritePath(projectRoot, filePath): string` — `resolve()` + prefix check (no realpath — file may not exist for creates)
|
||||
- Deny list: inherit from BooChat's `secret_guard.ts` (`.env`, `*.pem`, `id_rsa*`, etc.)
|
||||
- Fuzz tests: `../` escape, symlink outside root, null bytes, non-existent parent dirs
|
||||
|
||||
2. **Pending-changes service** (`apps/coder/src/services/pending_changes.ts`)
|
||||
- `queueEdit(session_id, task_id, file_path, old_string, new_string): PendingChange` — computes unified diff, validates write path, INSERTs
|
||||
- `queueCreate(session_id, task_id, file_path, content): PendingChange`
|
||||
- `queueDelete(session_id, task_id, file_path): PendingChange`
|
||||
- `applyAll(session_id): ApplyResult[]` — re-validates each path, writes to disk, marks `status='applied'`
|
||||
- `applyOne(change_id): ApplyResult`
|
||||
- `rejectOne(change_id): void` — marks `status='rejected'`
|
||||
- `rejectAll(session_id): void`
|
||||
- `rewindOne(change_id): void` — inverse-diff, writes to disk, marks `status='reverted'`
|
||||
- `listPending(session_id): PendingChange[]`
|
||||
|
||||
3. **Write tools** (`apps/coder/src/services/tools/`)
|
||||
- `edit_file.ts` — input: `{file_path, old_string, new_string}`, calls `queueEdit`
|
||||
- `create_file.ts` — input: `{file_path, content}`, calls `queueCreate`
|
||||
- `delete_file.ts` — input: `{file_path}`, calls `queueDelete`
|
||||
- `apply_pending.ts` — calls `applyAll` for current session
|
||||
- `rewind.ts` — input: `{change_id}` or `{all: true}`, calls `rewindOne`/`rewindAll`
|
||||
|
||||
4. **Tool registry** — register write tools alongside ALL read tools from BooChat
|
||||
- Import BooChat's read tools (view_file, grep, etc.) + codecontext tools
|
||||
- Add the 5 write tools
|
||||
- Alpha-sort the combined list
|
||||
|
||||
5. **Inference loop** — port from BooChat or share via workspace package
|
||||
- Copy `apps/server/src/services/inference/` into `apps/coder/src/services/inference/` (or symlink via pnpm workspace)
|
||||
- The outer loop (v1.14) runs unchanged — write tools are just ToolDefs with `execute()` functions
|
||||
- Compaction, doom-loop, step cap all carry forward
|
||||
|
||||
6. **API routes**
|
||||
- `POST /api/sessions/:id/messages` — same as BooChat (creates user + assistant rows, enqueues inference)
|
||||
- `GET /api/sessions/:id/pending` — returns pending changes for the session
|
||||
- `POST /api/sessions/:id/pending/apply` — applies all pending
|
||||
- `POST /api/pending/:id/apply` — applies one
|
||||
- `POST /api/pending/:id/reject` — rejects one
|
||||
- `POST /api/pending/:id/rewind` — reverts one
|
||||
- WebSocket streaming (same protocol as BooChat)
|
||||
|
||||
### Verification
|
||||
- Send a chat asking BooCoder to edit a file
|
||||
- LLM calls `edit_file` → change queued in `pending_changes`
|
||||
- `GET /api/sessions/:id/pending` shows the queued change with diff
|
||||
- `POST /api/pending/:id/apply` writes to disk
|
||||
- `POST /api/pending/:id/rewind` reverts it
|
||||
- Fuzz test: attempt traversal via `edit_file("../../etc/passwd", ...)` → rejected by write_guard
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend: Diff Pane + Chat (v2.0.0)
|
||||
|
||||
**Goal:** Browser UI at `coder.indifferentketchup.com` with chat pane + diff pane side by side.
|
||||
|
||||
**Estimated:** ~200 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create `apps/coder/web/`** — React + Vite SPA (same stack as BooChat's `apps/web/`)
|
||||
- Copy BooChat's Vite config, Tailwind v4 setup, font pipeline
|
||||
- Shared components: `MarkdownRenderer`, `CodeBlock`, `Button`, `Input`
|
||||
- New app shell: sidebar (sessions) + workspace (panes)
|
||||
|
||||
2. **Chat pane** — reuse BooChat's ChatPane/MessageBubble pattern
|
||||
- Same WS streaming, same `useSessionStream` hook, same message rendering
|
||||
- ActionRow includes tool-call rendering for write tools
|
||||
|
||||
3. **Diff pane** — NEW (`apps/coder/web/src/components/DiffPane.tsx`)
|
||||
- Fetches `GET /api/sessions/:id/pending`
|
||||
- Lists pending changes: file path + operation badge (create/edit/delete)
|
||||
- Per-change: syntax-highlighted unified diff view (use Shiki or a diff-specific highlighter)
|
||||
- Buttons: Approve / Reject per change, Approve All / Reject All
|
||||
- Real-time updates via WS frame (`pending_change_added`, `pending_change_applied`, etc.)
|
||||
|
||||
4. **Workspace splitter** — chat left, diff right (or configurable)
|
||||
|
||||
5. **Caddy route** — `coder.indifferentketchup.com` → boocoder:9502
|
||||
- Authelia gating (same as BooChat)
|
||||
|
||||
### Verification
|
||||
- Open `coder.indifferentketchup.com` in browser
|
||||
- Send a message asking for a code change
|
||||
- See the change appear in the diff pane in real time
|
||||
- Click Approve → file written, change marked applied
|
||||
- Click Reject → change discarded
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Dispatcher + Tasks (v2.0.0 final)
|
||||
|
||||
**Goal:** Task queue works. User can create tasks, dispatcher picks them up and runs them through Path A.
|
||||
|
||||
**Estimated:** ~150 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Dispatcher** (`apps/coder/src/services/dispatcher.ts`)
|
||||
- In-process `setInterval(5000)` polling `tasks` WHERE `state='pending'` ORDER BY `created_at`
|
||||
- For each ready task: mark `state='running'`, run inference with the task's `input` as the user message
|
||||
- On completion: mark `state='completed'`
|
||||
- On error: mark `state='failed'`
|
||||
- On abort: mark `state='cancelled'`
|
||||
- Respects `app.addHook('onClose')` — stops polling, waits for in-flight task
|
||||
|
||||
2. **Task API routes**
|
||||
- `POST /api/tasks` — create a task `{project_id, input, agent?, model?}`
|
||||
- `GET /api/tasks` — list tasks (filterable by state, project)
|
||||
- `GET /api/tasks/:id` — get task details + output_summary
|
||||
- `POST /api/tasks/:id/cancel` — cancel a running task
|
||||
|
||||
3. **Task → session linkage**
|
||||
- Each task creates its own session + chat for isolation
|
||||
- Task's pending_changes reference the task_id
|
||||
- When task completes, its pending_changes are visible in the UI for approval
|
||||
|
||||
4. **Agent probing** (`apps/coder/src/services/agent-probe.ts`)
|
||||
- On startup: `which opencode`, `which goose`, `which claude`, `which pi`
|
||||
- Parse version from `<agent> --version`
|
||||
- Check ACP support: `opencode acp --help` exits 0 → supports_acp = true
|
||||
- Populate `available_agents` table
|
||||
|
||||
### Verification
|
||||
- `POST /api/tasks {input: "add a /api/version endpoint"}` → task created
|
||||
- Dispatcher picks it up → inference runs → `edit_file` queued → task completes
|
||||
- `GET /api/tasks/:id` shows `state='completed'` + output_summary
|
||||
- Pending changes visible in diff pane for approval
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — ACP Dispatch (v2.0.1)
|
||||
|
||||
**Goal:** Tasks can be dispatched to external agents via ACP. opencode and goose run as subprocesses, their events flow back into BooCode.
|
||||
|
||||
**Estimated:** ~350 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **ACP client** (`apps/coder/src/services/acp-client.ts`)
|
||||
- Install: `pnpm -C apps/coder add @zed-industries/agent-client-protocol`
|
||||
- `spawnAcpAgent(agent: string, task: string, worktree: string, mcpServers: McpConfig[]): AcpSession`
|
||||
- Uses SDK's `StdioTransport` — spawn `opencode acp` or `goose acp` as child
|
||||
- Pass `context_servers` for MCP auto-forward
|
||||
- Event listener: maps ACP events to BooCode's parts taxonomy
|
||||
|
||||
2. **ACP event mapping**
|
||||
- `file_operation` → queue into `pending_changes` (same as Path A native writes)
|
||||
- `tool_call` / `tool_result` → insert as `message_parts` in the task's session
|
||||
- `terminal_output` → publish as WS frame for BooTerm routing
|
||||
- `permission_request` → pause (same mechanism as `ask_user_input`)
|
||||
- `session_end` → task state → `completed` or `failed`
|
||||
|
||||
3. **Worktree management** (`apps/coder/src/services/worktrees.ts`)
|
||||
- `createWorktree(projectPath, taskId): string` — `git worktree add /tmp/booworktrees/<taskId> -b task-<taskId> HEAD`
|
||||
- `diffWorktree(worktreePath, projectPath): UnifiedDiff[]` — `git diff HEAD...<worktree-branch>`
|
||||
- `cleanupWorktree(worktreePath): void` — `git worktree remove`
|
||||
- On ACP session end: diff the worktree, queue diffs into `pending_changes`, cleanup
|
||||
|
||||
4. **PTY fallback** (`apps/coder/src/services/pty-dispatch.ts`)
|
||||
- For agents without ACP (claude, pi, smallcode)
|
||||
- `spawnPtyAgent(agent: string, task: string, worktree: string): PtySession`
|
||||
- Uses `node-pty` — spawn `claude` or `pi` with cwd = worktree
|
||||
- Capture stdout/stderr into `message_parts` (kind='text', less structured than ACP)
|
||||
- On exit: diff worktree → queue pending_changes → cleanup
|
||||
|
||||
5. **Dispatcher update** — transport selection
|
||||
- Check `available_agents[agent].supports_acp` at dispatch time
|
||||
- ACP-capable → `spawnAcpAgent`
|
||||
- PTY fallback → `spawnPtyAgent`
|
||||
- Native (no agent specified) → Path A inference loop (Phase 4)
|
||||
|
||||
6. **AGENTS.md extensions**
|
||||
- Add `execution_strategy: plan | act | research` field
|
||||
- Add `expert_model` field for cost-routing
|
||||
- Add `output_schema` field (optional JSON Schema for structured final output)
|
||||
|
||||
### Verification
|
||||
- Create task with `agent: 'opencode'` → ACP subprocess spawns
|
||||
- opencode edits files in worktree → events stream into UI
|
||||
- On completion: worktree diff queued in `pending_changes`
|
||||
- Approve → changes applied to main project
|
||||
- Fallback: create task with `agent: 'claude'` → PTY captures output → worktree diff queued
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — MCP Server (v2.0.2)
|
||||
|
||||
**Goal:** BooCoder exposes its own primitives as MCP tools. External opencode sessions in Termius can drive the task queue.
|
||||
|
||||
**Estimated:** ~250 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **MCP server** (`apps/coder/src/services/mcp-server.ts`)
|
||||
- Use `@modelcontextprotocol/sdk` server-side (`Server` class)
|
||||
- Stdio transport (read from stdin, write to stdout)
|
||||
- Entry point: `boocoder --mcp` CLI flag starts the MCP server instead of the HTTP server
|
||||
|
||||
2. **Tool handlers** (6 tools)
|
||||
- `boocoder.create_task` → INSERT into tasks table, return task_id
|
||||
- `boocoder.list_pending_changes` → SELECT from pending_changes WHERE session matches
|
||||
- `boocoder.apply` → call `applyOne(change_id)`
|
||||
- `boocoder.reject` → call `rejectOne(change_id)`
|
||||
- `boocoder.dispatch_external_agent` → create task with agent specified, return task_id
|
||||
- `boocoder.list_worktrees` → list active worktrees from tasks WHERE worktree_path IS NOT NULL AND state='running'
|
||||
|
||||
3. **10-question eval** (per `anthropics/skills/mcp-builder` framework)
|
||||
- Write 10 independent, read-only, verifiable questions about the BooCoder state
|
||||
- Run eval: `echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"boocoder.list_pending_changes","arguments":{}},"id":1}' | boocoder --mcp`
|
||||
- All 10 must return correct answers
|
||||
|
||||
4. **opencode integration test**
|
||||
- Add BooCoder as an MCP server in `~/.opencode/config.json`:
|
||||
```json
|
||||
{"mcpServers": {"boocoder": {"type": "stdio", "command": "boocoder", "args": ["--mcp"]}}}
|
||||
```
|
||||
- From opencode: call `boocoder.create_task` → verify task appears in BooCoder UI
|
||||
|
||||
### Verification
|
||||
- `echo '...' | boocoder --mcp` returns valid MCP responses
|
||||
- 10-question eval passes
|
||||
- opencode can drive BooCoder's task queue via MCP
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — CLI + Polish (v2.0.3)
|
||||
|
||||
**Goal:** `boocode` CLI client, human inbox UI, cost tracking, observation hooks.
|
||||
|
||||
**Estimated:** ~400 LoC
|
||||
|
||||
### Steps
|
||||
|
||||
1. **CLI client** (`apps/coder/src/cli.ts`)
|
||||
- Thin HTTP/WS client against BooCoder API
|
||||
- `boocode run "task description"` → POST /api/tasks → stream output via WS
|
||||
- `boocode ls` → GET /api/tasks → formatted table
|
||||
- `boocode attach <id>` → WS subscribe to task's session → stream live
|
||||
- `boocode send <id> "message"` → POST message to task's session chat
|
||||
- Build as a standalone binary via `pkg` or `esbuild --bundle`
|
||||
|
||||
2. **Human inbox UI** (frontend)
|
||||
- New route: `/inbox` → shows tasks WHERE `state IN ('blocked', 'failed')`
|
||||
- Per-task: view output, retry (reset state to pending), cancel, reassign agent
|
||||
- Badge on sidebar showing count of inbox items
|
||||
|
||||
3. **Cost tracking**
|
||||
- `tasks.cost_tokens` populated from inference `usage` callback (same as BooChat's `tokens_used`)
|
||||
- Summary API: `GET /api/stats/costs?group_by=project|agent|day` → aggregated token spend
|
||||
- Simple UI: cost badge on each task, totals in settings
|
||||
|
||||
4. **Observation hooks** (budi taxonomy)
|
||||
- Emit 5 event types on the BooCoder WS protocol for dispatched agents:
|
||||
- `session_start` — agent spawned
|
||||
- `user_prompt_submit` — task spec delivered
|
||||
- `post_tool_use` — each tool call completed
|
||||
- `subagent_start` — nested dispatch (Boomerang)
|
||||
- `stop` — agent finished
|
||||
- Consumed by frontend for real-time status indicators
|
||||
|
||||
5. **Boomerang `new_task` tool** (subagent isolation)
|
||||
- When an agent's toolset includes `new_task`:
|
||||
- Creates a child task (fresh session, fresh context)
|
||||
- Child runs to completion
|
||||
- Parent gets only `attempt_completion` summary
|
||||
- Orchestrator agent profile: tools = `[new_task, list_tasks, check_task_status]` ONLY
|
||||
|
||||
### Verification
|
||||
- `boocode run "add health endpoint"` from terminal → task runs → output streams → diff queued
|
||||
- `boocode ls` shows task list with states + cost
|
||||
- Inbox shows failed tasks, retry works
|
||||
- Boomerang: orchestrator creates subtask → subtask runs isolated → parent gets summary only
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Hardening + Ship (v2.0.x)
|
||||
|
||||
**Goal:** Security hardening, integration tests, documentation, production deploy.
|
||||
|
||||
**Estimated:** ~100 LoC (mostly tests + docs)
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Path-guard fuzz suite** — property tests for every traversal pattern:
|
||||
- `../` sequences (all depths)
|
||||
- Symlink outside project root
|
||||
- Null bytes in path
|
||||
- Unicode normalization attacks
|
||||
- Race conditions (TOCTOU between validate + write)
|
||||
- MCP-served filesystem writes routed through pending_changes
|
||||
|
||||
2. **Integration tests**
|
||||
- End-to-end: create task → inference → edit_file → apply → file written → verify content
|
||||
- ACP dispatch: mock opencode → events flow → pending_changes queued
|
||||
- MCP server: 10-question eval automated in CI
|
||||
|
||||
3. **Documentation**
|
||||
- `BOOCODER.md` finalized (container guidance)
|
||||
- `CLAUDE.md` updated with BooCoder architecture section
|
||||
- `boocode_roadmap.md` v2.0 retrospective
|
||||
- `CHANGELOG.md` entries for each sub-version
|
||||
|
||||
4. **Production deploy**
|
||||
- Caddy config: `coder.indifferentketchup.com`
|
||||
- Authelia: same SSO group as BooChat
|
||||
- Smoke: full workflow (chat → edit → approve → verify)
|
||||
|
||||
5. **Tag** — `v2.0.0` (or `v2.0.0-rc1` if Sam wants a bake period)
|
||||
|
||||
---
|
||||
|
||||
## Execution order summary
|
||||
|
||||
```
|
||||
Phase 1 (foundation) → v2.0.0-alpha ~200 LoC container boots
|
||||
Phase 2 (write tools) → v2.0.0-beta ~400 LoC inference + pending_changes
|
||||
Phase 3 (frontend) → v2.0.0 ~200 LoC chat + diff panes
|
||||
Phase 4 (dispatcher) → v2.0.0-final ~150 LoC task queue + native dispatch
|
||||
Phase 5 (ACP dispatch) → v2.0.1 ~350 LoC external agents + worktrees
|
||||
Phase 6 (MCP server) → v2.0.2 ~250 LoC boocoder.* tools + eval
|
||||
Phase 7 (CLI + polish) → v2.0.3 ~400 LoC CLI + inbox + hooks + Boomerang
|
||||
Phase 8 (hardening) → v2.0.x ~100 LoC fuzz + integration tests + docs
|
||||
--------
|
||||
~2050 LoC total
|
||||
```
|
||||
|
||||
Each phase is independently dispatchable. Phases 1-4 are sequential (each needs the prior). Phases 5-7 are parallelizable after Phase 4 ships (they're independent protocol surfaces). Phase 8 gates the production tag.
|
||||
|
||||
---
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Path-guard bypass → arbitrary writes | Pending-changes double-validates (at queue time + apply time). Fuzz suite in Phase 8. OpenHands sandbox (v2.1) as fallback. |
|
||||
| ACP spec instability (remote transport WIP) | Use stdio only. No remote ACP in v2.0. |
|
||||
| node-pty native compilation breaks in Docker | bookworm-slim + glibc matches booterm's working config. Pin node-pty version. |
|
||||
| Worktree cleanup failure → disk bloat | 30-min idle timeout sweeper. `git worktree prune` on startup. |
|
||||
| DB rename breaks existing sessions | One-time migration with explicit backup. BooChat/BooTerm URLs unchanged. |
|
||||
| MCP server eval failure | Ship stdio MCP server only after 10/10 eval passes. |
|
||||
| Boomerang context leak (child leaks state to parent) | Architectural enforcement: child's session_id ≠ parent's. Summary field is the ONLY bridge. |
|
||||
346
openspec/changes/v2.0-boocoder/proposal.md
Normal file
346
openspec/changes/v2.0-boocoder/proposal.md
Normal file
@@ -0,0 +1,346 @@
|
||||
# v2.0 — BooCoder
|
||||
|
||||
Major version bump. New app `apps/coder/` inside the existing monorepo. Lands together with the `boocode_db` → `boochat_db` DB rename and the per-app subdomain split (`code.indifferentketchup.com` → BooChat, `coder.indifferentketchup.com` → BooCoder).
|
||||
|
||||
## What BooCoder is
|
||||
|
||||
A write-capable coding agent surface. Two execution paths, same UI:
|
||||
|
||||
- **Path A (native):** BooCode's own inference loop with write tools (`edit_file`, `create_file`, `delete_file`). Edits queue in `pending_changes` — nothing touches disk until user approves via `/apply`.
|
||||
- **Path B (dispatch):** Shells out to external CLI agents (`opencode`, `goose`, `claude`, `pi`) via ACP (preferred) or raw PTY (fallback). One git worktree per dispatch. Captures events into the same parts taxonomy.
|
||||
|
||||
Both paths feed the same task DAG, same project registry, same pending-changes queue, same UI.
|
||||
|
||||
## Why now
|
||||
|
||||
v1.x proved the read-only loop works end-to-end: inference, tool dispatch, streaming, compaction, MCP client, outer loop, step caps, artifact rendering. The infrastructure is stable. The jump from "read-only chat" to "write-capable agent orchestrator" is the remaining gap between BooCode and having a real development environment.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Three protocol roles (locked 2026-05-22)
|
||||
|
||||
1. **MCP client (write-capable allowed).** Inherits v1.15 client. Write-capable MCP servers (e.g. `@modelcontextprotocol/server-filesystem`) route writes through `pending_changes`. Per-task allow/deny means dispatched tasks can have a different MCP roster.
|
||||
2. **MCP server (BooCoder's own primitives).** Exposes `boocoder.create_task`, `boocoder.list_pending_changes`, `boocoder.apply`, `boocoder.reject`, `boocoder.dispatch_external_agent`, `boocoder.list_worktrees` as MCP tools. Stdio transport for local consumers (Sam's `opencode` in Termius); HTTP deferred until OAuth + secret storage.
|
||||
3. **ACP client (host).** Spawns `opencode acp` and `goose acp` as JSON-RPC stdio subprocesses. Maps ACP events (file operations, tool calls, terminal output) to BooCode's parts taxonomy. MCP servers configured in BooCoder are auto-forwarded to the dispatched agent (per goose docs — `context_servers` is the field).
|
||||
|
||||
### Container layout (post-v2.0)
|
||||
|
||||
| Container | Port | Mount | Purpose |
|
||||
|---|---|---|---|
|
||||
| `boochat` (was `boocode`) | `100.114.205.53:9500` | `/opt:/opt:ro` | Read-only chat + MCP client |
|
||||
| `booterm` | `100.114.205.53:9501` | `/opt:/opt:rw` | PTY/tmux terminal |
|
||||
| `boocoder` | `100.114.205.53:9502` | `/opt:/opt:rw` (policy-gated) | Write tools + ACP host + MCP client + MCP server |
|
||||
| `boochat_db` (was `boocode_db`) | `127.0.0.1:5500` | `boocode_pgdata` | Shared Postgres 16 |
|
||||
| `codecontext` | internal `:8080` | `/opt:/opt:ro` | Analysis sidecar (shared) |
|
||||
|
||||
### Caddy routing
|
||||
|
||||
```
|
||||
code.indifferentketchup.com → boochat:9500
|
||||
coder.indifferentketchup.com → boocoder:9502
|
||||
term.indifferentketchup.com → booterm:9501 (or routed under code.*/term/)
|
||||
```
|
||||
|
||||
## Schema (new tables)
|
||||
|
||||
```sql
|
||||
-- Pending changes: queued writes before /apply
|
||||
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES sessions(id),
|
||||
task_id UUID REFERENCES tasks(id),
|
||||
file_path TEXT NOT NULL,
|
||||
operation TEXT NOT NULL CHECK (operation IN ('create', 'edit', 'delete')),
|
||||
diff TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'applied', 'rejected', 'reverted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- Tasks: the dispatch DAG
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL REFERENCES projects(id),
|
||||
parent_task_id UUID REFERENCES tasks(id),
|
||||
state TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||
input TEXT NOT NULL,
|
||||
output_summary TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
execution_path TEXT CHECK (execution_path IN ('native', 'acp', 'pty')),
|
||||
worktree_path TEXT,
|
||||
cost_tokens INTEGER,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||
);
|
||||
|
||||
-- Available agents: probed at startup
|
||||
CREATE TABLE IF NOT EXISTS available_agents (
|
||||
name TEXT PRIMARY KEY,
|
||||
install_path TEXT,
|
||||
version TEXT,
|
||||
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||
last_probed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- Human inbox: tasks needing attention
|
||||
CREATE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
```
|
||||
|
||||
`task_templates` and `pipelines` deferred to v2.1 — overhead for single-user. The core is `tasks` + `pending_changes` + `available_agents`.
|
||||
|
||||
## Path A — Native write tools
|
||||
|
||||
### Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `edit_file` | Apply a diff to an existing file. Input: `{file_path, old_string, new_string}`. Queues in `pending_changes` with `operation='edit'`. |
|
||||
| `create_file` | Create a new file. Input: `{file_path, content}`. Queues as `operation='create'`. |
|
||||
| `delete_file` | Delete a file. Input: `{file_path}`. Queues as `operation='delete'`. |
|
||||
| `apply_pending` | Flush all pending changes for the current session to disk. Path-guarded. |
|
||||
| `rewind` | Revert a specific applied change or all changes since a checkpoint. |
|
||||
|
||||
### Path guard for writes
|
||||
|
||||
Same `pathGuard()` function from BooChat, but with a write-path variant:
|
||||
- `resolveWritePath(projectRoot, requested)` — uses `resolve()` (not `realpath()`, since the file may not exist yet for creates), then verifies the result starts with `projectRoot + sep`.
|
||||
- Deny list: everything in `secret_guard.ts` (`.env`, `*.pem`, etc.) — can't write to those either.
|
||||
- Defense-in-depth: the `pending_changes` queue means even a path-guard bypass only queues; it doesn't hit disk until `/apply` (which re-validates).
|
||||
|
||||
### Diff format
|
||||
|
||||
Standard unified diff (what `git diff` produces). The `edit_file` tool takes `old_string` / `new_string` (same as Claude Code's edit tool — the model is trained on this shape). Server computes the unified diff for storage in `pending_changes.diff`.
|
||||
|
||||
### UI: per-pane diff viewer
|
||||
|
||||
Frontend pane type `pending_changes` in BooCoder's workspace. Shows:
|
||||
- List of queued changes with file path + operation
|
||||
- Per-change diff view (syntax-highlighted, side-by-side or unified)
|
||||
- Approve / Reject per change, or Approve All / Reject All
|
||||
|
||||
## Path B — External agent dispatch
|
||||
|
||||
### dispatch_external_agent tool
|
||||
|
||||
```typescript
|
||||
{
|
||||
agent: 'opencode' | 'claude' | 'goose' | 'pi',
|
||||
model: string, // e.g. 'claude-opus-4-7'
|
||||
task: string, // natural-language task description
|
||||
worktree?: string, // optional — auto-creates if not specified
|
||||
}
|
||||
```
|
||||
|
||||
### Transport selection
|
||||
|
||||
Dispatcher checks `available_agents.supports_acp` at runtime:
|
||||
- **ACP** (preferred): `opencode acp`, `goose acp` — JSON-RPC stdio. Native session lifecycle, file-operation events, terminal events, permission prompts.
|
||||
- **PTY** (fallback): `claude`, `pi`, `smallcode` — raw terminal capture via `node-pty`. Captures stdout/stderr/exit-code into PostgreSQL. Less structured than ACP.
|
||||
|
||||
### Worktree management
|
||||
|
||||
Each dispatched task gets its own git worktree:
|
||||
```bash
|
||||
git worktree add /tmp/booworktrees/<task-id> -b task-<task-id> HEAD
|
||||
```
|
||||
|
||||
On completion: diff the worktree against HEAD, queue the diff into `pending_changes` for the same task, clean up the worktree. User approves/rejects the diff the same way as Path A.
|
||||
|
||||
### ACP event mapping
|
||||
|
||||
ACP events → BooCode parts taxonomy:
|
||||
- `file_operation` → `tool_call` part (name: `acp_edit_file`) + `tool_result` part
|
||||
- `tool_call` → `tool_call` part (preserves name)
|
||||
- `terminal_output` → routes into BooTerm pane
|
||||
- `permission_request` → pause inference (same mechanism as `ask_user_input`)
|
||||
- `session_end` → task state → `completed` or `failed`
|
||||
|
||||
### MCP server auto-forward
|
||||
|
||||
Per goose docs, `context_servers` field in the ACP session config auto-forwards BooCoder's configured MCP servers to the dispatched agent. One MCP config drives every agent.
|
||||
|
||||
## Dispatcher worker
|
||||
|
||||
Background process (or in-process `setInterval` for v2.0 simplicity) that:
|
||||
1. Queries `tasks` WHERE `state = 'pending'` ORDER BY `created_at`
|
||||
2. For each ready task (no unmet dependencies):
|
||||
- Mark `state = 'running'`
|
||||
- Resolve execution path (Path A if no agent specified, Path B if agent specified)
|
||||
- Path A: run the inference loop with write tools enabled
|
||||
- Path B: spawn ACP/PTY subprocess, stream events into parts
|
||||
- On completion: mark `state = 'completed'` or `'failed'`
|
||||
- Queue output diff into `pending_changes`
|
||||
3. On failure: mark `state = 'failed'`, surface in `human_inbox` view
|
||||
|
||||
## BooCoder MCP server
|
||||
|
||||
Exposes BooCoder's primitives as MCP tools so external agents (Sam's opencode in Termius) can drive the task queue:
|
||||
|
||||
| MCP Tool | Description |
|
||||
|---|---|
|
||||
| `boocoder.create_task` | Create a new task in the queue |
|
||||
| `boocoder.list_pending_changes` | List queued changes awaiting approval |
|
||||
| `boocoder.apply` | Apply a specific pending change |
|
||||
| `boocoder.reject` | Reject a pending change |
|
||||
| `boocoder.dispatch_external_agent` | Dispatch a task to an external agent |
|
||||
| `boocoder.list_worktrees` | List active git worktrees |
|
||||
|
||||
Stdio transport for local consumers. HTTP transport deferred until OAuth + secret storage.
|
||||
|
||||
**Eval requirement:** run through `anthropics/skills mcp-builder` 10-question evaluation framework before shipping.
|
||||
|
||||
## Code lifts
|
||||
|
||||
### Primary architectural template
|
||||
|
||||
**`Dominic789654/agent-hub`** (Apache-2.0) — task DAG schema, dispatcher worker, project registry, human inbox. Three-process model (board server + dispatcher + assistant terminal). BooCode adapts this into a single-process Fastify app (v2.0.0) with the dispatcher as an in-process worker.
|
||||
|
||||
### Pending-changes UX
|
||||
|
||||
**`plandex-ai/plandex`** (MIT) — diff/apply/rewind vocabulary. The `pending_changes` queue concept, per-file diff view, approve/reject UI pattern. No code lifted — schema and UX design only.
|
||||
|
||||
### ACP client
|
||||
|
||||
**`agentclientprotocol.com` spec + `@zed-industries/agent-client-protocol` SDK** (Apache-2.0) — local-subprocess ACP via stdio JSON-RPC. The SDK handles framing; BooCode maps events to its parts taxonomy.
|
||||
|
||||
**`goose` docs** (`goose-docs.ai/docs/guides/acp-clients/`) — `context_servers` auto-forward pattern. Critical: one MCP config drives every dispatched agent.
|
||||
|
||||
### MCP server
|
||||
|
||||
**`anthropics/skills/mcp-builder`** (MIT) — 4-phase build workflow + 10-question evaluation framework for validating the MCP server before shipping.
|
||||
|
||||
### Dispatcher pattern
|
||||
|
||||
**Paseo (`getpaseo/paseo`)** — AGPL-3.0, **design only, no code lift**. Daemon+clients architecture, `--worktree` flag, CLI verb shape (`run/ls/attach/send`). BooCode reproduces the architecture using only license-clean patterns.
|
||||
|
||||
**Roo Code Boomerang Tasks** — orchestrator with intentional capability restriction. Down-pass/up-pass context discipline (`new_task` message, `attempt_completion` result, no implicit inheritance). Explicit precedence override clause.
|
||||
|
||||
### Write-tool security
|
||||
|
||||
**opencode `permission/evaluate.ts`** — wildcard permission ruleset (already lifted in v1.15). Extended in v2.0 to gate write tools.
|
||||
|
||||
**`covibes/zeroshot`** — blind-validation invariant. Verify gate runs in a separate agent context that only sees the diff and acceptance criteria, not the producing conversation. v2.0+ optional batch.
|
||||
|
||||
## Sub-versions
|
||||
|
||||
| Version | Scope |
|
||||
|---|---|
|
||||
| **v2.0.0** | Schema + Path A (native write tools + pending-changes queue + diff UI) + basic dispatcher |
|
||||
| **v2.0.1** | Path B (ACP client for opencode/goose + PTY fallback for claude/pi + worktree management) |
|
||||
| **v2.0.2** | BooCoder MCP server (stdio transport, `boocoder.*` tools, eval framework) |
|
||||
| **v2.0.3** | Polish: `boocode` CLI (`run/ls/attach/send`), human_inbox UI, cost tracking |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- v1.13 ✅ (parts table — the event taxonomy for everything)
|
||||
- v1.14 ✅ (outer loop + step boundaries for future revert snapshots)
|
||||
- v1.14.x-mcp ✅ (MCP client PoC — proves the protocol)
|
||||
- v1.15 ✅ (full MCP client + tool globs — write-capable MCP servers route through pending_changes)
|
||||
- v1.16 ✅ (codesight merge — codecontext now has blast-radius for impact analysis)
|
||||
|
||||
All dependencies shipped. v2.0 is unblocked.
|
||||
|
||||
## Estimate
|
||||
|
||||
- v2.0.0: ~800 LoC (schema + write tools + pending-changes service + diff pane + dispatcher skeleton)
|
||||
- v2.0.1: ~600 LoC (ACP client + PTY dispatch + worktree management + event mapping)
|
||||
- v2.0.2: ~400 LoC (MCP server + 6 tool handlers + stdio transport + eval)
|
||||
- v2.0.3: ~400 LoC (CLI client + inbox UI + cost aggregation)
|
||||
- **Total: ~2200 LoC** across 4 sub-versions
|
||||
|
||||
## Hard rules
|
||||
|
||||
- BooChat stays read-only. BooCoder is the only surface with write tools.
|
||||
- Path-guard correctness is the #1 test target. Fuzz against every traversal pattern.
|
||||
- Pending-changes queue gates ALL writes (native + MCP). Nothing touches disk without user approval (or explicit auto-apply flag per task).
|
||||
- One shared database. Cross-surface joins are valuable (task → chat → terminal debugging session).
|
||||
- External CLI agents on the host, not in containers. BooCoder shells out via local-exec.
|
||||
- No OAuth in v2.0. MCP server is stdio-only until secret storage lands.
|
||||
- DB rename `boocode_db` → `boochat_db` lands with v2.0.0 (one-time migration).
|
||||
|
||||
## AGENTS.md extensions (v2.0.0)
|
||||
|
||||
Port from `qodo-ai/agents` (MIT) `agent.toml` schema and `ai-christianson/RA.Aid` (Apache-2.0) three-stage pattern:
|
||||
|
||||
| Field | Type | Purpose | Source |
|
||||
|---|---|---|---|
|
||||
| `steps` | number | Per-agent step cap (already shipped v1.14.0) | opencode |
|
||||
| `output_schema` | JSON Schema | Structured output constraint for the agent's final response | qodo-ai/agents |
|
||||
| `exit_expression` | string | Regex/predicate — when the agent considers itself done | qodo-ai/agents |
|
||||
| `execution_strategy` | `plan` \| `act` \| `research` | Which phase of the RA.Aid three-stage pattern this agent operates in | qodo-ai/agents + RA.Aid |
|
||||
| `model` | string | Per-agent model override (already shipped v1.8) | — |
|
||||
| `expert_model` | string | Escalation model for hard reasoning (RA.Aid "expert tool" escape hatch) | RA.Aid |
|
||||
|
||||
The three-stage pattern maps to BooCoder's use case:
|
||||
- **Research agent** (cheap model) → understand the task, find relevant files
|
||||
- **Planning agent** (standard model) → decide which files to edit, what the changes look like
|
||||
- **Implementation agent** (full model) → produce the actual diffs
|
||||
|
||||
`expert_model` is the escape hatch: a routine model handles most subtasks, but can call the expert model (e.g. qwopus27b) when stuck. Matches Sam's existing cost-routing discipline.
|
||||
|
||||
## Subagent isolation (Boomerang pattern, v2.0.1)
|
||||
|
||||
From Roo Code Boomerang Tasks (Apache-2.0 pattern):
|
||||
|
||||
When an orchestrator agent calls a `new_task` tool, BooCoder:
|
||||
1. Creates a fresh `tasks` row with `parent_task_id` pointing to the orchestrator's task
|
||||
2. Spawns a fresh inference session (Path A) or dispatch (Path B) with ONLY the task spec as context — no inherited conversation
|
||||
3. Child runs to `attempt_completion`, writes a summary to `tasks.output_summary`
|
||||
4. Parent resumes reading ONLY the summary (not the child's full conversation)
|
||||
|
||||
**Three principles:**
|
||||
- Orchestrator capability restriction: the orchestrator agent's tool list includes ONLY `new_task`, `list_tasks`, `check_task_status` — it cannot read files or call MCP tools directly
|
||||
- Down-pass: parent sends task spec via `new_task(input)`, nothing else inherited
|
||||
- Up-pass: child sends result via `attempt_completion(summary)`, nothing else surfaces to parent
|
||||
|
||||
This is the **single most important context-management primitive** — it prevents long-running orchestrators from poisoning their context with implementation detail.
|
||||
|
||||
## Observation hooks (v2.0.3)
|
||||
|
||||
From `siropkin/budi` (MIT) Claude Code 5-hook taxonomy:
|
||||
|
||||
Register BooCoder as a hook receiver for dispatched agents. Five events:
|
||||
- `SessionStart` — agent spawned
|
||||
- `UserPromptSubmit` — task spec delivered
|
||||
- `PostToolUse` — each tool call completed
|
||||
- `SubagentStart` — nested dispatch
|
||||
- `Stop` — agent finished
|
||||
|
||||
These map directly to BooCode's existing WS frame protocol. The hook receiver is the BooCoder Fastify server; events flow into the `message_parts` taxonomy as `step_start`-style instrumentation parts.
|
||||
|
||||
## Follow-up batches (v2.0+ optional, ordered by value)
|
||||
|
||||
| Batch | Source | What | When |
|
||||
|---|---|---|---|
|
||||
| **PR-resolver tool** | `qodo-ai/qodo-skills` (MIT) | Fetch GitHub issues → batch/interactive fix → inline PR reply. BooCoder tool that replaces Sam's manual PR workflow. | v2.0.3+ |
|
||||
| **HMAC audit log** | `sipyourdrink-ltd/bernstein` (verify license) | One new `audit_log` table with `prev_hmac` field. Tamper-evident history of every edit BooCoder makes. Small lift (~50 LoC). | v2.0.1+ |
|
||||
| **Blind-validation gate** | `covibes/zeroshot` (MIT) | Verify gate runs in a separate agent context that sees ONLY the diff + acceptance criteria, not the producing conversation. Complements Boomerang (isolation) + bernstein (lineage). | v2.0.2+ |
|
||||
| **Majority-vote ensembler** | `augmentcode/augment-swebench-agent` (MIT) | K candidate diffs from K agents → ranker model picks the best one. Optional layer above `pending_changes`. | v2.1+ |
|
||||
| **Drift detection** | `memovai/memov` (MIT) | `validate_commit` concept — detects when actual changes diverge from what was requested. Shadow timeline comparison. | v2.0.3+ |
|
||||
| **Anti-slop for frontend** | `Leonxlnx/taste-skill` (MIT) | 100+ specific font/color/layout ban list + 3-dial parameterization. Vendor into skills/ when BooCoder generates frontend code. | v2.0+ |
|
||||
| **Verify-before-commit gate** | `DeepSourceCorp/globstar` (MIT) | Rule-based AST linter as a pre-apply quality gate. YAML checkers in `.globstar/`. | v2.1+ (parked) |
|
||||
| **Docker sandbox** | `OpenHands/OpenHands` (MIT) | Per-session Docker container for write tools. Closes the `/opt:rw` mount risk if path-guard ever proves insufficient. | v2.1 (optional) |
|
||||
| **Multi-provider LLM** | `earendil-works/pi` (MIT) | Provider abstraction if a need for Anthropic/OpenAI/Mistral direct surfaces beyond llama-swap. | v2.x (optional) |
|
||||
|
||||
## Repos to clone before starting
|
||||
|
||||
```bash
|
||||
cd /opt/forks
|
||||
git clone https://github.com/Dominic789654/agent-hub.git # Apache-2.0, task DAG + dispatcher
|
||||
git clone https://github.com/plandex-ai/plandex.git # MIT, pending-changes UX
|
||||
git clone https://github.com/anomalyco/opencode.git # MIT, permission evaluate.ts reference
|
||||
git clone https://github.com/qodo-ai/agents.git # MIT, agent.toml schema (output_schema, exit_expression, execution_strategy)
|
||||
```
|
||||
|
||||
Also read (no clone needed):
|
||||
- `ai-christianson/RA.Aid` README — three-stage pattern + expert-tool escape hatch
|
||||
- `getpaseo/paseo` README + `skills/` directory — daemon architecture + CLI verbs (AGPL, design-only)
|
||||
- `agentclientprotocol.com` spec — ACP stdio protocol
|
||||
- `goose-docs.ai/docs/guides/acp-clients/` — `context_servers` auto-forward pattern
|
||||
- `siropkin/budi` README — 5-hook Claude Code taxonomy for observation
|
||||
|
||||
ACP SDK and MCP SDK are npm packages installed at implementation time.
|
||||
130
openspec/changes/v2.0-boocoder/tasks.md
Normal file
130
openspec/changes/v2.0-boocoder/tasks.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# v2.0 — BooCoder task breakdown
|
||||
|
||||
## Phase 0 — Prep (before any code)
|
||||
|
||||
- [ ] Clone lift sources: `agent-hub`, `plandex`, `opencode` to `/opt/forks/`
|
||||
- [ ] Read agent-hub's schema + dispatcher pattern (Apache-2.0)
|
||||
- [ ] Read plandex's pending-changes + diff/apply/rewind flow (MIT)
|
||||
- [ ] Read opencode's `permission/evaluate.ts` for write-gate patterns (MIT)
|
||||
- [ ] Install ACP SDK: `pnpm add @zed-industries/agent-client-protocol`
|
||||
- [ ] Verify `opencode acp` and `goose acp` are available on the host
|
||||
- [ ] Write `openspec/changes/v2.0-boocoder/design.md` with finalized decisions
|
||||
|
||||
## v2.0.0 — Schema + Path A (native write tools + pending-changes + diff UI)
|
||||
|
||||
### Infra
|
||||
|
||||
- [ ] Create `apps/coder/` directory skeleton (Fastify server, mirroring `apps/server/` structure)
|
||||
- [ ] Create `apps/coder/Dockerfile` (Node 20 bookworm-slim, `/opt:/opt:rw` mount)
|
||||
- [ ] Add `boocoder` service to `docker-compose.yml` (port 9502, boocode_net)
|
||||
- [ ] Add Caddy route: `coder.indifferentketchup.com` → boocoder:9502
|
||||
- [ ] DB rename: `boocode_db` → `boochat_db` (one-time ALTER DATABASE + docker-compose volume rename)
|
||||
- [ ] Schema migration: CREATE TABLE `pending_changes`, `tasks`, `available_agents`; CREATE VIEW `human_inbox`
|
||||
- [ ] Container guidance: `BOOCODER.md` (bind-mounted at `/app/BOOCODER.md`)
|
||||
|
||||
### Write tools
|
||||
|
||||
- [ ] `apps/coder/src/services/write_guard.ts` — `resolveWritePath(projectRoot, filePath)` (resolve + prefix-check, no realpath since file may not exist)
|
||||
- [ ] `apps/coder/src/services/pending_changes.ts` — queue, apply, reject, revert operations
|
||||
- [ ] Tool: `edit_file` — takes `{file_path, old_string, new_string}`, computes unified diff, queues in `pending_changes`
|
||||
- [ ] Tool: `create_file` — takes `{file_path, content}`, queues as `operation='create'`
|
||||
- [ ] Tool: `delete_file` — takes `{file_path}`, queues as `operation='delete'`
|
||||
- [ ] Tool: `apply_pending` — flushes pending changes to disk (re-validates write_guard before each write)
|
||||
- [ ] Tool: `rewind` — reverts applied changes by inverse-diff
|
||||
|
||||
### Inference loop
|
||||
|
||||
- [ ] Port the v1.14 outer loop from `apps/server/` into `apps/coder/` (or share via workspace package)
|
||||
- [ ] Register write tools in the coder's tool registry (alongside all read tools from BooChat)
|
||||
- [ ] Permission gate: write tools require `pending_changes` queue (can't bypass to direct disk write)
|
||||
|
||||
### Frontend (diff pane)
|
||||
|
||||
- [ ] Create `apps/coder/web/` SPA (React + Vite, same stack as BooChat's `apps/web/`)
|
||||
- [ ] Diff pane component: shows pending changes with syntax-highlighted diffs
|
||||
- [ ] Approve / Reject per change, Approve All / Reject All buttons
|
||||
- [ ] Workspace splitter integration (chat pane + diff pane side by side)
|
||||
|
||||
### Verification
|
||||
|
||||
- [ ] `pnpm -C apps/coder build` clean
|
||||
- [ ] Write path-guard fuzz tests (traversal patterns, symlinks, non-existent paths, `.env` deny)
|
||||
- [ ] `docker compose up --build -d` — boocoder container starts, healthcheck passes
|
||||
- [ ] Smoke: send a chat requesting a file edit → see it queued in diff pane → approve → file written
|
||||
|
||||
## v2.0.1 — Path B (ACP dispatch + PTY fallback + worktrees)
|
||||
|
||||
### ACP client
|
||||
|
||||
- [ ] `apps/coder/src/services/acp-client.ts` — spawn `opencode acp` / `goose acp` via `@zed-industries/agent-client-protocol` StdioTransport
|
||||
- [ ] Event mapping: ACP `file_operation` → `tool_call` part, `terminal_output` → BooTerm route, `permission_request` → pause
|
||||
- [ ] Session lifecycle: start, mid-session model switch, end
|
||||
- [ ] MCP auto-forward: pass BooCoder's `context_servers` config to the ACP session
|
||||
|
||||
### PTY fallback
|
||||
|
||||
- [ ] `apps/coder/src/services/pty-dispatch.ts` — spawn `claude` / `pi` / `smallcode` via `node-pty`
|
||||
- [ ] Capture stdout/stderr/exit-code into parts (less structured than ACP)
|
||||
- [ ] Worktree setup: `git worktree add /tmp/booworktrees/<task-id> -b task-<task-id> HEAD`
|
||||
- [ ] On completion: diff worktree vs HEAD → queue into `pending_changes`
|
||||
|
||||
### Dispatcher
|
||||
|
||||
- [ ] `apps/coder/src/services/dispatcher.ts` — polls `tasks` WHERE `state='pending'`, picks by priority + creation order
|
||||
- [ ] Transport selection: check `available_agents.supports_acp` at dispatch time
|
||||
- [ ] On failure: mark `state='failed'`, surface in `human_inbox`
|
||||
- [ ] On completion: mark `state='completed'`, queue diff if Path B
|
||||
|
||||
### Agent probing
|
||||
|
||||
- [ ] Startup probe: `which opencode && opencode --version`, `which goose`, `which claude`, `which pi`
|
||||
- [ ] Populate `available_agents` table with version + ACP support
|
||||
|
||||
### Verification
|
||||
|
||||
- [ ] Smoke: dispatch a task to `opencode` via ACP → task completes → diff queued
|
||||
- [ ] Smoke: dispatch to `claude` via PTY fallback → captures output → diff from worktree
|
||||
- [ ] Worktree cleanup after task completion
|
||||
|
||||
## v2.0.2 — BooCoder MCP server
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] `apps/coder/src/services/mcp-server.ts` — register 6 tools as MCP tool handlers
|
||||
- [ ] Stdio transport (use `@modelcontextprotocol/sdk` server-side, same SDK as client)
|
||||
- [ ] Tools: `boocoder.create_task`, `boocoder.list_pending_changes`, `boocoder.apply`, `boocoder.reject`, `boocoder.dispatch_external_agent`, `boocoder.list_worktrees`
|
||||
- [ ] Each tool maps to a DB operation or service call
|
||||
|
||||
### Eval
|
||||
|
||||
- [ ] Write 10-question eval per `anthropics/skills/mcp-builder` framework
|
||||
- [ ] Run eval against the MCP server — all 10 must pass before shipping
|
||||
- [ ] Document eval results in openspec
|
||||
|
||||
### Verification
|
||||
|
||||
- [ ] From a terminal: `echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | boocoder --mcp` → returns 6 tools
|
||||
- [ ] From opencode: configure BooCoder as an MCP server in `~/.opencode/config.json`, verify tool calls work
|
||||
|
||||
## v2.0.3 — Polish
|
||||
|
||||
### CLI client
|
||||
|
||||
- [ ] `apps/coder/src/cli.ts` — thin WebSocket/HTTP client against BooCoder API
|
||||
- [ ] Verbs: `boocode run <task>`, `boocode ls`, `boocode attach <id>`, `boocode send <id> <message>`
|
||||
- [ ] Mirrors Paseo's UX, license-clean implementation
|
||||
|
||||
### Human inbox UI
|
||||
|
||||
- [ ] Frontend route showing tasks in `blocked`/`failed` state
|
||||
- [ ] Per-task: view output, retry, cancel, reassign to different agent
|
||||
|
||||
### Cost tracking
|
||||
|
||||
- [ ] `tasks.cost_tokens` populated from inference usage
|
||||
- [ ] Summary view: per-project, per-agent, per-day token spend
|
||||
|
||||
### Verification
|
||||
|
||||
- [ ] `boocode run "add a health endpoint"` from terminal → task appears in UI → completes → diff in pane
|
||||
- [ ] `boocode ls` shows running/completed/failed tasks
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -46,6 +46,34 @@ importers:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
|
||||
apps/coder:
|
||||
dependencies:
|
||||
'@fastify/static':
|
||||
specifier: ^7.0.4
|
||||
version: 7.0.4
|
||||
'@fastify/websocket':
|
||||
specifier: ^10.0.1
|
||||
version: 10.0.1
|
||||
fastify:
|
||||
specifier: ^4.28.1
|
||||
version: 4.29.1
|
||||
postgres:
|
||||
specifier: ^3.4.4
|
||||
version: 3.4.9
|
||||
zod:
|
||||
specifier: ^3.23.8
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.14.10
|
||||
version: 20.19.41
|
||||
tsx:
|
||||
specifier: ^4.16.2
|
||||
version: 4.22.0
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
|
||||
apps/server:
|
||||
dependencies:
|
||||
'@ai-sdk/openai-compatible':
|
||||
|
||||
Reference in New Issue
Block a user