Merges feat/agent-project-membership (task 166/152). An agent can finally join many projects: membership routes, per-membership role and optional brain override, chat resolving context from the membership, and CLI attach-agent / detach-agent / get memberships. The legacy Agent.projectId fallback is kept for this release as the schema comment promises. Conflict resolved in main.ts: that branch added its RBAC case to mapUrlToPermission inline, while this branch had extracted the function to middleware/url-permission.ts so it could be tested. Kept the extraction and ported the case into it — joining a team is `edit:agents:<name>`, not `create:agents`, which is the authority to mint a whole agent. Also fixes something the extraction makes possible. security.test.ts contained a hand-copied reproduction of mapUrlToPermission, because main.ts self-invokes main() and nothing in it was importable. A security test asserting against a copy stops testing the system the moment the real one changes, and this copy had already drifted. It now imports the real function; all 18 assertions still pass, against the code that actually runs. Local: build clean, workspace 2550 passed, lint unchanged at 794 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4wNHWf7xSwnZCWpJcyv9p
4.2 KiB
Task ID: 114
Title: ProxyModel v2: Code-based MCP middleware plugin system
Status: done
Dependencies: None
Priority: high
Description: Redesign the ProxyModel framework from a YAML-configured content transformation pipeline into a full code-based MCP middleware plugin system. Proxy models become TypeScript files that can intercept any MCP request/response, create synthetic tools, maintain per-session state, and compose via multiple inheritance with compile-time conflict detection. The existing gate functionality (begin_session, tools/list filtering, prompt scoring, ungating) becomes the first proxy model implementation, proving the framework works by implementing gate entirely as a plugin with zero gate-specific code in router.ts.
Details:
Vision
A proxy model is a TypeScript code file (not YAML) that acts as full MCP middleware. It can:
- Intercept any MCP request (initialize, tools/list, tools/call, resources/, prompts/)
- Modify any response before it reaches the client
- Create synthetic tools (e.g. begin_session doesn't exist upstream)
- Maintain per-session state (gated/ungated, accumulated tags, etc.)
- Access project resources (prompts, servers, config)
- Transform content (what stages do today: paginate, section-split, etc.)
Key design decisions
- Code not YAML: Proxy models live as .ts files in a known directory (e.g. ~/.mcpctl/proxymodels/). File exists = model exists. No create/delete via CLI.
- Stages deprecated: No separate stage resource. Content transformation is just code inside the proxy model.
- Multiple inheritance: A model can extend [gate, subindex] to compose behaviors from multiple parents. Conflicts (two parents intercepting the same method incompatibly) detected at load/compile time, not runtime.
- Gate is just a proxy model: The ~300 lines of gate logic in router.ts move into a gate.ts proxy model file. Router becomes thin plumbing (~100 lines).
- gated:true replaced by proxyModel field: Projects get a proxyModel: gate field. If the assigned model implements gating, the project is gated. No separate boolean.
- Discoverable as resources: mcpctl get proxymodels lists available models (discovered from files). mcpctl describe proxymodel gate shows details. But no create/delete commands.
- Attached to projects: mcpctl edit project foo --proxyModel gate or via apply YAML.
Framework interface (sketch)
export interface ProxyModelContext { session: SessionState; project: ProjectConfig; upstream: UpstreamClient; llm?: LLMProvider; cache?: CacheProvider; }
export interface ProxyModel { name: string; extends?: string[]; onInitialize?(ctx, request): Promise; onToolsList?(ctx): Promise<Tool[]>; onToolCall?(ctx, name, args): Promise<ToolResult | null>; onResourceRead?(ctx, uri): Promise<ResourceContent | null>; transformContent?(ctx, content, contentType): Promise; createSessionState?(): Record<string, unknown>; }
Migration path
- Define the ProxyModel TypeScript interface
- Implement the plugin loader (discover .ts files, compile, validate inheritance, detect conflicts)
- Implement the router integration (router delegates to loaded proxy model)
- Extract gate logic from router.ts into gate.ts proxy model
- Extract content pipeline (passthrough, paginate, section-split) into proxy model code
- Add proxyModel field to Project schema (replaces gated boolean)
- Add CLI: get proxymodels, describe proxymodel, edit project --proxyModel
- Add smoke tests: gate proxy model produces identical behavior to current hardcoded gate
- Deprecate gated field (backward compat: gated:true maps to proxyModel:gate)
Supersedes
This task supersedes deferred tasks 83, 85-97, 98-99, 103, 104, 110, 111-112 which assumed the old YAML/stage architecture.
Test Strategy:
- Gate proxy model smoke test: identical behavior to current hardcoded gate (begin_session, tools/list filtering, ungating). 2. Composition test: model extending [gate, paginate] inherits both behaviors. 3. Conflict detection test: two parents intercepting same hook differently = compile-time error. 4. Discovery test: drop a .ts file in proxymodels dir, mcpctl get proxymodels shows it. 5. Existing smoke tests (proxy-pipeline.test.ts) pass unchanged after migration.