src/tools/namespaces.ts
166 lines and 6 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. It is in an import cycle. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| defineNamespaceOperations | 17 to 171 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:27> | 27 to 48 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:46> | 46 to 46 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:67> | 67 to 102 | 10 | 81% | WatchCRAP 10.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:115> | 115 to 134 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| <anonymous:153> | 153 to 169 | 6 | 6% | CriticalCRAP 36 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/agents-schema.ts, src/github.ts, src/limits.ts, src/namespace-delete.ts, src/scope.ts, src/store-guards.ts, src/tools/docs.ts, src/tools/surface.ts. Imported by src/tools/docs.ts.
Source
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import { IMPROVE_OVERRIDE_FLAGS } from "../scope";
import { surfaceOf } from "./surface";
import { NAMESPACE_DELETE_MAX_DOCUMENTS, performNamespaceDelete, previewNamespaceDelete } from "../namespace-delete";
import { parseReposList, REPO_SHAPE, requireSinglePrimary } from "../github";
import { auditStatement } from "../store-guards";
import { driverMintInstruction } from "../agents-schema";
import { bounded, MAX_CONFIRM_TOKEN, MAX_REPO_SELECTOR, MAX_REPOS_JSON, nsName } from "../limits";
import { ok, fail, type ToolCtx } from "./docs";
// The namespace operations: namespaces (capsid_find_docs action namespaces), and
// register_namespace, update_namespace and delete_namespace (capsid_admin_manage_namespaces
// actions register, update, delete_preview and delete_perform).
// defineDocOperations in ./docs registers them after the document tools.
export function defineNamespaceOperations(server: McpServer, ctx: ToolCtx): void {
const { env, db, actor } = ctx;
ctx.ops.define(
"namespaces",
{
description:
"List the namespaces this caller is scoped to and the repos each maps to. A scoped agent sees only its own namespaces.",
inputSchema: {},
},
async () => {
// unconsolidated = episodic and source docs not yet archived by the lint loop,
// surfaced so every session sees which namespaces need a run. Not filtered on
// status: the archive/ prefix is the only thing that takes a doc out of the loop.
const { results } = await db
.prepare(
`SELECT n.namespace, n.repos, n.created_at,
(SELECT COUNT(*) FROM documents d
WHERE d.namespace = n.namespace AND d.type IN ('episodic', 'source')
AND d.path NOT LIKE 'archive/%') AS unconsolidated
FROM namespaces n ORDER BY n.namespace`
)
.all();
// Filtered to the caller's own namespaces. This tool takes no arguments, so
// namespaceRefusal at the registrar has nothing to fire on. The mapping is what
// scripts/mint-agents.ts uses to set the repos axis and what resolveRepo
// resolves through, so handing it to a narrowed caller hands it the shape of the
// boundary it sits behind.
const scoped = ctx.agent.scopes.namespaces;
const visible = scoped === "*" ? results : results.filter((row) => scoped.includes(String((row as { namespace: string }).namespace)));
return ok(visible);
}
);
// Register a namespace: the one row in the namespaces table that repo tools and the
// namespaces list read. Writing documents to a new namespace label does not create
// it. Create-only: it will not overwrite an existing mapping. Admin only (TOOL_GRANTS in src/scope.ts): an OAuth
// session qualifies, a minted agent holding the write grant does not.
ctx.ops.define(
"register_namespace",
{
description:
"Register a namespace in the namespaces table. Give repo as 'owner/name' (label defaults to 'primary'), or pass a repos JSON array like [{\"repo\":\"owner/name\",\"label\":\"primary\"}] for a multi-repo namespace. Refused if the namespace exists. Admin only: an OAuth session qualifies, a minted agent holding the write grant does not.",
inputSchema: {
namespace: nsName,
repo: bounded(MAX_REPO_SELECTOR).optional(),
label: bounded(MAX_REPO_SELECTOR).optional(),
repos: bounded(MAX_REPOS_JSON).optional(),
},
},
async ({ namespace, repo, label, repos }) => {
const ns = namespace.trim();
if (!ns) return fail("namespace is required");
let list: Array<{ repo: string; label: string }>;
if (repos) {
const parsed = parseReposList(repos);
if ("error" in parsed) return fail(parsed.error);
list = parsed.list;
} else {
if (!repo || !REPO_SHAPE.test(repo)) {
return fail('provide repo as "owner/name", or pass a repos JSON array');
}
list = [{ repo, label: (label ?? "primary").trim() || "primary" }];
}
// The same single-primary requirement as update_namespace. Two primaries or none
// would make every repo tool resolve by accident, and update would refuse to fix
// it in place.
const primaryError = requireSinglePrimary(list);
if (primaryError) return fail(primaryError);
const existing = await db.prepare("SELECT namespace FROM namespaces WHERE namespace = ?1").bind(ns).first();
if (existing) {
return fail(`namespace already registered: ${ns}. Use capsid_admin_manage_namespaces action update to change its repo mapping; it snapshots the prior mapping to the audit log.`);
}
const reposJson = JSON.stringify(list);
await db.batch([
db.prepare("INSERT INTO namespaces (namespace, repos) VALUES (?1, ?2)").bind(ns, reposJson),
auditStatement(db, actor, "register_namespace", ns, null, list),
]);
return ok({
namespace: ns,
repos: list,
action: "registered",
driver_agent: null,
next: driverMintInstruction(ns),
});
}
);
// Remap an existing namespace's repos; register_namespace is the create path.
// Snapshots the prior mapping to the audit log. It does not rename the namespace: a
// rename touches document keys, versions and audit history.
ctx.ops.define(
"update_namespace",
{
description:
"Remap an existing namespace's repos. Pass repos as a JSON array like [{\"repo\":\"owner/name\",\"label\":\"primary\"},{\"repo\":\"owner/legacy\",\"label\":\"legacy\"}], with exactly one entry labeled \"primary\". The namespace must exist. Records the prior mapping in the audit log. Does not rename the namespace or move its documents. Admin only: an OAuth session qualifies, a minted agent holding the write grant does not.",
inputSchema: { namespace: nsName, repos: bounded(MAX_REPOS_JSON) },
},
async ({ namespace, repos }) => {
const ns = namespace.trim();
if (!ns) return fail("namespace is required");
const parsed = parseReposList(repos);
if ("error" in parsed) return fail(parsed.error);
const list = parsed.list;
const primaryError = requireSinglePrimary(list);
if (primaryError) return fail(primaryError);
const existing = await db
.prepare("SELECT repos FROM namespaces WHERE namespace = ?1")
.bind(ns)
.first<{ repos: string }>();
if (!existing) return fail(`namespace not found: ${ns}. Use capsid_admin_manage_namespaces action register to create it.`);
const reposJson = JSON.stringify(list);
await db.batch([
db.prepare("UPDATE namespaces SET repos = ?2 WHERE namespace = ?1").bind(ns, reposJson),
auditStatement(db, actor, "update_namespace", ns, null, { old: existing.repos, new: reposJson }),
]);
return ok({ namespace: ns, repos: list, action: "updated", previous: existing.repos });
}
);
// Delete a namespace: a preview that counts everything naming it and signs a token,
// then a perform that re-plans, requires the token to match and commits one guarded
// batch. The rules and what is kept are in src/namespace-delete.ts. Admin only
// (TOOL_GRANTS in src/scope.ts), like the other two tools that edit the mapping.
ctx.ops.define(
"delete_namespace",
{
description:
`Delete a registered namespace. Two calls. action 'delete_preview' writes nothing: it counts every live and archived document, snapshot, edge, job by status, agent naming the namespace, and the ops_sites row, and returns a verdict. It is refused while any job in the namespace is open (queued, claimed or blocked) or any live agent names it in its scopes (cascade never reaches jobs or agents; end them with capsid_work_jobs or capsid_admin_manage_agents), while it is on the roster, while live documents exist and cascade is not true, while it holds signed policy documents (policy/) and allow_improve_paths is not true, and while it holds more live documents than one batch deletes (${NAMESPACE_DELETE_MAX_DOCUMENTS}; delete some with capsid_delete_doc or move them with capsid_write_doc action move first). An allowed preview returns a token bound to the namespace, cascade, allow_improve_paths, the counts and this caller, valid five minutes. action 'delete_perform' takes the same arguments and that token, re-reads the plan and refuses if anything counted changed, then in one batch snapshots every live document to document_versions and deletes it with the edges touching it, deletes the ops_sites row and the namespaces row, and writes one audit row 'namespace-delete' holding the edges and both rows whole. Archived documents, versions, the audit log, finished jobs and their records are kept. Admin only: an OAuth session qualifies, a minted agent holding the write grant does not.`,
inputSchema: {
namespace: nsName,
action: z.enum(["preview", "perform"]),
cascade: z.boolean().optional(),
allow_improve_paths: z.boolean().optional(),
token: bounded(MAX_CONFIRM_TOKEN).optional(),
},
},
async ({ namespace, action, cascade, allow_improve_paths, token }) => {
const ns = namespace.trim();
if (!ns) return fail("namespace is required");
const opts = { cascade: cascade === true, allowImprovePaths: allow_improve_paths === true };
// The policy override is scoped as it is on a document delete: the flag is the
// permission, the argument only the caller's intent.
if (opts.allowImprovePaths) {
const overrideRefusal = ctx.scope({ ...surfaceOf("delete_namespace", "perform"), namespace: ns, flags: IMPROVE_OVERRIDE_FLAGS });
if (overrideRefusal) return fail(overrideRefusal);
}
const now = new Date();
const answer =
action === "preview"
? await previewNamespaceDelete(env, actor, ns, opts, now)
: await performNamespaceDelete(env, actor, ns, opts, token, now);
return answer.ok ? ok(answer.data) : fail(answer.refusal);
}
);
}