Skip to content

Commit 1de4372

Browse files
leo-aa88cursoragent
andcommitted
fix(mcp): address PR review — warnings, tests, digest docs
Return MCP discovery warnings from ApplyMCPSafetyDiscovery and surface them on validate (table + JSON). Use per-tool timeouts instead of a shared budget. Add config.Resolve integration tests, digest drift/stability coverage, named MCP meta key constants, and CHANGELOG guidance to pin spec.safety for stable plan→run digests when relying on MCP flags. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9dde1a3 commit 1de4372

10 files changed

Lines changed: 468 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +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.
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].
2525

2626
### Changed
2727

@@ -40,3 +40,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4040
```
4141
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).
4242
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).
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: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
"os"
1111
"path/filepath"
1212
"strings"
13-
"time"
1413

1514
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
1615
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/project"
@@ -29,9 +28,6 @@ const DefaultStateDSN = ".agentic/state.db"
2928

3029
const resolvedSnapshotRel = ".agentic/resolved-config.json"
3130

32-
// mcpDiscoveryTimeout bounds MCP tools/list during config resolution; failures fall back to fail-closed defaults.
33-
const mcpDiscoveryTimeout = 10 * time.Second
34-
3531
// ResolveOptions selects inputs for the configuration pipeline.
3632
type ResolveOptions struct {
3733
ProjectRoot string
@@ -43,11 +39,12 @@ type ResolveOptions struct {
4339
// ResolvedConfig is a frozen snapshot of the fully resolved project configuration.
4440
// Graph returns a defensive copy; treat it as read-only.
4541
type ResolvedConfig struct {
46-
graph *spec.ProjectGraph
47-
root string
48-
env string
49-
statePath string
50-
digest string
42+
graph *spec.ProjectGraph
43+
root string
44+
env string
45+
statePath string
46+
digest string
47+
mcpWarnings []tools.MCPDiscoveryWarning
5148
}
5249

5350
// Graph returns a defensive copy of the resolved, validated project graph.
@@ -94,6 +91,16 @@ func (r *ResolvedConfig) Digest() string {
9491
return r.digest
9592
}
9693

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+
97104
// Resolve loads, merges, normalizes, overlays, validates, and fingerprints the effective config.
98105
func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
99106
root, err := filepath.Abs(filepath.Clean(opts.ProjectRoot))
@@ -113,9 +120,7 @@ func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
113120

114121
ApplyUserLocalUnder(&graph.Spec, userLocal)
115122

116-
ctx, cancel := context.WithTimeout(context.Background(), mcpDiscoveryTimeout)
117-
defer cancel()
118-
tools.ApplyMCPSafetyDiscovery(ctx, graph)
123+
mcpWarnings := tools.ApplyMCPSafetyDiscovery(context.Background(), graph)
119124
spec.NormalizeProjectGraph(graph)
120125

121126
graph, err = spec.ApplyEnvironment(graph, opts.Env)
@@ -144,11 +149,12 @@ func Resolve(opts ResolveOptions) (*ResolvedConfig, error) {
144149
}
145150

146151
return &ResolvedConfig{
147-
graph: frozen,
148-
root: root,
149-
env: env,
150-
statePath: statePath,
151-
digest: digest,
152+
graph: frozen,
153+
root: root,
154+
env: env,
155+
statePath: statePath,
156+
digest: digest,
157+
mcpWarnings: mcpWarnings,
152158
}, nil
153159
}
154160

0 commit comments

Comments
 (0)