Skip to the content

Capsid menu

src/prompts.ts

93 lines and 9 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of prompts.ts
FunctionLinesComplexityCoveredCRAPMutation
registerPrompts6 to 941100%OkCRAP 1No data: mutation testing has not run on this repo yet
promptVariables16 to 161100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:16>16 to 161100%OkCRAP 1No data: mutation testing has not run on this repo yet
promptSafeTitle21 to 253100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:27>27 to 412100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:35>35 to 392100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:38>38 to 381100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:42>42 to 93884%WatchCRAP 8.3No data: mutation testing has not run on this repo yet
<anonymous:66>66 to 73263%OkCRAP 2.2No data: mutation testing has not run on this repo yet

Imports

It imports src/agents.ts, src/resources.ts, src/scope.ts. Imported by src/server.ts.

Source

import { McpServer, ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server";
import type { Agent } from "./agents";
import { checkScope } from "./scope";
import { namespaceFilter, resourceRefusal } from "./resources";

export function registerPrompts(server: McpServer, agent: Agent, db: D1Database): void {
  // Prompts: reusable templates stored as type 'prompt' documents whose bodies use
  // {{variable}} placeholders. Handled at the protocol level, since McpServer only
  // lists prompts registered at build time, so the D1 query runs lazily on
  // prompts/list and prompts/get.
  //
  // A prompt is named "<namespace>/<path without .md>", the way every other tool
  // addresses a document. A bare path is not a document key: two namespaces can both
  // hold prompts/brief.md.
  const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
  const promptVariables = (body: string) => [...new Set([...body.matchAll(PLACEHOLDER)].map((m) => m[1]))];
  // A title reaches the client's model as a description, which it trusts like a tool
  // description, and any write-grant session can write it, so it passes a character
  // allowlist (no backticks, braces or control characters) and a length cap.
  const PROMPT_TITLE_DISALLOWED = /[^A-Za-z0-9 ,.;:()'"!?_/-]+/g;
  const promptSafeTitle = (title: string | null): string | undefined => {
    if (!title) return undefined;
    const safe = title.replace(PROMPT_TITLE_DISALLOWED, " ").replace(/\s+/g, " ").trim().slice(0, 200);
    return safe || undefined;
  };
  server.server.registerCapabilities({ prompts: { listChanged: false } });
  server.server.setRequestHandler("prompts/list", async () => {
    // The whole scope check, as prompts/get makes it (Grok audit 2026-10-21, CA-5).
    const listRefusal = checkScope(agent, { tool: "capsid_read_doc", action: "read", grant: "read" });
    if (listRefusal) throw new ProtocolError(ProtocolErrorCode.InvalidParams, listRefusal);
    const { results } = await db
      .prepare("SELECT namespace, path, title, body FROM documents WHERE type = 'prompt' ORDER BY namespace, path")
      .all<{ namespace: string; path: string; title: string | null; body: string | null }>();
    return {
      prompts: namespaceFilter(agent, results).map((row) => ({
        name: `${row.namespace}/${row.path.replace(/\.md$/, "")}`,
        description: promptSafeTitle(row.title),
        arguments: promptVariables(row.body ?? "").map((name) => ({ name, required: true })),
      })),
    };
  });
  server.server.setRequestHandler("prompts/get", async (request) => {
    const { name } = request.params;
    const args = request.params.arguments ?? {};
    // Split on the FIRST slash only: the namespace never contains one and the
    // path frequently does.
    const slash = name.indexOf("/");
    if (slash <= 0 || slash === name.length - 1) {
      throw new ProtocolError(
        ProtocolErrorCode.InvalidParams,
        `prompt name must be "<namespace>/<path>", got "${name}". Call prompts/list for the available names.`
      );
    }
    const promptNs = name.slice(0, slash);
    const promptPath = name.slice(slash + 1);
    const refusal = resourceRefusal(agent, promptNs);
    if (refusal) throw new ProtocolError(ProtocolErrorCode.InvalidParams, refusal);
    const row = await db
      .prepare(
        "SELECT title, body FROM documents WHERE type = 'prompt' AND namespace = ?1 AND (path = ?2 OR path = ?2 || '.md')"
      )
      .bind(promptNs, promptPath)
      .first<{ title: string | null; body: string | null }>();
    if (!row) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `prompt not found: ${name}`);
    const missing = new Set<string>();
    const text = (row.body ?? "").replace(PLACEHOLDER, (placeholder, variable: string) => {
      const value = args[variable];
      if (value === undefined) {
        missing.add(variable);
        return placeholder;
      }
      return String(value);
    });
    if (missing.size > 0) {
      throw new ProtocolError(ProtocolErrorCode.InvalidParams, `missing arguments for prompt ${name}: ${[...missing].join(", ")}`);
    }
    return {
      description: promptSafeTitle(row.title),
      // The body is data, not the user's own words. Any write-grant session can write
      // it, and returning it as plain user text would hand whoever last wrote the row
      // a message the client's model reads as its human speaking. An embedded
      // resource is the protocol's shape for content from a store.
      messages: [
        {
          role: "user" as const,
          content: {
            type: "resource" as const,
            resource: { uri: `capsid://${promptNs}/${promptPath}`, mimeType: "text/markdown", text },
          },
        },
      ],
    };
  });
}