Skip to the content

Capsid menu

src/tools/docs.ts

144 lines and 7 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. It is in an import cycle. This file on GitHub.

Functions

The functions of docs.ts
FunctionLinesComplexityCoveredCRAPMutation
ok11 to 131100%OkCRAP 1No data: mutation testing has not run on this repo yet
fail15 to 171100%OkCRAP 1No data: mutation testing has not run on this repo yet
pathMutation24 to 512100%OkCRAP 2No data: mutation testing has not run on this repo yet
edgesTouching56 to 621100%OkCRAP 1No data: mutation testing has not run on this repo yet
confirmDestructive71 to 91591%WatchCRAP 5No data: mutation testing has not run on this repo yet
requireConfirmation101 to 1104100%OkCRAP 4No data: mutation testing has not run on this repo yet
defineDocOperations142 to 1551100%OkCRAP 1No data: mutation testing has not run on this repo yet

Imports

It imports src/agents.ts, src/env.ts, src/scope.ts, src/tools/docs-lifecycle.ts, src/tools/docs-read.ts, src/tools/docs-write.ts, src/tools/namespaces.ts, src/tools/operations.ts. Imported by src/namespace-delete.ts, src/server.ts, src/tools/admin.ts, src/tools/agents.ts, src/tools/claims.ts, src/tools/cloudflare.ts, src/tools/controls.ts, src/tools/docs-lifecycle.ts, src/tools/docs-read.ts, src/tools/docs-write.ts, src/tools/jobs.ts, src/tools/lint.ts, src/tools/namespaces.ts, src/tools/ops.ts, src/tools/repo-guards.ts, src/tools/repo.ts, src/tools/status.ts.

Source

import { McpServer } from "@modelcontextprotocol/server";
import type { Env } from "../env";
import type { Agent } from "../agents";
import { type ScopeNeed } from "../scope";
import type { Operations } from "./operations";
import { defineListOperation, defineReadOperation, defineBriefOperation, defineHistoryOperation, defineBacklinksOperation, defineFindOperation, defineSearchOperation } from "./docs-read";
import { defineWriteOperation } from "./docs-write";
import { defineDeleteOperation, defineMoveOperation, defineRestoreOperation } from "./docs-lifecycle";
import { defineNamespaceOperations } from "./namespaces";

export function ok(data: unknown) {
  return { content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }] };
}

export function fail(message: string) {
  return { content: [{ type: "text" as const, text: message }], isError: true };
}

// PATH_MUTATION_HELPER_START
// The only site that mutates documents.path or deletes a documents row.
// document_links stores (ns, path) strings, no FK. newPath null means delete.
// Statement order is positional: rename [0] documents [1] from_path [2] to_path;
// delete [0] document_links [1] documents.
export function pathMutation(
  db: D1Database,
  namespace: string,
  path: string,
  newPath: string | null
): D1PreparedStatement[] {
  if (newPath === null) {
    return [
      db
        .prepare(
          "DELETE FROM document_links WHERE (from_ns = ?1 AND from_path = ?2) OR (to_ns = ?1 AND to_path = ?2)"
        )
        .bind(namespace, path),
      db.prepare("DELETE FROM documents WHERE namespace = ?1 AND path = ?2").bind(namespace, path),
    ];
  }
  return [
    db
      .prepare("UPDATE documents SET path = ?3, updated_at = datetime('now') WHERE namespace = ?1 AND path = ?2")
      .bind(namespace, path, newPath),
    db
      .prepare("UPDATE document_links SET from_path = ?3 WHERE from_ns = ?1 AND from_path = ?2")
      .bind(namespace, path, newPath),
    db
      .prepare("UPDATE document_links SET to_path = ?3 WHERE to_ns = ?1 AND to_path = ?2")
      .bind(namespace, path, newPath),
  ];
}

// Edges touching a document, read before a mutation so the caller can record
// them. document_versions snapshots only title and body, so for a delete this
// is the only place the edges survive.
export function edgesTouching(db: D1Database, namespace: string, path: string): D1PreparedStatement {
  return db
    .prepare(
      "SELECT from_ns, from_path, type, to_ns, to_path FROM document_links WHERE (from_ns = ?1 AND from_path = ?2) OR (to_ns = ?1 AND to_path = ?2)"
    )
    .bind(namespace, path);
}
// PATH_MUTATION_HELPER_END

