src/resources.ts
115 lines and 7 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| resourceRefusal | 13 to 15 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| namespaceFilter | 18 to 21 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:20> | 20 to 20 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| registerDocumentResources | 27 to 121 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:42> | 42 to 53 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| <anonymous:64> | 64 to 120 | 13 | 100% | WatchCRAP 13 | No data: mutation testing has not run on this repo yet |
| <anonymous:110> | 110 to 117 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/agents.ts, src/encoding.ts, src/limits.ts, src/scope.ts. Imported by src/prompts.ts, src/server.ts.
Source
import { McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate } from "@modelcontextprotocol/server";
import type { Agent } from "./agents";
import { checkScope } from "./scope";
import { b64urlDecode, b64urlEncode } from "./encoding";
import { MAX_ROWS } from "./limits";
// Scopes for resources and prompts. guardRegistrations wraps registerTool only, and
// a raw request handler is not a registration, so these handlers check scope here.
// Without it, an agent bearer on /ops/mcp could read every document by URI and list
// every prompt while capsid_read_doc refused it.
// They are checked as capsid_read_doc action read, so an agent narrowed away from it loses
// both and the two surfaces cannot disagree.
export function resourceRefusal(agent: Agent, namespace: string): string | null {
return checkScope(agent, { tool: "capsid_read_doc", action: "read", grant: "read", namespace });
}
// A listing filters rather than refusing, which would leak that there is more.
export function namespaceFilter<T extends { namespace: string }>(agent: Agent, rows: T[]): T[] {
const visibleNamespaces = agent.scopes.namespaces;
return visibleNamespaces === "*" ? rows : rows.filter((row) => visibleNamespaces.includes(row.namespace));
}
// Stated once, used by the read registration and the list handler below. It spreads
// onto every listed resource, so keep it to fields true of every document.
const RESOURCE_METADATA = { title: "Capsid documents", mimeType: "text/markdown" };
export function registerDocumentResources(server: McpServer, agent: Agent, db: D1Database): void {
const visibleNamespaces = agent.scopes.namespaces;
// Resources: every document is addressable context at capsid://<namespace>/<path>.
// Read-only, same visibility as capsid_read_doc.
server.registerResource(
"document",
new ResourceTemplate("capsid://{namespace}/{+path}", {
// Listing is served by the raw handler below: McpServer discards _meta and
// nextCursor from a list callback, so a callback cannot say it was truncated,
// and capping it here would make document 501 unreachable with nothing in the
// response admitting it.
list: undefined,
}),
RESOURCE_METADATA,
async (uri, variables) => {
const namespace = String(variables.namespace);
const path = String(variables.path);
const refusal = resourceRefusal(agent, namespace);
if (refusal) throw new ProtocolError(ProtocolErrorCode.InvalidParams, refusal);
const row = await db
.prepare("SELECT body FROM documents WHERE namespace = ?1 AND path = ?2")
.bind(namespace, path)
.first<{ body: string | null }>();
if (!row) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `not found: ${uri.href}`);
return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: row.body ?? "" }] };
}
);
// resources/list, served directly because the request carries the cursor and the
// McpServer wrapper does not pass it through.
//
// Keyset pagination compared as a tuple: a concatenated key would skip rows ('-'
// sorts below '/').
//
// This overrides McpServer's handler, which is safe only while every resource is
// served by the one template above. test/bounded-reads.test.ts pins that.
server.server.setRequestHandler("resources/list", async (request) => {
// The grant, before the query.
// Every result is filtered to the caller's namespaces, so its cache hint is private
// (src/cache-hints.ts).
// The whole scope check, as resources/read makes it, so a caller narrowed away from
// capsid_read_doc (or a bench run key) lists nothing either (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);
if (visibleNamespaces !== "*" && visibleNamespaces.length === 0) return { resources: [] };
const cursor = request.params?.cursor;
let afterNs = "";
let afterPath = "";
if (typeof cursor === "string" && cursor.length > 0) {
try {
const parsed = JSON.parse(b64urlDecode(cursor)) as { n?: unknown; p?: unknown };
afterNs = typeof parsed.n === "string" ? parsed.n : "";
afterPath = typeof parsed.p === "string" ? parsed.p : "";
} catch {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, "invalid resources/list cursor; omit it to start from the beginning");
}
}
// Filtered in the query, so the LIMIT bounds what this caller can see rather than
// what the store holds. A filter after the LIMIT gives a caller scoped to a
// namespace that sorts late a page of rows it may not see, an empty page after
// filtering and no cursor, so its own documents are unreachable. The keyset
// cursor names the last row returned, which keeps the walk correct.
//
// One bound parameter rather than a spliced clause, so the statement text is fixed
// and test-integration/query-plans.test.ts can plan it. Null is the unscoped caller.
const visible = visibleNamespaces === "*" ? null : JSON.stringify(visibleNamespaces);
const { results } = await db
.prepare(
`SELECT namespace, path, title FROM documents
WHERE ((?1 = '' AND ?2 = '') OR (namespace > ?1 OR (namespace = ?1 AND path > ?2)))
AND (?4 IS NULL OR namespace IN (SELECT value FROM json_each(?4)))
ORDER BY namespace, path
LIMIT ?3`
)
.bind(afterNs, afterPath, MAX_ROWS + 1, visible)
.all<{ namespace: string; path: string; title: string | null }>();
// A no-op second pass that still holds if an edit loses the predicate.
const scoped = namespaceFilter(agent, results);
const more = scoped.length > MAX_ROWS;
const kept = more ? scoped.slice(0, MAX_ROWS) : scoped;
const last = kept[kept.length - 1];
return {
resources: kept.map((row) => ({
// Template metadata first, so a document's own title wins.
...RESOURCE_METADATA,
uri: `capsid://${row.namespace}/${row.path}`,
name: `${row.namespace}/${row.path}`,
title: row.title ?? undefined,
mimeType: "text/markdown",
})),
...(more && last ? { nextCursor: b64urlEncode(JSON.stringify({ n: last.namespace, p: last.path })) } : {}),
};
});
}