Skip to content

fix: classify upstream isError results as activity errors and gate config-load admission (#935, #937) - #944

Merged
github-actions[bot] merged 3 commits into
mainfrom
fix/935-937-activity-error-and-config-load-gate
Aug 1, 2026
Merged

fix: classify upstream isError results as activity errors and gate config-load admission (#935, #937)#944
github-actions[bot] merged 3 commits into
mainfrom
fix/935-937-activity-error-and-config-load-gate

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 1, 2026

Copy link
Copy Markdown
Member

Two independent HIGH-severity core fixes, one branch.


#935 — an upstream returning isError: true was recorded as status=success

What was wrong. A tool result carrying isError: true (bad argument value, unknown tool, server-side validation failure) is a failed tool call that arrives over a successful transport hop: err == nil. Every post-dispatch emit site hardcoded "success", so only mcpproxy's own pre-dispatch validation ever produced status=error. Consequences: the tray glance error series and its red ⊗ row markers never fired for the most common real failure, the 24h error counts were low, and a human debugging "why did my tool call not work" saw a green success row.

What changed.

  • New activityStatusForResult() (internal/server/activity_result_status.go) classifies a dispatched result and extracts an error_message from its text blocks, falling back to its structured content and then to a fixed string. The message is capped at 512 bytes on a rune boundary (activity records are persisted and streamed to the UI).
  • Wired into all three dispatch paths: handleCallToolVariant, the legacy handleCallTool, and the direct-routing handler in mcp_routing.go.
  • The paired internal_tool_call record carries the same classification (per (c) in the brief) — matching what the pre-existing err != nil branch already did.
  • ToolCallRecord.Error is mirrored so the tool-call history agrees with the activity log.

Consumers that become correct for free (per (d)): runtime.UsageAggregate.Apply derives per-tool Errors and the timeline's Errors bucket straight from ActivityRecord.Status, so the aggregates and the 24h histogram are fixed by the same change. No aggregate code was touched.

Explicitly preserved (per (a) and (b)): the caller still receives the upstream's isError result verbatim — only the activity classification changed; the pre-dispatch error path is untouched.

Deliberately deferred. The issue asks whether an upstream-reported error should be a distinct status from a proxy-side error. It should, but not here: every consumer (usage aggregate, timeline, tray, REST filters, frontend) switches on the two known values, and a third value would be silently dropped by all of them. That migration needs to move the consumers together. The error_message already carries the upstream's own words, which is what a human debugging the call needs. Rationale is recorded in the file's doc comment.

Known, pre-existing. ActivityFilter.ExcludeCallToolSuccess only drops successful call_tool_* internal records, so a failed dispatch shows both a tool_call row and an internal_tool_call row. That was already true for transport failures; isError calls now join them. Changing the dedup is a UI-behaviour change and was left out of scope.


#937 — servers loaded from mcp_config.json bypassed the trust-mode admission gate

What was wrong. Config.QuarantineDefaultForServer() was invoked from exactly three add-time sites. A server written straight into mcp_config.json reached the upstream manager without passing any of them: admitted unquarantined, tools auto-approved, poisoned description served verbatim — under the default manual trust mode with quarantine_enabled: true. ServerConfig.Quarantined being a plain bool meant an absent quarantined key was indistinguishable from an explicit false.

What changed.

  • ServerConfig.UnmarshalJSON now records whether the parsed document actually carried a quarantined key (unexported bit, never serialized — it describes the document, not the server). Exposed as QuarantineExplicitlySet(). Carried through CopyServerConfig.
  • Runtime.applyConfigLoadAdmissionGate runs inside LoadConfiguredServers, before the storage-save phase, so the gated value is what gets persisted, connected and reported.

Gating rule — a server is held only when both hold:

  1. the config document never stated a quarantined value (writing "quarantined": false is an operator statement and is obeyed), and
  2. the server is not yet recorded in config.db.

Upgrade safety. Condition 2 is the "has already been through admission" signal. Every server an existing user is running already has a config.db record, so an upgrade re-quarantines nothing.

The boundary, stated precisely (also in the code comment and in docs/features/security-quarantine.md): a server present in a hand-written config but absent from config.db is treated as first-seen and gated. That happens after a wiped data directory, or when a config file lands on a machine whose data dir has never seen it — which is exactly the "config files get shared, templated, copied between machines" case the issue calls out, so gating it is intended, not collateral.

Durability. Once gated, the decision is persisted. On the next start the server is in config.db while the file still says nothing, so the un-stated key inherits mcpproxy's own recorded state instead of resolving back to false — without that, the hole would reopen one restart later.

Direction. Both branches are guarded on !sc.Quarantined: the gate can only ever add quarantine, never remove it. A stale storage record can never relax a config (or an in-flight add-path struct) that already asks for quarantine. Un-quarantining stays a user action through Runtime.QuarantineServer, which writes both stores.

Not done. No TPA scan at config-load admission — quarantine-by-default already keeps the tools away from agents, and a synchronous scan would block boot on every first-seen server. Noted in the code comment and the docs.

Not changed: Quarantined stays a bool rather than becoming *bool (the issue's suggested direction). 234 call sites across the repo read it; the presence bit gets the same discrimination with no blast radius.


Verification

#935 — red first

$ go test ./internal/server/ -run 'TestActivityStatusForResult|TestActivityCompletionNeverHardcodesSuccess' -count=1
internal/server/activity_result_status_test.go:96:22: undefined: activityStatusForResult
internal/server/activity_result_status_test.go:107:31: undefined: activityErrorMessageLimit
FAIL	github.com/smart-mcp-proxy/mcpproxy-go/internal/server [build failed]

The AST guard, after the classifier existed but before the call sites were wired:

--- FAIL: TestActivityCompletionNeverHardcodesSuccess (0.01s)
    Messages: these emit a hardcoded success status and therefore record an
              isError:true upstream answer as a success (issue #935):
              mcp.go:2319:100 (emitActivityToolCallCompleted)
              mcp.go:2719:100 (emitActivityToolCallCompleted)
              mcp_routing.go:299:86 (emitActivityToolCallCompleted)

And the end-to-end test — a real mock upstream returning isError: true, driven through client → /mcpcall_tool_read → upstream, asserting on the persisted activity record:

--- FAIL: TestE2E_UpstreamIsErrorRecordedAsActivityError (12.56s)
    Error: Not equal:
           expected: "error"
           actual  : "success"
    Messages: an isError:true upstream answer must be recorded as an error (issue #935)
    Error: "" does not contain "Mars/Olympus"
    Messages: the recorded error_message must carry the upstream's own explanation

#935 — green

$ go test ./internal/server/ -run 'TestActivityStatusForResult|TestActivityCompletionNeverHardcodesSuccess' -count=1
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/server	0.542s

$ go test ./internal/server/ -run 'TestE2E_UpstreamIsErrorRecordedAsActivityError' -count=1
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/server	10.936s

#937 — red first

$ go test ./internal/config/ -run 'TestServerConfig_QuarantineExplicitlySet|...' -count=1
internal/config/quarantine_presence_test.go:56:40: sc.QuarantineExplicitlySet undefined
FAIL	github.com/smart-mcp-proxy/mcpproxy-go/internal/config [build failed]

$ go test ./internal/runtime/ -run 'TestConfigLoadAdmissionGate' -count=1
--- FAIL: TestConfigLoadAdmissionGate_QuarantinesFirstSeenServer (0.20s)
    Messages: a first-seen config-file server under the default manual trust mode
              must be quarantined (issue #937)
    Messages: the in-memory config must agree, or the supervisor will still
              connect it unquarantined
--- FAIL: TestConfigLoadAdmissionGate_QuarantineSurvivesRestart (0.17s)
    Messages: a quarantine recorded in config.db must not be reverted by a config
              file that never mentions the key
FAIL

Exactly the two security-relevant cases were red; the upgrade-safety and opt-out cases passed from the start and stand as regression guards.

#937 — green

$ go test ./internal/runtime/ -run 'TestConfigLoadAdmissionGate' -count=1 -v
--- PASS: TestConfigLoadAdmissionGate_QuarantinesFirstSeenServer (0.17s)
--- PASS: TestConfigLoadAdmissionGate_TrustModeAutoIsNotGated (0.14s)
--- PASS: TestConfigLoadAdmissionGate_QuarantineDisabledGlobally (0.14s)
--- PASS: TestConfigLoadAdmissionGate_ExplicitFalseIsHonoured (0.14s)
--- PASS: TestConfigLoadAdmissionGate_KnownServerIsNotRequarantined (0.15s)
--- PASS: TestConfigLoadAdmissionGate_QuarantineSurvivesRestart (0.15s)
--- PASS: TestConfigLoadAdmissionGate_MatchesAddPathDecision (0.57s)
      (subtests: trust_mode=, manual, scan, auto)
--- PASS: TestConfigLoadAdmissionGate_NeverUnquarantines (0.15s)
PASS
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime	2.049s

MatchesAddPathDecision is the issue's own comparison table as an assertion: for every trust mode, the config-load verdict must equal what Config.QuarantineDefaultForServer gives the add path — which is also the proof that add-path behaviour is unchanged (it is the reference the gate is measured against, and no add-path code was touched).

TestSaveServerSyncFieldCoverage in internal/storage caught the new struct field and was updated with a comment explaining why the parse-time bit is deliberately not persisted.

Full gates

$ ./scripts/run-linter.sh
Running golangci-lint...
0 issues.

$ go test ./internal/server/... -count=1
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/server	537.766s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/server/tokens	1.110s

$ go test ./internal/runtime/... -count=1
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime	100.796s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/configsvc	0.764s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/stateview	0.998s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/supervisor	1.970s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/runtime/supervisor/actor	1.920s

$ go test ./internal/storage/... ./internal/config/... -count=1
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/config	2.807s
ok  	github.com/smart-mcp-proxy/mcpproxy-go/internal/storage	16.488s

Also run clean as blast-radius checks on the new ServerConfig.UnmarshalJSON: ./internal/httpapi/..., ./internal/contracts/..., ./cmd/....

No REST annotations changed, so no make swagger needed; the pre-push hook's "Verify OpenAPI spec is up to date" passed.


Related #935
Related #937

…nfig-load admission

Two independent HIGH-severity core fixes.

activity: an upstream answering isError:true is an error (Related #935)

A tool result carrying isError:true — bad argument value, unknown tool,
server-side validation failure — was recorded with status=success, because
the transport hop returned err==nil and every post-dispatch emit site
hardcoded "success". Only mcpproxy's own pre-dispatch validation produced
status=error, so every failure consumer under-reported: the tray glance
error series and its red row markers never fired for the most common real
failure, and the 24h error counts were correspondingly low.

activityStatusForResult() now classifies the dispatched result and supplies
an error_message drawn from the result's text blocks (or its structured
content), capped at 512 bytes on a rune boundary. Wired into all three
dispatch paths: handleCallToolVariant, the legacy handleCallTool, and the
direct-routing handler. The paired internal_tool_call record carries the
same classification. Usage aggregates and the timeline derive from
ActivityRecord.Status, so they become correct with no further change.

What is returned to the caller is untouched — the isError result is still
forwarded verbatim. The pre-dispatch error path is unchanged.

security: apply the trust-mode admission gate on the config-load path (Related #937)

A server written directly into mcp_config.json never passed through
Config.QuarantineDefaultForServer, which was invoked only from the three
add-time sites. It was admitted unquarantined with its tools auto-approved,
serving poisoned tool descriptions verbatim under the default manual trust
mode with quarantine_enabled:true — contradicting the trust-mode table in
docs/features/security-quarantine.md.

ServerConfig.UnmarshalJSON now records whether the document carried a
"quarantined" key, making absent distinguishable from explicit false, and
LoadConfiguredServers applies the gate before its storage-save phase.

A server is gated only when the key was absent AND the server is not yet in
config.db. Every server an existing user runs already has a config.db
record, so upgrading re-quarantines nothing. The gate only ever adds
quarantine, never removes it.
@codecov-commenter

codecov-commenter commented Aug 1, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/935-937-activity-error-and-config-load-gate

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (22 MB)
  • installer-dmg-darwin-arm64 (20 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 30688558068 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Cross-model review of PR #944 found six ways the gate could be bypassed,
inverted, or raced. All six are fixed with a failing test first.

P1 — any mcpproxy-initiated config save permanently disarmed the gate.
ServerConfig.Quarantined had no omitempty and SaveConfig is a plain
MarshalIndent, so one write of the config file stamped "quarantined": false
onto every server; reading that back set the presence bit and the gate skipped
admission forever — including for servers it had just quarantined, whose
config.db record the next start then overwrote with false. ServerConfig now has
a MarshalJSON that omits the key when the value is false and no document ever
stated it, and always writes it when true.

P2 — a transient storage read failure mass-quarantined everything. A failed
ListUpstreamServers() was flattened into "no servers are known", so every
configured server looked first-seen and was walled off, permanently. The gate
now takes an explicit storage-readable flag and abstains when it is false.

P2 — the gate mutated an already-published config snapshot. It wrote through
the *config.ServerConfig pointers the supervisor and serverEligibleForIndexing
read concurrently, and both hot paths published before gating. The gate now
returns a copy and never writes through its input; configsvc gained a
pre-publish hook so every configuration is gated before any subscriber can
observe it; ApplyConfig gates before its disk write (covering the
RequiresRestart branch, which never reaches LoadConfiguredServers); and the
startup snapshot is gated before supervisor.Start().

P2 — installs already hit by #937 stay live after upgrading, because the buggy
admission left exactly the config.db record the gate reads as vetted. That
tradeoff stays (upgrade safety), but it is no longer silent: startup names the
affected servers in a warning, and the docs carry an "action required" upgrade
section. A human quarantine toggle now records itself as an explicit value in
the config document, so a reviewed server is never reported.

P2 — "quarantined": null counted as an operator statement, contradicting the
codebase's own RFC 7396 null-means-unset convention and letting a templating
tool admit a first-seen server. Null is now absence.

P2 — code_execution is a fourth upstream dispatch path and still classified on
err == nil alone, so an upstream CallToolResult{IsError:true} was stored in
tool-call history as a clean success while the identical call through
call_tool_read recorded the upstream's message. Nested calls now share the
issue #935 classifier; the code_execution wrapper deliberately keeps its own
result.Ok outcome, documented inline.

Related #937
Related #935
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: c64f128
Status: ✅  Deploy successful!
Preview URL: https://82e575b6.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-935-937-activity-error-a.mcpproxy-docs.pages.dev

View logs

@Dumbris

Dumbris commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Cross-model review fixes (round 2)

All six confirmed findings are fixed, each with a failing test written first.

Finding Fix Test
P1 any mcpproxy config save permanently disarms the gate ServerConfig.MarshalJSON omits quarantined when the value is false and no document stated it; always writes it when true TestSaveConfig_DoesNotFabricateQuarantineStatement, TestConfigLoadAdmissionGate_SurvivesMcpproxyOwnSave, TestConfigLoadAdmissionGate_GatesAfterSelfSaveOfNeverAdmittedServer
P2 transient storage read failure mass-quarantines everything gate takes an explicit storageOK flag and abstains; LoadConfiguredServers no longer flattens the error into an empty map TestConfigLoadAdmissionGate_SkippedWhenStorageUnreadable + control ..._FiresWhenStorageReadableAndEmpty
P2 gate mutates an already-published snapshot (race + pre-gate window) gate returns a copy and never writes through its input; new configsvc pre-publish hook gates every config before subscribers can observe it; ApplyConfig gates before its disk write (covers RequiresRestart); startup snapshot gated before supervisor.Start() TestConfigLoadAdmissionGate_DoesNotMutatePublishedSnapshot, TestApplyConfig_GatesFirstSeenServerBeforeSavingToDisk, -race on the suite
P2 installs already hit by #937 stay vulnerable startup warning naming the affected servers + "Upgrading from a release affected by #937 — action required" doc section; a human quarantine toggle now records itself explicitly in the config document so a reviewed server is never reported TestConfigLoadAdmissionGate_WarnsAboutServersAdmittedBeforeTheFix, ..._NoAdvisoryOnCleanInstall, TestQuarantineServer_RecordsTheDecisionInTheConfigDocument
P2 "quarantined": null counts as a statement null is absence, matching the RFC 7396 convention used elsewhere TestServerConfig_QuarantineExplicitlySet/explicit_null_is_NOT_a_statement
P2 code_execution classifies on err == nil only nested dispatches share the #935 classifier for both the in-memory record and the persisted history row; the wrapper deliberately keeps result.Ok, documented inline TestUpstreamToolCaller_RecordsIsErrorResultAsFailure, ..._TransportErrorStillRecorded, ..._StoresUpstreamErrorInHistory

Verification

$ ./scripts/run-linter.sh
Running golangci-lint...
0 issues.

$ go test ./internal/config/... ./internal/runtime/... ./internal/storage/... -count=1
ok  	.../internal/config	2.848s
ok  	.../internal/runtime	103.622s
ok  	.../internal/runtime/configsvc	1.126s
ok  	.../internal/runtime/stateview	0.400s
ok  	.../internal/runtime/supervisor	2.566s
ok  	.../internal/runtime/supervisor/actor	2.312s
ok  	.../internal/storage	22.261s

$ go test ./internal/server/... -count=1
ok  	.../internal/server	546.746s
ok  	.../internal/server/tokens	1.089s

$ go test ./internal/httpapi/... ./internal/contracts/... ./internal/upstream/... -count=1
ok  	(all)

$ go test -race ./internal/runtime/ -run 'TestConfigLoadAdmissionGate|TestApplyConfig_Gates|TestQuarantineServer_Records' -count=1
ok  	.../internal/runtime	6.702s

Deliberately not done

  • A one-time migration that re-quarantines pre-security: servers loaded from mcp_config.json bypass the trust-mode admission gate entirely #937 admissions. Nothing at load time distinguishes "admitted by the bug" from "added correctly and vetted" — config.db has no admission marker and adding one would not help existing rows. Re-quarantining on the marker's absence would hit every legitimately vetted server on upgrade, which is exactly the regression the gate's upgrade boundary exists to avoid. The remediation is the named startup warning plus the documented action.
  • A third activity status for upstream-reported errors. Unchanged from the original PR rationale: every consumer switches on the two known values.

…sponse

config.ServerConfig gained custom MarshalJSON/UnmarshalJSON in the #937
config-load admission gate work. Go promotes those methods to any struct
that embeds it, and api.ServerResponse embeds *config.ServerConfig, so
encoding/json treated the whole wrapper as a Marshaler/Unmarshaler and
delegated to the embedded config:

  - decode failed with "json: Unmarshal(nil *config.Alias)" (the embedded
    pointer is nil before decoding starts), turning every test in
    internal/serveredition/api red;
  - encode silently DROPPED "ownership" and "user_enabled" from every
    server-edition API response, and panicked outright on a nil embedded
    config. No test covered that, so it would have shipped as a silent
    API regression.

Give ServerResponse explicit JSON methods that marshal the embedded
config through its own MarshalJSON — preserving the #937 quarantined
presence semantics exactly — and splice the wrapper fields on top.
ServerResponse is the only type in the repo that embeds ServerConfig;
document the promotion hazard on the config methods so the next one
does not repeat it.

Related #937

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Live QA verified: upstream isError:true now recorded as status=error across all three dispatch paths (with usage aggregates/timeline agreeing), and config-load servers pass the trust-mode admission gate — upgrade-safe (only servers with no quarantined key AND absent from config.db). Post-review fix: explicit ServerResponse JSON methods, after proving the promoted MarshalJSON was silently dropping ownership/user_enabled from every server-edition response.

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.

2 participants