Skip to content

Writing Tools (Go)

Installation

Terminal window
go get github.com/msilverblatt/protomcp/sdk/go

The 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))
}),
)
OptionDescription
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

FunctionJSON 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,
)
FieldDescription
ResultTextThe result string
IsErrorWhether this is an error
ErrorCodeMachine-readable error code
MessageHuman-readable error message
SuggestionActionable suggestion for the caller
RetryableWhether the caller should retry
EnableToolsTool names to enable after this call
DisableToolsTool 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

MethodDescription
ReportProgress(progress, total int64, message string) errorSend a progress notification to the MCP host
IsCancelled() boolReturns 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

MethodLevel
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

PhaseWhen
"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 / OptionDescription
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.