Skip to content

feat(profiles): mcpproxy access suggest turns blocked calls into profile changes - #1718

Merged
Dumbris merged 7 commits into
smart-mcp-proxy:mainfrom
james-crowley:feat/access-suggest
Oct 22, 2026
Merged

Dumbris merged 7 commits into
smart-mcp-proxy:mainfrom
james-crowley:feat/access-suggest

Conversation

@james-crowley

Copy link
Copy Markdown
Contributor

Description

Closes #1700. Adds mcpproxy access suggest: it turns the calls your profiles blocked into reviewed profile changes. It follows the plan in the maintainer's comment on the issue.

  • One service. ProfilesService.Suggest does the work. The CLI, GET /api/v1/access/suggest and the suggest operation of the profiles MCP tool only render its result.
  • What it reads. Blocked tool_call and policy_decision records in the audit log over --since (a duration such as 90m, 24h, 7d; default 24h, the retention is the only limit), filtered by --profile / --token.
  • Grouping. By profile, server, tool and block reason, with the count, the tokens and clients involved, and first and last seen. One refusal that left two records with the same request id counts once.
  • Re-check. Each group is run through the access explainer again (profile subject), so a tool that is reachable now drops out. The explainer's own fixes are what is suggested, in its order.
  • Rules from the issue.
    • profile_tier: allow the tool (profile update <p> --add-allow).
    • profile_unannotated: classify it. The tier is never pre-filled (<read|write|destructive>, needs_input: ["tier"]) and it is left out of the patch.
    • profile_server_scope (nested code_execution calls): add the server.
    • profile_rule: reported, never offered for widening; the explainer's allow fix is not shown.
    • profile_code_execution, profile_management: reported with a warning naming the profile field; no command.
    • Blocks with a non-profile cause are only counted ("not caused by a profile"); access explain is the tool for those.
  • Never applies anything. The service has no write path; a test asserts no profile_change record is written, and the CLI only does GETs.
  • Admin only. The route uses the same gate as access explain (requireServerOp(ConfigWrite)) and is classified scopeRefused in the route table guard, so server-scope suggestions (which name servers a profile cannot see) are never shown to a scoped caller.
  • --patch (optional). Adds, per profile, the stored profile with every apply-ready suggestion merged in (tools.allow += …, servers += …). That is mcpproxy's own profile document: the body PUT /api/v1/profiles/{name} takes and a profiles[] entry in the config file. It is not a client permission file for Claude, Codex or similar. The end-to-end test PUTs the returned document unchanged and checks the tool drops out of the next run.
  • Goldens: the four tools/list goldens gain the suggest operation, since and patch on the profiles tool. OAS regenerated. Docs (docs/cli/profile-commands.md, docs/features/profiles.md) and CHANGELOG updated.

Choices I made that you may want different:

  • The issue says "group by (token, server, reason)". I group by profile, server, tool and reason and list the tokens in the row. The fix edits the profile, so two tokens on one profile blocked on the same tool have one fix, and the explainer needs the tool to re-check it. Per-token rows would repeat the same command.
  • Tools are not merged into globs (github:get_*). Every row is one tool.
  • Top-level out-of-scope refusals are recorded without a block_reason (the non-disclosing row), so they never appear here. Only nested profile_server_scope refusals do.
  • I did not touch the Spec 108 parity matrix. It is pinned at 25 rows against the spec table. If you want a row (Web and macOS out with a reason), say so and I will add it with the spec edit.

Testing

  • I have tested these changes locally

  • I have added/updated tests that prove my fix is effective or my feature works

  • All existing tests pass (see below)

  • internal/runtime: grouping, counts, ordering, the window, profile and token filters, request-id dedupe, re-check drop-out, removed profile, deny rule, unannotated, built-ins, server scope, patch merge, nothing written, window parser.

  • internal/httpapi: admin gate (403 for a scoped token), query reaches the service, bad since is 400, 503 without an evaluator, wire shape, route table guard.

  • internal/server: MCP suggest equals the REST data, schema and visibility tests, and an end-to-end test with real refusals from a pinned token, the real explainer and the real REST router.

  • cmd/mcpproxy: query mapping, table output, --patch, -o json, exit 1 on a refusal.

