From 89fe48923d5d4a498ba9b1d35278db3c30161dba Mon Sep 17 00:00:00 2001 From: Shankar Date: Mon, 23 Mar 2026 16:49:39 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20M18a=20=E2=80=94=20MCP=20server=20expos?= =?UTF-8?q?ing=20all=2076=20API=20endpoints=20as=20AI-native=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate standalone binary (cmd/mcp-server/) using official MCP Go SDK (modelcontextprotocol/go-sdk v1.4.1) with stdio transport. Stateless HTTP proxy translates MCP tool calls to certctl REST API requests. 76 tools across 16 resource domains with typed input structs and jsonschema tags for automatic LLM-friendly schema generation. Co-Authored-By: Claude Opus 4.6 --- README.md | 35 +- cmd/mcp-server/main.go | 43 ++ go.mod | 1 + internal/mcp/client.go | 141 ++++++ internal/mcp/tools.go | 1066 ++++++++++++++++++++++++++++++++++++++++ internal/mcp/types.go | 269 ++++++++++ 6 files changed, 1554 insertions(+), 1 deletion(-) create mode 100644 cmd/mcp-server/main.go create mode 100644 internal/mcp/client.go create mode 100644 internal/mcp/tools.go create mode 100644 internal/mcp/types.go diff --git a/README.md b/README.md index d52e45c..1914c1e 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,39 @@ Agent environment variables: Docker Compose overrides these for the demo stack (see `deploy/docker-compose.yml`): port `8443`, auth type `none`, database pointing to the postgres container. +## MCP Server (AI Integration) + +certctl ships a standalone MCP (Model Context Protocol) server that exposes all 76 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client. + +```bash +# Install +go install github.com/shankar0123/certctl/cmd/mcp-server@latest + +# Configure +export CERTCTL_SERVER_URL=http://localhost:8443 # certctl API endpoint +export CERTCTL_API_KEY=your-api-key # optional if auth disabled + +# Run (stdio transport — add to your AI client config) +mcp-server +``` + +**Claude Desktop** (`claude_desktop_config.json`): +```json +{ + "mcpServers": { + "certctl": { + "command": "mcp-server", + "env": { + "CERTCTL_SERVER_URL": "http://localhost:8443", + "CERTCTL_API_KEY": "your-api-key" + } + } + } +} +``` + +76 tools organized by resource: certificates (9), CRL/OCSP (3), issuers (6), targets (5), agents (8), jobs (5), policies (6), profiles (5), teams (5), owners (5), agent groups (6), audit (2), notifications (3), stats (5), metrics (1), health (4). + ## API Overview All endpoints are under `/api/v1/` and return JSON. List endpoints support pagination (`?page=1&per_page=50`). Full request/response schemas are available in the [OpenAPI 3.1 spec](api/openapi.yaml). @@ -417,7 +450,7 @@ All nine development milestones (M1–M9) are complete. The backend covers the f - **M15b: OCSP + Revocation GUI** ✅ — embedded OCSP responder (GET /api/v1/ocsp/{issuer_id}/{serial}), DER-encoded X.509 CRL (GET /api/v1/crl/{issuer_id}), short-lived cert exemption (TTL < 1h skip CRL/OCSP), revocation GUI with reason modal, ~31 new tests - **M13: GUI Operations** ✅ — bulk cert operations (multi-select → renew, revoke, reassign owner), deployment status timeline, inline policy/profile editor, target connector configuration wizard, audit trail export (CSV/JSON), short-lived credentials dashboard view - **M14: Observability** ✅ — dashboard charts (expiration heatmap, cert status distribution, job trends, issuance rate), agent fleet overview with OS/arch grouping, JSON metrics endpoint, stats API (5 endpoints), structured logging with request IDs, deployment rollback -- **M18a: MCP Server** (V2.1) — AI-native integration, expose REST API as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client +- **M18a: MCP Server** ✅ (V2.1) — AI-native integration, all 76 REST API endpoints exposed as MCP tools for Claude, Cursor, OpenClaw, and any MCP-compatible client - **M19: Immutable API Audit Log** — extend audit trail to log every API call (method, path, actor, status, latency), queryable via existing audit endpoint - **M16a: Notifier Connectors** — Slack, Microsoft Teams, PagerDuty, OpsGenie notification integrations (parallel with M19) - **M20: Enhanced Query API** — sparse field selection (`?fields=`), sort params, time-range filters, cursor pagination, `updatedAfter` for incremental agent sync, per-cert deployment history endpoint diff --git a/cmd/mcp-server/main.go b/cmd/mcp-server/main.go new file mode 100644 index 0000000..718d329 --- /dev/null +++ b/cmd/mcp-server/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/shankar0123/certctl/internal/mcp" +) + +// Version is set at build time via -ldflags. +var Version = "dev" + +func main() { + serverURL := os.Getenv("CERTCTL_SERVER_URL") + if serverURL == "" { + serverURL = "http://localhost:8443" + } + + apiKey := os.Getenv("CERTCTL_API_KEY") + + client := mcp.NewClient(serverURL, apiKey) + + server := gomcp.NewServer(&gomcp.Implementation{ + Name: "certctl", + Version: Version, + }, nil) + + mcp.RegisterTools(server, client) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + fmt.Fprintf(os.Stderr, "certctl MCP server %s (backend: %s)\n", Version, serverURL) + + if err := server.Run(ctx, &gomcp.StdioTransport{}); err != nil { + log.Fatalf("MCP server error: %v", err) + } +} diff --git a/go.mod b/go.mod index 9909a8e..85786d5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22.0 require ( github.com/google/uuid v1.6.0 github.com/lib/pq v1.10.9 + github.com/modelcontextprotocol/go-sdk v1.4.1 ) require golang.org/x/crypto v0.31.0 diff --git a/internal/mcp/client.go b/internal/mcp/client.go new file mode 100644 index 0000000..0545d08 --- /dev/null +++ b/internal/mcp/client.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Client is a thin HTTP client that forwards requests to the certctl REST API. +// It handles auth, base URL resolution, and JSON marshaling. +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewClient creates a new certctl API client. +func NewClient(baseURL, apiKey string) *Client { + return &Client{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// Get performs an HTTP GET and returns the raw JSON response body. +func (c *Client) Get(path string, query url.Values) (json.RawMessage, error) { + return c.do("GET", path, query, nil) +} + +// Post performs an HTTP POST with a JSON body and returns the raw JSON response. +func (c *Client) Post(path string, body interface{}) (json.RawMessage, error) { + return c.do("POST", path, nil, body) +} + +// Put performs an HTTP PUT with a JSON body and returns the raw JSON response. +func (c *Client) Put(path string, body interface{}) (json.RawMessage, error) { + return c.do("PUT", path, nil, body) +} + +// Delete performs an HTTP DELETE and returns the raw JSON response (may be empty for 204). +func (c *Client) Delete(path string) (json.RawMessage, error) { + return c.do("DELETE", path, nil, nil) +} + +// GetRaw performs an HTTP GET and returns the raw response body bytes and content type. +// Used for binary responses (DER CRL, OCSP). +func (c *Client) GetRaw(path string) ([]byte, string, error) { + u, err := url.JoinPath(c.baseURL, path) + if err != nil { + return nil, "", fmt.Errorf("invalid URL: %w", err) + } + + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return nil, "", fmt.Errorf("creating request: %w", err) + } + + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, "", fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(data)) + } + + return data, resp.Header.Get("Content-Type"), nil +} + +func (c *Client) do(method, path string, query url.Values, body interface{}) (json.RawMessage, error) { + u, err := url.JoinPath(c.baseURL, path) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + if query != nil && len(query) > 0 { + u = u + "?" + query.Encode() + } + + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshaling request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, u, bodyReader) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Accept", "application/json") + + if c.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+c.apiKey) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + // 204 No Content — return empty JSON object + if resp.StatusCode == 204 { + return json.RawMessage(`{"status":"deleted"}`), nil + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error (HTTP %d): %s", resp.StatusCode, string(respBody)) + } + + return json.RawMessage(respBody), nil +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..e5c8cbc --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,1066 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RegisterTools registers all certctl API endpoints as MCP tools on the server. +func RegisterTools(s *gomcp.Server, client *Client) { + registerCertificateTools(s, client) + registerCRLOCSPTools(s, client) + registerIssuerTools(s, client) + registerTargetTools(s, client) + registerAgentTools(s, client) + registerJobTools(s, client) + registerPolicyTools(s, client) + registerProfileTools(s, client) + registerTeamTools(s, client) + registerOwnerTools(s, client) + registerAgentGroupTools(s, client) + registerAuditTools(s, client) + registerNotificationTools(s, client) + registerStatsTools(s, client) + registerMetricsTools(s, client) + registerHealthTools(s, client) +} + +// ── Helpers ───────────────────────────────────────────────────────── + +func textResult(data json.RawMessage) (*gomcp.CallToolResult, any, error) { + return &gomcp.CallToolResult{ + Content: []gomcp.Content{ + &gomcp.TextContent{Text: string(data)}, + }, + }, nil, nil +} + +func errorResult(err error) (*gomcp.CallToolResult, any, error) { + return nil, nil, fmt.Errorf("%w", err) +} + +func paginationQuery(page, perPage int) url.Values { + q := url.Values{} + if page > 0 { + q.Set("page", strconv.Itoa(page)) + } + if perPage > 0 { + q.Set("per_page", strconv.Itoa(perPage)) + } + return q +} + +// ── Certificates ──────────────────────────────────────────────────── + +func registerCertificateTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_certificates", + Description: "List managed certificates with optional filters for status, environment, owner, team, and issuer. Returns paginated results.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListCertificatesInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.Status != "" { + q.Set("status", input.Status) + } + if input.Environment != "" { + q.Set("environment", input.Environment) + } + if input.OwnerID != "" { + q.Set("owner_id", input.OwnerID) + } + if input.TeamID != "" { + q.Set("team_id", input.TeamID) + } + if input.IssuerID != "" { + q.Set("issuer_id", input.IssuerID) + } + data, err := c.Get("/api/v1/certificates", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_certificate", + Description: "Get a specific certificate by ID. Returns full certificate details including status, expiry, owner, and tags.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/certificates/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_certificate", + Description: "Create a new managed certificate. Requires common_name and issuer_id at minimum.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/certificates", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_certificate", + Description: "Update an existing certificate's metadata (name, environment, owner, tags, etc.).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateCertificateInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/certificates/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_archive_certificate", + Description: "Archive (soft-delete) a certificate by ID.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/certificates/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_certificate_versions", + Description: "List all versions (renewals) of a certificate. Shows serial numbers, validity periods, and fingerprints.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListVersionsInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + data, err := c.Get("/api/v1/certificates/"+input.ID+"/versions", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_trigger_renewal", + Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_trigger_deployment", + Description: "Trigger deployment of a certificate to its targets. Optionally specify a single target.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input TriggerDeploymentInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{} + if input.TargetID != "" { + body["target_id"] = input.TargetID + } + data, err := c.Post("/api/v1/certificates/"+input.ID+"/deploy", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_revoke_certificate", + Description: "Revoke a certificate with an optional RFC 5280 reason code. Records in audit trail and notifies the issuer.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input RevokeCertificateInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{} + if input.Reason != "" { + body["reason"] = input.Reason + } + data, err := c.Post("/api/v1/certificates/"+input.ID+"/revoke", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── CRL & OCSP ────────────────────────────────────────────────────── + +func registerCRLOCSPTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_crl", + Description: "Get the Certificate Revocation List in JSON format. Lists all revoked certificate serial numbers with reasons and timestamps.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/crl", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_der_crl", + Description: "Get DER-encoded X.509 CRL for a specific issuer. Returns binary CRL data signed by the issuing CA.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetDERCRLInput) (*gomcp.CallToolResult, any, error) { + raw, contentType, err := c.GetRaw("/api/v1/crl/" + input.IssuerID) + if err != nil { + return errorResult(err) + } + return &gomcp.CallToolResult{ + Content: []gomcp.Content{ + &gomcp.TextContent{Text: fmt.Sprintf("DER CRL retrieved (%d bytes, content-type: %s)", len(raw), contentType)}, + }, + }, nil, nil + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_ocsp_check", + Description: "Check OCSP status for a certificate by issuer ID and hex serial number. Returns good, revoked, or unknown.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input OCSPInput) (*gomcp.CallToolResult, any, error) { + raw, contentType, err := c.GetRaw("/api/v1/ocsp/" + input.IssuerID + "/" + input.Serial) + if err != nil { + return errorResult(err) + } + return &gomcp.CallToolResult{ + Content: []gomcp.Content{ + &gomcp.TextContent{Text: fmt.Sprintf("OCSP response retrieved (%d bytes, content-type: %s)", len(raw), contentType)}, + }, + }, nil, nil + }) +} + +// ── Issuers ───────────────────────────────────────────────────────── + +func registerIssuerTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_issuers", + Description: "List all configured issuer connectors (Local CA, ACME, step-ca).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/issuers", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_issuer", + Description: "Get issuer details including type, configuration, and enabled status.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/issuers/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_issuer", + Description: "Register a new issuer connector. Requires name and type (ACME, GenericCA, or StepCA).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateIssuerInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/issuers", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_issuer", + Description: "Update an issuer connector's configuration.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateIssuerInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/issuers/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_issuer", + Description: "Delete an issuer connector.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/issuers/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_test_issuer", + Description: "Test connectivity to an issuer connector. Returns success or error details.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/issuers/"+input.ID+"/test", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Targets ───────────────────────────────────────────────────────── + +func registerTargetTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_targets", + Description: "List all deployment targets (NGINX, Apache, HAProxy, F5, IIS).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/targets", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_target", + Description: "Get deployment target details including type, agent, and configuration.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/targets/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_target", + Description: "Create a new deployment target. Requires name and type (NGINX, Apache, HAProxy, F5, IIS).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateTargetInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/targets", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_target", + Description: "Update a deployment target's configuration.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateTargetInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/targets/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_target", + Description: "Delete a deployment target.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/targets/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Agents ────────────────────────────────────────────────────────── + +func registerAgentTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_agents", + Description: "List all registered agents with status, OS, architecture, and version info.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agents", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_agent", + Description: "Get agent details including status, last heartbeat, OS, architecture, IP, and version.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agents/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_register_agent", + Description: "Register a new agent. Requires name and hostname.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input RegisterAgentInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/agents", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_agent_heartbeat", + Description: "Send agent heartbeat with optional metadata (OS, architecture, IP, version).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input struct { + ID string `json:"id" jsonschema:"Agent ID"` + Version string `json:"version,omitempty" jsonschema:"Agent version"` + Hostname string `json:"hostname,omitempty" jsonschema:"Hostname"` + OS string `json:"os,omitempty" jsonschema:"Operating system"` + Architecture string `json:"architecture,omitempty" jsonschema:"CPU architecture"` + IPAddress string `json:"ip_address,omitempty" jsonschema:"IP address"` + }) (*gomcp.CallToolResult, any, error) { + body := map[string]string{} + if input.Version != "" { + body["version"] = input.Version + } + if input.Hostname != "" { + body["hostname"] = input.Hostname + } + if input.OS != "" { + body["os"] = input.OS + } + if input.Architecture != "" { + body["architecture"] = input.Architecture + } + if input.IPAddress != "" { + body["ip_address"] = input.IPAddress + } + data, err := c.Post("/api/v1/agents/"+input.ID+"/heartbeat", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_agent_submit_csr", + Description: "Submit a PEM-encoded CSR from an agent for signing.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AgentCSRInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{"csr_pem": input.CSRPEM} + if input.CertificateID != "" { + body["certificate_id"] = input.CertificateID + } + data, err := c.Post("/api/v1/agents/"+input.AgentID+"/csr", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_agent_pickup_certificate", + Description: "Agent picks up a signed certificate after CSR has been processed.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AgentPickupInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agents/"+input.AgentID+"/certificates/"+input.CertID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_agent_get_work", + Description: "Get pending work items (deployment jobs, AwaitingCSR jobs) for an agent.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agents/"+input.ID+"/work", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_agent_report_job_status", + Description: "Agent reports completion or failure of an assigned job.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AgentJobStatusInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{"status": input.Status} + if input.Error != "" { + body["error"] = input.Error + } + data, err := c.Post("/api/v1/agents/"+input.AgentID+"/jobs/"+input.JobID+"/status", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Jobs ──────────────────────────────────────────────────────────── + +func registerJobTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_jobs", + Description: "List jobs with optional status and type filters. Job types: Issuance, Renewal, Deployment, Validation.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListJobsInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.Status != "" { + q.Set("status", input.Status) + } + if input.Type != "" { + q.Set("type", input.Type) + } + data, err := c.Get("/api/v1/jobs", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_job", + Description: "Get job details including type, status, attempts, errors, and timestamps.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/jobs/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_cancel_job", + Description: "Cancel a pending or running job.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/jobs/"+input.ID+"/cancel", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approve_job", + Description: "Approve a job that is in AwaitingApproval state.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/jobs/"+input.ID+"/approve", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_reject_job", + Description: "Reject a job in AwaitingApproval state with an optional reason.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input RejectJobInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{} + if input.Reason != "" { + body["reason"] = input.Reason + } + data, err := c.Post("/api/v1/jobs/"+input.ID+"/reject", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Policies ──────────────────────────────────────────────────────── + +func registerPolicyTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_policies", + Description: "List all policy rules. Policy types: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/policies", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_policy", + Description: "Get policy rule details including type, configuration, and enabled status.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/policies/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_policy", + Description: "Create a new policy rule. Requires name and type.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/policies", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_policy", + Description: "Update a policy rule's name, type, configuration, or enabled status.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/policies/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_policy", + Description: "Delete a policy rule.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/policies/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_policy_violations", + Description: "List violations for a specific policy. Shows affected certificates and severity (Warning, Error, Critical).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListViolationsInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + data, err := c.Get("/api/v1/policies/"+input.ID+"/violations", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Profiles ──────────────────────────────────────────────────────── + +func registerProfileTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_profiles", + Description: "List certificate enrollment profiles defining allowed key types, max TTL, and crypto constraints.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/profiles", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_profile", + Description: "Get certificate profile details including allowed algorithms, max TTL, EKUs, and SAN patterns.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/profiles/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_profile", + Description: "Create a certificate enrollment profile. Requires name.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateProfileInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/profiles", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_profile", + Description: "Update a certificate profile's constraints.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateProfileInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/profiles/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_profile", + Description: "Delete a certificate profile.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/profiles/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Teams ─────────────────────────────────────────────────────────── + +func registerTeamTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_teams", + Description: "List all teams for certificate ownership grouping.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/teams", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_team", + Description: "Get team details.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/teams/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_team", + Description: "Create a new team. Requires name.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateTeamInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/teams", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_team", + Description: "Update a team's name or description.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateTeamInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/teams/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_team", + Description: "Delete a team.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/teams/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Owners ────────────────────────────────────────────────────────── + +func registerOwnerTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_owners", + Description: "List all certificate owners with email and team assignment.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/owners", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_owner", + Description: "Get owner details including email and team.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/owners/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_owner", + Description: "Create a new certificate owner. Requires name.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateOwnerInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/owners", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_owner", + Description: "Update an owner's name, email, or team assignment.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateOwnerInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/owners/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_owner", + Description: "Delete a certificate owner.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/owners/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Agent Groups ──────────────────────────────────────────────────── + +func registerAgentGroupTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_agent_groups", + Description: "List agent groups with dynamic matching criteria (OS, architecture, IP CIDR, version).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agent-groups", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_agent_group", + Description: "Get agent group details including matching criteria.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agent-groups/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_agent_group", + Description: "Create a new agent group with dynamic matching criteria. Requires name.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateAgentGroupInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/agent-groups", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_agent_group", + Description: "Update an agent group's name, description, or matching criteria.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateAgentGroupInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/agent-groups/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_agent_group", + Description: "Delete an agent group.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/agent-groups/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_agent_group_members", + Description: "List agents that are members of a group (by dynamic criteria and manual membership).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/agent-groups/"+input.ID+"/members", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Audit ─────────────────────────────────────────────────────────── + +func registerAuditTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_audit_events", + Description: "List immutable audit trail events. Shows actor, action, resource, and timestamp for all lifecycle operations.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/audit", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_audit_event", + Description: "Get a specific audit event by ID.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/audit/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Notifications ─────────────────────────────────────────────────── + +func registerNotificationTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_notifications", + Description: "List notification events (expiration warnings, renewal/deployment results, policy violations, revocations).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/notifications", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_notification", + Description: "Get notification event details.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/notifications/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_mark_notification_read", + Description: "Mark a notification as read.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/notifications/"+input.ID+"/read", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Stats ─────────────────────────────────────────────────────────── + +func registerStatsTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_dashboard_summary", + Description: "Get high-level dashboard metrics: total/expiring/expired/revoked certs, active/offline agents, pending/failed/completed jobs.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/stats/summary", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_certificates_by_status", + Description: "Get certificate counts grouped by status (Active, Expiring, Expired, Revoked, etc.).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/stats/certificates-by-status", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_expiration_timeline", + Description: "Get certificates expiring per day for the next N days (default 30, max 365).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input TimelineInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if input.Days > 0 { + q.Set("days", strconv.Itoa(input.Days)) + } + data, err := c.Get("/api/v1/stats/expiration-timeline", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_job_trends", + Description: "Get job success/failure trends per day for the past N days (default 30, max 365).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input TimelineInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if input.Days > 0 { + q.Set("days", strconv.Itoa(input.Days)) + } + data, err := c.Get("/api/v1/stats/job-trends", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_issuance_rate", + Description: "Get new certificate issuance count per day for the past N days (default 30, max 365).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input TimelineInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if input.Days > 0 { + q.Set("days", strconv.Itoa(input.Days)) + } + data, err := c.Get("/api/v1/stats/issuance-rate", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Metrics ───────────────────────────────────────────────────────── + +func registerMetricsTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_metrics", + Description: "Get system metrics snapshot: gauge metrics (cert/agent/job counts), counters (completed/failed totals), and server uptime.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/metrics", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Health ────────────────────────────────────────────────────────── + +func registerHealthTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_health", + Description: "Check certctl server health status.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/health", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_ready", + Description: "Check certctl server readiness (database connectivity, etc.).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/ready", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_info", + Description: "Get auth configuration (auth type and whether auth is required).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/auth/info", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_check", + Description: "Validate that the configured API key is accepted by the server.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/auth/check", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} diff --git a/internal/mcp/types.go b/internal/mcp/types.go new file mode 100644 index 0000000..cace478 --- /dev/null +++ b/internal/mcp/types.go @@ -0,0 +1,269 @@ +package mcp + +// Input types for MCP tool arguments. +// The jsonschema struct tags provide descriptions for LLM tool discovery. + +// ── Pagination ────────────────────────────────────────────────────── + +type ListParams struct { + Page int `json:"page,omitempty" jsonschema:"Page number (default 1)"` + PerPage int `json:"per_page,omitempty" jsonschema:"Results per page (default 50, max 500)"` +} + +// ── Certificates ──────────────────────────────────────────────────── + +type ListCertificatesInput struct { + ListParams + Status string `json:"status,omitempty" jsonschema:"Filter by status: Pending, Active, Expiring, Expired, RenewalInProgress, Failed, Revoked, Archived"` + Environment string `json:"environment,omitempty" jsonschema:"Filter by environment"` + OwnerID string `json:"owner_id,omitempty" jsonschema:"Filter by owner ID"` + TeamID string `json:"team_id,omitempty" jsonschema:"Filter by team ID"` + IssuerID string `json:"issuer_id,omitempty" jsonschema:"Filter by issuer ID"` +} + +type GetByIDInput struct { + ID string `json:"id" jsonschema:"Resource ID (e.g. mc-api-prod, t-platform)"` +} + +type CreateCertificateInput struct { + ID string `json:"id,omitempty" jsonschema:"Certificate ID (auto-generated if empty)"` + Name string `json:"name" jsonschema:"Display name"` + CommonName string `json:"common_name" jsonschema:"Certificate common name (e.g. api.example.com)"` + SANs []string `json:"sans,omitempty" jsonschema:"Subject Alternative Names"` + Environment string `json:"environment,omitempty" jsonschema:"Environment (e.g. production, staging)"` + OwnerID string `json:"owner_id,omitempty" jsonschema:"Owner ID"` + TeamID string `json:"team_id,omitempty" jsonschema:"Team ID"` + IssuerID string `json:"issuer_id" jsonschema:"Issuer connector ID"` + TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"` + RenewalPolicyID string `json:"renewal_policy_id,omitempty" jsonschema:"Renewal policy ID"` + ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"` + Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"` +} + +type UpdateCertificateInput struct { + ID string `json:"id" jsonschema:"Certificate ID to update"` + Name string `json:"name,omitempty" jsonschema:"Display name"` + Environment string `json:"environment,omitempty" jsonschema:"Environment"` + OwnerID string `json:"owner_id,omitempty" jsonschema:"Owner ID"` + TeamID string `json:"team_id,omitempty" jsonschema:"Team ID"` + TargetIDs []string `json:"target_ids,omitempty" jsonschema:"Deployment target IDs"` + RenewalPolicyID string `json:"renewal_policy_id,omitempty" jsonschema:"Renewal policy ID"` + ProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Certificate profile ID"` + Tags map[string]string `json:"tags,omitempty" jsonschema:"Key-value tags"` +} + +type TriggerDeploymentInput struct { + ID string `json:"id" jsonschema:"Certificate ID"` + TargetID string `json:"target_id,omitempty" jsonschema:"Optional specific target ID"` +} + +type RevokeCertificateInput struct { + ID string `json:"id" jsonschema:"Certificate ID to revoke"` + Reason string `json:"reason,omitempty" jsonschema:"RFC 5280 reason: unspecified, keyCompromise, caCompromise, affiliationChanged, superseded, cessationOfOperation, certificateHold, privilegeWithdrawn"` +} + +type ListVersionsInput struct { + ID string `json:"id" jsonschema:"Certificate ID"` + ListParams +} + +// ── CRL & OCSP ────────────────────────────────────────────────────── + +type GetDERCRLInput struct { + IssuerID string `json:"issuer_id" jsonschema:"Issuer ID for DER-encoded CRL"` +} + +type OCSPInput struct { + IssuerID string `json:"issuer_id" jsonschema:"Issuer ID"` + Serial string `json:"serial" jsonschema:"Hex-encoded certificate serial number"` +} + +// ── Issuers ───────────────────────────────────────────────────────── + +type CreateIssuerInput struct { + ID string `json:"id,omitempty" jsonschema:"Issuer ID"` + Name string `json:"name" jsonschema:"Issuer display name"` + Type string `json:"type" jsonschema:"Issuer type: ACME, GenericCA, StepCA"` + Config interface{} `json:"config,omitempty" jsonschema:"Issuer-specific configuration"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the issuer is enabled"` +} + +type UpdateIssuerInput struct { + ID string `json:"id" jsonschema:"Issuer ID to update"` + Name string `json:"name,omitempty" jsonschema:"Issuer display name"` + Type string `json:"type,omitempty" jsonschema:"Issuer type"` + Config interface{} `json:"config,omitempty" jsonschema:"Issuer-specific configuration"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the issuer is enabled"` +} + +// ── Targets ───────────────────────────────────────────────────────── + +type CreateTargetInput struct { + ID string `json:"id,omitempty" jsonschema:"Target ID"` + Name string `json:"name" jsonschema:"Target display name"` + Type string `json:"type" jsonschema:"Target type: NGINX, Apache, HAProxy, F5, IIS"` + AgentID string `json:"agent_id,omitempty" jsonschema:"Agent ID that manages this target"` + Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"` +} + +type UpdateTargetInput struct { + ID string `json:"id" jsonschema:"Target ID to update"` + Name string `json:"name,omitempty" jsonschema:"Target display name"` + Type string `json:"type,omitempty" jsonschema:"Target type"` + AgentID string `json:"agent_id,omitempty" jsonschema:"Agent ID"` + Config interface{} `json:"config,omitempty" jsonschema:"Target-specific configuration"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the target is enabled"` +} + +// ── Agents ────────────────────────────────────────────────────────── + +type RegisterAgentInput struct { + ID string `json:"id,omitempty" jsonschema:"Agent ID"` + Name string `json:"name" jsonschema:"Agent display name"` + Hostname string `json:"hostname" jsonschema:"Agent hostname"` +} + +type AgentCSRInput struct { + AgentID string `json:"agent_id" jsonschema:"Agent ID"` + CSRPEM string `json:"csr_pem" jsonschema:"PEM-encoded certificate signing request"` + CertificateID string `json:"certificate_id,omitempty" jsonschema:"Certificate ID for the CSR"` +} + +type AgentPickupInput struct { + AgentID string `json:"agent_id" jsonschema:"Agent ID"` + CertID string `json:"cert_id" jsonschema:"Certificate ID to pick up"` +} + +type AgentJobStatusInput struct { + AgentID string `json:"agent_id" jsonschema:"Agent ID"` + JobID string `json:"job_id" jsonschema:"Job ID"` + Status string `json:"status" jsonschema:"Job status to report"` + Error string `json:"error,omitempty" jsonschema:"Error message if job failed"` +} + +// ── Jobs ──────────────────────────────────────────────────────────── + +type ListJobsInput struct { + ListParams + Status string `json:"status,omitempty" jsonschema:"Filter by status: Pending, AwaitingCSR, AwaitingApproval, Running, Completed, Failed, Cancelled"` + Type string `json:"type,omitempty" jsonschema:"Filter by type: Issuance, Renewal, Deployment, Validation"` +} + +type RejectJobInput struct { + ID string `json:"id" jsonschema:"Job ID to reject"` + Reason string `json:"reason,omitempty" jsonschema:"Reason for rejection"` +} + +// ── Policies ──────────────────────────────────────────────────────── + +type CreatePolicyInput struct { + ID string `json:"id,omitempty" jsonschema:"Policy ID"` + Name string `json:"name" jsonschema:"Policy display name"` + Type string `json:"type" jsonschema:"Policy type: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"` + Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"` +} + +type UpdatePolicyInput struct { + ID string `json:"id" jsonschema:"Policy ID to update"` + Name string `json:"name,omitempty" jsonschema:"Policy display name"` + Type string `json:"type,omitempty" jsonschema:"Policy type"` + Config interface{} `json:"config,omitempty" jsonschema:"Policy-specific configuration"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the policy is enabled"` +} + +type ListViolationsInput struct { + ID string `json:"id" jsonschema:"Policy ID"` + ListParams +} + +// ── Profiles ──────────────────────────────────────────────────────── + +type CreateProfileInput struct { + ID string `json:"id,omitempty" jsonschema:"Profile ID"` + Name string `json:"name" jsonschema:"Profile display name"` + Description string `json:"description,omitempty" jsonschema:"Profile description"` + AllowedKeyAlgorithms interface{} `json:"allowed_key_algorithms,omitempty" jsonschema:"Allowed key algorithms and minimum sizes"` + MaxTTLSeconds int `json:"max_ttl_seconds,omitempty" jsonschema:"Maximum certificate TTL in seconds"` + AllowedEKUs []string `json:"allowed_ekus,omitempty" jsonschema:"Allowed Extended Key Usages"` + RequiredSANPatterns []string `json:"required_san_patterns,omitempty" jsonschema:"Required SAN patterns"` + AllowShortLived bool `json:"allow_short_lived,omitempty" jsonschema:"Allow short-lived certificates (TTL < 1 hour)"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the profile is enabled"` +} + +type UpdateProfileInput struct { + ID string `json:"id" jsonschema:"Profile ID to update"` + Name string `json:"name,omitempty" jsonschema:"Profile display name"` + Description string `json:"description,omitempty" jsonschema:"Profile description"` + AllowedKeyAlgorithms interface{} `json:"allowed_key_algorithms,omitempty" jsonschema:"Allowed key algorithms and minimum sizes"` + MaxTTLSeconds *int `json:"max_ttl_seconds,omitempty" jsonschema:"Maximum certificate TTL in seconds"` + AllowedEKUs []string `json:"allowed_ekus,omitempty" jsonschema:"Allowed Extended Key Usages"` + RequiredSANPatterns []string `json:"required_san_patterns,omitempty" jsonschema:"Required SAN patterns"` + AllowShortLived *bool `json:"allow_short_lived,omitempty" jsonschema:"Allow short-lived certificates"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the profile is enabled"` +} + +// ── Teams ─────────────────────────────────────────────────────────── + +type CreateTeamInput struct { + ID string `json:"id,omitempty" jsonschema:"Team ID"` + Name string `json:"name" jsonschema:"Team name"` + Description string `json:"description,omitempty" jsonschema:"Team description"` +} + +type UpdateTeamInput struct { + ID string `json:"id" jsonschema:"Team ID to update"` + Name string `json:"name,omitempty" jsonschema:"Team name"` + Description string `json:"description,omitempty" jsonschema:"Team description"` +} + +// ── Owners ────────────────────────────────────────────────────────── + +type CreateOwnerInput struct { + ID string `json:"id,omitempty" jsonschema:"Owner ID"` + Name string `json:"name" jsonschema:"Owner display name"` + Email string `json:"email,omitempty" jsonschema:"Owner email for notifications"` + TeamID string `json:"team_id,omitempty" jsonschema:"Team ID the owner belongs to"` +} + +type UpdateOwnerInput struct { + ID string `json:"id" jsonschema:"Owner ID to update"` + Name string `json:"name,omitempty" jsonschema:"Owner display name"` + Email string `json:"email,omitempty" jsonschema:"Owner email"` + TeamID string `json:"team_id,omitempty" jsonschema:"Team ID"` +} + +// ── Agent Groups ──────────────────────────────────────────────────── + +type CreateAgentGroupInput struct { + ID string `json:"id,omitempty" jsonschema:"Agent group ID"` + Name string `json:"name" jsonschema:"Group display name"` + Description string `json:"description,omitempty" jsonschema:"Group description"` + MatchOS string `json:"match_os,omitempty" jsonschema:"Match agents by OS (e.g. linux, darwin, windows)"` + MatchArchitecture string `json:"match_architecture,omitempty" jsonschema:"Match agents by architecture (e.g. amd64, arm64)"` + MatchIPCIDR string `json:"match_ip_cidr,omitempty" jsonschema:"Match agents by IP CIDR range"` + MatchVersion string `json:"match_version,omitempty" jsonschema:"Match agents by version"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the group is enabled"` +} + +type UpdateAgentGroupInput struct { + ID string `json:"id" jsonschema:"Agent group ID to update"` + Name string `json:"name,omitempty" jsonschema:"Group display name"` + Description string `json:"description,omitempty" jsonschema:"Group description"` + MatchOS string `json:"match_os,omitempty" jsonschema:"Match agents by OS"` + MatchArchitecture string `json:"match_architecture,omitempty" jsonschema:"Match agents by architecture"` + MatchIPCIDR string `json:"match_ip_cidr,omitempty" jsonschema:"Match agents by IP CIDR range"` + MatchVersion string `json:"match_version,omitempty" jsonschema:"Match agents by version"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"Whether the group is enabled"` +} + +// ── Stats ─────────────────────────────────────────────────────────── + +type TimelineInput struct { + Days int `json:"days,omitempty" jsonschema:"Number of days to look back (default 30, max 365)"` +} + +// ── Empty ─────────────────────────────────────────────────────────── + +type EmptyInput struct{}