Skip to content

Commit d3ed20b

Browse files
jingle2008claude
andcommitted
feat(mcp): expose mutations as tools (gated on confirm=true)
Adds seven mutating MCP tools that wrap the same primitives the CLI mutation subcommands use, so a single MCP server gives an agent the full read+write surface: cordon_node k8s.SetCordon(want=true) uncordon_node k8s.SetCordon(want=false) drain_node k8s.DrainNode reboot_node actions.SoftResetInstance (--ocid bypass) terminate_node actions.TerminateInstance (--ocid bypass, DESTRUCTIVE) scale_gpu_pool actions.IncreasePoolSize (Terraform sourced) delete_dac actions.DeleteDedicatedAICluster (DESTRUCTIVE, polls) Each tool requires `confirm: true` to execute. The field is optional at the JSON-Schema level (omitempty) so the SDK invokes the handler even when confirm is missing — letting us audit-log the refused attempt and emit a notifications/message explaining the contract, in addition to surfacing a tool error. Both signals reach the client whether it watches tool results or notifications. Resolver flows mirror the CLI: - resolveNodeForOCIAction synthesizes a *GpuNode from --ocid or walks LoadGpuNodes for name lookup. - resolveGpuPoolForOCIAction does the LoadGpuPools + PopulateGpuPools dance to fill the OCID before scaling. All upstream calls go through seam variables (mcpSetCordonFn etc.) so tests don't touch a live cluster or OCI tenancy. The tests mark the file non-parallel because seam swapping is inherently shared state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a34d9c8 commit d3ed20b

4 files changed

Lines changed: 548 additions & 0 deletions

File tree