type ConfirmVerdict = "accepted" | "declined" | "unsupported";

// Asks the connected client to confirm a destructive action via MCP elicitation.
// Stateless Streamable HTTP clients usually cannot answer server-initiated requests,
// so "unsupported" is the common case and callers fall back to requiring an explicit
// confirm:true argument.
async function confirmDestructive(server: McpServer, message: string): Promise<ConfirmVerdict> {
  if (!server.server.getClientCapabilities()?.elicitation) return "unsupported";
  try {
    const result = await server.server.elicitInput(
      {
        message,
        requestedSchema: {
          type: "object",
          properties: {
            confirm: { type: "boolean", title: "Confirm", description: "Set to true to proceed" },
          },
          required: ["confirm"],
        },
      },
      { timeout: 90_000 }
    );
    return result.action === "accept" && result.content?.confirm === true ? "accepted" : "declined";
  } catch {
    return "unsupported";
  }
}

// The confirmation step for every destructive tool. The messages stay per tool
// because they are the instruction the caller acts on.
//
// It reports whether it actually elicited. An elicited answer can arrive 90 seconds
// after the body it approved was read, so that consent goes stale like an if_match,
// and write and restore arm the commit-time body guard on it.
type ConfirmResult = { ok: true; elicited: boolean } | { ok: false; message: string };

export async function requireConfirmation(
  server: McpServer,
  confirm: boolean | undefined,
  messages: { prompt: string; declined: string; unsupported: string }
): Promise<ConfirmResult> {
  if (confirm === true) return { ok: true, elicited: false };
  const verdict = await confirmDestructive(server, messages.prompt);
  if (verdict === "accepted") return { ok: true, elicited: true };
  return { ok: false, message: verdict === "declined" ? messages.declined : messages.unsupported };
}

// The grant, not a boolean. A boolean misread at every call site:
// `buildServer(env, true, ...)` looks like "this is the operator server" when it
// means "this grant may write", and a read-only ro: key is also an operator.
// src/auth.ts resolves a key to exactly this union.
export type ToolGrant = "write" | "read";

export interface ToolCtx {
  env: Env;
  db: D1Database;
  // The principal recorded on every audit_log row: "github:<login>" for an OAuth
  // session, "opkey:<fingerprint>" for an operator key, "agent:<name>" for a minted
  // agent (src/agents.ts). The fingerprint is a 12-char prefix of the key's sha256,
  // because OPERATOR_KEY_HASH is the verifier and a full hash in audit_log would copy
  // it into the database. Older rows keep a literal 'operator' value.
  actor: string;
  // The caller, resolved once per request (src/agents.ts). `actor` is a projection of
  // it; the scope checks read this.
  agent: Agent;
  // checkScope bound to this caller (src/scope.ts): a refusal naming the missing
  // scope, or null. The registrar has already checked the tool, grant, namespace and
  // repo; this is for what only the handler knows: an action tool's action, and the
  // flags a repo mutation needs.
  scope: (need: ScopeNeed) => string | null;
  // Where each module defines its operations (./operations). The composite tools in
  // ./surface dispatch to them.
  ops: Operations;
  lastActor: (ns: string, path: string) => Promise<string | null>;
}

// Registered in this order, which is the order tools/list publishes.
export function defineDocOperations(server: McpServer, ctx: ToolCtx): void {
  defineListOperation(server, ctx);
  defineReadOperation(server, ctx);
  defineBriefOperation(server, ctx);
  defineWriteOperation(server, ctx);
  defineHistoryOperation(server, ctx);
  defineRestoreOperation(server, ctx);
  defineBacklinksOperation(server, ctx);
  defineDeleteOperation(server, ctx);
  defineMoveOperation(server, ctx);
  defineFindOperation(server, ctx);
  defineSearchOperation(server, ctx);
  defineNamespaceOperations(server, ctx);
}