Skip to content

Commit ac34dcb

Browse files
authored
Merge pull request #154 from LAA-Software-Engineering/feat/125-mcp-safety-discovery
feat(mcp): wire tool discovery to spec.safety via meta.mcp_flags
2 parents e44c55c + 1de4372 commit ac34dcb

15 files changed

Lines changed: 1016 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2121
- **`spec.safety` on Tool resources** (issue #103): optional `trusted`, `sideEffects`, and `requiresApproval` fields. [NormalizeProjectGraph] materializes fail-closed defaults on load.
2222
- **Policy safety fallback**: when no `approvals.requiredFor` entry matches the exact `uses` string, [policy.Derive] consults resolved safety metadata. Unattended mutating tools require `--approve` (exit code **5**, `approval_required`).
2323
- **Plan risk hints** for tools that will require approval at run, including decision source (`explicit_policy_rule`, `safety_metadata`, `fail_closed_default`).
24+
- **MCP tool safety discovery** (issue #125): during config resolution, MCP `tools/list` descriptors supply `meta.mcp_flags` (`trusted`, `side_effects`, `requires_approval`) merged into `spec.safety` via [spec.SafetyFromMCPMeta] and [spec.MergeToolSafety]. Author-set YAML fields override MCP per field; discovery failures fall back to fail-closed defaults and emit validate-time warnings on [config.ResolvedConfig.MCPDiscoveryWarnings].
2425

2526
### Changed
2627

@@ -39,7 +40,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3940
```
4041
2. For tools where you accept **tool-wide** unattended use but still gate specific operations, set `trusted: true` and list write operations under `Policy.spec.approvals.requiredFor` (exact `uses` strings).
4142
3. Do **not** set `trusted: true` unless you intend every operation on that tool to run without safety-derived approval; per-action gating remains `requiredFor` only (exact match at runtime).
42-
43-
### Not yet wired
44-
45-
- MCP discovery does **not** yet apply [spec.SafetyFromMCPMeta] / [spec.MergeToolSafety]; author-set `spec.safety` in YAML is the source of truth until MCP merge lands (tracked separately from #103).
43+
4. For **MCP tools** that rely on server-provided `meta.mcp_flags`, pin explicit `spec.safety` in YAML when you need stable `validate`/`plan`→`run` digests. Resolved-config digests include normalized tool safety; if MCP `tools/list` fails at `plan` time but succeeds at `run` time (or vice versa), effective safety and the digest can change even when project YAML is unchanged (exit **3** drift). `agentctl validate` surfaces non-fatal MCP discovery warnings when listing fails.

internal/cli/validate.go

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/policy"
77
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/render"
88
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools"
910
"github.com/spf13/cobra"
1011
)
1112

@@ -46,7 +47,7 @@ func runValidate(cmd *cobra.Command, args []string, strict bool) error {
4647
}
4748
return NewExitError(ExitValidationError, fmt.Errorf("validation failed: high-severity policy lint findings (--strict)"))
4849
}
49-
if err := writeValidateSuccess(cmd, graph, g, findings); err != nil {
50+
if err := writeValidateSuccess(cmd, graph, g, findings, rc.MCPDiscoveryWarnings()); err != nil {
5051
return err
5152
}
5253
if err := persistSnapshots(rc); err != nil {
@@ -55,7 +56,7 @@ func runValidate(cmd *cobra.Command, args []string, strict bool) error {
5556
return nil
5657
}
5758

58-
func writeValidateSuccess(cmd *cobra.Command, graph *spec.ProjectGraph, g *Global, findings []policy.LintFinding) error {
59+
func writeValidateSuccess(cmd *cobra.Command, graph *spec.ProjectGraph, g *Global, findings []policy.LintFinding, mcpWarnings []tools.MCPDiscoveryWarning) error {
5960
out := cmd.OutOrStdout()
6061
envLabel := g.Env
6162
if envLabel == "" {
@@ -70,19 +71,21 @@ func writeValidateSuccess(cmd *cobra.Command, graph *spec.ProjectGraph, g *Globa
7071
switch g.Output {
7172
case render.FormatJSON:
7273
payload := struct {
73-
Project string `json:"project"`
74-
Environment string `json:"environment"`
75-
ResourceCount int `json:"resourceCount"`
76-
Valid bool `json:"valid"`
77-
Message string `json:"message"`
78-
PolicyLint []policy.LintFinding `json:"policyLint,omitempty"`
74+
Project string `json:"project"`
75+
Environment string `json:"environment"`
76+
ResourceCount int `json:"resourceCount"`
77+
Valid bool `json:"valid"`
78+
Message string `json:"message"`
79+
PolicyLint []policy.LintFinding `json:"policyLint,omitempty"`
80+
MCPDiscoveryWarnings []tools.MCPDiscoveryWarning `json:"mcpDiscoveryWarnings,omitempty"`
7981
}{
80-
Project: projName,
81-
Environment: envLabel,
82-
ResourceCount: n,
83-
Valid: true,
84-
Message: "Validation successful",
85-
PolicyLint: findingsOrNil(findings),
82+
Project: projName,
83+
Environment: envLabel,
84+
ResourceCount: n,
85+
Valid: true,
86+
Message: "Validation successful",
87+
PolicyLint: findingsOrNil(findings),
88+
MCPDiscoveryWarnings: mcpWarningsOrNil(mcpWarnings),
8689
}
8790
return render.WriteJSON(out, payload)
8891
case render.FormatYAML:
@@ -96,6 +99,9 @@ func writeValidateSuccess(cmd *cobra.Command, graph *spec.ProjectGraph, g *Globa
9699
if len(findings) > 0 {
97100
body["policyLint"] = findings
98101
}
102+
if len(mcpWarnings) > 0 {
103+
body["mcpDiscoveryWarnings"] = mcpWarnings
104+
}
99105
return render.WriteYAML(out, body)
100106
default:
101107
p := passPrefix(g)
@@ -117,6 +123,9 @@ func writeValidateSuccess(cmd *cobra.Command, graph *spec.ProjectGraph, g *Globa
117123
if err := writeValidateLintTable(out, g, findings); err != nil {
118124
return err
119125
}
126+
if err := writeValidateMCPDiscoveryWarnings(out, g, mcpWarnings); err != nil {
127+
return err
128+
}
120129
_, err := fmt.Fprintf(out, "\nValidation successful\n")
121130
return err
122131
}
@@ -200,6 +209,32 @@ func findingsOrNil(findings []policy.LintFinding) []policy.LintFinding {
200209
return findings
201210
}
202211

212+
func mcpWarningsOrNil(warnings []tools.MCPDiscoveryWarning) []tools.MCPDiscoveryWarning {
213+
if len(warnings) == 0 {
214+
return nil
215+
}
216+
return warnings
217+
}
218+
219+
func writeValidateMCPDiscoveryWarnings(out fmtWriter, g *Global, warnings []tools.MCPDiscoveryWarning) error {
220+
if len(warnings) == 0 {
221+
return nil
222+
}
223+
mark := "⚠"
224+
if g != nil && g.NoColor {
225+
mark = "*"
226+
}
227+
if _, err := fmt.Fprintf(out, "\nMCP discovery (%d warnings):\n", len(warnings)); err != nil {
228+
return err
229+
}
230+
for _, w := range warnings {
231+
if _, err := fmt.Fprintf(out, "%s %s\n", mark, tools.FormatMCPDiscoveryWarning(w)); err != nil {
232+
return err
233+
}
234+
}
235+
return nil
236+
}
237+
203238
func resourceCount(g *spec.ProjectGraph) int {
204239
if g == nil {
205240
return 0

internal/cli/validate_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,57 @@ func TestValidate_validateOk_strictPasses(t *testing.T) {
203203
t.Fatalf("validate_ok should pass strict lint: %v", err)
204204
}
205205
}
206+
207+
func TestValidate_mcpDiscoveryWarning_advisory(t *testing.T) {
208+
root := t.TempDir()
209+
writeFile(t, filepath.Join(root, "project.yaml"), `apiVersion: agentic.dev/v0
210+
kind: Project
211+
metadata:
212+
name: mcp-warn
213+
spec:
214+
imports:
215+
- tools/
216+
state:
217+
backend: sqlite
218+
dsn: .agentic/state.db
219+
`)
220+
writeFile(t, filepath.Join(root, "tools", "mc.yaml"), `apiVersion: agentic.dev/v0
221+
kind: Tool
222+
metadata:
223+
name: mc
224+
spec:
225+
type: mcp
226+
mcp:
227+
transport: stdio
228+
command: /nonexistent/mcp-binary-for-validate-test
229+
`)
230+
231+
ResetGlobalsForTest()
232+
cmd := NewRootCmd()
233+
var out bytes.Buffer
234+
cmd.SetOut(&out)
235+
cmd.SetErr(&out)
236+
cmd.SetArgs([]string{"-o", "json", "validate", "--project", root, "--no-color"})
237+
if err := cmd.Execute(); err != nil {
238+
t.Fatal(err)
239+
}
240+
var payload struct {
241+
Valid bool `json:"valid"`
242+
MCPDiscoveryWarnings []struct {
243+
Tool string `json:"tool"`
244+
Message string `json:"message"`
245+
} `json:"mcpDiscoveryWarnings"`
246+
}
247+
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
248+
t.Fatal(err)
249+
}
250+
if !payload.Valid {
251+
t.Fatalf("validate should succeed: %s", out.String())
252+
}
253+
if len(payload.MCPDiscoveryWarnings) != 1 {
254+
t.Fatalf("expected MCP discovery warning: %s", out.String())
255+
}
256+
if payload.MCPDiscoveryWarnings[0].Tool != "mc" {
257+
t.Fatalf("tool: %+v", payload.MCPDiscoveryWarnings[0])
258+
}
259+
}

internal/config/resolved.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"context"
45
"crypto/sha256"
56
"encoding/hex"
67
"encoding/json"
@@ -13,6 +14,7 @@ import (
1314
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
1415
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/project"
1516
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
17+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/tools"
1618
)
1719

1820
// ErrResolvedConfigDrift means the resolved config digest differs from the stored snapshot.
@@ -37,11 +39,12 @@ type ResolveOptions struct {
3739
// ResolvedConfig is a frozen snapshot of the fully resolved project configuration.
3840
// Graph returns a defensive copy; treat it as read-only.
3941
type ResolvedConfig struct {
40-
graph *spec.ProjectGraph
41-
root string
42-
env string
43-
statePath string
44-
digest string
42+
graph *spec.ProjectGraph
43+
root string
44+
env string
45+
statePath string
46+
digest string
47+
mcpWarnings []tools.MCPDiscoveryWarning
4548
}
4649

4750
// Graph returns a defensive copy of the resolved, validated project graph.
@@ -88,6 +91,16 @@ func (r *ResolvedConfig) Digest() string {
8891
return r.digest
8992
}
9093

94+
// MCPDiscoveryWarnings returns non-fatal MCP tools/list failures from the last resolve pass.
95+
func (r *ResolvedConfig) MCPDiscoveryWarnings() []tools.MCPDiscoveryWarning {
96+
if r == nil || len(r.mcpWarnings) == 0 {
97+
return nil
98+
}
99+
out := make([]tools.MCPDiscoveryWarning, len(r.mcpWarnings))
100+
copy(out, r.mcpWarnings)
101+
return out
102+
}
103+
91104
// Resolve loads, merges, normalizes, overlays, validates, and fingerprints the effective config.
92105
func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
93106
root, err := filepath.Abs(filepath.Clean(opts.ProjectRoot))
@@ -106,6 +119,8 @@ func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
106119
}
107120

108121
ApplyUserLocalUnder(&graph.Spec, userLocal)
122+
123+
mcpWarnings := tools.ApplyMCPSafetyDiscovery(context.Background(), graph)
109124
spec.NormalizeProjectGraph(graph)
110125

111126
graph, err = spec.ApplyEnvironment(graph, opts.Env)
@@ -134,11 +149,12 @@ func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
134149
}
135150

136151
return &ResolvedConfig{
137-
graph: frozen,
138-
root: root,
139-
env: env,
140-
statePath: statePath,
141-
digest: digest,
152+
graph: frozen,
153+
root: root,
154+
env: env,
155+
statePath: statePath,
156+
digest: digest,
157+
mcpWarnings: mcpWarnings,
142158
}, nil
143159
}
144160

0 commit comments

Comments
 (0)