mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
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:
+16
-7
@@ -66,26 +66,35 @@ After saving, restart your MCP client. You should see "certctl" appear in its to
|
|||||||
|
|
||||||
## Available Tools
|
## 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 |
|
| Domain | Tools | Examples |
|
||||||
|--------|-------|---------|
|
|--------|-------|---------|
|
||||||
| Certificates | 9 | List, get, create, update, archive, versions, renew, deploy, revoke |
|
| Certificates | 14 | List, get, create, update, archive, versions, renew, deploy, revoke, bulk-revoke / -renew / -reassign, claim/dismiss discovered |
|
||||||
| CRL & OCSP | 3 | Get JSON CRL, get DER CRL by issuer, check OCSP status |
|
| CRL & OCSP | 2 | Get DER CRL by issuer, check OCSP status |
|
||||||
| Issuers | 6 | List, get, create, update, delete, test connection |
|
| Issuers | 6 | List, get, create, update, delete, test connection |
|
||||||
| Targets | 5 | List, get, create, update, delete |
|
| Targets | 5 | List, get, create, update, delete |
|
||||||
| Agents | 8 | List, get, register, heartbeat, CSR submit, certificate pickup, get work, report job status |
|
| Agents | 9 | List, list retired, get, register, retire, heartbeat, get work, submit CSR, report job status |
|
||||||
| Jobs | 5 | List, get, cancel, approve, reject |
|
| Jobs | 5 | List, get, approve, reject, cancel |
|
||||||
| Policies | 6 | List, get, create, update, delete, list violations |
|
| Policies | 6 | List, get, create, update, delete, list violations |
|
||||||
| Profiles | 5 | List, get, create, update, delete |
|
| Profiles | 5 | List, get, create, update, delete |
|
||||||
| Teams | 5 | List, get, create, update, delete |
|
| Teams | 5 | List, get, create, update, delete |
|
||||||
| Owners | 5 | List, get, create, update, delete |
|
| Owners | 5 | List, get, create, update, delete |
|
||||||
| Agent Groups | 6 | List, get, create, update, delete, list members |
|
| Agent Groups | 6 | List, get, create, update, delete, list members |
|
||||||
| Audit | 2 | List events (with filters), get event by ID |
|
| 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 |
|
| Stats | 5 | Summary, certs by status, expiration timeline, job trends, issuance rate |
|
||||||
| Metrics | 1 | System metrics (gauges, counters, uptime) |
|
| Metrics | 1 | System metrics (gauges, counters, uptime) |
|
||||||
|
| Digest | 2 | Preview digest, send digest |
|
||||||
| Health | 4 | Health check, readiness probe, auth info, auth check |
|
| 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.
|
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
|
AI <-->|"stdio"| MCP
|
||||||
MCP -->|"HTTP + Bearer token"| SERVER
|
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:
|
The MCP server is intentionally thin:
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ func RegisterTools(s *gomcp.Server, client *Client) {
|
|||||||
registerDigestTools(s, client)
|
registerDigestTools(s, client)
|
||||||
registerHealthTools(s, client)
|
registerHealthTools(s, client)
|
||||||
registerESTTools(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 ─────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -1289,3 +1303,544 @@ func registerHealthTools(s *gomcp.Server, c *Client) {
|
|||||||
return textResult(data)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -367,6 +367,56 @@ var allHappyPathCases = []toolCase{
|
|||||||
{"est_get_csrattrs", map[string]any{"profile": "corp"}, http.MethodGet, "/.well-known/est/corp/csrattrs"},
|
{"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_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"},
|
{"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
|
// TestMCP_AllTools_HappyPath dispatches every tool against the mock API in
|
||||||
|
|||||||
@@ -346,6 +346,209 @@ type DismissDiscoveredCertificateInput struct {
|
|||||||
ID string `json:"id" jsonschema:"Discovered certificate ID (dc-*)"`
|
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 ───────────────────────────────────────────────────────────
|
// ── Empty ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type EmptyInput struct{}
|
type EmptyInput struct{}
|
||||||
|
|||||||
Reference in New Issue
Block a user