diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 7209c7b..fa3a7fa 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -66,26 +66,35 @@ After saving, restart your MCP client. You should see "certctl" appear in its to ## Available Tools -The MCP server exposes the full REST API organized across 16 resource domains: +The MCP server exposes the full REST API organized across 22 resource domains. Re-derive the live count via `grep -cE 'gomcp\.AddTool\(' internal/mcp/tools.go internal/mcp/tools_est.go` (the per-domain numbers below decay between releases — treat them as approximate at point of writing): | Domain | Tools | Examples | |--------|-------|---------| -| Certificates | 9 | List, get, create, update, archive, versions, renew, deploy, revoke | -| CRL & OCSP | 3 | Get JSON CRL, get DER CRL by issuer, check OCSP status | +| Certificates | 14 | List, get, create, update, archive, versions, renew, deploy, revoke, bulk-revoke / -renew / -reassign, claim/dismiss discovered | +| CRL & OCSP | 2 | Get DER CRL by issuer, check OCSP status | | Issuers | 6 | List, get, create, update, delete, test connection | | Targets | 5 | List, get, create, update, delete | -| Agents | 8 | List, get, register, heartbeat, CSR submit, certificate pickup, get work, report job status | -| Jobs | 5 | List, get, cancel, approve, reject | +| Agents | 9 | List, list retired, get, register, retire, heartbeat, get work, submit CSR, report job status | +| Jobs | 5 | List, get, approve, reject, cancel | | Policies | 6 | List, get, create, update, delete, list violations | | Profiles | 5 | List, get, create, update, delete | | Teams | 5 | List, get, create, update, delete | | Owners | 5 | List, get, create, update, delete | | Agent Groups | 6 | List, get, create, update, delete, list members | | Audit | 2 | List events (with filters), get event by ID | -| Notifications | 3 | List, get, mark as read | +| Notifications | 4 | List, get, mark as read, requeue dead-letter | | Stats | 5 | Summary, certs by status, expiration timeline, job trends, issuance rate | | Metrics | 1 | System metrics (gauges, counters, uptime) | +| Digest | 2 | Preview digest, send digest | | Health | 4 | Health check, readiness probe, auth info, auth check | +| Approvals | 4 | List, get, approve, reject (issuance approval workflow) | +| Health Checks | 8 | List, summary, get, create, update, delete, history, acknowledge | +| Renewal Policies | 5 | List, get, create, update, delete | +| Network Scan Targets | 6 | List, get, create, update, delete, trigger scan | +| Discovery | 4 | List discovered certs, get, list scans, summary | +| Intermediate CAs | 4 | List, create, get, retire (admin-gated) | +| Verification | 3 | List cert deployments, verify job, get job verification | +| EST | 6 | List/admin profiles, get cacerts, csrattrs, simpleenroll, simplereenroll | Every tool has typed input parameters with `jsonschema` descriptions, so the AI knows exactly what arguments to provide and what each field means. @@ -125,7 +134,7 @@ flowchart LR AI <-->|"stdio"| MCP MCP -->|"HTTP + Bearer token"| SERVER - MCP ~~~ TOOLS["REST API via MCP · 16 domains\nTyped input structs"] + MCP ~~~ TOOLS["REST API via MCP · 22 domains\nTyped input structs"] ``` The MCP server is intentionally thin: diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index c44d6bf..fa898a9 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -30,6 +30,20 @@ func RegisterTools(s *gomcp.Server, client *Client) { registerDigestTools(s, client) registerHealthTools(s, client) registerESTTools(s, client) + // 2026-05-05 CLI/API/MCP↔GUI parity audit closure (35 P1 findings). + // Each register function below maps to one phase of + // cowork/mcp-coverage-expansion-prompt.md. + registerApprovalTools(s, client) // Phase A — P1-28..P1-31 + registerHealthCheckTools(s, client) // Phase B — P1-20..P1-27 + registerRenewalPolicyTools(s, client) // Phase C — P1-1..P1-5 + registerNetworkScanTools(s, client) // Phase D — P1-14..P1-19 + registerDiscoveryReadTools(s, client) // Phase E — P1-10..P1-13 + registerIntermediateCATools(s, client) // Phase F — P1-6..P1-9 + registerVerificationTools(s, client) // Phase G — P1-32, P1-34, P1-35 + // Phase G P1-33 (POST /api/v1/agents/{id}/discoveries) is + // intentionally NOT exposed via MCP — it is a machine-to-machine + // channel for agents to push filesystem-scan reports, not an + // operator-driven flow. See registerAgentTools for context. } // ── Helpers ───────────────────────────────────────────────────────── @@ -1289,3 +1303,544 @@ func registerHealthTools(s *gomcp.Server, c *Client) { return textResult(data) }) } + +// ── Approvals (Phase A — P1-28..P1-31) ────────────────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. Operators using AI +// assistants for cert-renewal in regulated environments need natural-language +// approve/reject. The service layer enforces ErrApproveBySameActor (the +// requesting actor cannot self-approve) and the handler extracts the +// decided_by actor from middleware.UserKey — so the MCP server's API key +// identity becomes the audit-trail actor automatically. Two-person integrity +// is preserved as long as the MCP server's key is distinct from the +// requesting actor's; the tool inputs deliberately omit any actor_id field +// to prevent client-side spoofing. + +func registerApprovalTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_approvals", + Description: "List issuance approval requests (GET /api/v1/approvals). Optional state/certificate_id/requested_by filters narrow the returned set. Use state=pending to surface the operator-action queue.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListApprovalsInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.State != "" { + q.Set("state", input.State) + } + if input.CertificateID != "" { + q.Set("certificate_id", input.CertificateID) + } + if input.RequestedBy != "" { + q.Set("requested_by", input.RequestedBy) + } + data, err := c.Get("/api/v1/approvals", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_approval", + Description: "Get a single approval request (GET /api/v1/approvals/{id}). Returns the full ApprovalRequest row — state, requesting actor, linked job, linked certificate.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/approvals/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_approve_request", + Description: "Approve an issuance request (POST /api/v1/approvals/{id}/approve). The decided_by actor is derived server-side from the authenticated API-key name; the two-person-integrity contract (ErrApproveBySameActor → HTTP 403) is enforced unconditionally. Optional `note` is captured in the audit row.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ApprovalDecisionInput) (*gomcp.CallToolResult, any, error) { + body := approvalDecisionPayload{Note: input.Note} + data, err := c.Post("/api/v1/approvals/"+input.ID+"/approve", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_reject_request", + Description: "Reject an issuance request (POST /api/v1/approvals/{id}/reject). Same RBAC contract as approve. Optional `note` is captured in the audit row.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ApprovalDecisionInput) (*gomcp.CallToolResult, any, error) { + body := approvalDecisionPayload{Note: input.Note} + data, err := c.Post("/api/v1/approvals/"+input.ID+"/reject", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// approvalDecisionPayload mirrors the handler-side approvalDecisionBody. +type approvalDecisionPayload struct { + Note string `json:"note,omitempty"` +} + +// ── Health Checks (Phase B — P1-20..P1-27) ────────────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. AI-assistant queries like +// "are any health checks failing?" / "ack the prod nginx incident" had no +// MCP path — operators had to drop to curl. Mirrors the existing target +// resource shape (CRUD + history + summary + acknowledge). + +func registerHealthCheckTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_health_checks", + Description: "List monitored TLS endpoint health checks (GET /api/v1/health-checks). Optional filters: status, certificate_id, network_scan_target_id, enabled.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListHealthChecksInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.Status != "" { + q.Set("status", input.Status) + } + if input.CertificateID != "" { + q.Set("certificate_id", input.CertificateID) + } + if input.NetworkScanTargetID != "" { + q.Set("network_scan_target_id", input.NetworkScanTargetID) + } + if input.Enabled != "" { + q.Set("enabled", input.Enabled) + } + data, err := c.Get("/api/v1/health-checks", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_health_check_summary", + Description: "Return aggregate counts of TLS health-check states (GET /api/v1/health-checks/summary). Useful for dashboard-style queries about endpoint posture.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/health-checks/summary", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_health_check", + Description: "Get a single TLS endpoint health check (GET /api/v1/health-checks/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/health-checks/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_health_check", + Description: "Create a TLS endpoint health check (POST /api/v1/health-checks). Required: endpoint (host:port). Server-side defaults: check_interval_seconds=300, degraded_threshold=2, down_threshold=5.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateHealthCheckInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/health-checks", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_health_check", + Description: "Update a TLS endpoint health check (PUT /api/v1/health-checks/{id}). The handler performs a merge update: non-zero numeric fields and non-empty strings overwrite, zero values preserve existing.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateHealthCheckInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/health-checks/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_health_check", + Description: "Delete a TLS endpoint health check (DELETE /api/v1/health-checks/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/health-checks/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_health_check_history", + Description: "Get probe history for a TLS endpoint health check (GET /api/v1/health-checks/{id}/history). Default limit 100; max 1000 (clamped server-side).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input HealthCheckHistoryInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if input.Limit > 0 { + q.Set("limit", strconv.Itoa(input.Limit)) + } + data, err := c.Get("/api/v1/health-checks/"+input.ID+"/history", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_acknowledge_health_check", + Description: "Acknowledge a TLS health-check incident (POST /api/v1/health-checks/{id}/acknowledge). Marks the check Acknowledged=true; the handler records the actor (defaults to 'unknown' if absent) for the audit trail.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AcknowledgeHealthCheckInput) (*gomcp.CallToolResult, any, error) { + body := struct { + Actor string `json:"actor,omitempty"` + }{Actor: input.Actor} + data, err := c.Post("/api/v1/health-checks/"+input.ID+"/acknowledge", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Renewal Policies (Phase C — P1-1..P1-5) ───────────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. The G-1 milestone shipped +// renewal_policies as a separate resource from the policy engine; the GUI +// has the page and the API has full CRUD, but MCP previously had zero +// coverage. Note: the MCP "policy" tools registered by registerPolicyTools +// already point at /api/v1/renewal-policies (legacy alias) — these new tools +// expose the renewal-policy domain directly with explicit naming. + +func registerRenewalPolicyTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_renewal_policies", + Description: "List renewal policies (GET /api/v1/renewal-policies). Each policy controls renewal-window, retry, and alert-threshold/severity matrix for managed certificates.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/renewal-policies", paginationQuery(input.Page, input.PerPage)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_renewal_policy", + Description: "Get a single renewal policy (GET /api/v1/renewal-policies/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/renewal-policies/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_renewal_policy", + Description: "Create a renewal policy (POST /api/v1/renewal-policies). Required: name. Reasonable defaults exist server-side for renewal_window_days, retries, and alert thresholds.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateRenewalPolicyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/renewal-policies", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_renewal_policy", + Description: "Update a renewal policy (PUT /api/v1/renewal-policies/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateRenewalPolicyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/renewal-policies/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_renewal_policy", + Description: "Delete a renewal policy (DELETE /api/v1/renewal-policies/{id}). Returns HTTP 409 if any managed_certificates still reference the policy (FK-RESTRICT via ErrRenewalPolicyInUse).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/renewal-policies/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Network-Scan Targets (Phase D — P1-14..P1-19) ─────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. AI-assistant queries like +// "what new certs did the scanner find on my fleet?" or "trigger a scan of +// the DC1 web tier" had no MCP path. trigger_network_scan returns the +// scan-row body so the AI can subsequently call list_discovered_certificates. + +func registerNetworkScanTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_network_scan_targets", + Description: "List network-scan targets (GET /api/v1/network-scan-targets). Each target is a (CIDR, ports) tuple the scheduler probes for TLS certificates.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/network-scan-targets", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_network_scan_target", + Description: "Get a single network-scan target (GET /api/v1/network-scan-targets/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/network-scan-targets/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_network_scan_target", + Description: "Create a network-scan target (POST /api/v1/network-scan-targets). Provide cidrs and ports for the scanner to probe (e.g. cidrs=['10.0.0.0/24'], ports=[443,8443]).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateNetworkScanTargetInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/network-scan-targets", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_update_network_scan_target", + Description: "Update a network-scan target (PUT /api/v1/network-scan-targets/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateNetworkScanTargetInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Put("/api/v1/network-scan-targets/"+input.ID, input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_delete_network_scan_target", + Description: "Delete a network-scan target (DELETE /api/v1/network-scan-targets/{id}).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Delete("/api/v1/network-scan-targets/" + input.ID) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_trigger_network_scan", + Description: "Trigger an immediate network scan of a target (POST /api/v1/network-scan-targets/{id}/scan). Returns the discovery-scan body when certs are found; the AI can then call certctl_list_discovered_certificates filtered by agent_id to view results.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/network-scan-targets/"+input.ID+"/scan", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Discovery read-side (Phase E — P1-10..P1-13) ──────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. The MCP server already +// has certctl_claim_discovered_certificate + certctl_dismiss_discovered_certificate +// (registered by registerHealthTools — historical placement; see I-2 closure). +// This phase adds the read-side so operators can ask "what's in the triage +// queue?" and "what did the scanner pick up overnight?". + +func registerDiscoveryReadTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_discovered_certificates", + Description: "List discovered certificates (GET /api/v1/discovered-certificates). These are TLS certs found by agent filesystem scans + network scans that are not yet under management. Filter by agent_id and/or status (Unmanaged, Managed, Dismissed).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListDiscoveredCertificatesInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.AgentID != "" { + q.Set("agent_id", input.AgentID) + } + if input.Status != "" { + q.Set("status", input.Status) + } + data, err := c.Get("/api/v1/discovered-certificates", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_discovered_certificate", + Description: "Get a single discovered certificate (GET /api/v1/discovered-certificates/{id}). Returns the dc-* row including subject DN, SANs, fingerprint, observed-at endpoint, and managed_certificate_id (set if claimed).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/discovered-certificates/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_discovery_scans", + Description: "List discovery-scan rows (GET /api/v1/discovery-scans). Each row records one agent filesystem scan or network scan run with timing + cert-count.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListDiscoveryScansInput) (*gomcp.CallToolResult, any, error) { + q := paginationQuery(input.Page, input.PerPage) + if input.AgentID != "" { + q.Set("agent_id", input.AgentID) + } + data, err := c.Get("/api/v1/discovery-scans", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_discovery_summary", + Description: "Return aggregate counts of discovered-certificate states (GET /api/v1/discovery-summary). Useful for triage-queue dashboard queries.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input EmptyInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/discovery-summary", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Intermediate CAs (Phase F — P1-6..P1-9) ───────────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. Rank 8 primitive +// (multi-level CA hierarchy management). The handlers are admin-gated via +// middleware.IsAdmin — non-admin callers see HTTP 403 regardless of MCP +// surface. We expose the full management API rather than carving it off +// because the operator ran the original Rank 8 deliverable to make this +// a first-class managed primitive; gating by API key role at the handler +// layer is the correct least-privilege boundary, not by transport. + +func registerIntermediateCATools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_intermediate_cas", + Description: "List the intermediate-CA hierarchy under a parent issuer (GET /api/v1/issuers/{id}/intermediates). Admin-gated route. Returns flat rows; callers render the tree from each row's parent_ca_id.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input ListIntermediateCAsInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/issuers/"+input.IssuerID+"/intermediates", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_create_intermediate_ca", + Description: "Create an intermediate CA under a parent issuer (POST /api/v1/issuers/{id}/intermediates). Admin-gated. Discriminator: when parent_ca_id is empty AND root_cert_pem + key_driver_id are present, registers an operator-supplied root CA; otherwise signs a child under the named parent.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateIntermediateCAInput) (*gomcp.CallToolResult, any, error) { + body := map[string]any{"name": input.Name} + if input.ParentCAID != "" { + body["parent_ca_id"] = input.ParentCAID + } + if input.RootCertPEM != "" { + body["root_cert_pem"] = input.RootCertPEM + } + if input.KeyDriverID != "" { + body["key_driver_id"] = input.KeyDriverID + } + if len(input.Subject) > 0 { + body["subject"] = input.Subject + } + if input.Algorithm != "" { + body["algorithm"] = input.Algorithm + } + if input.TTLDays > 0 { + body["ttl_days"] = input.TTLDays + } + if input.PathLenConstraint != nil { + body["path_len_constraint"] = *input.PathLenConstraint + } + if len(input.NameConstraints) > 0 { + body["name_constraints"] = input.NameConstraints + } + if input.OCSPResponderURL != "" { + body["ocsp_responder_url"] = input.OCSPResponderURL + } + if len(input.Metadata) > 0 { + body["metadata"] = input.Metadata + } + data, err := c.Post("/api/v1/issuers/"+input.IssuerID+"/intermediates", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_intermediate_ca", + Description: "Get a single intermediate CA (GET /api/v1/intermediates/{id}). Admin-gated.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/intermediates/"+input.ID, nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_retire_intermediate_ca", + Description: "Retire an intermediate CA (POST /api/v1/intermediates/{id}/retire). Admin-gated. Two-phase: first call (confirm=false) transitions active→retiring; second call (confirm=true) transitions retiring→retired. Refuses retired transition while active children remain (drain-first semantics).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input RetireIntermediateCAInput) (*gomcp.CallToolResult, any, error) { + body := struct { + Note string `json:"note,omitempty"` + Confirm bool `json:"confirm,omitempty"` + }{Note: input.Note, Confirm: input.Confirm} + data, err := c.Post("/api/v1/intermediates/"+input.ID+"/retire", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Verification (Phase G — P1-32, P1-34, P1-35) ──────────────────── +// +// 2026-05-05 CLI/API/MCP↔GUI parity audit closure. P1-33 (POST +// /api/v1/agents/{id}/discoveries) is intentionally excluded — it is a +// machine-to-machine push channel for agents reporting filesystem-scan +// results, not an operator-driven flow. The remaining three round out +// MCP coverage of certificate-deployment and job-verification surfaces. + +func registerVerificationTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_list_certificate_deployments", + Description: "List deployments for a managed certificate (GET /api/v1/certificates/{id}/deployments). Returns the per-target deployment status rows for the named cert.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/certificates/"+input.ID+"/deployments", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_verify_job", + Description: "Record post-deployment verification for a job (POST /api/v1/jobs/{id}/verify). Required: target_id, expected_fingerprint, actual_fingerprint. Typically called by agents after probing the live TLS endpoint, but exposed here for operator-driven manual verification.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input VerifyJobInput) (*gomcp.CallToolResult, any, error) { + body := map[string]any{ + "target_id": input.TargetID, + "expected_fingerprint": input.ExpectedFingerprint, + "actual_fingerprint": input.ActualFingerprint, + "verified": input.Verified, + } + if input.Error != "" { + body["error"] = input.Error + } + data, err := c.Post("/api/v1/jobs/"+input.ID+"/verify", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_get_job_verification", + Description: "Get the recorded verification status for a job (GET /api/v1/jobs/{id}/verification). Returns the latest VerificationResult row (expected/actual fingerprint, verified bool, timestamp).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/jobs/"+input.ID+"/verification", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} diff --git a/internal/mcp/tools_per_tool_test.go b/internal/mcp/tools_per_tool_test.go index f86ec97..5e3ae2d 100644 --- a/internal/mcp/tools_per_tool_test.go +++ b/internal/mcp/tools_per_tool_test.go @@ -367,6 +367,56 @@ var allHappyPathCases = []toolCase{ {"est_get_csrattrs", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/csrattrs"}, {"est_enroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simpleenroll"}, {"est_reenroll", map[string]any{"profile": "corp", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nXXX\n-----END CERTIFICATE REQUEST-----"}, http.MethodPost, "/.well-known/est/corp/simplereenroll"}, + + // 2026-05-05 CLI/API/MCP↔GUI parity audit closure — 34 new tools across 7 phases. + + // Phase A — Approvals (P1-28..P1-31) + {"certctl_list_approvals", map[string]any{}, http.MethodGet, "/api/v1/approvals"}, + {"certctl_get_approval", map[string]any{"id": "ar-1"}, http.MethodGet, "/api/v1/approvals/ar-1"}, + {"certctl_approve_request", map[string]any{"id": "ar-1"}, http.MethodPost, "/api/v1/approvals/ar-1/approve"}, + {"certctl_reject_request", map[string]any{"id": "ar-1"}, http.MethodPost, "/api/v1/approvals/ar-1/reject"}, + + // Phase B — Health Checks (P1-20..P1-27) + {"certctl_list_health_checks", map[string]any{}, http.MethodGet, "/api/v1/health-checks"}, + {"certctl_health_check_summary", map[string]any{}, http.MethodGet, "/api/v1/health-checks/summary"}, + {"certctl_get_health_check", map[string]any{"id": "hc-1"}, http.MethodGet, "/api/v1/health-checks/hc-1"}, + {"certctl_create_health_check", map[string]any{"endpoint": "api.example.com:443"}, http.MethodPost, "/api/v1/health-checks"}, + {"certctl_update_health_check", map[string]any{"id": "hc-1", "endpoint": "api.example.com:443"}, http.MethodPut, "/api/v1/health-checks/hc-1"}, + {"certctl_delete_health_check", map[string]any{"id": "hc-1"}, http.MethodDelete, "/api/v1/health-checks/hc-1"}, + {"certctl_health_check_history", map[string]any{"id": "hc-1"}, http.MethodGet, "/api/v1/health-checks/hc-1/history"}, + {"certctl_acknowledge_health_check", map[string]any{"id": "hc-1"}, http.MethodPost, "/api/v1/health-checks/hc-1/acknowledge"}, + + // Phase C — Renewal Policies (P1-1..P1-5) + {"certctl_list_renewal_policies", map[string]any{}, http.MethodGet, "/api/v1/renewal-policies"}, + {"certctl_get_renewal_policy", map[string]any{"id": "rp-1"}, http.MethodGet, "/api/v1/renewal-policies/rp-1"}, + {"certctl_create_renewal_policy", map[string]any{"name": "weekly-rotate"}, http.MethodPost, "/api/v1/renewal-policies"}, + {"certctl_update_renewal_policy", map[string]any{"id": "rp-1", "name": "renamed"}, http.MethodPut, "/api/v1/renewal-policies/rp-1"}, + {"certctl_delete_renewal_policy", map[string]any{"id": "rp-1"}, http.MethodDelete, "/api/v1/renewal-policies/rp-1"}, + + // Phase D — Network Scan Targets (P1-14..P1-19) + {"certctl_list_network_scan_targets", map[string]any{}, http.MethodGet, "/api/v1/network-scan-targets"}, + {"certctl_get_network_scan_target", map[string]any{"id": "ns-1"}, http.MethodGet, "/api/v1/network-scan-targets/ns-1"}, + {"certctl_create_network_scan_target", map[string]any{"name": "dc1-web", "cidrs": []string{"10.0.0.0/24"}, "ports": []int{443}}, http.MethodPost, "/api/v1/network-scan-targets"}, + {"certctl_update_network_scan_target", map[string]any{"id": "ns-1", "name": "renamed"}, http.MethodPut, "/api/v1/network-scan-targets/ns-1"}, + {"certctl_delete_network_scan_target", map[string]any{"id": "ns-1"}, http.MethodDelete, "/api/v1/network-scan-targets/ns-1"}, + {"certctl_trigger_network_scan", map[string]any{"id": "ns-1"}, http.MethodPost, "/api/v1/network-scan-targets/ns-1/scan"}, + + // Phase E — Discovery read-side (P1-10..P1-13) + {"certctl_list_discovered_certificates", map[string]any{}, http.MethodGet, "/api/v1/discovered-certificates"}, + {"certctl_get_discovered_certificate", map[string]any{"id": "dc-1"}, http.MethodGet, "/api/v1/discovered-certificates/dc-1"}, + {"certctl_list_discovery_scans", map[string]any{}, http.MethodGet, "/api/v1/discovery-scans"}, + {"certctl_discovery_summary", map[string]any{}, http.MethodGet, "/api/v1/discovery-summary"}, + + // Phase F — Intermediate CAs (P1-6..P1-9) + {"certctl_list_intermediate_cas", map[string]any{"issuer_id": "iss-1"}, http.MethodGet, "/api/v1/issuers/iss-1/intermediates"}, + {"certctl_create_intermediate_ca", map[string]any{"issuer_id": "iss-1", "name": "subca-1", "parent_ca_id": "ica-root"}, http.MethodPost, "/api/v1/issuers/iss-1/intermediates"}, + {"certctl_get_intermediate_ca", map[string]any{"id": "ica-1"}, http.MethodGet, "/api/v1/intermediates/ica-1"}, + {"certctl_retire_intermediate_ca", map[string]any{"id": "ica-1"}, http.MethodPost, "/api/v1/intermediates/ica-1/retire"}, + + // Phase G — Verification + deployments (P1-32, P1-34, P1-35) + {"certctl_list_certificate_deployments", map[string]any{"id": "mc-1"}, http.MethodGet, "/api/v1/certificates/mc-1/deployments"}, + {"certctl_verify_job", map[string]any{"id": "j-1", "target_id": "t-1", "expected_fingerprint": "AA:BB", "actual_fingerprint": "AA:BB", "verified": true}, http.MethodPost, "/api/v1/jobs/j-1/verify"}, + {"certctl_get_job_verification", map[string]any{"id": "j-1"}, http.MethodGet, "/api/v1/jobs/j-1/verification"}, } // TestMCP_AllTools_HappyPath dispatches every tool against the mock API in diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 21b8f3c..426c344 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -346,6 +346,209 @@ type DismissDiscoveredCertificateInput struct { ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"` } +// ── Approvals (P1-28..P1-31, MCP coverage expansion) ─────────────── + +// ListApprovalsInput is the MCP tool input for listing approval requests +// (GET /api/v1/approvals). State filter accepts pending|approved|rejected|expired. +// CertificateID and RequestedBy are optional pre-filters that narrow the +// returned set; pagination matches the standard envelope. +type ListApprovalsInput struct { + ListParams + State string `json:"state,omitempty" jsonschema:"Filter by state: pending, approved, rejected, expired"` + CertificateID string `json:"certificate_id,omitempty" jsonschema:"Filter by certificate ID"` + RequestedBy string `json:"requested_by,omitempty" jsonschema:"Filter by requesting actor (API-key name)"` +} + +// ApprovalDecisionInput is the MCP tool input for approve / reject endpoints. +// The decided_by actor is derived server-side from the authenticated API-key +// name (middleware.UserKey) — NOT from this body. The two-person-integrity +// contract (ErrApproveBySameActor) is enforced regardless of who pushes the +// decision through MCP, so as long as the MCP server's API key identity is +// distinct from the requesting actor, the contract holds. +type ApprovalDecisionInput struct { + ID string `json:"id" jsonschema:"Approval request ID"` + Note string `json:"note,omitempty" jsonschema:"Optional decision note (e.g. 'approved per ticket SECOPS-12345')"` +} + +// ── Health Checks (P1-20..P1-27, MCP coverage expansion) ─────────── + +// ListHealthChecksInput pages + filters /api/v1/health-checks. +// Status accepts healthy|degraded|down|cert_mismatch|unknown. +type ListHealthChecksInput struct { + ListParams + Status string `json:"status,omitempty" jsonschema:"Filter by health status: healthy, degraded, down, cert_mismatch, unknown"` + CertificateID string `json:"certificate_id,omitempty" jsonschema:"Filter by linked certificate ID"` + NetworkScanTargetID string `json:"network_scan_target_id,omitempty" jsonschema:"Filter by linked network-scan target ID"` + Enabled string `json:"enabled,omitempty" jsonschema:"Filter by enabled state: 'true' or 'false' (omit for all)"` +} + +// CreateHealthCheckInput maps to the POST /api/v1/health-checks body +// (domain.EndpointHealthCheck JSON shape). Only `endpoint` is enforced +// at the handler layer; defaults are applied server-side for thresholds. +type CreateHealthCheckInput struct { + Endpoint string `json:"endpoint" jsonschema:"TLS endpoint to monitor (host:port)"` + CertificateID string `json:"certificate_id,omitempty" jsonschema:"Optional managed-certificate ID to bind"` + NetworkScanTargetID string `json:"network_scan_target_id,omitempty" jsonschema:"Optional network-scan target ID to bind"` + ExpectedFingerprint string `json:"expected_fingerprint,omitempty" jsonschema:"Expected SHA-256 fingerprint for cert-pin alerts"` + CheckIntervalSecs int `json:"check_interval_seconds,omitempty" jsonschema:"Probe interval in seconds (default 300)"` + DegradedThreshold int `json:"degraded_threshold,omitempty" jsonschema:"Consecutive failures before degraded (default 2)"` + DownThreshold int `json:"down_threshold,omitempty" jsonschema:"Consecutive failures before down (default 5)"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the check is active"` +} + +// UpdateHealthCheckInput maps to PUT /api/v1/health-checks/{id}. The handler +// performs a merge update: non-zero fields overwrite, zero fields preserve. +type UpdateHealthCheckInput struct { + ID string `json:"id" jsonschema:"Health-check ID to update"` + Endpoint string `json:"endpoint,omitempty" jsonschema:"TLS endpoint to monitor"` + ExpectedFingerprint string `json:"expected_fingerprint,omitempty" jsonschema:"Expected SHA-256 fingerprint"` + CheckIntervalSecs int `json:"check_interval_seconds,omitempty" jsonschema:"Probe interval in seconds"` + DegradedThreshold int `json:"degraded_threshold,omitempty" jsonschema:"Consecutive failures before degraded"` + DownThreshold int `json:"down_threshold,omitempty" jsonschema:"Consecutive failures before down"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the check is active"` +} + +// HealthCheckHistoryInput maps to GET /api/v1/health-checks/{id}/history. +// Limit is clamped server-side to 1000. +type HealthCheckHistoryInput struct { + ID string `json:"id" jsonschema:"Health-check ID"` + Limit int `json:"limit,omitempty" jsonschema:"Max history entries to return (default 100, max 1000)"` +} + +// AcknowledgeHealthCheckInput maps to POST /api/v1/health-checks/{id}/acknowledge. +// Actor is sent in the body for the audit trail; the handler accepts an empty +// actor and falls back to "unknown". +type AcknowledgeHealthCheckInput struct { + ID string `json:"id" jsonschema:"Health-check ID to acknowledge"` + Actor string `json:"actor,omitempty" jsonschema:"Actor recording the acknowledgement (defaults to 'unknown')"` +} + +// ── Renewal Policies (P1-1..P1-5, MCP coverage expansion) ────────── + +// CreateRenewalPolicyInput maps to POST /api/v1/renewal-policies. Required: +// name. The remaining fields drive the renewal-window, retry, and alert +// behavior; reasonable defaults exist server-side. +type CreateRenewalPolicyInput struct { + ID string `json:"id,omitempty" jsonschema:"Policy ID (auto-generated if empty)"` + Name string `json:"name" jsonschema:"Policy display name (required)"` + RenewalWindowDays int `json:"renewal_window_days,omitempty" jsonschema:"Days before expiry to start renewal (e.g. 30)"` + AutoRenew bool `json:"auto_renew,omitempty" jsonschema:"Whether the scheduler renews automatically"` + MaxRetries int `json:"max_retries,omitempty" jsonschema:"Maximum renewal retry attempts on failure"` + RetryInterval int `json:"retry_interval_seconds,omitempty" jsonschema:"Seconds between renewal retry attempts"` + AlertThresholdsDays []int `json:"alert_thresholds_days,omitempty" jsonschema:"Days-before-expiry at which to fire alerts (e.g. [30,14,7,0])"` + CertificateProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Optional certificate-profile binding"` + AlertChannels map[string]any `json:"alert_channels,omitempty" jsonschema:"Per-severity channel matrix (informational/warning/critical → channel list)"` + AlertSeverityMap map[string]any `json:"alert_severity_map,omitempty" jsonschema:"Threshold-day → severity tier map (off-map defaults to informational)"` +} + +// UpdateRenewalPolicyInput maps to PUT /api/v1/renewal-policies/{id}. +// Replace-style update — the handler decodes into a fresh domain.RenewalPolicy +// and forwards it to the service layer. +type UpdateRenewalPolicyInput struct { + ID string `json:"id" jsonschema:"Policy ID to update"` + Name string `json:"name,omitempty" jsonschema:"Policy display name"` + RenewalWindowDays int `json:"renewal_window_days,omitempty" jsonschema:"Days before expiry to start renewal"` + AutoRenew bool `json:"auto_renew,omitempty" jsonschema:"Whether the scheduler renews automatically"` + MaxRetries int `json:"max_retries,omitempty" jsonschema:"Maximum renewal retry attempts on failure"` + RetryInterval int `json:"retry_interval_seconds,omitempty" jsonschema:"Seconds between renewal retry attempts"` + AlertThresholdsDays []int `json:"alert_thresholds_days,omitempty" jsonschema:"Days-before-expiry at which to fire alerts"` + CertificateProfileID string `json:"certificate_profile_id,omitempty" jsonschema:"Optional certificate-profile binding"` + AlertChannels map[string]any `json:"alert_channels,omitempty" jsonschema:"Per-severity channel matrix"` + AlertSeverityMap map[string]any `json:"alert_severity_map,omitempty" jsonschema:"Threshold-day → severity tier map"` +} + +// ── Network-Scan Targets (P1-14..P1-19, MCP coverage expansion) ──── + +// CreateNetworkScanTargetInput maps to POST /api/v1/network-scan-targets. +// CIDRs / Ports are required for a scan to do anything; the handler +// itself only enforces JSON parse, so the operator gets a downstream +// error for empty CIDR / Port slices. +type CreateNetworkScanTargetInput struct { + ID string `json:"id,omitempty" jsonschema:"Target ID (auto-generated if empty)"` + Name string `json:"name" jsonschema:"Target display name"` + CIDRs []string `json:"cidrs,omitempty" jsonschema:"CIDR ranges to scan (e.g. ['10.0.0.0/24'])"` + Ports []int `json:"ports,omitempty" jsonschema:"TCP ports to probe (e.g. [443, 8443])"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is active"` + ScanIntervalHours int `json:"scan_interval_hours,omitempty" jsonschema:"Scan interval in hours (default 24)"` + TimeoutMs int `json:"timeout_ms,omitempty" jsonschema:"Per-endpoint TLS handshake timeout in milliseconds"` +} + +// UpdateNetworkScanTargetInput maps to PUT /api/v1/network-scan-targets/{id}. +type UpdateNetworkScanTargetInput struct { + ID string `json:"id" jsonschema:"Target ID to update"` + Name string `json:"name,omitempty" jsonschema:"Target display name"` + CIDRs []string `json:"cidrs,omitempty" jsonschema:"CIDR ranges to scan"` + Ports []int `json:"ports,omitempty" jsonschema:"TCP ports to probe"` + Enabled bool `json:"enabled,omitempty" jsonschema:"Whether the target is active"` + ScanIntervalHours int `json:"scan_interval_hours,omitempty" jsonschema:"Scan interval in hours"` + TimeoutMs int `json:"timeout_ms,omitempty" jsonschema:"Per-endpoint TLS handshake timeout in milliseconds"` +} + +// ── Discovery (P1-10..P1-13, MCP coverage expansion) ─────────────── + +// ListDiscoveredCertificatesInput maps to GET /api/v1/discovered-certificates. +// Status filter accepts the enum from domain.DiscoveryStatus +// (Unmanaged|Managed|Dismissed|...). +type ListDiscoveredCertificatesInput struct { + ListParams + AgentID string `json:"agent_id,omitempty" jsonschema:"Filter by reporting agent ID"` + Status string `json:"status,omitempty" jsonschema:"Filter by discovery status (Unmanaged, Managed, Dismissed)"` +} + +// ListDiscoveryScansInput maps to GET /api/v1/discovery-scans. +type ListDiscoveryScansInput struct { + ListParams + AgentID string `json:"agent_id,omitempty" jsonschema:"Filter by reporting agent ID"` +} + +// ── Intermediate CAs (P1-6..P1-9, MCP coverage expansion) ────────── + +// CreateIntermediateCAInput maps to POST /api/v1/issuers/{id}/intermediates. +// Admin-gated route. Discriminator on body shape: when ParentCAID is empty +// AND RootCertPEM + KeyDriverID are set, the call registers an operator- +// supplied root; otherwise it signs a child under the named parent. +type CreateIntermediateCAInput struct { + IssuerID string `json:"issuer_id" jsonschema:"Parent issuer ID (path param)"` + Name string `json:"name" jsonschema:"Intermediate CA display name"` + ParentCAID string `json:"parent_ca_id,omitempty" jsonschema:"Parent CA ID (empty for root registration)"` + RootCertPEM string `json:"root_cert_pem,omitempty" jsonschema:"PEM-encoded root cert (root path only)"` + KeyDriverID string `json:"key_driver_id,omitempty" jsonschema:"Signer-driver ID (root path only)"` + Subject map[string]any `json:"subject,omitempty" jsonschema:"X.509 subject (common_name, organization[], country[], ...)"` + Algorithm string `json:"algorithm,omitempty" jsonschema:"Key algorithm: ECDSA-P256, RSA-3072, ..."` + TTLDays int `json:"ttl_days,omitempty" jsonschema:"Validity period in days"` + PathLenConstraint *int `json:"path_len_constraint,omitempty" jsonschema:"X.509 BasicConstraints pathLen"` + NameConstraints []map[string]any `json:"name_constraints,omitempty" jsonschema:"X.509 NameConstraints (RFC 5280 §4.2.1.10)"` + OCSPResponderURL string `json:"ocsp_responder_url,omitempty" jsonschema:"OCSP responder URL embedded as AIA extension"` + Metadata map[string]string `json:"metadata,omitempty" jsonschema:"Free-form metadata"` +} + +// ListIntermediateCAsInput maps to GET /api/v1/issuers/{id}/intermediates. +type ListIntermediateCAsInput struct { + IssuerID string `json:"issuer_id" jsonschema:"Parent issuer ID"` +} + +// RetireIntermediateCAInput maps to POST /api/v1/intermediates/{id}/retire. +// Two-phase: first call (Confirm=false) transitions active→retiring; second +// call (Confirm=true) transitions retiring→retired. +type RetireIntermediateCAInput struct { + ID string `json:"id" jsonschema:"Intermediate CA ID to retire"` + Note string `json:"note,omitempty" jsonschema:"Audit-trail note"` + Confirm bool `json:"confirm,omitempty" jsonschema:"Set true on the second call to transition to fully retired"` +} + +// ── Misc (P1-32..P1-35, MCP coverage expansion) ──────────────────── + +// VerifyJobInput maps to POST /api/v1/jobs/{id}/verify. All four body fields +// after target_id are required at the handler layer (manual emptiness checks). +type VerifyJobInput struct { + ID string `json:"id" jsonschema:"Job ID to record verification against"` + TargetID string `json:"target_id" jsonschema:"Deployment target ID (required)"` + ExpectedFingerprint string `json:"expected_fingerprint" jsonschema:"Expected SHA-256 fingerprint (required)"` + ActualFingerprint string `json:"actual_fingerprint" jsonschema:"Probed SHA-256 fingerprint (required)"` + Verified bool `json:"verified" jsonschema:"Whether the verification probe matched"` + Error string `json:"error,omitempty" jsonschema:"Optional probe error message"` +} + // ── Empty ─────────────────────────────────────────────────────────── type EmptyInput struct{}