Skip to content

Commit 000ab23

Browse files
alicodingclaude
andcommitted
feat: MCP writes judged through the shared guardrail rule core (ADR-0047 §5.4)
A gated MCP write's approve/deny/ask verdict now comes from EvaluateAction -- the same rule-evaluation core RequestGuardedAction and the workflow execution gate already share -- instead of a flat enabled/required settings toggle with no rule concept at all. A user authoring a guardrail rule scoped to kind "mcp-write" through the ordinary Configure > Guardrail CRUD now governs MCP writes: an explicit allow rule executes immediately with no park, an explicit deny rule blocks outright, and the "ask" default (no matching rule, or no guardrail service wired) falls straight through to the existing durable park/poll mechanism unchanged. The durable park itself (MCPWriteRecord, its restart-survival, courtesy window, sweep/expiry, audit, and Activity emission) stays this package's own -- unifying it onto guardrailsvc's PendingGuardedAction would need that store to gain durable persistence and an apply-on-approve payload, which several existing tests here (constructed with no guardrail dependency at all, asserting directly on this package's own store-persistence failure paths) assume it never does. Full mechanism deletion is a follow-up, not folded into this slice. Wiring: SetGuardrailService is a new late-bound setter (same shape as SetAuditResolver/SetExecutionService), nil-safe so no existing test construction changes. The gateWrite-adjacent wiring call in main.go was extracted into wiring.WireMillMCPService to stay under the 500-line file cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq
1 parent c60e25f commit 000ab23

6 files changed

Lines changed: 242 additions & 17 deletions

File tree

internal/services/mcpsvc/millmcpservice.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"github.com/alicoding/mill/internal/services/atlassvc"
2626
"github.com/alicoding/mill/internal/services/compositionsvc"
2727
"github.com/alicoding/mill/internal/services/configuresvc"
28+
"github.com/alicoding/mill/internal/services/guardrailsvc"
2829
"github.com/modelcontextprotocol/go-sdk/mcp"
2930
)
3031

@@ -95,6 +96,15 @@ type MillMCPService struct {
9596
// until wired (every test that doesn't call SetAuditResolver), in
9697
// which case resolution calls are simply skipped.
9798
auditResolver func(writeID string, outcome mcpaudit.Outcome, errText string)
99+
// guard is the shared guardrail rule-evaluation core (docs/adr/0047
100+
// §5.4) -- late-bound via SetGuardrailService, same injected-seam
101+
// shape as auditResolver above (guardrailsvc.NewGuardrailService is
102+
// constructed before this service in main.go, but wiring it as a
103+
// constructor parameter would force every existing test in this
104+
// package to construct one too; nil until wired, in which case
105+
// gateWrite falls through to its unconditional-ask park, exactly the
106+
// pre-rebase behavior).
107+
guard *guardrailsvc.GuardrailService
98108
}
99109

