docs: update README for plugin system, add proxyModel tests

- Rewrite README Content Pipeline section as Plugin System section
  documenting built-in plugins (default, gate, content-pipeline),
  plugin hooks, and the relationship between gating and proxyModel
- Update all README examples to use --proxy-model instead of --gated
- Add unit tests: proxyModel normalization in JSON/YAML output (4 tests),
  Plugin Config section in describe output (2 tests)
- Add smoke tests: yaml/json output shows resolved proxyModel without
  gated field, round-trip compatibility (4 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 01:24:47 +00:00
parent f60d40a25b
commit d9d0a7a374
4 changed files with 224 additions and 40 deletions

113
README.md
View File

@@ -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. - **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. - **mcpctl** — the CLI. Talks to mcpd (via mcplocal or directly) to manage everything.
## Quick Start ## Quick Start
@@ -88,7 +88,7 @@ A project groups servers together and configures how Claude interacts with them.
mcpctl create project monitoring \ mcpctl create project monitoring \
--description "Grafana dashboards and alerting" \ --description "Grafana dashboards and alerting" \
--server grafana \ --server grafana \
--no-gated --proxy-model content-pipeline
``` ```
### 6. Connect Claude Code ### 6. Connect Claude Code
@@ -133,7 +133,7 @@ servers:
projects: projects:
- name: monitoring - name: monitoring
description: "Infrastructure monitoring" description: "Infrastructure monitoring"
gated: false proxyModel: content-pipeline
servers: servers:
- grafana - grafana
``` ```
@@ -150,55 +150,80 @@ mcpctl get all --project monitoring -o yaml > backup.yaml
mcpctl apply -f 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 | | **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) 1. Upstream returns a large tool result (e.g., 50KB of device states)
2. `section-split` divides content into logical sections (2KB15KB 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) 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 4. Client receives the index and can request specific sections via `_section` parameter
### Configuration ### Configuration
Set per-project (all servers use the same model): Set per-project:
```yaml ```yaml
kind: Project kind: project
metadata: name: home-automation
name: home-automation proxyModel: default
spec: servers:
servers: [home-assistant, node-red] - home-assistant
proxyModel: subindex - node-red
```
Override per-server within a project:
```yaml
kind: Project
metadata:
name: monitoring
spec:
servers: [grafana, prometheus]
proxyModel: default
serverOverrides:
grafana:
proxyModel: subindex
``` ```
Via CLI: Via CLI:
```bash ```bash
mcpctl create project monitoring --server grafana --server prometheus --proxy-model subindex mcpctl create project monitoring --server grafana --proxy-model content-pipeline
``` ```
### Custom ProxyModels ### Custom ProxyModels
@@ -223,7 +248,13 @@ spec:
cacheable: true 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 ## Resources
@@ -255,12 +286,12 @@ mcpctl describe project monitoring
# Create resources # Create resources
mcpctl create server <name> [flags] mcpctl create server <name> [flags]
mcpctl create secret <name> --data KEY=value mcpctl create secret <name> --data KEY=value
mcpctl create project <name> --server <srv> [--gated] mcpctl create project <name> --server <srv> [--proxy-model <plugin>]
mcpctl create prompt <name> --project <proj> --content "..." mcpctl create prompt <name> --project <proj> --content "..."
# Modify resources # Modify resources
mcpctl edit server grafana # Opens in $EDITOR 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 mcpctl apply -f config.yaml # Declarative create/update
# Delete resources # Delete resources
@@ -300,7 +331,7 @@ mcpctl create server my-ha \
## Gated Sessions ## 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 1. Claude sees only a `begin_session` tool initially
2. Claude calls `begin_session` with a description of its task 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. 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 ```bash
# Enable/disable gating # Gated (default)
mcpctl patch project monitoring gated=true mcpctl create project monitoring --server grafana --proxy-model default
mcpctl patch project monitoring gated=false
# Ungated (direct tool access)
mcpctl create project tools --server grafana --proxy-model content-pipeline
``` ```
## Prompts ## Prompts

View File

@@ -89,6 +89,44 @@ describe('describe command', () => {
expect(text).toContain('user-1'); 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 () => { it('shows secret detail with masked values', async () => {
const deps = makeDeps({ const deps = makeDeps({
id: 'sec-1', id: 'sec-1',

View File

@@ -335,4 +335,82 @@ describe('get command', () => {
await cmd.parseAsync(['node', 'test', 'prompts']); await cmd.parseAsync(['node', 'test', 'prompts']);
expect(deps.output[0]).toContain('No prompts found'); 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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
expect(parsed[0]!.proxyModel).toBe('gate');
expect(parsed[0]).not.toHaveProperty('gated');
});
}); });

View File

@@ -142,5 +142,40 @@ describe('ProxyModel smoke tests', () => {
expect(output).toContain('Plugin Config'); expect(output).toContain('Plugin Config');
expect(output).toContain('Plugin:'); 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:');
});
}); });
}); });