Skip to content

feat(mcp-proxy): capture action.target resource for non-file actions#855

Open
ojongerius wants to merge 3 commits into
mainfrom
worktree-issue-852-mcp-resource
Open

feat(mcp-proxy): capture action.target resource for non-file actions#855
ojongerius wants to merge 3 commits into
mainfrom
worktree-issue-852-mcp-resource

Conversation

@ojongerius

Copy link
Copy Markdown
Contributor

Summary

Closes #852.

The dashboard's cross-agent contention graph draws edges from action.target.resource, which until now only the filesystem hook populated (native Read/Write/Edit). MCP/network/API/DB tool calls were captured but carried no structured resource, so two agents hitting the same API endpoint, database table, or repo drew no contention edge — exactly the shared-external-state case the attribution story is about (disclosed in #849/#850).

This populates action.target.{system,resource} for MCP tool calls in obsigna-mcp, so shared-API/DB/repo contention surfaces the same way shared-file contention already does.

What changed

  • New audit.ExtractTarget(serverName, toolName, args) (mcp-proxy/internal/audit/target.go) — derives a stable resource identifier from a tool call's arguments. system is the MCP server name; resource is extracted opportunistically by precedence:
    1. URI/endpoint (url/uri/endpoint) — canonicalised to scheme://host/path, dropping query and fragment so the same endpoint reached with different parameters resolves to one resource.
    2. Database table (table/collection) — qualified by a neighbouring database/schema/dataset/keyspace key (e.g. analytics.events).
    3. Version-control repo (owner + repoowner/repo) — repo-level granularity, so two agents on the same repo contend even when they target different issues/files.
    4. Generic resource keys (path/key/bucket/object/resource/resource_id). Bare id/name are deliberately excluded as too generic to be reliable contention keys.
  • Wired through emitToContext (mcp-proxy/cmd/obsigna-mcp/main.go) — the target is computed once per tool call, stored on the pending call, and stamped onto emitter.Event.Target for the blocked, denied, and allowed emission paths.

Design notes

  • Best-effort, mirrors the hook. A tool whose arguments match no known shape yields no target, exactly as a non-filesystem tool does in the filesystem hook.
  • All-or-nothing. system and resource are always both set or both empty, satisfying the emitter's existing Target rule.
  • No truncation. A resource longer than the 4096-byte cap is dropped rather than truncated — a truncated identifier would be a misleading contention key.
  • No schema or daemon change. validateFrame and buildAndSign already pass frame target fields through to the signed receipt (added for filesystem in feat: populate action.target.resource from Claude Code hook tool input #784). Dashboard rendering of the new resource types is downstream of this change.

Testing

  • New target_test.go covers every tier, tier precedence, the guards (nil args, empty server, non-string/whitespace values, oversize resource), and the both-set-or-both-empty invariant.
  • Extended TestEmitToContext_AllowedToolCall to assert the target materialises on action.target end-to-end through the daemon.
  • go test ./... and go vet ./... pass for mcp-proxy.

The dashboard's cross-agent contention graph draws edges from
action.target.resource, which only the filesystem hook populated.
MCP/network/API/DB tool calls carried no structured resource, so two
agents hitting the same API or table drew no contention edge.

Add audit.ExtractTarget to derive a stable resource identifier from MCP
tool arguments (URI/endpoint, database table, owner/repo, generic
resource keys) and thread it through emitToContext into
emitter.Event.Target. system is the MCP server name. Extraction is
best-effort: arguments matching no shape yield no target, and the pair
is always both set or both empty, satisfying the emitter's
all-or-nothing Target rule. No schema or daemon change — the daemon
already passes frame target fields through to the signed receipt.

Closes #852
ExtractTarget capped the resource length but returned serverName as the
system unconditionally. The emitter caps Target.System at 256 bytes and
rejects the whole frame when exceeded — so a server name over 256 bytes
would drop the entire receipt, where before it only populated the
uncapped Tool.Server. Guard serverName length too, honouring the
best-effort contract that a target must never cost the audit record.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds best-effort action.target.{system,resource} extraction for MCP tool calls so the dashboard’s cross-agent contention graph can key non-file actions (API endpoints, DB tables, repos) the same way it already keys filesystem actions.

Changes:

  • Introduces audit.ExtractTarget(serverName, toolName, args) with tiered heuristics (URI → table → repo → generic) and length guards.
  • Wires extracted target through obsigna-mcp’s pending-call tracking and emitToContext, stamping it onto emitted events for allowed/denied paths.
  • Adds unit tests for the extractor and extends the daemon integration test to assert action.target end-to-end.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
mcp-proxy/internal/audit/target.go Implements target extraction and URI canonicalisation logic.
mcp-proxy/internal/audit/target_test.go Adds coverage for all tiers, precedence, and guardrails.
mcp-proxy/cmd/obsigna-mcp/main.go Computes/stores target once per tool call and forwards into emitted events.
mcp-proxy/cmd/obsigna-mcp/main_test.go Updates helper test to match new emitToContext signature.
mcp-proxy/cmd/obsigna-mcp/emitter_integration_test.go Asserts action.target materialises in receipts.
daemon/CHANGELOG.md Documents the new behavior in the unified release train changelog.

Comment thread mcp-proxy/internal/audit/target.go
Comment on lines +120 to +124
for _, k := range keys {
if v := stringValue(args[k]); v != "" {
return v
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping exact-match here, deliberately. Flattening args into a lowercased map would make the resource nondeterministic: an object carrying both url and URL (distinct JSON keys) would resolve by Go's randomised map-iteration order, and a contention key has to be stable across agents to draw a correct edge. The case-insensitive precedent in classifier.go is for risk scoring (order-independent boolean), where nondeterminism is harmless; this is an identity field where it isn't. In practice MCP servers emit lowercase snake_case argument keys (e.g. github-mcp-server's owner/repo/path), so the miss rate is negligible. If a real server surfaces URL-style keys we can add that specific spelling to the key lists rather than take on the determinism risk.

canonicalURI rebuilt scheme://host/path even when url.Parse left the
scheme empty (scheme-relative "//host/path"), yielding a malformed
"://host/path". Bail to the trimmed raw value when either scheme or
host is absent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Capture a structured resource for non-file actions (MCP/API/DB) so cross-agent contention draws edges

2 participants