100110
// SetAuditResolver wires the audit trail's parked-write resolution
@@ -107,6 +117,15 @@ func (m *MillMCPService) SetAuditResolver(fn func(writeID string, outcome mcpaud
107117
m.auditResolver = fn
108118
}
109119

120+
// SetGuardrailService wires the shared rule-evaluation core (main.go,
121+
// after guardrailsvc.NewGuardrailService succeeds) so a gated write is
122+
// judged by the SAME user-authored rules a workflow step's own guardrail
123+
// gate already evaluates (docs/adr/0047 §5.4) -- see the guard field's
124+
// own doc comment for why this is late-bound.
125+
func (m *MillMCPService) SetGuardrailService(g *guardrailsvc.GuardrailService) {
126+
m.guard = g
127+
}
128+
110129
// NewMillMCPService builds the MCP server and registers every
111130
// resource/template up front -- registration is static (the resource
112131
// URIs themselves never change), only the data a read returns is

internal/services/mcpsvc/millmcpservice_approval.go

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@ import (
1212
)
1313

1414
// Per-write MCP approval lifecycle: park-and-poll (docs/adr/0032,
15-
// superseding the old bounded-120s-blocking-wait shape). A gated write
16-
// tool call submits (description, toolName, argsJSON) to gateWrite,
17-
// which parks the write itself -- not a live channel -- as a durable
15+
// superseding the old bounded-120s-blocking-wait shape). gateWrite first
16+
// judges the write through the shared guardrail rule-evaluation core
17+
// (writeVerdictShortCircuit, millmcpservice_approval_guardrail.go --
18+
// docs/adr/0047 §5.4): an explicit allow/deny rule short-circuits the
19+
// park entirely; the ask default (no rule, matching pre-rebase behavior)
20+
// parks the write itself -- not a live channel -- as a durable
1821
// MCPWriteRecord persisted via the settings store, so an approval can
1922
// execute the write later even if the requester (or Mill itself) has
20-
// since restarted. A short in-call courtesy window (10s) keeps the
21-
// co-present-approver case a single round trip; past that, the call
22-
// returns a SUCCESSFUL parked-pending text so the client polls
23-
// check_write_status instead of the connection dying against a real
24-
// host's own ~60s to-first-byte timer (ADR-0032's own research).
23+
// since restarted. A short in-call courtesy window (10s) keeps a
24+
// co-present approver to one round trip; past that, the call returns a
25+
// SUCCESSFUL parked-pending text so the client polls check_write_status
26+
// instead of the connection dying against a real host's own ~60s
27+
// to-first-byte timer (ADR-0032's own research).
2528

2629
// MCPWriteApprovalKey: when writes are enabled at all
2730
// (MCPWriteEnabledKey), this second toggle decides whether each write
@@ -191,6 +194,13 @@ func (m *MillMCPService) gateWrite(toolName, description, argsJSON string) (*mcp
191194
return textResult(text), nil
192195
}
193196

197+
// docs/adr/0047 §5.4: an explicit allow/deny rule short-circuits the
198+
// ask-every-time default here; "ask" (no rule, or no guardrail
199+
// service wired) falls through to the unchanged park below.
200+
if result, err, handled := m.writeVerdictShortCircuit(toolName, description, argsJSON); handled {
201+
return result, err
202+
}
203+
194204
rec := &MCPWriteRecord{
195205
ID: uuid.NewString(),
196206
Description: description,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package mcpsvc
2+
3+
// The rule-plane half of the park-and-poll approval lifecycle
4+
// (millmcpservice_approval.go): a gated write is judged against the
5+
// shared guardrail rule-evaluation core before it ever reaches the
6+
// durable park (docs/adr/0047 §5.4) -- split out once the lifecycle file
7+
// crossed the 500-line limit (CLAUDE.md/§1.3), same "split along a real
8+
// seam" discipline millmcpservice_approval_query.go already established.
9+
//
10+
// The durable park itself stays this package's own MCPWriteRecord, not
11+
// guardrailsvc's in-memory PendingGuardedAction: unifying the park too
12+
// would need PendingGuardedAction to gain durable persistence and an
13+
// apply-on-approve payload, which several existing tests in this
14+
// package (constructed with no guardrail dependency at all, asserting
15+
// directly on this package's own store-persistence failure paths)
16+
// assume it never does -- tracked as a follow-up, not folded into this
17+
// slice.
18+
19+
import (
20+
"fmt"
21+
22+
"github.com/alicoding/mill/internal/domain/guardrail"
23+
"github.com/modelcontextprotocol/go-sdk/mcp"
24+
)
25+
26+
// mcpWriteGuardrailKind is the guardrail Kind a gated write is judged
27+
// under (docs/adr/0047 §5.4) -- fills guardrail.Step.NodeTypeID via
28+
// EvaluateAction, the same scope axis a workflow node's own NodeTypeID
29+
// already targets, so a rule authored through the ordinary Guardrail
30+
// CRUD governs every gated write tool uniformly, never a parallel rule
31+
// vocabulary.
32+
const mcpWriteGuardrailKind = "mcp-write"
33+
34+
// evaluateWriteVerdict judges one gated write against the shared
35+
// guardrail rule-evaluation core (docs/adr/0047 §5.4's "one entry, one
36+
// rule plane") -- the exact EvaluateStep/EvaluateAction core
37+
// RequestGuardedAction and the workflow execution gate already share, so
38+
// a rule created via the normal Configure > Guardrail CRUD applies to
39+
// MCP writes too. m.guard is nil in every test that never calls
40+
// SetGuardrailService; the fail-safe there is the same one
41+
// guardrail.ClassExternal's own DefaultEffect already returns for "no
42+
// rule matched" -- ask, i.e. always park, exactly gateWrite's pre-rebase
43+
// behavior.
44+
func (m *MillMCPService) evaluateWriteVerdict(toolName, description string) guardrail.Verdict {
45+
if m.guard == nil {
46+
return guardrail.Verdict{Effect: guardrail.EffectAsk}
47+
}
48+
attrs := map[string]string{"toolName": toolName, "description": description}
49+
return m.guard.EvaluateAction(mcpWriteGuardrailKind, attrs, guardrail.ClassExternal)
50+
}
51+
52+
// writeVerdictShortCircuit evaluates the write and, when the verdict is
53+
// allow or deny, returns the FINAL result/error for gateWrite to hand
54+
// back directly (handled=true) -- allow executes immediately, deny
55+
// blocks with no park at all. handled=false means "ask" (no rule
56+
// matched, or no guardrail service wired): gateWrite falls through to
57+
// its existing durable park unchanged.
58+
func (m *MillMCPService) writeVerdictShortCircuit(toolName, description, argsJSON string) (result *mcp.CallToolResult, err error, handled bool) {
59+
switch verdict := m.evaluateWriteVerdict(toolName, description); verdict.Effect {
60+
case guardrail.EffectDeny:
61+
reason := verdict.RuleLabel
62+
if reason == "" {
63+
reason = verdict.RuleID
64+
}
65+
return nil, fmt.Errorf("denied by guardrail rule %q", reason), true
66+
case guardrail.EffectAllow:
67+
text, execErr := m.execute(toolName, argsJSON)
68+
if execErr != nil {
69+
return nil, execErr, true
70+
}
71+
return textResult(text), nil, true
72+
}
73+
return nil, nil, false
74+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package mcpsvc
2+
3+
// The new rules-leverage payoff (docs/adr/0047 §5.4): a guardrail rule
4+
// authored through the ordinary Configure > Guardrail CRUD -- the exact
5+
// same guardrailsvc.GuardrailService.CreateRule a human uses from the
6+
// Review "Rules" audit view -- now governs a gated MCP write, because
7+
// gateWrite's evaluateWriteVerdict judges it through EvaluateAction, the
8+
// same rule-evaluation core the workflow execution gate and
9+
// RequestGuardedAction already share (guardrailservice_request.go).
10+
11+
import (
12+
"testing"
13+
14+
"github.com/alicoding/mill/internal/domain/composition"
15+
"github.com/alicoding/mill/internal/domain/guardrail"
16+
"github.com/alicoding/mill/internal/services/compositionsvc"
17+
"github.com/alicoding/mill/internal/services/configuresvc"
18+
"github.com/alicoding/mill/internal/services/guardrailsvc"
19+
"github.com/alicoding/mill/internal/services/servicetest"
20+
)
21+
22+
// newGuardrailWiredHarness builds a MillMCPService with a real,
23+
// store-backed GuardrailService wired via SetGuardrailService -- the
24+
// main.go wiring this test proves matters, unlike every other harness in
25+
// this package which leaves m.guard nil deliberately.
26+
func newGuardrailWiredHarness(t *testing.T) (*MillMCPService, *guardrailsvc.GuardrailService, *compositionsvc.CompositionService) {
27+
t.Helper()
28+
store := servicetest.NewFakeStore()
29+
comp := compositionsvc.NewCompositionService(store)
30+
cfg := configuresvc.NewConfigureService(store, comp, servicetest.FakeCredentialStore{})
31+
guard := guardrailsvc.NewGuardrailService(store, comp)
32+
33+
m := NewMillMCPService("0.0.0-test", comp, cfg, store, nil)
34+
m.SetGuardrailService(guard)
35+
if err := store.Set(MCPWriteEnabledKey, "true"); err != nil {
36+
t.Fatalf("set write key: %v", err)
37+
}
38+
// approval key left unset: required is the default -- a rule must be
39+
// what changes the outcome here, not a relaxed toggle.
40+
return m, guard, comp
41+
}
42+
43+
// TestGateWrite_DenyRuleFromNormalCRUD_BlocksTheWriteWithNoPark proves a
44+
// guardrail rule created through CreateRule (not a bespoke MCP-only
45+
// concept) denies a gated write outright -- no park, no Activity/audit
46+
// ceremony a plain policy deny never gets, and nothing written.
47+
func TestGateWrite_DenyRuleFromNormalCRUD_BlocksTheWriteWithNoPark(t *testing.T) {
48+
m, guard, comp := newGuardrailWiredHarness(t)
49+
before := len(comp.Workflows())
50+
51+
if _, err := guard.CreateRule(guardrail.Rule{
52+
Label: "Block all MCP writes", Effect: guardrail.EffectDeny, NodeTypeID: mcpWriteGuardrailKind,
53+
}); err != nil {
54+
t.Fatalf("CreateRule: %v", err)
55+
}
56+
57+
res, err := m.gateWrite("import_workflow", "denied by rule", "{}")
58+
if err == nil {
59+
t.Fatalf("gateWrite() with a deny rule in place: want an error, got a result (res=%+v)", res)
60+
}
61+
if got := len(comp.Workflows()); got != before {
62+
t.Errorf("workflow count = %d, want %d -- a denied write must write nothing", got, before)
63+
}
64+
if pending := m.PendingMCPWrites(); len(pending) != 0 {
65+
t.Errorf("PendingMCPWrites() = %+v, want empty -- a deny-rule verdict must never park", pending)
66+
}
67+
}
68+
69+
// TestGateWrite_AllowRuleFromNormalCRUD_SkipsTheParkAndExecutes proves
70+
// the allow side of the same leverage: a matching allow rule executes
71+
// the write immediately even though MCPWriteApprovalKey defaults to
72+
// required, since the rule now short-circuits the ask-every-time
73+
// default that governed every MCP write before this rebase.
74+
func TestGateWrite_AllowRuleFromNormalCRUD_SkipsTheParkAndExecutes(t *testing.T) {
75+
m, guard, comp := newGuardrailWiredHarness(t)
76+
77+
wf, err := comp.CreateWorkflow("Allow-rule source workflow", "",
78+
[]composition.Node{{ID: "t", NodeTypeID: "trigger-manual"}, {ID: "c", NodeTypeID: "capture-clipboard-html"}},
79+
[]composition.Edge{{ID: "e1", Source: "t", Target: "c"}})
80+
if err != nil {
81+
t.Fatalf("CreateWorkflow: %v", err)
82+
}
83+
exported, err := comp.ExportWorkflow(wf.ID)
84+
if err != nil {
85+
t.Fatalf("ExportWorkflow: %v", err)
86+
}
87+
before := len(comp.Workflows())
88+
89+
if _, err := guard.CreateRule(guardrail.Rule{
90+
Label: "Allow all MCP writes", Effect: guardrail.EffectAllow, NodeTypeID: mcpWriteGuardrailKind,
91+
}); err != nil {
92+
t.Fatalf("CreateRule: %v", err)
93+
}
94+
95+
argsJSON, err := marshalArgs(importToolArgs{JSON: stripJSONIDField(t, exported)})
96+
if err != nil {
97+
t.Fatalf("marshalArgs: %v", err)
98+
}
99+
res, err := m.gateWrite("import_workflow", "allowed by rule", argsJSON)
100+
if err != nil {
101+
t.Fatalf("gateWrite() with an allow rule in place: %v", err)
102+
}
103+
if res == nil || res.IsError {
104+
t.Fatalf("gateWrite() result = %+v, want a successful (non-error) result", res)
105+
}
106+
if got := len(comp.Workflows()); got != before+1 {
107+
t.Errorf("workflow count = %d, want %d -- an allow-rule verdict must execute immediately, no park needed", got, before+1)
108+
}
109+
if pending := m.PendingMCPWrites(); len(pending) != 0 {
110+
t.Errorf("PendingMCPWrites() = %+v, want empty -- an allow-rule verdict must never park", pending)
111+
}
112+
}

internal/services/wiring/wiring.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,21 @@ func publishSystemEventNotification(notif *notificationsvc.NotificationService,
378378
slog.Warn("publish system-event notification", "event", ev.Event, "error", err)
379379
}
380380
}
381+
382+
// WireMillMCPService late-binds every cross-service seam MillMCPService
383+
// needs (docs/adr/0047 §5.4's guardrail wiring included) and starts its
384+
// HTTP listener -- a bind failure is logged, not fatal, since this is
385+
// additive local tooling the rest of the app doesn't depend on to
386+
// function.
387+
func WireMillMCPService(mill *mcpsvc.MillMCPService, settingsService *settingssvc.SettingsService, exec *executionsvc.ExecutionService, atlas *atlassvc.AtlasService, audit *mcpauditsvc.MCPAuditService, guard *guardrailsvc.GuardrailService, addr string, logger *slog.Logger) {
388+
settingsService.SetMCPService(mill)
389+
mill.SetExecutionService(exec)
390+
mill.SetAtlasService(atlas)
391+
mill.SetAuditResolver(audit.ResolveParkedWrite)
392+
mill.SetGuardrailService(guard)
393+
if err := mill.Start(addr); err != nil {
394+
logger.Error("mill MCP server", "error", err)
395+
} else {
396+
logger.Info("mill MCP server listening", "addr", addr)
397+
}
398+
}

main.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -291,15 +291,7 @@ func main() {
291291
// something the rest of the app depends on to function.
292292
millMCPAddr, _ := settingssvc.ResolveMCPAddr(os.Getenv("MILL_MCP_ADDR"), settingsService.MCPAccessAddress())
293293
millMCPService := mcpsvc.NewMillMCPService(millVersion, compositionService, configureService, settingsStore, userdocsFS, mcpAuditService.ServerMiddleware())
294-
settingsService.SetMCPService(millMCPService)
295-
millMCPService.SetExecutionService(executionService)
296-
millMCPService.SetAtlasService(atlasService)
297-
millMCPService.SetAuditResolver(mcpAuditService.ResolveParkedWrite)
298-
if err := millMCPService.Start(millMCPAddr); err != nil {
299-
logger.Error("mill MCP server", "error", err)
300-
} else {
301-
logger.Info("mill MCP server listening", "addr", millMCPAddr)
302-
}
294+
wiring.WireMillMCPService(millMCPService, settingsService, executionService, atlasService, mcpAuditService, guardrailService, millMCPAddr, logger)
303295

304296
agentLoopService := agentloopsvc.NewAgentLoopService(millMCPService) // an MCP client of it, ADR-0035
305297

0 commit comments

Comments
 (0)