Add a tool

Goal: give the agent a capability it does not have, in one file plus one line.

Before writing anything, check the capability passes the test in Designing the tool surface: a tool models an intention, not an endpoint.

1. Write the module

Create mcp-nextcloud/src/modules/<domain>.js exporting register(server, ctx):

import * as z from "zod/v4";
import { refuse, toolError } from "../nextcloud/errors.js";

export function register(server, ctx) {
  server.registerTool(
    "notes_search",
    {
      title: "Search notes",
      description:
        "Find notes by words in their title or body. Returns the `note` id " +
        "notes_read takes. Says nothing about a note's contents — use " +
        "notes_read for that.",
      inputSchema: z.object({
        query: z.string().describe("Words to look for. Not a note id."),
        limit: z.number().int().min(1).max(50).default(20),
      }),
      annotations: { readOnlyHint: true, idempotentHint: true },
    },
    async ({ query, limit }) => {
      try {
        const data = await ctx.ocs("GET", `/ocs/v2.php/apps/notes/api/v1/notes`);
        const out = { notes: data.slice(0, limit), count: data.length };
        if (out.count === 0) {
          out.hint = "Nothing matched. Try fewer words before concluding it does not exist.";
        }
        return { content: [{ type: "text", text: JSON.stringify(out) }], structuredContent: out };
      } catch (err) {
        return toolError(err, { uid: await ctx.uid().catch(() => null) });
      }
    },
  );
}

ctx gives you baseUrl, authorization (verbatim from the request), uid() (async, memoised — at most one lookup per HTTP request), ocs(), dav, and log().

2. Register it

One import and one array entry in mcp-nextcloud/src/server.js. Nothing else: the approval policy comes from the annotations, and the context pruner derives what it needs from the tool name.

3. Get the three silent things right

Annotations decide whether the user is asked. A tool with an effect and no destructiveHint runs without a confirmation, and nothing reports an error. Omitting annotations entirely is safe — the policy fails closed — but a wrong one is not.

Never write permission logic. A 403 or a 404 from Nextcloud is the answer. Do not check first, do not retry with a different path, do not fall back to another account.

Every error tells the model what to do next. Use toolError for anything upstream and refuse for anything you reject yourself, and make the refusal name the next action:

return refuse(
  `No notebook named '${name}'. Available: ${names.join(", ")}. Use one of those.`,
);

4. Read before you write

If the tool modifies something, fetch the current state and merge, rather than sending the fields you were given. Two capabilities in this codebase were shipped without doing that, and both silently destroyed user data:

  • Deck's update has no defaults for title, type and owner, so a partial body blanks the description — and omitting done untick a finished card.
  • Rebuilding a calendar event from the fields we model drops the repeat rule, the alarms and the attendees of an event created in the web interface.

Both now read, merge and write, and both have a regression test that fails if anyone simplifies it back.

5. Test it

Pure logic — anything under src/format/ or nextcloud/paths.js — imports nothing and is testable offline:

cd mcp-nextcloud && npm test

Add the file to the test script in package.json; it lists test files explicitly, so a new one is silently skipped otherwise.

For anything that talks to Nextcloud, the integration suite is gated on a real credential:

NC_TEST_USER=NC_TEST_PASSWORD=npm run test:integration

To drive the tool by hand against the running stack, the server accepts Basic auth in development, so no OIDC flow is needed:

// docker compose exec orchestrator node /tmp/try.mjs
const AUTH = "Basic " + Buffer.from("alex:password").toString("base64");
const res = await fetch("http://mcp-nextcloud:8091/mcp", {
  method: "POST",
  headers: { "Content-Type": "application/json",
             Accept: "application/json, text/event-stream", Authorization: AUTH },
  body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call",
    params: { name: "notes_search", arguments: { query: "budget" } } }),
});
console.log((await res.text()).replace(/^event:.*\ndata: /m, ""));

That harness is how the last four bugs in this codebase were found, including two that every unit test passed through happily.

6. Restart and confirm it loaded

docker compose up -d --build mcp-nextcloud

docker compose up -d alone neither rebuilds on a source change nor recreates on an .env change. Three separate debugging dead-ends came from exactly that.

Then check the tool is actually exposed, rather than assuming:

const r = await fetch("http://mcp-nextcloud:8091/mcp", { /* …tools/list… */ });

Adding a whole newsource of tools

Another MCP server, a local tool, or a deterministic workflow goes in orchestrator/src/tools/registry.ts, and only there. agent/ receives a tool set as an argument and imports nothing from the tool layer — which is what keeps "adding a capability" from touching the loop.