mcp(coverage): add 34 tools across 7 domains to close 2026-05-05 parity audit P1 findings

Closes findings P1-1..P1-35 from the 2026-05-05 CLI/API/MCP↔GUI parity
audit (cowork/cli-gui-parity-audit-2026-05-05/RESULTS.md). Before this
bundle, 35 operator-facing API endpoints had GUI surfaces but no MCP
counterpart — operators using AI assistants for cert lifecycle work in
regulated environments had to drop to curl for approve/reject, health-check
acknowledgement, renewal-policy CRUD, network-scan triggering, discovery
triage, intermediate-CA management, and job verification.

Tool count: 87→121 in tools.go (+34), 6 unchanged in tools_est.go.
Re-derive via grep -cE 'gomcp\\.AddTool\\(' internal/mcp/tools.go
internal/mcp/tools_est.go.

The 7 phases (matching the bundle prompt at
cowork/mcp-coverage-expansion-prompt.md):

  Phase A — Approvals (P1-28..P1-31, 4 tools)
    list_approvals, get_approval, approve_request, reject_request.
    Two-person-integrity contract (ErrApproveBySameActor → HTTP 403)
    is preserved automatically: the decided_by actor is derived
    server-side from middleware.UserKey, NOT from request body, so
    the MCP server's authenticated API-key identity becomes the
    audit-trail actor. The MCP input schema deliberately omits any
    actor_id field to prevent client-side spoofing.

  Phase B — Health Checks (P1-20..P1-27, 8 tools)
    list, summary, get, create, update, delete, history, acknowledge.
    Mirrors the existing target-resource shape; acknowledge takes
    optional 'actor' string captured in the audit row (handler defaults
    to 'unknown' if absent).

  Phase C — Renewal Policies (P1-1..P1-5, 5 tools)
    Standard CRUD against /api/v1/renewal-policies. Distinct from the
    legacy 'policy' tools that point at the same path — these expose
    the renewal-policy domain explicitly with full alert_channels +
    alert_severity_map field shape.

  Phase D — Network Scan Targets (P1-14..P1-19, 6 tools)
    CRUD + trigger_scan. trigger_network_scan returns the discovery-
    scan body so the AI can chain into list_discovered_certificates
    filtered by agent_id.

  Phase E — Discovery read-side (P1-10..P1-13, 4 tools)
    list_discovered_certificates, get_discovered_certificate,
    list_discovery_scans, discovery_summary. Complements the
    pre-existing claim/dismiss tools (registered alongside Health
    historically per the I-2 closure).

  Phase F — Intermediate CAs (P1-6..P1-9, 4 tools)
    list, create (root + child via discriminator on body shape), get,
    retire. The handler is admin-gated via middleware.IsAdmin; the
    least-privilege boundary is enforced at the API layer (HTTP 403
    for non-admin Bearer callers) — not by transport carve-out.

  Phase G — Verification + deployments (P1-32, P1-34, P1-35, 3 tools)
    list_certificate_deployments, verify_job, get_job_verification.
    P1-33 (POST /api/v1/agents/{id}/discoveries) is intentionally
    excluded — machine-to-machine push channel for agents reporting
    filesystem-scan results, not an operator-driven flow. Documented
    inline in the RegisterTools dispatch.

Implementation:
  - 14 new input types in internal/mcp/types.go with jsonschema struct
    tags driving LLM tool discovery.
  - 7 register* functions in internal/mcp/tools.go each handling one
    phase, wired into RegisterTools dispatch in declaration order.
  - 34 new entries in tools_per_tool_test.go::allHappyPathCases —
    the existing in-process MCP harness (TestMCP_AllTools_HappyPath +
    TestMCP_AllTools_ErrorPath + TestMCP_RegisterTools_DispatchableToolCount)
    auto-extends coverage to cover every new tool: happy-path round-
    trip with fence-shape assertion, 5xx error-path with MCP_ERROR fence
    propagation, and 'every registered tool is dispatchable' guard.
  - docs/reference/mcp.md 'Available Tools' table expanded from 16 to
    22 resource domains with current per-domain tool counts.

Acceptance gate (verified):
  - go build ./cmd/server/... ./cmd/agent/... ./cmd/cli/... ./cmd/mcp-server/...
    clean across all four production binaries.
  - go vet ./... clean.
  - go test -short -count=1 ./internal/mcp/... pass (TestMCP_AllTools_*
    expanded to 127 tool round-trips).
  - go test -short -count=1 ./... pass repo-wide.
  - bash scripts/ci-guards/openapi-handler-parity.sh clean (router 178,
    OpenAPI 144, exceptions 36 — unchanged; we add MCP wrappers, not
    routes).
  - gofmt -l clean across the four touched files.
This commit is contained in:
shankar0123
2026-05-05 19:29:57 +00:00
parent acf4ccd6d8
commit 9d183478de
4 changed files with 824 additions and 7 deletions
+16 -7
View File
@@ -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:
+555
View File
@@ -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)
})
}
+50
View File
@@ -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
+203
View File
@@ -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{}