Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5e86e94
test(scope): Spec 105 PR H0 red phase — FR-012 stored-script enumerat…
Dumbris Sep 16, 2026
3cd939f
feat(scope): Spec 105 PR H0 — stored-script enumeration is administra…
Dumbris Sep 16, 2026
2aab29e
fix(scope): PR H0 critique round 1 — gate the REST script listing, sc…
Dumbris Sep 16, 2026
69f3822
fix(scope): PR H0 codex round 1 — scoped script resolution never read…
Dumbris Sep 16, 2026
51ce3c3
test(scope): PR H0 — make the FR-012 tests portable to Windows
Dumbris Sep 16, 2026
2d0d027
fix(scope): PR H0 codex round 2 — administrator Resolve keeps its dir…
Dumbris Sep 16, 2026
39cd1d0
fix(scope): PR H0 codex round 3 — scoped resolution fails closed on a…
Dumbris Sep 16, 2026
fbe5cc7
fix(scope): PR H0 review round 4 — an exactly named script runs on a …
Dumbris Sep 16, 2026
91e901a
fix(scope): PR H0 review round 5 — the scoped resolver answers from a…
Dumbris Sep 16, 2026
4a879bb
test(codescripts): drop the dead Windows skip from the Linux-tagged i…
Dumbris Sep 16, 2026
f3b265d
fix(scope): PR H0 review round 6 — the scoped stored-script index is …
Dumbris Sep 16, 2026
6c46ca9
fix(scope): PR H0 review round 8 — the stored-script index answers on…
Dumbris Sep 16, 2026
ab05427
fix(scope): an unreadable scripts directory is refused as unreadable …
Dumbris Sep 16, 2026
71a4007
fix(scope): PR H0 review round 9 — the index authorizes only once set…
Dumbris Sep 16, 2026
d046008
test(codescripts): exercise the post-open spelling proof with a renam…
Dumbris Sep 16, 2026
f4eec72
fix(scope): PR H0 review round 11 — bind scoped resolution to one dir…
Dumbris Sep 17, 2026
80e28ec
fix(scope): PR H0 review round 13 — unify darwin/Windows onto the sha…
Dumbris Sep 17, 2026
a58fe56
fix(scope): PR H0 — a directory candidate is non-regular on Windows, …
Dumbris Sep 17, 2026
6db4f55
fix(scope): PR H0 review round 14 — Windows path-size off-by-one and …
Dumbris Sep 17, 2026
b57b7f1
test(codescripts): force the directory mtime forward in the Windows r…
Dumbris Sep 17, 2026
fc30954
fix(scope): PR H0 review round 15 — bound the Windows final-path resi…
Dumbris Sep 17, 2026
b3bd123
fix(scope): PR H0 review round 17 — Warm blocks on the shared rebuild…
Dumbris Sep 18, 2026
b48c78c
merge: resolve main into 105-h0-scripts
Dumbris Sep 18, 2026
1f352c2
fix(scope): LanguageMismatchError was the one refusal that ignored di…
Dumbris Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ internal/server/testdata/**/*.golden.json text eol=lf
# Without this Windows checks out CRLF and all three
# TestToolsListSnapshot_MatchesMergeBaseGoldens surfaces fail on \r alone.
internal/server/testdata/toolslist_goldens/*.json text eol=lf
# The frozen baselines (pre099/, pre105/) live one level down, where a single
# `*` does not reach.
internal/server/testdata/toolslist_goldens/**/*.json text eol=lf

