feat: eager vLLM warmup and smart page titles in paginate stage
- Add warmup() to LlmProvider interface for eager subprocess startup - ManagedVllmProvider.warmup() starts vLLM in background on project load - ProviderRegistry.warmupAll() triggers all managed providers - NamedProvider proxies warmup() to inner provider - paginate stage generates LLM-powered descriptive page titles when available, cached by content hash, falls back to generic "Page N" - project-mcp-endpoint calls warmupAll() on router creation so vLLM is loading while the session initializes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1892,13 +1892,670 @@
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-25T23:12:22.363Z"
|
||||
},
|
||||
{
|
||||
"id": "71",
|
||||
"title": "Define ProxyModel Public Type Contract",
|
||||
"description": "Create the core TypeScript types for the ProxyModel framework that stages will import from `mcpctl/proxymodel`. This establishes the public API contract that stage authors write against.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/types.ts` with:\n\n```typescript\nexport interface StageHandler {\n (content: string, ctx: StageContext): Promise<StageResult>;\n}\n\nexport interface StageContext {\n contentType: 'prompt' | 'toolResult' | 'resource';\n sourceName: string;\n projectName: string;\n sessionId: string;\n originalContent: string;\n llm: LLMProvider;\n cache: CacheProvider;\n log: Logger;\n config: Record<string, unknown>;\n}\n\nexport interface StageResult {\n content: string;\n sections?: Section[];\n metadata?: Record<string, unknown>;\n}\n\nexport interface Section {\n id: string;\n title: string;\n content: string;\n}\n\nexport interface LLMProvider {\n complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise<string>;\n available(): boolean;\n}\n\nexport interface CacheProvider {\n getOrCompute(key: string, compute: () => Promise<string>): Promise<string>;\n hash(content: string): string;\n get(key: string): Promise<string | null>;\n set(key: string, value: string): Promise<void>;\n}\n\nexport interface Logger {\n debug(msg: string): void;\n info(msg: string): void;\n warn(msg: string): void;\n error(msg: string): void;\n}\n```\n\nAlso create `src/mcplocal/src/proxymodel/index.ts` as the public entrypoint that re-exports these types. Update `package.json` exports to expose `mcpctl/proxymodel`.",
|
||||
"testStrategy": "Unit tests verifying type exports are accessible from the public entrypoint. Create a sample stage file that imports from `mcpctl/proxymodel` and verify it compiles without errors.",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:50:07.620Z"
|
||||
},
|
||||
{
|
||||
"id": "72",
|
||||
"title": "Implement LLMProvider Adapter",
|
||||
"description": "Create an adapter that wraps the existing ProviderRegistry to implement the StageContext.llm interface, providing stages with a simplified LLM access API.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/llm-adapter.ts`:\n\n```typescript\nimport type { LLMProvider } from './types';\nimport type { ProviderRegistry } from '../providers/registry';\n\nexport function createLLMAdapter(registry: ProviderRegistry, projectName: string): LLMProvider {\n return {\n async complete(prompt: string, options?: { system?: string; maxTokens?: number }): Promise<string> {\n const provider = registry.getProvider('heavy');\n if (!provider) throw new Error('No LLM provider configured');\n \n const messages = options?.system \n ? [{ role: 'system', content: options.system }, { role: 'user', content: prompt }]\n : [{ role: 'user', content: prompt }];\n \n const result = await provider.complete({\n messages,\n maxTokens: options?.maxTokens ?? 1000,\n });\n return result.content;\n },\n \n available(): boolean {\n return registry.getProvider('heavy') !== null;\n }\n };\n}\n```\n\nThis adapter uses the 'heavy' tier from the existing registry, preserving the project-level LLM configuration.",
|
||||
"testStrategy": "Unit test with mocked ProviderRegistry verifying complete() calls are delegated correctly. Test available() returns false when no provider is configured. Integration test with a real provider.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:50:07.628Z"
|
||||
},
|
||||
{
|
||||
"id": "73",
|
||||
"title": "Implement In-Memory CacheProvider",
|
||||
"description": "Create the CacheProvider implementation that stages use for caching expensive computations. Start with in-memory cache for Phase 1, with content-addressed keys.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/cache-provider.ts`:\n\n```typescript\nimport { createHash } from 'crypto';\nimport type { CacheProvider } from './types';\n\nexport class InMemoryCacheProvider implements CacheProvider {\n private cache = new Map<string, { value: string; timestamp: number }>();\n private maxSize: number;\n private ttlMs: number;\n\n constructor(options: { maxSize?: number; ttlMs?: number } = {}) {\n this.maxSize = options.maxSize ?? 1000;\n this.ttlMs = options.ttlMs ?? 3600000; // 1 hour default\n }\n\n hash(content: string): string {\n return createHash('sha256').update(content).digest('hex').slice(0, 16);\n }\n\n async get(key: string): Promise<string | null> {\n const entry = this.cache.get(key);\n if (!entry) return null;\n if (Date.now() - entry.timestamp > this.ttlMs) {\n this.cache.delete(key);\n return null;\n }\n return entry.value;\n }\n\n async set(key: string, value: string): Promise<void> {\n if (this.cache.size >= this.maxSize) this.evictOldest();\n this.cache.set(key, { value, timestamp: Date.now() });\n }\n\n async getOrCompute(key: string, compute: () => Promise<string>): Promise<string> {\n const cached = await this.get(key);\n if (cached !== null) return cached;\n const value = await compute();\n await this.set(key, value);\n return value;\n }\n\n private evictOldest(): void {\n const oldest = [...this.cache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0];\n if (oldest) this.cache.delete(oldest[0]);\n }\n}\n```",
|
||||
"testStrategy": "Unit tests for: hash() produces consistent output, get() returns null for missing keys, set()/get() round-trip works, TTL expiration works, LRU eviction triggers at maxSize, getOrCompute() caches and returns cached values.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:50:07.634Z"
|
||||
},
|
||||
{
|
||||
"id": "74",
|
||||
"title": "Implement Content Type Detection",
|
||||
"description": "Create a utility that detects content type (JSON, YAML, XML, code, prose) for structural splitting in the section-split stage.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/content-detection.ts`:\n\n```typescript\nexport type ContentType = 'json' | 'yaml' | 'xml' | 'code' | 'prose';\n\nexport function detectContentType(content: string): ContentType {\n const trimmed = content.trimStart();\n \n // JSON detection\n if (trimmed.startsWith('{') || trimmed.startsWith('[')) {\n try {\n JSON.parse(content);\n return 'json';\n } catch { /* not valid JSON, continue */ }\n }\n \n // XML detection\n if (trimmed.startsWith('<?xml') || /^<[a-zA-Z][^>]*>/.test(trimmed)) {\n return 'xml';\n }\n \n // YAML detection (key: value at start of lines)\n if (/^[a-zA-Z_][a-zA-Z0-9_]*:\\s/m.test(trimmed) && !trimmed.includes('{')) {\n return 'yaml';\n }\n \n // Code detection (common patterns)\n const codePatterns = [\n /^(function |class |def |const |let |var |import |export |package |pub fn |fn |impl )/m,\n /^#include\\s+[<\"]/m,\n /^(public |private |protected )?(static )?(void |int |string |bool )/m,\n ];\n if (codePatterns.some(p => p.test(trimmed))) {\n return 'code';\n }\n \n return 'prose';\n}\n```",
|
||||
"testStrategy": "Unit tests with sample content for each type: valid JSON objects/arrays, XML documents, YAML configs, code snippets in multiple languages (JS, Python, Rust, Go, Java), and prose markdown. Edge cases: JSON-like strings that aren't valid JSON, mixed content.",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:50:07.640Z"
|
||||
},
|
||||
{
|
||||
"id": "75",
|
||||
"title": "Implement section-split Stage",
|
||||
"description": "Create the built-in section-split stage that splits content based on detected content type, using structural boundaries for JSON/YAML/XML and headers for prose.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/stages/section-split.ts`:\n\n```typescript\nimport type { StageHandler, Section } from '../types';\nimport { detectContentType } from '../content-detection';\n\nconst handler: StageHandler = async (content, ctx) => {\n const minSize = (ctx.config.minSectionSize as number) ?? 2000;\n const maxSize = (ctx.config.maxSectionSize as number) ?? 15000;\n const contentType = detectContentType(content);\n \n let sections: Section[];\n \n switch (contentType) {\n case 'json':\n sections = splitJson(content, minSize, maxSize);\n break;\n case 'yaml':\n sections = splitYaml(content, minSize, maxSize);\n break;\n case 'xml':\n sections = splitXml(content, minSize, maxSize);\n break;\n case 'code':\n sections = splitCode(content, minSize);\n break;\n default:\n sections = splitProse(content, minSize);\n }\n \n if (sections.length === 0) {\n return { content, sections: [{ id: 'main', title: 'Content', content }] };\n }\n \n const toc = sections.map((s, i) => `[${s.id}] ${s.title}`).join('\\n');\n return {\n content: `${sections.length} sections (${contentType}):\\n${toc}`,\n sections,\n };\n};\n\nfunction splitJson(content: string, minSize: number, maxSize: number): Section[] {\n const parsed = JSON.parse(content);\n if (Array.isArray(parsed)) {\n return parsed.map((item, i) => ({\n id: item.id ?? item.name ?? `item-${i}`,\n title: item.label ?? item.title ?? item.name ?? `Item ${i}`,\n content: JSON.stringify(item, null, 2),\n }));\n }\n return Object.entries(parsed).map(([key, value]) => ({\n id: key,\n title: key,\n content: JSON.stringify(value, null, 2),\n }));\n}\n\n// Similar implementations for splitYaml, splitXml, splitCode, splitProse\n```",
|
||||
"testStrategy": "Unit tests for each content type: JSON arrays split by element, JSON objects split by key, YAML split by top-level keys, XML split by elements, prose split by markdown headers. Test minSize/maxSize thresholds. Test fallback when content can't be parsed.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71",
|
||||
"74"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:55:47.712Z"
|
||||
},
|
||||
{
|
||||
"id": "76",
|
||||
"title": "Implement summarize-tree Stage",
|
||||
"description": "Create the built-in summarize-tree stage that recursively summarizes sections, using structural summaries for programmatic content and LLM summaries for prose.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/stages/summarize-tree.ts`:\n\n```typescript\nimport type { StageHandler, Section, StageContext } from '../types';\nimport { detectContentType } from '../content-detection';\n\nconst handler: StageHandler = async (content, ctx) => {\n const maxTokens = (ctx.config.maxSummaryTokens as number) ?? 200;\n const maxGroup = (ctx.config.maxGroupSize as number) ?? 5;\n const maxDepth = (ctx.config.maxDepth as number) ?? 3;\n \n // Parse sections from previous stage or create single section\n const inputSections = parseSectionsFromContent(content);\n \n const tree = await buildTree(inputSections, ctx, { maxTokens, maxGroup, maxDepth, depth: 0 });\n \n const toc = tree.map(s => \n `[${s.id}] ${s.title} — ${s.metadata?.summary ?? ''}` +\n (s.sections?.length ? `\\n → ${s.sections.length} sub-sections` : '')\n ).join('\\n');\n \n return {\n content: `${tree.length} sections:\\n${toc}\\n\\nUse section parameter to read details.`,\n sections: tree,\n };\n};\n\nasync function buildTree(\n sections: Section[], \n ctx: StageContext, \n opts: { maxTokens: number; maxGroup: number; maxDepth: number; depth: number }\n): Promise<Section[]> {\n for (const section of sections) {\n const contentType = detectContentType(section.content);\n \n // Structural summary for programmatic content (no LLM needed)\n if (contentType !== 'prose') {\n section.metadata = { summary: generateStructuralSummary(section.content, contentType) };\n } else {\n // LLM summary for prose (cached)\n const cacheKey = `summary:${ctx.cache.hash(section.content)}:${opts.maxTokens}`;\n const summary = await ctx.cache.getOrCompute(cacheKey, () =>\n ctx.llm.complete(\n `Summarize in ${opts.maxTokens} tokens, preserve MUST/REQUIRED items:\\n\\n${section.content}`\n )\n );\n section.metadata = { summary };\n }\n \n // Recurse if large and not at max depth\n if (section.content.length > 5000 && opts.depth < opts.maxDepth) {\n section.sections = await buildTree(\n splitContent(section.content),\n ctx,\n { ...opts, depth: opts.depth + 1 }\n );\n }\n }\n return sections;\n}\n\nfunction generateStructuralSummary(content: string, type: string): string {\n // Generate summary from structure: key names, array lengths, types\n // No LLM needed for JSON/YAML/XML/code\n}\n```",
|
||||
"testStrategy": "Unit tests: prose content gets LLM summary (mock LLM), JSON content gets structural summary without LLM call, recursive splitting triggers at 5000 chars, maxDepth is respected, cache is used for repeated content. Integration test with real LLM provider.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71",
|
||||
"72",
|
||||
"73",
|
||||
"74"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:55:47.719Z"
|
||||
},
|
||||
{
|
||||
"id": "77",
|
||||
"title": "Implement passthrough and paginate Stages",
|
||||
"description": "Create the built-in passthrough (no-op) and paginate (large response splitting) stages that form the default proxymodel.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/stages/passthrough.ts`:\n\n```typescript\nimport type { StageHandler } from '../types';\n\nconst handler: StageHandler = async (content, ctx) => {\n return { content };\n};\nexport default handler;\n```\n\nCreate `src/mcplocal/src/proxymodel/stages/paginate.ts`:\n\n```typescript\nimport type { StageHandler, Section } from '../types';\n\nconst handler: StageHandler = async (content, ctx) => {\n const pageSize = (ctx.config.pageSize as number) ?? 8000;\n \n if (content.length <= pageSize) {\n return { content };\n }\n \n const pages: Section[] = [];\n let offset = 0;\n let pageNum = 1;\n \n while (offset < content.length) {\n const pageContent = content.slice(offset, offset + pageSize);\n pages.push({\n id: `page-${pageNum}`,\n title: `Page ${pageNum}`,\n content: pageContent,\n });\n offset += pageSize;\n pageNum++;\n }\n \n return {\n content: `Content split into ${pages.length} pages (${content.length} chars total). Use section parameter to read specific pages.`,\n sections: pages,\n };\n};\nexport default handler;\n```",
|
||||
"testStrategy": "passthrough: verify content returned unchanged. paginate: verify content under threshold returns unchanged, content over threshold splits correctly, page boundaries are correct, section IDs are sequential.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T17:55:47.725Z"
|
||||
},
|
||||
{
|
||||
"id": "78",
|
||||
"title": "Create ProxyModel YAML Schema and Loader",
|
||||
"description": "Define the YAML schema for proxymodel definitions and implement the loader that reads from ~/.mcpctl/proxymodels/ and merges with built-ins.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/schema.ts`:\n\n```typescript\nimport { z } from 'zod';\n\nexport const ProxyModelSchema = z.object({\n kind: z.literal('ProxyModel'),\n metadata: z.object({\n name: z.string(),\n }),\n spec: z.object({\n controller: z.string().optional().default('gate'),\n controllerConfig: z.record(z.unknown()).optional(),\n stages: z.array(z.object({\n type: z.string(),\n config: z.record(z.unknown()).optional(),\n })),\n appliesTo: z.array(z.enum(['prompts', 'toolResults', 'resource'])).optional(),\n cacheable: z.boolean().optional().default(true),\n }),\n});\n\nexport type ProxyModelDefinition = z.infer<typeof ProxyModelSchema>;\n```\n\nCreate `src/mcplocal/src/proxymodel/loader.ts`:\n\n```typescript\nimport { readdir, readFile } from 'fs/promises';\nimport { join } from 'path';\nimport { parse as parseYaml } from 'yaml';\nimport { ProxyModelSchema, type ProxyModelDefinition } from './schema';\nimport { getBuiltInProxyModels } from './built-in-models';\n\nconst PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');\n\nexport async function loadProxyModels(): Promise<Map<string, ProxyModelDefinition>> {\n const models = new Map<string, ProxyModelDefinition>();\n \n // Load built-ins first\n for (const [name, model] of getBuiltInProxyModels()) {\n models.set(name, model);\n }\n \n // Load local (overrides built-ins)\n try {\n const files = await readdir(PROXYMODELS_DIR);\n for (const file of files.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {\n const content = await readFile(join(PROXYMODELS_DIR, file), 'utf-8');\n const parsed = parseYaml(content);\n const validated = ProxyModelSchema.parse(parsed);\n models.set(validated.metadata.name, validated);\n }\n } catch (e) {\n // Directory doesn't exist or can't be read - use built-ins only\n }\n \n return models;\n}\n```",
|
||||
"testStrategy": "Unit tests: valid YAML parses correctly, invalid YAML throws validation error, local models override built-ins with same name, missing directory doesn't throw. Create test fixtures for various YAML configurations.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:02:37.061Z"
|
||||
},
|
||||
{
|
||||
"id": "79",
|
||||
"title": "Implement Stage Registry and Dynamic Loader",
|
||||
"description": "Create the stage registry that resolves stage names to handlers, loading from ~/.mcpctl/stages/ for custom stages and falling back to built-ins.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/stage-registry.ts`:\n\n```typescript\nimport { readdir, stat } from 'fs/promises';\nimport { join } from 'path';\nimport type { StageHandler } from './types';\n\nconst STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');\n\nconst builtInStages: Map<string, StageHandler> = new Map();\nconst customStages: Map<string, StageHandler> = new Map();\n\n// Register built-ins at module load\nimport passthrough from './stages/passthrough';\nimport paginate from './stages/paginate';\nimport sectionSplit from './stages/section-split';\nimport summarizeTree from './stages/summarize-tree';\n\nbuiltInStages.set('passthrough', passthrough);\nbuiltInStages.set('paginate', paginate);\nbuiltInStages.set('section-split', sectionSplit);\nbuiltInStages.set('summarize-tree', summarizeTree);\n\nexport async function loadCustomStages(): Promise<void> {\n customStages.clear();\n try {\n const files = await readdir(STAGES_DIR);\n for (const file of files.filter(f => f.endsWith('.ts') || f.endsWith('.js'))) {\n const name = file.replace(/\\.(ts|js)$/, '');\n const module = await import(join(STAGES_DIR, file));\n customStages.set(name, module.default);\n }\n } catch { /* directory doesn't exist */ }\n}\n\nexport function getStage(name: string): StageHandler | null {\n return customStages.get(name) ?? builtInStages.get(name) ?? null;\n}\n\nexport function listStages(): { name: string; source: 'built-in' | 'local' }[] {\n const result: { name: string; source: 'built-in' | 'local' }[] = [];\n for (const name of builtInStages.keys()) {\n result.push({ name, source: customStages.has(name) ? 'local' : 'built-in' });\n }\n for (const name of customStages.keys()) {\n if (!builtInStages.has(name)) result.push({ name, source: 'local' });\n }\n return result;\n}\n```",
|
||||
"testStrategy": "Unit tests: built-in stages are registered, getStage() returns correct handler, custom stages override built-ins, listStages() shows correct sources, missing stages return null. Integration test with actual stage files in temp directory.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71",
|
||||
"75",
|
||||
"76",
|
||||
"77"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:02:37.068Z"
|
||||
},
|
||||
{
|
||||
"id": "80",
|
||||
"title": "Implement Pipeline Executor",
|
||||
"description": "Create the pipeline executor that runs content through a sequence of stages, managing context, caching, and error handling.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/executor.ts`:\n\n```typescript\nimport type { StageContext, StageResult, Section } from './types';\nimport type { ProxyModelDefinition } from './schema';\nimport { getStage } from './stage-registry';\nimport { createLLMAdapter } from './llm-adapter';\nimport { InMemoryCacheProvider } from './cache-provider';\nimport type { ProviderRegistry } from '../providers/registry';\n\nexport interface ExecuteOptions {\n content: string;\n contentType: 'prompt' | 'toolResult' | 'resource';\n sourceName: string;\n projectName: string;\n sessionId: string;\n proxyModel: ProxyModelDefinition;\n providerRegistry: ProviderRegistry;\n cache?: InMemoryCacheProvider;\n}\n\nexport async function executePipeline(opts: ExecuteOptions): Promise<StageResult> {\n const { content, proxyModel, providerRegistry } = opts;\n const cache = opts.cache ?? new InMemoryCacheProvider();\n const llm = createLLMAdapter(providerRegistry, opts.projectName);\n \n let currentContent = content;\n let sections: Section[] | undefined;\n let metadata: Record<string, unknown> = {};\n \n for (const stageConfig of proxyModel.spec.stages) {\n const handler = getStage(stageConfig.type);\n if (!handler) {\n console.warn(`Stage '${stageConfig.type}' not found, skipping`);\n continue;\n }\n \n const ctx: StageContext = {\n contentType: opts.contentType,\n sourceName: opts.sourceName,\n projectName: opts.projectName,\n sessionId: opts.sessionId,\n originalContent: content,\n llm,\n cache,\n log: createLogger(stageConfig.type),\n config: stageConfig.config ?? {},\n };\n \n try {\n const result = await handler(currentContent, ctx);\n currentContent = result.content;\n if (result.sections) sections = result.sections;\n if (result.metadata) metadata = { ...metadata, ...result.metadata };\n } catch (err) {\n console.error(`Stage '${stageConfig.type}' failed:`, err);\n // Continue with previous content on error\n }\n }\n \n return { content: currentContent, sections, metadata };\n}\n\nfunction createLogger(stageName: string) {\n return {\n debug: (msg: string) => console.debug(`[${stageName}] ${msg}`),\n info: (msg: string) => console.info(`[${stageName}] ${msg}`),\n warn: (msg: string) => console.warn(`[${stageName}] ${msg}`),\n error: (msg: string) => console.error(`[${stageName}] ${msg}`),\n };\n}\n```",
|
||||
"testStrategy": "Unit tests: single stage executes correctly, multiple stages chain output to input, originalContent preserved across stages, missing stage logs warning and continues, stage error doesn't break pipeline, sections/metadata accumulate correctly.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"71",
|
||||
"72",
|
||||
"73",
|
||||
"79"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:03:47.548Z"
|
||||
},
|
||||
{
|
||||
"id": "81",
|
||||
"title": "Define Built-in ProxyModels (default, subindex)",
|
||||
"description": "Create the built-in proxymodel definitions for 'default' (current behavior) and 'subindex' (hierarchical navigation).",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/built-in-models.ts`:\n\n```typescript\nimport type { ProxyModelDefinition } from './schema';\n\nexport function getBuiltInProxyModels(): Map<string, ProxyModelDefinition> {\n const models = new Map<string, ProxyModelDefinition>();\n \n models.set('default', {\n kind: 'ProxyModel',\n metadata: { name: 'default' },\n spec: {\n controller: 'gate',\n controllerConfig: { byteBudget: 8192 },\n stages: [\n { type: 'passthrough' },\n { type: 'paginate', config: { pageSize: 8000 } },\n ],\n appliesTo: ['prompts', 'toolResults'],\n cacheable: false,\n },\n });\n \n models.set('subindex', {\n kind: 'ProxyModel',\n metadata: { name: 'subindex' },\n spec: {\n controller: 'gate',\n controllerConfig: { byteBudget: 8192 },\n stages: [\n { type: 'section-split', config: { minSectionSize: 2000, maxSectionSize: 15000 } },\n { type: 'summarize-tree', config: { maxSummaryTokens: 200, maxGroupSize: 5, maxDepth: 3 } },\n ],\n appliesTo: ['prompts', 'toolResults'],\n cacheable: true,\n },\n });\n \n return models;\n}\n```",
|
||||
"testStrategy": "Unit tests: both models are returned by getBuiltInProxyModels(), 'default' has passthrough+paginate stages, 'subindex' has section-split+summarize-tree stages, both schemas validate correctly.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"78"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:02:37.075Z"
|
||||
},
|
||||
{
|
||||
"id": "82",
|
||||
"title": "Integrate Pipeline Executor into Router",
|
||||
"description": "Modify the McpRouter to route content through the proxymodel pipeline, keeping the gating logic cleanly separated from content processing.",
|
||||
"details": "Modify `src/mcplocal/src/router.ts` to:\n\n1. Add proxyModel resolution during router creation:\n```typescript\nimport { loadProxyModels } from './proxymodel/loader';\nimport { executePipeline } from './proxymodel/executor';\n\ninterface RouterOptions {\n proxyModelName?: string;\n // ... existing options\n}\n\nasync function createRouter(opts: RouterOptions): Promise<McpRouter> {\n const proxyModels = await loadProxyModels();\n const proxyModel = proxyModels.get(opts.proxyModelName ?? 'default');\n // ...\n}\n```\n\n2. Add content processing method:\n```typescript\nasync processContent(\n content: string,\n type: 'prompt' | 'toolResult',\n sourceName: string,\n sessionId: string\n): Promise<StageResult> {\n if (!this.proxyModel) return { content };\n \n const appliesTo = this.proxyModel.spec.appliesTo ?? ['prompts', 'toolResults'];\n if (!appliesTo.includes(type === 'prompt' ? 'prompts' : 'toolResults')) {\n return { content };\n }\n \n return executePipeline({\n content,\n contentType: type,\n sourceName,\n projectName: this.projectName,\n sessionId,\n proxyModel: this.proxyModel,\n providerRegistry: this.providerRegistry,\n cache: this.cache,\n });\n}\n```\n\n3. Call processContent at the appropriate points in the request flow (prompt serving, tool result handling) WITHOUT interweaving with gating logic.",
|
||||
"testStrategy": "Integration tests: default proxymodel passes content through unchanged, subindex proxymodel produces summaries, appliesTo filtering works correctly, gating still works as before with proxymodel processing happening at the right stage.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"80",
|
||||
"81"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:06:18.464Z"
|
||||
},
|
||||
{
|
||||
"id": "83",
|
||||
"title": "Implement Section Drill-Down for Prompts",
|
||||
"description": "Extend read_prompts to support section parameter for drilling into specific sections produced by proxymodel stages.",
|
||||
"details": "Modify the read_prompts handler in `src/mcplocal/src/router.ts`:\n\n```typescript\n// In the read_prompts tool handler\nif (args.section) {\n // Look up section in the processed result\n const sectionId = args.section;\n const cachedResult = this.sectionCache.get(promptName);\n if (cachedResult?.sections) {\n const section = findSection(cachedResult.sections, sectionId);\n if (section) {\n return { content: [{ type: 'text', text: section.content }] };\n }\n return { content: [{ type: 'text', text: `Section '${sectionId}' not found` }], isError: true };\n }\n}\n\n// Helper to find section by ID (supports nested sections)\nfunction findSection(sections: Section[], id: string): Section | null {\n for (const s of sections) {\n if (s.id === id) return s;\n if (s.sections) {\n const nested = findSection(s.sections, id);\n if (nested) return nested;\n }\n }\n return null;\n}\n```\n\nAlso add a sectionCache Map to store processed results with their sections for drill-down.",
|
||||
"testStrategy": "Integration tests: read_prompts with section parameter returns correct section content, nested section lookup works, missing section returns error, section cache populated after initial processing.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"82"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.043Z"
|
||||
},
|
||||
{
|
||||
"id": "84",
|
||||
"title": "Implement Section Drill-Down for Tool Results",
|
||||
"description": "Extend tool result handling to support _section parameter for drilling into specific sections of large tool responses.",
|
||||
"details": "Modify tool call handling in `src/mcplocal/src/router.ts`:\n\n```typescript\n// When processing tool calls\nif (args._section) {\n const sectionId = args._section;\n delete args._section; // Don't pass to upstream\n \n // Check cache for previous full result\n const cacheKey = `tool:${serverName}/${toolName}:${JSON.stringify(args)}`;\n const cachedResult = this.toolResultCache.get(cacheKey);\n \n if (cachedResult?.sections) {\n const section = findSection(cachedResult.sections, sectionId);\n if (section) {\n return { content: [{ type: 'text', text: section.content }] };\n }\n }\n // If no cache, make the full call and process, then serve section\n}\n\n// After receiving tool result, process through pipeline\nconst processed = await this.processContent(result, 'toolResult', `${serverName}/${toolName}`, sessionId);\nif (processed.sections) {\n this.toolResultCache.set(cacheKey, processed);\n}\n```\n\nAdd a toolResultCache Map with appropriate TTL.",
|
||||
"testStrategy": "Integration tests: large tool result gets processed into sections, _section parameter returns specific section, _section removed before upstream call, cache hit serves from cache, cache miss processes and caches.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"82"
|
||||
],
|
||||
"status": "done",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-27T18:06:37.590Z"
|
||||
},
|
||||
{
|
||||
"id": "85",
|
||||
"title": "Implement Hot-Reload for Stages",
|
||||
"description": "Add file watching for ~/.mcpctl/stages/ to automatically reload custom stages when they change without restarting mcplocal.",
|
||||
"details": "Modify `src/mcplocal/src/proxymodel/stage-registry.ts`:\n\n```typescript\nimport { watch, FSWatcher } from 'fs';\nimport { join, basename } from 'path';\n\nlet watcher: FSWatcher | null = null;\nconst stageFileHashes: Map<string, string> = new Map();\n\nexport function startStageWatcher(): void {\n if (watcher) return;\n \n try {\n watcher = watch(STAGES_DIR, async (eventType, filename) => {\n if (!filename || (!filename.endsWith('.ts') && !filename.endsWith('.js'))) return;\n \n const name = filename.replace(/\\.(ts|js)$/, '');\n const fullPath = join(STAGES_DIR, filename);\n \n if (eventType === 'rename') {\n // File added or removed\n await loadCustomStages();\n console.info(`[proxymodel] Stages reloaded due to ${filename} change`);\n } else if (eventType === 'change') {\n // File modified - invalidate module cache and reload\n delete require.cache[require.resolve(fullPath)];\n try {\n const module = await import(fullPath + '?t=' + Date.now());\n customStages.set(name, module.default);\n console.info(`[proxymodel] Stage '${name}' hot-reloaded`);\n } catch (err) {\n console.error(`[proxymodel] Failed to reload stage '${name}':`, err);\n }\n }\n });\n } catch {\n // Directory doesn't exist - no watching needed\n }\n}\n\nexport function stopStageWatcher(): void {\n watcher?.close();\n watcher = null;\n}\n```\n\nCall startStageWatcher() during mcplocal initialization.",
|
||||
"testStrategy": "Integration tests: modify a stage file and verify the new version is loaded without restart, add a new stage file and verify it becomes available, remove a stage file and verify it's no longer available, syntax errors in stage file don't crash the watcher.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.050Z"
|
||||
},
|
||||
{
|
||||
"id": "86",
|
||||
"title": "Implement Hot-Reload for ProxyModels",
|
||||
"description": "Add file watching for ~/.mcpctl/proxymodels/ to automatically reload proxymodel definitions when they change.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/model-watcher.ts`:\n\n```typescript\nimport { watch, FSWatcher } from 'fs';\nimport { join } from 'path';\nimport { readFile } from 'fs/promises';\nimport { parse as parseYaml } from 'yaml';\nimport { ProxyModelSchema } from './schema';\n\nconst PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');\nlet watcher: FSWatcher | null = null;\nconst modelUpdateCallbacks: Set<() => void> = new Set();\n\nexport function onModelUpdate(callback: () => void): () => void {\n modelUpdateCallbacks.add(callback);\n return () => modelUpdateCallbacks.delete(callback);\n}\n\nexport function startModelWatcher(): void {\n if (watcher) return;\n \n try {\n watcher = watch(PROXYMODELS_DIR, async (eventType, filename) => {\n if (!filename || (!filename.endsWith('.yaml') && !filename.endsWith('.yml'))) return;\n \n console.info(`[proxymodel] Model file ${filename} changed, reloading...`);\n \n // Notify all subscribers to reload their models\n for (const cb of modelUpdateCallbacks) {\n try { cb(); } catch (err) { console.error('Model update callback failed:', err); }\n }\n });\n } catch {\n // Directory doesn't exist\n }\n}\n```\n\nIntegrate with router to reload proxymodels when files change.",
|
||||
"testStrategy": "Integration tests: modify a proxymodel YAML and verify changes take effect, add a new proxymodel and verify it becomes available, invalid YAML logs error but doesn't crash.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"78"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.059Z"
|
||||
},
|
||||
{
|
||||
"id": "87",
|
||||
"title": "Add proxyModel Field to Project Schema",
|
||||
"description": "Extend the Project database schema and API to support proxyModel field and proxyModelOverrides for per-content-type configuration.",
|
||||
"details": "Update `src/db/prisma/schema.prisma`:\n\n```prisma\nmodel Project {\n // ... existing fields\n proxyModel String? @default(\"default\")\n proxyModelOverrides Json? // { prompts: { \"prompt-name\": \"model\" }, toolResults: { \"server/tool\": \"model\" } }\n}\n```\n\nRun `npx prisma migrate dev --name add_proxymodel_field`.\n\nUpdate `src/mcpd/src/routes/projects.ts` to include the new fields in CRUD operations.\n\nUpdate `src/cli/src/commands/get.ts` and `describe.ts` to display proxyModel.\n\nUpdate `src/cli/src/commands/patch.ts` to support `--set proxyModel=<name>`.",
|
||||
"testStrategy": "Database migration test: verify migration applies cleanly. API tests: verify proxyModel field is returned in project GET, can be updated via PATCH. CLI tests: verify `mcpctl describe project <name>` shows proxyModel.",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"status": "deferred",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Minimal placeholder subtask",
|
||||
"description": "This task requires complete rewrite before expansion.",
|
||||
"dependencies": [],
|
||||
"details": "Task 87 has been marked as DO NOT EXPAND and needs to be completely rewritten first. No subtasks should be generated until the task is properly redefined.",
|
||||
"status": "pending",
|
||||
"testStrategy": null,
|
||||
"parentId": "undefined"
|
||||
}
|
||||
],
|
||||
"updatedAt": "2026-02-28T01:07:00.065Z"
|
||||
},
|
||||
{
|
||||
"id": "88",
|
||||
"title": "Rename proxyMode: filtered to proxyMode: proxy",
|
||||
"description": "Rename the existing proxyMode value 'filtered' to 'proxy' for clarity, with backwards compatibility for existing configs.",
|
||||
"details": "Update `src/db/prisma/schema.prisma`:\n\n```prisma\nenum ProxyMode {\n direct\n proxy // renamed from 'filtered'\n}\n```\n\nCreate migration that updates existing 'filtered' values to 'proxy':\n```sql\nUPDATE Project SET proxyMode = 'proxy' WHERE proxyMode = 'filtered';\n```\n\nUpdate all code references from 'filtered' to 'proxy':\n- `src/mcplocal/src/http/project-mcp-endpoint.ts`\n- `src/cli/src/commands/create.ts`\n- Documentation and help text\n\nFor backwards compatibility in config files, add a normalization step that treats 'filtered' as 'proxy'.",
|
||||
"testStrategy": "Migration test: existing projects with proxyMode='filtered' are updated to 'proxy'. Config parsing test: both 'filtered' and 'proxy' values work. CLI test: help text shows 'proxy' not 'filtered'.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"87"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.071Z"
|
||||
},
|
||||
{
|
||||
"id": "89",
|
||||
"title": "Implement mcpctl get proxymodels Command",
|
||||
"description": "Add CLI command to list all available proxymodels (built-in + local) with source, stages, and requirements.",
|
||||
"details": "Create `src/cli/src/commands/get-proxymodels.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { loadProxyModels } from 'mcplocal/proxymodel/loader';\nimport { listStages } from 'mcplocal/proxymodel/stage-registry';\nimport Table from 'cli-table3';\n\nexport function registerGetProxymodels(program: Command): void {\n program\n .command('get proxymodels')\n .description('List all available proxymodels')\n .action(async () => {\n const models = await loadProxyModels();\n const stageInfo = new Map(listStages().map(s => [s.name, s]));\n \n const table = new Table({\n head: ['NAME', 'SOURCE', 'STAGES', 'REQUIRES-LLM', 'CACHEABLE'],\n });\n \n for (const [name, model] of models) {\n const source = isBuiltIn(name) ? 'built-in' : 'local';\n const stages = model.spec.stages.map(s => s.type).join(',');\n const requiresLlm = model.spec.stages.some(s => stageRequiresLlm(s.type));\n const cacheable = model.spec.cacheable ? 'yes' : 'no';\n \n table.push([name, source, stages, requiresLlm ? 'yes' : 'no', cacheable]);\n }\n \n console.log(table.toString());\n });\n}\n```\n\nRegister in `src/cli/src/commands/get.ts` as a subcommand.",
|
||||
"testStrategy": "CLI test: `mcpctl get proxymodels` outputs table with expected columns. Test with only built-ins, test with local overrides, verify correct source detection.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"78",
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.076Z"
|
||||
},
|
||||
{
|
||||
"id": "90",
|
||||
"title": "Implement mcpctl get stages Command",
|
||||
"description": "Add CLI command to list all available stages (built-in + custom) with source and LLM requirements.",
|
||||
"details": "Create `src/cli/src/commands/get-stages.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { listStages } from 'mcplocal/proxymodel/stage-registry';\nimport Table from 'cli-table3';\n\nconst LLM_REQUIRING_STAGES = ['summarize', 'summarize-tree', 'enhance', 'compress'];\n\nexport function registerGetStages(program: Command): void {\n program\n .command('get stages')\n .description('List all available stages')\n .action(async () => {\n const stages = listStages();\n \n const table = new Table({\n head: ['NAME', 'SOURCE', 'REQUIRES-LLM'],\n });\n \n for (const stage of stages) {\n const requiresLlm = LLM_REQUIRING_STAGES.includes(stage.name);\n table.push([stage.name, stage.source, requiresLlm ? 'yes' : 'no']);\n }\n \n console.log(table.toString());\n });\n}\n```",
|
||||
"testStrategy": "CLI test: `mcpctl get stages` outputs table with expected columns. Test with only built-ins, test with custom stages in ~/.mcpctl/stages/, verify custom overrides show 'local' source.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.082Z"
|
||||
},
|
||||
{
|
||||
"id": "91",
|
||||
"title": "Implement mcpctl describe proxymodel Command",
|
||||
"description": "Add CLI command to show detailed information about a specific proxymodel including full stage configuration.",
|
||||
"details": "Create `src/cli/src/commands/describe-proxymodel.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { loadProxyModels } from 'mcplocal/proxymodel/loader';\nimport { stringify as yamlStringify } from 'yaml';\n\nexport function registerDescribeProxymodel(program: Command): void {\n program\n .command('describe proxymodel <name>')\n .description('Show detailed information about a proxymodel')\n .action(async (name: string) => {\n const models = await loadProxyModels();\n const model = models.get(name);\n \n if (!model) {\n console.error(`Proxymodel '${name}' not found`);\n process.exit(1);\n }\n \n console.log(`Name: ${model.metadata.name}`);\n console.log(`Source: ${isBuiltIn(name) ? 'built-in' : 'local'}`);\n console.log(`Controller: ${model.spec.controller ?? 'gate'}`);\n console.log(`Cacheable: ${model.spec.cacheable ? 'yes' : 'no'}`);\n console.log(`Applies to: ${(model.spec.appliesTo ?? ['prompts', 'toolResults']).join(', ')}`);\n console.log('');\n console.log('Stages:');\n for (const stage of model.spec.stages) {\n console.log(` - ${stage.type}`);\n if (stage.config) {\n console.log(` config:`);\n for (const [k, v] of Object.entries(stage.config)) {\n console.log(` ${k}: ${JSON.stringify(v)}`);\n }\n }\n }\n });\n}\n```",
|
||||
"testStrategy": "CLI test: `mcpctl describe proxymodel default` shows expected output. Test with proxymodel that has stage configs, verify all fields displayed correctly.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"78"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.087Z"
|
||||
},
|
||||
{
|
||||
"id": "92",
|
||||
"title": "Implement mcpctl describe stage Command",
|
||||
"description": "Add CLI command to show detailed information about a specific stage including its source location.",
|
||||
"details": "Create `src/cli/src/commands/describe-stage.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { listStages, getStage } from 'mcplocal/proxymodel/stage-registry';\nimport { join } from 'path';\n\nconst STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');\n\nconst STAGE_DESCRIPTIONS: Record<string, string> = {\n 'passthrough': 'Returns content unchanged. No processing.',\n 'paginate': 'Splits large content into pages with navigation.',\n 'section-split': 'Splits content on structural boundaries (headers, JSON keys, etc.).',\n 'summarize-tree': 'Recursively summarizes sections with hierarchical navigation.',\n};\n\nexport function registerDescribeStage(program: Command): void {\n program\n .command('describe stage <name>')\n .description('Show detailed information about a stage')\n .action(async (name: string) => {\n const stages = listStages();\n const stageInfo = stages.find(s => s.name === name);\n \n if (!stageInfo) {\n console.error(`Stage '${name}' not found`);\n process.exit(1);\n }\n \n console.log(`Name: ${name}`);\n console.log(`Source: ${stageInfo.source}`);\n if (stageInfo.source === 'local') {\n console.log(`Path: ${join(STAGES_DIR, name + '.ts')}`);\n }\n console.log(`Description: ${STAGE_DESCRIPTIONS[name] ?? 'Custom stage'}`);\n console.log(`Requires LLM: ${requiresLlm(name) ? 'yes' : 'no'}`);\n });\n}\n```",
|
||||
"testStrategy": "CLI test: `mcpctl describe stage passthrough` shows expected output. Test with custom stage, verify path is shown correctly.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.092Z"
|
||||
},
|
||||
{
|
||||
"id": "93",
|
||||
"title": "Implement mcpctl create stage Command",
|
||||
"description": "Add CLI command to scaffold a new custom stage with boilerplate TypeScript code.",
|
||||
"details": "Create `src/cli/src/commands/create-stage.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { mkdir, writeFile, access } from 'fs/promises';\nimport { join } from 'path';\n\nconst STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');\n\nconst STAGE_TEMPLATE = `import type { StageHandler } from 'mcpctl/proxymodel';\n\n/**\n * Custom stage: {{name}}\n * \n * Modify this handler to transform content as needed.\n * Available in ctx:\n * - ctx.llm.complete(prompt) - call the configured LLM\n * - ctx.cache.getOrCompute(key, fn) - cache expensive computations\n * - ctx.config - stage configuration from proxymodel YAML\n * - ctx.originalContent - raw content before any stage processing\n * - ctx.log - structured logging\n */\nconst handler: StageHandler = async (content, ctx) => {\n // TODO: Implement your transformation\n return { content };\n};\n\nexport default handler;\n`;\n\nexport function registerCreateStage(program: Command): void {\n program\n .command('create stage <name>')\n .description('Create a new custom stage')\n .action(async (name: string) => {\n await mkdir(STAGES_DIR, { recursive: true });\n \n const filePath = join(STAGES_DIR, `${name}.ts`);\n \n try {\n await access(filePath);\n console.error(`Stage '${name}' already exists at ${filePath}`);\n process.exit(1);\n } catch {\n // File doesn't exist, good\n }\n \n const code = STAGE_TEMPLATE.replace(/\\{\\{name\\}\\}/g, name);\n await writeFile(filePath, code);\n \n console.log(`Created ${filePath}`);\n console.log('Edit the file to implement your stage logic.');\n });\n}\n```",
|
||||
"testStrategy": "CLI test: `mcpctl create stage my-filter` creates file at expected path with correct template. Test error when stage already exists. Verify generated code compiles.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"71"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.098Z"
|
||||
},
|
||||
{
|
||||
"id": "94",
|
||||
"title": "Implement mcpctl create proxymodel Command",
|
||||
"description": "Add CLI command to scaffold a new proxymodel YAML file with specified stages.",
|
||||
"details": "Create `src/cli/src/commands/create-proxymodel.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { mkdir, writeFile, access } from 'fs/promises';\nimport { join } from 'path';\nimport { stringify as yamlStringify } from 'yaml';\n\nconst PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');\n\nexport function registerCreateProxymodel(program: Command): void {\n program\n .command('create proxymodel <name>')\n .description('Create a new proxymodel')\n .option('--stages <stages>', 'Comma-separated list of stage names', 'passthrough')\n .option('--controller <controller>', 'Session controller (gate or none)', 'gate')\n .action(async (name: string, opts) => {\n await mkdir(PROXYMODELS_DIR, { recursive: true });\n \n const filePath = join(PROXYMODELS_DIR, `${name}.yaml`);\n \n try {\n await access(filePath);\n console.error(`Proxymodel '${name}' already exists at ${filePath}`);\n process.exit(1);\n } catch {\n // File doesn't exist, good\n }\n \n const stages = opts.stages.split(',').map((s: string) => ({ type: s.trim() }));\n \n const model = {\n kind: 'ProxyModel',\n metadata: { name },\n spec: {\n controller: opts.controller,\n stages,\n appliesTo: ['prompts', 'toolResults'],\n cacheable: true,\n },\n };\n \n await writeFile(filePath, yamlStringify(model));\n \n console.log(`Created ${filePath}`);\n });\n}\n```",
|
||||
"testStrategy": "CLI test: `mcpctl create proxymodel my-pipeline --stages summarize,compress` creates valid YAML. Test default values. Verify generated YAML validates against schema.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"78"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.102Z"
|
||||
},
|
||||
{
|
||||
"id": "95",
|
||||
"title": "Implement mcpctl proxymodel validate Command",
|
||||
"description": "Add CLI command to validate a proxymodel definition, checking that all stages resolve and config is valid.",
|
||||
"details": "Create `src/cli/src/commands/proxymodel-validate.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { loadProxyModels } from 'mcplocal/proxymodel/loader';\nimport { getStage, loadCustomStages } from 'mcplocal/proxymodel/stage-registry';\n\nexport function registerProxymodelValidate(program: Command): void {\n program\n .command('proxymodel validate <name>')\n .description('Validate a proxymodel definition')\n .action(async (name: string) => {\n await loadCustomStages();\n const models = await loadProxyModels();\n const model = models.get(name);\n \n if (!model) {\n console.error(`Proxymodel '${name}' not found`);\n process.exit(1);\n }\n \n let valid = true;\n const errors: string[] = [];\n \n // Check all stages resolve\n for (const stageConfig of model.spec.stages) {\n const stage = getStage(stageConfig.type);\n if (!stage) {\n errors.push(`Stage '${stageConfig.type}' not found`);\n valid = false;\n }\n }\n \n // Check controller is valid\n const validControllers = ['gate', 'none'];\n if (model.spec.controller && !validControllers.includes(model.spec.controller)) {\n errors.push(`Unknown controller '${model.spec.controller}'`);\n valid = false;\n }\n \n if (valid) {\n console.log(`✓ Proxymodel '${name}' is valid`);\n } else {\n console.error(`✗ Proxymodel '${name}' has errors:`);\n for (const err of errors) {\n console.error(` - ${err}`);\n }\n process.exit(1);\n }\n });\n}\n```",
|
||||
"testStrategy": "CLI test: valid proxymodel passes, proxymodel with unknown stage fails with clear error, proxymodel with unknown controller fails. Test with both built-in and custom stages.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"78",
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.107Z"
|
||||
},
|
||||
{
|
||||
"id": "96",
|
||||
"title": "Implement mcpctl delete stage Command",
|
||||
"description": "Add CLI command to delete a custom stage file (cannot delete built-ins).",
|
||||
"details": "Create `src/cli/src/commands/delete-stage.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { unlink, access } from 'fs/promises';\nimport { join } from 'path';\nimport { listStages } from 'mcplocal/proxymodel/stage-registry';\n\nconst STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');\n\nexport function registerDeleteStage(program: Command): void {\n program\n .command('delete stage <name>')\n .description('Delete a custom stage')\n .action(async (name: string) => {\n const stages = listStages();\n const stageInfo = stages.find(s => s.name === name);\n \n if (!stageInfo) {\n console.error(`Stage '${name}' not found`);\n process.exit(1);\n }\n \n if (stageInfo.source === 'built-in') {\n console.error(`Cannot delete built-in stage '${name}'`);\n process.exit(1);\n }\n \n const filePath = join(STAGES_DIR, `${name}.ts`);\n await unlink(filePath);\n \n console.log(`Deleted ${filePath}`);\n });\n}\n```",
|
||||
"testStrategy": "CLI test: can delete custom stage, cannot delete built-in stage (error message), deleting non-existent stage shows error.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"79"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.116Z"
|
||||
},
|
||||
{
|
||||
"id": "97",
|
||||
"title": "Implement mcpctl delete proxymodel Command",
|
||||
"description": "Add CLI command to delete a local proxymodel YAML file (cannot delete built-ins).",
|
||||
"details": "Create `src/cli/src/commands/delete-proxymodel.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { unlink, access } from 'fs/promises';\nimport { join } from 'path';\nimport { loadProxyModels } from 'mcplocal/proxymodel/loader';\nimport { getBuiltInProxyModels } from 'mcplocal/proxymodel/built-in-models';\n\nconst PROXYMODELS_DIR = join(process.env.HOME ?? '', '.mcpctl', 'proxymodels');\n\nexport function registerDeleteProxymodel(program: Command): void {\n program\n .command('delete proxymodel <name>')\n .description('Delete a local proxymodel')\n .action(async (name: string) => {\n const models = await loadProxyModels();\n const builtIns = getBuiltInProxyModels();\n \n if (!models.has(name)) {\n console.error(`Proxymodel '${name}' not found`);\n process.exit(1);\n }\n \n const filePath = join(PROXYMODELS_DIR, `${name}.yaml`);\n \n try {\n await access(filePath);\n } catch {\n if (builtIns.has(name)) {\n console.error(`Cannot delete built-in proxymodel '${name}'`);\n } else {\n console.error(`Proxymodel '${name}' file not found at ${filePath}`);\n }\n process.exit(1);\n }\n \n await unlink(filePath);\n console.log(`Deleted ${filePath}`);\n \n if (builtIns.has(name)) {\n console.log(`Note: Built-in '${name}' will still be available`);\n }\n });\n}\n```",
|
||||
"testStrategy": "CLI test: can delete local proxymodel, cannot delete built-in (error message), deleting local override shows note about built-in fallback.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"78"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.122Z"
|
||||
},
|
||||
{
|
||||
"id": "98",
|
||||
"title": "Implement Persistent File Cache for Stages",
|
||||
"description": "Extend CacheProvider with file-based persistence in ~/.mcpctl/cache/proxymodel/ for cross-session caching.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/file-cache.ts`:\n\n```typescript\nimport { mkdir, readFile, writeFile, readdir, stat, unlink } from 'fs/promises';\nimport { join } from 'path';\nimport { createHash } from 'crypto';\nimport type { CacheProvider } from './types';\n\nconst CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');\n\nexport class FileCacheProvider implements CacheProvider {\n private memCache = new Map<string, string>();\n private maxSizeBytes: number;\n \n constructor(options: { maxSizeBytes?: number } = {}) {\n this.maxSizeBytes = options.maxSizeBytes ?? 100 * 1024 * 1024; // 100MB default\n }\n \n hash(content: string): string {\n return createHash('sha256').update(content).digest('hex').slice(0, 16);\n }\n \n private keyToPath(key: string): string {\n const safeKey = key.replace(/[^a-zA-Z0-9-_]/g, '_');\n return join(CACHE_DIR, safeKey);\n }\n \n async get(key: string): Promise<string | null> {\n // Check memory first\n if (this.memCache.has(key)) return this.memCache.get(key)!;\n \n // Check file\n try {\n const content = await readFile(this.keyToPath(key), 'utf-8');\n this.memCache.set(key, content); // Warm memory cache\n return content;\n } catch {\n return null;\n }\n }\n \n async set(key: string, value: string): Promise<void> {\n await mkdir(CACHE_DIR, { recursive: true });\n this.memCache.set(key, value);\n await writeFile(this.keyToPath(key), value);\n await this.enforceMaxSize();\n }\n \n async getOrCompute(key: string, compute: () => Promise<string>): Promise<string> {\n const cached = await this.get(key);\n if (cached !== null) return cached;\n const value = await compute();\n await this.set(key, value);\n return value;\n }\n \n private async enforceMaxSize(): Promise<void> {\n // LRU eviction based on file mtime when cache exceeds maxSizeBytes\n }\n}\n```",
|
||||
"testStrategy": "Unit tests: file-based persistence survives process restart, memory cache is warmed on file read, LRU eviction works when size exceeded, concurrent access is safe. Integration test with real filesystem.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"73"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.128Z"
|
||||
},
|
||||
{
|
||||
"id": "99",
|
||||
"title": "Add Cache Key with Stage File Hash",
|
||||
"description": "Include the stage file hash in cache keys so cached artifacts are automatically invalidated when stage code changes.",
|
||||
"details": "Modify `src/mcplocal/src/proxymodel/executor.ts`:\n\n```typescript\nimport { readFile, stat } from 'fs/promises';\nimport { createHash } from 'crypto';\nimport { join } from 'path';\n\nconst STAGES_DIR = join(process.env.HOME ?? '', '.mcpctl', 'stages');\nconst stageFileHashes: Map<string, string> = new Map();\n\nasync function getStageFileHash(stageName: string): Promise<string> {\n // Check if custom stage file exists\n const filePath = join(STAGES_DIR, `${stageName}.ts`);\n try {\n const content = await readFile(filePath, 'utf-8');\n const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);\n stageFileHashes.set(stageName, hash);\n return hash;\n } catch {\n // Built-in stage, use version-based hash or fixed value\n return 'builtin-v1';\n }\n}\n\n// In executePipeline, compute cache key:\nconst stageHash = await getStageFileHash(stageConfig.type);\nconst cacheKey = [\n 'stage',\n stageConfig.type,\n stageHash,\n cache.hash(currentContent),\n cache.hash(JSON.stringify(stageConfig.config ?? {})),\n].join(':');\n\n// Use pipeline-level cache wrapping:\nif (proxyModel.spec.cacheable) {\n const cached = await cache.get(cacheKey);\n if (cached) {\n currentContent = cached;\n continue; // Skip stage execution\n }\n}\n```",
|
||||
"testStrategy": "Unit tests: changing stage file content changes the hash, built-in stages have stable hash, cache miss when stage file changes, cache hit when stage file unchanged.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"98"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.134Z"
|
||||
},
|
||||
{
|
||||
"id": "100",
|
||||
"title": "Implement mcpctl cache list Command",
|
||||
"description": "Add CLI command to list cached proxymodel artifacts with size and age information.",
|
||||
"details": "Create `src/cli/src/commands/cache-list.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { readdir, stat } from 'fs/promises';\nimport { join } from 'path';\nimport Table from 'cli-table3';\n\nconst CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');\n\nexport function registerCacheList(program: Command): void {\n program\n .command('cache list')\n .description('List cached proxymodel artifacts')\n .option('--project <name>', 'Filter by project')\n .action(async (opts) => {\n try {\n const files = await readdir(CACHE_DIR);\n \n const table = new Table({\n head: ['KEY', 'SIZE', 'AGE'],\n });\n \n let totalSize = 0;\n \n for (const file of files) {\n const filePath = join(CACHE_DIR, file);\n const stats = await stat(filePath);\n const age = formatAge(Date.now() - stats.mtimeMs);\n const size = formatSize(stats.size);\n totalSize += stats.size;\n \n if (opts.project && !file.includes(opts.project)) continue;\n \n table.push([file, size, age]);\n }\n \n console.log(table.toString());\n console.log(`Total: ${formatSize(totalSize)}`);\n } catch {\n console.log('No cache entries found');\n }\n });\n}\n\nfunction formatSize(bytes: number): string {\n if (bytes < 1024) return `${bytes}B`;\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;\n return `${(bytes / 1024 / 1024).toFixed(1)}MB`;\n}\n\nfunction formatAge(ms: number): string {\n const mins = Math.floor(ms / 60000);\n if (mins < 60) return `${mins}m`;\n const hours = Math.floor(mins / 60);\n if (hours < 24) return `${hours}h`;\n return `${Math.floor(hours / 24)}d`;\n}\n```",
|
||||
"testStrategy": "CLI test: list shows cache entries with correct format, --project filter works, empty cache shows appropriate message, size/age formatting is correct.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"98"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.496Z"
|
||||
},
|
||||
{
|
||||
"id": "101",
|
||||
"title": "Implement mcpctl cache clear Command",
|
||||
"description": "Add CLI command to clear the proxymodel cache, optionally filtered by project.",
|
||||
"details": "Create `src/cli/src/commands/cache-clear.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { readdir, unlink, rmdir } from 'fs/promises';\nimport { join } from 'path';\n\nconst CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');\n\nexport function registerCacheClear(program: Command): void {\n program\n .command('cache clear')\n .description('Clear the proxymodel cache')\n .option('--project <name>', 'Clear only cache for a specific project')\n .option('--force', 'Skip confirmation', false)\n .action(async (opts) => {\n try {\n const files = await readdir(CACHE_DIR);\n const toDelete = opts.project \n ? files.filter(f => f.includes(opts.project))\n : files;\n \n if (toDelete.length === 0) {\n console.log('No cache entries to clear');\n return;\n }\n \n if (!opts.force) {\n console.log(`This will delete ${toDelete.length} cache entries.`);\n // Add confirmation prompt\n }\n \n for (const file of toDelete) {\n await unlink(join(CACHE_DIR, file));\n }\n \n console.log(`Cleared ${toDelete.length} cache entries`);\n } catch {\n console.log('Cache directory does not exist');\n }\n });\n}\n```",
|
||||
"testStrategy": "CLI test: clears all entries without --project, clears filtered entries with --project, confirmation required without --force, --force skips confirmation.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"98"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.501Z"
|
||||
},
|
||||
{
|
||||
"id": "102",
|
||||
"title": "Implement mcpctl cache stats Command",
|
||||
"description": "Add CLI command to show cache statistics including hit rates, total size, and entry counts.",
|
||||
"details": "Create `src/cli/src/commands/cache-stats.ts`:\n\n```typescript\nimport { Command } from 'commander';\nimport { readdir, stat } from 'fs/promises';\nimport { join } from 'path';\n\nconst CACHE_DIR = join(process.env.HOME ?? '', '.mcpctl', 'cache', 'proxymodel');\n\nexport function registerCacheStats(program: Command): void {\n program\n .command('cache stats')\n .description('Show cache statistics')\n .action(async () => {\n try {\n const files = await readdir(CACHE_DIR);\n \n let totalSize = 0;\n let oldest = Date.now();\n let newest = 0;\n \n for (const file of files) {\n const filePath = join(CACHE_DIR, file);\n const stats = await stat(filePath);\n totalSize += stats.size;\n oldest = Math.min(oldest, stats.mtimeMs);\n newest = Math.max(newest, stats.mtimeMs);\n }\n \n console.log(`Entries: ${files.length}`);\n console.log(`Total size: ${formatSize(totalSize)}`);\n console.log(`Oldest entry: ${formatAge(Date.now() - oldest)} ago`);\n console.log(`Newest entry: ${formatAge(Date.now() - newest)} ago`);\n \n // Note: hit rate tracking would require runtime instrumentation\n console.log('\\nNote: Hit rate statistics require runtime instrumentation.');\n } catch {\n console.log('No cache data available');\n }\n });\n}\n```",
|
||||
"testStrategy": "CLI test: shows correct stats for populated cache, handles empty cache gracefully, size formatting is correct.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"98"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.507Z"
|
||||
},
|
||||
{
|
||||
"id": "103",
|
||||
"title": "Add Shell Completions for ProxyModel Commands",
|
||||
"description": "Extend shell completions to include all new proxymodel-related commands, resources, and flags.",
|
||||
"details": "Update `src/cli/src/completions.ts` to add completions for:\n\n```typescript\n// Resource types\nconst RESOURCE_TYPES = [...existing, 'proxymodels', 'stages'];\n\n// Command completions\nconst COMMANDS = {\n 'get': ['proxymodels', 'stages', ...existing],\n 'describe': ['proxymodel', 'stage', ...existing],\n 'create': ['proxymodel', 'stage', ...existing],\n 'delete': ['proxymodel', 'stage', ...existing],\n 'proxymodel': ['validate'],\n 'cache': ['list', 'clear', 'stats'],\n};\n\n// Dynamic completions for proxymodel/stage names\nasync function completeProxymodelName(partial: string): Promise<string[]> {\n const models = await loadProxyModels();\n return [...models.keys()].filter(n => n.startsWith(partial));\n}\n\nasync function completeStageName(partial: string): Promise<string[]> {\n const stages = listStages();\n return stages.map(s => s.name).filter(n => n.startsWith(partial));\n}\n```\n\nGenerate completion scripts for bash, zsh, and fish.",
|
||||
"testStrategy": "Manual test: completions work in bash/zsh/fish for all new commands. Test proxymodel name completion, stage name completion, subcommand completion.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"89",
|
||||
"90",
|
||||
"91",
|
||||
"92",
|
||||
"93",
|
||||
"94",
|
||||
"95",
|
||||
"96",
|
||||
"97",
|
||||
"100",
|
||||
"101",
|
||||
"102"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.141Z"
|
||||
},
|
||||
{
|
||||
"id": "104",
|
||||
"title": "Extend Traffic Events for ProxyModel Processing",
|
||||
"description": "Add new traffic event types for proxymodel processing: content_original, content_transformed, stage timing, cache hits/misses.",
|
||||
"details": "Modify `src/mcplocal/src/http/traffic.ts`:\n\n```typescript\nexport type TrafficEventType = \n | 'client_request'\n | 'client_response'\n | 'upstream_request'\n | 'upstream_response'\n | 'client_notification'\n // New proxymodel events:\n | 'content_original'\n | 'content_transformed'\n | 'stage_executed'\n | 'stage_cache_hit'\n | 'stage_cache_miss';\n\nexport interface ContentOriginalEvent {\n eventType: 'content_original';\n sessionId: string;\n contentType: 'prompt' | 'toolResult';\n sourceName: string;\n content: string;\n charCount: number;\n}\n\nexport interface ContentTransformedEvent {\n eventType: 'content_transformed';\n sessionId: string;\n contentType: 'prompt' | 'toolResult';\n sourceName: string;\n content: string;\n charCount: number;\n proxyModel: string;\n stages: string[];\n durationMs: number;\n}\n\nexport interface StageExecutedEvent {\n eventType: 'stage_executed';\n sessionId: string;\n stageName: string;\n inputChars: number;\n outputChars: number;\n durationMs: number;\n cacheHit: boolean;\n}\n```\n\nEmit these events from the pipeline executor.",
|
||||
"testStrategy": "Unit tests: events emitted at correct points in pipeline execution, event payloads contain correct data, cache hit/miss events distinguish correctly. Integration test with inspector showing new events.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"80"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.147Z"
|
||||
},
|
||||
{
|
||||
"id": "105",
|
||||
"title": "Implement Model Studio TUI Base",
|
||||
"description": "Create the base TUI for mcpctl console --model-studio that extends --inspect with original vs transformed view.",
|
||||
"details": "Create `src/cli/src/commands/console/model-studio.tsx`:\n\n```typescript\nimport React, { useState, useEffect } from 'react';\nimport { Box, Text, useInput } from 'ink';\nimport { TrafficEvent } from './types';\n\ninterface ModelStudioProps {\n projectName: string;\n events: TrafficEvent[];\n}\n\nexport function ModelStudio({ projectName, events }: ModelStudioProps) {\n const [selectedIdx, setSelectedIdx] = useState(0);\n const [viewMode, setViewMode] = useState<'original' | 'transformed' | 'diff'>('transformed');\n const [pauseMode, setPauseMode] = useState(false);\n \n useInput((input, key) => {\n if (input === 'j') setSelectedIdx(i => Math.min(i + 1, events.length - 1));\n if (input === 'k') setSelectedIdx(i => Math.max(i - 1, 0));\n if (input === 'o') setViewMode(m => m === 'original' ? 'transformed' : m === 'transformed' ? 'diff' : 'original');\n if (input === 'p') setPauseMode(p => !p);\n if (input === 'G') setSelectedIdx(events.length - 1);\n });\n \n const selected = events[selectedIdx];\n const isContentEvent = selected?.eventType === 'content_original' || selected?.eventType === 'content_transformed';\n \n return (\n <Box flexDirection=\"column\" height=\"100%\">\n <Box borderStyle=\"single\" padding={1}>\n <Text>Model Studio: {projectName}</Text>\n <Text> | </Text>\n <Text>View: {viewMode}</Text>\n <Text> | </Text>\n <Text color={pauseMode ? 'red' : 'green'}>{pauseMode ? '⏸ PAUSED' : '▶ LIVE'}</Text>\n </Box>\n \n <Box flexGrow={1} flexDirection=\"row\">\n {/* Event list sidebar */}\n <Box width=\"30%\" borderStyle=\"single\">\n {events.map((e, i) => (\n <Text key={i} inverse={i === selectedIdx}>\n {formatEventLine(e)}\n </Text>\n ))}\n </Box>\n \n {/* Content view */}\n <Box width=\"70%\" borderStyle=\"single\">\n {isContentEvent && (\n <ContentView event={selected} mode={viewMode} />\n )}\n </Box>\n </Box>\n \n <Box borderStyle=\"single\">\n <Text>[o] toggle view [p] pause [j/k] navigate [G] latest [q] quit</Text>\n </Box>\n </Box>\n );\n}\n```\n\nAdd --model-studio flag to console command.",
|
||||
"testStrategy": "Manual test: TUI renders correctly, keyboard navigation works, original/transformed/diff views switch correctly, pause indicator shows correctly.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"104"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.514Z"
|
||||
},
|
||||
{
|
||||
"id": "106",
|
||||
"title": "Implement Pause Queue for Model Studio",
|
||||
"description": "Add a pause queue in mcplocal that holds outgoing responses when model studio pause mode is active.",
|
||||
"details": "Create `src/mcplocal/src/proxymodel/pause-queue.ts`:\n\n```typescript\ninterface PausedResponse {\n id: string;\n sessionId: string;\n contentType: 'prompt' | 'toolResult';\n sourceName: string;\n original: string;\n transformed: string;\n resolve: (content: string) => void;\n timestamp: number;\n}\n\nclass PauseQueue {\n private paused = false;\n private queue: PausedResponse[] = [];\n private listeners = new Set<(items: PausedResponse[]) => void>();\n \n setPaused(paused: boolean): void {\n this.paused = paused;\n if (!paused) {\n // Release all paused items with their transformed content\n for (const item of this.queue) {\n item.resolve(item.transformed);\n }\n this.queue = [];\n }\n this.notifyListeners();\n }\n \n isPaused(): boolean {\n return this.paused;\n }\n \n async enqueue(item: Omit<PausedResponse, 'resolve' | 'id' | 'timestamp'>): Promise<string> {\n if (!this.paused) return item.transformed;\n \n return new Promise(resolve => {\n this.queue.push({\n ...item,\n id: crypto.randomUUID(),\n timestamp: Date.now(),\n resolve,\n });\n this.notifyListeners();\n });\n }\n \n editAndRelease(id: string, editedContent: string): void {\n const idx = this.queue.findIndex(q => q.id === id);\n if (idx >= 0) {\n const item = this.queue.splice(idx, 1)[0];\n item.resolve(editedContent);\n this.notifyListeners();\n }\n }\n \n releaseOne(id: string): void {\n const idx = this.queue.findIndex(q => q.id === id);\n if (idx >= 0) {\n const item = this.queue.splice(idx, 1)[0];\n item.resolve(item.transformed);\n this.notifyListeners();\n }\n }\n \n dropOne(id: string): void {\n const idx = this.queue.findIndex(q => q.id === id);\n if (idx >= 0) {\n const item = this.queue.splice(idx, 1)[0];\n item.resolve(''); // Empty response\n this.notifyListeners();\n }\n }\n}\n\nexport const pauseQueue = new PauseQueue();\n```",
|
||||
"testStrategy": "Unit tests: enqueue returns immediately when not paused, enqueue blocks when paused, releaseOne/editAndRelease/dropOne work correctly, setPaused(false) releases all.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"105"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.521Z"
|
||||
},
|
||||
{
|
||||
"id": "107",
|
||||
"title": "Implement Edit Mode for Model Studio",
|
||||
"description": "Add inline editing capability to model studio for modifying paused responses before release.",
|
||||
"details": "Extend `src/cli/src/commands/console/model-studio.tsx`:\n\n```typescript\nimport { spawn } from 'child_process';\nimport { writeFileSync, readFileSync, unlinkSync } from 'fs';\nimport { tmpdir } from 'os';\nimport { join } from 'path';\n\nasync function editContent(original: string): Promise<string> {\n const editor = process.env.EDITOR ?? 'vim';\n const tmpFile = join(tmpdir(), `mcpctl-edit-${Date.now()}.txt`);\n \n writeFileSync(tmpFile, original);\n \n return new Promise((resolve, reject) => {\n const proc = spawn(editor, [tmpFile], {\n stdio: 'inherit',\n });\n \n proc.on('close', (code) => {\n if (code === 0) {\n const edited = readFileSync(tmpFile, 'utf-8');\n unlinkSync(tmpFile);\n resolve(edited);\n } else {\n unlinkSync(tmpFile);\n reject(new Error(`Editor exited with code ${code}`));\n }\n });\n });\n}\n\n// In the TUI component:\nuseInput(async (input, key) => {\n if (input === 'e' && pauseMode && selectedPausedItem) {\n const edited = await editContent(selectedPausedItem.transformed);\n pauseQueue.editAndRelease(selectedPausedItem.id, edited);\n \n // Emit correction event\n trafficCapture.emit({\n eventType: 'content_edited',\n sessionId: selectedPausedItem.sessionId,\n contentType: selectedPausedItem.contentType,\n sourceName: selectedPausedItem.sourceName,\n original: selectedPausedItem.original,\n transformed: selectedPausedItem.transformed,\n edited,\n timestamp: Date.now(),\n });\n }\n});\n```",
|
||||
"testStrategy": "Integration test: pressing 'e' opens editor with content, saving and closing applies edit, edit event is emitted with correct before/after content.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"106"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.528Z"
|
||||
},
|
||||
{
|
||||
"id": "108",
|
||||
"title": "Implement Model Switch for Model Studio",
|
||||
"description": "Add ability to switch the active proxymodel for a project mid-session from model studio.",
|
||||
"details": "Extend `src/cli/src/commands/console/model-studio.tsx`:\n\n```typescript\nfunction ModelPicker({ models, current, onSelect }: {\n models: string[];\n current: string;\n onSelect: (name: string) => void;\n}) {\n const [selectedIdx, setSelectedIdx] = useState(models.indexOf(current));\n \n useInput((input, key) => {\n if (key.upArrow) setSelectedIdx(i => Math.max(0, i - 1));\n if (key.downArrow) setSelectedIdx(i => Math.min(models.length - 1, i + 1));\n if (key.return) onSelect(models[selectedIdx]);\n });\n \n return (\n <Box flexDirection=\"column\" borderStyle=\"single\">\n <Text bold>Select ProxyModel:</Text>\n {models.map((m, i) => (\n <Text key={m} inverse={i === selectedIdx}>\n {m === current ? '✓ ' : ' '}{m}\n </Text>\n ))}\n </Box>\n );\n}\n\n// Add to main component:\nconst [showModelPicker, setShowModelPicker] = useState(false);\nconst [activeModel, setActiveModel] = useState('default');\n\nuseInput((input) => {\n if (input === 'm') setShowModelPicker(true);\n});\n\nasync function switchModel(name: string) {\n // Call mcplocal API to switch model\n await fetch(`http://localhost:${port}/projects/${projectName}/proxymodel`, {\n method: 'PUT',\n body: JSON.stringify({ proxyModel: name }),\n });\n setActiveModel(name);\n setShowModelPicker(false);\n \n // Emit model_switched event\n trafficCapture.emit({\n eventType: 'model_switched',\n projectName,\n previousModel: activeModel,\n newModel: name,\n timestamp: Date.now(),\n });\n}\n```\n\nAdd PUT endpoint to mcplocal for switching proxymodel.",
|
||||
"testStrategy": "Integration test: 'm' opens model picker, selecting a model updates the active model, subsequent content flows through new model, model_switched event is emitted.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"105",
|
||||
"82"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.534Z"
|
||||
},
|
||||
{
|
||||
"id": "109",
|
||||
"title": "Implement Studio MCP Server Tools",
|
||||
"description": "Create MCP tools for Claude Monitor to observe traffic, get corrections, switch models, and modify stages.",
|
||||
"details": "Extend `src/cli/src/commands/console/inspect-mcp.ts` with studio tools:\n\n```typescript\nconst studioTools: Tool[] = [\n {\n name: 'get_content_diff',\n description: 'Get original vs transformed vs edited content for a specific event',\n inputSchema: {\n type: 'object',\n properties: {\n eventId: { type: 'string', description: 'Event ID' },\n },\n required: ['eventId'],\n },\n },\n {\n name: 'get_corrections',\n description: 'Get all user corrections (edits) in a session',\n inputSchema: {\n type: 'object',\n properties: {\n sessionId: { type: 'string', description: 'Optional session filter' },\n },\n },\n },\n {\n name: 'get_active_model',\n description: 'Get current proxymodel name and stage list for a project',\n inputSchema: {\n type: 'object',\n properties: {\n project: { type: 'string' },\n },\n required: ['project'],\n },\n },\n {\n name: 'switch_model',\n description: 'Hot-swap the active proxymodel on a project',\n inputSchema: {\n type: 'object',\n properties: {\n project: { type: 'string' },\n model: { type: 'string' },\n },\n required: ['project', 'model'],\n },\n },\n {\n name: 'reload_stages',\n description: 'Force reload all stages from ~/.mcpctl/stages/',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'get_stage_source',\n description: 'Read the source code of a stage file',\n inputSchema: {\n type: 'object',\n properties: {\n name: { type: 'string' },\n },\n required: ['name'],\n },\n },\n {\n name: 'list_models',\n description: 'List available proxymodels',\n inputSchema: { type: 'object', properties: {} },\n },\n {\n name: 'list_stages',\n description: 'List available stages',\n inputSchema: { type: 'object', properties: {} },\n },\n];\n```",
|
||||
"testStrategy": "Integration test with MCP client: each tool returns expected data format, switch_model actually changes the model, reload_stages picks up file changes, get_corrections returns user edits.",
|
||||
"priority": "medium",
|
||||
"dependencies": [
|
||||
"104",
|
||||
"106",
|
||||
"107",
|
||||
"108"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.542Z"
|
||||
},
|
||||
{
|
||||
"id": "110",
|
||||
"title": "Implement RBAC for ProxyModels",
|
||||
"description": "Add 'run' permission on proxymodels resource controlling which proxymodels users can activate on projects.",
|
||||
"details": "Update RBAC schema and enforcement:\n\n1. Add to `src/db/prisma/schema.prisma`:\n```prisma\n// Extend existing RbacBinding or Permission model\nenum RbacResource {\n // ... existing\n proxymodels\n}\n\nenum RbacPermission {\n // ... existing\n run // permission to use a proxymodel\n cache // permission to push to shared cache\n}\n```\n\n2. Add enforcement in `src/mcplocal/src/router.ts`:\n```typescript\nasync function resolveProxyModel(\n requestedModel: string,\n projectName: string,\n userId: string\n): Promise<ProxyModelDefinition> {\n const models = await loadProxyModels();\n const model = models.get(requestedModel);\n \n if (!model) {\n console.warn(`Proxymodel '${requestedModel}' not found, using default`);\n return models.get('default')!;\n }\n \n // Check RBAC permission\n const hasPermission = await checkPermission(userId, 'run', 'proxymodels', requestedModel);\n if (!hasPermission) {\n console.warn(`User lacks 'run' permission for proxymodel '${requestedModel}', using default`);\n return models.get('default')!;\n }\n \n return model;\n}\n```\n\n3. 'default' proxymodel requires no permission (always allowed).",
|
||||
"testStrategy": "Integration test: user with 'run' permission can use proxymodel, user without permission falls back to default, 'default' always works, permission check logs reason for fallback.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"87"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.152Z"
|
||||
},
|
||||
{
|
||||
"id": "111",
|
||||
"title": "Write Integration Tests for subindex Model",
|
||||
"description": "Create comprehensive integration tests for the subindex proxymodel processing real content through section-split and summarize-tree.",
|
||||
"details": "Create `src/mcplocal/tests/proxymodel/subindex.test.ts`:\n\n```typescript\nimport { describe, it, expect, beforeAll } from 'vitest';\nimport { executePipeline } from '../../src/proxymodel/executor';\nimport { loadProxyModels } from '../../src/proxymodel/loader';\nimport { createMockProviderRegistry } from '../mocks/providers';\n\ndescribe('subindex proxymodel', () => {\n let proxyModel;\n let mockRegistry;\n \n beforeAll(async () => {\n const models = await loadProxyModels();\n proxyModel = models.get('subindex');\n mockRegistry = createMockProviderRegistry({\n complete: async (prompt) => 'Mock summary of the content',\n });\n });\n \n it('splits JSON array into sections', async () => {\n const content = JSON.stringify([\n { id: 'flow1', label: 'Thermostat', nodes: [] },\n { id: 'flow2', label: 'Lighting', nodes: [] },\n ]);\n \n const result = await executePipeline({\n content,\n contentType: 'toolResult',\n sourceName: 'test/get_flows',\n projectName: 'test',\n sessionId: 'test-session',\n proxyModel,\n providerRegistry: mockRegistry,\n });\n \n expect(result.sections).toHaveLength(2);\n expect(result.sections[0].id).toBe('flow1');\n expect(result.content).toContain('2 sections');\n });\n \n it('provides drill-down to exact JSON content', async () => {\n // Test that drilling into a section returns exact original JSON\n });\n \n it('uses structural summaries for JSON (no LLM call)', async () => {\n // Verify LLM not called for JSON content\n });\n \n it('uses LLM summaries for prose content', async () => {\n // Verify LLM called for markdown content\n });\n \n it('caches summaries across requests', async () => {\n // Verify cache hit on second request with same content\n });\n});\n```",
|
||||
"testStrategy": "Run with vitest, verify all test cases pass, check LLM call counts are as expected (structural vs prose), verify cache behavior.",
|
||||
"priority": "high",
|
||||
"dependencies": [
|
||||
"75",
|
||||
"76",
|
||||
"82",
|
||||
"83"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.158Z"
|
||||
},
|
||||
{
|
||||
"id": "112",
|
||||
"title": "Write Documentation for ProxyModel Authoring",
|
||||
"description": "Create comprehensive documentation for users wanting to create custom stages and proxymodels.",
|
||||
"details": "Create documentation covering:\n\n1. `docs/proxymodels/authoring-guide.md` - Complete guide from PRD's \"Authoring Guide\" section:\n - Concepts: stages, proxymodels, framework\n - File locations\n - Step-by-step stage creation\n - Step-by-step proxymodel creation\n - Testing with mcpctl proxymodel validate\n - Section drill-down\n - Cache usage\n - Error handling\n - Full example\n\n2. `docs/proxymodels/built-in-stages.md` - Reference for all built-in stages:\n - passthrough\n - paginate\n - section-split\n - summarize-tree\n - Config options for each\n\n3. `docs/proxymodels/api-reference.md` - Type reference:\n - StageHandler\n - StageContext\n - StageResult\n - Section\n - LLMProvider\n - CacheProvider\n\n4. Update main README with proxymodels overview.",
|
||||
"testStrategy": "Review documentation for completeness, verify all code examples compile, test example stage from documentation works end-to-end.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"71",
|
||||
"78",
|
||||
"93",
|
||||
"94"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:07:00.164Z"
|
||||
},
|
||||
{
|
||||
"id": "113",
|
||||
"title": "Write Documentation for Model Studio",
|
||||
"description": "Create documentation for using model studio for live proxymodel development and debugging.",
|
||||
"details": "Create `docs/proxymodels/model-studio.md` covering:\n\n1. Overview: Three-window setup (Claude Client, Model Studio, Claude Monitor)\n2. Starting Model Studio: `mcpctl console --model-studio <project>`\n3. Keyboard shortcuts reference\n4. Viewing original vs transformed content\n5. Pause mode: when and why to use it\n6. Editing paused responses\n7. Switching proxymodels mid-session\n8. Using Claude Monitor to observe and modify stages\n9. The correction workflow: edit → observe → adjust stage → verify\n10. MCP tools available to Claude Monitor\n11. Troubleshooting common issues",
|
||||
"testStrategy": "Review documentation for completeness, verify all described features work as documented.",
|
||||
"priority": "low",
|
||||
"dependencies": [
|
||||
"105",
|
||||
"106",
|
||||
"107",
|
||||
"108",
|
||||
"109"
|
||||
],
|
||||
"status": "deferred",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T01:11:23.552Z"
|
||||
},
|
||||
{
|
||||
"id": "114",
|
||||
"title": "ProxyModel v2: Code-based MCP middleware plugin system",
|
||||
"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\n\nA proxy model is a TypeScript code file (not YAML) that acts as full MCP middleware. It can:\n- Intercept any MCP request (initialize, tools/list, tools/call, resources/*, prompts/*)\n- Modify any response before it reaches the client\n- Create synthetic tools (e.g. begin_session doesn't exist upstream)\n- Maintain per-session state (gated/ungated, accumulated tags, etc.)\n- Access project resources (prompts, servers, config)\n- Transform content (what stages do today: paginate, section-split, etc.)\n\n## Key design decisions\n\n1. 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.\n2. Stages deprecated: No separate stage resource. Content transformation is just code inside the proxy model.\n3. 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.\n4. 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).\n5. 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.\n6. Discoverable as resources: mcpctl get proxymodels lists available models (discovered from files). mcpctl describe proxymodel gate shows details. But no create/delete commands.\n7. Attached to projects: mcpctl edit project foo --proxyModel gate or via apply YAML.\n\n## Framework interface (sketch)\n\nexport interface ProxyModelContext {\n session: SessionState;\n project: ProjectConfig;\n upstream: UpstreamClient;\n llm?: LLMProvider;\n cache?: CacheProvider;\n}\n\nexport interface ProxyModel {\n name: string;\n extends?: string[];\n onInitialize?(ctx, request): Promise<InitializeResult>;\n onToolsList?(ctx): Promise<Tool[]>;\n onToolCall?(ctx, name, args): Promise<ToolResult | null>;\n onResourceRead?(ctx, uri): Promise<ResourceContent | null>;\n transformContent?(ctx, content, contentType): Promise<string>;\n createSessionState?(): Record<string, unknown>;\n}\n\n## Migration path\n\n1. Define the ProxyModel TypeScript interface\n2. Implement the plugin loader (discover .ts files, compile, validate inheritance, detect conflicts)\n3. Implement the router integration (router delegates to loaded proxy model)\n4. Extract gate logic from router.ts into gate.ts proxy model\n5. Extract content pipeline (passthrough, paginate, section-split) into proxy model code\n6. Add proxyModel field to Project schema (replaces gated boolean)\n7. Add CLI: get proxymodels, describe proxymodel, edit project --proxyModel\n8. Add smoke tests: gate proxy model produces identical behavior to current hardcoded gate\n9. Deprecate gated field (backward compat: gated:true maps to proxyModel:gate)\n\n## Supersedes\n\nThis task supersedes deferred tasks 83, 85-97, 98-99, 103, 104, 110, 111-112 which assumed the old YAML/stage architecture.",
|
||||
"status": "in-progress",
|
||||
"priority": "high",
|
||||
"dependencies": [],
|
||||
"testStrategy": "1. 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.",
|
||||
"subtasks": [],
|
||||
"updatedAt": "2026-02-28T03:37:04.389Z"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"version": "1.0.0",
|
||||
"lastModified": "2026-02-25T23:12:22.364Z",
|
||||
"taskCount": 70,
|
||||
"completedCount": 67,
|
||||
"lastModified": "2026-02-28T03:37:04.390Z",
|
||||
"taskCount": 114,
|
||||
"completedCount": 80,
|
||||
"tags": [
|
||||
"master"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user