go test ./internal/runtime ./internal/httpapi ./internal/profile ./cmd/mcpproxy pass. In the full ./internal/server run the failures are the ones that also fail on a clean upstream/main here: TestE2E_CredentialsLifecycle_SecretSinks, TestCodeExecClientModeE2E (daemon not ready), the TestBinary* / TestMCPProtocol* tests (they need a built mcpproxy binary) and TestSearchServers_CatalogOrderMatchesTheRESTGolden (passes alone, fails in the full run on main too).

…ile changes (smart-mcp-proxy#1700)

Reads the profile refusals in the audit log, groups them by profile, tool and
block reason, re-checks each group with the access explainer so reachable tools
drop out, and lists the fix. A deny rule is reported, never widened; an
unannotated tool never gets a tier; code_execution and management refusals carry
a warning. Nothing is applied. --patch adds the proposed profile document.

One service (ProfilesService.Suggest); the CLI, GET /api/v1/access/suggest and
the profiles MCP tool's suggest operation render it.
@codecov-commenter

codecov-commenter commented Oct 20, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 85.47486% with 52 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/runtime/profiles_suggest.go 83.71% 26 Missing and 10 partials ⚠️
cmd/mcpproxy/access_cmd.go 90.00% 6 Missing and 4 partials ⚠️
internal/server/mcp_profiles_tool.go 76.47% 2 Missing and 2 partials ⚠️
internal/httpapi/profiles_views.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

Pushed a merge of main onto your branch with the OpenAPI artifacts regenerated (make swagger), since the only conflict was the generated oas/docs.go. No change to your code. Merging once CI is green on the new head.

…rent deny rule, drains the stream on cancel

- A built-in refusal (code_execution, management_tools) drops out once the
  profile setting is on, like any other already-fixed group.
- A tool the fresh explanation stops at a deny rule for is reported, never
  offered the explainer's allow fix, whatever the recorded reason was.
- An early return on context cancellation drains StreamActivities so its
  producer ends its BBolt read transaction instead of blocking forever.
@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

Thanks for following the plan from #1700 so closely; the service/renders split and the per-group re-explain are exactly what I wanted. I verified the branch on a merge with main and cross-reviewed it, and pushed one small commit (6e5b959) for three things the review turned up, rather than send you back for them:

  • Built-in refusals never dropped out. A code_execution / management_tools group was reported for the whole window even after the profile setting had been switched on. It now counts as already_fixed like any other group (EffectiveCodeExecution(), explicit management_tools: true).
  • A current deny rule could still get an allow patch. A group recorded as profile_tier for a tool that a tools.deny rule has blocked since was offered the explainer's allow fix, which cannot help against a rule. The deny-rule note now keys on the fresh explanation (first failure at tool_rule with no classify fix), not only on the recorded reason.
  • Cancellation left the activity stream's producer blocked. StreamActivities sends from inside a BBolt read transaction into a 100-record buffer, so returning early on ctx.Err() with records left kept that transaction open for the life of the process. The early return now drains the stream. (The same pattern exists in the older profiles usage path; I will take that one separately.)

Three tests cover them. Merging once CI is green on the new head.

A day count past 3650 overflowed time.Duration: 300000d wrapped to about
86 days and was accepted, 200000d wrapped negative and was refused with a
misleading message.
@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

And one tiny follow-up (afda7f6): the <n>d form of --since is bounded at 3650d. Past that it overflowed time.Duration (300000d became about 86 days and was accepted; 200000d went negative and got the wrong error).

@Dumbris Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified on a merge with main: build, CI lint config, race tests for runtime, httpapi, cmd/mcpproxy and the profile/access/suggest tests in internal/server; admin gate proven for the MCP operation and the REST route against agent tokens, a pinned token and no auth; cross-reviewed twice. The three review findings are fixed on the branch.

@Dumbris
Dumbris enabled auto-merge (squash) October 22, 2026 11:51
@Dumbris
Dumbris merged commit fa75d60 into smart-mcp-proxy:main Oct 22, 2026
60 of 61 checks passed
@Dumbris

Dumbris commented Oct 22, 2026

Copy link
Copy Markdown
Member

Merged, and #1700 is closed with it. Thank you, @james-crowley! mcpproxy access suggest turns the strict-profile review loop from "read the audit log, run explain, edit the profile" into one command whose output is a reviewed, apply-ready patch, and it never widens anything by itself. Building it as one service with thin CLI, REST and MCP renders is what made the review-time fixes cheap to land.

Contributions like this are what keep MCPProxy moving, and they matter a lot to the project.

One small ask, only if you feel like it: a short public note about your change (a tweet, a LinkedIn post, a line in a community you are part of) helps more people discover MCPProxy. No pressure at all, and I would be glad to repost it.

github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Description

The Build Binaries job in `pr-build.yml` runs `go test ./...` with Go's
default 10-minute per-package timeout. `internal/server` now takes close
to that on a loaded runner, so the job failed on PR #1718 (run
39776808883) with `panic: test timed out after 10m0s` while every test
was passing; the test running at that moment had been going for 10
seconds. The same job passed on the previous head of that PR and on the
run in between, so this is a budget, not a hang.