# Self-contained verification/QA reports embed base64 PNG screenshots, so a
# single file is multiple MB of "HTML". They are point-in-time artifacts, not
Expand Down
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ graph LR
| Web UI + macOS app UX audit | In progress | P0 | — | | |
| Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | — | [081-release-qa-gate](./specs/081-release-qa-gate/) | |
| Action log / transparency — info at a glance | In progress | P1 | — | | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 12/109 (11%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 18/109 (17%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | |
| Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | |
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
Expand Down Expand Up @@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) |
| [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) |
| [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 12/109 (11%) |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 18/109 (17%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 100/126 (79%) |
6 changes: 4 additions & 2 deletions cmd/mcpproxy/code_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -607,8 +607,10 @@ func outputResult(result *cliclient.CodeExecResult) error {
func outputResultFromMCP(result *mcp.CallToolResult) error {
// A tool ERROR is plain text, not the execution envelope — and for a stored
// script that text is the recovery path: naming one that does not exist
// answers with the available names (FR-004). Parsing it as JSON and giving
// up ("unexpected result format") threw that away.
// answers with the available names (FR-004) because the CLI authenticates
// with the admin API key (an agent token would get the non-disclosing
// form, Spec 105 FR-012). Parsing it as JSON and giving up ("unexpected
// result format") threw that away.
if result.IsError {
for _, content := range result.Content {
if textContent, ok := mcp.AsTextContent(content); ok {
Expand Down
26 changes: 19 additions & 7 deletions docs/code_execution/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,11 +588,14 @@ executed source under `code` and additionally carry `script: "<name>"`.
| Situation | Message (abbreviated) |
|-----------|-----------------------|
| Both or neither of `code` / `script` | `Provide exactly one of 'code' (inline source) or 'script' (the name of a script stored in the 'scripts' directory next to mcpproxy's config file) — not both, not neither.` |
| Unknown name | `stored script "X" not found in <dir>. Available scripts (N): a, b, c …` |
| No scripts at all | `stored script "X" not found: no stored scripts in <dir> (create X.js or X.ts there)` |
| Unknown name (administrator) | `stored script "X" not found in <dir>. Available scripts (N): a, b, c …` |
| No scripts at all (administrator) | `stored script "X" not found: no stored scripts in <dir> (create X.js or X.ts there)` |
| Unknown name ([agent token](https://docs.mcpproxy.app/features/agent-tokens/), any scope) | `stored script "X" not found (the stored-script listing is available to administrators only; an agent-token caller must already know the script name)` — identical for an empty and a populated directory; the listing is administrator-only |
| Invalid name | `invalid script name "…": character "/" is not allowed …` |
| Both extensions present | `stored script "X" is ambiguous: <dir>/X.js and <dir>/X.ts both exist — remove one` |
| Empty / oversized / unreadable / non-regular | `stored script "X" (<path>) is oversized: scripts are limited to 262144 bytes` |
| Both extensions present (administrator) | `stored script "X" is ambiguous: <dir>/X.js and <dir>/X.ts both exist — remove one` |
| Both extensions present (agent token) | `stored script "X" is ambiguous: both a .js and a .ts file exist — ask an administrator to remove one` — no host path |
| Empty / oversized / unreadable / non-regular (administrator) | `stored script "X" (<path>) is oversized: scripts are limited to 262144 bytes` |
| Empty / oversized / unreadable / non-regular (agent token) | `stored script "X" is oversized: scripts are limited to 262144 bytes` — the reason stays, the host path and any raw OS error are withheld |
| `language` contradicts the extension | `stored script "X" is a .ts file (typescript) but language "javascript" was requested …` |

The not-found error **is** the MCP discovery mechanism (FR-004): it lists the
Expand Down Expand Up @@ -633,9 +636,9 @@ never re-sends a request that cannot succeed:
| Situation | Status | `error.code` |
|-----------|--------|--------------|
| `enable_code_execution` is `false` | 403 | `FEATURE_DISABLED` |
| Unknown script name (carries the available names) | 404 | `SCRIPT_NOT_FOUND` |
| Unknown script name (carries the available names for an administrator; an agent token gets the non-disclosing message) | 404 | `SCRIPT_NOT_FOUND` |
| Invalid script name | 400 | `INVALID_SCRIPT_NAME` |
| Ambiguous, empty, oversized, unreadable or non-regular | 400 | `SCRIPT_UNUSABLE` |
| Ambiguous, empty, oversized, unreadable or non-regular (an agent token gets the path-free message) | 400 | `SCRIPT_UNUSABLE` |
| `language` contradicts the extension | 400 | `INVALID_LANGUAGE` |
| Execution fault (pool, storage, internal) | 500 | `EXECUTION_FAILED` |

Expand All @@ -649,7 +652,12 @@ switching the feature off also stops stored scripts from being read from disk.
### REST: `GET /api/v1/code/scripts`

Read-only listing of the stored scripts, using the same API-key auth as the rest
of `/api/v1` (`X-API-Key` header or `?apikey=`):
of `/api/v1` (`X-API-Key` header or `?apikey=`). **Administrator-only**: the
admin API key (and the tray over the local socket) get the listing; an
[agent token](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn)
— whatever its server scope — is refused with `403` and a body that names
nothing about the directory, because this listing is exactly the enumeration
the missing-script error withholds from a scoped caller:

```bash
curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts
Expand Down Expand Up @@ -682,6 +690,10 @@ curl -H "X-API-Key: $MCPPROXY_API_KEY" http://127.0.0.1:8080/api/v1/code/scripts
An absent or empty directory returns an empty `scripts` list, not an error.
Statuses are advisory — the tool re-checks at invocation time.

```json
{"success": false, "error": "Agent tokens cannot list stored scripts (the stored-script listing is available to administrators only)"}
```

**There is no write surface.** No endpoint, tool, or CLI verb creates, updates,
or deletes a script; the filesystem is the sole authoring interface.

Expand Down
14 changes: 10 additions & 4 deletions docs/code_execution/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,16 @@ Things to know when converting a recipe:
- **Edit by atomic replace** (write a temp file, `mv` it over) and the next
invocation runs the new content — no daemon restart, so the authoring loop is
still "edit, rerun".
- **Discovery** is the not‑found error: naming a script that does not exist
returns the available names (first 20 alphabetically, plus the total), so an
agent never needs the list out of band. `mcpproxy code scripts list` shows the
full set, including `ambiguous` and `invalid` entries.
- **Discovery is administrator‑only**: for an administrator (admin API key,
tray, in‑process caller) naming a script that does not exist returns the
available names (first 20 alphabetically, plus the total). An
[agent token](https://docs.mcpproxy.app/features/agent-tokens/) must already
know the name — its not‑found error lists nothing, so hand the agent the
script names out of band (or in its custom instructions). `mcpproxy code
scripts list` shows the full set, including `ambiguous` and `invalid` entries.
- **Scripts are published content**: whoever can run one sees whatever it
returns without an upstream call; only its `call_tool()` calls are
scope‑checked. Keep server names and secrets out of script source.
- **Read‑only surface**: nothing writes scripts for you — no tool, no endpoint,
no CLI verb. Authoring is the filesystem, deliberately.

Expand Down
88 changes: 83 additions & 5 deletions docs/code_execution/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@ JS
mv "$tmp" ~/.mcpproxy/scripts/fetch-prs.js # atomic within the same filesystem
```

Adding or deleting a file is reflected on the next invocation or listing.
Adding or deleting a file is reflected on the next invocation or listing
(for an agent token on Linux, after the next index refresh — see
[Discovering script names](#discovering-script-names)).
Editing a script **in place** while it is being invoked is the one unsupported
case: the run gets whatever the read returned (validated, but unspecified).

Expand All @@ -376,17 +378,93 @@ mcpproxy code scripts list -o json # {"dir": "...", "scripts": [{"name","paths"
curl -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/v1/code/scripts
```

Both are administrator views: the REST listing answers only the admin API key
(or the tray over the local socket) and refuses an agent token with `403`.

MCP clients do not get a listing tool — registrations are static, so an embedded
list would go stale. Discovery is **error-driven** instead: invoking a name that
does not exist returns an error listing the first 20 available names
alphabetically plus the total, so an agent recovers the current name set from a
single failed call.
list would go stale. For **administrators** (the admin API key, the tray over the
local socket, an in-process caller — and, under the default
`require_mcp_auth: false`, an unauthenticated `/mcp` client, which the proxy
treats as an administrator for backward compatibility) discovery is
**error-driven** instead:
invoking a name that does not exist returns an error listing the first 20
available names alphabetically plus the total, so the current name set is
recovered from a single failed call.

```text
Cannot execute stored script: stored script "fetch-pr" not found in
/Users/me/.mcpproxy/scripts. Available scripts (3): daily-report, fetch-prs, triage
```

**Enumeration is administrator-only.** An
[agent token](https://docs.mcpproxy.app/features/agent-tokens/) — whatever its
server scope, even `--servers "*"` — must already know the script name. Its
not-found error names neither the other stored scripts, nor how many there are,
nor the directory, and it is byte-for-byte the same whether the directory is
empty or full, so a failed call cannot be used to probe what is stored — and
the proxy does not read the directory on its behalf at all — it probes the
requested name's two candidate files and nothing else — so the refusal's cost
does not grow with the number of stored scripts. (On Linux and the BSDs, which
have no single-entry call reporting how a name is spelled on disk, the scoped
resolver answers ONLY from an exact-name index of the directory that matches
its CURRENT state: built when the daemon starts, validated by one stat of
the directory per request, and refreshed by a background rebuild when that
stat finds the directory changed. No request lists the directory, cold or
warm. On Linux/BSD, every step of that per-request check — the stat, the
candidate probe, the open, and the re-check after the open — is bound to
the SAME retained directory descriptor rather than resolving the path
again for each one, so a symlink or bind mount retargeted mid-request
cannot make different steps see different directories. A call landing while
that rebuild is merely scheduled or in flight is
refused exactly like one against a directory the index has never seen —
never answered from what the index held before the change — so a rename
under a scoped caller's feet cannot have that caller's own probe fold onto
whatever now occupies the old name. Beyond that, the index only ever
*authorizes* from a stamp that is provably SETTLED — old enough (about two
seconds, the coarsest directory-timestamp granularity MCPProxy has to assume)
that no filesystem write could still land on it unseen — so a matching
generation is not, by itself, enough to trust a hit; a directory whose
timestamp is younger than that refuses every scoped call, hit or miss alike,
the same fail-closed way. A script added to, or renamed within, the
directory becomes callable by agent tokens once the index has both
refreshed AND settled — typically milliseconds for the refresh, up to about
two seconds to settle; retry a call refused in that window — while
administrators see the change immediately. Every platform — Linux, the
BSDs, darwin and Windows alike — answers from this same index, so a name
that is merely a case-variant of a stored one and a name that is not stored
at all cost the same: both are plain index misses. macOS/darwin adds one
extra, belt-and-suspenders check on top: after the winning candidate is
opened, MCPProxy re-reads its on-disk spelling from the open descriptor
itself (`F_GETPATH`) and compares it to what was requested, so a
case-rename racing the open is caught on the descriptor that would actually
have been read. On Windows every step — probing a candidate, opening it,
listing the directory to refresh the index — is performed relative to ONE
directory handle retained for the whole call (`NtCreateFile` with the
handle as the open's root), so a rename or a reparse point planted on the
directory itself or an ancestor cannot redirect where a "relative" open
actually lands; the post-open check then only needs to confirm the opened
descriptor's own base name (`GetFinalPathNameByHandle`), since the parent
is already structurally guaranteed by the handle-relative open itself. The
refusal itself:

```text
Cannot execute stored script: stored script "fetch-pr" not found (the stored-script
listing is available to administrators only; an agent-token caller must already
know the script name)
```

The same rule covers the other refusals: an ambiguous, empty, oversized or
unreadable script is reported to an agent token by name and reason only — no
host path, no raw OS error — while an administrator sees the full path.

Stored scripts are operator-published content: any caller allowed to run
`code_execution` can run a script it knows the name of and receive whatever the
script returns without an upstream call, while every `call_tool()` the script
makes is still checked against the caller's server scope and permission tier.
Do not put server names, credentials or other secrets in a script's source or
its constant return values — see the
[agent-token invariant](https://docs.mcpproxy.app/features/agent-tokens/#what-a-scoped-token-cannot-learn).

### No write path

Nothing in mcpproxy creates, edits, or deletes a stored script: no MCP tool, no
Expand Down
Loading
Loading