diff --git a/README.md b/README.md index 6f7b4e8..0dc1e34 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Claude Code <--STDIO--> mcplocal (local proxy) <--HTTP--> mcpd (daemon) <-- ``` - **mcpd** — the daemon. Runs on a server, manages MCP server containers (Docker/Podman), stores configuration in PostgreSQL. -- **mcplocal** — local proxy. Runs on your machine, presents a single MCP endpoint to Claude that merges tools from all your servers. Handles namespacing (`grafana/search_dashboards`), gated sessions, and prompt delivery. +- **mcplocal** — local proxy. Runs on your machine, presents a single MCP endpoint to Claude that merges tools from all your servers. Handles namespacing (`grafana/search_dashboards`), plugin execution (gating, content pipelines), and prompt delivery. - **mcpctl** — the CLI. Talks to mcpd (via mcplocal or directly) to manage everything. ## Quick Start @@ -88,7 +88,7 @@ A project groups servers together and configures how Claude interacts with them. mcpctl create project monitoring \ --description "Grafana dashboards and alerting" \ --server grafana \ - --no-gated + --proxy-model content-pipeline ``` ### 6. Connect Claude Code @@ -133,7 +133,7 @@ servers: projects: - name: monitoring description: "Infrastructure monitoring" - gated: false + proxyModel: content-pipeline servers: - grafana ``` @@ -150,55 +150,80 @@ mcpctl get all --project monitoring -o yaml > backup.yaml mcpctl apply -f backup.yaml ``` -## Content Pipeline (ProxyModel) +## Plugin System (ProxyModel) -ProxyModel defines a **content transformation pipeline** that runs between upstream MCP servers and the client (e.g., Claude). It processes tool results, prompts, and resources through ordered stages before delivering them. +ProxyModel is mcpctl's plugin system. Each project is assigned a **plugin** that controls how Claude interacts with its servers. Plugins are composed from two layers: **TypeScript plugins** (MCP middleware hooks) and **YAML pipelines** (content transformation stages). -### Built-in Models +### Built-in Plugins -| Model | Stages | Use case | -|-------|--------|----------| +| Plugin | Includes gating | Content pipeline | Description | +|--------|:-:|:-:|---| +| **default** | Yes | Yes | Gate + content pipeline. The default for all projects. | +| **gate** | Yes | No | Gating only — `begin_session` gate with prompt delivery. | +| **content-pipeline** | No | No | Content transformation only — no gating. | + +**Gating** means Claude initially sees only a `begin_session` tool. After calling it with a task description, relevant prompts are delivered and the full tool list is revealed. This keeps Claude's context focused. + +```bash +# Create a gated project (default behavior) +mcpctl create project home --server my-ha --proxy-model default + +# Create an ungated project (direct tool access, no gate) +mcpctl create project tools --server grafana --proxy-model content-pipeline +``` + +### Plugin Hooks + +TypeScript plugins intercept MCP requests/responses at specific lifecycle points: + +| Hook | When it fires | +|------|--------------| +| `onSessionCreate` | New MCP session established | +| `onSessionDestroy` | Session ends | +| `onInitialize` | MCP `initialize` request — can inject instructions | +| `onToolsList` | `tools/list` — can filter/modify tool list | +| `onToolCallBefore` | Before forwarding a tool call — can intercept | +| `onToolCallAfter` | After receiving tool result — can transform | +| `onResourcesList` | `resources/list` — can filter resources | +| `onResourceRead` | `resources/read` — can intercept resource reads | +| `onPromptsList` | `prompts/list` — can filter prompts | +| `onPromptGet` | `prompts/get` — can intercept prompt reads | + +Plugins compose via `extends` — the `default` plugin extends both `gate` and `content-pipeline`, inheriting all their hooks. + +### Content Pipelines + +Content pipelines transform tool results through ordered stages before delivering to Claude: + +| Pipeline | Stages | Use case | +|----------|--------|----------| | **default** | `passthrough` → `paginate` (8KB pages) | Safe pass-through with pagination for large responses | -| **subindex** | `section-split` → `summarize-tree` | Splits large content into sections, returns a summary index. Client drills down with `_resultId`/`_section` params | +| **subindex** | `section-split` → `summarize-tree` | Splits large content into sections, returns a summary index | -### How `subindex` Works +#### How `subindex` Works 1. Upstream returns a large tool result (e.g., 50KB of device states) -2. `section-split` divides content into logical sections (2KB–15KB each) +2. `section-split` divides content into logical sections (2KB-15KB each) 3. `summarize-tree` generates a compact index with section summaries (~200 tokens each) 4. Client receives the index and can request specific sections via `_section` parameter ### Configuration -Set per-project (all servers use the same model): +Set per-project: ```yaml -kind: Project -metadata: - name: home-automation -spec: - servers: [home-assistant, node-red] - proxyModel: subindex -``` - -Override per-server within a project: - -```yaml -kind: Project -metadata: - name: monitoring -spec: - servers: [grafana, prometheus] - proxyModel: default - serverOverrides: - grafana: - proxyModel: subindex +kind: project +name: home-automation +proxyModel: default +servers: + - home-assistant + - node-red ``` Via CLI: ```bash -mcpctl create project monitoring --server grafana --server prometheus --proxy-model subindex +mcpctl create project monitoring --server grafana --proxy-model content-pipeline ``` ### Custom ProxyModels @@ -223,7 +248,13 @@ spec: cacheable: true ``` -Inspect available models: `mcpctl get proxymodels` / `mcpctl describe proxymodel subindex` +Inspect available plugins and pipelines: + +```bash +mcpctl get proxymodels # List all plugins and pipelines +mcpctl describe proxymodel default # Pipeline details (stages, controller) +mcpctl describe proxymodel gate # Plugin details (hooks, extends) +``` ## Resources @@ -255,12 +286,12 @@ mcpctl describe project monitoring # Create resources mcpctl create server [flags] mcpctl create secret --data KEY=value -mcpctl create project --server [--gated] +mcpctl create project --server [--proxy-model ] mcpctl create prompt --project --content "..." # Modify resources mcpctl edit server grafana # Opens in $EDITOR -mcpctl patch project myproj gated=true +mcpctl patch project myproj proxyModel=default mcpctl apply -f config.yaml # Declarative create/update # Delete resources @@ -300,7 +331,7 @@ mcpctl create server my-ha \ ## Gated Sessions -Projects are **gated** by default. When Claude connects to a gated project: +Projects using the `default` or `gate` plugin are **gated**. When Claude connects to a gated project: 1. Claude sees only a `begin_session` tool initially 2. Claude calls `begin_session` with a description of its task @@ -310,9 +341,11 @@ Projects are **gated** by default. When Claude connects to a gated project: This keeps Claude's context focused — instead of dumping 100+ tools and pages of docs upfront, only the relevant ones are delivered based on the task at hand. ```bash -# Enable/disable gating -mcpctl patch project monitoring gated=true -mcpctl patch project monitoring gated=false +# Gated (default) +mcpctl create project monitoring --server grafana --proxy-model default + +# Ungated (direct tool access) +mcpctl create project tools --server grafana --proxy-model content-pipeline ``` ## Prompts diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts index b04fb24..e1d6e68 100644 --- a/src/cli/tests/commands/describe.test.ts +++ b/src/cli/tests/commands/describe.test.ts @@ -89,6 +89,44 @@ describe('describe command', () => { expect(text).toContain('user-1'); }); + it('shows project Plugin Config with proxyModel', async () => { + const deps = makeDeps({ + id: 'proj-1', + name: 'gated-project', + description: 'A gated project', + ownerId: 'user-1', + proxyModel: 'default', + proxyMode: 'direct', + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'project', 'proj-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Plugin Config:'); + expect(text).toContain('Plugin:'); + expect(text).toContain('default'); + expect(text).not.toContain('Gated:'); + }); + + it('shows project Plugin Config defaulting to "default" when proxyModel is empty', async () => { + const deps = makeDeps({ + id: 'proj-1', + name: 'old-project', + description: '', + ownerId: 'user-1', + proxyModel: '', + gated: true, + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'project', 'proj-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Plugin Config:'); + expect(text).toContain('default'); + }); + it('shows secret detail with masked values', async () => { const deps = makeDeps({ id: 'sec-1', diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index 3b765ca..71865a6 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -335,4 +335,82 @@ describe('get command', () => { await cmd.parseAsync(['node', 'test', 'prompts']); expect(deps.output[0]).toContain('No prompts found'); }); + + it('lists projects with PLUGIN column showing resolved proxyModel', async () => { + const deps = makeDeps([{ + id: 'proj-1', + name: 'home', + description: '', + proxyMode: 'direct', + proxyModel: '', + gated: true, + ownerId: 'usr-1', + servers: [], + }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'projects']); + + const text = deps.output.join('\n'); + expect(text).toContain('PLUGIN'); + expect(text).not.toContain('GATED'); + // proxyModel is empty but gated=true, table shows 'default' + expect(text).toContain('default'); + }); + + it('project JSON output resolves proxyModel from gated=true', async () => { + const deps = makeDeps([{ + id: 'proj-1', + name: 'home', + description: '', + proxyMode: 'direct', + proxyModel: '', + gated: true, + ownerId: 'usr-1', + servers: [], + }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? '') as Array>; + expect(parsed[0]!.proxyModel).toBe('default'); + expect(parsed[0]).not.toHaveProperty('gated'); + }); + + it('project JSON output resolves proxyModel from gated=false', async () => { + const deps = makeDeps([{ + id: 'proj-1', + name: 'tools', + description: '', + proxyMode: 'direct', + proxyModel: '', + gated: false, + ownerId: 'usr-1', + servers: [], + }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? '') as Array>; + expect(parsed[0]!.proxyModel).toBe('content-pipeline'); + expect(parsed[0]).not.toHaveProperty('gated'); + }); + + it('project JSON output preserves explicit proxyModel and drops gated', async () => { + const deps = makeDeps([{ + id: 'proj-1', + name: 'custom', + description: '', + proxyMode: 'direct', + proxyModel: 'gate', + gated: true, + ownerId: 'usr-1', + servers: [], + }]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'projects', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? '') as Array>; + expect(parsed[0]!.proxyModel).toBe('gate'); + expect(parsed[0]).not.toHaveProperty('gated'); + }); }); diff --git a/src/mcplocal/tests/smoke/proxymodel.test.ts b/src/mcplocal/tests/smoke/proxymodel.test.ts index b46e3c4..030ca2d 100644 --- a/src/mcplocal/tests/smoke/proxymodel.test.ts +++ b/src/mcplocal/tests/smoke/proxymodel.test.ts @@ -142,5 +142,40 @@ describe('ProxyModel smoke tests', () => { expect(output).toContain('Plugin Config'); expect(output).toContain('Plugin:'); }); + + it('mcpctl get projects -o yaml shows proxyModel and no gated field', async () => { + if (!available) return; + + const output = await mcpctl('get projects -o yaml'); + // proxyModel should be resolved (not empty) + expect(output).toContain('proxyModel:'); + expect(output).not.toContain('gated:'); + }); + + it('mcpctl get projects -o json shows proxyModel and no gated field', async () => { + if (!available) return; + + const json = await mcpctl('get projects -o json'); + const projects = JSON.parse(json) as Array<{ proxyModel?: string; gated?: boolean }>; + expect(projects.length).toBeGreaterThan(0); + + for (const project of projects) { + expect(project.proxyModel).toBeDefined(); + expect(project.proxyModel).not.toBe(''); + expect(project).not.toHaveProperty('gated'); + } + }); + + it('mcpctl get projects -o yaml is round-trip compatible with apply', async () => { + if (!available) return; + + const yaml = await mcpctl('get projects -o yaml'); + // Should contain kind and proxyModel (apply-compatible fields) + expect(yaml).toContain('kind: project'); + expect(yaml).toContain('proxyModel:'); + // Should not contain internal fields + expect(yaml).not.toContain('ownerId:'); + expect(yaml).not.toContain('createdAt:'); + }); }); });