Files
certctl/internal/mcp/tools_certificates.go
shankar0123 fbe053aa0c refactor(mcp): split tools.go by tool domain — Option B sibling-files (Phase 9, 10 of N)
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.
2026-05-14 10:15:21 +00:00

405 lines
17 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package mcp
import (
"context"
"fmt"
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. Package
// stays `mcp`; every external caller of RegisterTools(...) resolves
// the same way — pure mechanical relocation. The dispatcher in
// tools.go still calls registerCertificateTools / registerCRLOCSPTools
// / registerRenewalPolicyTools / registerVerificationTools in the
// same order, just from this file.
//
// This file groups the certificate-lifecycle MCP tool domain:
// certificate CRUD + revocation (registerCertificateTools), CRL/OCSP
// surface (registerCRLOCSPTools), renewal-policy management
// (registerRenewalPolicyTools — Phase C of the 2026-05-05 parity
// audit), and certificate-verification tooling (registerVerificationTools
// — Phase G P1-32/P1-34/P1-35 of the same audit). Co-locating these
// four register-functions matches the operator-mental-model boundary
// (everything a certificate-administrator touches in one file) and
// pre-dates the Sprint 10 split — tools_audit_fix.go + tools_auth.go +
// tools_auth_bundle2.go + tools_est.go already follow the same
// sibling-file convention.
// ── Certificates ────────────────────────────────────────────────────
func registerCertificateTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_list_certificates",
Description: "List managed certificates with optional filters for status, environment, owner, team, and issuer. Returns paginated results.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListCertificatesInput) (*gomcp.CallToolResult, any, error) {
q := paginationQuery(input.Page, input.PerPage)
if input.Status != "" {
q.Set("status", input.Status)
}
if input.Environment != "" {
q.Set("environment", input.Environment)
}
if input.OwnerID != "" {
q.Set("owner_id", input.OwnerID)
}
if input.TeamID != "" {
q.Set("team_id", input.TeamID)
}
if input.IssuerID != "" {
q.Set("issuer_id", input.IssuerID)
}
data, err := c.Get("/api/v1/certificates", q)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_get_certificate",
Description: "Get a specific certificate by ID. Returns full certificate details including status, expiry, owner, and tags.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Get("/api/v1/certificates/"+input.ID, nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_create_certificate",
Description: "Create a new managed certificate. Requires name, common_name, renewal_policy_id, issuer_id, owner_id, and team_id.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input CreateCertificateInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/certificates", input)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_update_certificate",
Description: "Update an existing certificate's metadata (name, environment, owner, tags, etc.).",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input UpdateCertificateInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Put("/api/v1/certificates/"+input.ID, input)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_archive_certificate",
Description: "Archive (soft-delete) a certificate by ID.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Delete("/api/v1/certificates/" + input.ID)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_list_certificate_versions",
Description: "List all versions (renewals) of a certificate. Shows serial numbers, validity periods, and fingerprints.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input ListVersionsInput) (*gomcp.CallToolResult, any, error) {
q := paginationQuery(input.Page, input.PerPage)
data, err := c.Get("/api/v1/certificates/"+input.ID+"/versions", q)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_trigger_renewal",
Description: "Trigger immediate renewal of a certificate. Creates a renewal job (async, returns 202). Returns 404 if certificate not found, 400 if certificate is archived/expired, 409 if renewal already in progress.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetByIDInput) (*gomcp.CallToolResult, any, error) {
data, err := c.Post("/api/v1/certificates/"+input.ID+"/renew", nil)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_trigger_deployment",
Description: "Trigger deployment of a certificate to its targets. Optionally specify a single target.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input TriggerDeploymentInput) (*gomcp.CallToolResult, any, error) {
body := map[string]string{}
if input.TargetID != "" {
body["target_id"] = input.TargetID
}
data, err := c.Post("/api/v1/certificates/"+input.ID+"/deploy", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_revoke_certificate",
Description: "Revoke a certificate with an optional RFC 5280 reason code. Records in audit trail and notifies the issuer.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input RevokeCertificateInput) (*gomcp.CallToolResult, any, error) {
body := map[string]string{}
if input.Reason != "" {
body["reason"] = input.Reason
}
data, err := c.Post("/api/v1/certificates/"+input.ID+"/revoke", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_revoke_certificates",
Description: "Bulk revoke certificates matching filter criteria. At least one criterion (profile_id, owner_id, agent_id, issuer_id, team_id, or certificate_ids) is required. Returns counts of matched, revoked, skipped, and failed certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRevokeCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{
"reason": input.Reason,
}
if input.ProfileID != "" {
body["profile_id"] = input.ProfileID
}
if input.OwnerID != "" {
body["owner_id"] = input.OwnerID
}
if input.AgentID != "" {
body["agent_id"] = input.AgentID
}
if input.IssuerID != "" {
body["issuer_id"] = input.IssuerID
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
if len(input.CertificateIDs) > 0 {
body["certificate_ids"] = input.CertificateIDs
}
data, err := c.Post("/api/v1/certificates/bulk-revoke", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
// L-1 master closure (cat-l-fa0c1ac07ab5): bulk-renew MCP tool.
// Mirrors certctl_bulk_revoke_certificates shape sans the Reason
// field. Server returns total_matched / total_enqueued /
// total_skipped / total_failed plus per-cert {certificate_id,
// job_id} pairs in enqueued_jobs.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_renew_certificates",
Description: "Bulk renew certificates matching filter criteria (profile_id, owner_id, agent_id, issuer_id, team_id) or an explicit certificate_ids list. At least one selector required. Returns counts of matched, enqueued, skipped, and failed certificates plus per-cert {certificate_id, job_id} pairs.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkRenewCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{}
if input.ProfileID != "" {
body["profile_id"] = input.ProfileID
}
if input.OwnerID != "" {
body["owner_id"] = input.OwnerID
}
if input.AgentID != "" {
body["agent_id"] = input.AgentID
}
if input.IssuerID != "" {
body["issuer_id"] = input.IssuerID
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
if len(input.CertificateIDs) > 0 {
body["certificate_ids"] = input.CertificateIDs
}
data, err := c.Post("/api/v1/certificates/bulk-renew", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
// L-2 closure (cat-l-8a1fb258a38a): bulk-reassign MCP tool.
// Narrower than bulk-renew/revoke — IDs-only, no criteria-mode.
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_bulk_reassign_certificates",
Description: "Bulk reassign owner (and optionally team) for a set of certificates. owner_id is required. team_id is optional and updates only when non-empty. Returns counts of matched, reassigned, skipped (already-owned-by-target), and failed certificates.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input BulkReassignCertificatesInput) (*gomcp.CallToolResult, any, error) {
body := map[string]interface{}{
"certificate_ids": input.CertificateIDs,
"owner_id": input.OwnerID,
}
if input.TeamID != "" {
body["team_id"] = input.TeamID
}
data, err := c.Post("/api/v1/certificates/bulk-reassign", body)
if err != nil {
return errorResult(err)
}
return textResult(data)
})
}
// ── CRL & OCSP ──────────────────────────────────────────────────────
//
// M-006 relocation: CRL and OCSP are served unauthenticated under the
// RFC 8615 `.well-known/pki/*` namespace (RFC 5280 §5 for CRL, RFC 6960
// §2.1 for OCSP) so relying parties can retrieve them without a certctl
// API key. The non-standard JSON CRL tool (`certctl_get_crl`) has been
// removed — RFC 5280 defines only the DER wire format.
func registerCRLOCSPTools(s *gomcp.Server, c *Client) {
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_get_der_crl",
Description: "Get DER-encoded X.509 CRL for a specific issuer (RFC 5280). Served unauthenticated at /.well-known/pki/crl/{issuer_id}. Returns binary CRL data signed by the issuing CA.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input GetDERCRLInput) (*gomcp.CallToolResult, any, error) {
raw, contentType, err := c.GetRaw("/.well-known/pki/crl/" + input.IssuerID)
if err != nil {
return errorResult(err)
}
return &gomcp.CallToolResult{
Content: []gomcp.Content{
&gomcp.TextContent{Text: fmt.Sprintf("DER CRL retrieved (%d bytes, content-type: %s)", len(raw), contentType)},
},
}, nil, nil
})
gomcp.AddTool(s, &gomcp.Tool{
Name: "certctl_ocsp_check",
Description: "Check OCSP status for a certificate by issuer ID and hex serial number (RFC 6960). Served unauthenticated at /.well-known/pki/ocsp/{issuer_id}/{serial}. Returns good, revoked, or unknown.",
}, func(ctx context.Context, req *gomcp.CallToolRequest, input OCSPInput) (*gomcp.CallToolResult, any, error) {
raw, contentType, err := c.GetRaw("/.well-known/pki/ocsp/" + input.IssuerID + "/" + input.Serial)
if err != nil {
return errorResult(err)
}
return &gomcp.CallToolResult{
Content: []gomcp.Content{
&gomcp.TextContent{Text: fmt.Sprintf("OCSP response retrieved (%d bytes, content-type: %s)", len(raw), contentType)},
},
}, nil, nil
})
}
// ── 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)
})
}
// ── 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)
})
}