Set `-timeout 20m` on both `go test` lines, matching what
`unit-tests.yml` already uses. CI-only change.
github-actions Bot pushed a commit that referenced this pull request Oct 22, 2026
## Hazard

`storage.Manager.StreamActivities` walked the activity bucket inside a
BBolt read transaction and sent every matching record into a channel
buffered at 100, with no context or done channel. Any consumer that
stopped reading early — the 24h profile stats loop breaking on
`ctx.Err()`, an activity export whose client disconnected mid-stream —
left the producer blocked on the send for the life of the process,
holding the read transaction and the mmap read lock: `Stats().OpenTxN`
stayed at 1, later writes could not remap the file, and `db.Close` hung
on shutdown. #1718 worked around it in its own consumer with a drain
goroutine (`drainActivities`); the two older consumers still leaked.

## Fix

- `StreamActivities(ctx, filter)` at the source: the producer checks
`ctx.Err()` before every send and selects on `ctx.Done()` around it, so
cancellation returns from `db.View` promptly and closes the channel.
Cancellation is not logged as a stream error. The signature changed
everywhere (storage → runtime → server → httpapi controller interface
and its test doubles); no external callers.
- Consumers pass their request/loop context: the httpapi export cancels
a per-request context on its write-error return, summary and scoped
usage pass `r.Context()`, `Stats24h` and `Suggest` pass their `ctx`.
`Stats24h` no longer caches a truncated rollup after cancellation;
`Suggest` returns the context error after the loop instead of a partial
window (a pre-cancelled context now yields zero records, so the in-loop
check alone would have returned a clean empty result).
- `drainActivities` and its test are removed.

## Tests

- `internal/storage/activity_stream_cancel_test.go`: abandon the stream
after one record with 300 matching records, assert `OpenTxN` returns to
0 and `Close` returns; the no-cancel variant proves the transaction
really is pinned without cancellation (guards the first test against
passing vacuously); pre-cancelled context yields nothing; live context
streams all. The first test hung `Close` for the full timeout before the
fix.
- `TestProfilesService_Stats24hDoesNotCacheTruncatedRollup`,
`TestSuggest_CancelledContextReturnsWithRecordsLeft` (updated).
- Green under `-race`: `internal/storage`, `internal/httpapi`,
`internal/runtime`. `golangci-lint` (CI config, bare and `--build-tags
server`) reports only pre-existing findings in untouched lines.
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.

[Feature]: profile suggest - aggregate blocked calls into reviewable profile fixes

3 participants