Writing Tools (Go)
Installation
go get github.com/msilverblatt/protomcp/sdk/goThe Tool() function
Register tools using Tool() with functional options. The first argument is the tool name.
package main
import ( "fmt" "github.com/msilverblatt/protomcp/sdk/go/protomcp")
func main() { protomcp.Tool("add", protomcp.Description("Add two numbers"), protomcp.Args( protomcp.IntArg("a"), protomcp.IntArg("b"), ), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { a := int(args["a"].(float64)) b := int(args["b"].(float64)) return protomcp.Result(fmt.Sprintf("%d", a+b)) }), )
protomcp.Run()}Tool metadata
Pass hint options to provide metadata to the MCP host:
protomcp.Tool("delete_file", protomcp.Description("Delete a file from disk"), protomcp.DestructiveHint(true), protomcp.IdempotentHint(false), protomcp.ReadOnlyHint(false), protomcp.OpenWorldHint(false), protomcp.Args(protomcp.StrArg("path")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { path := args["path"].(string) os.Remove(path) return protomcp.Result(fmt.Sprintf("Deleted %s", path)) }),)| Option | Description |
|---|---|
Description(s) | Human-readable description (required) |
Title(s) | Display name shown in the MCP host UI |
DestructiveHint(bool) | Hint: the tool has destructive side effects |
IdempotentHint(bool) | Hint: calling multiple times has the same effect as once |
ReadOnlyHint(bool) | Hint: the tool does not modify state |
OpenWorldHint(bool) | Hint: the tool may access external resources |
TaskSupportHint(bool) | Hint: the tool supports long-running async task semantics |
Argument types
| Function | JSON Schema type |
|---|---|
IntArg(name) | "integer" |
StrArg(name) | "string" |
NumArg(name) | "number" |
BoolArg(name) | "boolean" |
ArrayArg(name, itemType) | {"type": "array", "items": <itemType>} |
UnionArg(name, types...) | {"anyOf": [...]} |
LiteralArg(name, values...) | {"type": "string", "enum": [...]} |
Arguments are passed to the handler as map[string]interface{}. JSON numbers are float64 in Go — cast with int() when expecting integers.
ToolResult
All handlers return a ToolResult.
Success
return protomcp.Result("done")Structured error
return protomcp.ErrorResult( "The file /tmp/data.csv does not exist", "NOT_FOUND", "Check the path and try again", false,)| Field | Description |
|---|---|
ResultText | The result string |
IsError | Whether this is an error |
ErrorCode | Machine-readable error code |
Message | Human-readable error message |
Suggestion | Actionable suggestion for the caller |
Retryable | Whether the caller should retry |
EnableTools | Tool names to enable after this call |
DisableTools | Tool names to disable after this call |
Enabling / disabling tools from a result
protomcp.Tool("login", protomcp.Description("Log in and unlock tools"), protomcp.Args(protomcp.StrArg("username"), protomcp.StrArg("password")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { username := args["username"].(string) password := args["password"].(string) if !authenticate(username, password) { return protomcp.ErrorResult("Authentication failed", "AUTH_FAILED", "", false) } r := protomcp.Result("Logged in") r.EnableTools = []string{"delete_file", "write_file"} return r }),)Progress Reporting
Use ToolContext to report progress during long-running tool calls.
protomcp.Tool("process_data", protomcp.Description("Process a large dataset"), protomcp.Args(protomcp.StrArg("file_path")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { filePath := args["file_path"].(string) rows := loadRows(filePath) total := len(rows) for i, row := range rows { if ctx.IsCancelled() { return protomcp.ErrorResult("Cancelled", "CANCELLED", "", false) } processRow(row) ctx.ReportProgress(int64(i+1), int64(total), "processing rows") } return protomcp.Result(fmt.Sprintf("Processed %d rows", total)) }),)ToolContext API
| Method | Description |
|---|---|
ReportProgress(progress, total int64, message string) error | Send a progress notification to the MCP host |
IsCancelled() bool | Returns true if the MCP host has cancelled this call |
Server Logging
Use ServerLogger to send structured log messages back to the MCP host.
protomcp.Tool("fetch_data", protomcp.Description("Fetch remote data"), protomcp.Args(protomcp.StrArg("url")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { url := args["url"].(string) protomcp.Log.Info(fmt.Sprintf("Fetching %s", url)) data, err := download(url) if err != nil { protomcp.Log.Error(fmt.Sprintf("Fetch failed: %v", err)) return protomcp.ErrorResult(err.Error(), "FETCH_FAILED", "", true) } protomcp.Log.Debug(fmt.Sprintf("Fetched %d bytes", len(data))) return protomcp.Result(data) }),)Log levels
| Method | Level |
|---|---|
Log.Debug(msg) | debug |
Log.Info(msg) | info |
Log.Notice(msg) | notice |
Log.Warning(msg) | warning |
Log.Error(msg) | error |
Log.Critical(msg) | critical |
Log.Alert(msg) | alert |
Log.Emergency(msg) | emergency |
Cancellation
Check ctx.IsCancelled() periodically in long-running tools to stop early when the MCP host cancels the request.
protomcp.Tool("slow_compute", protomcp.Description("Run a slow computation"), protomcp.Args(protomcp.IntArg("n")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { n := int(args["n"].(float64)) result := 0 for i := 0; i < n; i++ { if ctx.IsCancelled() { return protomcp.ErrorResult("Cancelled by host", "CANCELLED", "", false) } result += expensiveStep(i) } return protomcp.Result(fmt.Sprintf("%d", result)) }),)Cancellation is cooperative — protomcp sets the cancelled flag and your tool is responsible for checking it.
Testing tools
Use ClearRegistry() between tests:
func TestAdd(t *testing.T) { protomcp.ClearRegistry()
protomcp.Tool("add", protomcp.Description("Add two numbers"), protomcp.Args(protomcp.IntArg("a"), protomcp.IntArg("b")), protomcp.Handler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { a := int(args["a"].(float64)) b := int(args["b"].(float64)) return protomcp.Result(fmt.Sprintf("%d", a+b)) }), )
tools := protomcp.GetRegisteredTools() if len(tools) != 1 { t.Fatalf("expected 1 tool, got %d", len(tools)) } if tools[0].Name != "add" { t.Fatalf("expected tool name 'add', got %q", tools[0].Name) }}Tool Groups
Group related actions under a single tool using ToolGroup().
protomcp.ToolGroup("files", protomcp.GroupDescription("File operations"), protomcp.GroupStrategy("union"), protomcp.Action("read", protomcp.ActionDescription("Read a file"), protomcp.ActionArgs(protomcp.StrArg("path")), protomcp.ActionHandler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { path := args["path"].(string) return protomcp.Result(readFile(path)) }), ), protomcp.Action("write", protomcp.ActionDescription("Write a file"), protomcp.ActionArgs(protomcp.StrArg("path"), protomcp.StrArg("content")), protomcp.ActionHandler(func(ctx protomcp.ToolContext, args map[string]interface{}) protomcp.ToolResult { return protomcp.Result("written") }), ),)Strategy: union (default)
With GroupStrategy("union"), the group registers as a single tool with a discriminated oneOf schema. The caller passes an action field to select the action.
Strategy: separate
With GroupStrategy("separate"), each action becomes its own tool, namespaced as group.action (e.g. files.read, files.write).
Dispatch and fuzzy matching
When an unknown action is passed, the dispatcher returns an error with a fuzzy “Did you mean?” suggestion.
Declarative Validation
Declare validation rules on Action() to validate input before the handler runs.
Requires
protomcp.Action("deploy", protomcp.ActionRequires("env", "version"), protomcp.ActionHandler(deployHandler),)EnumFields
Restrict a field to a set of valid values. Invalid values trigger a “Did you mean?” suggestion.
protomcp.Action("set_env", protomcp.ActionEnumField("env", []string{"dev", "staging", "prod"}), protomcp.ActionHandler(setEnvHandler),)CrossRules
Validate relationships between parameters.
protomcp.Action("scale", protomcp.ActionCrossRule(func(args map[string]interface{}) bool { return args["min"].(float64) > args["max"].(float64) }, "min must be <= max"), protomcp.ActionHandler(scaleHandler),)Hints
Hints are not currently available in the Go SDK. For advisory messages, check conditions inside your handler and append warnings to the result text.
Server Context
Register resolvers that inject shared parameters into tool handlers automatically.
protomcp.ServerContext("project_dir", func(args map[string]interface{}) interface{} { dir, _ := os.Getwd() return dir }, protomcp.Expose(false),)With Expose(false), the parameter is hidden from the tool schema sent to the MCP host but still injected into the handler’s args map.
Local Middleware
Wrap tool handlers with in-process middleware for cross-cutting concerns.
protomcp.LocalMiddleware(10, func(ctx protomcp.ToolContext, toolName string, args map[string]interface{}, next func(protomcp.ToolContext, map[string]interface{}) protomcp.ToolResult) protomcp.ToolResult { start := time.Now() result := next(ctx, args) log.Printf("%s took %v", toolName, time.Since(start)) return result})Middleware is sorted by priority (lowest first = outermost). Return a ToolResult directly to short-circuit the chain.
Local vs Go-bridge middleware
Local middleware runs in-process. Go-bridge middleware runs at the transport layer between the MCP host and the server process.
Telemetry
Observe tool calls with fail-safe telemetry sinks.
protomcp.TelemetrySink(func(event protomcp.ToolCallEvent) { log.Printf("[%s] %s: %s", event.Phase, event.ToolName, event.Message)})ToolCallEvent phases
| Phase | When |
|---|---|
"start" | Before the handler runs |
"success" | After the handler returns successfully |
"error" | After the handler raises or returns an error |
"progress" | When ReportProgress is called |
Exceptions in sinks are silently swallowed and do not affect tool execution.
Sidecar Management
Declare companion processes that protomcp manages alongside your server.
protomcp.Sidecar("redis", []string{"redis-server", "--port", "6380"}, protomcp.HealthCheck("http://localhost:6380/ping"), protomcp.StartOn("server_start"), protomcp.HealthTimeout(30*time.Second),)| Parameter / Option | Description |
|---|---|
command []string (2nd arg) | Process command and arguments |
HealthCheck(url) | URL to poll for health (HTTP 200 = healthy) |
StartOn(trigger) | "server_start" or "first_tool_call" |
HealthTimeout(d) | Duration to wait for health check to pass |
PID files are stored in ~/.protomcp/sidecars/. Processes receive SIGTERM on shutdown, followed by SIGKILL if they do not stop within the timeout.