LLMSkillHub
mirroredCopied from radimsem/remindb under MIT. radimsem has not claimed this page, and is not affiliated with LLM SkillHub.

radimsem / add-mcp-tool

Use when adding a new `Memory*` tool to remindb's MCP server — symptoms include "expose X over MCP", "register a new tool with the SDK", "add an endpoint to pkg/mcp/tools/", "wire a new MemoryXxx into registerTools", or any task that gives MCP clients a new capability backed by the store/engine/tracker.

0
SKILL.md
8.1 KB

Add a new MCP tool

Tools live in pkg/mcp/tools/ as one file per tool. Each tool is a method on *Deps that takes a typed input struct, calls into Store / Engine / Tracker, and returns a *mcp.CallToolResult with text content. Adding one means four changes plus a docs sync.

Where it lands

FileWhat changes
pkg/mcp/tools/<tool>.goNew file — XxxInput struct + HandleXxx method on *Deps
pkg/mcp/server.goAdd a mcp.AddTool(srv, ...) entry to registerTools
pkg/mcp/tools/tools_test.goTest using mcptest.NewEnv from internal/mcptest
skills/remind/SKILL.md (read tools) or skills/memorize/SKILL.md (write tools)Add the new tool to the inventory and any pattern section it belongs in

Tool file template

The shape is uniform across fetch.go, search.go, summarize.go, write.go. Mirror it.

package tools

import (
    "context"
    "fmt"
    "time"

    gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
)

type ExampleInput struct {
    Anchor string `json:"anchor" jsonschema:"Node ID to operate on"`
    Budget int    `json:"budget,omitempty" jsonschema:"Token budget for the response"`
}

func (d *Deps) HandleExample(ctx context.Context, _ *gomcp.CallToolRequest, input ExampleInput) (_ *gomcp.CallToolResult, _ any, err error) {
    defer d.logCall("MemoryExample", &err, time.Now(), "anchor", input.Anchor, "budget", input.Budget)

    // Write-side tools take the lock; read-side tools do not (see "Locking" below).
    // d.Store.OpMu.Lock()
    // defer d.Store.OpMu.Unlock()

    result, err := d.Engine.DoSomething(ctx, input.Anchor, input.Budget)
    if err != nil {
        return nil, nil, fmt.Errorf("failed to do-something: %w", err)
    }

    d.boostResultNodes(ctx, result)         // read tools only

    return &gomcp.CallToolResult{
        Content: []gomcp.Content{&gomcp.TextContent{Text: result.Format()}},
    }, nil, nil
}

Five things every tool gets right:

  1. Named return values for the deferred logger. (_ *gomcp.CallToolResult, _ any, err error) is the SDK signature; the err name is required so defer d.logCall(..., &err, ...) can capture the final error. Renaming or omitting err silently breaks call logging.
  2. defer d.logCall(...) on the first line. Prefix the tool name with Memory to match the registered name. Pass enough attrs to debug a misbehaving call (anchor, budget, payload byte-count — never the full payload).
  3. Locking decision (see below).
  4. Error wrapping. Action errors take failed to <verb>: per go-concise.md §5; wrap the engine/store error with %w so callers can errors.Is.
  5. Return *mcp.CallToolResult with text content. Format complex results into one string before returning — clients render text, not structured JSON.

Locking

The store uses a single sync.Mutex exposed as Store.OpMu (memory: "no wrapper methods around sync primitives"). The rule:

Tool kindTake OpMu
Read-only (MemorySearch, MemoryFetch, MemoryTree, MemoryDelta, MemoryHistory, MemoryRelated)No
Mutating (MemoryWrite, MemorySummarize, MemoryCompile, MemoryRelate)Yes

Mutating tools call d.Store.OpMu.Lock() immediately after the deferred logger and defer d.Store.OpMu.Unlock(). See pkg/mcp/tools/summarize.go:21-22 and write.go:24-25 for the canonical pattern.

Read tools also call d.boostResultNodes(ctx, result) to bump temperature on accessed nodes — mutating tools do not (the write itself is the access).

The registration entry

Open pkg/mcp/server.go:146-186 (the registerTools function) and add:

