Example: Knowledge-base tool (read + write the user's Trove)
Most toolkits reach out to a third-party API. This one reaches in — to the calling user’s own Trove knowledge base — using the ctx.trove client. It exposes two tools:
find_notes— semantic search over the user’s KB (ctx.trove.search), read-only.save_note— write a quick note into the KB (ctx.trove.ingest), mutating.
ctx.trove is present only when the manifest grants the matching scope, so this is the canonical example of scopes: ["trove:search", "trove:ingest"] and how the SDK’s read-only derivation reacts to a write scope.
Project layout
Section titled “Project layout”my-notes/ manifest.json server.tsmanifest.json
Section titled “manifest.json”No secrets and no egress — this server never leaves Trove. The only capabilities it requests are the two Trove scopes.
{ "id": "my-notes", "name": "My Notes", "description": "Search and save quick notes in your Trove knowledge base.", "version": "1.0.0", "sdk": "@ontrove/mcp@^1", "secrets": [], "egress": [], "scopes": ["trove:search", "trove:ingest"], "visibility": "private"}server.ts
Section titled “server.ts”Mirror the manifest scopes into defineMcpServer({ scopes }) so the SDK’s readOnlyHint derivation matches the manifest. Because the server declares trove:ingest, its tools default to readOnlyHint: false — so we set readOnlyHint: true explicitly on the read tool, and mutating: true on the write tool.
import { defineMcpServer, z, ToolError } from "@ontrove/mcp";
export default defineMcpServer({ // Mirror manifest.scopes so the SDK derives annotations correctly. scopes: ["trove:search", "trove:ingest"], tools: [ { name: "find_notes", title: "Find notes", description: "Semantically search the user's Trove knowledge base and return the most " + "relevant documents. Use when the user asks what they've read or saved about a topic.", // The server declares a write scope, so the default would be readOnlyHint:false. // This tool only reads, so we override. annotations: { readOnlyHint: true }, input: z.object({ query: z.string().min(1).describe("What to search for."), limit: z.number().int().min(1).max(20).default(5).describe("Max results."), }), output: z.object({ results: z.array( z.object({ id: z.string(), title: z.string(), snippet: z.string(), score: z.number(), author: z.string().optional(), }), ), }), async handler({ query, limit }, ctx) { // ctx.trove is present only because the manifest granted trove:search. if (!ctx.trove) { throw new ToolError("Trove search is not available for this server.", { retryable: false, }); } // search(query, opts?) — query is positional. const results = await ctx.trove.search(query, { limit });
if (results.length === 0) { return { text: `No notes found for "${query}".`, structured: { results: [] } }; } const lines = results .map((r) => ` [${r.score.toFixed(2)}] ${r.title} — ${r.snippet}`) .join("\n"); return { text: `${results.length} note(s) for "${query}":\n${lines}`, structured: { results }, }; }, }, { name: "save_note", title: "Save note", description: "Save a short note into the user's Trove knowledge base so it's searchable later. " + "Use when the user says 'save this', 'note that', or 'remember…'.", // A write tool: mark it mutating so the client surfaces consent. mutating: true, input: z.object({ title: z.string().min(1).describe("A short title for the note."), text: z.string().min(1).describe("The full note body to index."), url: z.string().url().optional().describe("Optional source URL."), }), output: z.object({ ingested: z.number() }), async handler({ title, text, url }, ctx) { if (!ctx.trove) { throw new ToolError("Trove ingest is not available for this server.", { retryable: false, }); } // ingest takes TroveIngestDoc documents; only `title` is required. const result = await ctx.trove.ingest([ { title, text, ...(url ? { url } : {}), author: "My Notes" }, ]); return { text: `Saved "${title}" (${result.ingested} document indexed).`, structured: { ingested: result.ingested }, }; }, }, ],});Why this shape
Section titled “Why this shape”scopes: ["trove:search", "trove:ingest"]in both the manifest anddefineMcpServer. The manifest grants the capability; mirroring it in the SDK config keeps the auto-derivedannotationshonest. Without the scope,ctx.troveisundefined.find_notesoverrides toreadOnlyHint: true. Because the server declares a write scope, the SDK’s default for every tool isreadOnlyHint: false. The read tool sets it back totrueso clients don’t add needless confirmation friction.save_notesetsmutating: true. This forces client consent for the write, and the SDK keepsreadOnlyHint: false.ctx.trove.search(query, opts)takes the query positionally;ctx.trove.ingest(docs)takesTroveIngestDocdocuments and returns{ ingested }. Onlytitleis required — a doc can also carry afileUrlto capture, and afeedto group by. This toolkit’s notes are all one flat list, so it declares no feed.- No
egress, nosecrets. This server only talks to Trove, so it declares neither.
Deploy
Section titled “Deploy”trove mcp deploy# ✓ deployed my-notes (version 1.0.0, building)# my-notes__find_notes# my-notes__save_noteNothing else to configure — there are no secrets, and the scopes are granted by the manifest at deploy time.
What the user sees in Claude
Section titled “What the user sees in Claude”Remember that the Q3 board meeting moved to the 14th.
Claude calls my-notes__save_note({ title: "Q3 board meeting date", text: "Moved to the 14th." }) — and, because the tool is mutating, the client surfaces a confirmation before the write. Later:
What did I note about the Q3 board meeting?
Claude calls my-notes__find_notes({ query: "Q3 board meeting" }), which runs semantically over the user’s KB via ctx.trove.search and returns the saved note.
Next steps
Section titled “Next steps”- NASA APOD — an authed server that reaches an external API
- Hacker News source adapter — the other authoring direction: collecting data in
- SDK Reference →
ctx.trove— the full knowledge-base client