src/prompts.ts
93 lines and 9 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| registerPrompts | 6 to 94 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| promptVariables | 16 to 16 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:16> | 16 to 16 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| promptSafeTitle | 21 to 25 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:27> | 27 to 41 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:35> | 35 to 39 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:38> | 38 to 38 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:42> | 42 to 93 | 8 | 84% | WatchCRAP 8.3 | No data: mutation testing has not run on this repo yet |
| <anonymous:66> | 66 to 73 | 2 | 63% | OkCRAP 2.2 | No 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 },
},
},
],
};
});
}