Every tool follows the same pattern:
// register.go
s.AddTool(mcp.NewTool(toolname.MyTool,
mcp.WithDescription(asset.ToolDesc(toolname.MyTool)),
mcp.WithString("param", mcp.Required(), mcp.Description(asset.ParamDesc(toolname.MyTool, "param"))),
), myToolHandler)
// handler.go
func myToolHandler(_ context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { ... }-
Add a tool name constant — add a constant to
pkg/toolname/toolname.goand re-export it intoolnames.go. -
Add the description — add your tool entry to the corresponding
internal/asset/descriptions/*.jsonfile. Useasset.ToolDesc(toolname.X)for the tool description andasset.ParamDesc(toolname.X, "param")for parameter descriptions. Never hardcode descriptions inline in the registration call. -
Write the handler — add the handler function to
handler.goin the matching package (file,dir,exec,search,system,user,multi). Usereq.GetString/req.GetBool/req.GetFloat/req.GetStringSlicefor parameters; never reach intoreq.GetArguments()for scalar types. -
Return results — use
mcp.NewToolResultText(s)for success andmcp.NewToolResultError(s)for user-facing errors. Return(nil, err)only for unexpected infrastructure failures. -
Register — call
s.AddTool(...)inside theRegisterfunction inregister.goof the matching package. -
Test — add test cases to the matching
handler_test.gofile. -
Update documentation — update these files:
internal/asset/descriptions/server_instructions.txt— add your tool to the appropriate sectionREADME.md— add to the tool table + update the tool count
If your tool lives in a brand-new internal/tool/<group>/ package (i.e. not one of user, file, multi, dir, search, exec, system), there are two extra steps so the group can be selectively disabled:
- Add a
Group<Name>constant inserver.goand append it toAllGroups(). - Wrap the new
xxx.Register(reg)call inRegister()withif !cfg.disableGroups[Group<Name>] { ... }. - Add a
[[group:<name>]]marker line ininternal/asset/descriptions/server_instructions.txtimmediately above your section soServerInstructionsExcludingGroupscan strip it.
Tool descriptions are embedded at compile time via go:embed in internal/asset/asset.go. The flow:
internal/asset/descriptions/*.json → go:embed → asset.ToolDesc() / asset.ParamDesc()
This means descriptions are part of the binary — no external files needed at runtime. When you add a description entry to a JSON file, it's automatically available after rebuilding.
| Package | Contents |
|---|---|
pkg/toolname/ |
Canonical tool name constants (importable by external connectors) |
toolnames.go |
Root-level re-export of all tool name constants |
internal/asset/ |
Embedded JSON descriptions + HTML/CSS/JS templates |
internal/helper/ |
Shared utilities (fs, read, diff, edit, mime, glob, checksum) |
internal/tool/<name>/ |
Each tool group: register.go, handler.go, testutil_test.go, handler_test.go |
- All tool handler signatures must accept
context.Contextas the first argument (even if unused — propagation matters for future cancellation support). - Parameter descriptions live exclusively in
internal/asset/descriptions/*.json— not inline. - Use
helper.AtomicWriteFilefor all file writes to prevent partial-write corruption. - Use
helper.LockFile/ defer unlock whenever the same file path could be written concurrently. - Use
helper.HumanizeBytesfor human-readable file sizes in output.
The following were observed while building this project and may be useful context for contributors or upstream maintainers:
-
No typed getter for object/map params. Getting a
mapparameter requires reaching intoreq.GetArguments()directly and doing a manual type-assertion:if hmap, ok := rawHeaders.(map[string]any); ok { ... }
A helper like
req.GetMap("headers") → map[string]anywould be cleaner. -
No streaming / incremental results. All output is buffered and returned at once. For long-running commands there is no way to stream partial results to the caller. A streaming variant would improve perceived latency on slow operations.
-
No structured result type. Only text results are available. A
mcp.NewToolResultJSON(v any)helper would let callers reason over structured data without parsing text. -
mcp.WithObjectschema is not validated. Object parameters accept any shape — there is no way to declare that values must be strings or follow a specific schema. JSON Schema support would catch caller mistakes early.
make testEvery internal/tool/<name>/ package has a testutil_test.go file that provides shared test utilities. When writing new tests, use these helpers — do not duplicate them:
| Helper | Signature | Purpose |
|---|---|---|
newTestRequest |
(args map[string]any) mcp.CallToolRequest |
Build a mcp.CallToolRequest with the given argument map — the standard way to invoke a handler in tests. |
isResultError |
(r *mcp.CallToolResult) bool |
Return r.IsError — used to assert that a handler returned an error result. |
resultText |
(r *mcp.CallToolResult) string |
Extract the first TextContent string from a result — used to inspect handler output. |
Finding them: grep -r "func newTestRequest" internal/ — each package has its own copy in testutil_test.go.
Adding tests: add cases to the existing handler_test.go in the matching package. Check for name collisions first (grep "^func Test" internal/tool/<pkg>/handler_test.go). Always add a comment above each test explaining why the case is needed.
askUserHandler is asynchronous — it spawns a goroutine and immediately returns a JSON token. To test the response-receiving side without browser interaction, pre-load state with storePendingDialog(token, state) where state.responseCh is a buffered channel pre-seeded with the answer. See internal/tool/user/handler_test.go for full examples.
make lintmake build VERSION=v1.2.3