Skip to content

Adding Tools

Adding a new tool requires changes across several files. This guide walks through the complete process.

Every tool goes through these layers:

  1. Definitiontools.yaml (name, description, parameters, annotations)
  2. Constantinternal/mcp/schema.go (tool name constant)
  3. Handlerinternal/mcp/<domain>.go (business logic)
  4. Client methodpkg/portainer/client/ (API call)
  5. Local modelpkg/portainer/models/ (response type)
  6. RegistrationAddXxxFeatures() function
  7. Meta-toolinternal/mcp/metatool_registry.go (action mapping)
  8. Tests → Unit + optionally integration
  1. - name: getContainerLogs
    description: "Retrieve logs from a Docker container"
    parameters:
    - name: environmentId
    type: number
    description: "Environment ID where the container runs"
    required: true
    - name: containerId
    type: string
    description: "Docker container ID or name"
    required: true
    - name: tail
    type: number
    description: "Number of lines from the end (default: 100)"
    required: false
    annotations:
    readOnlyHint: true
    destructiveHint: false
    idempotentHint: true
    openWorldHint: true

    Parameter types: string, number, integer, boolean, object, array

    Annotations control tool behavior:

    AnnotationMeaning
    readOnlyHinttrue = available in read-only mode
    destructiveHinttrue = deletes or irreversibly modifies data
    idempotentHinttrue = safe to retry
    openWorldHinttrue = interacts with external systems
  2. In internal/mcp/schema.go:

    const ToolGetContainerLogs = "getContainerLogs"

    Constants ensure type-safe references across handler registration and meta-tools.

  3. 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 text
    func (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 or mcp.NewToolResultText() for plain text
  4. 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 client
    resp, err := c.cli.Docker.ContainerLogs(...)
    if err != nil {
    return "", fmt.Errorf("failed to get container logs: %w", err)
    }
    return resp.Payload, nil
    }
  5. 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,
    }
    }
  6. In the appropriate AddXxxFeatures() function in internal/mcp/:

    func (s *PortainerMCPServer) AddDockerFeatures() {
    // ... existing tools ...
    s.addToolIfExists(ToolGetContainerLogs, s.HandleGetContainerLogs())
    }
  7. 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 description to mention the new action.

  8. Unit test in internal/mcp/docker_test.go:

    func TestHandleGetContainerLogs(t *testing.T) {
    tests := []struct {
    name string
    args map[string]any
    mockSetup 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.go using testify/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)
    }

After implementing a new tool, verify:

  • tools.yaml definition 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