Skip to content

Commit fa75d60

Browse files
feat(profiles): mcpproxy access suggest turns blocked calls into profile changes (#1718)
## 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 - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] 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). --------- Co-authored-by: Algis Dumbris <a.dumbris@gmail.com>
1 parent 1c51f74 commit fa75d60

27 files changed

Lines changed: 1698 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ Releases follow [Semantic Versioning](https://semver.org/).
7979

8080
### Features
8181

82+
- **profiles:** `mcpproxy access suggest` turns the calls your profiles blocked into reviewed profile
83+
changes (#1700). It reads the profile refusals in the audit log (`--since`, default 24h), groups them
84+
by profile, tool and block reason, re-checks each group with the access explainer so tools that are
85+
reachable now drop out, and prints the command that would fix each one. A deny rule is reported and
86+
never offered for widening, an unannotated tool is never given a tier, and `code_execution` and
87+
management refusals carry a warning. Nothing is applied. `--patch` adds the proposed profile
88+
document per profile, in mcpproxy's own profile format. Also `GET /api/v1/access/suggest` and the
89+
`suggest` operation of the `profiles` MCP tool (administrators only).
8290
- **`mcp_reject_query_apikey` refuses query-parameter credentials on MCP endpoints.** Set it to `true` and every MCP endpoint (`/mcp`, `/mcp/p/<slug>`, `/mcp/all`, `/mcp/code`, `/mcp/call` and the legacy `/v1/tool_code` aliases) answers a request carrying `?apikey=` with a 401 instead of honouring it, which forces every client onto `Authorization: Bearer` or `X-API-Key`. It cannot stop a URL that was already sent from being logged by whatever sits in front of the proxy; it makes sure such a client stops working until it is migrated, so the token stops appearing in access logs, proxy logs and browser history from then on. The refusal is explicit rather than "no credential" because, with `require_mcp_auth` off, an unrecognised credential would otherwise be served as the anonymous administrator. Default `false`, so nothing changes unless you opt in; the REST API, the Web UI and its `/events` stream are not affected. Live, no restart. **Caveat:** the Connect wizard writes Codex (and the generic fallback) entries with `?apikey=<credential>` in the URL, so a client connected that way gets a 401 once the option is on: remove the query parameter and carry the credential in a header instead (`env_http_headers` or `http_headers`, see the Connect clients guide); Connect itself is not yet aware of the option.
8391
- **config: `MCPPROXY_API_KEY_FILE` reads the API key from a file (#1706).** The `*_FILE` form of Docker Compose/Swarm secrets, Kubernetes secret volumes and systemd `LoadCredential=`: the key is not in the process environment (`docker inspect`, `/proc/<pid>/environ`) and is never written to `mcp_config.json`. The path must be absolute; one trailing LF or CRLF is removed; at most 64 KiB is read. Setting both `MCPPROXY_API_KEY` and `MCPPROXY_API_KEY_FILE` (both non-empty) is an error: mcpproxy refuses to start with a message naming both variables (never a key or file contents), and the CLI commands and the tray stop the same way. An empty value is treated as unset. A file that is named but cannot be used stops startup with an error naming the variable and path: mcpproxy does not fall back to the config file or a generated key. `mcpproxy status`, the other CLI commands and the tray read it too, and a tray-launched core reads the file itself instead of receiving the key in its environment. `Config.EnsureAPIKey` now also returns an error. (refs #1706, spec 111 FR-014/FR-030/US7)
8492

‎cmd/mcpproxy/access_cmd.go‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func GetAccessCommand() *cobra.Command {
2424
Short: "Explain what a client, token or profile can reach",
2525
}
2626
cmd.AddCommand(newAccessExplainCmd())
27+
cmd.AddCommand(newAccessSuggestCmd())
2728
silenceUsageOnRun(cmd)
2829
return cmd
2930
}
@@ -89,6 +90,139 @@ Examples:
8990
return cmd
9091
}
9192

93+
func newAccessSuggestCmd() *cobra.Command {
94+
var since, token, prof string
95+
var patch bool
96+
cmd := &cobra.Command{
97+
Use: "suggest",
98+
Short: "Suggest profile changes from calls the profiles blocked",
99+
Long: `Read the profile refusals in the audit log, group them by profile, tool and
100+
block reason, and print one reviewed suggestion per group with the command that
101+
would apply it. Each group is re-checked with the access explainer, so a tool
102+
that is reachable now no longer appears.
103+
104+
Nothing is applied. A deny rule is reported and never offered for widening, and
105+
an unannotated tool is never given a tier: you choose it. Blocks that no profile
106+
change fixes (quarantine, token permissions) are counted; use "access explain".
107+
Administrators only.
108+
109+
--since is a duration (90m, 24h, 7d); the default is 24h and the audit-log
110+
retention is the only limit. --patch also prints, per profile, the proposed
111+
profile document. That is mcpproxy's own profile format (what PUT
112+
/api/v1/profiles/{name} takes, and a profiles[] entry in the config file), not a
113+
client permission file.
114+
115+
Examples:
116+
mcpproxy access suggest
117+
mcpproxy access suggest --since 7d --profile work-readonly
118+
mcpproxy access suggest --token ci-bot --patch
119+
mcpproxy access suggest -o json`,
120+
Args: cobra.NoArgs,
121+
RunE: func(_ *cobra.Command, _ []string) error {
122+
q := url.Values{}
123+
q.Set("since", since)
124+
if token != "" {
125+
q.Set("token", token)
126+
}
127+
if prof != "" {
128+
q.Set("profile", prof)
129+
}
130+
if patch {
131+
q.Set("patch", "true")
132+
}
133+
data, err := restDo("access", http.MethodGet, "/api/v1/access/suggest?"+q.Encode(), nil)
134+
if err != nil {
135+
return err
136+
}
137+
if structuredOutput() {
138+
return printData(data)
139+
}
140+
var res runtime.AccessSuggestions
141+
if err := json.Unmarshal(data, &res); err != nil {
142+
return err
143+
}
144+
return printSuggestions(res, patch)
145+
},
146+
}
147+
cmd.Flags().StringVar(&since, "since", "24h", "Look-back window: a duration such as 90m, 24h or 7d")
148+
cmd.Flags().StringVar(&token, "token", "", "Only blocks of this token")
149+
cmd.Flags().StringVar(&prof, "profile", "", "Only blocks under this profile")
150+
cmd.Flags().BoolVar(&patch, "patch", false, "Also print the proposed profile document per profile")
151+
return cmd
152+
}
153+
154+
func printSuggestions(res runtime.AccessSuggestions, patch bool) error {
155+
fmt.Printf("Profile refusals in the last %s: %d\n", res.Window, res.BlockedCalls)
156+
if len(res.Suggestions) == 0 {
157+
fmt.Println("Nothing to suggest.")
158+
} else {
159+
rows := make([][]string, 0, len(res.Suggestions))
160+
for _, s := range res.Suggestions {
161+
rows = append(rows, []string{
162+
s.Profile, s.Tool, string(s.BlockReason), fmt.Sprint(s.Count),
163+
dash(strings.Join(s.Tokens, ",")), s.LastSeen.Local().Format("2006-01-02 15:04"), string(s.Suggestion),
164+
})
165+
}
166+
if err := printTable([]string{"PROFILE", "TOOL", "REASON", "BLOCKED", "TOKENS", "LAST SEEN", "SUGGESTION"}, rows); err != nil {
167+
return err
168+
}
169+
for _, s := range res.Suggestions {
170+
printSuggestionDetail(s)
171+
}
172+
}
173+
var skipped []string
174+
add := func(n int, what string) {
175+
if n > 0 {
176+
skipped = append(skipped, fmt.Sprintf("%d %s", n, what))
177+
}
178+
}
179+
add(res.AlreadyFixed, "group(s) already reachable")
180+
add(res.ProfileRemoved, "group(s) of a removed profile")
181+
add(res.NonProfileBlocked, "blocked call(s) not caused by a profile (see 'mcpproxy access explain')")
182+
add(res.Unattributed, "refusal(s) naming no profile")
183+
if len(skipped) > 0 {
184+
fmt.Println("Not listed: " + strings.Join(skipped, "; ") + ".")
185+
}
186+
if patch {
187+
if len(res.Patches) == 0 {
188+
fmt.Println("No apply-ready profile change (classify needs a tier from you).")
189+
}
190+
for _, p := range res.Patches {
191+
doc, err := json.MarshalIndent(p.Proposed, "", " ")
192+
if err != nil {
193+
return err
194+
}
195+
fmt.Printf("\nProposed profile %s (%s):\n%s\n", p.Profile, strings.Join(p.Changes, "; "), doc)
196+
}
197+
}
198+
return nil
199+
}
200+
201+
// printSuggestionDetail prints the command, note and warning of one row.
202+
func printSuggestionDetail(s runtime.AccessSuggestion) {
203+
var want profile.FixAction
204+
switch s.Suggestion {
205+
case runtime.SuggestAllow:
206+
want = profile.FixAllowInProfile
207+
case runtime.SuggestAddServer:
208+
want = profile.FixAddServerToProfile
209+
case runtime.SuggestClassify:
210+
want = profile.FixClassifyInProfile
211+
}
212+
fmt.Printf("\n%s %s (%s, %d blocked)\n", s.Profile, s.Tool, s.BlockReason, s.Count)
213+
for _, f := range s.Fixes {
214+
if f.Action == want {
215+
fmt.Printf(" %s\n", fixCommand(f, s.Tool))
216+
}
217+
}
218+
if s.Note != "" {
219+
fmt.Printf(" %s\n", s.Note)
220+
}
221+
if s.Warning != "" {
222+
fmt.Printf(" WARNING: %s\n", s.Warning)
223+
}
224+
}
225+
92226
func printExplanation(ex runtime.AccessExplanation) {
93227
subject := string(ex.Subject.Kind)
94228
if ex.Subject.Name != "" {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package main
2+
3+
import (
4+
"net/url"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
// `mcpproxy access suggest` (#1700): the query it sends, what it prints, and
12+
// that it never applies anything.
13+
14+
const suggestResponse = `{
15+
"since":"2026-10-19T12:00:00Z","until":"2026-10-20T12:00:00Z","window":"24h0m0s",
16+
"blocked_calls":9,"already_fixed":1,"profile_removed":0,"non_profile_blocked":2,"unattributed":0,
17+
"suggestions":[
18+
{"profile":"research","tool":"github:create_issue","server":"github","block_reason":"profile_tier","count":5,
19+
"tokens":["ci-bot","dev-bot"],"clients":[],"first_seen":"2026-10-20T08:00:00Z","last_seen":"2026-10-20T11:00:00Z",
20+
"suggestion":"allow","fixes":[{"step":"tier_cap","action":"allow_in_profile","target":"research","label":"Allow github:create_issue in research"}]},
21+
{"profile":"research","tool":"github:search_code","server":"github","block_reason":"profile_unannotated","count":2,
22+
"tokens":["ci-bot"],"clients":[],"first_seen":"2026-10-20T08:00:00Z","last_seen":"2026-10-20T09:00:00Z",
23+
"suggestion":"classify","needs_input":["tier"],"note":"The tool declares no tier. Choose read, write or destructive; no tier is assumed.",
24+
"fixes":[{"step":"tier_cap","action":"classify_in_profile","target":"research","label":"Classify github:search_code in research"}]},
25+
{"profile":"research","tool":"github:delete_repo","server":"github","block_reason":"profile_rule","count":1,
26+
"tokens":["ci-bot"],"clients":[],"first_seen":"2026-10-20T08:00:00Z","last_seen":"2026-10-20T08:00:00Z",
27+
"suggestion":"none","note":"Denied by an explicit rule in the profile. Edit tools.deny if this access is intended.","fixes":[]},
28+
{"profile":"research","tool":"code_execution","block_reason":"profile_code_execution","count":1,
29+
"tokens":["ci-bot"],"clients":[],"first_seen":"2026-10-20T08:00:00Z","last_seen":"2026-10-20T08:00:00Z",
30+
"suggestion":"none","warning":"Setting code_execution on profile research lets scripts call every upstream tool the profile can reach.","fixes":[]}
31+
],
32+
"patches":[{"profile":"research","changes":["tools.allow += github:create_issue"],
33+
"proposed":{"name":"research","servers":["github"],"max_tier":"read","tools":{"allow":["github:create_issue"]}}}]
34+
}`
35+
36+
func TestAccessSuggest_DefaultQuery(t *testing.T) {
37+
rec := newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
38+
_, _, err := runCLI(t, GetAccessCommand, "table", "suggest")
39+
require.NoError(t, err)
40+
q, _ := url.ParseQuery(rec.only(t).RawQuery)
41+
require.Equal(t, url.Values{"since": {"24h"}}, q, "the default window is 24h and no patch is asked for")
42+
}
43+
44+
func TestAccessSuggest_FlagsMapToTheQuery(t *testing.T) {
45+
rec := newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
46+
_, _, err := runCLI(t, GetAccessCommand, "table", "suggest", "--since", "7d", "--profile", "research", "--token", "ci-bot", "--patch")
47+
require.NoError(t, err)
48+
q, _ := url.ParseQuery(rec.only(t).RawQuery)
49+
require.Equal(t, url.Values{"since": {"7d"}, "profile": {"research"}, "token": {"ci-bot"}, "patch": {"true"}}, q)
50+
}
51+
52+
func TestAccessSuggest_TableShowsCommandsNotesAndWarnings(t *testing.T) {
53+
newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
54+
out, _, err := runCLI(t, GetAccessCommand, "table", "suggest")
55+
require.NoError(t, err)
56+
57+
last := time.Date(2026, 10, 20, 11, 0, 0, 0, time.UTC).Local().Format("2006-01-02 15:04")
58+
require.Contains(t, out, "Profile refusals in the last 24h0m0s: 9")
59+
require.Contains(t, out, "github:create_issue")
60+
require.Contains(t, out, "ci-bot,dev-bot")
61+
require.Contains(t, out, last)
62+
require.Contains(t, out, "mcpproxy profile update research --add-allow github:create_issue")
63+
// A tier is never filled in for the operator.
64+
require.Contains(t, out, "mcpproxy profile classify research github:search_code <read|write|destructive>")
65+
require.Contains(t, out, "Denied by an explicit rule")
66+
require.NotContains(t, out, "--add-allow github:delete_repo", "a deny rule is never offered for widening")
67+
require.Contains(t, out, "WARNING: Setting code_execution on profile research")
68+
require.Contains(t, out, "Not listed: 1 group(s) already reachable; 2 blocked call(s) not caused by a profile")
69+
require.NotContains(t, out, "Proposed profile", "the patch is opt-in")
70+
}
71+
72+
func TestAccessSuggest_PatchPrintsTheProposedProfileDocument(t *testing.T) {
73+
newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
74+
out, _, err := runCLI(t, GetAccessCommand, "table", "suggest", "--patch")
75+
require.NoError(t, err)
76+
require.Contains(t, out, "Proposed profile research (tools.allow += github:create_issue):")
77+
require.Contains(t, out, `"allow": [`)
78+
require.Contains(t, out, `"max_tier": "read"`)
79+
}
80+
81+
func TestAccessSuggest_JSONIsData(t *testing.T) {
82+
newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
83+
out, _, err := runCLI(t, GetAccessCommand, "json", "suggest")
84+
require.NoError(t, err)
85+
require.Equal(t, indented(t, suggestResponse), out)
86+
}
87+
88+
func TestAccessSuggest_NothingToSuggest(t *testing.T) {
89+
newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(`{"window":"24h0m0s","suggestions":[],"blocked_calls":0}`)})
90+
out, _, err := runCLI(t, GetAccessCommand, "table", "suggest")
91+
require.NoError(t, err)
92+
require.Contains(t, out, "Nothing to suggest.")
93+
}
94+
95+
func TestAccessSuggest_RefusalIsExit1(t *testing.T) {
96+
newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": refuse(400, `{"success":false,"error":"invalid since \"soon\": use a duration such as 90m, 24h or 7d"}`)})
97+
_, _, err := runCLI(t, GetAccessCommand, "table", "suggest", "--since", "soon")
98+
require.Error(t, err)
99+
require.Equal(t, ExitCodeGeneralError, classifyError(err))
100+
require.Contains(t, err.Error(), "invalid since")
101+
}
102+
103+
func TestAccessSuggest_OnlyReadsFromTheDaemon(t *testing.T) {
104+
rec := newRESTRecorder(t, map[string]cannedResponse{"GET /api/v1/access/suggest": okResp(suggestResponse)})
105+
_, _, err := runCLI(t, GetAccessCommand, "table", "suggest", "--patch")
106+
require.NoError(t, err)
107+
for _, r := range rec.all() {
108+
require.Equal(t, "GET", r.Method, "suggest never writes a profile")
109+
}
110+
}

‎docs/cli/profile-commands.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,30 @@ mcpproxy access explain --tool github:create_issue (--client cursor | --token ci
8888

8989
Walks the gates a call meets (credential, profile, server in scope, tool rule, tier cap, token permission, global gate, server state, tool approval), prints the verdict (`allowed`, `blocked`, `hidden`) and the fixes in preference order, each with the command that performs it. The verdict is computed by the same predicate that enforces the call, so it never disagrees with what actually happens. The move-client fix prints `mcpproxy client set-profile <client> <destination-profile>` with the profile it names. Exit code 0 whenever an explanation is produced: the verdict is data.
9090

91+
## `mcpproxy access suggest`
92+
93+
```bash
94+
mcpproxy access suggest [--since 24h] [--profile <p>] [--token <name>] [--patch] [-o json]
95+
```
96+
97+
Turns the calls your profiles blocked into reviewed profile changes. It reads the profile refusals in the audit log over `--since` (a duration such as `90m`, `24h` or `7d`; default `24h`, and the audit-log retention is the only limit), groups them by profile, tool and block reason, and prints one row per group: how many calls, which tokens, when last, and the command that would fix it. Each group is checked again with the access explainer first, so a tool that is reachable now no longer appears.
98+
99+
What it will and will not suggest:
100+
101+
| Block reason | Suggestion |
102+
|--------------|------------|
103+
| `profile_tier` | `profile update <p> --add-allow <server:tool>` |
104+
| `profile_unannotated` | `profile classify <p> <server:tool> <read\|write\|destructive>`. The tier is yours to choose; none is assumed. |
105+
| `profile_server_scope` | `profile update <p> --add-server <server>` (a `code_execution` script called a server outside the profile) |
106+
| `profile_rule` | Reported, never offered for widening. A deny rule is a decision someone wrote. |
107+
| `profile_code_execution`, `profile_management` | Reported with a warning naming the profile field (`code_execution`, `management_tools`); no command. |
108+
109+
Only profile changes are suggested. Calls blocked for another reason (quarantine, token permissions, a global gate) are counted under "Not listed"; use `access explain` for those. Nothing is applied: you run the commands yourself. Administrators only.
110+
111+
`--patch` also prints, per profile, the stored profile with every apply-ready suggestion merged in. That document is mcpproxy's own profile format: the body `PUT /api/v1/profiles/<name>` takes and the shape of a `profiles[]` entry in the config file. It is not a permission file for Claude, Codex or any other client; those clients do not decide what a profile allows. A classify suggestion is left out of the document, because it needs a tier from you. With `-o json` the same data (and `patches` when `--patch` is set) is returned as JSON.
112+
113+
The same data is available as `GET /api/v1/access/suggest` (`since`, `profile`, `token`, `patch`) and as the `suggest` operation of the `profiles` MCP tool.
114+
91115
## `mcpproxy token`
92116

93117
- `token create --profile <p>` pins the token to a profile; its scope comes from the profile. `--servers`/`--permissions` are optional with `--profile`; without it at least one is required (a legacy scope, which prints a hint to prefer a profile). `--profile-pin` still works as a hidden alias and prints a deprecation notice. Names starting with `client-` are reserved for client credentials.

0 commit comments

Comments
 (0)