Skip to content

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.

my-notes/
manifest.json
server.ts

No secrets and no egress — this server never leaves Trove. The only capabilities it requests are the two Trove scopes.

manifest.json
{
"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"
}

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.

server.ts
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 },
};
},
},
],
});
  • scopes: ["trove:search", "trove:ingest"] in both the manifest and defineMcpServer. The manifest grants the capability; mirroring it in the SDK config keeps the auto-derived annotations honest. Without the scope, ctx.trove is undefined.
  • find_notes overrides to readOnlyHint: true. Because the server declares a write scope, the SDK’s default for every tool is readOnlyHint: false. The read tool sets it back to true so clients don’t add needless confirmation friction.
  • save_note sets mutating: true. This forces client consent for the write, and the SDK keeps readOnlyHint: false.
  • ctx.trove.search(query, opts) takes the query positionally; ctx.trove.ingest(docs) takes TroveIngestDoc documents and returns { ingested }. Only title is required — a doc can also carry a fileUrl to capture, and a feed to group by. This toolkit’s notes are all one flat list, so it declares no feed.
  • No egress, no secrets. This server only talks to Trove, so it declares neither.
Terminal window
trove mcp deploy
# ✓ deployed my-notes (version 1.0.0, building)
# my-notes__find_notes
# my-notes__save_note

Nothing else to configure — there are no secrets, and the scopes are granted by the manifest at deploy time.

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.