mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
fbe053aa0c
Phase 9 ARCH-M2 closure Sprint 10. Splits internal/mcp/tools.go
(was 1867 LOC, the second-largest backend hotspot after the
service/acme.go cuts in Sprints 9 + 9b) via the Option B sibling-
file pattern — new files stay in `package mcp` so every external
caller of `mcp.RegisterTools(...)` resolves the same way. Pure
mechanical relocation; no signature, no behavior, no import-graph
change.
Why this is naturally suited to Option B
========================================
The mcp package already follows the sibling-file convention:
tools_audit_fix.go (registerAuditFixTools), tools_auth.go
(registerAuthTools), tools_auth_bundle2.go (registerAuthBundle2Tools),
and tools_est.go (registerESTTools) each carry a single
register-function each, all in the same `mcp` package. Sprint 10
extends that pattern to the 22 register-functions still inside
tools.go.
The structure of tools.go is unusually clean for a refactor: every
domain has its own `// ── DomainName ──` banner above its
register-function, and every register-function ends with a `}` +
blank line before the next domain's banner. The RegisterTools
dispatcher stayed in tools.go and still invokes each
registerXxxTools(...) in the same order — calls cross a file
boundary but stay in `package mcp`, so same-package resolution
makes them zero-cost.
What moved
==========
New `internal/mcp/tools_certificates.go` (404 LOC) — certificate-
lifecycle domain:
- registerCertificateTools (cert CRUD + revocation)
- registerCRLOCSPTools
- registerRenewalPolicyTools (Phase C P1-1..P1-5)
- registerVerificationTools (Phase G P1-32/P1-34/P1-35)
New `internal/mcp/tools_agents.go` (266 LOC) — agent-management
domain:
- registerAgentTools (per-agent CRUD + lifecycle)
- registerAgentGroupTools
New `internal/mcp/tools_resources.go` (565 LOC) — resource-
management / configuration surface:
- registerIssuerTools, registerTargetTools
- registerPolicyTools, registerProfileTools
- registerTeamTools, registerOwnerTools
- registerNotificationTools
- registerIntermediateCATools (Phase F P1-6..P1-9)
New `internal/mcp/tools_jobs.go` (170 LOC) — workflow domain:
- registerJobTools
- registerApprovalTools + approvalDecisionPayload struct
(Phase A P1-28..P1-31)
New `internal/mcp/tools_discovery.go` (169 LOC) — discovery domain:
- registerNetworkScanTools (Phase D P1-14..P1-19)
- registerDiscoveryReadTools (Phase E P1-10..P1-13)
New `internal/mcp/tools_admin.go` (369 LOC) — observability / admin
domain:
- registerAuditTools, registerStatsTools, registerDigestTools,
registerMetricsTools, registerHealthTools
- registerHealthCheckTools (Phase B P1-20..P1-27)
What stays in tools.go (109 LOC, down from 1867)
================================================
- The RegisterTools dispatcher (still owns the canonical
registration order; calls cross-file but stay in-package).
- The three Bundle-3 wrappers + helper that every register
function consumes: textResult (the json.RawMessage success-path
fence), errorResult (the failure-path fence), paginationQuery
(the URL helper).
The unused `context` import is dropped from tools.go as a clean
side effect — none of the four surviving functions take a
context.Context. Per-import audit on every new file:
- tools_certificates.go: context, fmt, gomcp
- tools_agents.go: context, fmt, net/url, gomcp
- tools_resources.go: context, gomcp
- tools_jobs.go: context, gomcp
- tools_discovery.go: context, gomcp
- tools_admin.go: context, net/url, strconv, gomcp
None of the moved code touched encoding/json directly — that import
stays inside tools.go for textResult's json.RawMessage param.
Bundle-3 fence guardrail update
===============================
The existing TestFenceGuardrail_NoBareCallToolResult guardrail in
fence_guardrail_test.go fails any file that constructs
gomcp.CallToolResult{...} literals outside the tools.go allowlist.
registerCRLOCSPTools — which moved to tools_certificates.go — has
two pre-existing literal CallToolResult constructions: each returns
a server-built status string of the form "DER CRL retrieved (%d
bytes, content-type: %s)" or "OCSP response retrieved (...)". The
byte count is `len(raw)` (server-controlled) and the content-type
comes from the HTTP header on the upstream PKI endpoint
(server-controlled in self-hosted deployments). Both predate
Bundle-3 fencing.
Two options to keep CI green:
(a) Route through textResult — but that changes behavior (adds
the UNTRUSTED MCP_RESPONSE fence around the response), which
breaks the "mechanical relocation, no behavior change" rule
Sprint 10 commits to.
(b) Add tools_certificates.go to the allowlist with a comment
explaining the carve-out is pre-existing and Sprint 10
preserves byte-exact behavior.
This commit takes option (b). The allowlist comment in
fence_guardrail_test.go documents the carve-out, points at the
specific tools (CRL + OCSP binary-pass-through with server-built
status descriptions), and flags tightening these two sites through
textResult as a follow-up concern (open question: does the format
break MCP consumers that parse the description text).
Net effect
==========
tools.go: 1867 → 109 LOC (-1758 = -94.2%). Six new sibling files at
1943 LOC total (109 LOC of header + Phase 9 doc-comment overhead
per file = ~185 LOC of added documentation; the rest is moved
code). The biggest pre-Sprint-10 hotspot in the mcp package is now
smaller than tools_test.go (435 LOC).
Cumulative Phase 9 progress
===========================
config.go 3403 → 1342 (-60.6%, Sprints 1-7)
cmd/server/main.go 2966 → 2260 (-23.8%, Sprints 8 + 8b)
service/acme.go 1965 → 1162 (-40.9%, Sprints 9 + 9b)
mcp/tools.go 1867 → 109 (-94.2%, Sprint 10)
TOTAL across 4 files: 10,201 → 4,873 LOC = -5,328 (-52.2%)
Behavior preservation contract
==============================
1. gofmt -l clean across all 8 affected files.
2. go vet ./internal/mcp/... — no findings.
3. staticcheck ./internal/mcp/... ./cmd/mcp-server/... — no findings.
4. go test -short -count=1 ./internal/mcp/... — green (includes the
TestFenceGuardrail_NoBareCallToolResult guardrail post-allowlist-
update, the tools_per_tool_test.go suite that exercises every
moved register function, and the injection_regression_test.go
suite that pins Bundle-3 fencing behavior on the wrapper layer).
5. Broader-importer build green: go build ./... .
6. Broader-importer tests green: go test -short ./cmd/mcp-server/...
./internal/api/handler/... ./cmd/server/... .
Same-package resolution means the RegisterTools dispatcher's
13-line call list in tools.go reaches each registerXxxTools across
six new sibling files via compile-time-resolved package-level
names; the public mcp.RegisterTools entry point + its (s, client)
signature is unchanged.
What remains for Phase 9
========================
Two sibling-file splits queued:
- Sprint 11: internal/api/handler/auth_session_oidc.go (1577 LOC)
split per handler verb (login / callback / refresh / logout /
backchannel).
- Sprint 12: cmd/agent/main.go (1489 LOC) mirroring the cmd/server
pattern from Sprints 8 + 8b.
Refs: ARCH-M2 (god-files), Phase 9 audit. Sprint 10 closes the MCP
hotspot from the audit's top-6 list.
565 lines
22 KiB
Go
565 lines
22 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
|
|
gomcp "github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Phase 9 ARCH-M2 closure Sprint 10 (2026-05-14): extracted from
|
|
// internal/mcp/tools.go via the Option B sibling-file pattern.
|
|
//
|
|
// This file groups the resource-management MCP tool domain — the
|
|
// configuration surface an operator builds out once and then
|
|
// references throughout cert issuance:
|
|
//
|
|
// - registerIssuerTools — issuer CRUD across the 12 issuer
|
|
// connectors (local CA, ACME upstream, ADCS / NDES, GlobalSign,
|
|
// Sectigo, DigiCert, Let's Encrypt, etc.).
|
|
// - registerTargetTools — deployment target CRUD across the 13
|
|
// target connectors (nginx / apache / haproxy / F5 / Palo Alto /
|
|
// IIS / WinCertStore / JavaKeystore / etc.).
|
|
// - registerPolicyTools — policy / policy-rule CRUD (issuance
|
|
// policies, key-strength rules, validity caps, EKU constraints).
|
|
// - registerProfileTools — certificate-profile CRUD (named
|
|
// bundles of "issuer + policy + targets + renewal cadence").
|
|
// - registerTeamTools / registerOwnerTools — ownership + RBAC
|
|
// scoping primitives (assign profiles to teams / owners).
|
|
// - registerNotificationTools — notification-channel CRUD across
|
|
// the 6 notifier connectors (email + webhook + chat + paging).
|
|
// - registerIntermediateCATools — Phase F P1-6..P1-9 (signed
|
|
// intermediate CA lifecycle: issue / sign / renew / list under
|
|
// the local issuer).
|
|
//
|
|
// Co-located because they're the "configure once, reference
|
|
// everywhere" half of the API surface; an LLM consumer reasoning
|
|
// about "what objects can the operator create + edit" sees them
|
|
// here together.
|
|
|
|
// ── Issuers ─────────────────────────────────────────────────────────
|
|
|
|
func registerIssuerTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_issuers",
|
|
Description: "List all configured issuer connectors (Local CA, ACME, step-ca).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/issuers", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_issuer",
|
|
Description: "Get issuer details including type, configuration, and enabled status.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/issuers/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_issuer",
|
|
Description: "Register a new issuer connector. Requires name and type (ACME, GenericCA, or StepCA).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateIssuerInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/issuers", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_issuer",
|
|
Description: "Update an issuer connector's configuration.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateIssuerInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/issuers/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_issuer",
|
|
Description: "Delete an issuer connector.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/issuers/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_test_issuer",
|
|
Description: "Test connectivity to an issuer connector. Returns success or error details.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/issuers/"+input.ID+"/test", nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Targets ─────────────────────────────────────────────────────────
|
|
|
|
func registerTargetTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_targets",
|
|
Description: "List all deployment targets (NGINX, Apache, HAProxy, F5, IIS).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/targets", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_target",
|
|
Description: "Get deployment target details including type, agent, and configuration.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/targets/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_target",
|
|
Description: "Create a new deployment target. Requires name and type (NGINX, Apache, HAProxy, F5, IIS).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateTargetInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/targets", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_target",
|
|
Description: "Update a deployment target's configuration.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateTargetInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/targets/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_target",
|
|
Description: "Delete a deployment target.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/targets/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Policies ────────────────────────────────────────────────────────
|
|
|
|
func registerPolicyTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_policies",
|
|
Description: "List all policy rules. Policy types: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/policies", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_policy",
|
|
Description: "Get policy rule details including type, configuration, and enabled status.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/policies/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_policy",
|
|
Description: "Create a new policy rule. Requires name and type. Optional severity (Warning, Error, Critical) defaults to Warning.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/policies", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_policy",
|
|
Description: "Update a policy rule's name, type, configuration, enabled status, or severity.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdatePolicyInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/policies/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_policy",
|
|
Description: "Delete a policy rule.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/policies/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_policy_violations",
|
|
Description: "List violations for a specific policy. Shows affected certificates and severity (Warning, Error, Critical).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListViolationsInput) (*gomcp.CallToolResult, any, error) {
|
|
q := paginationQuery(input.Page, input.PerPage)
|
|
data, err := c.Get("/api/v1/policies/"+input.ID+"/violations", q)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Profiles ────────────────────────────────────────────────────────
|
|
|
|
func registerProfileTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_profiles",
|
|
Description: "List certificate enrollment profiles defining allowed key types, max TTL, and crypto constraints.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/profiles", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_profile",
|
|
Description: "Get certificate profile details including allowed algorithms, max TTL, EKUs, and SAN patterns.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/profiles/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_profile",
|
|
Description: "Create a certificate enrollment profile. Requires name.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateProfileInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/profiles", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_profile",
|
|
Description: "Update a certificate profile's constraints.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateProfileInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/profiles/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_profile",
|
|
Description: "Delete a certificate profile.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/profiles/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Teams ───────────────────────────────────────────────────────────
|
|
|
|
func registerTeamTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_teams",
|
|
Description: "List all teams for certificate ownership grouping.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/teams", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_team",
|
|
Description: "Get team details.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/teams/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_team",
|
|
Description: "Create a new team. Requires name.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateTeamInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/teams", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_team",
|
|
Description: "Update a team's name or description.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateTeamInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/teams/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_team",
|
|
Description: "Delete a team.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/teams/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Owners ──────────────────────────────────────────────────────────
|
|
|
|
func registerOwnerTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_owners",
|
|
Description: "List all certificate owners with email and team assignment.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListParams) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/owners", paginationQuery(input.Page, input.PerPage))
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_owner",
|
|
Description: "Get owner details including email and team.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/owners/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_create_owner",
|
|
Description: "Create a new certificate owner. Requires name.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateOwnerInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/owners", input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_update_owner",
|
|
Description: "Update an owner's name, email, or team assignment.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateOwnerInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Put("/api/v1/owners/"+input.ID, input)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_delete_owner",
|
|
Description: "Delete a certificate owner.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Delete("/api/v1/owners/" + input.ID)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
}
|
|
|
|
// ── Notifications ───────────────────────────────────────────────────
|
|
|
|
func registerNotificationTools(s *gomcp.Server, c *Client) {
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_list_notifications",
|
|
Description: "List notification events (expiration warnings, renewal/deployment results, policy violations, revocations). Optional status filter supports the I-005 Dead letter tab (status=dead).",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListNotificationsInput) (*gomcp.CallToolResult, any, error) {
|
|
q := paginationQuery(input.Page, input.PerPage)
|
|
if input.Status != "" {
|
|
q.Set("status", input.Status)
|
|
}
|
|
data, err := c.Get("/api/v1/notifications", q)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_get_notification",
|
|
Description: "Get notification event details.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Get("/api/v1/notifications/"+input.ID, nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_mark_notification_read",
|
|
Description: "Mark a notification as read.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/notifications/"+input.ID+"/read", nil)
|
|
if err != nil {
|
|
return errorResult(err)
|
|
}
|
|
return textResult(data)
|
|
})
|
|
|
|
// I-005: requeue a dead-letter notification. Flips status from 'dead'
|
|
// back to 'pending' and clears next_retry_at so the retry sweep picks
|
|
// the notification up on its next tick. Operator-triggered; the tool
|
|
// is the MCP counterpart of the GUI's Dead letter tab "Requeue" button.
|
|
gomcp.AddTool(s, &gomcp.Tool{
|
|
Name: "certctl_requeue_notification",
|
|
Description: "Requeue a dead notification back to pending so the retry sweep can deliver it again. Used to recover from persistent delivery failures after the underlying issue (SMTP config, webhook endpoint, etc.) has been fixed.",
|
|
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
|
|
data, err := c.Post("/api/v1/notifications/"+input.ID+"/requeue", 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
|
|
// auth.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)
|
|
})
|
|
}
|