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:
113
README.md
113
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 <name> [flags]
|
||||
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 "..."
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user