-
Define the args and result structs. For simple tools (≤3 fields), use an inline anonymous struct in tools.go. For complex tools (4+ fields or reusable logic), create internal/mcp/<tool_name>.go.
Naming: <toolName>Args, <toolName>Result. JSON tags use snake_case with omitempty for optional fields.
type myToolArgs struct {
PhaseNum int `json:"phase_num"`
Title string `json:"title"`
ReqIDs []string `json:"req_ids,omitempty"`
}
type myToolResult struct {
BeadID string `json:"bead_id"`
}
Verify: All required fields have no omitempty. JSON tags match the InputSchema property names.
-
Write the handler function (if using a dedicated file). Signature:
func handleMyTool(ctx context.Context, state *serverState, args myToolArgs) (*mcpsdk.CallToolResult, error) {
if err := state.init(ctx); err != nil {
return toolError(err.Error()), nil
}
return toolResult(&myToolResult{BeadID: bead.ID})
}
Imports: mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" and "github.com/The-Artificer-of-Ciphers-LLC/gsd-wired/internal/graph" as needed.
Verify: state.init(ctx) is the first call. All error paths return toolError(...), nil.
-
Register the tool in registerTools() in internal/mcp/tools.go. Add a new server.AddTool() block following the existing pattern:
server.AddTool(&mcpsdk.Tool{
Name: "my_tool",
Description: "Full description for Claude to read.",
InputSchema: json.RawMessage(`{"type":"object","properties":{"phase_num":{"type":"integer","description":"Phase number"},"title":{"type":"string","description":"Title"}},"required":["phase_num","title"],"additionalProperties":false}`),
}, func(ctx context.Context, req *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) {
var args myToolArgs
if err := json.Unmarshal(req.Params.Arguments, &args); err != nil {
return toolError("invalid arguments: " + err.Error()), nil
}
return handleMyTool(ctx, state, args)
})
For inline handlers (simple tools), put state.init(ctx) inside the closure directly instead of delegating.
Verify: InputSchema JSON is valid. "additionalProperties":false is set. "required" lists all mandatory fields.
-
Update the tool count. In tools.go, update the registerTools doc comment (// registerTools registers all N GSD MCP tools). In tools_test.go, update TestToolsRegistered:
- Change the count assertion:
if len(result.Tools) != N {
- Add your tool name to the
wantNames slice.
Verify: go test ./internal/mcp/ -run TestToolsRegistered passes.
-
Write the test in internal/mcp/tools_test.go (simple) or internal/mcp/<tool_name>_test.go (complex). Follow this pattern:
func TestToolCallMyTool(t *testing.T) {
tmpDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(tmpDir, ".beads"), 0755); err != nil {
t.Fatal(err)
}
state := &serverState{beadsDir: tmpDir, bdPath: fakeBdPathMCP}
if err := state.init(context.Background()); err != nil {
t.Fatalf("state.init() failed: %v", err)
}
cs := connectInProcess(t, state)
result, err := cs.CallTool(context.Background(), &mcpsdk.CallToolParams{
Name: "my_tool",
Arguments: map[string]any{
"phase_num": 1,
"title": "Test",
},
})
if err != nil {
t.Fatalf("CallTool(my_tool) returned error: %v", err)
}
if result.IsError {
t.Fatalf("CallTool(my_tool) returned IsError=true: %v", contentText(result))
}
var resp myToolResult
if err := json.Unmarshal([]byte(contentText(result)), &resp); err != nil {
t.Fatalf("response is not valid JSON: %v", err)
}
if resp.BeadID == "" {
t.Error("expected non-empty bead_id")
}
}
Also add a bad-args subtest if the tool has required fields with specific types.
Verify: go test ./internal/mcp/ -run TestToolCallMyTool -v passes.
-
Run full test suite. go test ./internal/mcp/ — all tests must pass including the updated tool count.