mcp.AddTool(srv, &mcp.Tool{
    Name:        "MemoryExample",
    Description: "<one short sentence — what it does, not how>",
}, d.HandleExample)

Keep the name Memory<Verb> so it sorts cleanly with the existing inventory and matches the defer d.logCall(...) argument.

The test

pkg/mcp/tools/tools_test.go uses the in-process MCP transport via internal/mcptest.NewEnv(t). Call your tool through the client session and assert on the text output:

func TestExample_HappyPath(t *testing.T) {
    env := mcptest.NewEnv(t)
    res := env.CallTool(t, "MemoryExample", map[string]any{
        "anchor": "<seeded-id>",
        "budget": 500,
    })
    text := env.TextContent(t, res)
    if !strings.Contains(text, "<expected substring>") {
        t.Fatalf("unexpected output: %s", text)
    }
}

The docs sync — easy to skip, easy to regret

Two public skills under skills/ are the contract with future Claude sessions about what tools exist. Pick the one that matches the tool's side:

Tool kindSkill to update
Read (MemoryTree, MemorySearch, MemoryFetch, MemoryDelta, MemoryHistory, MemoryRelated)skills/remind/SKILL.md
Write (MemoryWrite, MemorySummarize, MemoryCompile, MemoryRelate)skills/memorize/SKILL.md
Crosses the boundary (introduces a new mental-model concept used on both sides)Both

For each affected skill, when you add a tool:

  1. Update the frontmatter description tool list.
  2. Update the opening / inventory paragraph to reflect the new surface.
  3. Add at least one example call into the relevant pattern section.

Skipping this means future sessions won't know the tool exists. The skills are the API contract, not just docs.

Quick reference

1. pkg/mcp/tools/<tool>.go        (Input struct + Handle method on *Deps)
2. pkg/mcp/server.go              (mcp.AddTool entry in registerTools)
3. pkg/mcp/tools/tools_test.go    (env := mcptest.NewEnv(t); env.CallTool(...))
4. skills/remind/SKILL.md   (read tools)
   OR
   skills/memorize/SKILL.md          (write tools)
   OR both, when the change crosses the read/write boundary
5. go test ./pkg/mcp/...          (must pass)

Common mistakes

  • Read tool that mutates. If your tool mutates (even just bumping temperature), it must take OpMu. The temperature boost in boostResultNodes is the one exception — it goes through Tracker.RecordAccess → BoostTemperatureBatch, which serializes through SQLite's WAL writer, not the in-memory mutex.
  • Forgetting the named err return. defer d.logCall(..., &err, ...) captures err by pointer. If the function signature uses an unnamed error or shadows err with :=, the deferred log shows <nil> for failed calls.
  • Returning nil, nil, nil on the no-result path. Return an empty *mcp.CallToolResult with a text body like "no results" — clients expect text, not a missing content array. See pkg/mcp/tools/search.go and the query.FormatCompact "no results" string.
  • Passing the raw payload as a log attr. Use byte-count ("payload_bytes", len(input.Payload)) — payloads can be MB, and slog will serialize the whole thing.
  • Skipping the public-skill update. Tool exists in code but invisible to agents. Read tools must show up in skills/remind/SKILL.md; write tools in skills/memorize/SKILL.md. Test: a fresh session reading the relevant skill should be able to use the new tool from the description alone.

Cross-references

  • .claude/rules/go-concise.md — error wrapping, naming, locking discipline, no-wrapper-methods rule
  • .claude/rules/git-versioning.md — one commit per logical change; the four code edits ship together as feat(mcp): add MemoryExample tool, the docs sync as a follow-up docs(skill): document MemoryExample if it grew large, otherwise bundled
  • .claude/skills/add-store-query/SKILL.md — if the new tool needs a query the store doesn't have yet, do that skill first
  • skills/remind/SKILL.md — docs target for read-side tools (MemoryTree, MemorySearch, MemoryFetch, MemoryDelta, MemoryHistory, MemoryRelated)
  • skills/memorize/SKILL.md — docs target for write-side tools (MemoryWrite, MemorySummarize, MemoryCompile, MemoryRelate)