internal/mcp/integration_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ func TestIntegration_ToolsListAndCall(t *testing.T) {
257257
got[tool.Name] = true
258258
}
259259
want := []string{
260+
// Read-only list_* tools.
260261
"list_tenants",
261262
"list_base_models",
262263
"list_gpu_pools",
@@ -269,6 +270,14 @@ func TestIntegration_ToolsListAndCall(t *testing.T) {
269270
"list_tenancy_overrides",
270271
"list_regional_overrides",
271272
"list_aliases",
273+
// Mutation tools (all gated on confirm=true; see mutations.go).
274+
"cordon_node",
275+
"uncordon_node",
276+
"drain_node",
277+
"reboot_node",
278+
"terminate_node",
279+
"scale_gpu_pool",
280+
"delete_dac",
272281
}
273282
for _, name := range want {
274283
assert.True(t, got[name], "tools/list missing %q (got %d tools total)", name, len(listRes.Tools))

internal/mcp/mutations.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
8+
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
9+
10+
"github.com/jingle2008/toolkit/internal/infra/k8s"
11+
"github.com/jingle2008/toolkit/internal/infra/terraform"
12+
"github.com/jingle2008/toolkit/internal/ui/tui/actions"
13+
"github.com/jingle2008/toolkit/pkg/infra/logging"
14+
"github.com/jingle2008/toolkit/pkg/models"
15+
)
16+
17+
// Seam variables — overrideable in tests so handlers don't reach a
18+
// live cluster or OCI tenancy. Production callers go through the
19+
// upstream packages directly.
20+
var (
21+
mcpSetCordonFn = k8s.SetCordon
22+
mcpDrainNodeFn = k8s.DrainNode
23+
mcpSoftResetFn = actions.SoftResetInstance
24+
mcpTerminateFn = actions.TerminateInstance
25+
mcpIncreasePoolSizeFn = actions.IncreasePoolSize
26+
mcpDeleteDACFn = actions.DeleteDedicatedAICluster
27+
mcpPopulateGpuPoolsFn = actions.PopulateGpuPools
28+
mcpResolveCompartmentFn = mcpResolveCompartmentID
29+
)
30+
31+
// confirmGate is embedded in every mutating tool's input. The field
32+
// is OPTIONAL at the JSON-Schema level (omitempty) so the SDK passes
33+
// the call to the handler even when confirm is missing — that way we
34+
// can audit-log the attempt and emit a notifications/message
35+
// explaining the contract. Only confirm=true triggers execution.
36+
type confirmGate struct {
37+
Confirm bool `json:"confirm,omitempty" jsonschema:"set true to execute; otherwise the tool refuses without acting"`
38+
}
39+
40+
// requireConfirm short-circuits with a uniform tool error when the
41+
// caller forgot to set confirm: true. Audit log captures the intent
42+
// so the refusal is visible alongside successful mutations.
43+
//
44+
// Returns (response, _, err, gated). When gated is true the caller
45+
// must return the first three values immediately.
46+
func (s *Server) requireConfirm(ctx context.Context, req *sdk.CallToolRequest, action, kind, target string, confirm bool) (*sdk.CallToolResult, struct{}, error, bool) {
47+
if confirm {
48+
return nil, struct{}{}, nil, false
49+
}
50+
s.logger.Infow("mutation refused (confirm=false)",
51+
"action", action, "kind", kind, "target", target, "surface", "mcp",
52+
)
53+
notify(ctx, req.Session, "info",
54+
fmt.Sprintf("%s %s/%s refused: set confirm=true to execute", action, kind, target))
55+
res, _, err := failTool(ctx, req, action,
56+
fmt.Errorf("mutating tool requires confirm=true (target %s/%s)", kind, target))
57+
return res, struct{}{}, err, true
58+
}
59+
60+
// runMutationTool wraps the audit+notify+execute flow that every
61+
// mutating handler shares. Mirrors cli.runMutation but adapted to the
62+
// MCP response shape — no stdout/prompt; success becomes a structured
63+
// jsonResult.
64+
func (s *Server) runMutationTool(ctx context.Context, req *sdk.CallToolRequest, action, kind, target string, perform func() error) (*sdk.CallToolResult, struct{}, error) {
65+
s.logger.Infow("mutation",
66+
"action", action, "kind", kind, "target", target, "surface", "mcp",
67+
"phase", "begin",
68+
)
69+
if err := perform(); err != nil {
70+
return failTool(ctx, req, action+" "+kind+"/"+target, err)
71+
}
72+
s.logger.Infow("mutation",
73+
"action", action, "kind", kind, "target", target, "surface", "mcp",
74+
"phase", "done",
75+
)
76+
notify(ctx, req.Session, "info",
77+
fmt.Sprintf("%s %s/%s: OK", action, kind, target))
78+
return jsonResult(map[string]string{
79+
"status": "OK",
80+
"action": action,
81+
"kind": kind,
82+
"target": target,
83+
}, nil)
84+
}
85+
86+
// --- Input types --------------------------------------------------
87+
88+
type cordonNodeInput struct {
89+
Node string `json:"node" jsonschema:"the node name as reported by kubectl get nodes"`
90+
confirmGate
91+
}
92+
93+
type drainNodeInput struct {
94+
Node string `json:"node" jsonschema:"the node name as reported by kubectl get nodes"`
95+
confirmGate
96+
}
97+
98+
type rebootNodeInput struct {
99+
Node string `json:"node" jsonschema:"the node name as reported by kubectl get nodes"`
100+
OCID string `json:"ocid,omitempty" jsonschema:"skip k8s lookup and target this instance OCID directly"`
101+
confirmGate
102+
}
103+
104+
type terminateNodeInput struct {
105+
Node string `json:"node" jsonschema:"the node name as reported by kubectl get nodes"`
106+
OCID string `json:"ocid,omitempty" jsonschema:"skip k8s lookup and target this instance OCID directly"`
107+
confirmGate
108+
}
109+
110+
type scaleGpuPoolInput struct {
111+
Name string `json:"name" jsonschema:"the pool name from the Terraform repo (same as toolkit get gpupool)"`
112+
confirmGate
113+
}
114+
115+
type deleteDACInput struct {
116+
Name string `json:"name" jsonschema:"the DAC name (same identifier as toolkit get dac shows)"`
117+
confirmGate
118+
}
119+
120+
// --- Handlers -----------------------------------------------------
121+
122+
func (s *Server) handleCordonNode(ctx context.Context, req *sdk.CallToolRequest, in cordonNodeInput) (*sdk.CallToolResult, struct{}, error) {
123+
if res, _, err, gated := s.requireConfirm(ctx, req, "cordon", "node", in.Node, in.Confirm); gated {
124+
return res, struct{}{}, err
125+
}
126+
env := s.envFor(envOverride{})
127+
return s.runMutationTool(ctx, req, "cordon", "node", in.Node, func() error {
128+
_, err := mcpSetCordonFn(ctx, s.cfg.KubeConfig, env.GetKubeContext(), in.Node, true)
129+
return err
130+
})
131+
}
132+
133+
func (s *Server) handleUncordonNode(ctx context.Context, req *sdk.CallToolRequest, in cordonNodeInput) (*sdk.CallToolResult, struct{}, error) {
134+
if res, _, err, gated := s.requireConfirm(ctx, req, "uncordon", "node", in.Node, in.Confirm); gated {
135+
return res, struct{}{}, err
136+
}
137+
env := s.envFor(envOverride{})
138+
return s.runMutationTool(ctx, req, "uncordon", "node", in.Node, func() error {
139+
_, err := mcpSetCordonFn(ctx, s.cfg.KubeConfig, env.GetKubeContext(), in.Node, false)
140+
return err
141+
})
142+
}
143+
144+
func (s *Server) handleDrainNode(ctx context.Context, req *sdk.CallToolRequest, in drainNodeInput) (*sdk.CallToolResult, struct{}, error) {
145+
if res, _, err, gated := s.requireConfirm(ctx, req, "drain", "node", in.Node, in.Confirm); gated {
146+
return res, struct{}{}, err
147+
}
148+
env := s.envFor(envOverride{})
149+
return s.runMutationTool(ctx, req, "drain", "node", in.Node, func() error {
150+
return mcpDrainNodeFn(ctx, s.cfg.KubeConfig, env.GetKubeContext(), in.Node)
151+
})
152+
}
153+
154+
func (s *Server) handleRebootNode(ctx context.Context, req *sdk.CallToolRequest, in rebootNodeInput) (*sdk.CallToolResult, struct{}, error) {
155+
if res, _, err, gated := s.requireConfirm(ctx, req, "reboot", "node", in.Node, in.Confirm); gated {
156+
return res, struct{}{}, err
157+
}
158+
env := s.envFor(envOverride{})
159+
return s.runMutationTool(ctx, req, "reboot", "node", in.Node, func() error {
160+
node, err := s.resolveNodeForOCIAction(ctx, env, in.Node, in.OCID)
161+
if err != nil {
162+
return err
163+
}
164+
return mcpSoftResetFn(ctx, node, env, logging.FromContext(ctx))
165+
})
166+
}
167+
168+
func (s *Server) handleTerminateNode(ctx context.Context, req *sdk.CallToolRequest, in terminateNodeInput) (*sdk.CallToolResult, struct{}, error) {
169+
if res, _, err, gated := s.requireConfirm(ctx, req, "terminate", "node", in.Node, in.Confirm); gated {
170+
return res, struct{}{}, err
171+
}
172+
env := s.envFor(envOverride{})
173+
return s.runMutationTool(ctx, req, "terminate", "node", in.Node, func() error {
174+
node, err := s.resolveNodeForOCIAction(ctx, env, in.Node, in.OCID)
175+
if err != nil {
176+
return err
177+
}
178+
return mcpTerminateFn(ctx, node, env, logging.FromContext(ctx))
179+
})
180+
}
181+
182+
func (s *Server) handleScaleGpuPool(ctx context.Context, req *sdk.CallToolRequest, in scaleGpuPoolInput) (*sdk.CallToolResult, struct{}, error) {
183+
if res, _, err, gated := s.requireConfirm(ctx, req, "scale", "gpu_pool", in.Name, in.Confirm); gated {
184+
return res, struct{}{}, err
185+
}
186+
env := s.envFor(envOverride{})
187+
return s.runMutationTool(ctx, req, "scale", "gpu_pool", in.Name, func() error {
188+
pool, err := s.resolveGpuPoolForOCIAction(ctx, env, in.Name)
189+
if err != nil {
190+
return err
191+
}
192+
return mcpIncreasePoolSizeFn(ctx, pool, env, logging.FromContext(ctx))
193+
})
194+
}
195+
196+
func (s *Server) handleDeleteDAC(ctx context.Context, req *sdk.CallToolRequest, in deleteDACInput) (*sdk.CallToolResult, struct{}, error) {
197+
if res, _, err, gated := s.requireConfirm(ctx, req, "delete", "dac", in.Name, in.Confirm); gated {
198+
return res, struct{}{}, err
199+
}
200+
env := s.envFor(envOverride{})
201+
return s.runMutationTool(ctx, req, "delete", "dac", in.Name, func() error {
202+
dac := &models.DedicatedAICluster{Name: in.Name}
203+
return mcpDeleteDACFn(ctx, dac, env, logging.FromContext(ctx))
204+
})
205+
}
206+
207+
// --- Resolvers ----------------------------------------------------
208+
209+
// resolveNodeForOCIAction synthesizes a *GpuNode for OCI compute
210+
// actions. With ocid set, the cluster isn't consulted; otherwise the
211+
// loader is asked for all GPU nodes and the named one is returned.
212+
func (s *Server) resolveNodeForOCIAction(ctx context.Context, env models.Environment, name, ocid string) (*models.GpuNode, error) {
213+
if ocid != "" {
214+
return &models.GpuNode{Name: name, ID: ocid}, nil
215+
}
216+
grouped, err := s.loader.LoadGpuNodes(ctx, s.cfg.KubeConfig, env)
217+
if err != nil {
218+
return nil, fmt.Errorf("load gpu nodes: %w", err)
219+
}
220+
for _, nodes := range grouped {
221+
for i := range nodes {
222+
if nodes[i].Name == name {
223+
return &nodes[i], nil
224+
}
225+
}
226+
}
227+
return nil, fmt.Errorf("gpu node %q not found in any pool", name)
228+
}
229+
230+
// resolveGpuPoolForOCIAction loads pools from Terraform, finds the
231+
// named one, then enriches with OCI ID/ActualSize via
232+
// PopulateGpuPools. Mirrors the CLI's realResolveGpuPool.
233+
func (s *Server) resolveGpuPoolForOCIAction(ctx context.Context, env models.Environment, name string) (*models.GpuPool, error) {
234+
pools, err := s.loader.LoadGpuPools(ctx, s.cfg.RepoPath, env)
235+
if err != nil {
236+
if _, ok := errors.AsType[*terraform.PartialLoadError](err); !ok {
237+
return nil, fmt.Errorf("load gpu pools: %w", err)
238+
}
239+
s.logger.Infow("gpu pools loaded with partial failures (mcp)", "error", err)
240+
}
241+
idx := -1
242+
for i := range pools {
243+
if pools[i].Name == name {
244+
idx = i
245+
break
246+
}
247+
}
248+
if idx < 0 {
249+
return nil, fmt.Errorf("gpu pool %q not found in repo", name)
250+
}
251+
compartmentID, err := mcpResolveCompartmentFn(ctx, s, env)
252+
if err != nil {
253+
return nil, fmt.Errorf("resolve compartment ID: %w", err)
254+
}
255+
enriched := []models.GpuPool{pools[idx]}
256+
if err := mcpPopulateGpuPoolsFn(ctx, enriched, env, compartmentID); err != nil {
257+
return nil, fmt.Errorf("populate gpu pool: %w", err)
258+
}
259+
if enriched[0].ID == "" {
260+
return nil, fmt.Errorf("gpu pool %q has no OCID after OCI lookup; may not be applied yet", name)
261+
}
262+
return &enriched[0], nil
263+
}
264+
265+
// mcpResolveCompartmentID queries the cluster for any GPU node and
266+
// returns its CompartmentID. Default value of mcpResolveCompartmentFn.
267+
func mcpResolveCompartmentID(ctx context.Context, s *Server, env models.Environment) (string, error) {
268+
clientset, err := k8s.NewClientsetFromKubeConfig(s.cfg.KubeConfig, env.GetKubeContext())
269+
if err != nil {
270+
return "", err
271+
}
272+
nodes, err := k8s.ListGpuNodes(ctx, clientset, 1)
273+
if err != nil {
274+
return "", err
275+
}
276+
if len(nodes) == 0 {
277+
return "", fmt.Errorf("no GPU nodes in cluster (cannot resolve compartment ID)")
278+
}
279+
return nodes[0].CompartmentID, nil
280+
}
281+
282+
// --- Registration -------------------------------------------------
283+
284+
// registerMutationTools adds the seven mutating tools. Each requires
285+
// confirm=true at the input level; the tool description tells the
286+
// agent explicitly so the contract is discoverable without running
287+
// the tool to see the refusal.
288+
func registerMutationTools(s *Server) {
289+
sdk.AddTool(s.server, &sdk.Tool{
290+
Name: "cordon_node",
291+
Description: "Cordon (mark unschedulable) a Kubernetes node. Idempotent. Mutating: requires confirm=true to execute, otherwise refuses without acting.",
292+
}, s.handleCordonNode)
293+
294+
sdk.AddTool(s.server, &sdk.Tool{
295+
Name: "uncordon_node",
296+
Description: "Uncordon (mark schedulable) a Kubernetes node. Idempotent. Mutating: requires confirm=true.",
297+
}, s.handleUncordonNode)
298+
299+
sdk.AddTool(s.server, &sdk.Tool{
300+
Name: "drain_node",
301+
Description: "Drain pods from a node (cordon + evict). Use before terminate. Mutating: requires confirm=true.",
302+
}, s.handleDrainNode)
303+
304+
sdk.AddTool(s.server, &sdk.Tool{
305+
Name: "reboot_node",
306+
Description: "Soft-reset the OCI instance backing a GPU node. Fire-and-forget. Mutating: requires confirm=true.",
307+
}, s.handleRebootNode)
308+
309+
sdk.AddTool(s.server, &sdk.Tool{
310+
Name: "terminate_node",
311+
Description: "Terminate the OCI instance backing a GPU node (boot volume destroyed). DESTRUCTIVE. Mutating: requires confirm=true.",
312+
}, s.handleTerminateNode)
313+
314+
sdk.AddTool(s.server, &sdk.Tool{
315+
Name: "scale_gpu_pool",
316+
Description: "Push the Terraform-declared pool.Size to OCI for the named GPU pool. No size override: Terraform is the source of truth. Mutating: requires confirm=true.",
317+
}, s.handleScaleGpuPool)
318+
319+
sdk.AddTool(s.server, &sdk.Tool{
320+
Name: "delete_dac",
321+
Description: "Delete a dedicated AI cluster and its endpoints (synchronous, polls the work request). DESTRUCTIVE. Mutating: requires confirm=true.",
322+
}, s.handleDeleteDAC)
323+
}

0 commit comments

Comments
 (0)