diff --git a/.taskmaster/docs/prd-gated-prompts.md b/.taskmaster/docs/prd-gated-prompts.md new file mode 100644 index 0000000..deb4f62 --- /dev/null +++ b/.taskmaster/docs/prd-gated-prompts.md @@ -0,0 +1,392 @@ +# PRD: Gated Project Experience & Prompt Intelligence + +## Overview + +When 300 developers connect their LLM clients (Claude Code, Cursor, etc.) to mcpctl projects, they need relevant context — security policies, architecture decisions, operational runbooks — without flooding the context window. This feature introduces a gated session flow where the client LLM drives its own context retrieval through keyword-based matching, with the proxy providing a prompt index and encouraging ongoing discovery. + +## Problem + +- Injecting all prompts into instructions doesn't scale (hundreds of pages of policies) +- Exposing prompts only as MCP resources means LLMs never read them +- An index-only approach works for small numbers but breaks down at scale +- No mechanism to link external knowledge (Notion, Docmost) as prompts +- LLMs tend to work with whatever they have rather than proactively seek more context + +## Core Concepts + +### Gated Experience + +A project-level flag (`gated: boolean`, default: `true`) that controls whether sessions go through a keyword-driven prompt retrieval flow before accessing project tools and resources. + +**Flow (A + C):** + +1. On `initialize`, instructions include the **prompt index** (names + summaries for all prompts, up to a reasonable cap) and tell client LLM: "Call `begin_session` with 5 keywords describing your task" +2. **If client obeys**: `begin_session({ tags: ["zigbee", "lights", "mqtt", "pairing", "automation"] })` → prompt selection (see below) → returns matched prompt content + full prompt index + encouragement to retrieve more → session ungated +3. **If client ignores**: First `tools/call` is intercepted → keywords extracted from tool name + arguments → same prompt selection → briefing injected alongside tool result → session ungated +4. **Ongoing retrieval**: Client can call `read_prompts({ tags: ["security", "vpn"] })` at any point to retrieve more prompts. The prompt index is always visible so the client LLM can see what's available. + +**Prompt selection — tiered approach:** + +- **Primary (heavy LLM available)**: Tags + full prompt index (names, priorities, summaries, chapters) are sent to the heavy LLM (e.g. Gemini). The LLM understands synonyms, context, and intent — it knows "zigbee" relates to "Z2M" and "Zigbee2MQTT", and that someone working on "lights" probably needs the "common-mistakes" prompt about pairing. The LLM returns a ranked list of relevant prompt names with brief explanations of why each is relevant. The heavy LLM may use the fast LLM for preprocessing if needed (e.g. generating missing summaries on the fly). +- **Fallback (no LLM, or `llmProvider=none`)**: Deterministic keyword-based tag matching against summaries/chapters with byte-budget allocation (see "Tag Matching Algorithm" below). Same approach as ResponsePaginator's byte-based fallback. Triggered when: no LLM providers configured, project has `llmProvider: "none"`, or local override sets `provider: "none"`. +- **Hybrid (both paths always available)**: Even when heavy LLM does the initial selection, the `read_prompts({ tags: [...] })` tool always uses keyword matching. This way the client LLM can retrieve specific prompts by keyword that the heavy LLM may have missed. The LLM is smart about context, keywords are precise about names — together they cover both fuzzy and exact retrieval. + +**LLM availability resolution** (same chain as existing LLM features): +- Project `llmProvider: "none"` → no LLM, keyword fallback only +- Project `llmProvider: null` → inherit from global config +- Local override `provider: "none"` → no LLM, keyword fallback only +- No providers configured → keyword fallback only +- Otherwise → use heavy LLM for `begin_session`, fast LLM for summary generation + +### Encouraging Retrieval + +LLMs tend to proceed with incomplete information rather than seek more context. The system must actively counter this at multiple points: + +**In `initialize` instructions:** +``` +You have access to project knowledge containing policies, architecture decisions, +and guidelines. Some may contain critical rules about what you're doing. After your +initial briefing, if you're unsure about conventions, security requirements, or +best practices — request more context using read_prompts. It's always better to +check than to guess wrong. The project may have specific rules you don't know about yet. +``` + +**In `begin_session` response (after matched prompts):** +``` +Other prompts available that may become relevant as your work progresses: +- security-policies: Network segmentation, firewall rules, VPN access +- naming-conventions: Service and resource naming standards +- ... +If any of these seem related to what you're doing now or later, request them +with read_prompts({ tags: [...] }) or resources/read. Don't assume you have +all the context — check when in doubt. +``` + +**In `read_prompts` response:** +``` +Remember: you can request more prompts at any time with read_prompts({ tags: [...] }). +The project may have additional guidelines relevant to your current approach. +``` + +The tone is not "here's optional reading" but "there are rules you might not know about, and violating them costs more than reading them." + +### Prompt Priority (1-10) + +Every prompt has a priority level that influences selection order and byte-budget allocation: + +| Range | Meaning | Behavior | +|-------|---------|----------| +| 1-3 | Reference | Low priority, included only on strong keyword match | +| 4-6 | Standard | Default priority, included on moderate keyword match | +| 7-9 | Important | High priority, lower match threshold | +| 10 | Critical | Always included in full, regardless of keyword match (guardrails, common mistakes) | + +Default priority for new prompts: `5`. + +### Prompt Summaries & Chapters (Auto-generated) + +Each prompt gets auto-generated metadata used for the prompt index and tag matching: + +- `summary` (string, ~20 words) — one-line description of what the prompt covers +- `chapters` (string[]) — key sections/topics extracted from content + +Generation pipeline: +- **Fast LLM available**: Summarize content, extract key topics +- **No fast LLM**: First sentence of content + markdown headings via regex +- Regenerated on prompt create/update +- Cached on the prompt record + +### Tag Matching Algorithm (No-LLM Fallback) + +When no local LLM is available, the system falls back to a deterministic retrieval algorithm: + +1. Client provides tags (5 keywords from `begin_session`, or extracted from tool call) +2. For each prompt, compute a match score: + - Check tags against prompt `summary` and `chapters` (case-insensitive substring match) + - Score = `number_of_matching_tags * base_priority` + - Priority 10 prompts: score = infinity (always included) +3. Sort by score descending +4. Fill a byte budget (configurable, default ~8KB) from top down: + - Include full content until budget exhausted + - Remaining matched prompts: include as index entries (name + summary) + - Non-matched prompts: listed as names only in the "other prompts available" section + +**When `begin_session` is skipped (intercept path):** +- Extract keywords from tool name + arguments (e.g., `home-assistant/get_entities({ domain: "light" })` → tags: `["home-assistant", "entities", "light"]`) +- Run same matching algorithm +- Inject briefing alongside the real tool result + +### `read_prompts` Tool (Ongoing Retrieval) + +Available after session is ungated. Allows the client LLM to request more context at any point: + +```json +{ + "name": "read_prompts", + "description": "Request additional project context by keywords. Use this whenever you need guidelines, policies, or conventions related to your current work. It's better to check than to guess.", + "inputSchema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Keywords describing what context you need (e.g. [\"security\", \"vpn\", \"firewall\"])" + } + }, + "required": ["tags"] + } +} +``` + +Returns matched prompt content + the prompt index reminder. + +### Prompt Links + +A prompt can be a **link** to an MCP resource in another project's server. The linked content is fetched server-side (by the proxy, not the client), enforcing RBAC. + +Format: `project/server:resource-uri` +Example: `system-public/docmost-mcp:docmost://pages/architecture-overview` + +Properties: +- The proxy fetches linked content using the source project's service account +- Client LLM never gets direct access to the source MCP server +- Dead links are detected and marked (health check on link resolution) +- Dead links generate error log entries + +RBAC for links: +- Creating a link requires `edit` permission on RBAC in the target project +- A service account permission is created on the source project for the linked resource +- Default: admin group members can manage links + +## Schema Changes + +### Project + +Add field: +- `gated: boolean` (default: `true`) + +### Prompt + +Add fields: +- `priority: integer` (1-10, default: 5) +- `summary: string | null` (auto-generated) +- `chapters: string[] | null` (auto-generated, stored as JSON) +- `linkTarget: string | null` (format: `project/server:resource-uri`, null for regular prompts) + +### PromptRequest + +Add field: +- `priority: integer` (1-10, default: 5) + +## API Changes + +### Modified Endpoints + +- `POST /api/v1/prompts` — accept `priority`, `linkTarget` +- `PUT /api/v1/prompts/:id` — accept `priority` (not `linkTarget` — links are immutable, delete and recreate) +- `POST /api/v1/promptrequests` — accept `priority` +- `GET /api/v1/prompts` — return `priority`, `summary`, `linkTarget`, `linkStatus` (alive/dead/unknown) +- `GET /api/v1/projects/:name/prompts/visible` — return `priority`, `summary`, `chapters` + +### New Endpoints + +- `POST /api/v1/prompts/:id/regenerate-summary` — force re-generation of summary/chapters +- `GET /api/v1/projects/:name/prompt-index` — returns compact index (name, priority, summary, chapters) + +## MCP Protocol Changes (mcplocal router) + +### Session State + +Router tracks per-session state: +- `gated: boolean` — starts `true` if project is gated +- `tags: string[]` — accumulated tags from begin_session + read_prompts calls +- `retrievedPrompts: Set` — prompts already sent to client (avoid re-sending) + +### Gated Session Flow + +1. On `initialize`: instructions include prompt index + gate message + retrieval encouragement +2. `tools/list` while gated: only `begin_session` visible (progressive tool exposure) +3. `begin_session({ tags })`: match tags → return briefing + prompt index + encouragement → ungate → send `notifications/tools/list_changed` +4. On first `tools/call` while still gated: extract keywords → match → inject briefing alongside result → ungate +5. After ungating: all tools work normally, `read_prompts` available for ongoing retrieval + +### `begin_session` Tool + +```json +{ + "name": "begin_session", + "description": "Start your session by providing 5 keywords that describe your current task. You'll receive relevant project context, policies, and guidelines. Required before using other tools.", + "inputSchema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "maxItems": 10, + "description": "5 keywords describing your current task (e.g. [\"zigbee\", \"automation\", \"lights\", \"mqtt\", \"pairing\"])" + } + }, + "required": ["tags"] + } +} +``` + +Response structure: +``` +[Priority 10 prompts — always, full content] + +[Tag-matched prompts — full content, byte-budget-capped, priority-ordered] + +Other prompts available that may become relevant as your work progresses: +- : +- : +- ... +If any of these seem related to what you're doing, request them with +read_prompts({ tags: [...] }). Don't assume you have all the context — check. +``` + +### Prompt Index in Instructions + +The `initialize` instructions include a compact prompt index so the client LLM can see what knowledge exists. Format per prompt: `- : ` (~100 chars max per entry). + +Cap: if more than 50 prompts, include only priority 7+ in instructions index. Full index always available via `resources/list`. + +## CLI Changes + +### New/Modified Commands + +- `mcpctl create prompt --priority <1-10>` — create with priority +- `mcpctl create prompt --link ` — create linked prompt +- `mcpctl get prompt -A` — show all prompts across all projects, with link targets +- `mcpctl describe project ` — show gated status, session greeting, prompt table +- `mcpctl edit project ` — `gated` field editable + +### Prompt Link Display + +``` +$ mcpctl get prompt -A +PROJECT NAME PRIORITY LINK STATUS +homeautomation security-policies 8 - - +homeautomation architecture-adr 6 system-public/docmost-mcp:docmost://pages/a1 alive +homeautomation common-mistakes 10 - - +system-public onboarding 4 - - +``` + +## Describe Project Output + +``` +$ mcpctl describe project homeautomation +Name: homeautomation +Gated: true +LLM Provider: gemini-cli +... + +Session greeting: + You have access to project knowledge containing policies, architecture decisions, + and guidelines. Call begin_session with 5 keywords describing your task to receive + relevant context. Some prompts contain critical rules — it's better to check than guess. + +Prompts: + NAME PRIORITY TYPE LINK + common-mistakes 10 local - + security-policies 8 local - + architecture-adr 6 link system-public/docmost-mcp:docmost://pages/a1 + stack 5 local - +``` + +## Testing Strategy + +**Full test coverage is required.** Every new module, service, route, and algorithm must have comprehensive tests. No feature ships without tests. + +### Unit Tests (mcpd) +- Prompt priority CRUD: create/update/get with priority field, default value, validation (1-10 range) +- Prompt link CRUD: create with linkTarget, immutability (can't update linkTarget), delete +- Prompt summary generation: auto-generation on create/update, regex fallback when no LLM +- `GET /api/v1/prompts` with priority, linkTarget, linkStatus fields +- `GET /api/v1/projects/:name/prompt-index` returns compact index +- `POST /api/v1/prompts/:id/regenerate-summary` triggers re-generation +- Project `gated` field: CRUD, default value + +### Unit Tests (mcplocal — gating flow) +- State machine: gated → `begin_session` → ungated (happy path) +- State machine: gated → `tools/call` intercepted → ungated (fallback path) +- State machine: non-gated project skips gate entirely +- LLM selection path: tags + prompt index sent to heavy LLM, ranked results returned, priority 10 always included +- LLM selection path: heavy LLM uses fast LLM for missing summary generation +- No-LLM fallback: tag matching score calculation, priority weighting, substring matching +- No-LLM fallback: byte-budget exhaustion, priority ordering, index fallback, edge cases +- Keyword extraction from tool calls: tool name parsing, argument extraction +- `begin_session` response: matched content + index + encouragement text (both LLM and fallback paths) +- `read_prompts` response: additional matches, deduplication against already-sent prompts (both paths) +- Tools blocked while gated: return error directing to `begin_session` +- `tools/list` while gated: only `begin_session` visible +- `tools/list` after ungating: `begin_session` replaced by `read_prompts` + all upstream tools +- Priority 10 always included regardless of tag match or budget +- Prompt index in instructions: cap at 50, priority 7+ when over cap +- Notifications: `tools/list_changed` sent after ungating + +### Unit Tests (mcplocal — prompt links) +- Link resolution: fetch content from source project's MCP server via service account +- Dead link detection: source server unavailable, resource not found, permission denied +- Dead link marking: status field updated, error logged +- RBAC enforcement: link creation requires edit permission on target project RBAC +- Service account permission: auto-created on source project for linked resource +- Content isolation: client LLM cannot access source server directly + +### Unit Tests (CLI) +- `create prompt` with `--priority` flag, validation +- `create prompt` with `--link` flag, format validation +- `get prompt -A` output: all projects, link targets, status columns +- `describe project` output: gated status, session greeting, prompt table +- `edit project` with gated field +- Shell completions for new flags and resources + +### Integration Tests +- End-to-end gated session: connect → begin_session with tags → tools available → correct prompts returned +- End-to-end intercept: connect → skip begin_session → call tool → keywords extracted → briefing injected +- End-to-end read_prompts: after ungating → request more context → additional prompts returned → no duplicates +- Prompt link resolution: create link → fetch content → verify content matches source +- Dead link lifecycle: create link → kill source → verify dead detection → restore → verify recovery +- Priority ordering: create prompts at various priorities → verify selection order and budget allocation +- Encouragement text: verify retrieval encouragement present in begin_session, read_prompts, and instructions + +## System Prompts (mcpctl-system project) + +All gate messages, encouragement text, and briefing templates are stored as prompts in a special `mcpctl-system` project. This makes them editable at runtime via `mcpctl edit prompt` without code changes or redeployment. + +### Required System Prompts + +| Name | Priority | Purpose | +|------|----------|---------| +| `gate-instructions` | 10 | Text injected into `initialize` instructions for gated projects. Tells client to call `begin_session` with 5 keywords. | +| `gate-encouragement` | 10 | Appended after `begin_session` response. Lists remaining prompts and encourages further retrieval. | +| `read-prompts-reminder` | 10 | Appended after `read_prompts` response. Reminds client that more context is available. | +| `gate-intercept-preamble` | 10 | Prepended to briefing when injected via tool call intercept (Option C fallback). | +| `session-greeting` | 10 | Shown in `mcpctl describe project` as the "hello prompt" — what client LLMs see on connect. | + +### Bootstrap + +The `mcpctl-system` project and its system prompts are created automatically on first startup (seed migration). They can be edited afterward but not deleted — delete attempts return an error. + +### How mcplocal Uses Them + +On router initialization, mcplocal fetches system prompts from mcpd via: +``` +GET /api/v1/projects/mcpctl-system/prompts/visible +``` + +These are cached with the same 60s TTL as project routers. The prompt content supports template variables: +- `{{prompt_index}}` — replaced with the current project's prompt index +- `{{project_name}}` — replaced with the current project name +- `{{matched_prompts}}` — replaced with tag-matched prompt content +- `{{remaining_prompts}}` — replaced with the list of non-matched prompts + +This way the encouragement text, tone, and structure can be tuned by editing prompts — no code changes needed. + +## Security Considerations + +- Prompt links: content fetched server-side, client never gets direct access to source MCP server +- RBAC: link creation requires edit permission on target project's RBAC +- Service account: source project grants read access to linked resource only +- Dead links: logged as errors, marked in listings, never expose source server errors to client +- Tag extraction: sanitize tool call arguments before using as keywords (prevent injection) diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index e239f8f..47cf183 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -1408,13 +1408,497 @@ "status": "done", "subtasks": [], "updatedAt": "2026-02-21T18:52:29.084Z" + }, + { + "id": "37", + "title": "Add priority, summary, chapters, and linkTarget fields to Prompt schema", + "description": "Extend the Prisma schema for the Prompt model to include priority (integer 1-10, default 5), summary (nullable string), chapters (nullable JSON array), and linkTarget (nullable string for prompt links).", + "details": "1. Update `/src/db/prisma/schema.prisma` to add fields to the Prompt model:\n - `priority Int @default(5)` with check constraint 1-10\n - `summary String? @db.Text`\n - `chapters Json?` (stored as JSON array of strings)\n - `linkTarget String?` (format: `project/server:resource-uri`)\n\n2. Create Prisma migration:\n ```bash\n pnpm --filter db exec prisma migrate dev --name add-prompt-priority-summary-chapters-link\n ```\n\n3. Update TypeScript types in shared package to reflect new fields\n\n4. Add validation for priority range (1-10) at the database level if possible, otherwise enforce in application layer", + "testStrategy": "- Unit test: Verify migration creates columns with correct types and defaults\n- Unit test: Verify priority default is 5\n- Unit test: Verify nullable fields accept null\n- Unit test: Verify chapters stores/retrieves JSON arrays correctly\n- Integration test: Create prompt with all new fields, retrieve and verify values", + "priority": "high", + "dependencies": [], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:35:08.154Z" + }, + { + "id": "38", + "title": "Add priority field to PromptRequest schema", + "description": "Extend the Prisma schema for the PromptRequest model to include the priority field (integer 1-10, default 5) to match the Prompt model.", + "details": "1. Update `/src/db/prisma/schema.prisma` to add to PromptRequest:\n - `priority Int @default(5)`\n\n2. Create Prisma migration:\n ```bash\n pnpm --filter db exec prisma migrate dev --name add-promptrequest-priority\n ```\n\n3. Update the `CreatePromptRequestSchema` in `/src/mcpd/src/validation/prompt.schema.ts` to include priority validation:\n ```typescript\n priority: z.number().int().min(1).max(10).default(5).optional(),\n ```\n\n4. Update TypeScript types in shared package", + "testStrategy": "- Unit test: Migration creates priority column with default 5\n- Unit test: PromptRequest creation with explicit priority\n- Unit test: PromptRequest creation uses default priority when not specified\n- Unit test: Validation rejects priority outside 1-10 range", + "priority": "high", + "dependencies": [ + "37" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:35:08.160Z" + }, + { + "id": "39", + "title": "Add gated field to Project schema", + "description": "Extend the Prisma schema for the Project model to include the gated boolean field (default true) that controls whether sessions go through the keyword-driven prompt retrieval flow.", + "details": "1. Update `/src/db/prisma/schema.prisma` to add to Project:\n - `gated Boolean @default(true)`\n\n2. Create Prisma migration:\n ```bash\n pnpm --filter db exec prisma migrate dev --name add-project-gated\n ```\n\n3. Update project-related TypeScript types\n\n4. Update project validation schemas to include gated field:\n ```typescript\n gated: z.boolean().default(true).optional(),\n ```\n\n5. Update project API routes to accept and return the gated field", + "testStrategy": "- Unit test: Migration creates gated column with default true\n- Unit test: Project creation with gated=false\n- Unit test: Project creation uses default gated=true when not specified\n- Unit test: Project update can toggle gated field\n- Integration test: GET /api/v1/projects/:name returns gated field", + "priority": "high", + "dependencies": [], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:35:08.165Z" + }, + { + "id": "40", + "title": "Update Prompt CRUD API to handle priority and linkTarget", + "description": "Modify prompt API endpoints to accept, validate, and return the priority and linkTarget fields. LinkTarget should be immutable after creation.", + "details": "1. Update `/src/mcpd/src/validation/prompt.schema.ts`:\n ```typescript\n export const CreatePromptSchema = z.object({\n name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),\n content: z.string().min(1).max(50000),\n projectId: z.string().optional(),\n priority: z.number().int().min(1).max(10).default(5).optional(),\n linkTarget: z.string().regex(/^[a-z0-9-]+\\/[a-z0-9-]+:[\\S]+$/).optional(),\n });\n \n export const UpdatePromptSchema = z.object({\n content: z.string().min(1).max(50000).optional(),\n priority: z.number().int().min(1).max(10).optional(),\n // Note: linkTarget is NOT included - links are immutable\n });\n ```\n\n2. Update `/src/mcpd/src/routes/prompts.ts`:\n - POST /api/v1/prompts: Accept priority, linkTarget\n - PUT /api/v1/prompts/:id: Accept priority only (not linkTarget)\n - GET endpoints: Return priority, linkTarget in response\n\n3. Update repository layer to handle new fields\n\n4. Add linkTarget format validation: `project/server:resource-uri`", + "testStrategy": "- Unit test: POST /api/v1/prompts with priority creates prompt with correct priority\n- Unit test: POST /api/v1/prompts with linkTarget creates linked prompt\n- Unit test: PUT /api/v1/prompts/:id with priority updates priority\n- Unit test: PUT /api/v1/prompts/:id rejects linkTarget (immutable)\n- Unit test: GET /api/v1/prompts returns priority and linkTarget fields\n- Unit test: Invalid linkTarget format rejected (validation error)\n- Unit test: Priority outside 1-10 range rejected", + "priority": "high", + "dependencies": [ + "37" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:37:17.506Z" + }, + { + "id": "41", + "title": "Update PromptRequest API to handle priority", + "description": "Modify prompt request API endpoints to accept, validate, and return the priority field for proposed prompts.", + "details": "1. Update validation in `/src/mcpd/src/validation/prompt.schema.ts`:\n ```typescript\n export const CreatePromptRequestSchema = z.object({\n name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),\n content: z.string().min(1).max(50000),\n projectId: z.string().optional(),\n createdBySession: z.string().optional(),\n createdByUserId: z.string().optional(),\n priority: z.number().int().min(1).max(10).default(5).optional(),\n });\n ```\n\n2. Update `/src/mcpd/src/routes/prompts.ts` for PromptRequest endpoints:\n - POST /api/v1/promptrequests: Accept priority\n - GET /api/v1/promptrequests: Return priority\n - POST /api/v1/promptrequests/:id/approve: Preserve priority when creating Prompt\n\n3. Update PromptService.approve() to copy priority from request to prompt\n\n4. Update repository layer", + "testStrategy": "- Unit test: POST /api/v1/promptrequests with priority creates request with correct priority\n- Unit test: POST /api/v1/promptrequests uses default priority 5 when not specified\n- Unit test: GET /api/v1/promptrequests returns priority field\n- Unit test: Approve preserves priority from request to created prompt\n- Unit test: Priority validation (1-10 range)", + "priority": "high", + "dependencies": [ + "38" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:37:17.511Z" + }, + { + "id": "42", + "title": "Implement prompt summary generation service", + "description": "Create a service that auto-generates summary (20 words) and chapters (key sections) for prompts, using fast LLM when available or regex fallback.", + "details": "1. Create `/src/mcpd/src/services/prompt-summary.service.ts`:\n ```typescript\n export class PromptSummaryService {\n constructor(\n private llmClient: LlmClient | null,\n private promptRepo: IPromptRepository\n ) {}\n \n async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> {\n if (this.llmClient) {\n return this.generateWithLlm(content);\n }\n return this.generateWithRegex(content);\n }\n \n private async generateWithLlm(content: string): Promise<...> {\n // Send content to fast LLM with prompt:\n // \"Generate a 20-word summary and extract key section topics...\"\n }\n \n private generateWithRegex(content: string): { summary: string; chapters: string[] } {\n // summary: first sentence of content (truncated to ~20 words)\n // chapters: extract markdown headings via regex /^#+\\s+(.+)$/gm\n }\n }\n ```\n\n2. Integrate with PromptService:\n - Call generateSummary on prompt create\n - Call generateSummary on prompt update (when content changes)\n - Cache results on the prompt record\n\n3. Handle LLM availability check via existing LlmConfig patterns", + "testStrategy": "- Unit test: generateWithRegex extracts first sentence as summary\n- Unit test: generateWithRegex extracts markdown headings as chapters\n- Unit test: generateWithLlm calls LLM with correct prompt (mock LLM)\n- Unit test: generateSummary uses LLM when available\n- Unit test: generateSummary falls back to regex when no LLM\n- Unit test: Empty content handled gracefully\n- Unit test: Content without headings returns empty chapters array\n- Integration test: Creating prompt triggers summary generation", + "priority": "high", + "dependencies": [ + "37" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:39:28.196Z" + }, + { + "id": "43", + "title": "Add regenerate-summary API endpoint", + "description": "Create POST /api/v1/prompts/:id/regenerate-summary endpoint to force re-generation of summary and chapters for a prompt.", + "details": "1. Add route in `/src/mcpd/src/routes/prompts.ts`:\n ```typescript\n fastify.post('/api/v1/prompts/:id/regenerate-summary', async (request, reply) => {\n const { id } = request.params as { id: string };\n const prompt = await promptService.findById(id);\n if (!prompt) {\n return reply.status(404).send({ error: 'Prompt not found' });\n }\n \n const { summary, chapters } = await summaryService.generateSummary(prompt.content);\n const updated = await promptService.updateSummary(id, summary, chapters);\n \n return reply.send(updated);\n });\n ```\n\n2. Add `updateSummary(id, summary, chapters)` method to PromptRepository and PromptService\n\n3. Return the updated prompt with new summary/chapters in response", + "testStrategy": "- Unit test: POST to valid prompt ID regenerates summary\n- Unit test: Returns updated prompt with new summary/chapters\n- Unit test: 404 for non-existent prompt ID\n- Unit test: Uses LLM when available, regex fallback otherwise\n- Integration test: End-to-end regeneration updates database", + "priority": "medium", + "dependencies": [ + "42" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:39:28.201Z" + }, + { + "id": "44", + "title": "Create prompt-index API endpoint", + "description": "Create GET /api/v1/projects/:name/prompt-index endpoint that returns a compact index of prompts (name, priority, summary, chapters) for a project.", + "details": "1. Add route in `/src/mcpd/src/routes/prompts.ts`:\n ```typescript\n fastify.get('/api/v1/projects/:name/prompt-index', async (request, reply) => {\n const { name } = request.params as { name: string };\n const project = await projectService.findByName(name);\n if (!project) {\n return reply.status(404).send({ error: 'Project not found' });\n }\n \n const prompts = await promptService.findByProject(project.id);\n const index = prompts.map(p => ({\n name: p.name,\n priority: p.priority,\n summary: p.summary,\n chapters: p.chapters,\n linkTarget: p.linkTarget,\n }));\n \n return reply.send({ prompts: index });\n });\n ```\n\n2. Consider adding global prompts to the index (inherited by all projects)\n\n3. Sort by priority descending in response", + "testStrategy": "- Unit test: Returns compact index for valid project\n- Unit test: Index contains name, priority, summary, chapters, linkTarget\n- Unit test: 404 for non-existent project\n- Unit test: Empty array for project with no prompts\n- Unit test: Results sorted by priority descending\n- Integration test: End-to-end retrieval matches database state", + "priority": "medium", + "dependencies": [ + "42" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:39:28.208Z" + }, + { + "id": "45", + "title": "Implement tag-matching algorithm for prompt selection", + "description": "Create a deterministic keyword-based tag matching algorithm as the no-LLM fallback for prompt selection, with byte-budget allocation and priority weighting.", + "details": "1. Create `/src/mcplocal/src/services/tag-matcher.service.ts`:\n ```typescript\n interface MatchedPrompt {\n prompt: PromptIndex;\n score: number;\n matchedTags: string[];\n }\n \n export class TagMatcherService {\n constructor(private byteBudget: number = 8192) {}\n \n matchPrompts(tags: string[], promptIndex: PromptIndex[]): {\n fullContent: PromptIndex[]; // Prompts to include in full\n indexOnly: PromptIndex[]; // Prompts to include as index entries\n remaining: PromptIndex[]; // Non-matched prompts (names only)\n } {\n // 1. Priority 10 prompts: always included (score = Infinity)\n // 2. For each prompt, compute score:\n // - Check tags against summary + chapters (case-insensitive substring)\n // - score = matching_tags_count * priority\n // 3. Sort by score descending\n // 4. Fill byte budget from top:\n // - Include full content until budget exhausted\n // - Remaining matched: include as index entries\n // - Non-matched: names only\n }\n \n private computeScore(tags: string[], prompt: PromptIndex): number {\n if (prompt.priority === 10) return Infinity;\n const matchingTags = tags.filter(tag => \n this.matchesPrompt(tag.toLowerCase(), prompt)\n );\n return matchingTags.length * prompt.priority;\n }\n \n private matchesPrompt(tag: string, prompt: PromptIndex): boolean {\n const searchText = [\n prompt.summary || '',\n ...(prompt.chapters || [])\n ].join(' ').toLowerCase();\n return searchText.includes(tag);\n }\n }\n ```\n\n2. Handle edge cases: empty tags, no prompts, all priority 10, etc.", + "testStrategy": "- Unit test: Priority 10 prompts always included regardless of tags\n- Unit test: Score calculation: matching_tags * priority\n- Unit test: Case-insensitive matching\n- Unit test: Substring matching in summary and chapters\n- Unit test: Byte budget exhaustion stops full content inclusion\n- Unit test: Matched prompts beyond budget become index entries\n- Unit test: Non-matched prompts listed as names only\n- Unit test: Sorting by score descending\n- Unit test: Empty tags returns priority 10 only\n- Unit test: No prompts returns empty result", + "priority": "high", + "dependencies": [ + "44" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:40:47.570Z" + }, + { + "id": "46", + "title": "Implement LLM-based prompt selection service", + "description": "Create a service that uses the heavy LLM to intelligently select relevant prompts based on tags and the full prompt index, understanding synonyms and context.", + "details": "1. Create `/src/mcplocal/src/services/llm-prompt-selector.service.ts`:\n ```typescript\n export class LlmPromptSelectorService {\n constructor(\n private llmClient: LlmClient,\n private fastLlmClient: LlmClient | null,\n private tagMatcher: TagMatcherService // fallback\n ) {}\n \n async selectPrompts(tags: string[], promptIndex: PromptIndex[]): Promise<{\n selected: Array<{ name: string; reason: string }>;\n priority10: PromptIndex[]; // Always included\n }> {\n // 1. Extract priority 10 prompts (always included)\n // 2. Generate missing summaries using fast LLM if needed\n // 3. Send to heavy LLM:\n const prompt = `\n Given these keywords: ${tags.join(', ')}\n And this prompt index:\n ${promptIndex.map(p => `- ${p.name}: ${p.summary}`).join('\\n')}\n \n Select the most relevant prompts for someone working on tasks\n related to these keywords. Consider synonyms and related concepts.\n Return a ranked JSON array: [{name: string, reason: string}]\n `;\n // 4. Parse LLM response\n // 5. On LLM error, fall back to tag matcher\n }\n }\n ```\n\n2. Handle LLM timeouts and errors gracefully with fallback\n\n3. Validate LLM response format", + "testStrategy": "- Unit test: Priority 10 prompts always returned regardless of LLM selection\n- Unit test: LLM called with correct prompt format (mock)\n- Unit test: LLM response parsed correctly\n- Unit test: Invalid LLM response falls back to tag matcher\n- Unit test: LLM timeout falls back to tag matcher\n- Unit test: Missing summaries trigger fast LLM generation\n- Unit test: No LLM available uses tag matcher directly\n- Integration test: End-to-end selection with mock LLM", + "priority": "high", + "dependencies": [ + "45" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:45:57.158Z" + }, + { + "id": "47", + "title": "Implement session state management for gating", + "description": "Extend the McpRouter to track per-session gating state including gated status, accumulated tags, and retrieved prompts set.", + "details": "1. Update `/src/mcplocal/src/router.ts` to add session state:\n ```typescript\n interface SessionState {\n gated: boolean; // starts true if project is gated\n tags: string[]; // accumulated from begin_session + read_prompts\n retrievedPrompts: Set; // prompts already sent (avoid duplicates)\n }\n \n export class McpRouter {\n private sessionStates: Map = new Map();\n \n getSessionState(sessionId: string): SessionState {\n if (!this.sessionStates.has(sessionId)) {\n this.sessionStates.set(sessionId, {\n gated: this.projectConfig?.gated ?? true,\n tags: [],\n retrievedPrompts: new Set(),\n });\n }\n return this.sessionStates.get(sessionId)!;\n }\n \n ungateSession(sessionId: string): void {\n const state = this.getSessionState(sessionId);\n state.gated = false;\n }\n \n addRetrievedPrompts(sessionId: string, names: string[]): void {\n const state = this.getSessionState(sessionId);\n names.forEach(n => state.retrievedPrompts.add(n));\n }\n }\n ```\n\n2. Clean up session state when session closes\n\n3. Handle session state for non-gated projects (gated=false from start)", + "testStrategy": "- Unit test: New session starts with gated=true for gated project\n- Unit test: New session starts with gated=false for non-gated project\n- Unit test: ungateSession changes gated to false\n- Unit test: addRetrievedPrompts adds to set\n- Unit test: retrievedPrompts prevents duplicates\n- Unit test: Session state isolated per sessionId\n- Unit test: Session cleanup removes state", + "priority": "high", + "dependencies": [ + "39" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:45:57.164Z" + }, + { + "id": "48", + "title": "Implement begin_session tool for gated sessions", + "description": "Create the begin_session MCP tool that accepts 5 keywords, triggers prompt selection, returns matched content with encouragement, and ungates the session.", + "details": "1. Add begin_session tool definition in `/src/mcplocal/src/router.ts`:\n ```typescript\n private getBeginSessionTool(): Tool {\n return {\n name: 'begin_session',\n description: 'Start your session by providing 5 keywords that describe your current task. You\\'ll receive relevant project context, policies, and guidelines. Required before using other tools.',\n inputSchema: {\n type: 'object',\n properties: {\n tags: {\n type: 'array',\n items: { type: 'string' },\n maxItems: 10,\n description: '5 keywords describing your current task'\n }\n },\n required: ['tags']\n }\n };\n }\n ```\n\n2. Implement begin_session handler:\n - Validate tags array (1-10 items)\n - Call LlmPromptSelector or TagMatcher based on LLM availability\n - Fetch full content for selected prompts\n - Build response with matched content + index + encouragement\n - Ungate session\n - Send `notifications/tools/list_changed`\n\n3. Response format:\n ```\n [Priority 10 prompts - full content]\n \n [Tag-matched prompts - full content, priority-ordered]\n \n Other prompts available that may become relevant...\n - name: summary\n ...\n If any seem related, request them with read_prompts({ tags: [...] }).\n ```", + "testStrategy": "- Unit test: begin_session with valid tags returns matched prompts\n- Unit test: begin_session includes priority 10 prompts always\n- Unit test: begin_session response includes encouragement text\n- Unit test: begin_session response includes prompt index\n- Unit test: Session ungated after successful begin_session\n- Unit test: notifications/tools/list_changed sent after ungating\n- Unit test: Empty tags handled (returns priority 10 only)\n- Unit test: Invalid tags rejected with error\n- Unit test: begin_session while already ungated returns error", + "priority": "high", + "dependencies": [ + "46", + "47" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:50:39.111Z" + }, + { + "id": "49", + "title": "Implement read_prompts tool for ongoing retrieval", + "description": "Create the read_prompts MCP tool that allows clients to request additional context by keywords after the session is ungated.", + "details": "1. Add read_prompts tool definition:\n ```typescript\n private getReadPromptsTool(): Tool {\n return {\n name: 'read_prompts',\n description: 'Request additional project context by keywords. Use this whenever you need guidelines, policies, or conventions related to your current work.',\n inputSchema: {\n type: 'object',\n properties: {\n tags: {\n type: 'array',\n items: { type: 'string' },\n description: 'Keywords describing what context you need'\n }\n },\n required: ['tags']\n }\n };\n }\n ```\n\n2. Implement read_prompts handler:\n - Always use keyword matching (not LLM) for precision\n - Exclude already-retrieved prompts from response\n - Add newly retrieved prompts to session state\n - Include reminder about more prompts available\n\n3. Response format:\n ```\n [Matched prompt content - deduplicated]\n \n Remember: you can request more prompts at any time with read_prompts({ tags: [...] }).\n The project may have additional guidelines relevant to your current approach.\n ```", + "testStrategy": "- Unit test: read_prompts returns matched prompts by keyword\n- Unit test: Already retrieved prompts excluded from response\n- Unit test: Newly retrieved prompts added to session state\n- Unit test: Response includes reminder text\n- Unit test: read_prompts while gated returns error\n- Unit test: Empty tags returns empty response\n- Unit test: Uses keyword matching not LLM", + "priority": "high", + "dependencies": [ + "48" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:50:39.115Z" + }, + { + "id": "50", + "title": "Implement progressive tool exposure for gated sessions", + "description": "Modify tools/list behavior to only expose begin_session while gated, and expose all tools plus read_prompts after ungating.", + "details": "1. Update tools/list handling in `/src/mcplocal/src/router.ts`:\n ```typescript\n async handleToolsList(sessionId: string): Promise {\n const state = this.getSessionState(sessionId);\n \n if (state.gated) {\n // Only show begin_session while gated\n return [this.getBeginSessionTool()];\n }\n \n // After ungating: all upstream tools + read_prompts\n const upstreamTools = await this.discoverTools();\n return [...upstreamTools, this.getReadPromptsTool()];\n }\n ```\n\n2. Block direct tool calls while gated:\n ```typescript\n async handleToolCall(sessionId: string, toolName: string, args: any): Promise {\n const state = this.getSessionState(sessionId);\n \n if (state.gated && toolName !== 'begin_session') {\n // Intercept: extract keywords, match prompts, inject briefing\n return this.handleInterceptedCall(sessionId, toolName, args);\n }\n \n // Normal routing\n return this.routeToolCall(toolName, args);\n }\n ```\n\n3. Ensure notifications/tools/list_changed is sent after ungating", + "testStrategy": "- Unit test: tools/list while gated returns only begin_session\n- Unit test: tools/list after ungating returns all tools + read_prompts\n- Unit test: begin_session not visible after ungating\n- Unit test: Tool call while gated (not begin_session) triggers intercept\n- Unit test: Tool call after ungating routes normally\n- Unit test: notifications/tools/list_changed sent on ungate", + "priority": "high", + "dependencies": [ + "48", + "49" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:50:39.120Z" + }, + { + "id": "51", + "title": "Implement keyword extraction from tool calls", + "description": "Create a service that extracts keywords from tool names and arguments for the intercept fallback path when clients skip begin_session.", + "details": "1. Create `/src/mcplocal/src/services/keyword-extractor.service.ts`:\n ```typescript\n export class KeywordExtractorService {\n extractKeywords(toolName: string, args: Record): string[] {\n const keywords: string[] = [];\n \n // Extract from tool name (split on / and -)\n // e.g., \"home-assistant/get_entities\" -> [\"home\", \"assistant\", \"get\", \"entities\"]\n keywords.push(...this.extractFromName(toolName));\n \n // Extract from argument values\n // e.g., { domain: \"light\", entity_id: \"light.kitchen\" } -> [\"light\", \"kitchen\"]\n keywords.push(...this.extractFromArgs(args));\n \n // Deduplicate and sanitize\n return [...new Set(keywords.map(k => this.sanitize(k)))];\n }\n \n private sanitize(keyword: string): string {\n // Remove special characters, lowercase, limit length\n return keyword.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 50);\n }\n }\n ```\n\n2. Handle various argument types: strings, arrays, nested objects\n\n3. Prevent injection by sanitizing extracted keywords", + "testStrategy": "- Unit test: Extracts keywords from tool name with /\n- Unit test: Extracts keywords from tool name with -\n- Unit test: Extracts keywords from string argument values\n- Unit test: Extracts keywords from array argument values\n- Unit test: Handles nested object arguments\n- Unit test: Sanitizes special characters\n- Unit test: Deduplicates keywords\n- Unit test: Handles empty arguments\n- Unit test: Limits keyword length to prevent abuse", + "priority": "medium", + "dependencies": [], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:40:47.575Z" + }, + { + "id": "52", + "title": "Implement tool call intercept with briefing injection", + "description": "When a gated session calls a tool without first calling begin_session, intercept the call, extract keywords, match prompts, and inject the briefing alongside the real tool result.", + "details": "1. Implement handleInterceptedCall in `/src/mcplocal/src/router.ts`:\n ```typescript\n async handleInterceptedCall(\n sessionId: string,\n toolName: string,\n args: any\n ): Promise {\n // 1. Extract keywords from tool call\n const keywords = this.keywordExtractor.extractKeywords(toolName, args);\n \n // 2. Match prompts using keywords\n const { fullContent, indexOnly, remaining } = \n await this.promptSelector.selectPrompts(keywords, this.promptIndex);\n \n // 3. Execute the actual tool call\n const actualResult = await this.routeToolCall(toolName, args);\n \n // 4. Build briefing with intercept preamble\n const briefing = this.buildBriefing(fullContent, indexOnly, remaining, 'intercept');\n \n // 5. Ungate session\n this.ungateSession(sessionId);\n \n // 6. Send notifications/tools/list_changed\n await this.sendToolsListChanged();\n \n // 7. Return combined result\n return {\n content: [{\n type: 'text',\n text: `${briefing}\\n\\n---\\n\\n${actualResult.content[0].text}`\n }]\n };\n }\n ```\n\n2. Use gate-intercept-preamble system prompt for the briefing prefix", + "testStrategy": "- Unit test: Tool call while gated triggers intercept\n- Unit test: Keywords extracted from tool name and args\n- Unit test: Prompts matched using extracted keywords\n- Unit test: Actual tool still executes and returns result\n- Unit test: Briefing prepended to tool result\n- Unit test: Session ungated after intercept\n- Unit test: notifications/tools/list_changed sent\n- Unit test: Intercept preamble included in briefing\n- Integration test: End-to-end intercept flow", + "priority": "high", + "dependencies": [ + "50", + "51" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:51:03.822Z" + }, + { + "id": "53", + "title": "Add prompt index to initialize instructions", + "description": "Modify the initialize handler to include the compact prompt index and gate message in instructions for gated projects.", + "details": "1. Update initialize handling in `/src/mcplocal/src/router.ts`:\n ```typescript\n async handleInitialize(sessionId: string): Promise {\n const state = this.getSessionState(sessionId);\n \n let instructions = this.projectConfig.prompt || '';\n \n if (state.gated) {\n // Add gate instructions\n const gateInstructions = await this.getSystemPrompt('gate-instructions');\n \n // Build prompt index (cap at 50, priority 7+ if over)\n const index = this.buildPromptIndex();\n \n instructions += `\\n\\n${gateInstructions.replace('{{prompt_index}}', index)}`;\n }\n \n return {\n protocolVersion: '2024-11-05',\n capabilities: { ... },\n serverInfo: { ... },\n instructions,\n };\n }\n ```\n\n2. Build prompt index with cap:\n - If <= 50 prompts: include all\n - If > 50 prompts: include only priority 7+\n - Format: `- : ` (~100 chars per entry)", + "testStrategy": "- Unit test: Gated project includes gate instructions in initialize\n- Unit test: Prompt index included in instructions\n- Unit test: Index capped at 50 entries\n- Unit test: Over 50 prompts shows priority 7+ only\n- Unit test: Non-gated project skips gate instructions\n- Unit test: {{prompt_index}} template replaced\n- Integration test: End-to-end initialize with gated project", + "priority": "high", + "dependencies": [ + "47", + "44" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:52:13.697Z" + }, + { + "id": "54", + "title": "Create mcpctl-system project with system prompts", + "description": "Implement bootstrap logic to create the mcpctl-system project and its required system prompts on first startup, with protection against deletion.", + "details": "1. Create seed migration or startup hook:\n ```typescript\n async function bootstrapSystemProject() {\n const systemProject = await projectRepo.findByName('mcpctl-system');\n if (systemProject) return; // Already exists\n \n // Create mcpctl-system project\n const project = await projectRepo.create({\n name: 'mcpctl-system',\n description: 'System prompts for mcpctl gating and encouragement',\n gated: false, // System project is not gated\n ownerId: SYSTEM_USER_ID,\n });\n \n // Create required system prompts\n const systemPrompts = [\n { name: 'gate-instructions', priority: 10, content: GATE_INSTRUCTIONS },\n { name: 'gate-encouragement', priority: 10, content: GATE_ENCOURAGEMENT },\n { name: 'read-prompts-reminder', priority: 10, content: READ_PROMPTS_REMINDER },\n { name: 'gate-intercept-preamble', priority: 10, content: GATE_INTERCEPT_PREAMBLE },\n { name: 'session-greeting', priority: 10, content: SESSION_GREETING },\n ];\n \n for (const p of systemPrompts) {\n await promptRepo.create({ ...p, projectId: project.id });\n }\n }\n ```\n\n2. Add delete protection in prompt delete endpoint:\n - Check if prompt belongs to mcpctl-system\n - Return 403 error if attempting to delete system prompt\n\n3. Define default content for each system prompt per PRD", + "testStrategy": "- Unit test: System project created on first startup\n- Unit test: All 5 system prompts created\n- Unit test: Subsequent startups don't duplicate\n- Unit test: Delete system prompt returns 403\n- Unit test: System prompts have priority 10\n- Unit test: mcpctl-system project has gated=false\n- Integration test: End-to-end bootstrap flow", + "priority": "high", + "dependencies": [ + "40", + "39" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:56:12.064Z" + }, + { + "id": "55", + "title": "Implement system prompt fetching and caching in mcplocal", + "description": "Add functionality to mcplocal router to fetch system prompts from mcpd and cache them with 60s TTL, supporting template variable replacement.", + "details": "1. Add system prompt fetching in `/src/mcplocal/src/router.ts`:\n ```typescript\n private systemPromptCache: Map = new Map();\n \n async getSystemPrompt(name: string): Promise {\n const cached = this.systemPromptCache.get(name);\n if (cached && cached.expiresAt > Date.now()) {\n return cached.content;\n }\n \n const prompts = await this.mcpdClient.fetch(\n '/api/v1/projects/mcpctl-system/prompts/visible'\n );\n const prompt = prompts.find(p => p.name === name);\n if (!prompt) {\n throw new Error(`System prompt not found: ${name}`);\n }\n \n this.systemPromptCache.set(name, {\n content: prompt.content,\n expiresAt: Date.now() + 60000, // 60s TTL\n });\n \n return prompt.content;\n }\n ```\n\n2. Add template variable replacement:\n ```typescript\n replaceTemplateVariables(content: string, vars: Record): string {\n return content\n .replace(/\\{\\{prompt_index\\}\\}/g, vars.prompt_index || '')\n .replace(/\\{\\{project_name\\}\\}/g, vars.project_name || '')\n .replace(/\\{\\{matched_prompts\\}\\}/g, vars.matched_prompts || '')\n .replace(/\\{\\{remaining_prompts\\}\\}/g, vars.remaining_prompts || '');\n }\n ```", + "testStrategy": "- Unit test: System prompt fetched from mcpd\n- Unit test: Cached prompt returned within TTL\n- Unit test: Cache miss triggers fresh fetch\n- Unit test: Missing system prompt throws error\n- Unit test: Template variables replaced correctly\n- Unit test: Unknown template variables left as-is\n- Integration test: End-to-end fetch and cache", + "priority": "high", + "dependencies": [ + "54" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:57:28.917Z" + }, + { + "id": "56", + "title": "Implement prompt link resolution service", + "description": "Create a service that fetches linked prompt content from source MCP servers using the project's service account, with dead link detection.", + "details": "1. Create `/src/mcplocal/src/services/link-resolver.service.ts`:\n ```typescript\n export class LinkResolverService {\n constructor(private mcpdClient: McpdClient) {}\n \n async resolveLink(linkTarget: string): Promise<{\n content: string | null;\n status: 'alive' | 'dead' | 'unknown';\n error?: string;\n }> {\n // Parse linkTarget: project/server:resource-uri\n const { project, server, uri } = this.parseLink(linkTarget);\n \n try {\n // Use service account for source project\n const content = await this.fetchResource(project, server, uri);\n return { content, status: 'alive' };\n } catch (error) {\n this.logDeadLink(linkTarget, error);\n return { \n content: null, \n status: 'dead',\n error: error.message \n };\n }\n }\n \n private parseLink(linkTarget: string): { project: string; server: string; uri: string } {\n const match = linkTarget.match(/^([^/]+)\\/([^:]+):(.+)$/);\n if (!match) throw new Error('Invalid link format');\n return { project: match[1], server: match[2], uri: match[3] };\n }\n \n private async fetchResource(project: string, server: string, uri: string): Promise {\n // Call mcpd to fetch resource via service account\n // mcpd routes to the source project's MCP server\n }\n }\n ```\n\n2. Log dead links as errors\n\n3. Cache resolution results", + "testStrategy": "- Unit test: Valid link parsed correctly\n- Unit test: Invalid link format throws error\n- Unit test: Successful resolution returns content and status='alive'\n- Unit test: Failed resolution returns status='dead' with error\n- Unit test: Dead link logged as error\n- Unit test: Service account header included in request\n- Integration test: End-to-end link resolution", + "priority": "medium", + "dependencies": [ + "40" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:07:29.026Z" + }, + { + "id": "57", + "title": "Add linkStatus to prompt GET responses", + "description": "Modify the GET /api/v1/prompts endpoint to include linkStatus (alive/dead/unknown) for linked prompts by checking link health.", + "details": "1. Update `/src/mcpd/src/routes/prompts.ts` GET endpoint:\n ```typescript\n fastify.get('/api/v1/prompts', async (request, reply) => {\n const prompts = await promptService.findAll(filter);\n \n // Check link status for linked prompts\n const promptsWithStatus = await Promise.all(\n prompts.map(async (p) => {\n if (!p.linkTarget) {\n return { ...p, linkStatus: null };\n }\n const status = await linkResolver.checkLinkHealth(p.linkTarget);\n return { ...p, linkStatus: status };\n })\n );\n \n return reply.send(promptsWithStatus);\n });\n ```\n\n2. Consider caching link health to avoid repeated checks\n\n3. Add `linkStatus` field to prompt response schema:\n - `null` for non-linked prompts\n - `'alive'` for working links\n - `'dead'` for broken links\n - `'unknown'` for unchecked links", + "testStrategy": "- Unit test: Non-linked prompt has linkStatus=null\n- Unit test: Linked prompt with working link has linkStatus='alive'\n- Unit test: Linked prompt with broken link has linkStatus='dead'\n- Unit test: Link health cached to avoid repeated checks\n- Unit test: All prompts in response have linkStatus field\n- Integration test: End-to-end GET with linked prompts", + "priority": "medium", + "dependencies": [ + "56" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:09:07.078Z" + }, + { + "id": "58", + "title": "Add RBAC for prompt link creation", + "description": "Implement RBAC checks requiring edit permission on the target project to create prompt links, and auto-create service account permission on the source project.", + "details": "1. Update prompt creation in `/src/mcpd/src/services/prompt.service.ts`:\n ```typescript\n async createPrompt(data: CreatePromptInput, userId: string): Promise {\n if (data.linkTarget) {\n // Verify user has edit permission on target project RBAC\n const hasPermission = await this.rbacService.checkPermission(\n userId, data.projectId, 'edit'\n );\n if (!hasPermission) {\n throw new ForbiddenError('Edit permission required to create prompt links');\n }\n \n // Parse link target\n const { project: sourceProject, server, uri } = this.parseLink(data.linkTarget);\n \n // Create service account permission on source project\n await this.rbacService.createServiceAccountPermission(\n data.projectId, // target project\n sourceProject, // source project\n server,\n uri,\n 'read'\n );\n }\n \n return this.promptRepo.create(data);\n }\n ```\n\n2. Clean up service account permission when link is deleted\n\n3. Handle permission denied from source project", + "testStrategy": "- Unit test: Link creation requires edit permission\n- Unit test: Link creation without permission throws 403\n- Unit test: Service account permission created on source project\n- Unit test: Service account permission deleted when link deleted\n- Unit test: Non-link prompts skip RBAC checks\n- Integration test: End-to-end link creation with RBAC", + "priority": "medium", + "dependencies": [ + "56" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:09:07.081Z" + }, + { + "id": "59", + "title": "Update CLI create prompt command for priority and link", + "description": "Extend the mcpctl create prompt command to accept --priority (1-10) and --link (project/server:uri) flags.", + "details": "1. Update `/src/cli/src/commands/create.ts` for prompt:\n ```typescript\n .command('prompt ')\n .description('Create a new prompt')\n .option('-p, --project ', 'Project to create prompt in')\n .option('--priority ', 'Priority level (1-10, default: 5)', '5')\n .option('--link ', 'Link to MCP resource (project/server:uri)')\n .option('-f, --file ', 'Read content from file')\n .action(async (name, options) => {\n const priority = parseInt(options.priority, 10);\n if (priority < 1 || priority > 10) {\n console.error('Priority must be between 1 and 10');\n process.exit(1);\n }\n \n let content = '';\n if (options.link) {\n // Linked prompts don't need content (fetched from source)\n content = `[Link: ${options.link}]`;\n } else if (options.file) {\n content = await fs.readFile(options.file, 'utf-8');\n } else {\n content = await promptForContent();\n }\n \n const body = {\n name,\n content,\n projectId: options.project,\n priority,\n linkTarget: options.link,\n };\n \n await api.post('/api/v1/prompts', body);\n });\n ```\n\n2. Validate link format: `project/server:resource-uri`\n\n3. Add shell completions for new flags", + "testStrategy": "- Unit test: --priority flag sets prompt priority\n- Unit test: --priority validation (1-10 range)\n- Unit test: --link flag sets linkTarget\n- Unit test: --link validation (format check)\n- Unit test: Linked prompt skips content prompt\n- Unit test: Default priority is 5\n- Integration test: End-to-end create with flags", + "priority": "medium", + "dependencies": [ + "40" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:03:45.972Z" + }, + { + "id": "60", + "title": "Update CLI get prompt command for -A flag and link columns", + "description": "Extend the mcpctl get prompt command with -A (all projects) flag and add link target and status columns to output.", + "details": "1. Update `/src/cli/src/commands/get.ts` for prompt:\n ```typescript\n .command('prompt [name]')\n .option('-A, --all-projects', 'Show prompts from all projects')\n .option('-p, --project ', 'Filter by project')\n .action(async (name, options) => {\n let url = '/api/v1/prompts';\n if (options.allProjects) {\n url += '?all=true';\n } else if (options.project) {\n url += `?project=${options.project}`;\n }\n \n const prompts = await api.get(url);\n \n // Format table with new columns\n formatPromptsTable(prompts, {\n columns: ['PROJECT', 'NAME', 'PRIORITY', 'LINK', 'STATUS']\n });\n });\n ```\n\n2. Update table formatter to handle link columns:\n ```\n PROJECT NAME PRIORITY LINK STATUS\n homeautomation security-policies 8 - -\n homeautomation architecture-adr 6 system-public/docmost-mcp:docmost://pages/a1 alive\n ```\n\n3. Add shell completions for -A flag", + "testStrategy": "- Unit test: -A flag shows all projects\n- Unit test: --project flag filters by project\n- Unit test: PRIORITY column displayed\n- Unit test: LINK column shows linkTarget or -\n- Unit test: STATUS column shows linkStatus or -\n- Unit test: Table formatted correctly\n- Integration test: End-to-end get with flags", + "priority": "medium", + "dependencies": [ + "57", + "59" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:09:31.501Z" + }, + { + "id": "61", + "title": "Update CLI describe project command for gated status", + "description": "Extend mcpctl describe project to show gated status, session greeting, and prompt table with priority and link information.", + "details": "1. Update `/src/cli/src/commands/get.ts` describe project:\n ```typescript\n async function describeProject(name: string) {\n const project = await api.get(`/api/v1/projects/${name}`);\n const prompts = await api.get(`/api/v1/projects/${name}/prompt-index`);\n const greeting = await getSessionGreeting(name);\n \n console.log(`Name: ${project.name}`);\n console.log(`Gated: ${project.gated}`);\n console.log(`LLM Provider: ${project.llmProvider || '-'}`);\n console.log(`...`);\n console.log();\n console.log(`Session greeting:`);\n console.log(` ${greeting}`);\n console.log();\n console.log(`Prompts:`);\n console.log(` NAME PRIORITY TYPE LINK`);\n for (const p of prompts) {\n const type = p.linkTarget ? 'link' : 'local';\n const link = p.linkTarget || '-';\n console.log(` ${p.name.padEnd(20)} ${p.priority.toString().padEnd(9)} ${type.padEnd(7)} ${link}`);\n }\n }\n ```\n\n2. Fetch session greeting from system prompts or project config", + "testStrategy": "- Unit test: Gated status displayed\n- Unit test: Session greeting displayed\n- Unit test: Prompt table with PRIORITY, TYPE, LINK columns\n- Unit test: TYPE shows 'local' or 'link'\n- Unit test: LINK shows target or -\n- Integration test: End-to-end describe project", + "priority": "medium", + "dependencies": [ + "44", + "54" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:04:56.320Z" + }, + { + "id": "62", + "title": "Update CLI edit project command for gated field", + "description": "Extend mcpctl edit project to allow editing the gated boolean field.", + "details": "1. Update `/src/cli/src/commands/edit.ts` for project:\n ```typescript\n async function editProject(name: string) {\n const project = await api.get(`/api/v1/projects/${name}`);\n \n // Add gated to editable fields\n const yaml = `\n name: ${project.name}\n description: ${project.description}\n gated: ${project.gated}\n llmProvider: ${project.llmProvider || ''}\n ...`;\n \n const edited = await openEditor(yaml);\n const parsed = YAML.parse(edited);\n \n // Validate gated is boolean\n if (typeof parsed.gated !== 'boolean') {\n console.error('gated must be true or false');\n process.exit(1);\n }\n \n await api.put(`/api/v1/projects/${name}`, parsed);\n }\n ```\n\n2. Update project validation schema to accept gated\n\n3. Handle conversion from string 'true'/'false' to boolean", + "testStrategy": "- Unit test: Gated field appears in editor YAML\n- Unit test: Gated field saved on edit\n- Unit test: Boolean validation (true/false only)\n- Unit test: String 'true'/'false' converted to boolean\n- Integration test: End-to-end edit project gated", + "priority": "medium", + "dependencies": [ + "39" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:03:46.657Z" + }, + { + "id": "63", + "title": "Add unit tests for prompt priority and link CRUD", + "description": "Create comprehensive unit tests for all prompt CRUD operations with the new priority and linkTarget fields.", + "details": "1. Add tests in `/src/mcpd/tests/services/prompt-service.test.ts`:\n ```typescript\n describe('Prompt Priority', () => {\n it('creates prompt with explicit priority', async () => {\n const prompt = await service.createPrompt({ ...data, priority: 8 });\n expect(prompt.priority).toBe(8);\n });\n \n it('uses default priority 5 when not specified', async () => {\n const prompt = await service.createPrompt(data);\n expect(prompt.priority).toBe(5);\n });\n \n it('validates priority range 1-10', async () => {\n await expect(service.createPrompt({ ...data, priority: 11 }))\n .rejects.toThrow();\n });\n \n it('updates priority', async () => {\n const updated = await service.updatePrompt(id, { priority: 3 });\n expect(updated.priority).toBe(3);\n });\n });\n \n describe('Prompt Links', () => {\n it('creates linked prompt', async () => {\n const prompt = await service.createPrompt({\n ...data,\n linkTarget: 'project/server:uri'\n });\n expect(prompt.linkTarget).toBe('project/server:uri');\n });\n \n it('rejects invalid link format', async () => {\n await expect(service.createPrompt({\n ...data,\n linkTarget: 'invalid'\n })).rejects.toThrow();\n });\n \n it('linkTarget is immutable on update', async () => {\n // linkTarget not accepted in update schema\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- All priority CRUD tests pass\n- All link CRUD tests pass\n- Validation tests cover edge cases\n- Tests use proper mocking patterns\n- Coverage meets project standards", + "priority": "high", + "dependencies": [ + "40", + "41" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:52:53.091Z" + }, + { + "id": "64", + "title": "Add unit tests for tag matching algorithm", + "description": "Create comprehensive unit tests for the deterministic tag matching algorithm covering score calculation, byte budget, and priority handling.", + "details": "1. Add tests in `/src/mcplocal/tests/services/tag-matcher.test.ts`:\n ```typescript\n describe('TagMatcherService', () => {\n describe('score calculation', () => {\n it('priority 10 prompts have infinite score', () => {\n const score = matcher.computeScore(['any'], { priority: 10, ... });\n expect(score).toBe(Infinity);\n });\n \n it('score = matching_tags * priority', () => {\n const score = matcher.computeScore(\n ['tag1', 'tag2'],\n { priority: 5, summary: 'tag1 tag2', chapters: [] }\n );\n expect(score).toBe(10); // 2 tags * 5 priority\n });\n });\n \n describe('matching', () => {\n it('matches case-insensitively', () => {\n const matches = matcher.matchesPrompt('ZIGBEE', { summary: 'zigbee setup' });\n expect(matches).toBe(true);\n });\n \n it('matches substring in summary', () => { ... });\n it('matches substring in chapters', () => { ... });\n });\n \n describe('byte budget', () => {\n it('includes full content until budget exhausted', () => { ... });\n it('matched prompts beyond budget become index entries', () => { ... });\n it('non-matched prompts listed as names only', () => { ... });\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- Score calculation tests pass\n- Matching tests cover all cases\n- Byte budget tests verify allocation\n- Edge cases handled (empty tags, no prompts, etc.)\n- Tests are deterministic", + "priority": "high", + "dependencies": [ + "45" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:51:03.827Z" + }, + { + "id": "65", + "title": "Add unit tests for gating state machine", + "description": "Create comprehensive unit tests for the session gating state machine covering all transitions and edge cases.", + "details": "1. Add tests in `/src/mcplocal/tests/router-gating.test.ts`:\n ```typescript\n describe('Gating State Machine', () => {\n describe('initial state', () => {\n it('starts gated for gated project', () => {\n const router = createRouter({ gated: true });\n const state = router.getSessionState('session1');\n expect(state.gated).toBe(true);\n });\n \n it('starts ungated for non-gated project', () => {\n const router = createRouter({ gated: false });\n const state = router.getSessionState('session1');\n expect(state.gated).toBe(false);\n });\n });\n \n describe('begin_session transition', () => {\n it('ungates session on successful begin_session', async () => {\n const router = createGatedRouter();\n await router.handleBeginSession('session1', { tags: ['test'] });\n expect(router.getSessionState('session1').gated).toBe(false);\n });\n \n it('returns matched prompts', async () => { ... });\n it('sends notifications/tools/list_changed', async () => { ... });\n });\n \n describe('intercept transition', () => {\n it('ungates session on tool call intercept', async () => { ... });\n it('extracts keywords from tool call', async () => { ... });\n it('injects briefing with tool result', async () => { ... });\n });\n \n describe('tools/list behavior', () => {\n it('returns only begin_session while gated', async () => { ... });\n it('returns all tools + read_prompts after ungating', async () => { ... });\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- Initial state tests pass\n- Transition tests cover happy paths\n- Edge case tests (already ungated, etc.)\n- Notification tests verify signals sent\n- Tests use proper mocking", + "priority": "high", + "dependencies": [ + "50", + "52" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:51:03.832Z" + }, + { + "id": "66", + "title": "Add unit tests for LLM prompt selection", + "description": "Create unit tests for the LLM-based prompt selection service covering LLM interactions, fallback behavior, and priority 10 handling.", + "details": "1. Add tests in `/src/mcplocal/tests/services/llm-prompt-selector.test.ts`:\n ```typescript\n describe('LlmPromptSelectorService', () => {\n describe('priority 10 handling', () => {\n it('always includes priority 10 prompts', async () => {\n const result = await selector.selectPrompts(['unrelated'], promptIndex);\n expect(result.priority10).toContain(priority10Prompt);\n });\n });\n \n describe('LLM selection', () => {\n it('sends tags and index to heavy LLM', async () => {\n await selector.selectPrompts(['zigbee', 'mqtt'], promptIndex);\n expect(mockLlm.complete).toHaveBeenCalledWith(\n expect.stringContaining('zigbee')\n );\n });\n \n it('parses LLM response correctly', async () => {\n mockLlm.complete.mockResolvedValue(\n '[{\"name\": \"prompt1\", \"reason\": \"relevant\"}]'\n );\n const result = await selector.selectPrompts(['test'], promptIndex);\n expect(result.selected[0].name).toBe('prompt1');\n });\n });\n \n describe('fallback behavior', () => {\n it('falls back to tag matcher on LLM error', async () => { ... });\n it('falls back on LLM timeout', async () => { ... });\n it('falls back when no LLM available', async () => { ... });\n });\n \n describe('summary generation', () => {\n it('generates missing summaries with fast LLM', async () => { ... });\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- Priority 10 tests pass\n- LLM interaction tests use proper mocks\n- Fallback tests cover all error scenarios\n- Summary generation tests pass\n- Response parsing handles edge cases", + "priority": "high", + "dependencies": [ + "46" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:51:03.836Z" + }, + { + "id": "67", + "title": "Add integration tests for gated session flow", + "description": "Create end-to-end integration tests for the complete gated session flow including connect, begin_session, tool calls, and read_prompts.", + "details": "1. Add tests in `/src/mcplocal/tests/integration/gated-flow.test.ts`:\n ```typescript\n describe('Gated Session Flow Integration', () => {\n let app: FastifyInstance;\n let mcpClient: McpClient;\n \n beforeAll(async () => {\n app = await createTestApp();\n // Seed test project with gated=true and test prompts\n });\n \n describe('end-to-end gated flow', () => {\n it('connect → begin_session with tags → tools available → correct prompts', async () => {\n // 1. Connect to MCP endpoint\n const session = await mcpClient.connect(app, 'test-project');\n \n // 2. Verify only begin_session available\n const toolsBefore = await session.listTools();\n expect(toolsBefore.map(t => t.name)).toEqual(['begin_session']);\n \n // 3. Call begin_session\n const briefing = await session.callTool('begin_session', {\n tags: ['test', 'integration']\n });\n expect(briefing).toContain('matched prompt content');\n \n // 4. Verify all tools now available\n const toolsAfter = await session.listTools();\n expect(toolsAfter.map(t => t.name)).toContain('read_prompts');\n });\n });\n \n describe('end-to-end intercept flow', () => {\n it('connect → skip begin_session → call tool → keywords extracted → briefing injected', async () => { ... });\n });\n \n describe('end-to-end read_prompts', () => {\n it('after ungating → request more context → additional prompts → no duplicates', async () => { ... });\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- Happy path tests pass\n- Intercept path tests pass\n- read_prompts deduplication works\n- Tests use realistic data\n- Tests clean up properly", + "priority": "high", + "dependencies": [ + "65" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T22:51:03.840Z" + }, + { + "id": "68", + "title": "Add integration tests for prompt links", + "description": "Create end-to-end integration tests for prompt link creation, resolution, and dead link detection.", + "details": "1. Add tests in `/src/mcplocal/tests/integration/prompt-links.test.ts`:\n ```typescript\n describe('Prompt Links Integration', () => {\n describe('link creation', () => {\n it('creates link with RBAC permission', async () => {\n // Setup: user with edit permission on target project\n const prompt = await api.post('/api/v1/prompts', {\n name: 'linked-prompt',\n content: '[Link]',\n projectId: targetProject.id,\n linkTarget: 'source-project/server:uri'\n });\n expect(prompt.linkTarget).toBe('source-project/server:uri');\n });\n \n it('rejects link creation without RBAC permission', async () => { ... });\n });\n \n describe('link resolution', () => {\n it('fetches content from source server', async () => { ... });\n it('uses service account for RBAC', async () => { ... });\n });\n \n describe('dead link lifecycle', () => {\n it('detects dead link when source unavailable', async () => {\n // Kill source server\n const prompts = await api.get('/api/v1/prompts');\n const linked = prompts.find(p => p.linkTarget);\n expect(linked.linkStatus).toBe('dead');\n });\n \n it('recovers when source restored', async () => { ... });\n });\n });\n ```", + "testStrategy": "This task IS the test implementation. Verify:\n- RBAC tests cover permission scenarios\n- Resolution tests verify content fetched\n- Dead link tests cover full lifecycle\n- Tests properly mock/control source servers\n- Tests clean up resources", + "priority": "medium", + "dependencies": [ + "57", + "58" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:12:22.348Z" + }, + { + "id": "69", + "title": "Add CLI unit tests for new prompt and project flags", + "description": "Create unit tests for the new CLI flags: --priority, --link for prompts, -A for get, and gated field for projects.", + "details": "1. Add tests in `/src/cli/tests/commands/prompt.test.ts`:\n ```typescript\n describe('create prompt command', () => {\n it('--priority sets prompt priority', async () => {\n await cli('create prompt test --priority 8');\n expect(mockApi.post).toHaveBeenCalledWith(\n '/api/v1/prompts',\n expect.objectContaining({ priority: 8 })\n );\n });\n \n it('--priority validates range 1-10', async () => {\n await expect(cli('create prompt test --priority 15'))\n .rejects.toThrow('Priority must be between 1 and 10');\n });\n \n it('--link sets linkTarget', async () => {\n await cli('create prompt test --link proj/srv:uri');\n expect(mockApi.post).toHaveBeenCalledWith(\n '/api/v1/prompts',\n expect.objectContaining({ linkTarget: 'proj/srv:uri' })\n );\n });\n });\n \n describe('get prompt command', () => {\n it('-A shows all projects', async () => {\n await cli('get prompt -A');\n expect(mockApi.get).toHaveBeenCalledWith('/api/v1/prompts?all=true');\n });\n });\n ```\n\n2. Add tests for project gated field editing\n\n3. Add tests for describe project output", + "testStrategy": "This task IS the test implementation. Verify:\n- Flag parsing tests pass\n- Validation tests cover edge cases\n- API call tests verify correct parameters\n- Output formatting tests verify columns\n- Tests mock API properly", + "priority": "medium", + "dependencies": [ + "59", + "60", + "61", + "62" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:12:22.352Z" + }, + { + "id": "70", + "title": "Add shell completions for new CLI flags", + "description": "Update shell completion scripts (bash, zsh, fish) to include completions for new flags: --priority, --link, -A, and gated values.", + "details": "1. Update `/completions/mcpctl.fish`:\n ```fish\n # create prompt completions\n complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l priority -d 'Priority level (1-10)' -a '(seq 1 10)'\n complete -c mcpctl -n '__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt' -l link -d 'Link to MCP resource (project/server:uri)'\n \n # get prompt completions \n complete -c mcpctl -n '__fish_seen_subcommand_from get; and __fish_seen_subcommand_from prompt' -s A -l all-projects -d 'Show prompts from all projects'\n ```\n\n2. Update bash completions similarly\n\n3. Update zsh completions similarly\n\n4. Add dynamic completion for priority values (1-10)", + "testStrategy": "- Manual test: Fish completions suggest --priority with values 1-10\n- Manual test: Fish completions suggest --link flag\n- Manual test: Fish completions suggest -A/--all-projects\n- Manual test: Bash completions work similarly\n- Manual test: Zsh completions work similarly", + "priority": "low", + "dependencies": [ + "59", + "60" + ], + "status": "done", + "subtasks": [], + "updatedAt": "2026-02-25T23:12:22.363Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-02-21T18:52:29.084Z", - "taskCount": 36, - "completedCount": 33, + "lastModified": "2026-02-25T23:12:22.364Z", + "taskCount": 70, + "completedCount": 67, "tags": [ "master" ] diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index a63b461..2a0d87d 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -3,7 +3,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply backup restore mcp approve help +set -l commands status login logout config get describe delete logs create edit apply patch backup restore mcp approve help set -l project_commands attach-server detach-server get describe delete logs create edit help # Disable file completions by default @@ -29,18 +29,20 @@ end # Helper: check if a resource type has been selected after get/describe/delete/edit set -l resources servers instances secrets templates projects users groups rbac prompts promptrequests +# All accepted resource aliases (plural + singular + short forms) +set -l resource_aliases servers server srv instances instance inst secrets secret sec templates template tpl projects project proj users user groups group rbac rbac-definition rbac-binding prompts prompt promptrequests promptrequest pr function __mcpctl_needs_resource_type set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens if $found_cmd - # Check if next token after get/describe/delete/edit is a resource type - if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests + # Check if next token after get/describe/delete/edit is a resource type or alias + if contains -- $tok $resource_aliases return 1 # resource type already present end end - if contains -- $tok get describe delete edit approve + if contains -- $tok get describe delete edit patch set found_cmd true end end @@ -50,17 +52,34 @@ function __mcpctl_needs_resource_type return 1 end +# Map any resource alias to the canonical plural form for API calls +function __mcpctl_resolve_resource + switch $argv[1] + case server srv servers; echo servers + case instance inst instances; echo instances + case secret sec secrets; echo secrets + case template tpl templates; echo templates + case project proj projects; echo projects + case user users; echo users + case group groups; echo groups + case rbac rbac-definition rbac-binding; echo rbac + case prompt prompts; echo prompts + case promptrequest promptrequests pr; echo promptrequests + case '*'; echo $argv[1] + end +end + function __mcpctl_get_resource_type set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens if $found_cmd - if contains -- $tok servers instances secrets templates projects users groups rbac prompts promptrequests - echo $tok + if contains -- $tok $resource_aliases + __mcpctl_resolve_resource $tok return end end - if contains -- $tok get describe delete edit approve + if contains -- $tok get describe delete edit patch set found_cmd true end end @@ -75,6 +94,9 @@ function __mcpctl_resource_names # Instances don't have a name field — use server.name instead if test "$resource" = "instances" mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null + else if test "$resource" = "prompts" -o "$resource" = "promptrequests" + # Use -A to include all projects, not just global + mcpctl get $resource -A -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null else mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null end @@ -139,6 +161,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a prompt request' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help' @@ -154,11 +177,11 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help' # Resource types — only when resource type not yet selected -complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type' -complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type' +complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete patch; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type' +complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers secrets projects groups rbac prompts promptrequests' -d 'Resource type' # Resource names — after resource type is selected -complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' +complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # Helper: check if attach-server/detach-server already has a server argument function __mcpctl_needs_server_arg @@ -188,6 +211,8 @@ complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_n # get/describe options complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml' +complete -c mcpctl -n "__fish_seen_subcommand_from get" -l project -d 'Filter by project' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from get" -s A -l all -d 'Show all resources across projects' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml' complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values' @@ -217,7 +242,22 @@ complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request' -# logs options +# create prompt/promptrequest options +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt promptrequest" -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt promptrequest" -l content -d 'Prompt content text' -x +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt promptrequest" -l content-file -d 'Read content from file' -rF +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt promptrequest" -l priority -d 'Priority 1-10' -xa '(seq 1 10)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from prompt" -l link -d 'Link to MCP resource (project/server:uri)' -x + +# create project --gated/--no-gated +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from project" -l gated -d 'Enable gated sessions' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and __fish_seen_subcommand_from project" -l no-gated -d 'Disable gated sessions' + +# logs: takes a server/instance name, then options +function __mcpctl_instance_names + mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null +end +complete -c mcpctl -n "__fish_seen_subcommand_from logs" -a '(__mcpctl_instance_names)' -d 'Server name' complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l since -d 'Since timestamp' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s f -l follow -d 'Follow log output' @@ -231,8 +271,52 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s i -l input -d 'In complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d 'Decryption password' -x complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail' -# approve: first arg is resource type (promptrequest only), second is name -complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_resource_type" -a 'promptrequest' -d 'Resource type' +# approve: first arg is resource type, second is name +function __mcpctl_approve_needs_type + set -l tokens (commandline -opc) + set -l found false + for tok in $tokens + if $found + if contains -- $tok promptrequest promptrequests + return 1 # type already given + end + end + if test "$tok" = "approve" + set found true + end + end + if $found + return 0 # approve found but no type yet + end + return 1 +end + +function __mcpctl_approve_needs_name + set -l tokens (commandline -opc) + set -l found_type false + for tok in $tokens + if $found_type + # next non-flag token after type is the name + if not string match -q -- '-*' $tok + return 1 # name already given + end + end + if contains -- $tok promptrequest promptrequests + set found_type true + end + end + if $found_type + return 0 # type given but no name yet + end + return 1 +end + +function __mcpctl_promptrequest_names + mcpctl get promptrequests -A -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null +end + +complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_approve_needs_type" -a 'promptrequest' -d 'Resource type' +complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_approve_needs_name" -a '(__mcpctl_promptrequest_names)' -d 'Prompt request name' # apply takes a file complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 915aeb3..d810690 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readSync } from 'node:fs'; import yaml from 'js-yaml'; import { z } from 'zod'; import type { ApiClient } from '../api-client.js'; @@ -108,6 +108,8 @@ const PromptSpecSchema = z.object({ name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), content: z.string().min(1).max(50000), projectId: z.string().optional(), + priority: z.number().int().min(1).max(10).optional(), + linkTarget: z.string().optional(), }); const ProjectSpecSchema = z.object({ @@ -115,6 +117,7 @@ const ProjectSpecSchema = z.object({ description: z.string().default(''), prompt: z.string().max(10000).default(''), proxyMode: z.enum(['direct', 'filtered']).default('direct'), + gated: z.boolean().default(true), llmProvider: z.string().optional(), llmModel: z.string().optional(), servers: z.array(z.string()).default([]), @@ -175,11 +178,27 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { }); } +function readStdin(): string { + const chunks: Buffer[] = []; + const buf = Buffer.alloc(4096); + try { + // eslint-disable-next-line no-constant-condition + while (true) { + const bytesRead = readSync(0, buf, 0, buf.length, null); + if (bytesRead === 0) break; + chunks.push(buf.subarray(0, bytesRead)); + } + } catch { + // EOF or closed pipe + } + return Buffer.concat(chunks).toString('utf-8'); +} + function loadConfigFile(path: string): ApplyConfig { - const raw = readFileSync(path, 'utf-8'); + const raw = path === '-' ? readStdin() : readFileSync(path, 'utf-8'); let parsed: unknown; - if (path.endsWith('.json')) { + if (path === '-' ? raw.trimStart().startsWith('{') : path.endsWith('.json')) { parsed = JSON.parse(raw); } else { parsed = yaml.load(raw); @@ -308,7 +327,9 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args try { const existing = await findByName(client, 'prompts', prompt.name); if (existing) { - await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content }); + const updateData: Record = { content: prompt.content }; + if (prompt.priority !== undefined) updateData.priority = prompt.priority; + await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, updateData); log(`Updated prompt: ${prompt.name}`); } else { await client.post('/api/v1/prompts', prompt); diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index b128951..199995d 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -197,6 +197,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('-d, --description ', 'Project description', '') .option('--proxy-mode ', 'Proxy mode (direct, filtered)') .option('--prompt ', 'Project-level prompt / instructions for the LLM') + .option('--gated', 'Enable gated sessions (default: true)') + .option('--no-gated', 'Disable gated sessions') .option('--server ', 'Server name (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') .action(async (name: string, opts) => { @@ -206,6 +208,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { proxyMode: opts.proxyMode ?? 'direct', }; if (opts.prompt) body.prompt = opts.prompt; + if (opts.gated !== undefined) body.gated = opts.gated as boolean; if (opts.server.length > 0) body.servers = opts.server; try { @@ -352,6 +355,8 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--project ', 'Project name to scope the prompt to') .option('--content ', 'Prompt content text') .option('--content-file ', 'Read prompt content from file') + .option('--priority ', 'Priority 1-10 (default: 5, higher = more important)') + .option('--link ', 'Link to MCP resource (format: project/server:uri)') .action(async (name: string, opts) => { let content = opts.content as string | undefined; if (opts.contentFile) { @@ -370,6 +375,16 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (!project) throw new Error(`Project '${opts.project as string}' not found`); body.projectId = project.id; } + if (opts.priority) { + const priority = Number(opts.priority); + if (isNaN(priority) || priority < 1 || priority > 10) { + throw new Error('--priority must be a number between 1 and 10'); + } + body.priority = priority; + } + if (opts.link) { + body.linkTarget = opts.link; + } const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body); log(`prompt '${prompt.name}' created (id: ${prompt.id})`); @@ -379,9 +394,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { cmd.command('promptrequest') .description('Create a prompt request (pending proposal that needs approval)') .argument('', 'Prompt request name (lowercase alphanumeric with hyphens)') - .requiredOption('--project ', 'Project name (required)') + .option('--project ', 'Project name to scope the prompt request to') .option('--content ', 'Prompt content text') .option('--content-file ', 'Read prompt content from file') + .option('--priority ', 'Priority 1-10 (default: 5, higher = more important)') .action(async (name: string, opts) => { let content = opts.content as string | undefined; if (opts.contentFile) { @@ -392,10 +408,21 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { throw new Error('--content or --content-file is required'); } - const projectName = opts.project as string; + const body: Record = { name, content }; + if (opts.project) { + body.project = opts.project; + } + if (opts.priority) { + const priority = Number(opts.priority); + if (isNaN(priority) || priority < 1 || priority > 10) { + throw new Error('--priority must be a number between 1 and 10'); + } + body.priority = priority; + } + const pr = await client.post<{ id: string; name: string }>( - `/api/v1/projects/${encodeURIComponent(projectName)}/promptrequests`, - { name, content }, + '/api/v1/promptrequests', + body, ); log(`prompt request '${pr.name}' created (id: ${pr.id})`); log(` approve with: mcpctl approve promptrequest ${pr.name}`); diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 7c8026a..a9fdc87 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -133,11 +133,15 @@ function formatInstanceDetail(instance: Record, inspect?: Recor return lines.join('\n'); } -function formatProjectDetail(project: Record): string { +function formatProjectDetail( + project: Record, + prompts: Array<{ name: string; priority: number; linkTarget: string | null }> = [], +): string { const lines: string[] = []; lines.push(`=== Project: ${project.name} ===`); lines.push(`${pad('Name:')}${project.name}`); if (project.description) lines.push(`${pad('Description:')}${project.description}`); + lines.push(`${pad('Gated:')}${project.gated ? 'yes' : 'no'}`); // Proxy config section const proxyMode = project.proxyMode as string | undefined; @@ -162,6 +166,18 @@ function formatProjectDetail(project: Record): string { } } + // Prompts section + if (prompts.length > 0) { + lines.push(''); + lines.push('Prompts:'); + const nameW = Math.max(4, ...prompts.map((p) => p.name.length)) + 2; + lines.push(` ${'NAME'.padEnd(nameW)}${'PRI'.padEnd(6)}TYPE`); + for (const p of prompts) { + const type = p.linkTarget ? 'link' : 'local'; + lines.push(` ${p.name.padEnd(nameW)}${String(p.priority).padEnd(6)}${type}`); + } + } + lines.push(''); lines.push('Metadata:'); lines.push(` ${pad('ID:', 12)}${project.id}`); @@ -586,9 +602,13 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'templates': deps.log(formatTemplateDetail(item)); break; - case 'projects': - deps.log(formatProjectDetail(item)); + case 'projects': { + const projectPrompts = await deps.client + .get>(`/api/v1/prompts?projectId=${item.id as string}`) + .catch(() => []); + deps.log(formatProjectDetail(item, projectPrompts)); break; + } case 'users': { // Fetch RBAC definitions and groups to show permissions const [rbacDefsForUser, allGroupsForUser] = await Promise.all([ diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index ba9d2fe..3d48c25 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -6,6 +6,7 @@ import { execSync } from 'node:child_process'; import yaml from 'js-yaml'; import type { ApiClient } from '../api-client.js'; import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js'; +import { reorderKeys } from '../formatters/output.js'; export interface EditCommandDeps { client: ApiClient; @@ -47,7 +48,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac']; + const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; @@ -61,7 +62,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { const current = await client.get>(`/api/v1/${resource}/${id}`); // Strip read-only fields for editor - const editable = stripInternalFields(current); + const editable = reorderKeys(stripInternalFields(current)) as Record; // Serialize to YAML const singular = resource.replace(/s$/, ''); diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 74cdc14..ea6b9ff 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -5,7 +5,7 @@ import type { Column } from '../formatters/table.js'; import { resolveResource, stripInternalFields } from './shared.js'; export interface GetCommandDeps { - fetchResource: (resource: string, id?: string) => Promise; + fetchResource: (resource: string, id?: string, opts?: { project?: string; all?: boolean }) => Promise; log: (...args: string[]) => void; } @@ -22,6 +22,7 @@ interface ProjectRow { name: string; description: string; proxyMode: string; + gated: boolean; ownerId: string; servers?: Array<{ server: { name: string } }>; } @@ -83,6 +84,7 @@ interface RbacRow { const projectColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 }, + { header: 'GATED', key: (r) => r.gated ? 'yes' : 'no', width: 6 }, { header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 }, { header: 'DESCRIPTION', key: 'description', width: 30 }, { header: 'ID', key: 'id' }, @@ -134,6 +136,10 @@ interface PromptRow { id: string; name: string; projectId: string | null; + project?: { name: string } | null; + priority: number; + linkTarget: string | null; + linkStatus: 'alive' | 'dead' | null; createdAt: string; } @@ -141,20 +147,24 @@ interface PromptRequestRow { id: string; name: string; projectId: string | null; + project?: { name: string } | null; createdBySession: string | null; createdAt: string; } const promptColumns: Column[] = [ { header: 'NAME', key: 'name' }, - { header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 }, + { header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 }, + { header: 'PRI', key: (r) => String(r.priority), width: 4 }, + { header: 'LINK', key: (r) => r.linkTarget ? r.linkTarget.split(':')[0]! : '-', width: 20 }, + { header: 'STATUS', key: (r) => r.linkStatus ?? '-', width: 6 }, { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, { header: 'ID', key: 'id' }, ]; const promptRequestColumns: Column[] = [ { header: 'NAME', key: 'name' }, - { header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 }, + { header: 'PROJECT', key: (r) => r.project?.name ?? (r.projectId ? r.projectId : '(global)'), width: 20 }, { header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 }, { header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 }, { header: 'ID', key: 'id' }, @@ -216,9 +226,14 @@ export function createGetCommand(deps: GetCommandDeps): Command { .argument('', 'resource type (servers, projects, instances)') .argument('[id]', 'specific resource ID or name') .option('-o, --output ', 'output format (table, json, yaml)', 'table') - .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { + .option('--project ', 'Filter by project') + .option('-A, --all', 'Show all (including project-scoped) resources') + .action(async (resourceArg: string, id: string | undefined, opts: { output: string; project?: string; all?: true }) => { const resource = resolveResource(resourceArg); - const items = await deps.fetchResource(resource, id); + const fetchOpts: { project?: string; all?: boolean } = {}; + if (opts.project) fetchOpts.project = opts.project; + if (opts.all) fetchOpts.all = true; + const items = await deps.fetchResource(resource, id, Object.keys(fetchOpts).length > 0 ? fetchOpts : undefined); if (opts.output === 'json') { // Apply-compatible JSON wrapped in resource key diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts index f8c4a8c..706fbcb 100644 --- a/src/cli/src/commands/project-ops.ts +++ b/src/cli/src/commands/project-ops.ts @@ -52,13 +52,12 @@ export function createApproveCommand(deps: ProjectOpsDeps): Command { return new Command('approve') .description('Approve a pending prompt request (atomic: delete request, create prompt)') .argument('', 'Resource type (promptrequest)') - .argument('', 'Prompt request name or ID') + .argument('', 'Resource name or ID') .action(async (resourceArg: string, nameOrId: string) => { const resource = resolveResource(resourceArg); if (resource !== 'promptrequests') { throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`); } - const id = await resolveNameOrId(client, 'promptrequests', nameOrId); const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {}); log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`); diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 84ae677..0beac22 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -61,8 +61,21 @@ export async function resolveNameOrId( /** Strip internal/read-only fields from an API response to make it apply-compatible. */ export function stripInternalFields(obj: Record): Record { const result = { ...obj }; - for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) { + for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId', 'summary', 'chapters']) { delete result[key]; } + // Strip relationship joins that aren't part of the resource spec (like k8s namespaces don't list deployments) + if ('servers' in result && Array.isArray(result.servers)) { + delete result.servers; + } + if ('owner' in result && typeof result.owner === 'object') { + delete result.owner; + } + if ('members' in result && Array.isArray(result.members)) { + delete result.members; + } + if ('project' in result && typeof result.project === 'object' && result.project !== null) { + delete result.project; + } return result; } diff --git a/src/cli/src/formatters/output.ts b/src/cli/src/formatters/output.ts index cc3e894..1a46078 100644 --- a/src/cli/src/formatters/output.ts +++ b/src/cli/src/formatters/output.ts @@ -6,6 +6,29 @@ export function formatJson(data: unknown): string { return JSON.stringify(data, null, 2); } -export function formatYaml(data: unknown): string { - return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd(); +/** + * Reorder object keys so that long text fields (like `content`, `prompt`) + * come last. This makes YAML output more readable when content spans + * multiple lines. + */ +export function reorderKeys(obj: unknown): unknown { + if (Array.isArray(obj)) return obj.map(reorderKeys); + if (obj !== null && typeof obj === 'object') { + const rec = obj as Record; + const lastKeys = ['content', 'prompt']; + const ordered: Record = {}; + for (const key of Object.keys(rec)) { + if (!lastKeys.includes(key)) ordered[key] = reorderKeys(rec[key]); + } + for (const key of lastKeys) { + if (key in rec) ordered[key] = rec[key]; + } + return ordered; + } + return obj; +} + +export function formatYaml(data: unknown): string { + const reordered = reorderKeys(data); + return yaml.dump(reordered, { lineWidth: 120, noRefs: true }).trimEnd(); } diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index d36b5f1..12c4e58 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -14,6 +14,7 @@ import { createBackupCommand, createRestoreCommand } from './commands/backup.js' import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js'; import { createMcpCommand } from './commands/mcp.js'; +import { createPatchCommand } from './commands/patch.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -54,8 +55,8 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); - const fetchResource = async (resource: string, nameOrId?: string): Promise => { - const projectName = program.opts().project as string | undefined; + const fetchResource = async (resource: string, nameOrId?: string, opts?: { project?: string; all?: boolean }): Promise => { + const projectName = opts?.project ?? program.opts().project as string | undefined; // --project scoping for servers and instances if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) { @@ -70,6 +71,17 @@ export function createProgram(): Command { return allInstances.filter((inst) => serverIds.has(inst.serverId)); } + // --project scoping for prompts and promptrequests + if (!nameOrId && (resource === 'prompts' || resource === 'promptrequests')) { + if (projectName) { + return client.get(`/api/v1/${resource}?project=${encodeURIComponent(projectName)}`); + } + // Default: global-only. --all (-A) shows everything. + if (!opts?.all) { + return client.get(`/api/v1/${resource}?scope=global`); + } + } + if (nameOrId) { // Glob pattern — use query param filtering if (nameOrId.includes('*')) { @@ -134,6 +146,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createPatchCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createBackupCommand({ client, log: (...args) => console.log(...args), diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts index 2598ca9..081d6ce 100644 --- a/src/cli/tests/commands/create.test.ts +++ b/src/cli/tests/commands/create.test.ts @@ -447,4 +447,114 @@ describe('create command', () => { }); }); }); + + describe('create prompt', () => { + it('creates a prompt with content', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'test-prompt' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['prompt', 'test-prompt', '--content', 'Hello world'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', { + name: 'test-prompt', + content: 'Hello world', + }); + expect(output.join('\n')).toContain("prompt 'test-prompt' created"); + }); + + it('requires content or content-file', async () => { + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['prompt', 'no-content'], { from: 'user' }), + ).rejects.toThrow('--content or --content-file is required'); + }); + + it('--priority sets prompt priority', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'pri-prompt' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['prompt', 'pri-prompt', '--content', 'x', '--priority', '8'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({ + priority: 8, + })); + }); + + it('--priority validates range 1-10', async () => { + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '15'], { from: 'user' }), + ).rejects.toThrow('--priority must be a number between 1 and 10'); + }); + + it('--priority rejects zero', async () => { + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--priority', '0'], { from: 'user' }), + ).rejects.toThrow('--priority must be a number between 1 and 10'); + }); + + it('--link sets linkTarget', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'linked' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['prompt', 'linked', '--content', 'x', '--link', 'proj/srv:docmost://pages/abc'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({ + linkTarget: 'proj/srv:docmost://pages/abc', + })); + }); + + it('--project resolves project name to ID', async () => { + vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-project' }] as never); + vi.mocked(client.post).mockResolvedValueOnce({ id: 'p-1', name: 'scoped' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['prompt', 'scoped', '--content', 'x', '--project', 'my-project'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/prompts', expect.objectContaining({ + projectId: 'proj-1', + })); + }); + + it('--project throws when project not found', async () => { + vi.mocked(client.get).mockResolvedValueOnce([] as never); + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['prompt', 'bad', '--content', 'x', '--project', 'nope'], { from: 'user' }), + ).rejects.toThrow("Project 'nope' not found"); + }); + }); + + describe('create promptrequest', () => { + it('creates a prompt request with priority', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'r-1', name: 'req' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['promptrequest', 'req', '--content', 'proposal', '--priority', '7'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/promptrequests', expect.objectContaining({ + name: 'req', + content: 'proposal', + priority: 7, + })); + }); + }); + + describe('create project', () => { + it('creates a project with --gated', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'gated-proj' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['project', 'gated-proj', '--gated'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ + gated: true, + })); + }); + + it('creates a project with --no-gated', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'proj-1', name: 'open-proj' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['project', 'open-proj', '--no-gated'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ + gated: false, + })); + }); + }); }); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index 1ef2c74..4480df4 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -20,7 +20,7 @@ describe('get command', () => { const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'servers']); - expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined); expect(deps.output[0]).toContain('NAME'); expect(deps.output[0]).toContain('TRANSPORT'); expect(deps.output.join('\n')).toContain('slack'); @@ -31,14 +31,14 @@ describe('get command', () => { const deps = makeDeps([]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'srv']); - expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('servers', undefined, undefined); }); it('passes ID when provided', async () => { const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'servers', 'srv-1']); - expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); + expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1', undefined); }); it('outputs apply-compatible JSON format', async () => { @@ -94,7 +94,7 @@ describe('get command', () => { const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'users']); - expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined); const text = deps.output.join('\n'); expect(text).toContain('EMAIL'); expect(text).toContain('NAME'); @@ -110,7 +110,7 @@ describe('get command', () => { const deps = makeDeps([]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'user']); - expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined, undefined); }); it('lists groups with correct columns', async () => { @@ -126,7 +126,7 @@ describe('get command', () => { const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'groups']); - expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined); const text = deps.output.join('\n'); expect(text).toContain('NAME'); expect(text).toContain('MEMBERS'); @@ -141,7 +141,7 @@ describe('get command', () => { const deps = makeDeps([]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'group']); - expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined, undefined); }); it('lists rbac definitions with correct columns', async () => { @@ -156,7 +156,7 @@ describe('get command', () => { const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac']); - expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined); const text = deps.output.join('\n'); expect(text).toContain('NAME'); expect(text).toContain('SUBJECTS'); @@ -170,7 +170,7 @@ describe('get command', () => { const deps = makeDeps([]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'rbac-definition']); - expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined); + expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined, undefined); }); it('lists projects with new columns', async () => { @@ -251,4 +251,87 @@ describe('get command', () => { await cmd.parseAsync(['node', 'test', 'rbac']); expect(deps.output[0]).toContain('No rbac found'); }); + + it('lists prompts with project name column', async () => { + const deps = makeDeps([ + { id: 'p-1', name: 'debug-guide', projectId: 'proj-1', project: { name: 'smart-home' }, createdAt: '2025-01-01T00:00:00Z' }, + { id: 'p-2', name: 'global-rules', projectId: null, project: null, createdAt: '2025-01-01T00:00:00Z' }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts']); + + const text = deps.output.join('\n'); + expect(text).toContain('NAME'); + expect(text).toContain('PROJECT'); + expect(text).toContain('debug-guide'); + expect(text).toContain('smart-home'); + expect(text).toContain('global-rules'); + expect(text).toContain('(global)'); + }); + + it('lists promptrequests with project name column', async () => { + const deps = makeDeps([ + { id: 'pr-1', name: 'new-rule', projectId: 'proj-1', project: { name: 'my-project' }, createdBySession: 'sess-abc123def456', createdAt: '2025-01-01T00:00:00Z' }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'promptrequests']); + + const text = deps.output.join('\n'); + expect(text).toContain('new-rule'); + expect(text).toContain('my-project'); + expect(text).toContain('sess-abc123d'); + }); + + it('passes --project option to fetchResource', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'smart-home']); + + expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'smart-home' }); + }); + + it('does not pass project when --project is not specified', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts']); + + expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined); + }); + + it('passes --all flag to fetchResource', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts', '-A']); + + expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { all: true }); + }); + + it('passes both --project and --all when both given', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts', '--project', 'my-proj', '-A']); + + expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, { project: 'my-proj', all: true }); + }); + + it('resolves prompt alias', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompt']); + expect(deps.fetchResource).toHaveBeenCalledWith('prompts', undefined, undefined); + }); + + it('resolves pr alias to promptrequests', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'pr']); + expect(deps.fetchResource).toHaveBeenCalledWith('promptrequests', undefined, undefined); + }); + + it('shows no results message for empty prompts list', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'prompts']); + expect(deps.output[0]).toContain('No prompts found'); + }); }); diff --git a/src/cli/tests/e2e/cli-commands.test.ts b/src/cli/tests/e2e/cli-commands.test.ts index 26e4a91..fe6a5db 100644 --- a/src/cli/tests/e2e/cli-commands.test.ts +++ b/src/cli/tests/e2e/cli-commands.test.ts @@ -47,7 +47,7 @@ describe('CLI command registration (e2e)', () => { expect(subcommands).toContain('reset'); }); - it('create command has user, group, rbac subcommands', () => { + it('create command has user, group, rbac, prompt, promptrequest subcommands', () => { const program = createProgram(); const create = program.commands.find((c) => c.name() === 'create'); expect(create).toBeDefined(); @@ -59,6 +59,24 @@ describe('CLI command registration (e2e)', () => { expect(subcommands).toContain('user'); expect(subcommands).toContain('group'); expect(subcommands).toContain('rbac'); + expect(subcommands).toContain('prompt'); + expect(subcommands).toContain('promptrequest'); + }); + + it('get command accepts --project option', () => { + const program = createProgram(); + const get = program.commands.find((c) => c.name() === 'get'); + expect(get).toBeDefined(); + + const projectOpt = get!.options.find((o) => o.long === '--project'); + expect(projectOpt).toBeDefined(); + expect(projectOpt!.description).toContain('project'); + }); + + it('program-level --project option is defined', () => { + const program = createProgram(); + const projectOpt = program.options.find((o) => o.long === '--project'); + expect(projectOpt).toBeDefined(); }); it('displays version', () => { diff --git a/src/db/prisma/migrations/20260225220000_add_gated_prompt_priority_summary_link/migration.sql b/src/db/prisma/migrations/20260225220000_add_gated_prompt_priority_summary_link/migration.sql new file mode 100644 index 0000000..e014d9c --- /dev/null +++ b/src/db/prisma/migrations/20260225220000_add_gated_prompt_priority_summary_link/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable: Add gated flag to Project +ALTER TABLE "Project" ADD COLUMN "gated" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable: Add priority, summary, chapters, linkTarget to Prompt +ALTER TABLE "Prompt" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 5; +ALTER TABLE "Prompt" ADD COLUMN "summary" TEXT; +ALTER TABLE "Prompt" ADD COLUMN "chapters" JSONB; +ALTER TABLE "Prompt" ADD COLUMN "linkTarget" TEXT; + +-- AlterTable: Add priority to PromptRequest +ALTER TABLE "PromptRequest" ADD COLUMN "priority" INTEGER NOT NULL DEFAULT 5; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index c88fd15..aaace79 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -172,6 +172,7 @@ model Project { description String @default("") prompt String @default("") proxyMode String @default("direct") + gated Boolean @default(true) llmProvider String? llmModel String? ownerId String @@ -233,13 +234,17 @@ enum InstanceStatus { // ── Prompts (approved content resources) ── model Prompt { - id String @id @default(cuid()) - name String - content String @db.Text - projectId String? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + priority Int @default(5) + summary String? @db.Text + chapters Json? + linkTarget String? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) @@ -254,6 +259,7 @@ model PromptRequest { name String content String @db.Text projectId String? + priority Int @default(5) createdBySession String? createdByUserId String? createdAt DateTime @default(now()) diff --git a/src/mcpd/src/bootstrap/system-project.ts b/src/mcpd/src/bootstrap/system-project.ts new file mode 100644 index 0000000..444902e --- /dev/null +++ b/src/mcpd/src/bootstrap/system-project.ts @@ -0,0 +1,102 @@ +/** + * Bootstrap the mcpctl-system project and its system prompts. + * + * This runs on every mcpd startup and uses upserts to be idempotent. + * System prompts are editable by users but will be re-created if deleted. + */ + +import type { PrismaClient } from '@prisma/client'; + +/** Well-known owner ID for system-managed resources. */ +export const SYSTEM_OWNER_ID = 'system'; + +/** Well-known project name for system prompts. */ +export const SYSTEM_PROJECT_NAME = 'mcpctl-system'; + +interface SystemPromptDef { + name: string; + priority: number; + content: string; +} + +const SYSTEM_PROMPTS: SystemPromptDef[] = [ + { + name: 'gate-instructions', + priority: 10, + content: `This project uses a gated session. Before you can access tools, you must describe your current task by calling begin_session with 3-7 keywords. + +After calling begin_session, you will receive: +1. Relevant project prompts matched to your keywords +2. A list of other available prompts +3. Full access to all project tools + +Choose your keywords carefully — they determine which context you receive.`, + }, + { + name: 'gate-encouragement', + priority: 10, + content: `If any of the listed prompts seem relevant to your work, or if you encounter unfamiliar patterns, conventions, or constraints during implementation, use read_prompts({ tags: [...] }) to retrieve them. + +It is better to check and not need it than to proceed without important context. The project maintainers have documented common pitfalls, architecture decisions, and required patterns — taking 10 seconds to retrieve a prompt can save hours of rework.`, + }, + { + name: 'gate-intercept-preamble', + priority: 10, + content: `The following project context was automatically retrieved based on your tool call. You bypassed the begin_session step, so this context was matched using keywords extracted from your tool invocation. + +Review this context carefully — it may contain important guidelines, constraints, or patterns relevant to your work. If you need more context, use read_prompts({ tags: [...] }) at any time.`, + }, + { + name: 'session-greeting', + priority: 10, + content: `Welcome to this project. To get started, call begin_session with keywords describing your task. + +Example: begin_session({ tags: ["zigbee", "pairing", "mqtt"] }) + +This will load relevant project context, policies, and guidelines tailored to your work.`, + }, +]; + +/** + * Ensure the mcpctl-system project and its system prompts exist. + * Uses upserts so this is safe to call on every startup. + */ +export async function bootstrapSystemProject(prisma: PrismaClient): Promise { + // Upsert the system project + const project = await prisma.project.upsert({ + where: { name: SYSTEM_PROJECT_NAME }, + create: { + name: SYSTEM_PROJECT_NAME, + description: 'System prompts for mcpctl gating and session management', + prompt: '', + proxyMode: 'direct', + gated: false, + ownerId: SYSTEM_OWNER_ID, + }, + update: {}, // Don't overwrite user edits to the project itself + }); + + // Upsert each system prompt (re-create if deleted, don't overwrite content if edited) + for (const def of SYSTEM_PROMPTS) { + const existing = await prisma.prompt.findFirst({ + where: { name: def.name, projectId: project.id }, + }); + + if (!existing) { + await prisma.prompt.create({ + data: { + name: def.name, + content: def.content, + priority: def.priority, + projectId: project.id, + }, + }); + } + // If the prompt exists, don't overwrite — user may have edited it + } +} + +/** Get the names of all system prompts (for delete protection). */ +export function getSystemPromptNames(): string[] { + return SYSTEM_PROMPTS.map((p) => p.name); +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index a0b2b6d..e91c360 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -20,6 +20,7 @@ import { } from './repositories/index.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; +import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, SecretService, @@ -235,6 +236,9 @@ async function main(): Promise { }); await seedTemplates(prisma, templates); + // Bootstrap system project and prompts + await bootstrapSystemProject(prisma); + // Repositories const serverRepo = new McpServerRepository(prisma); const secretRepo = new SecretRepository(prisma); diff --git a/src/mcpd/src/repositories/prompt-request.repository.ts b/src/mcpd/src/repositories/prompt-request.repository.ts index 49fddd6..bc1e2f1 100644 --- a/src/mcpd/src/repositories/prompt-request.repository.ts +++ b/src/mcpd/src/repositories/prompt-request.repository.ts @@ -2,10 +2,12 @@ import type { PrismaClient, PromptRequest } from '@prisma/client'; export interface IPromptRequestRepository { findAll(projectId?: string): Promise; + findGlobal(): Promise; findById(id: string): Promise; findByNameAndProject(name: string, projectId: string | null): Promise; findBySession(sessionId: string, projectId?: string): Promise; - create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise; + create(data: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string }): Promise; + update(id: string, data: { content?: string; priority?: number }): Promise; delete(id: string): Promise; } @@ -13,13 +15,23 @@ export class PromptRequestRepository implements IPromptRequestRepository { constructor(private readonly prisma: PrismaClient) {} async findAll(projectId?: string): Promise { + const include = { project: { select: { name: true } } }; if (projectId !== undefined) { return this.prisma.promptRequest.findMany({ where: { OR: [{ projectId }, { projectId: null }] }, + include, orderBy: { createdAt: 'desc' }, }); } - return this.prisma.promptRequest.findMany({ orderBy: { createdAt: 'desc' } }); + return this.prisma.promptRequest.findMany({ include, orderBy: { createdAt: 'desc' } }); + } + + async findGlobal(): Promise { + return this.prisma.promptRequest.findMany({ + where: { projectId: null }, + include: { project: { select: { name: true } } }, + orderBy: { createdAt: 'desc' }, + }); } async findById(id: string): Promise { @@ -43,10 +55,14 @@ export class PromptRequestRepository implements IPromptRequestRepository { }); } - async create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise { + async create(data: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string }): Promise { return this.prisma.promptRequest.create({ data }); } + async update(id: string, data: { content?: string; priority?: number }): Promise { + return this.prisma.promptRequest.update({ where: { id }, data }); + } + async delete(id: string): Promise { await this.prisma.promptRequest.delete({ where: { id } }); } diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index b09eb9f..055354b 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -2,10 +2,11 @@ import type { PrismaClient, Prompt } from '@prisma/client'; export interface IPromptRepository { findAll(projectId?: string): Promise; + findGlobal(): Promise; findById(id: string): Promise; findByNameAndProject(name: string, projectId: string | null): Promise; - create(data: { name: string; content: string; projectId?: string }): Promise; - update(id: string, data: { content?: string }): Promise; + create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise; + update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise; delete(id: string): Promise; } @@ -13,14 +14,24 @@ export class PromptRepository implements IPromptRepository { constructor(private readonly prisma: PrismaClient) {} async findAll(projectId?: string): Promise { + const include = { project: { select: { name: true } } }; if (projectId !== undefined) { // Project-scoped + global prompts return this.prisma.prompt.findMany({ where: { OR: [{ projectId }, { projectId: null }] }, + include, orderBy: { name: 'asc' }, }); } - return this.prisma.prompt.findMany({ orderBy: { name: 'asc' } }); + return this.prisma.prompt.findMany({ include, orderBy: { name: 'asc' } }); + } + + async findGlobal(): Promise { + return this.prisma.prompt.findMany({ + where: { projectId: null }, + include: { project: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); } async findById(id: string): Promise { @@ -33,11 +44,11 @@ export class PromptRepository implements IPromptRepository { }); } - async create(data: { name: string; content: string; projectId?: string }): Promise { + async create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise { return this.prisma.prompt.create({ data }); } - async update(id: string, data: { content?: string }): Promise { + async update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise { return this.prisma.prompt.update({ where: { id }, data }); } diff --git a/src/mcpd/src/routes/prompts.ts b/src/mcpd/src/routes/prompts.ts index afb3b46..cf912ae 100644 --- a/src/mcpd/src/routes/prompts.ts +++ b/src/mcpd/src/routes/prompts.ts @@ -1,6 +1,56 @@ import type { FastifyInstance } from 'fastify'; +import type { Prompt } from '@prisma/client'; import type { PromptService } from '../services/prompt.service.js'; -import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js'; + +type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null }; + +/** + * Enrich prompts with linkStatus by checking if the target project/server exists. + * This is a structural check (does the target exist?) — not a runtime probe. + */ +async function enrichWithLinkStatus( + prompts: Prompt[], + projectRepo: IProjectRepository, +): Promise { + // Cache project lookups to avoid repeated DB queries + const projectCache = new Map(); + + const results: PromptWithLinkStatus[] = []; + + for (const p of prompts) { + if (!p.linkTarget) { + results.push({ ...p, linkStatus: null } as PromptWithLinkStatus); + continue; + } + + try { + // Parse: project/server:uri + const slashIdx = p.linkTarget.indexOf('/'); + if (slashIdx < 1) { results.push({ ...p, linkStatus: 'dead' as const }); continue; } + const projectName = p.linkTarget.slice(0, slashIdx); + const rest = p.linkTarget.slice(slashIdx + 1); + const colonIdx = rest.indexOf(':'); + if (colonIdx < 1) { results.push({ ...p, linkStatus: 'dead' as const }); continue; } + const serverName = rest.slice(0, colonIdx); + + // Check if project exists (cached) + if (!projectCache.has(projectName)) { + projectCache.set(projectName, await projectRepo.findByName(projectName)); + } + const project = projectCache.get(projectName); + if (!project) { results.push({ ...p, linkStatus: 'dead' as const }); continue; } + + // Check if server is linked to that project + const hasServer = project.servers.some((s) => s.server.name === serverName); + results.push({ ...p, linkStatus: hasServer ? 'alive' as const : 'dead' as const }); + } catch { + results.push({ ...p, linkStatus: 'dead' as const }); + } + } + + return results; +} export function registerPromptRoutes( app: FastifyInstance, @@ -9,12 +59,29 @@ export function registerPromptRoutes( ): void { // ── Prompts (approved) ── - app.get('/api/v1/prompts', async () => { - return service.listPrompts(); + app.get<{ Querystring: { project?: string; scope?: string; projectId?: string } }>('/api/v1/prompts', async (request) => { + let prompts: Prompt[]; + const projectName = request.query.project; + if (projectName) { + const project = await projectRepo.findByName(projectName); + if (!project) { + throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 }); + } + prompts = await service.listPrompts(project.id); + } else if (request.query.projectId) { + prompts = await service.listPrompts(request.query.projectId); + } else if (request.query.scope === 'global') { + prompts = await service.listGlobalPrompts(); + } else { + prompts = await service.listPrompts(); + } + return enrichWithLinkStatus(prompts, projectRepo); }); app.get<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => { - return service.getPrompt(request.params.id); + const prompt = await service.getPrompt(request.params.id); + const [enriched] = await enrichWithLinkStatus([prompt], projectRepo); + return enriched; }); app.post('/api/v1/prompts', async (request, reply) => { @@ -34,7 +101,18 @@ export function registerPromptRoutes( // ── Prompt Requests (pending proposals) ── - app.get('/api/v1/promptrequests', async () => { + app.get<{ Querystring: { project?: string; scope?: string } }>('/api/v1/promptrequests', async (request) => { + const projectName = request.query.project; + if (projectName) { + const project = await projectRepo.findByName(projectName); + if (!project) { + throw Object.assign(new Error(`Project not found: ${projectName}`), { statusCode: 404 }); + } + return service.listPromptRequests(project.id); + } + if (request.query.scope === 'global') { + return service.listGlobalPromptRequests(); + } return service.listPromptRequests(); }); @@ -42,16 +120,59 @@ export function registerPromptRoutes( return service.getPromptRequest(request.params.id); }); + app.put<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request) => { + return service.updatePromptRequest(request.params.id, request.body); + }); + app.delete<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request, reply) => { await service.deletePromptRequest(request.params.id); reply.code(204); }); + app.post('/api/v1/promptrequests', async (request, reply) => { + const body = request.body as Record; + // Resolve project name → ID if provided + if (body.project && typeof body.project === 'string') { + const project = await projectRepo.findByName(body.project); + if (!project) { + throw Object.assign(new Error(`Project not found: ${body.project}`), { statusCode: 404 }); + } + const { project: _, ...rest } = body; + const req = await service.propose({ ...rest, projectId: project.id }); + reply.code(201); + return req; + } + const req = await service.propose(body); + reply.code(201); + return req; + }); + // Approve: atomic delete request → create prompt app.post<{ Params: { id: string } }>('/api/v1/promptrequests/:id/approve', async (request) => { return service.approve(request.params.id); }); + // Regenerate summary/chapters for a prompt + app.post<{ Params: { id: string } }>('/api/v1/prompts/:id/regenerate-summary', async (request) => { + return service.regenerateSummary(request.params.id); + }); + + // Compact prompt index for gating LLM (name, priority, summary, chapters) + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/prompt-index', async (request) => { + const project = await projectRepo.findByName(request.params.name); + if (!project) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + const prompts = await service.listPrompts(project.id); + return prompts.map((p) => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + linkTarget: p.linkTarget, + })); + }); + // ── Project-scoped endpoints (for mcplocal) ── // Visible prompts: approved + session's pending requests diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index 8c35492..d21ae0a 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -56,6 +56,7 @@ export class ProjectService { prompt: data.prompt, ownerId, proxyMode: data.proxyMode, + gated: data.gated, ...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}), ...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}), }); @@ -80,6 +81,7 @@ export class ProjectService { if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode; if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider; if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel; + if (data.gated !== undefined) updateData['gated'] = data.gated; // Update scalar fields if any changed if (Object.keys(updateData).length > 0) { diff --git a/src/mcpd/src/services/prompt-summary.service.ts b/src/mcpd/src/services/prompt-summary.service.ts new file mode 100644 index 0000000..c331bbd --- /dev/null +++ b/src/mcpd/src/services/prompt-summary.service.ts @@ -0,0 +1,96 @@ +/** + * Generates summary and chapters for prompt content. + * + * Uses regex-based extraction by default (first sentence + markdown headings). + * An optional LLM generator can be injected for higher-quality summaries. + */ + +const MAX_SUMMARY_WORDS = 20; +const HEADING_RE = /^#{1,6}\s+(.+)$/gm; + +export interface LlmSummaryGenerator { + generate(content: string): Promise<{ summary: string; chapters: string[] }>; +} + +export class PromptSummaryService { + constructor(private readonly llmGenerator: LlmSummaryGenerator | null = null) {} + + async generateSummary(content: string): Promise<{ summary: string; chapters: string[] }> { + if (this.llmGenerator) { + try { + return await this.llmGenerator.generate(content); + } catch { + // Fall back to regex on LLM failure + } + } + return this.generateWithRegex(content); + } + + generateWithRegex(content: string): { summary: string; chapters: string[] } { + return { + summary: extractFirstSentence(content, MAX_SUMMARY_WORDS), + chapters: extractHeadings(content), + }; + } +} + +/** + * Extract the first sentence, truncated to maxWords. + * Strips markdown formatting. + */ +export function extractFirstSentence(content: string, maxWords: number): string { + if (!content.trim()) return ''; + + // Skip leading headings and blank lines to find first content line + const lines = content.split('\n'); + let firstContent = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (trimmed.startsWith('#')) continue; + firstContent = trimmed; + break; + } + + if (!firstContent) { + // All lines are headings or empty — use first heading text + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('#')) { + firstContent = trimmed.replace(/^#+\s*/, ''); + break; + } + } + } + + if (!firstContent) return ''; + + // Strip basic markdown formatting + firstContent = firstContent + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/\[(.+?)\]\(.+?\)/g, '$1'); + + // Split on sentence boundaries + const sentenceEnd = firstContent.search(/[.!?]\s|[.!?]$/); + const sentence = sentenceEnd >= 0 ? firstContent.slice(0, sentenceEnd + 1) : firstContent; + + // Truncate to maxWords + const words = sentence.split(/\s+/); + if (words.length <= maxWords) return sentence; + return words.slice(0, maxWords).join(' ') + '...'; +} + +/** + * Extract markdown headings as chapter titles. + */ +export function extractHeadings(content: string): string[] { + const headings: string[] = []; + let match: RegExpExecArray | null; + while ((match = HEADING_RE.exec(content)) !== null) { + const heading = match[1]!.trim(); + if (heading) headings.push(heading); + } + return headings; +} diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index b815cc7..143672c 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -2,22 +2,34 @@ import type { Prompt, PromptRequest } from '@prisma/client'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js'; import type { IProjectRepository } from '../repositories/project.repository.js'; -import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema } from '../validation/prompt.schema.js'; +import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js'; import { NotFoundError } from './mcp-server.service.js'; +import type { PromptSummaryService } from './prompt-summary.service.js'; +import { SYSTEM_PROJECT_NAME } from '../bootstrap/system-project.js'; export class PromptService { + private summaryService: PromptSummaryService | null = null; + constructor( private readonly promptRepo: IPromptRepository, private readonly promptRequestRepo: IPromptRequestRepository, private readonly projectRepo: IProjectRepository, ) {} + setSummaryService(service: PromptSummaryService): void { + this.summaryService = service; + } + // ── Prompt CRUD ── async listPrompts(projectId?: string): Promise { return this.promptRepo.findAll(projectId); } + async listGlobalPrompts(): Promise { + return this.promptRepo.findGlobal(); + } + async getPrompt(id: string): Promise { const prompt = await this.promptRepo.findById(id); if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`); @@ -32,24 +44,58 @@ export class PromptService { if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); } - const createData: { name: string; content: string; projectId?: string } = { + const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = { name: data.name, content: data.content, }; if (data.projectId !== undefined) createData.projectId = data.projectId; - return this.promptRepo.create(createData); + if (data.priority !== undefined) createData.priority = data.priority; + if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; + const prompt = await this.promptRepo.create(createData); + // Auto-generate summary/chapters (non-blocking — don't fail create if summary fails) + if (this.summaryService && !data.linkTarget) { + this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); + } + return prompt; } async updatePrompt(id: string, input: unknown): Promise { const data = UpdatePromptSchema.parse(input); await this.getPrompt(id); - const updateData: { content?: string } = {}; + const updateData: { content?: string; priority?: number } = {}; if (data.content !== undefined) updateData.content = data.content; - return this.promptRepo.update(id, updateData); + if (data.priority !== undefined) updateData.priority = data.priority; + const prompt = await this.promptRepo.update(id, updateData); + // Regenerate summary when content changes + if (this.summaryService && data.content !== undefined && !prompt.linkTarget) { + this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); + } + return prompt; + } + + async regenerateSummary(id: string): Promise { + const prompt = await this.getPrompt(id); + if (!this.summaryService) { + throw new Error('Summary generation not available'); + } + return this.generateAndStoreSummary(prompt.id, prompt.content); + } + + private async generateAndStoreSummary(id: string, content: string): Promise { + if (!this.summaryService) throw new Error('No summary service'); + const { summary, chapters } = await this.summaryService.generateSummary(content); + return this.promptRepo.update(id, { summary, chapters }); } async deletePrompt(id: string): Promise { - await this.getPrompt(id); + const prompt = await this.getPrompt(id); + // Protect system prompts from deletion + if (prompt.projectId) { + const project = await this.projectRepo.findById(prompt.projectId); + if (project?.name === SYSTEM_PROJECT_NAME) { + throw Object.assign(new Error('Cannot delete system prompts'), { statusCode: 403 }); + } + } await this.promptRepo.delete(id); } @@ -59,12 +105,25 @@ export class PromptService { return this.promptRequestRepo.findAll(projectId); } + async listGlobalPromptRequests(): Promise { + return this.promptRequestRepo.findGlobal(); + } + async getPromptRequest(id: string): Promise { const req = await this.promptRequestRepo.findById(id); if (req === null) throw new NotFoundError(`PromptRequest not found: ${id}`); return req; } + async updatePromptRequest(id: string, input: unknown): Promise { + await this.getPromptRequest(id); + const data = UpdatePromptRequestSchema.parse(input); + const updateData: { content?: string; priority?: number } = {}; + if (data.content !== undefined) updateData.content = data.content; + if (data.priority !== undefined) updateData.priority = data.priority; + return this.promptRequestRepo.update(id, updateData); + } + async deletePromptRequest(id: string): Promise { await this.getPromptRequest(id); await this.promptRequestRepo.delete(id); @@ -80,11 +139,12 @@ export class PromptService { if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); } - const createData: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string } = { + const createData: { name: string; content: string; projectId?: string; priority?: number; createdBySession?: string; createdByUserId?: string } = { name: data.name, content: data.content, }; if (data.projectId !== undefined) createData.projectId = data.projectId; + if (data.priority !== undefined) createData.priority = data.priority; if (data.createdBySession !== undefined) createData.createdBySession = data.createdBySession; if (data.createdByUserId !== undefined) createData.createdByUserId = data.createdByUserId; return this.promptRequestRepo.create(createData); @@ -95,12 +155,13 @@ export class PromptService { async approve(requestId: string): Promise { const req = await this.getPromptRequest(requestId); - // Create the approved prompt - const createData: { name: string; content: string; projectId?: string } = { + // Create the approved prompt (carry priority from request) + const createData: { name: string; content: string; projectId?: string; priority?: number } = { name: req.name, content: req.content, }; if (req.projectId !== null) createData.projectId = req.projectId; + if (req.priority !== 5) createData.priority = req.priority; const prompt = await this.promptRepo.create(createData); diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts index acf7c32..9529549 100644 --- a/src/mcpd/src/validation/project.schema.ts +++ b/src/mcpd/src/validation/project.schema.ts @@ -5,6 +5,7 @@ export const CreateProjectSchema = z.object({ description: z.string().max(1000).default(''), prompt: z.string().max(10000).default(''), proxyMode: z.enum(['direct', 'filtered']).default('direct'), + gated: z.boolean().default(true), llmProvider: z.string().max(100).optional(), llmModel: z.string().max(100).optional(), servers: z.array(z.string().min(1)).default([]), @@ -17,6 +18,7 @@ export const UpdateProjectSchema = z.object({ description: z.string().max(1000).optional(), prompt: z.string().max(10000).optional(), proxyMode: z.enum(['direct', 'filtered']).optional(), + gated: z.boolean().optional(), llmProvider: z.string().max(100).nullable().optional(), llmModel: z.string().max(100).nullable().optional(), servers: z.array(z.string().min(1)).optional(), diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index 1b8484f..f1b760e 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -1,23 +1,36 @@ import { z } from 'zod'; +const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/; + export const CreatePromptSchema = z.object({ name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), content: z.string().min(1).max(50000), projectId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(), }); export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), + priority: z.number().int().min(1).max(10).optional(), + // linkTarget intentionally excluded — links are immutable }); export const CreatePromptRequestSchema = z.object({ name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), content: z.string().min(1).max(50000), projectId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), createdBySession: z.string().optional(), createdByUserId: z.string().optional(), }); +export const UpdatePromptRequestSchema = z.object({ + content: z.string().min(1).max(50000).optional(), + priority: z.number().int().min(1).max(10).optional(), +}); + export type CreatePromptInput = z.infer; export type UpdatePromptInput = z.infer; export type CreatePromptRequestInput = z.infer; +export type UpdatePromptRequestInput = z.infer; diff --git a/src/mcpd/tests/bootstrap-system-project.test.ts b/src/mcpd/tests/bootstrap-system-project.test.ts new file mode 100644 index 0000000..e56899d --- /dev/null +++ b/src/mcpd/tests/bootstrap-system-project.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { bootstrapSystemProject, SYSTEM_PROJECT_NAME, SYSTEM_OWNER_ID, getSystemPromptNames } from '../src/bootstrap/system-project.js'; +import type { PrismaClient } from '@prisma/client'; + +function mockPrisma(): PrismaClient { + const prompts = new Map(); + let promptIdCounter = 1; + + return { + project: { + upsert: vi.fn(async (args: { where: { name: string }; create: Record; update: Record }) => ({ + id: 'sys-proj-id', + name: args.where.name, + ...args.create, + })), + }, + prompt: { + findFirst: vi.fn(async (args: { where: { name: string; projectId: string } }) => { + return prompts.get(`${args.where.projectId}:${args.where.name}`) ?? null; + }), + create: vi.fn(async (args: { data: { name: string; content: string; priority: number; projectId: string } }) => { + const id = `prompt-${promptIdCounter++}`; + const prompt = { id, ...args.data }; + prompts.set(`${args.data.projectId}:${args.data.name}`, prompt); + return prompt; + }), + }, + } as unknown as PrismaClient; +} + +describe('bootstrapSystemProject', () => { + let prisma: PrismaClient; + + beforeEach(() => { + prisma = mockPrisma(); + }); + + it('creates the mcpctl-system project via upsert', async () => { + await bootstrapSystemProject(prisma); + + expect(prisma.project.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: SYSTEM_PROJECT_NAME }, + create: expect.objectContaining({ + name: SYSTEM_PROJECT_NAME, + ownerId: SYSTEM_OWNER_ID, + gated: false, + }), + update: {}, + }), + ); + }); + + it('creates all system prompts', async () => { + await bootstrapSystemProject(prisma); + + const expectedNames = getSystemPromptNames(); + expect(expectedNames.length).toBeGreaterThanOrEqual(4); + + for (const name of expectedNames) { + expect(prisma.prompt.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name, projectId: 'sys-proj-id' }, + }), + ); + } + + expect(prisma.prompt.create).toHaveBeenCalledTimes(expectedNames.length); + }); + + it('creates system prompts with priority 10', async () => { + await bootstrapSystemProject(prisma); + + const createCalls = vi.mocked(prisma.prompt.create).mock.calls; + for (const call of createCalls) { + const data = (call[0] as { data: { priority: number } }).data; + expect(data.priority).toBe(10); + } + }); + + it('does not re-create existing prompts (idempotent)', async () => { + // First call creates everything + await bootstrapSystemProject(prisma); + const firstCallCount = vi.mocked(prisma.prompt.create).mock.calls.length; + + // Second call — prompts already exist in mock, should not create again + await bootstrapSystemProject(prisma); + + // create should not have been called additional times + expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(firstCallCount); + }); + + it('re-creates deleted prompts on subsequent startup', async () => { + // First run creates everything + await bootstrapSystemProject(prisma); + + // Simulate deletion: clear the map so findFirst returns null + vi.mocked(prisma.prompt.findFirst).mockResolvedValue(null); + vi.mocked(prisma.prompt.create).mockClear(); + + // Second run should recreate + await bootstrapSystemProject(prisma); + + const expectedNames = getSystemPromptNames(); + expect(vi.mocked(prisma.prompt.create).mock.calls.length).toBe(expectedNames.length); + }); + + it('system project has gated=false', async () => { + await bootstrapSystemProject(prisma); + + const upsertCall = vi.mocked(prisma.project.upsert).mock.calls[0]![0]; + expect((upsertCall as { create: { gated: boolean } }).create.gated).toBe(false); + }); +}); + +describe('getSystemPromptNames', () => { + it('returns all system prompt names', () => { + const names = getSystemPromptNames(); + expect(names).toContain('gate-instructions'); + expect(names).toContain('gate-encouragement'); + expect(names).toContain('gate-intercept-preamble'); + expect(names).toContain('session-greeting'); + }); +}); diff --git a/src/mcpd/tests/project-routes.test.ts b/src/mcpd/tests/project-routes.test.ts index d1d4a58..c0f1bfc 100644 --- a/src/mcpd/tests/project-routes.test.ts +++ b/src/mcpd/tests/project-routes.test.ts @@ -16,6 +16,7 @@ function makeProject(overrides: Partial = {}): ProjectWith description: '', ownerId: 'user-1', proxyMode: 'direct', + gated: true, llmProvider: null, llmModel: null, version: 1, diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts index a6322f0..3438f94 100644 --- a/src/mcpd/tests/project-service.test.ts +++ b/src/mcpd/tests/project-service.test.ts @@ -12,6 +12,7 @@ function makeProject(overrides: Partial = {}): ProjectWith description: '', ownerId: 'user-1', proxyMode: 'direct', + gated: true, llmProvider: null, llmModel: null, version: 1, diff --git a/src/mcpd/tests/prompt-routes.test.ts b/src/mcpd/tests/prompt-routes.test.ts new file mode 100644 index 0000000..5e41596 --- /dev/null +++ b/src/mcpd/tests/prompt-routes.test.ts @@ -0,0 +1,508 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerPromptRoutes } from '../src/routes/prompts.js'; +import { PromptService } from '../src/services/prompt.service.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { IPromptRequestRepository } from '../src/repositories/prompt-request.repository.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { Prompt, PromptRequest, Project } from '@prisma/client'; + +let app: FastifyInstance; + +function makePrompt(overrides: Partial = {}): Prompt { + return { + id: 'prompt-1', + name: 'test-prompt', + content: 'Hello world', + projectId: null, + priority: 5, + summary: null, + chapters: null, + linkTarget: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makePromptRequest(overrides: Partial = {}): PromptRequest { + return { + id: 'req-1', + name: 'test-request', + content: 'Proposed content', + projectId: null, + priority: 5, + createdBySession: 'session-abc', + createdByUserId: null, + createdAt: new Date(), + ...overrides, + }; +} + +function makeProject(overrides: Partial = {}): Project { + return { + id: 'proj-1', + name: 'homeautomation', + description: '', + prompt: '', + proxyMode: 'direct', + gated: true, + llmProvider: null, + llmModel: null, + ownerId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Project; +} + +function mockPromptRepo(): IPromptRepository { + return { + findAll: vi.fn(async () => []), + findGlobal: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data) => makePrompt(data)), + update: vi.fn(async (id, data) => makePrompt({ id, ...data })), + delete: vi.fn(async () => {}), + }; +} + +function mockPromptRequestRepo(): IPromptRequestRepository { + return { + findAll: vi.fn(async () => []), + findGlobal: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + findBySession: vi.fn(async () => []), + create: vi.fn(async (data) => makePromptRequest(data)), + update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })), + delete: vi.fn(async () => {}), + }; +} + +function makeProjectWithServers( + overrides: Partial = {}, + serverNames: string[] = [], +) { + return { + ...makeProject(overrides), + servers: serverNames.map((name, i) => ({ + id: `ps-${i}`, + projectId: overrides.id ?? 'proj-1', + serverId: `srv-${i}`, + server: { id: `srv-${i}`, name }, + })), + }; +} + +function mockProjectRepo(): IProjectRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => makeProject(data)), + update: vi.fn(async (_id, data) => makeProject({ ...data as Partial })), + delete: vi.fn(async () => {}), + }; +} + +afterEach(async () => { + if (app) await app.close(); +}); + +function buildApp(opts?: { + promptRepo?: IPromptRepository; + promptRequestRepo?: IPromptRequestRepository; + projectRepo?: IProjectRepository; +}) { + const promptRepo = opts?.promptRepo ?? mockPromptRepo(); + const promptRequestRepo = opts?.promptRequestRepo ?? mockPromptRequestRepo(); + const projectRepo = opts?.projectRepo ?? mockProjectRepo(); + const service = new PromptService(promptRepo, promptRequestRepo, projectRepo); + + app = Fastify(); + app.setErrorHandler(errorHandler); + registerPromptRoutes(app, service, projectRepo); + return { app, promptRepo, promptRequestRepo, projectRepo, service }; +} + +describe('Prompt routes', () => { + describe('GET /api/v1/prompts', () => { + it('returns all prompts without project filter', async () => { + const promptRepo = mockPromptRepo(); + const globalPrompt = makePrompt({ id: 'p-1', name: 'global-rule', projectId: null }); + const scopedPrompt = makePrompt({ id: 'p-2', name: 'scoped-rule', projectId: 'proj-1' }); + vi.mocked(promptRepo.findAll).mockResolvedValue([globalPrompt, scopedPrompt]); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Prompt[]; + expect(body).toHaveLength(2); + expect(promptRepo.findAll).toHaveBeenCalledWith(undefined); + }); + + it('filters by project name when ?project= is given', async () => { + const promptRepo = mockPromptRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1', name: 'homeautomation' })); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'ha-rule', projectId: 'proj-1' }), + makePrompt({ id: 'p-2', name: 'global-rule', projectId: null }), + ]); + + const { app: a } = buildApp({ promptRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=homeautomation' }); + + expect(res.statusCode).toBe(200); + expect(projectRepo.findByName).toHaveBeenCalledWith('homeautomation'); + expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); + }); + + it('returns only global prompts when ?scope=global', async () => { + const promptRepo = mockPromptRepo(); + const globalOnly = [makePrompt({ id: 'p-g', name: 'global-rule', projectId: null })]; + vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalOnly); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?scope=global' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Prompt[]; + expect(body).toHaveLength(1); + expect(promptRepo.findGlobal).toHaveBeenCalled(); + expect(promptRepo.findAll).not.toHaveBeenCalled(); + }); + + it('returns 404 when ?project= references unknown project', async () => { + const { app: a } = buildApp(); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=nonexistent' }); + + expect(res.statusCode).toBe(404); + const body = res.json() as { error: string }; + expect(body.error).toContain('Project not found'); + }); + }); + + describe('GET /api/v1/promptrequests', () => { + it('returns all prompt requests without project filter', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + vi.mocked(promptRequestRepo.findAll).mockResolvedValue([ + makePromptRequest({ id: 'r-1', name: 'req-a' }), + ]); + + const { app: a } = buildApp({ promptRequestRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests' }); + + expect(res.statusCode).toBe(200); + expect(promptRequestRepo.findAll).toHaveBeenCalledWith(undefined); + }); + + it('returns only global prompt requests when ?scope=global', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + vi.mocked(promptRequestRepo.findGlobal).mockResolvedValue([]); + + const { app: a } = buildApp({ promptRequestRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?scope=global' }); + + expect(res.statusCode).toBe(200); + expect(promptRequestRepo.findGlobal).toHaveBeenCalled(); + expect(promptRequestRepo.findAll).not.toHaveBeenCalled(); + }); + + it('filters by project name when ?project= is given', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' })); + + const { app: a } = buildApp({ promptRequestRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=homeautomation' }); + + expect(res.statusCode).toBe(200); + expect(promptRequestRepo.findAll).toHaveBeenCalledWith('proj-1'); + }); + + it('returns 404 for unknown project on promptrequests', async () => { + const { app: a } = buildApp(); + const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=nope' }); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /api/v1/promptrequests', () => { + it('creates a global prompt request (no project)', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + const { app: a } = buildApp({ promptRequestRepo }); + const res = await a.inject({ + method: 'POST', + url: '/api/v1/promptrequests', + payload: { name: 'global-req', content: 'some content' }, + }); + + expect(res.statusCode).toBe(201); + expect(promptRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'global-req', content: 'some content' }), + ); + }); + + it('resolves project name to ID when project given', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + const projectRepo = mockProjectRepo(); + const proj = makeProject({ id: 'proj-1', name: 'myproj' }); + vi.mocked(projectRepo.findByName).mockResolvedValue(proj); + vi.mocked(projectRepo.findById).mockResolvedValue(proj); + + const { app: a } = buildApp({ promptRequestRepo, projectRepo }); + const res = await a.inject({ + method: 'POST', + url: '/api/v1/promptrequests', + payload: { name: 'scoped-req', content: 'text', project: 'myproj' }, + }); + + expect(res.statusCode).toBe(201); + expect(projectRepo.findByName).toHaveBeenCalledWith('myproj'); + expect(promptRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'scoped-req', projectId: 'proj-1' }), + ); + }); + + it('returns 404 for unknown project name', async () => { + const { app: a } = buildApp(); + const res = await a.inject({ + method: 'POST', + url: '/api/v1/promptrequests', + payload: { name: 'bad-req', content: 'x', project: 'nope' }, + }); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('POST /api/v1/promptrequests/:id/approve', () => { + it('atomically approves a prompt request', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + const promptRepo = mockPromptRepo(); + const req = makePromptRequest({ id: 'req-1', name: 'my-rule', projectId: 'proj-1' }); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); + + const { app: a } = buildApp({ promptRepo, promptRequestRepo }); + const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests/req-1/approve' }); + + expect(res.statusCode).toBe(200); + expect(promptRepo.create).toHaveBeenCalledWith({ + name: 'my-rule', + content: 'Proposed content', + projectId: 'proj-1', + }); + expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1'); + }); + }); + + describe('Security: projectId tampering', () => { + it('rejects projectId in prompt update payload', async () => { + const promptRepo = mockPromptRepo(); + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ id: 'p-1', projectId: 'proj-a' })); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ + method: 'PUT', + url: '/api/v1/prompts/p-1', + payload: { content: 'new content', projectId: 'proj-evil' }, + }); + + // Should succeed but ignore projectId — UpdatePromptSchema strips it + expect(res.statusCode).toBe(200); + expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' }); + // projectId must NOT be in the update call + const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1]; + expect(updateArg).not.toHaveProperty('projectId'); + }); + + it('rejects projectId in promptrequest update payload', async () => { + const promptRequestRepo = mockPromptRequestRepo(); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest({ id: 'r-1', projectId: 'proj-a' })); + + const { app: a } = buildApp({ promptRequestRepo }); + const res = await a.inject({ + method: 'PUT', + url: '/api/v1/promptrequests/r-1', + payload: { content: 'new content', projectId: 'proj-evil' }, + }); + + expect(res.statusCode).toBe(200); + expect(promptRequestRepo.update).toHaveBeenCalledWith('r-1', { content: 'new content' }); + const updateArg = vi.mocked(promptRequestRepo.update).mock.calls[0]![1]; + expect(updateArg).not.toHaveProperty('projectId'); + }); + }); + + describe('linkStatus enrichment', () => { + it('returns linkStatus=null for non-linked prompts', async () => { + const promptRepo = mockPromptRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'plain', linkTarget: null }), + ]); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ linkStatus: string | null }>; + expect(body[0]!.linkStatus).toBeNull(); + }); + + it('returns linkStatus=alive when project and server exist', async () => { + const promptRepo = mockPromptRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'source-proj/docmost-mcp:docmost://pages/abc' }), + ]); + vi.mocked(projectRepo.findByName).mockImplementation(async (name) => { + if (name === 'source-proj') { + return makeProjectWithServers({ id: 'sp-1', name: 'source-proj' }, ['docmost-mcp']) as never; + } + return null; + }); + + const { app: a } = buildApp({ promptRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ linkStatus: string }>; + expect(body[0]!.linkStatus).toBe('alive'); + }); + + it('returns linkStatus=dead when source project not found', async () => { + const promptRepo = mockPromptRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'broken', linkTarget: 'missing-proj/srv:some://uri' }), + ]); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ linkStatus: string }>; + expect(body[0]!.linkStatus).toBe('dead'); + }); + + it('returns linkStatus=dead when server not in project', async () => { + const promptRepo = mockPromptRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'wrong-srv', linkTarget: 'proj/wrong-server:some://uri' }), + ]); + vi.mocked(projectRepo.findByName).mockResolvedValue( + makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['other-server']) as never, + ); + + const { app: a } = buildApp({ promptRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ linkStatus: string }>; + expect(body[0]!.linkStatus).toBe('dead'); + }); + + it('enriches single prompt GET with linkStatus', async () => { + const promptRepo = mockPromptRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(promptRepo.findById).mockResolvedValue( + makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'proj/srv:some://uri' }), + ); + vi.mocked(projectRepo.findByName).mockResolvedValue( + makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never, + ); + + const { app: a } = buildApp({ promptRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts/p-1' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as { linkStatus: string }; + expect(body.linkStatus).toBe('alive'); + }); + + it('caches project lookup for multiple linked prompts', async () => { + const promptRepo = mockPromptRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'link-a', linkTarget: 'proj/srv:uri-a' }), + makePrompt({ id: 'p-2', name: 'link-b', linkTarget: 'proj/srv:uri-b' }), + ]); + vi.mocked(projectRepo.findByName).mockResolvedValue( + makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never, + ); + + const { app: a } = buildApp({ promptRepo, projectRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ linkStatus: string }>; + expect(body).toHaveLength(2); + expect(body[0]!.linkStatus).toBe('alive'); + expect(body[1]!.linkStatus).toBe('alive'); + // Should only call findByName once (cached) + expect(projectRepo.findByName).toHaveBeenCalledTimes(1); + }); + + it('supports ?projectId= query parameter', async () => { + const promptRepo = mockPromptRepo(); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ id: 'p-1', name: 'scoped', projectId: 'proj-1' }), + ]); + + const { app: a } = buildApp({ promptRepo }); + const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?projectId=proj-1' }); + + expect(res.statusCode).toBe(200); + expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1'); + }); + }); + + describe('GET /api/v1/projects/:name/prompts/visible', () => { + it('returns approved prompts + session pending requests', async () => { + const promptRepo = mockPromptRepo(); + const promptRequestRepo = mockPromptRequestRepo(); + const projectRepo = mockProjectRepo(); + vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' })); + vi.mocked(promptRepo.findAll).mockResolvedValue([ + makePrompt({ name: 'approved-one', projectId: 'proj-1' }), + makePrompt({ name: 'global-one', projectId: null }), + ]); + vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([ + makePromptRequest({ name: 'pending-one', projectId: 'proj-1' }), + ]); + + const { app: a } = buildApp({ promptRepo, promptRequestRepo, projectRepo }); + const res = await a.inject({ + method: 'GET', + url: '/api/v1/projects/homeautomation/prompts/visible?session=sess-123', + }); + + expect(res.statusCode).toBe(200); + const body = res.json() as Array<{ name: string; type: string }>; + expect(body).toHaveLength(3); + expect(body.map((b) => b.name)).toContain('approved-one'); + expect(body.map((b) => b.name)).toContain('global-one'); + expect(body.map((b) => b.name)).toContain('pending-one'); + const pending = body.find((b) => b.name === 'pending-one'); + expect(pending?.type).toBe('promptrequest'); + }); + + it('returns 404 for unknown project', async () => { + const { app: a } = buildApp(); + const res = await a.inject({ + method: 'GET', + url: '/api/v1/projects/nonexistent/prompts/visible', + }); + + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts index ac83e5c..69c2d7c 100644 --- a/src/mcpd/tests/services/prompt-service.test.ts +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -11,6 +11,10 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + priority: 5, + summary: null, + chapters: null, + linkTarget: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -24,6 +28,7 @@ function makePromptRequest(overrides: Partial = {}): PromptReques name: 'test-request', content: 'Proposed content', projectId: null, + priority: 5, createdBySession: 'session-abc', createdByUserId: null, createdAt: new Date(), @@ -38,6 +43,7 @@ function makeProject(overrides: Partial = {}): Project { description: '', prompt: '', proxyMode: 'direct', + gated: true, llmProvider: null, llmModel: null, ownerId: 'user-1', @@ -50,6 +56,7 @@ function makeProject(overrides: Partial = {}): Project { function mockPromptRepo(): IPromptRepository { return { findAll: vi.fn(async () => []), + findGlobal: vi.fn(async () => []), findById: vi.fn(async () => null), findByNameAndProject: vi.fn(async () => null), create: vi.fn(async (data) => makePrompt(data)), @@ -61,10 +68,12 @@ function mockPromptRepo(): IPromptRepository { function mockPromptRequestRepo(): IPromptRequestRepository { return { findAll: vi.fn(async () => []), + findGlobal: vi.fn(async () => []), findById: vi.fn(async () => null), findByNameAndProject: vi.fn(async () => null), findBySession: vi.fn(async () => []), create: vi.fn(async (data) => makePromptRequest(data)), + update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })), delete: vi.fn(async () => {}), }; } @@ -111,6 +120,17 @@ describe('PromptService', () => { }); }); + describe('listGlobalPrompts', () => { + it('should return only global prompts', async () => { + const globalPrompts = [makePrompt({ name: 'global-rule', projectId: null })]; + vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalPrompts); + + const result = await service.listGlobalPrompts(); + expect(result).toEqual(globalPrompts); + expect(promptRepo.findGlobal).toHaveBeenCalled(); + }); + }); + describe('getPrompt', () => { it('should return a prompt by id', async () => { const prompt = makePrompt(); @@ -173,6 +193,21 @@ describe('PromptService', () => { it('should throw for missing prompt', async () => { await expect(service.deletePrompt('nope')).rejects.toThrow('Prompt not found'); }); + + it('should reject deletion of system prompts', async () => { + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'sys-proj' })); + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'sys-proj', name: 'mcpctl-system' })); + + await expect(service.deletePrompt('prompt-1')).rejects.toThrow('Cannot delete system prompts'); + }); + + it('should allow deletion of non-system project prompts', async () => { + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ projectId: 'proj-1' })); + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1', name: 'my-project' })); + + await service.deletePrompt('prompt-1'); + expect(promptRepo.delete).toHaveBeenCalledWith('prompt-1'); + }); }); // ── PromptRequest CRUD ── @@ -267,6 +302,90 @@ describe('PromptService', () => { }); }); + // ── Priority ── + + describe('prompt priority', () => { + it('creates prompt with explicit priority', async () => { + const result = await service.createPrompt({ name: 'high-pri', content: 'x', priority: 8 }); + expect(promptRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority: 8 })); + expect(result.priority).toBe(8); + }); + + it('uses default priority 5 when not specified', async () => { + const result = await service.createPrompt({ name: 'default-pri', content: 'x' }); + // Default in schema is 5 — create is called without priority + const createArg = vi.mocked(promptRepo.create).mock.calls[0]![0]; + expect(createArg.priority).toBeUndefined(); + }); + + it('rejects priority below 1', async () => { + await expect( + service.createPrompt({ name: 'bad-pri', content: 'x', priority: 0 }), + ).rejects.toThrow(); + }); + + it('rejects priority above 10', async () => { + await expect( + service.createPrompt({ name: 'bad-pri', content: 'x', priority: 11 }), + ).rejects.toThrow(); + }); + + it('updates prompt priority', async () => { + vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); + await service.updatePrompt('prompt-1', { priority: 3 }); + expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', expect.objectContaining({ priority: 3 })); + }); + }); + + // ── Link Target ── + + describe('prompt links', () => { + it('creates linked prompt with valid linkTarget', async () => { + const result = await service.createPrompt({ + name: 'linked', + content: 'link content', + linkTarget: 'other-project/docmost-mcp:docmost://pages/abc', + }); + expect(promptRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ linkTarget: 'other-project/docmost-mcp:docmost://pages/abc' }), + ); + }); + + it('rejects invalid link format', async () => { + await expect( + service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'invalid-format' }), + ).rejects.toThrow(); + }); + + it('rejects link without server part', async () => { + await expect( + service.createPrompt({ name: 'bad-link', content: 'x', linkTarget: 'project:uri' }), + ).rejects.toThrow(); + }); + + it('approve carries priority from request to prompt', async () => { + const req = makePromptRequest({ id: 'req-1', name: 'high-pri', content: 'x', projectId: 'proj-1', priority: 9 }); + vi.mocked(promptRequestRepo.findById).mockResolvedValue(req); + + await service.approve('req-1'); + + expect(promptRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ priority: 9 }), + ); + }); + + it('propose passes priority through', async () => { + const result = await service.propose({ + name: 'pri-req', + content: 'x', + priority: 7, + }); + expect(promptRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ priority: 7 }), + ); + }); + }); + // ── Visibility ── describe('getVisiblePrompts', () => { diff --git a/src/mcpd/tests/services/prompt-summary.test.ts b/src/mcpd/tests/services/prompt-summary.test.ts new file mode 100644 index 0000000..972dc16 --- /dev/null +++ b/src/mcpd/tests/services/prompt-summary.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + PromptSummaryService, + extractFirstSentence, + extractHeadings, + type LlmSummaryGenerator, +} from '../../src/services/prompt-summary.service.js'; + +describe('extractFirstSentence', () => { + it('extracts first sentence from plain text', () => { + const result = extractFirstSentence('This is the first sentence. And this is the second.', 20); + expect(result).toBe('This is the first sentence.'); + }); + + it('truncates to maxWords', () => { + const long = 'word '.repeat(30).trim(); + const result = extractFirstSentence(long, 5); + expect(result).toBe('word word word word word...'); + }); + + it('skips markdown headings to find content', () => { + const content = '# Title\n\n## Subtitle\n\nActual content here. More text.'; + expect(extractFirstSentence(content, 20)).toBe('Actual content here.'); + }); + + it('falls back to first heading if no content lines', () => { + const content = '# Only Headings\n## Nothing Else'; + expect(extractFirstSentence(content, 20)).toBe('Only Headings'); + }); + + it('strips markdown formatting', () => { + const content = 'This has **bold** and *italic* and `code` and [link](http://example.com).'; + expect(extractFirstSentence(content, 20)).toBe('This has bold and italic and code and link.'); + }); + + it('handles empty content', () => { + expect(extractFirstSentence('', 20)).toBe(''); + expect(extractFirstSentence(' ', 20)).toBe(''); + }); + + it('handles content with no sentence boundary', () => { + const content = 'No period at the end'; + expect(extractFirstSentence(content, 20)).toBe('No period at the end'); + }); + + it('handles exclamation and question marks', () => { + expect(extractFirstSentence('Is this a question? Yes it is.', 20)).toBe('Is this a question?'); + expect(extractFirstSentence('Watch out! Be careful.', 20)).toBe('Watch out!'); + }); +}); + +describe('extractHeadings', () => { + it('extracts all levels of markdown headings', () => { + const content = '# H1\n## H2\n### H3\nSome text\n#### H4'; + expect(extractHeadings(content)).toEqual(['H1', 'H2', 'H3', 'H4']); + }); + + it('returns empty array for content without headings', () => { + expect(extractHeadings('Just plain text\nMore text')).toEqual([]); + }); + + it('handles empty content', () => { + expect(extractHeadings('')).toEqual([]); + }); + + it('trims heading text', () => { + const content = '# Spaced Heading \n## Another '; + expect(extractHeadings(content)).toEqual(['Spaced Heading', 'Another']); + }); +}); + +describe('PromptSummaryService', () => { + it('uses regex fallback when no LLM', async () => { + const service = new PromptSummaryService(null); + const result = await service.generateSummary('# Overview\n\nThis is a test document. It has content.\n\n## Section One\n\n## Section Two'); + expect(result.summary).toBe('This is a test document.'); + expect(result.chapters).toEqual(['Overview', 'Section One', 'Section Two']); + }); + + it('uses LLM when available', async () => { + const mockLlm: LlmSummaryGenerator = { + generate: vi.fn(async () => ({ + summary: 'LLM-generated summary', + chapters: ['LLM Chapter 1'], + })), + }; + const service = new PromptSummaryService(mockLlm); + const result = await service.generateSummary('Some content'); + expect(result.summary).toBe('LLM-generated summary'); + expect(result.chapters).toEqual(['LLM Chapter 1']); + expect(mockLlm.generate).toHaveBeenCalledWith('Some content'); + }); + + it('falls back to regex on LLM failure', async () => { + const mockLlm: LlmSummaryGenerator = { + generate: vi.fn(async () => { throw new Error('LLM unavailable'); }), + }; + const service = new PromptSummaryService(mockLlm); + const result = await service.generateSummary('Fallback content here. Second sentence.'); + expect(result.summary).toBe('Fallback content here.'); + expect(mockLlm.generate).toHaveBeenCalled(); + }); + + it('generateWithRegex works directly', () => { + const service = new PromptSummaryService(null); + const result = service.generateWithRegex('# Title\n\nContent line. More.\n\n## Chapter A\n\n## Chapter B'); + expect(result.summary).toBe('Content line.'); + expect(result.chapters).toEqual(['Title', 'Chapter A', 'Chapter B']); + }); +}); diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 6ec64bb..c304349 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -55,6 +55,7 @@ export async function refreshProjectUpstreams( export interface ProjectLlmConfig { llmProvider?: string; llmModel?: string; + gated?: boolean; } export async function fetchProjectLlmConfig( @@ -65,10 +66,12 @@ export async function fetchProjectLlmConfig( const project = await mcpdClient.get<{ llmProvider?: string; llmModel?: string; + gated?: boolean; }>(`/api/v1/projects/${encodeURIComponent(projectName)}`); const config: ProjectLlmConfig = {}; if (project.llmProvider) config.llmProvider = project.llmProvider; if (project.llmModel) config.llmModel = project.llmModel; + if (project.gated !== undefined) config.gated = project.gated; return config; } catch { return {}; diff --git a/src/mcplocal/src/gate/llm-selector.ts b/src/mcplocal/src/gate/llm-selector.ts new file mode 100644 index 0000000..1d61931 --- /dev/null +++ b/src/mcplocal/src/gate/llm-selector.ts @@ -0,0 +1,81 @@ +/** + * LLM-based prompt selection for the gating flow. + * + * Sends tags + prompt index to the heavy LLM, which returns + * a ranked list of relevant prompt names. + */ + +import type { ProviderRegistry } from '../providers/registry.js'; + +export interface PromptIndexForLlm { + name: string; + priority: number; + summary: string | null; + chapters: string[] | null; +} + +export interface LlmSelectionResult { + selectedNames: string[]; + reasoning: string; +} + +export class LlmPromptSelector { + constructor( + private readonly providerRegistry: ProviderRegistry, + private readonly modelOverride?: string, + ) {} + + async selectPrompts( + tags: string[], + promptIndex: PromptIndexForLlm[], + ): Promise { + const systemPrompt = `You are a context selection assistant. Given a developer's task keywords and a list of available project prompts, select which prompts are relevant to their work. Return a JSON object with "selectedNames" (array of prompt names) and "reasoning" (brief explanation). Priority 10 prompts must always be included.`; + + const userPrompt = `Task keywords: ${tags.join(', ')} + +Available prompts: +${promptIndex.map((p) => `- ${p.name} (priority: ${p.priority}): ${p.summary ?? 'No summary'}${p.chapters?.length ? `\n Chapters: ${p.chapters.join(', ')}` : ''}`).join('\n')} + +Select the relevant prompts. Return JSON: { "selectedNames": [...], "reasoning": "..." }`; + + const provider = this.providerRegistry.getProvider('heavy'); + if (!provider) { + throw new Error('No heavy LLM provider available'); + } + + const completionOptions: import('../providers/types.js').CompletionOptions = { + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + temperature: 0, + maxTokens: 1024, + }; + if (this.modelOverride) { + completionOptions.model = this.modelOverride; + } + + const result = await provider.complete(completionOptions); + + const response = result.content; + + // Parse JSON from response (may be wrapped in markdown code blocks) + const jsonMatch = response.match(/\{[\s\S]*"selectedNames"[\s\S]*\}/); + if (!jsonMatch) { + throw new Error('LLM response did not contain valid selection JSON'); + } + + const parsed = JSON.parse(jsonMatch[0]) as { selectedNames?: string[]; reasoning?: string }; + const selectedNames = parsed.selectedNames ?? []; + const reasoning = parsed.reasoning ?? ''; + + // Always include priority 10 prompts + for (const p of promptIndex) { + if (p.priority === 10 && !selectedNames.includes(p.name)) { + selectedNames.push(p.name); + } + } + + return { selectedNames, reasoning }; + } +} diff --git a/src/mcplocal/src/gate/session-gate.ts b/src/mcplocal/src/gate/session-gate.ts new file mode 100644 index 0000000..920451a --- /dev/null +++ b/src/mcplocal/src/gate/session-gate.ts @@ -0,0 +1,76 @@ +/** + * Per-session gating state machine. + * + * Tracks whether a session has gone through the prompt selection flow. + * When gated, only begin_session is accessible. After ungating, all tools work. + */ + +import type { PromptIndexEntry, TagMatchResult } from './tag-matcher.js'; + +export interface SessionState { + gated: boolean; + tags: string[]; + retrievedPrompts: Set; + briefing: string | null; +} + +export class SessionGate { + private sessions = new Map(); + + /** Create a new session. Starts gated if the project is gated. */ + createSession(sessionId: string, projectGated: boolean): void { + this.sessions.set(sessionId, { + gated: projectGated, + tags: [], + retrievedPrompts: new Set(), + briefing: null, + }); + } + + /** Get session state. Returns null if session doesn't exist. */ + getSession(sessionId: string): SessionState | null { + return this.sessions.get(sessionId) ?? null; + } + + /** Check if a session is currently gated. Unknown sessions are treated as ungated. */ + isGated(sessionId: string): boolean { + return this.sessions.get(sessionId)?.gated ?? false; + } + + /** Ungate a session after prompt selection is complete. */ + ungate(sessionId: string, tags: string[], matchResult: TagMatchResult): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.gated = false; + session.tags = [...session.tags, ...tags]; + + // Track which prompts have been sent + for (const p of matchResult.fullContent) { + session.retrievedPrompts.add(p.name); + } + } + + /** Record additional prompts retrieved via read_prompts. */ + addRetrievedPrompts(sessionId: string, tags: string[], promptNames: string[]): void { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.tags = [...session.tags, ...tags]; + for (const name of promptNames) { + session.retrievedPrompts.add(name); + } + } + + /** Filter out prompts already sent to avoid duplicates. */ + filterAlreadySent(sessionId: string, prompts: PromptIndexEntry[]): PromptIndexEntry[] { + const session = this.sessions.get(sessionId); + if (!session) return prompts; + return prompts.filter((p) => !session.retrievedPrompts.has(p.name)); + } + + /** Remove a session (cleanup on disconnect). */ + removeSession(sessionId: string): void { + this.sessions.delete(sessionId); + } +} diff --git a/src/mcplocal/src/gate/tag-matcher.ts b/src/mcplocal/src/gate/tag-matcher.ts new file mode 100644 index 0000000..612a61c --- /dev/null +++ b/src/mcplocal/src/gate/tag-matcher.ts @@ -0,0 +1,109 @@ +/** + * Deterministic keyword-based tag matching for prompt selection. + * + * Used as the no-LLM fallback (and for read_prompts in hybrid mode). + * Scores prompts by tag overlap * priority, then fills a byte budget. + */ + +export interface PromptIndexEntry { + name: string; + priority: number; + summary: string | null; + chapters: string[] | null; + content: string; +} + +export interface TagMatchResult { + /** Prompts with full content included (within byte budget) */ + fullContent: PromptIndexEntry[]; + /** Matched prompts beyond byte budget — name + summary only */ + indexOnly: PromptIndexEntry[]; + /** Non-matched prompts — listed for awareness */ + remaining: PromptIndexEntry[]; +} + +const DEFAULT_BYTE_BUDGET = 8192; + +export class TagMatcher { + constructor(private readonly byteBudget: number = DEFAULT_BYTE_BUDGET) {} + + match(tags: string[], prompts: PromptIndexEntry[]): TagMatchResult { + const lowerTags = tags.map((t) => t.toLowerCase()); + + // Score each prompt + const scored = prompts.map((p) => ({ + prompt: p, + score: this.computeScore(lowerTags, p), + matched: this.computeScore(lowerTags, p) > 0, + })); + + // Partition: matched (score > 0) vs non-matched + const matched = scored.filter((s) => s.matched).sort((a, b) => b.score - a.score); + const nonMatched = scored.filter((s) => !s.matched).map((s) => s.prompt); + + // Fill byte budget from matched prompts + let budgetRemaining = this.byteBudget; + const fullContent: PromptIndexEntry[] = []; + const indexOnly: PromptIndexEntry[] = []; + + for (const { prompt } of matched) { + const contentBytes = Buffer.byteLength(prompt.content, 'utf-8'); + if (budgetRemaining >= contentBytes) { + fullContent.push(prompt); + budgetRemaining -= contentBytes; + } else { + indexOnly.push(prompt); + } + } + + return { fullContent, indexOnly, remaining: nonMatched }; + } + + private computeScore(lowerTags: string[], prompt: PromptIndexEntry): number { + // Priority 10 always included + if (prompt.priority === 10) return Infinity; + + if (lowerTags.length === 0) return 0; + + const searchText = [ + prompt.name, + prompt.summary ?? '', + ...(prompt.chapters ?? []), + ].join(' ').toLowerCase(); + + let matchCount = 0; + for (const tag of lowerTags) { + if (searchText.includes(tag)) matchCount++; + } + + return matchCount * prompt.priority; + } +} + +/** + * Extract keywords from a tool call for the intercept fallback path. + * Pulls words from the tool name and string argument values. + */ +export function extractKeywordsFromToolCall( + toolName: string, + args: Record, +): string[] { + const keywords = new Set(); + + // Tool name parts (split on / and -) + for (const part of toolName.split(/[/-]/)) { + if (part.length > 2) keywords.add(part.toLowerCase()); + } + + // String argument values — extract words + for (const value of Object.values(args)) { + if (typeof value === 'string' && value.length < 200) { + for (const word of value.split(/\s+/)) { + const clean = word.replace(/[^a-zA-Z0-9-]/g, '').toLowerCase(); + if (clean.length > 2) keywords.add(clean); + } + } + } + + return [...keywords].slice(0, 10); // Cap at 10 keywords +} diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts index 12cd6b2..e316268 100644 --- a/src/mcplocal/src/http/project-mcp-endpoint.ts +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -52,13 +52,28 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp const mcpdConfig = await fetchProjectLlmConfig(mcpdClient, projectName); const resolvedModel = localOverride?.model ?? mcpdConfig.llmModel ?? undefined; + // If project llmProvider is "none", disable LLM for this project + const llmDisabled = mcpdConfig.llmProvider === 'none' || localOverride?.provider === 'none'; + const effectiveRegistry = llmDisabled ? null : (providerRegistry ?? null); + // Wire pagination support with LLM provider and project model override - router.setPaginator(new ResponsePaginator(providerRegistry ?? null, {}, resolvedModel)); + router.setPaginator(new ResponsePaginator(effectiveRegistry, {}, resolvedModel)); // Configure prompt resources with SA-scoped client for RBAC const saClient = mcpdClient.withHeaders({ 'X-Service-Account': `project:${projectName}` }); router.setPromptConfig(saClient, projectName); + // Configure gating if project has it enabled (default: true) + const isGated = mcpdConfig.gated !== false; + const gateConfig: import('../router.js').GateConfig = { + gated: isGated, + providerRegistry: effectiveRegistry, + }; + if (resolvedModel) { + gateConfig.modelOverride = resolvedModel; + } + router.setGateConfig(gateConfig); + // Fetch project instructions and set on router try { const instructions = await mcpdClient.get<{ prompt: string; servers: Array<{ name: string; description: string }> }>( @@ -131,6 +146,7 @@ export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: Mcp const id = transport.sessionId; if (id) { sessions.delete(id); + router.cleanupSession(id); } }; diff --git a/src/mcplocal/src/router.ts b/src/mcplocal/src/router.ts index d03c772..cb12674 100644 --- a/src/mcplocal/src/router.ts +++ b/src/mcplocal/src/router.ts @@ -2,11 +2,23 @@ import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotifi import type { LlmProcessor } from './llm/processor.js'; import { ResponsePaginator } from './llm/pagination.js'; import type { McpdClient } from './http/mcpd-client.js'; +import { SessionGate } from './gate/session-gate.js'; +import { TagMatcher, extractKeywordsFromToolCall } from './gate/tag-matcher.js'; +import type { PromptIndexEntry, TagMatchResult } from './gate/tag-matcher.js'; +import { LlmPromptSelector } from './gate/llm-selector.js'; +import type { ProviderRegistry } from './providers/registry.js'; export interface RouteContext { sessionId?: string; } +export interface GateConfig { + gated: boolean; + providerRegistry: ProviderRegistry | null; + modelOverride?: string; + byteBudget?: number; +} + /** * Routes MCP requests to the appropriate upstream server. * @@ -28,11 +40,28 @@ export class McpRouter { private projectName: string | null = null; private mcpctlResourceContents = new Map(); private paginator: ResponsePaginator | null = null; + private sessionGate = new SessionGate(); + private gateConfig: GateConfig | null = null; + private tagMatcher: TagMatcher | null = null; + private llmSelector: LlmPromptSelector | null = null; + private cachedPromptIndex: PromptIndexEntry[] | null = null; + private promptIndexFetchedAt = 0; + private readonly PROMPT_INDEX_TTL_MS = 60_000; + private systemPromptCache = new Map(); + private readonly SYSTEM_PROMPT_TTL_MS = 300_000; // 5 minutes setPaginator(paginator: ResponsePaginator): void { this.paginator = paginator; } + setGateConfig(config: GateConfig): void { + this.gateConfig = config; + this.tagMatcher = new TagMatcher(config.byteBudget); + if (config.providerRegistry) { + this.llmSelector = new LlmPromptSelector(config.providerRegistry, config.modelOverride); + } + } + setLlmProcessor(processor: LlmProcessor): void { this.llmProcessor = processor; } @@ -257,28 +286,50 @@ export class McpRouter { */ async route(request: JsonRpcRequest, context?: RouteContext): Promise { switch (request.method) { - case 'initialize': - return { - jsonrpc: '2.0', - id: request.id, - result: { - protocolVersion: '2024-11-05', - serverInfo: { - name: 'mcpctl-proxy', - version: '0.1.0', - }, - capabilities: { - tools: {}, - resources: {}, - prompts: {}, - }, - ...(this.instructions ? { instructions: this.instructions } : {}), + case 'initialize': { + // Create gated session if project is gated + const isGated = this.gateConfig?.gated ?? false; + if (context?.sessionId && this.gateConfig) { + this.sessionGate.createSession(context.sessionId, isGated); + } + + // Build instructions: base project instructions + gate message with prompt index + let instructions = this.instructions ?? ''; + if (isGated) { + instructions = await this.buildGatedInstructions(instructions); + } + + const result: Record = { + protocolVersion: '2024-11-05', + serverInfo: { + name: 'mcpctl-proxy', + version: '0.1.0', + }, + capabilities: { + tools: {}, + resources: {}, + prompts: {}, }, }; + if (instructions) { + result['instructions'] = instructions; + } + + return { jsonrpc: '2.0', id: request.id, result }; + } case 'tools/list': { + // When gated, only show begin_session + if (context?.sessionId && this.sessionGate.isGated(context.sessionId)) { + return { + jsonrpc: '2.0', + id: request.id, + result: { tools: [this.getBeginSessionTool()] }, + }; + } + const tools = await this.discoverTools(); - // Append propose_prompt tool if prompt config is set + // Append built-in tools if prompt config is set if (this.mcpdClient && this.projectName) { tools.push({ name: 'propose_prompt', @@ -293,6 +344,10 @@ export class McpRouter { }, }); } + // Always offer read_prompts when gating is configured (even for ungated sessions) + if (this.gateConfig && this.mcpdClient && this.projectName) { + tools.push(this.getReadPromptsTool()); + } return { jsonrpc: '2.0', id: request.id, @@ -337,6 +392,44 @@ export class McpRouter { case 'resources/read': { const params = request.params as Record | undefined; const uri = params?.['uri'] as string | undefined; + if (uri?.startsWith('mcpctl://prompts/') && this.mcpdClient && this.projectName) { + const promptName = uri.slice('mcpctl://prompts/'.length); + try { + const sessionParam = context?.sessionId ? `?session=${encodeURIComponent(context.sessionId)}` : ''; + const visible = await this.mcpdClient.get>( + `/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible${sessionParam}`, + ); + const found = visible.find((p) => p.name === promptName); + if (found) { + this.mcpctlResourceContents.set(uri, found.content); + return { + jsonrpc: '2.0', + id: request.id, + result: { + contents: [{ uri, mimeType: 'text/plain', text: found.content }], + }, + }; + } + } catch { + // Fall through to cache + } + // Fallback to cache if mcpd is unreachable + const cached = this.mcpctlResourceContents.get(uri); + if (cached !== undefined) { + return { + jsonrpc: '2.0', + id: request.id, + result: { + contents: [{ uri, mimeType: 'text/plain', text: cached }], + }, + }; + } + return { + jsonrpc: '2.0', + id: request.id, + error: { code: -32602, message: `Resource not found: ${uri}` }, + }; + } if (uri?.startsWith('mcpctl://')) { const content = this.mcpctlResourceContents.get(uri); if (content !== undefined) { @@ -400,13 +493,26 @@ export class McpRouter { const params = request.params as Record | undefined; const toolName = params?.['name'] as string | undefined; - // Handle built-in propose_prompt tool + // Handle built-in tools if (toolName === 'propose_prompt') { return this.handleProposePrompt(request, context); } + if (toolName === 'begin_session') { + return this.handleBeginSession(request, context); + } + if (toolName === 'read_prompts') { + return this.handleReadPrompts(request, context); + } + + // Extract tool arguments early (needed for both gated intercept and pagination) + const toolArgs = (params?.['arguments'] ?? {}) as Record; + + // Intercept: if session is gated and trying to call a real tool, auto-ungate with keyword extraction + if (context?.sessionId && this.sessionGate.isGated(context.sessionId)) { + return this.handleGatedIntercept(request, context, toolName ?? '', toolArgs); + } // Intercept pagination page requests before routing to upstream - const toolArgs = (params?.['arguments'] ?? {}) as Record; if (this.paginator) { const paginationReq = ResponsePaginator.extractPaginationParams(toolArgs); if (paginationReq) { @@ -525,6 +631,417 @@ export class McpRouter { } } + // ── Gate tool definitions ── + + private getBeginSessionTool(): { name: string; description: string; inputSchema: unknown } { + return { + name: 'begin_session', + description: 'Start your session by providing keywords that describe your current task. You will receive relevant project context, policies, and guidelines. This is required before using other tools.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + maxItems: 10, + description: '3-7 keywords describing your current task (e.g. ["zigbee", "pairing", "mqtt"])', + }, + }, + required: ['tags'], + }, + }; + } + + private getReadPromptsTool(): { name: string; description: string; inputSchema: unknown } { + return { + name: 'read_prompts', + description: 'Retrieve additional project prompts by keywords. Use this if you need more context about specific topics. Returns matched prompts and a list of other available prompts.', + inputSchema: { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + maxItems: 10, + description: 'Keywords to match against available prompts', + }, + }, + required: ['tags'], + }, + }; + } + + // ── Gate handlers ── + + private async handleBeginSession(request: JsonRpcRequest, context?: RouteContext): Promise { + if (!this.gateConfig || !this.mcpdClient || !this.projectName) { + return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Gating not configured' } }; + } + + const params = request.params as Record | undefined; + const args = (params?.['arguments'] ?? {}) as Record; + const tags = args['tags'] as string[] | undefined; + + if (!tags || !Array.isArray(tags) || tags.length === 0) { + return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } }; + } + + const sessionId = context?.sessionId; + if (sessionId && !this.sessionGate.isGated(sessionId)) { + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: 'Session already started. Use read_prompts to retrieve additional context.' }], + }, + }; + } + + try { + const promptIndex = await this.fetchPromptIndex(); + + // Primary: LLM selection. Fallback: deterministic tag matching. + let matchResult: TagMatchResult; + let reasoning = ''; + + if (this.llmSelector) { + try { + const llmIndex = promptIndex.map((p) => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + })); + const llmResult = await this.llmSelector.selectPrompts(tags, llmIndex); + reasoning = llmResult.reasoning; + + // Convert LLM names back to full PromptIndexEntry results via TagMatcher for byte-budget + const selectedSet = new Set(llmResult.selectedNames); + const selected = promptIndex.filter((p) => selectedSet.has(p.name)); + const remaining = promptIndex.filter((p) => !selectedSet.has(p.name)); + + // Apply byte budget to the LLM-selected prompts + matchResult = this.tagMatcher!.match( + // Use all tags + selected names as keywords so everything scores > 0 + [...tags, ...llmResult.selectedNames], + selected, + ); + // Put LLM-unselected in remaining + matchResult.remaining = [...matchResult.remaining, ...remaining]; + } catch { + // LLM failed — fall back to keyword matching + matchResult = this.tagMatcher!.match(tags, promptIndex); + } + } else { + matchResult = this.tagMatcher!.match(tags, promptIndex); + } + + // Ungate the session + if (sessionId) { + this.sessionGate.ungate(sessionId, tags, matchResult); + } + + // Build response + const responseParts: string[] = []; + + if (reasoning) { + responseParts.push(`Selection reasoning: ${reasoning}\n`); + } + + // Full content prompts + for (const p of matchResult.fullContent) { + responseParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`); + } + + // Index-only (over budget) + if (matchResult.indexOnly.length > 0) { + responseParts.push('Additional matched prompts (use read_prompts to retrieve full content):'); + for (const p of matchResult.indexOnly) { + responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`); + } + responseParts.push(''); + } + + // Remaining prompts for awareness + if (matchResult.remaining.length > 0) { + responseParts.push('Other available prompts:'); + for (const p of matchResult.remaining) { + responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`); + } + responseParts.push(''); + } + + // Encouragement (from system prompt or fallback) + const encouragement = await this.getSystemPrompt( + 'gate-encouragement', + 'If any of the listed prompts seem relevant to your work, or if you encounter unfamiliar patterns, conventions, or constraints during implementation, use read_prompts({ tags: [...] }) to retrieve them. It is better to check and not need it than to proceed without important context.', + ); + responseParts.push(encouragement); + + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: responseParts.join('\n') }], + }, + }; + } catch (err) { + return { + jsonrpc: '2.0', + id: request.id, + error: { code: -32603, message: `begin_session failed: ${err instanceof Error ? err.message : String(err)}` }, + }; + } + } + + private async handleReadPrompts(request: JsonRpcRequest, context?: RouteContext): Promise { + if (!this.tagMatcher || !this.mcpdClient || !this.projectName) { + return { jsonrpc: '2.0', id: request.id, error: { code: -32603, message: 'Prompt retrieval not configured' } }; + } + + const params = request.params as Record | undefined; + const args = (params?.['arguments'] ?? {}) as Record; + const tags = args['tags'] as string[] | undefined; + + if (!tags || !Array.isArray(tags) || tags.length === 0) { + return { jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Missing or empty tags array' } }; + } + + try { + const promptIndex = await this.fetchPromptIndex(); + const sessionId = context?.sessionId; + + // Filter out already-sent prompts + const available = sessionId ? this.sessionGate.filterAlreadySent(sessionId, promptIndex) : promptIndex; + + // Always use deterministic tag matching for read_prompts (hybrid mode) + const matchResult = this.tagMatcher.match(tags, available); + + // Record retrieved prompts + if (sessionId) { + this.sessionGate.addRetrievedPrompts( + sessionId, + tags, + matchResult.fullContent.map((p) => p.name), + ); + } + + if (matchResult.fullContent.length === 0 && matchResult.indexOnly.length === 0) { + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: 'No new matching prompts found for the given keywords.' }], + }, + }; + } + + const responseParts: string[] = []; + + for (const p of matchResult.fullContent) { + responseParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`); + } + + if (matchResult.indexOnly.length > 0) { + responseParts.push('Additional matched prompts (too large to include, try more specific keywords):'); + for (const p of matchResult.indexOnly) { + responseParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`); + } + } + + return { + jsonrpc: '2.0', + id: request.id, + result: { + content: [{ type: 'text', text: responseParts.join('\n') }], + }, + }; + } catch (err) { + return { + jsonrpc: '2.0', + id: request.id, + error: { code: -32603, message: `read_prompts failed: ${err instanceof Error ? err.message : String(err)}` }, + }; + } + } + + /** + * Intercept handler: when a gated session tries to call a real tool, + * extract keywords from the tool call, auto-ungate, and prepend a briefing. + */ + private async handleGatedIntercept( + request: JsonRpcRequest, + context: RouteContext, + toolName: string, + toolArgs: Record, + ): Promise { + const sessionId = context.sessionId!; + + // Extract keywords from the tool call as a fallback + const tags = extractKeywordsFromToolCall(toolName, toolArgs); + + try { + const promptIndex = await this.fetchPromptIndex(); + const matchResult = this.tagMatcher!.match(tags, promptIndex); + + // Ungate the session + this.sessionGate.ungate(sessionId, tags, matchResult); + + // Build briefing from matched content + const briefingParts: string[] = []; + if (matchResult.fullContent.length > 0) { + const preamble = await this.getSystemPrompt( + 'gate-intercept-preamble', + 'The following project context was automatically retrieved based on your tool call.', + ); + briefingParts.push(`--- ${preamble} ---\n`); + for (const p of matchResult.fullContent) { + briefingParts.push(`--- ${p.name} (priority: ${p.priority}) ---\n${p.content}\n`); + } + briefingParts.push('--- End of project context ---\n'); + } + + if (matchResult.remaining.length > 0 || matchResult.indexOnly.length > 0) { + briefingParts.push('Other prompts available (use read_prompts to retrieve):'); + for (const p of [...matchResult.indexOnly, ...matchResult.remaining]) { + briefingParts.push(` - ${p.name}: ${p.summary ?? 'No description'}`); + } + briefingParts.push(''); + } + + // Now route the actual tool call + const response = await this.routeNamespacedCall(request, 'name', this.toolToServer); + const paginatedResponse = await this.maybePaginate(toolName, response); + + // Prepend briefing to the response + if (briefingParts.length > 0 && paginatedResponse.result && !paginatedResponse.error) { + const result = paginatedResponse.result as { content?: Array<{ type: string; text: string }> }; + const briefing = briefingParts.join('\n'); + if (result.content && Array.isArray(result.content)) { + result.content.unshift({ type: 'text', text: briefing }); + } else { + (paginatedResponse.result as Record)['_briefing'] = briefing; + } + } + + return paginatedResponse; + } catch { + // If prompt retrieval fails, just ungate and route normally + this.sessionGate.ungate(sessionId, tags, { fullContent: [], indexOnly: [], remaining: [] }); + return this.routeNamespacedCall(request, 'name', this.toolToServer); + } + } + + /** + * Fetch prompt index from mcpd with caching. + */ + private async fetchPromptIndex(): Promise { + const now = Date.now(); + if (this.cachedPromptIndex && (now - this.promptIndexFetchedAt) < this.PROMPT_INDEX_TTL_MS) { + return this.cachedPromptIndex; + } + + if (!this.mcpdClient || !this.projectName) { + return []; + } + + const index = await this.mcpdClient.get>( + `/api/v1/projects/${encodeURIComponent(this.projectName)}/prompts/visible`, + ); + + this.cachedPromptIndex = index.map((p) => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + content: p.content ?? '', + })); + this.promptIndexFetchedAt = now; + return this.cachedPromptIndex; + } + + /** + * Build instructions for gated projects: base instructions + gate message + prompt index. + */ + private async buildGatedInstructions(baseInstructions: string): Promise { + const parts: string[] = []; + if (baseInstructions) { + parts.push(baseInstructions); + } + + const gateInstructions = await this.getSystemPrompt( + 'gate-instructions', + 'IMPORTANT: This project uses a gated session. You must call begin_session with keywords describing your task before using any other tools. This will provide you with relevant project context, policies, and guidelines.', + ); + parts.push(`\n${gateInstructions}`); + + // Append compact prompt index so the LLM knows what's available + try { + const promptIndex = await this.fetchPromptIndex(); + if (promptIndex.length > 0) { + // Cap at 50 entries; if over 50, show priority 7+ only + let displayIndex = promptIndex; + if (displayIndex.length > 50) { + displayIndex = displayIndex.filter((p) => p.priority >= 7); + } + // Sort by priority descending + displayIndex.sort((a, b) => b.priority - a.priority); + + parts.push('\nAvailable project prompts:'); + for (const p of displayIndex) { + const summary = p.summary ? `: ${p.summary}` : ''; + parts.push(`- ${p.name} (priority ${p.priority})${summary}`); + } + parts.push( + '\nChoose your begin_session keywords based on which of these prompts seem relevant to your task.', + ); + } + } catch { + // Prompt index is optional — don't fail initialization + } + + return parts.join('\n'); + } + + /** + * Fetch a system prompt from mcpctl-system project, with caching and fallback. + */ + private async getSystemPrompt(name: string, fallback: string): Promise { + const now = Date.now(); + const cached = this.systemPromptCache.get(name); + if (cached && (now - cached.fetchedAt) < this.SYSTEM_PROMPT_TTL_MS) { + return cached.content; + } + + if (!this.mcpdClient) return fallback; + + try { + const visible = await this.mcpdClient.get>( + '/api/v1/projects/mcpctl-system/prompts/visible', + ); + // Cache all system prompts from the response + for (const p of visible) { + this.systemPromptCache.set(p.name, { content: p.content, fetchedAt: now }); + } + const found = visible.find((p) => p.name === name); + return found?.content ?? fallback; + } catch { + return fallback; + } + } + + // ── Session cleanup ── + + cleanupSession(sessionId: string): void { + this.sessionGate.removeSession(sessionId); + } + getUpstreamNames(): string[] { return [...this.upstreams.keys()]; } diff --git a/src/mcplocal/src/services/link-resolver.ts b/src/mcplocal/src/services/link-resolver.ts new file mode 100644 index 0000000..e306952 --- /dev/null +++ b/src/mcplocal/src/services/link-resolver.ts @@ -0,0 +1,133 @@ +import type { McpdClient } from '../http/mcpd-client.js'; + +export interface LinkResolution { + content: string | null; + status: 'alive' | 'dead' | 'unknown'; + error?: string; +} + +interface CacheEntry { + resolution: LinkResolution; + expiresAt: number; +} + +interface ParsedLink { + project: string; + server: string; + uri: string; +} + +/** + * Resolves prompt links by fetching MCP resources from source projects via mcpd. + * Link format: project/server:resource-uri + */ +export class LinkResolver { + private cache = new Map(); + + constructor( + private readonly mcpdClient: McpdClient, + private readonly cacheTtlMs = 5 * 60 * 1000, // 5 minutes + ) {} + + /** + * Parse a link target string into its components. + * Format: project/server:resource-uri + */ + parseLink(linkTarget: string): ParsedLink { + const slashIdx = linkTarget.indexOf('/'); + if (slashIdx < 1) throw new Error(`Invalid link format (missing project): ${linkTarget}`); + + const project = linkTarget.slice(0, slashIdx); + const rest = linkTarget.slice(slashIdx + 1); + + const colonIdx = rest.indexOf(':'); + if (colonIdx < 1) throw new Error(`Invalid link format (missing server:uri): ${linkTarget}`); + + const server = rest.slice(0, colonIdx); + const uri = rest.slice(colonIdx + 1); + if (!uri) throw new Error(`Invalid link format (empty uri): ${linkTarget}`); + + return { project, server, uri }; + } + + /** + * Resolve a link target and return the fetched content + status. + * Results are cached with a configurable TTL. + */ + async resolve(linkTarget: string): Promise { + // Check cache first + const cached = this.cache.get(linkTarget); + if (cached && cached.expiresAt > Date.now()) { + return cached.resolution; + } + + let resolution: LinkResolution; + try { + const { project, server, uri } = this.parseLink(linkTarget); + const content = await this.fetchResource(project, server, uri); + resolution = { content, status: 'alive' }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`[link-resolver] Dead link: ${linkTarget} — ${message}`); + resolution = { content: null, status: 'dead', error: message }; + } + + // Cache the result + this.cache.set(linkTarget, { + resolution, + expiresAt: Date.now() + this.cacheTtlMs, + }); + + return resolution; + } + + /** + * Check link health without returning full content (uses cache if available). + */ + async checkHealth(linkTarget: string): Promise<'alive' | 'dead' | 'unknown'> { + const cached = this.cache.get(linkTarget); + if (cached && cached.expiresAt > Date.now()) { + return cached.resolution.status; + } + // Don't do a full resolve just for health — return unknown + return 'unknown'; + } + + /** Clear all cached resolutions. */ + clearCache(): void { + this.cache.clear(); + } + + private async fetchResource(project: string, server: string, uri: string): Promise { + // Step 1: Resolve server name → server ID from the project's servers + const servers = await this.mcpdClient.get>( + `/api/v1/projects/${encodeURIComponent(project)}/servers`, + ); + const target = servers.find((s) => s.name === server); + if (!target) { + throw new Error(`Server '${server}' not found in project '${project}'`); + } + + // Step 2: Call resources/read via the MCP proxy + const proxyResponse = await this.mcpdClient.post<{ + result?: { contents?: Array<{ text?: string; uri?: string }> }; + error?: { code: number; message: string }; + }>('/api/v1/mcp/proxy', { + serverId: target.id, + method: 'resources/read', + params: { uri }, + }); + + if (proxyResponse.error) { + throw new Error(`MCP error: ${proxyResponse.error.message}`); + } + + const contents = proxyResponse.result?.contents; + if (!contents || contents.length === 0) { + throw new Error(`No content returned for resource: ${uri}`); + } + + // Concatenate all text contents + return contents.map((c) => c.text ?? '').join('\n'); + } +} diff --git a/src/mcplocal/tests/link-resolver.test.ts b/src/mcplocal/tests/link-resolver.test.ts new file mode 100644 index 0000000..1b3c3f8 --- /dev/null +++ b/src/mcplocal/tests/link-resolver.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LinkResolver } from '../src/services/link-resolver.js'; +import type { McpdClient } from '../src/http/mcpd-client.js'; + +function mockClient(): McpdClient { + return { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + forward: vi.fn(), + withHeaders: vi.fn(), + } as unknown as McpdClient; +} + +describe('LinkResolver', () => { + let client: McpdClient; + let resolver: LinkResolver; + + beforeEach(() => { + client = mockClient(); + resolver = new LinkResolver(client, 1000); // 1s TTL for tests + }); + + // ── parseLink ── + + describe('parseLink', () => { + it('parses valid link target', () => { + const result = resolver.parseLink('my-project/docmost-mcp:docmost://pages/abc'); + expect(result).toEqual({ + project: 'my-project', + server: 'docmost-mcp', + uri: 'docmost://pages/abc', + }); + }); + + it('parses link with complex URI', () => { + const result = resolver.parseLink('proj/srv:file:///path/to/resource'); + expect(result).toEqual({ + project: 'proj', + server: 'srv', + uri: 'file:///path/to/resource', + }); + }); + + it('throws on missing project separator', () => { + expect(() => resolver.parseLink('noslash')).toThrow('missing project'); + }); + + it('throws on missing server:uri separator', () => { + expect(() => resolver.parseLink('proj/nocolon')).toThrow('missing server:uri'); + }); + + it('throws on empty uri', () => { + expect(() => resolver.parseLink('proj/srv:')).toThrow('empty uri'); + }); + + it('throws when project is empty', () => { + expect(() => resolver.parseLink('/srv:uri')).toThrow('missing project'); + }); + + it('throws when server is empty', () => { + expect(() => resolver.parseLink('proj/:uri')).toThrow('missing server:uri'); + }); + }); + + // ── resolve ── + + describe('resolve', () => { + it('fetches resource content successfully', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-id-1', name: 'docmost-mcp' }, + ]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [{ text: 'Hello from docmost', uri: 'docmost://pages/abc' }] }, + }); + + const result = await resolver.resolve('my-project/docmost-mcp:docmost://pages/abc'); + + expect(result).toEqual({ content: 'Hello from docmost', status: 'alive' }); + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my-project/servers'); + expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', { + serverId: 'srv-id-1', + method: 'resources/read', + params: { uri: 'docmost://pages/abc' }, + }); + }); + + it('returns dead status when server not found in project', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-other', name: 'other-server' }, + ]); + + const result = await resolver.resolve('proj/missing-srv:some://uri'); + + expect(result.status).toBe('dead'); + expect(result.content).toBeNull(); + expect(result.error).toContain("Server 'missing-srv' not found"); + }); + + it('returns dead status when MCP proxy returns error', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + error: { code: -32601, message: 'Method not found' }, + }); + + const result = await resolver.resolve('proj/srv:some://uri'); + + expect(result.status).toBe('dead'); + expect(result.error).toContain('Method not found'); + }); + + it('returns dead status when no content returned', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [] }, + }); + + const result = await resolver.resolve('proj/srv:some://uri'); + + expect(result.status).toBe('dead'); + expect(result.error).toContain('No content returned'); + }); + + it('returns dead status on network error', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('Connection refused')); + + const result = await resolver.resolve('proj/srv:some://uri'); + + expect(result.status).toBe('dead'); + expect(result.error).toContain('Connection refused'); + }); + + it('concatenates multiple content entries', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { + contents: [ + { text: 'Part 1', uri: 'uri1' }, + { text: 'Part 2', uri: 'uri2' }, + ], + }, + }); + + const result = await resolver.resolve('proj/srv:some://uri'); + + expect(result.content).toBe('Part 1\nPart 2'); + expect(result.status).toBe('alive'); + }); + + it('logs dead link to console.error', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('fail')); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await resolver.resolve('proj/srv:some://uri'); + + expect(spy).toHaveBeenCalledWith(expect.stringContaining('[link-resolver] Dead link')); + spy.mockRestore(); + }); + }); + + // ── caching ── + + describe('caching', () => { + it('returns cached result on second call', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [{ text: 'cached content' }] }, + }); + + const first = await resolver.resolve('proj/srv:some://uri'); + const second = await resolver.resolve('proj/srv:some://uri'); + + expect(first).toEqual(second); + // Only one HTTP call — second was cached + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it('refetches after cache expires', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [{ text: 'content' }] }, + }); + + await resolver.resolve('proj/srv:some://uri'); + + // Advance time past TTL + vi.useFakeTimers(); + vi.advanceTimersByTime(1500); + + await resolver.resolve('proj/srv:some://uri'); + + expect(client.get).toHaveBeenCalledTimes(2); + vi.useRealTimers(); + }); + + it('clearCache removes all entries', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [{ text: 'content' }] }, + }); + + await resolver.resolve('proj/srv:some://uri'); + resolver.clearCache(); + await resolver.resolve('proj/srv:some://uri'); + + expect(client.get).toHaveBeenCalledTimes(2); + }); + }); + + // ── checkHealth ── + + describe('checkHealth', () => { + it('returns cached status if available', async () => { + vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]); + vi.mocked(client.post).mockResolvedValue({ + result: { contents: [{ text: 'content' }] }, + }); + + await resolver.resolve('proj/srv:some://uri'); + const health = await resolver.checkHealth('proj/srv:some://uri'); + + expect(health).toBe('alive'); + }); + + it('returns unknown if not cached', async () => { + const health = await resolver.checkHealth('proj/srv:some://uri'); + expect(health).toBe('unknown'); + }); + + it('returns dead from cached dead link', async () => { + vi.mocked(client.get).mockRejectedValue(new Error('fail')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + await resolver.resolve('proj/srv:some://uri'); + const health = await resolver.checkHealth('proj/srv:some://uri'); + + expect(health).toBe('dead'); + }); + }); +}); diff --git a/src/mcplocal/tests/llm-selector.test.ts b/src/mcplocal/tests/llm-selector.test.ts new file mode 100644 index 0000000..3012465 --- /dev/null +++ b/src/mcplocal/tests/llm-selector.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi } from 'vitest'; +import { LlmPromptSelector, type PromptIndexForLlm } from '../src/gate/llm-selector.js'; +import { ProviderRegistry } from '../src/providers/registry.js'; +import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js'; + +function makeMockProvider(responseContent: string): LlmProvider { + return { + name: 'mock-heavy', + complete: vi.fn().mockResolvedValue({ + content: responseContent, + toolCalls: [], + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + finishReason: 'stop', + } satisfies CompletionResult), + listModels: vi.fn().mockResolvedValue(['mock-model']), + isAvailable: vi.fn().mockResolvedValue(true), + }; +} + +function makeRegistry(provider: LlmProvider): ProviderRegistry { + const registry = new ProviderRegistry(); + registry.register(provider); + registry.assignTier(provider.name, 'heavy'); + return registry; +} + +const sampleIndex: PromptIndexForLlm[] = [ + { name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices', chapters: ['Setup', 'Troubleshooting'] }, + { name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration', chapters: null }, + { name: 'common-mistakes', priority: 10, summary: 'Critical safety rules', chapters: null }, +]; + +describe('LlmPromptSelector', () => { + it('sends tags and index to heavy LLM and parses response', async () => { + const provider = makeMockProvider( + '```json\n{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }\n```', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + const result = await selector.selectPrompts(['zigbee', 'pairing'], sampleIndex); + + expect(result.selectedNames).toContain('zigbee-pairing'); + expect(result.selectedNames).toContain('common-mistakes'); // Priority 10 always included + expect(result.reasoning).toBe('User is working with zigbee'); + }); + + it('always includes priority 10 prompts even if LLM omits them', async () => { + const provider = makeMockProvider( + '{ "selectedNames": ["mqtt-config"], "reasoning": "MQTT related" }', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + const result = await selector.selectPrompts(['mqtt'], sampleIndex); + + expect(result.selectedNames).toContain('mqtt-config'); + expect(result.selectedNames).toContain('common-mistakes'); + }); + + it('does not duplicate priority 10 if LLM already selected them', async () => { + const provider = makeMockProvider( + '{ "selectedNames": ["common-mistakes", "mqtt-config"], "reasoning": "Both needed" }', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + const result = await selector.selectPrompts(['mqtt'], sampleIndex); + + const count = result.selectedNames.filter((n) => n === 'common-mistakes').length; + expect(count).toBe(1); + }); + + it('passes system and user messages to provider.complete', async () => { + const provider = makeMockProvider( + '{ "selectedNames": [], "reasoning": "none" }', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + await selector.selectPrompts(['test'], sampleIndex); + + expect(provider.complete).toHaveBeenCalledOnce(); + const call = (provider.complete as ReturnType).mock.calls[0]![0] as CompletionOptions; + expect(call.messages).toHaveLength(2); + expect(call.messages[0]!.role).toBe('system'); + expect(call.messages[1]!.role).toBe('user'); + expect(call.messages[1]!.content).toContain('test'); + expect(call.temperature).toBe(0); + }); + + it('passes model override to complete options', async () => { + const provider = makeMockProvider( + '{ "selectedNames": [], "reasoning": "" }', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry, 'gemini-pro'); + + await selector.selectPrompts(['test'], sampleIndex); + + const call = (provider.complete as ReturnType).mock.calls[0]![0] as CompletionOptions; + expect(call.model).toBe('gemini-pro'); + }); + + it('throws when no heavy provider is available', async () => { + const registry = new ProviderRegistry(); // Empty registry + const selector = new LlmPromptSelector(registry); + + await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow( + 'No heavy LLM provider available', + ); + }); + + it('throws when LLM response has no valid JSON', async () => { + const provider = makeMockProvider('I cannot help with that request.'); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + await expect(selector.selectPrompts(['test'], sampleIndex)).rejects.toThrow( + 'LLM response did not contain valid selection JSON', + ); + }); + + it('handles response with empty selectedNames', async () => { + const provider = makeMockProvider('{ "selectedNames": [], "reasoning": "nothing matched" }'); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + // Empty selectedNames, but priority 10 should still be included + const result = await selector.selectPrompts(['test'], sampleIndex); + expect(result.selectedNames).toEqual(['common-mistakes']); + expect(result.reasoning).toBe('nothing matched'); + }); + + it('handles response with reasoning missing', async () => { + const provider = makeMockProvider('{ "selectedNames": ["mqtt-config"] }'); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + const result = await selector.selectPrompts(['test'], sampleIndex); + expect(result.reasoning).toBe(''); + expect(result.selectedNames).toContain('mqtt-config'); + }); + + it('includes prompt details in the user prompt', async () => { + const indexWithNull: PromptIndexForLlm[] = [ + ...sampleIndex, + { name: 'no-desc', priority: 3, summary: null, chapters: null }, + ]; + const provider = makeMockProvider( + '{ "selectedNames": [], "reasoning": "" }', + ); + const registry = makeRegistry(provider); + const selector = new LlmPromptSelector(registry); + + await selector.selectPrompts(['zigbee'], indexWithNull); + + const call = (provider.complete as ReturnType).mock.calls[0]![0] as CompletionOptions; + const userMsg = call.messages[1]!.content; + expect(userMsg).toContain('zigbee-pairing'); + expect(userMsg).toContain('priority: 7'); + expect(userMsg).toContain('How to pair Zigbee devices'); + expect(userMsg).toContain('Setup, Troubleshooting'); + expect(userMsg).toContain('No summary'); // For prompts with null summary + }); +}); diff --git a/src/mcplocal/tests/router-gate.test.ts b/src/mcplocal/tests/router-gate.test.ts new file mode 100644 index 0000000..5615905 --- /dev/null +++ b/src/mcplocal/tests/router-gate.test.ts @@ -0,0 +1,520 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { McpRouter } from '../src/router.js'; +import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js'; +import type { McpdClient } from '../src/http/mcpd-client.js'; +import { ProviderRegistry } from '../src/providers/registry.js'; +import type { LlmProvider, CompletionResult } from '../src/providers/types.js'; + +function mockUpstream( + name: string, + opts: { tools?: Array<{ name: string; description?: string }> } = {}, +): UpstreamConnection { + return { + name, + isAlive: vi.fn(() => true), + close: vi.fn(async () => {}), + onNotification: vi.fn(), + send: vi.fn(async (req: JsonRpcRequest): Promise => { + if (req.method === 'tools/list') { + return { jsonrpc: '2.0', id: req.id, result: { tools: opts.tools ?? [] } }; + } + if (req.method === 'tools/call') { + return { + jsonrpc: '2.0', + id: req.id, + result: { content: [{ type: 'text', text: `Called ${(req.params as Record)?.name}` }] }, + }; + } + if (req.method === 'resources/list') { + return { jsonrpc: '2.0', id: req.id, result: { resources: [] } }; + } + if (req.method === 'prompts/list') { + return { jsonrpc: '2.0', id: req.id, result: { prompts: [] } }; + } + return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } }; + }), + } as UpstreamConnection; +} + +function mockMcpdClient(prompts: Array<{ name: string; priority: number; summary: string | null; chapters: string[] | null; content: string; type?: string }> = []): McpdClient { + return { + get: vi.fn(async (path: string) => { + if (path.includes('/prompts/visible')) { + return prompts.map((p) => ({ ...p, type: p.type ?? 'prompt' })); + } + if (path.includes('/prompt-index')) { + return prompts.map((p) => ({ + name: p.name, + priority: p.priority, + summary: p.summary, + chapters: p.chapters, + })); + } + return []; + }), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + forward: vi.fn(async () => ({ status: 200, body: {} })), + withHeaders: vi.fn(function (this: McpdClient) { return this; }), + } as unknown as McpdClient; +} + +const samplePrompts = [ + { name: 'common-mistakes', priority: 10, summary: 'Critical safety rules everyone must follow', chapters: null, content: 'NEVER do X. ALWAYS do Y.' }, + { name: 'zigbee-pairing', priority: 7, summary: 'How to pair Zigbee devices with the hub', chapters: ['Setup', 'Troubleshooting'], content: 'Step 1: Put device in pairing mode...' }, + { name: 'mqtt-config', priority: 5, summary: 'MQTT broker configuration guide', chapters: ['Broker Setup', 'Authentication'], content: 'Configure the MQTT broker at...' }, + { name: 'security-policy', priority: 8, summary: 'Security policies for production deployments', chapters: ['Network', 'Auth'], content: 'All connections must use TLS...' }, +]; + +function setupGatedRouter( + opts: { + gated?: boolean; + prompts?: typeof samplePrompts; + withLlm?: boolean; + llmResponse?: string; + } = {}, +): { router: McpRouter; mcpdClient: McpdClient } { + const router = new McpRouter(); + const prompts = opts.prompts ?? samplePrompts; + const mcpdClient = mockMcpdClient(prompts); + router.setPromptConfig(mcpdClient, 'test-project'); + + let providerRegistry: ProviderRegistry | null = null; + if (opts.withLlm) { + providerRegistry = new ProviderRegistry(); + const mockProvider: LlmProvider = { + name: 'mock-heavy', + complete: vi.fn().mockResolvedValue({ + content: opts.llmResponse ?? '{ "selectedNames": ["zigbee-pairing"], "reasoning": "User is working with zigbee" }', + toolCalls: [], + usage: { promptTokens: 100, completionTokens: 50, totalTokens: 150 }, + finishReason: 'stop', + } satisfies CompletionResult), + listModels: vi.fn().mockResolvedValue([]), + isAvailable: vi.fn().mockResolvedValue(true), + }; + providerRegistry.register(mockProvider); + providerRegistry.assignTier(mockProvider.name, 'heavy'); + } + + router.setGateConfig({ + gated: opts.gated !== false, + providerRegistry, + }); + + return { router, mcpdClient }; +} + +describe('McpRouter gating', () => { + describe('initialize with gating', () => { + it('creates gated session on initialize', async () => { + const { router } = setupGatedRouter(); + + const res = await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + expect(res.result).toBeDefined(); + // The session should be gated now + const toolsRes = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { sessionId: 's1' }, + ); + const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe('begin_session'); + }); + + it('creates ungated session when project is not gated', async () => { + const { router } = setupGatedRouter({ gated: false }); + router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] })); + + await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + const toolsRes = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { sessionId: 's1' }, + ); + const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; + const names = tools.map((t) => t.name); + expect(names).toContain('ha/get_entities'); + expect(names).toContain('read_prompts'); + expect(names).not.toContain('begin_session'); + }); + }); + + describe('tools/list gating', () => { + it('shows only begin_session when session is gated', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { sessionId: 's1' }, + ); + + const tools = (res.result as { tools: Array<{ name: string }> }).tools; + expect(tools).toHaveLength(1); + expect(tools[0]!.name).toBe('begin_session'); + }); + + it('shows all tools plus read_prompts after ungating', async () => { + const { router } = setupGatedRouter(); + router.addUpstream(mockUpstream('ha', { tools: [{ name: 'get_entities' }] })); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // Ungate via begin_session + await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + const toolsRes = await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/list' }, + { sessionId: 's1' }, + ); + const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; + const names = tools.map((t) => t.name); + expect(names).toContain('ha/get_entities'); + expect(names).toContain('propose_prompt'); + expect(names).toContain('read_prompts'); + expect(names).not.toContain('begin_session'); + }); + }); + + describe('begin_session', () => { + it('returns matched prompts with keyword matching', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee', 'pairing'] } } }, + { sessionId: 's1' }, + ); + + expect(res.error).toBeUndefined(); + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + // Should include priority 10 prompt + expect(text).toContain('common-mistakes'); + expect(text).toContain('NEVER do X'); + // Should include zigbee-pairing (matches both tags) + expect(text).toContain('zigbee-pairing'); + expect(text).toContain('pairing mode'); + // Should include encouragement + expect(text).toContain('read_prompts'); + }); + + it('includes priority 10 prompts even without matching tags', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['unrelated-keyword'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('common-mistakes'); + expect(text).toContain('NEVER do X'); + }); + + it('uses LLM selection when provider is available', async () => { + const { router } = setupGatedRouter({ + withLlm: true, + llmResponse: '{ "selectedNames": ["zigbee-pairing", "security-policy"], "reasoning": "Zigbee pairing needs security awareness" }', + }); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('Zigbee pairing needs security awareness'); + expect(text).toContain('zigbee-pairing'); + expect(text).toContain('security-policy'); + expect(text).toContain('common-mistakes'); // priority 10 always included + }); + + it('rejects empty tags', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: [] } } }, + { sessionId: 's1' }, + ); + + expect(res.error).toBeDefined(); + expect(res.error!.code).toBe(-32602); + }); + + it('returns message when session is already ungated', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // First call ungates + await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + // Second call tells user to use read_prompts + const res = await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['mqtt'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('already started'); + expect(text).toContain('read_prompts'); + }); + + it('lists remaining prompts for awareness', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + // Non-matching prompts should be listed as "other available prompts" + // security-policy doesn't match 'zigbee' in keyword mode + expect(text).toContain('security-policy'); + }); + }); + + describe('read_prompts', () => { + it('returns prompts matching keywords', async () => { + const { router } = setupGatedRouter({ gated: false }); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt', 'broker'] } } }, + { sessionId: 's1' }, + ); + + expect(res.error).toBeUndefined(); + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('mqtt-config'); + expect(text).toContain('Configure the MQTT broker'); + }); + + it('filters out already-sent prompts', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // begin_session sends common-mistakes (priority 10) and zigbee-pairing + await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'begin_session', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + // read_prompts for mqtt should not re-send common-mistakes + const res = await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('mqtt-config'); + // common-mistakes was already sent, should not appear again + expect(text).not.toContain('NEVER do X'); + }); + + it('returns message when no new prompts match', async () => { + const { router } = setupGatedRouter({ prompts: [] }); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['nonexistent'] } } }, + { sessionId: 's1' }, + ); + + const text = ((res.result as { content: Array<{ text: string }> }).content[0]!.text); + expect(text).toContain('No new matching prompts'); + }); + + it('rejects empty tags', async () => { + const { router } = setupGatedRouter({ gated: false }); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: [] } } }, + { sessionId: 's1' }, + ); + + expect(res.error).toBeDefined(); + expect(res.error!.code).toBe(-32602); + }); + }); + + describe('gated intercept', () => { + it('auto-ungates when gated session calls a real tool', async () => { + const { router } = setupGatedRouter(); + const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] }); + router.addUpstream(ha); + await router.discoverTools(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // Call a real tool while gated — should intercept, extract keywords, and route + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } }, + { sessionId: 's1' }, + ); + + // Response should include the tool result + expect(res.error).toBeUndefined(); + const result = res.result as { content: Array<{ type: string; text: string }> }; + // Should have briefing prepended + expect(result.content.length).toBeGreaterThanOrEqual(1); + + // Session should now be ungated + const toolsRes = await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/list' }, + { sessionId: 's1' }, + ); + const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; + expect(tools.map((t) => t.name)).toContain('ha/get_entities'); + }); + + it('includes project context in intercepted response', async () => { + const { router } = setupGatedRouter(); + const ha = mockUpstream('ha', { tools: [{ name: 'get_entities' }] }); + router.addUpstream(ha); + await router.discoverTools(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + const res = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'ha/get_entities', arguments: { domain: 'light' } } }, + { sessionId: 's1' }, + ); + + const result = res.result as { content: Array<{ type: string; text: string }> }; + // First content block should be the briefing (priority 10 at minimum) + const briefing = result.content[0]!.text; + expect(briefing).toContain('common-mistakes'); + expect(briefing).toContain('NEVER do X'); + }); + }); + + describe('initialize instructions for gated projects', () => { + it('includes gate message and prompt index in instructions', async () => { + const { router } = setupGatedRouter(); + + const res = await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + const result = res.result as { instructions?: string }; + expect(result.instructions).toBeDefined(); + expect(result.instructions).toContain('begin_session'); + expect(result.instructions).toContain('gated session'); + // Should list available prompts + expect(result.instructions).toContain('common-mistakes'); + expect(result.instructions).toContain('zigbee-pairing'); + }); + + it('does not include gate message for non-gated projects', async () => { + const { router } = setupGatedRouter({ gated: false }); + router.setInstructions('Base project instructions'); + + const res = await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + const result = res.result as { instructions?: string }; + expect(result.instructions).toBe('Base project instructions'); + expect(result.instructions).not.toContain('gated session'); + }); + + it('preserves base instructions and appends gate message', async () => { + const { router } = setupGatedRouter(); + router.setInstructions('You are a helpful assistant.'); + + const res = await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + const result = res.result as { instructions?: string }; + expect(result.instructions).toContain('You are a helpful assistant.'); + expect(result.instructions).toContain('begin_session'); + }); + + it('sorts prompt index by priority descending', async () => { + const { router } = setupGatedRouter(); + + const res = await router.route( + { jsonrpc: '2.0', id: 1, method: 'initialize' }, + { sessionId: 's1' }, + ); + + const result = res.result as { instructions: string }; + const lines = result.instructions.split('\n'); + // Find the prompt index lines + const promptLines = lines.filter((l) => l.startsWith('- ') && l.includes('priority')); + // priority 10 should come first + expect(promptLines[0]).toContain('common-mistakes'); + expect(promptLines[0]).toContain('priority 10'); + }); + }); + + describe('session cleanup', () => { + it('cleanupSession removes gate state', async () => { + const { router } = setupGatedRouter(); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // Session is gated + let toolsRes = await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/list' }, + { sessionId: 's1' }, + ); + expect((toolsRes.result as { tools: Array<{ name: string }> }).tools[0]!.name).toBe('begin_session'); + + // Cleanup + router.cleanupSession('s1'); + + // After cleanup, session is treated as unknown (ungated) + toolsRes = await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/list' }, + { sessionId: 's1' }, + ); + const tools = (toolsRes.result as { tools: Array<{ name: string }> }).tools; + expect(tools.map((t) => t.name)).not.toContain('begin_session'); + }); + }); + + describe('prompt index caching', () => { + it('caches prompt index for 60 seconds', async () => { + const { router, mcpdClient } = setupGatedRouter({ gated: false }); + await router.route({ jsonrpc: '2.0', id: 1, method: 'initialize' }, { sessionId: 's1' }); + + // First read_prompts call fetches from mcpd + await router.route( + { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['mqtt'] } } }, + { sessionId: 's1' }, + ); + + // Second call should use cache + await router.route( + { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_prompts', arguments: { tags: ['zigbee'] } } }, + { sessionId: 's1' }, + ); + + // mcpdClient.get should have been called only once for prompts/visible + const getCalls = vi.mocked(mcpdClient.get).mock.calls.filter((c) => (c[0] as string).includes('/prompts/visible')); + expect(getCalls).toHaveLength(1); + }); + }); +}); diff --git a/src/mcplocal/tests/router-prompts.test.ts b/src/mcplocal/tests/router-prompts.test.ts index a31ffb7..431303c 100644 --- a/src/mcplocal/tests/router-prompts.test.ts +++ b/src/mcplocal/tests/router-prompts.test.ts @@ -165,16 +165,13 @@ describe('McpRouter - Prompt Integration', () => { ); }); - it('should read mcpctl resource content', async () => { + it('should read mcpctl resource content live from mcpd', async () => { router.setPromptConfig(mcpdClient, 'proj'); vi.mocked(mcpdClient.get).mockResolvedValue([ { name: 'my-prompt', content: 'The content here', type: 'prompt' }, ]); - // First list to populate cache - await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' }); - - // Then read + // Read directly — no need to list first const response = await router.route({ jsonrpc: '2.0', id: 2, @@ -187,8 +184,55 @@ describe('McpRouter - Prompt Integration', () => { expect(contents[0]!.text).toBe('The content here'); }); + it('should return fresh content after prompt update', async () => { + router.setPromptConfig(mcpdClient, 'proj'); + + // First call returns old content + vi.mocked(mcpdClient.get).mockResolvedValueOnce([ + { name: 'my-prompt', content: 'Old content', type: 'prompt' }, + ]); + await router.route({ + jsonrpc: '2.0', id: 1, method: 'resources/read', + params: { uri: 'mcpctl://prompts/my-prompt' }, + }); + + // Second call returns updated content + vi.mocked(mcpdClient.get).mockResolvedValueOnce([ + { name: 'my-prompt', content: 'Updated content', type: 'prompt' }, + ]); + const response = await router.route({ + jsonrpc: '2.0', id: 2, method: 'resources/read', + params: { uri: 'mcpctl://prompts/my-prompt' }, + }); + + const contents = (response.result as { contents: Array<{ text: string }> }).contents; + expect(contents[0]!.text).toBe('Updated content'); + }); + + it('should fall back to cache when mcpd is unreachable on read', async () => { + router.setPromptConfig(mcpdClient, 'proj'); + + // Populate cache via list + vi.mocked(mcpdClient.get).mockResolvedValueOnce([ + { name: 'cached-prompt', content: 'Cached content', type: 'prompt' }, + ]); + await router.route({ jsonrpc: '2.0', id: 1, method: 'resources/list' }); + + // mcpd goes down for read + vi.mocked(mcpdClient.get).mockRejectedValueOnce(new Error('Connection refused')); + const response = await router.route({ + jsonrpc: '2.0', id: 2, method: 'resources/read', + params: { uri: 'mcpctl://prompts/cached-prompt' }, + }); + + expect(response.error).toBeUndefined(); + const contents = (response.result as { contents: Array<{ text: string }> }).contents; + expect(contents[0]!.text).toBe('Cached content'); + }); + it('should return error for unknown mcpctl resource', async () => { router.setPromptConfig(mcpdClient, 'proj'); + vi.mocked(mcpdClient.get).mockResolvedValue([]); const response = await router.route({ jsonrpc: '2.0', diff --git a/src/mcplocal/tests/session-gate.test.ts b/src/mcplocal/tests/session-gate.test.ts new file mode 100644 index 0000000..a086df1 --- /dev/null +++ b/src/mcplocal/tests/session-gate.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { SessionGate } from '../src/gate/session-gate.js'; +import type { TagMatchResult, PromptIndexEntry } from '../src/gate/tag-matcher.js'; + +function makeMatchResult(names: string[]): TagMatchResult { + return { + fullContent: names.map((name) => ({ + name, + priority: 5, + summary: null, + chapters: null, + content: `Content of ${name}`, + })), + indexOnly: [], + remaining: [], + }; +} + +describe('SessionGate', () => { + it('creates a gated session when project is gated', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + expect(gate.isGated('s1')).toBe(true); + }); + + it('creates an ungated session when project is not gated', () => { + const gate = new SessionGate(); + gate.createSession('s1', false); + expect(gate.isGated('s1')).toBe(false); + }); + + it('unknown sessions are treated as ungated', () => { + const gate = new SessionGate(); + expect(gate.isGated('nonexistent')).toBe(false); + }); + + it('getSession returns null for unknown sessions', () => { + const gate = new SessionGate(); + expect(gate.getSession('nonexistent')).toBeNull(); + }); + + it('getSession returns session state', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + const state = gate.getSession('s1'); + expect(state).not.toBeNull(); + expect(state!.gated).toBe(true); + expect(state!.tags).toEqual([]); + expect(state!.retrievedPrompts.size).toBe(0); + expect(state!.briefing).toBeNull(); + }); + + it('ungate marks session as ungated and records tags', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + + gate.ungate('s1', ['zigbee', 'mqtt'], makeMatchResult(['prompt-a', 'prompt-b'])); + + expect(gate.isGated('s1')).toBe(false); + const state = gate.getSession('s1'); + expect(state!.tags).toEqual(['zigbee', 'mqtt']); + expect(state!.retrievedPrompts.has('prompt-a')).toBe(true); + expect(state!.retrievedPrompts.has('prompt-b')).toBe(true); + }); + + it('ungate appends tags on repeated calls', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + + gate.ungate('s1', ['zigbee'], makeMatchResult(['p1'])); + gate.ungate('s1', ['mqtt'], makeMatchResult(['p2'])); + + const state = gate.getSession('s1'); + expect(state!.tags).toEqual(['zigbee', 'mqtt']); + expect(state!.retrievedPrompts.has('p1')).toBe(true); + expect(state!.retrievedPrompts.has('p2')).toBe(true); + }); + + it('ungate is no-op for unknown sessions', () => { + const gate = new SessionGate(); + // Should not throw + gate.ungate('nonexistent', ['tag'], makeMatchResult(['p'])); + }); + + it('addRetrievedPrompts records additional prompts', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + gate.ungate('s1', ['zigbee'], makeMatchResult(['p1'])); + + gate.addRetrievedPrompts('s1', ['mqtt', 'lights'], ['p2', 'p3']); + + const state = gate.getSession('s1'); + expect(state!.tags).toEqual(['zigbee', 'mqtt', 'lights']); + expect(state!.retrievedPrompts.has('p2')).toBe(true); + expect(state!.retrievedPrompts.has('p3')).toBe(true); + }); + + it('addRetrievedPrompts is no-op for unknown sessions', () => { + const gate = new SessionGate(); + gate.addRetrievedPrompts('nonexistent', ['tag'], ['p']); + }); + + it('filterAlreadySent removes already-sent prompts', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + gate.ungate('s1', ['zigbee'], makeMatchResult(['p1'])); + + const prompts: PromptIndexEntry[] = [ + { name: 'p1', priority: 5, summary: 'already sent', chapters: null, content: 'x' }, + { name: 'p2', priority: 5, summary: 'new', chapters: null, content: 'y' }, + ]; + + const filtered = gate.filterAlreadySent('s1', prompts); + expect(filtered).toHaveLength(1); + expect(filtered[0]!.name).toBe('p2'); + }); + + it('filterAlreadySent returns all prompts for unknown sessions', () => { + const gate = new SessionGate(); + const prompts: PromptIndexEntry[] = [ + { name: 'p1', priority: 5, summary: null, chapters: null, content: 'x' }, + ]; + + const filtered = gate.filterAlreadySent('nonexistent', prompts); + expect(filtered).toHaveLength(1); + }); + + it('removeSession cleans up state', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + expect(gate.getSession('s1')).not.toBeNull(); + + gate.removeSession('s1'); + expect(gate.getSession('s1')).toBeNull(); + expect(gate.isGated('s1')).toBe(false); + }); + + it('removeSession is safe for unknown sessions', () => { + const gate = new SessionGate(); + gate.removeSession('nonexistent'); // Should not throw + }); + + it('manages multiple sessions independently', () => { + const gate = new SessionGate(); + gate.createSession('s1', true); + gate.createSession('s2', false); + + expect(gate.isGated('s1')).toBe(true); + expect(gate.isGated('s2')).toBe(false); + + gate.ungate('s1', ['zigbee'], makeMatchResult(['p1'])); + expect(gate.isGated('s1')).toBe(false); + expect(gate.getSession('s2')!.tags).toEqual([]); // s2 untouched + }); +}); diff --git a/src/mcplocal/tests/tag-matcher.test.ts b/src/mcplocal/tests/tag-matcher.test.ts new file mode 100644 index 0000000..c095431 --- /dev/null +++ b/src/mcplocal/tests/tag-matcher.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect } from 'vitest'; +import { TagMatcher, extractKeywordsFromToolCall, type PromptIndexEntry } from '../src/gate/tag-matcher.js'; + +function makePrompt(overrides: Partial = {}): PromptIndexEntry { + return { + name: 'test-prompt', + priority: 5, + summary: 'A test prompt for testing', + chapters: ['Chapter One', 'Chapter Two'], + content: 'Full content of the test prompt.', + ...overrides, + }; +} + +describe('TagMatcher', () => { + it('returns priority 10 prompts regardless of tags', () => { + const matcher = new TagMatcher(); + const critical = makePrompt({ name: 'common-mistakes', priority: 10, summary: 'Unrelated stuff' }); + const normal = makePrompt({ name: 'normal', priority: 5, summary: 'Something else' }); + + const result = matcher.match([], [critical, normal]); + expect(result.fullContent.map((p) => p.name)).toEqual(['common-mistakes']); + expect(result.remaining.map((p) => p.name)).toEqual(['normal']); + }); + + it('scores by matching_tags * priority', () => { + const matcher = new TagMatcher(); + const high = makePrompt({ name: 'important', priority: 8, summary: 'zigbee mqtt pairing' }); + const low = makePrompt({ name: 'basic', priority: 3, summary: 'zigbee basics' }); + + // Both match "zigbee": high scores 1*8=8, low scores 1*3=3 + const result = matcher.match(['zigbee'], [low, high]); + expect(result.fullContent[0]!.name).toBe('important'); + expect(result.fullContent[1]!.name).toBe('basic'); + }); + + it('matches more tags = higher score', () => { + const matcher = new TagMatcher(); + const twoMatch = makePrompt({ name: 'two-match', priority: 5, summary: 'zigbee mqtt' }); + const oneMatch = makePrompt({ name: 'one-match', priority: 5, summary: 'zigbee only' }); + + // two-match: 2*5=10, one-match: 1*5=5 + const result = matcher.match(['zigbee', 'mqtt'], [oneMatch, twoMatch]); + expect(result.fullContent[0]!.name).toBe('two-match'); + }); + + it('performs case-insensitive matching', () => { + const matcher = new TagMatcher(); + const prompt = makePrompt({ name: 'test', summary: 'ZIGBEE Protocol Setup' }); + + const result = matcher.match(['zigbee'], [prompt]); + expect(result.fullContent).toHaveLength(1); + }); + + it('matches against name, summary, and chapters', () => { + const matcher = new TagMatcher(); + const byName = makePrompt({ name: 'zigbee-config', summary: 'unrelated', chapters: [] }); + const bySummary = makePrompt({ name: 'setup', summary: 'zigbee setup guide', chapters: [] }); + const byChapter = makePrompt({ name: 'guide', summary: 'unrelated', chapters: ['Zigbee Pairing'] }); + + const result = matcher.match(['zigbee'], [byName, bySummary, byChapter]); + expect(result.fullContent).toHaveLength(3); + }); + + it('respects byte budget', () => { + const matcher = new TagMatcher(100); // Very small budget + const small = makePrompt({ name: 'small', summary: 'zigbee', content: 'Short.' }); // ~6 bytes + const big = makePrompt({ name: 'big', summary: 'zigbee', content: 'x'.repeat(200) }); // 200 bytes + + const result = matcher.match(['zigbee'], [small, big]); + expect(result.fullContent.map((p) => p.name)).toEqual(['small']); + expect(result.indexOnly.map((p) => p.name)).toEqual(['big']); + }); + + it('puts non-matched prompts in remaining', () => { + const matcher = new TagMatcher(); + const matched = makePrompt({ name: 'matched', summary: 'zigbee stuff' }); + const unmatched = makePrompt({ name: 'unmatched', summary: 'completely different topic' }); + + const result = matcher.match(['zigbee'], [matched, unmatched]); + expect(result.fullContent.map((p) => p.name)).toEqual(['matched']); + expect(result.remaining.map((p) => p.name)).toEqual(['unmatched']); + }); + + it('handles empty tags — only priority 10 matched', () => { + const matcher = new TagMatcher(); + const critical = makePrompt({ name: 'critical', priority: 10 }); + const normal = makePrompt({ name: 'normal', priority: 5 }); + + const result = matcher.match([], [critical, normal]); + expect(result.fullContent.map((p) => p.name)).toEqual(['critical']); + expect(result.remaining.map((p) => p.name)).toEqual(['normal']); + }); + + it('handles empty prompts array', () => { + const matcher = new TagMatcher(); + const result = matcher.match(['zigbee'], []); + expect(result.fullContent).toEqual([]); + expect(result.indexOnly).toEqual([]); + expect(result.remaining).toEqual([]); + }); + + it('all priority 10 prompts are included even beyond budget', () => { + const matcher = new TagMatcher(50); // Tiny budget + const c1 = makePrompt({ name: 'c1', priority: 10, content: 'x'.repeat(40) }); + const c2 = makePrompt({ name: 'c2', priority: 10, content: 'y'.repeat(40) }); + + const result = matcher.match([], [c1, c2]); + // Both should be in fullContent — priority 10 has Infinity score + // First one fits budget, second overflows but still priority 10 + expect(result.fullContent.length + result.indexOnly.length).toBe(2); + // At minimum the first one is in fullContent + expect(result.fullContent[0]!.name).toBe('c1'); + }); + + it('sorts matched by score descending', () => { + const matcher = new TagMatcher(); + const p1 = makePrompt({ name: 'p1', priority: 3, summary: 'mqtt zigbee lights' }); // 3 matches * 3 = 9 + const p2 = makePrompt({ name: 'p2', priority: 8, summary: 'mqtt' }); // 1 match * 8 = 8 + const p3 = makePrompt({ name: 'p3', priority: 2, summary: 'mqtt zigbee lights pairing automation' }); // 5 * 2 = 10 + + const result = matcher.match(['mqtt', 'zigbee', 'lights', 'pairing', 'automation'], [p1, p2, p3]); + expect(result.fullContent.map((p) => p.name)).toEqual(['p3', 'p1', 'p2']); + }); +}); + +describe('extractKeywordsFromToolCall', () => { + it('extracts from tool name', () => { + const keywords = extractKeywordsFromToolCall('home-assistant/get_entities', {}); + expect(keywords).toContain('home'); + expect(keywords).toContain('assistant'); + expect(keywords).toContain('get_entities'); + }); + + it('extracts from string arguments', () => { + const keywords = extractKeywordsFromToolCall('tool', { domain: 'light', area: 'kitchen' }); + expect(keywords).toContain('light'); + expect(keywords).toContain('kitchen'); + }); + + it('ignores short words (<=2 chars)', () => { + const keywords = extractKeywordsFromToolCall('ab', { x: 'hi' }); + expect(keywords).not.toContain('ab'); + expect(keywords).not.toContain('hi'); + }); + + it('ignores long string values (>200 chars)', () => { + const keywords = extractKeywordsFromToolCall('tool', { data: 'x'.repeat(201) }); + // Only 'tool' from the name + expect(keywords).toEqual(['tool']); + }); + + it('caps at 10 keywords', () => { + const args: Record = {}; + for (let i = 0; i < 20; i++) args[`key${i}`] = `keyword${i}value`; + const keywords = extractKeywordsFromToolCall('tool', args); + expect(keywords.length).toBeLessThanOrEqual(10); + }); + + it('lowercases all keywords', () => { + const keywords = extractKeywordsFromToolCall('MyTool', { name: 'MQTT' }); + expect(keywords).toContain('mytool'); + expect(keywords).toContain('mqtt'); + }); +});