Adding Tools
Adding a new tool requires changes across several files. This guide walks through the complete process.
Overview
Section titled “Overview”Every tool goes through these layers:
- Definition →
tools.yaml(name, description, parameters, annotations) - Constant →
internal/mcp/schema.go(tool name constant) - Handler →
internal/mcp/<domain>.go(business logic) - Client method →
pkg/portainer/client/(API call) - Local model →
pkg/portainer/models/(response type) - Registration →
AddXxxFeatures()function - Meta-tool →
internal/mcp/metatool_registry.go(action mapping) - Tests → Unit + optionally integration
Step-by-Step
Section titled “Step-by-Step”-
Define the tool in
Section titled “Define the tool in tools.yaml”tools.yaml- name: getContainerLogsdescription: "Retrieve logs from a Docker container"parameters:- name: environmentIdtype: numberdescription: "Environment ID where the container runs"required: true- name: containerIdtype: stringdescription: "Docker container ID or name"required: true- name: tailtype: numberdescription: "Number of lines from the end (default: 100)"required: falseannotations:readOnlyHint: truedestructiveHint: falseidempotentHint: trueopenWorldHint: trueParameter types:
string,number,integer,boolean,object,arrayAnnotations control tool behavior:
Annotation Meaning readOnlyHinttrue= available in read-only modedestructiveHinttrue= deletes or irreversibly modifies dataidempotentHinttrue= safe to retryopenWorldHinttrue= interacts with external systems -
Add the tool constant
Section titled “Add the tool constant”In
internal/mcp/schema.go:const ToolGetContainerLogs = "getContainerLogs"Constants ensure type-safe references across handler registration and meta-tools.
-
Implement the handler
Section titled “Implement the handler”Create or extend a file in
internal/mcp/:// HandleGetContainerLogs retrieves logs from a Docker container.//// Parameters:// - environmentId (number, required): Environment ID// - containerId (string, required): Container ID or name// - tail (number, optional): Lines from end (default 100)//// Returns: Container log output as textfunc (s *PortainerMCPServer) HandleGetContainerLogs() server.ToolHandlerFunc {return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {parser := toolgen.NewParameterParser(request)envID, err := parser.GetInt("environmentId", true)if err != nil {return mcp.NewToolResultErrorFromErr("invalid environmentId parameter", err), nil}containerID, err := parser.GetString("containerId", true)if err != nil {return mcp.NewToolResultErrorFromErr("invalid containerId parameter", err), nil}tail, err := parser.GetInt("tail", false)if err != nil {return mcp.NewToolResultErrorFromErr("invalid tail parameter", err), nil}if tail == 0 {tail = 100}logs, err := s.cli.GetContainerLogs(envID, containerID, tail)if err != nil {return mcp.NewToolResultErrorFromErr("failed to get container logs", err), nil}return mcp.NewToolResultText(logs), nil}}Key conventions:
- Godoc comment with Parameters/Returns sections
parser.GetString/GetInt/GetBool(name, required)for parameter extraction- Return
mcp.NewToolResultErrorFromErr()for parameter/API errors (not Go errors) - Error wrapping with
fmt.Errorf("context: %w", err)in the client layer - Return
jsonResult()for structured data ormcp.NewToolResultText()for plain text
-
Add the client method
Section titled “Add the client method”Interface — add to the appropriate domain interface in
internal/mcp/client_interfaces.go(or create a new one if needed):GetContainerLogs(environmentID int, containerID string, tail int) (string, error)Implementation — add in
pkg/portainer/client/docker.go:func (c *PortainerClient) GetContainerLogs(environmentID int, containerID string, tail int) (string, error) {// Call Portainer API via the raw SDK clientresp, err := c.cli.Docker.ContainerLogs(...)if err != nil {return "", fmt.Errorf("failed to get container logs: %w", err)}return resp.Payload, nil} -
Add local model (if needed)
Section titled “Add local model (if needed)”If the tool returns structured data, create a model in
pkg/portainer/models/:type ContainerLog struct {ContainerID string `json:"containerId"`Output string `json:"output"`Lines int `json:"lines"`}// ConvertContainerLog transforms the raw API response into a local model.func ConvertContainerLog(raw *apimodels.ContainerLogResponse) ContainerLog {return ContainerLog{ContainerID: raw.ID,Output: raw.Output,Lines: raw.LineCount,}} -
Register the handler
Section titled “Register the handler”In the appropriate
AddXxxFeatures()function ininternal/mcp/:func (s *PortainerMCPServer) AddDockerFeatures() {// ... existing tools ...s.addToolIfExists(ToolGetContainerLogs, s.HandleGetContainerLogs())} -
Add to a meta-tool group
Section titled “Add to a meta-tool group”In
internal/mcp/metatool_registry.go, add the action to the appropriate meta-tool:// In the manage_docker meta-tool definition:{ActionName: "get_container_logs",ToolName: ToolGetContainerLogs,HandlerFunc: s.HandleGetContainerLogs,ReadOnly: true,},Remember to update the meta-tool’s
descriptionto mention the new action. -
Write tests
Section titled “Write tests”Unit test in
internal/mcp/docker_test.go:func TestHandleGetContainerLogs(t *testing.T) {tests := []struct {name stringargs map[string]anymockSetup func(*MockPortainerClient)wantErr bool}{{name: "valid request",args: map[string]any{"environmentId": float64(1),"containerId": "abc123",},mockSetup: func(m *MockPortainerClient) {m.On("GetContainerLogs", 1, "abc123", 100).Return("log output", nil)},},{name: "missing environment ID",args: map[string]any{"containerId": "abc"},wantErr: true,},{name: "missing container ID",args: map[string]any{"environmentId": float64(1)},wantErr: true,},}for _, tt := range tests {t.Run(tt.name, func(t *testing.T) {mockClient := &MockPortainerClient{}if tt.mockSetup != nil {tt.mockSetup(mockClient)}s := &PortainerMCPServer{cli: mockClient}result, err := s.HandleGetContainerLogs()(context.Background(), CreateMCPRequest(tt.args))if tt.wantErr {assert.True(t, result.IsError)} else {assert.NoError(t, err)assert.NotNil(t, result)}mockClient.AssertExpectations(t)})}}Mock — add to
mocks_test.gousingtestify/mock:func (m *MockPortainerClient) GetContainerLogs(envID int, containerID string, tail int) (string, error) {args := m.Called(envID, containerID, tail)return args.String(0), args.Error(1)}
Checklist
Section titled “Checklist”After implementing a new tool, verify:
-
tools.yamldefinition with correct parameter types and annotations - Tool constant in
schema.go - Handler with proper error wrapping and parameter validation
- Client interface method + implementation
- Local model with conversion function (if applicable)
- Handler registered in
AddXxxFeatures() - Action added to meta-tool in
metatool_registry.go - Unit tests covering success, missing params, and error cases
-
go build ./...passes -
go vet ./...is clean -
go test ./...passes