diff --git a/.github/RELEASE_NOTICE.md b/.github/RELEASE_NOTICE.md
index cd35609eb..65f1ba1a5 100644
--- a/.github/RELEASE_NOTICE.md
+++ b/.github/RELEASE_NOTICE.md
@@ -107,3 +107,14 @@ A tenant — anyone who signs in through the team's IdP rather than through the
- **Subject-rebind procedure for a re-created IdP account**: if your IdP re-creates a user's account (new subject, same email), the login is refused (`subject_mismatch`) rather than silently taking over the existing record. An administrator re-arms the binding by disabling the user and then re-enabling them — this arms a single-use, persisted rebind window — and the user's *next successful login* accepts the new subject and rebinds automatically. No other action is needed and no record is deleted.
- **Tenant Web UI**: a signed-in tenant now gets a working dashboard, server list and activity view built entirely from the session cookie and the tenant-allowed routes — no `?apikey=`, no calls to administrator-only or global-state endpoints (`/info`, `/routing`, `/docker/status`, `/connect`, `/stats/tokens`, `/security/overview`, `/onboarding/state`, core `/activity*`, core `/config`); those cards, chips and pages are hidden rather than issued-and-403'd — Settings stays an administrator-only page (its own personal-server and token management live under `/my/servers`, `/my/tokens`). Diagnostics and history use the tenant-scoped `/user/diagnostics` and `/user/activity` endpoints. The `access` map is edited by an administrator through Settings' Raw JSON tab and shown read-only as group chips on the admin server page and on `AdminUsers`.
- No action needed if you do not set `server_edition.access` at all: every tenant keeps seeing every `Shared` server exactly as before this release, on group grants alone. To start restricting tenants by IdP group, add the `access` block — from that point on, only a matching group entry (or `default_servers`) grants a shared server; a present-but-empty block denies every tenant until you populate it. Administrators are unaffected either way.
+
+## Server edition: every authorization decision and tool call now writes an audit line
+
+A new `audit_log` writes one JSON line per pre-dispatch authorization decision and one per completed tool call (`authz`/`tool_call` events, spec 107 FR-012..FR-019), plus one per login/logout attempt (`auth_event`, already covered above). Arguments are never logged in the clear: each line carries `args_sha256`, a SHA-256 over the RFC 8785 (JCS) canonical form of the call's arguments, and `args_bytes`, never the arguments themselves. A quarantined or otherwise hidden server name is written for the operator's own record but is never echoed back to the caller — the audit line and the caller-facing refusal stay separately governed. Nested `code_execution` sub-calls get their own `authz`/`tool_call` pair carrying `parent_id`, so a script that fans out into several upstream tools is fully attributable, not collapsed into one line.
+
+- **Personal edition default: off** (`audit_log.enabled: false`); nothing changes unless you turn it on. **Server edition default: on**, writing to stdout, with one line logged at startup announcing the sink. Set `audit_log.path` to a file instead (rotated: `max_size_mb`/`max_backups`/`max_age_days`/`compress`, defaults 50 MB / 10 / 90 days / compressed) if you want the audit stream off your process's own stdout.
+- **Under the native stdio transport, stdout is JSON-RPC and can never double as the audit sink.** With `audit_log` absent, the server edition silently falls back to `{enabled:false}` and logs one `WARN` (`audit_log.stdout is ignored under the stdio transport; set audit_log.path`) instead of writing audit JSON into the protocol stream. If you **explicitly** set `audit_log.enabled: true, stdout: true` with no `path` under stdio, that is refused, not silently downgraded: startup fails with exit code 4, `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path`.
+- **An unwritable audit path is a boot failure, not a warning.** If `audit_log.path` cannot be opened for append (missing parent directory, permissions), `mcpproxy-server` exits with code 4 and logs `audit_log.path %q cannot be opened for append: %v`. Point the path at a writable location before starting, or use the stdout sink where the transport allows it.
+- The sink is a single mutex-guarded synchronous writer (`plan.md` Complexity Tracking) — a write failure after startup increments an always-on counter (visible in `mcpproxy doctor`) rather than blocking or dropping the request; audit lines are best-effort after boot, guaranteed-writable at boot.
+- `audit_log` is bound at sink construction, so every key under it (`enabled`, `path`, `stdout`, `max_size_mb`, `max_backups`, `max_age_days`, `compress`) requires a restart to take effect; a hot `PATCH`/`apply` is accepted but only applies on the next start.
+- Details: [audit log](https://docs.mcpproxy.app/features/audit-log/).
diff --git a/.github/workflows/release-qa-gate.yml b/.github/workflows/release-qa-gate.yml
index 0d4309bc4..da8a128b3 100644
--- a/.github/workflows/release-qa-gate.yml
+++ b/.github/workflows/release-qa-gate.yml
@@ -109,6 +109,13 @@ jobs:
mkdir -p dist-bin
# Candidate headless core (nogui matches the E2E build; no tray deps).
go build -tags nogui -ldflags "${LDFLAGS}" -o dist-bin/mcpproxy ./cmd/mcpproxy
+ # Server edition (Spec 107 round-3 cross-review finding, PR-D):
+ # test-api-e2e.sh's audit_log sub-test requires ./mcpproxy-server
+ # and hard-FAILS ("Audit log: server-edition binary present") when
+ # it is missing/non-executable rather than skipping — this job
+ # never built it, so every run of suite/api-e2e deterministically
+ # failed that test.
+ go build -tags server,nogui -ldflags "${LDFLAGS}" -o dist-bin/mcpproxy-server ./cmd/mcpproxy
go build -o dist-bin/mcpfixture ./cmd/mcpfixture
go build -o dist-bin/oauthserver ./tests/oauthserver/cmd/server
go build -o dist-bin/release-gate ./cmd/release-gate
@@ -161,6 +168,9 @@ jobs:
chmod +x dist-bin/*
# test-api-e2e.sh expects the built core at ./mcpproxy (unmodified).
cp dist-bin/mcpproxy ./mcpproxy
+ # ...and its audit_log sub-test (Spec 107 PR-D) expects the
+ # server-edition binary at ./mcpproxy-server.
+ cp dist-bin/mcpproxy-server ./mcpproxy-server
- name: Run API E2E suite
run: |
diff --git a/ROADMAP.md b/ROADMAP.md
index a0d997a8d..22a2238b5 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -80,8 +80,8 @@ graph LR
- 🔵 **Release qualification gate (auto-QA matrix blocks the tag)** — In progress · P0
- 🔵 **MCP protocol upgrade to 2026-07-28 revision** — In progress · P1
- 🔵 **Planning/docs truth automation** — In progress · P2
-- 🔵 **Spec 107 server edition SSO front door hardened for real IdPs** — In progress · P2
- 🔵 **Discovery-quality eval harness (Spec 065 second half)** — In progress · P3
+- 🟡 **Spec 107 server edition SSO front door hardened for real IdPs** — In review · P2
- ⚪ **Windows native tray app** — Todo · P2
- ⚫ **Server marketplace** — Todo · P3 · parked
- ⚫ **Audit SIEM integration** — Todo · P3 · parked
@@ -406,59 +406,57 @@ graph LR
-🔵 Spec 107 server edition SSO front door hardened for real IdPs — In progress · P2
+🔵 Discovery-quality eval harness (Spec 065 second half) — In progress · P3
-> Generic OIDC, IdP-group -> server allowlist, attributable JSONL audit line; freeze the latent multiuser/credential-injection code. Research: docs/research/server-edition-2026-09-14 (#1281).
+> IN PROGRESS — 2026-08-31 audit, corrected on cross-model review: both halves of the HARNESS shipped INDEPENDENTLY (not via token-bench-harness), but spec 065 is NOT fully met, so this is not done. FR-009 and SC-005 require CI to FAIL on a discovery regression beyond tolerance; the retrieval-D1 job is continue-on-error on pull requests, so on the PR path it does not fail — eval.yml itself records the promotion to PR-blocking as still open (MCP-742). A second, weaker tension to adjudicate rather than assume: CN-002 asks that scoring never run against a live drifting corpus, and D1 does boot a live mcpproxy serving 7 reference servers — but #931 pinned all seven upstreams to freeze-era versions and the job gates on the exact corpus ID set, so the corpus is reproducible in practice. Decide whether that satisfies CN-002 or whether a committed snapshot is required. Remaining work is therefore the gating promotion, not the harness. The earlier 'superseded / folded into token-bench-harness' framing was wrong on its own terms: token-bench-harness is still unbuilt, so nothing could have been folded into it. Security recall/FP half: cmd/scan-eval, backing the Spec 076/077 gate in eval.yml. Discovery-quality half: the eval.yml retrieval-d1 job boots mcpproxy and scores retrieval_golden_v1.json against a committed baseline at --tolerance 0.05 via the pinned external mcp-eval repo — note continue-on-error is scoped to github.event_name == 'pull_request', so the job is REPORT-ONLY on PRs (npx/uvx fetch flake) and BLOCKING on both the nightly schedule and manual workflow_dispatch runs. Promoting it to PR-blocking after a green soak is still open (MCP-742). NB the workflow's own inline comment says 'blocking on the nightly schedule' and omits workflow_dispatch. A second in-repo implementation lives in bench/: metrics.go defines RecallAtK/NDCGAtK, and the SC-003 recall@5 = 0.68 +/- 0.05 parity gate through the production Bleve index is asserted in bench/armindex_test.go (armindex.go supplies the production index wiring, not the assertion). Kept as a stable depends_on target; do not build a standalone harness.
-Spec: [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/)
+Spec: [065-evaluation-foundation](./specs/065-evaluation-foundation/)
```mermaid
graph LR
- sso_pr_a_freeze_cut["PR-A freeze/cut latent code + config normalis…"]
- sso_pr_b_oidc_front_door["PR-B generic OIDC provider + front door behin…"]
- sso_pr_c_group_allowlist["PR-C one entitlement predicate, group grants,…"]
- sso_pr_d_audit_line["PR-D attributable JSONL audit line + auth_eve…"]
+ discovery_eval_pr_blocking["Promote retrieval-D1 from report-only to PR-b…
MCP-742"]
- sso_pr_a_freeze_cut --> sso_pr_b_oidc_front_door
- sso_pr_b_oidc_front_door --> sso_pr_c_group_allowlist
- sso_pr_c_group_allowlist --> sso_pr_d_audit_line
- classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff;
- classDef in_progress fill:#1f6feb,stroke:#0b3d91,color:#ffffff;
classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff;
- class sso_pr_a_freeze_cut,sso_pr_b_oidc_front_door done;
- class sso_pr_c_group_allowlist in_progress;
- class sso_pr_d_audit_line todo;
+ class discovery_eval_pr_blocking todo;
```
| Task | Status | Refs |
| --- | --- | --- |
-| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | 🟢 Done | #1287 |
-| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | 🟢 Done | #1292 |
-| PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | 🔵 In progress | #1293 |
-| PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | ⚪ Todo | — |
+| Promote retrieval-D1 from report-only to PR-blocking (spec 065 FR-009/SC-005), and adjudicate the CN-002 frozen-corpus question | ⚪ Todo | `MCP-742` |
-🔵 Discovery-quality eval harness (Spec 065 second half) — In progress · P3
+🟡 Spec 107 server edition SSO front door hardened for real IdPs — In review · P2
-> IN PROGRESS — 2026-08-31 audit, corrected on cross-model review: both halves of the HARNESS shipped INDEPENDENTLY (not via token-bench-harness), but spec 065 is NOT fully met, so this is not done. FR-009 and SC-005 require CI to FAIL on a discovery regression beyond tolerance; the retrieval-D1 job is continue-on-error on pull requests, so on the PR path it does not fail — eval.yml itself records the promotion to PR-blocking as still open (MCP-742). A second, weaker tension to adjudicate rather than assume: CN-002 asks that scoring never run against a live drifting corpus, and D1 does boot a live mcpproxy serving 7 reference servers — but #931 pinned all seven upstreams to freeze-era versions and the job gates on the exact corpus ID set, so the corpus is reproducible in practice. Decide whether that satisfies CN-002 or whether a committed snapshot is required. Remaining work is therefore the gating promotion, not the harness. The earlier 'superseded / folded into token-bench-harness' framing was wrong on its own terms: token-bench-harness is still unbuilt, so nothing could have been folded into it. Security recall/FP half: cmd/scan-eval, backing the Spec 076/077 gate in eval.yml. Discovery-quality half: the eval.yml retrieval-d1 job boots mcpproxy and scores retrieval_golden_v1.json against a committed baseline at --tolerance 0.05 via the pinned external mcp-eval repo — note continue-on-error is scoped to github.event_name == 'pull_request', so the job is REPORT-ONLY on PRs (npx/uvx fetch flake) and BLOCKING on both the nightly schedule and manual workflow_dispatch runs. Promoting it to PR-blocking after a green soak is still open (MCP-742). NB the workflow's own inline comment says 'blocking on the nightly schedule' and omits workflow_dispatch. A second in-repo implementation lives in bench/: metrics.go defines RecallAtK/NDCGAtK, and the SC-003 recall@5 = 0.68 +/- 0.05 parity gate through the production Bleve index is asserted in bench/armindex_test.go (armindex.go supplies the production index wiring, not the assertion). Kept as a stable depends_on target; do not build a standalone harness.
+> Generic OIDC, IdP-group -> server allowlist, attributable JSONL audit line; freeze the latent multiuser/credential-injection code. Research: docs/research/server-edition-2026-09-14 (#1281).
-Spec: [065-evaluation-foundation](./specs/065-evaluation-foundation/)
+Spec: [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/)
```mermaid
graph LR
- discovery_eval_pr_blocking["Promote retrieval-D1 from report-only to PR-b…
MCP-742"]
+ sso_pr_a_freeze_cut["PR-A freeze/cut latent code + config normalis…"]
+ sso_pr_b_oidc_front_door["PR-B generic OIDC provider + front door behin…"]
+ sso_pr_c_group_allowlist["PR-C one entitlement predicate, group grants,…"]
+ sso_pr_d_audit_line["PR-D attributable JSONL audit line + auth_eve…"]
+ sso_pr_a_freeze_cut --> sso_pr_b_oidc_front_door
+ sso_pr_b_oidc_front_door --> sso_pr_c_group_allowlist
+ sso_pr_c_group_allowlist --> sso_pr_d_audit_line
- classDef todo fill:#6e7781,stroke:#3d4248,color:#ffffff;
- class discovery_eval_pr_blocking todo;
+ classDef done fill:#1f7a1f,stroke:#0d3d0d,color:#ffffff;
+ classDef in_review fill:#9a6700,stroke:#5c3d00,color:#ffffff;
+ class sso_pr_a_freeze_cut,sso_pr_b_oidc_front_door,sso_pr_c_group_allowlist done;
+ class sso_pr_d_audit_line in_review;
```
| Task | Status | Refs |
| --- | --- | --- |
-| Promote retrieval-D1 from report-only to PR-blocking (spec 065 FR-009/SC-005), and adjudicate the CN-002 frozen-corpus question | ⚪ Todo | `MCP-742` |
+| PR-A freeze/cut latent code + config normaliser + per-owner token cap (US5, US6) | 🟢 Done | #1287 |
+| PR-B generic OIDC provider + front door behind ingress + telemetry v13 (US2, US7) | 🟢 Done | #1292 |
+| PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4) | 🟢 Done | #1293 |
+| PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3) | 🟡 In review | #1296 |
@@ -896,8 +894,8 @@ graph LR
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
| MCP protocol upgrade to 2026-07-28 revision | In progress | P1 | 19/81 (23%) | [058-mcp-2026-upgrade](./specs/058-mcp-2026-upgrade/) | |
| Planning/docs truth automation | In progress | P2 | — | | |
-| Spec 107 server edition SSO front door hardened for real IdPs | In progress | P2 | 100/126 (79%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | |
| Discovery-quality eval harness (Spec 065 second half) | In progress | P3 | — | [065-evaluation-foundation](./specs/065-evaluation-foundation/) | |
+| Spec 107 server edition SSO front door hardened for real IdPs | In review | P2 | 102/126 (81%) | [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | |
| tpa-db: versioned TPA signature database for the offline scanner | Todo | P1 | — | [101-tpa-db](./specs/101-tpa-db/) | |
| Auto routing mode: budget-fitted tool surface per session (spec 104) | Todo | P1 | — | [104-auto-routing-mode](./specs/104-auto-routing-mode/) | |
| Windows native tray app `MCP-43` | Todo | P2 | — | | |
@@ -1038,4 +1036,4 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 12/109 (11%) |
| [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%) |
+| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 102/126 (81%) |
diff --git a/cmd/mcpproxy/audit_boot_test.go b/cmd/mcpproxy/audit_boot_test.go
new file mode 100644
index 000000000..4c8c3898b
--- /dev/null
+++ b/cmd/mcpproxy/audit_boot_test.go
@@ -0,0 +1,106 @@
+//go:build server
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+)
+
+// Spec 107 T108/T109: a typed *config.StartupError classifies as exit code 4
+// through classifyError, even when it wraps an underlying "permission denied"
+// message that would otherwise classify as exit 5 (ExitCodePermissionError)
+// by main.go's string heuristics.
+func TestClassifyError_AuditLogStartupError_ExitCode4(t *testing.T) {
+ wrapped := fmt.Errorf("failed to create server: %w",
+ config.NewStartupError(config.ExitCodeAuditLogError,
+ `audit_log.path "/no/such/dir/audit.jsonl" cannot be opened for append: permission denied`))
+ if got := classifyError(wrapped); got != ExitCodeConfigError {
+ t.Fatalf("classifyError(%v) = %d, want %d (ExitCodeConfigError)", wrapped, got, ExitCodeConfigError)
+ }
+}
+
+// TestAuditSinkConstruction_UnwritablePath_TypedStartupError pins that
+// audit.NewFileSink's own error, once wrapped into a *config.StartupError the
+// way cmd/mcpproxy's serve startup does it (see main.go), carries exit code 4
+// and the exact message text of contracts/config-keys.md.
+func TestAuditSinkConstruction_UnwritablePath_TypedStartupError(t *testing.T) {
+ unwritableDir := filepath.Join(t.TempDir(), "does-not-exist")
+ path := filepath.Join(unwritableDir, "audit.jsonl")
+
+ _, err := audit.NewFileSink(path, 50, 10, 90, true)
+ if err == nil {
+ t.Fatalf("expected NewFileSink to fail for a path under a missing directory")
+ }
+
+ startupErr := config.NewStartupError(config.ExitCodeAuditLogError,
+ fmt.Sprintf("audit_log.path %q cannot be opened for append: %v", path, err))
+ if startupErr.ExitCode != 4 {
+ t.Fatalf("ExitCode = %d, want 4", startupErr.ExitCode)
+ }
+ if got := classifyError(startupErr); got != ExitCodeConfigError {
+ t.Fatalf("classifyError(startupErr) = %d, want %d", got, ExitCodeConfigError)
+ }
+}
+
+// TestEffectiveAuditLog_StdioAbsentBlock_WarnAndDisabled pins the boot-path
+// contract T108 names: with the block absent under the native stdio
+// transport, EffectiveAuditLog resolves to a disabled sink and a warning
+// naming audit_log.path — main.go logs that warning and constructs no sink
+// (verified indirectly: no error, Enabled=false).
+func TestEffectiveAuditLog_StdioAbsentBlock_WarnAndDisabled(t *testing.T) {
+ resolved, warn, err := config.EffectiveAuditLog(&config.Config{}, config.TransportStdio)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if resolved.Enabled {
+ t.Fatalf("expected disabled sink under stdio with an absent block, got %+v", resolved)
+ }
+ if warn == "" {
+ t.Fatalf("expected a WARN naming audit_log.path")
+ }
+}
+
+// TestEffectiveAuditLog_StdioExplicitStdout_NoPath_StartupError pins the
+// exit-4 refusal path end to end through EffectiveAuditLog, mirroring what
+// main.go does before constructing any sink.
+func TestEffectiveAuditLog_StdioExplicitStdout_NoPath_StartupError(t *testing.T) {
+ enabled, stdout := true, true
+ cfg := &config.Config{AuditLog: &config.AuditLogConfig{Enabled: &enabled, Stdout: &stdout}}
+
+ _, _, err := config.EffectiveAuditLog(cfg, config.TransportStdio)
+ if err == nil {
+ t.Fatalf("expected a StartupError")
+ }
+ if got := classifyError(err); got != ExitCodeConfigError {
+ t.Fatalf("classifyError(err) = %d, want %d", got, ExitCodeConfigError)
+ }
+}
+
+// TestEffectiveAuditLog_StdioExplicitPath_FileSink pins that an explicit path
+// under stdio is honoured (a real, writable sink), never refused.
+func TestEffectiveAuditLog_StdioExplicitPath_FileSink(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "audit.jsonl")
+ cfg := &config.Config{AuditLog: &config.AuditLogConfig{Path: path}}
+
+ resolved, _, err := config.EffectiveAuditLog(cfg, config.TransportStdio)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !resolved.Enabled || resolved.Path != path {
+ t.Fatalf("resolved = %+v", resolved)
+ }
+ sink, err := audit.NewFileSink(resolved.Path, resolved.MaxSizeMB, resolved.MaxBackups, resolved.MaxAgeDays, resolved.Compress)
+ if err != nil {
+ t.Fatalf("NewFileSink: %v", err)
+ }
+ defer func() { _ = sink.Close() }()
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected the sink to have created %s: %v", path, err)
+ }
+}
diff --git a/cmd/mcpproxy/main.go b/cmd/mcpproxy/main.go
index 4aafe1470..7e6937295 100644
--- a/cmd/mcpproxy/main.go
+++ b/cmd/mcpproxy/main.go
@@ -38,6 +38,7 @@ import (
bbolterrors "go.etcd.io/bbolt/errors"
"go.uber.org/zap"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/branding"
clioutput "github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
@@ -548,7 +549,57 @@ func runServer(cmd *cobra.Command, _ []string) error {
// Create server with the config path that was actually loaded
actualConfigPath := saver.path
- srv, err := server.NewServerWithConfigPath(cfg, actualConfigPath, logger)
+ // Spec 107 T109: construct the audit sink before the server so a bad
+ // audit_log configuration fails startup with exit code 4 (classifyError,
+ // below) rather than silently running without attribution. Transport is
+ // derived exactly as internal/server.Server.Start does (Listen empty or
+ // ":0" means the native stdio MCP transport, where stdout carries
+ // JSON-RPC and can never double as a log sink - FR-014).
+ transport := config.TransportHTTP
+ if cfg.Listen == "" || cfg.Listen == ":0" {
+ transport = config.TransportStdio
+ }
+ resolvedAudit, auditWarning, err := config.EffectiveAuditLog(cfg, transport)
+ if err != nil {
+ recordStartupOutcome(cfg, actualConfigPath, classifyStartupError(err), saver.save)
+ return err
+ }
+ if auditWarning != "" {
+ logger.Warn(auditWarning)
+ }
+
+ var auditSink audit.Sink
+ if resolvedAudit.Enabled {
+ if resolvedAudit.Path != "" {
+ auditSink, err = audit.NewFileSink(
+ resolvedAudit.Path,
+ resolvedAudit.MaxSizeMB,
+ resolvedAudit.MaxBackups,
+ resolvedAudit.MaxAgeDays,
+ resolvedAudit.Compress,
+ audit.WithFailureLogger(func(werr error) {
+ logger.Warn("audit_log write failed", zap.Error(werr))
+ }),
+ )
+ if err != nil {
+ startupErr := config.NewStartupError(config.ExitCodeAuditLogError,
+ fmt.Sprintf("audit_log.path %q cannot be opened for append: %v", resolvedAudit.Path, err))
+ recordStartupOutcome(cfg, actualConfigPath, classifyStartupError(startupErr), saver.save)
+ return startupErr
+ }
+ } else if resolvedAudit.Stdout {
+ auditSink = audit.NewStdoutSink(os.Stdout,
+ audit.WithFailureLogger(func(werr error) {
+ logger.Warn("audit_log write failed", zap.Error(werr))
+ }),
+ )
+ }
+ }
+ if auditSink != nil {
+ defer func() { _ = auditSink.Close() }()
+ }
+
+ srv, err := server.NewServerWithConfigPath(cfg, actualConfigPath, logger, server.WithAuditSink(auditSink))
if err != nil {
// Spec 042: classify the failure into a startup outcome enum.
recordStartupOutcome(cfg, actualConfigPath, classifyStartupError(err), saver.save)
@@ -910,6 +961,16 @@ func classifyError(err error) int {
return preflightGeneralErr.ExitCode()
}
+ // Spec 107 FR-014: a typed configuration StartupError (e.g. an unwritable
+ // audit_log.path, or audit_log.stdout refused under the stdio transport)
+ // carries its own exit code, matched here BEFORE the string heuristics
+ // below - a wrapped "permission denied" underneath must classify as exit
+ // 4 (config error), never fall through to exit 5.
+ var startupErr *config.StartupError
+ if errors.As(err, &startupErr) {
+ return startupErr.ExitCode
+ }
+
// Check for port conflict errors
var portErr *server.PortInUseError
if errors.As(err, &portErr) {
diff --git a/docs/configuration.md b/docs/configuration.md
index 95f4acfba..886296a37 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -678,6 +678,36 @@ See [OAuth Documentation](mcp-go-oauth.md) for complete details.
- **Auto-Generation**: If no API key is provided, one is generated and logged for easy access
- **Tray Integration**: Tray app automatically manages API keys for core communication
+## Audit Log
+
+One JSONL line per authorization decision and tool call, edition-neutral. Personal
+edition defaults to off; the server edition defaults to on (stdout, unless the native
+stdio transport is in use — see below). See [Audit Log](features/audit-log.md).
+
+```json
+{
+ "audit_log": {
+ "enabled": true,
+ "stdout": false,
+ "path": "/var/log/mcpproxy/audit.jsonl",
+ "max_size_mb": 50,
+ "max_backups": 10,
+ "max_age_days": 90,
+ "compress": true
+ }
+}
+```
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `audit_log.enabled` | boolean | personal: `false`; server: `true` | Turn audit logging on. Restart-pinned — the sink is bound at construction |
+| `audit_log.stdout` | boolean | server: `true` when the block is absent | Write lines to stdout. Env `MCPPROXY_AUDIT_LOG_STDOUT`. Not used under the native stdio transport (stdout carries JSON-RPC); an explicit `stdout: true` with no `path` there fails boot with exit code 4 |
+| `audit_log.path` | string | `""` | File to append lines to (rotated). Env `MCPPROXY_AUDIT_LOG_PATH`. An unwritable path fails boot with exit code 4 |
+| `audit_log.max_size_mb` | int | `50` | Rotate after this size. Must be positive when a path is set |
+| `audit_log.max_backups` | int | `10` | Rotated files to keep. Must be positive when a path is set |
+| `audit_log.max_age_days` | int | `90` | Delete rotated files after this many days. Must be positive when a path is set |
+| `audit_log.compress` | boolean | `true` | gzip rotated files |
+
### Reverse Proxy Deployments (`trusted_hosts`)
When mcpproxy listens on a loopback address (the default `127.0.0.1:8080`), DNS-rebinding
@@ -1631,6 +1661,9 @@ Many configuration options can be overridden via environment variables:
| `MCPPROXY_API_KEY` | `api_key` | API key for authentication (empty values trigger auto-generation; auth remains enabled) |
| `MCPPROXY_TRUSTED_HOSTS` | `trusted_hosts` | Comma-separated `Host` allowlist for loopback listeners behind a reverse proxy |
| `MCPPROXY_TRUSTED_PROXIES` | `trusted_proxies` | Comma-separated CIDRs/IPs whose `X-Forwarded-*` headers are honoured |
+| `MCPPROXY_AUDIT_LOG_ENABLED` | `audit_log.enabled` | Turn audit logging on/off |
+| `MCPPROXY_AUDIT_LOG_PATH` | `audit_log.path` | Audit log file path |
+| `MCPPROXY_AUDIT_LOG_STDOUT` | `audit_log.stdout` | Write audit lines to stdout |
| `MCPPROXY_TLS_ENABLED` | `tls.enabled` | Enable HTTPS/TLS |
| `MCPPROXY_TLS_REQUIRE_CLIENT_CERT` | `tls.require_client_cert` | Enable mTLS |
| `MCPPROXY_CERTS_DIR` | `tls.certs_dir` | Custom certificates directory |
diff --git a/docs/configuration/config-file.md b/docs/configuration/config-file.md
index 2423fe2c9..3133d8783 100644
--- a/docs/configuration/config-file.md
+++ b/docs/configuration/config-file.md
@@ -64,6 +64,25 @@ MCPProxy uses a JSON configuration file located at `~/.mcpproxy/mcp_config.json`
| `require_mcp_auth` | boolean | `false` | Require an API key on the `/mcp` endpoint (off by default for client compatibility). Enable when exposing MCPProxy beyond localhost. **Server edition:** forced to `true` whenever `server_edition.enabled` is `true` — an explicit `false` is not an error, but boot logs `require_mcp_auth: false is overridden to true because server_edition.enabled is true` and `mcpproxy doctor` reports the same finding |
| `enable_socket` | boolean | `true` | Enable Unix socket/named pipe for local communication |
+### `audit_log` (edition-neutral JSONL audit record)
+
+One JSONL line per authorization decision and tool call. Personal edition defaults to
+`{enabled:false}`; the server edition defaults to `{enabled:true, stdout:true}` when the
+block is absent — except under the native stdio transport, where stdout carries the
+MCP JSON-RPC channel and the default resolves to `{enabled:false}` with a startup WARN
+naming `audit_log.path` as the stdio-compatible sink (an *explicit* value always wins).
+See [Audit Log](/features/audit-log) for the line schema and event vocabulary.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `audit_log.enabled` | boolean | see above | Turn audit logging on. Restart-pinned — the sink is bound at construction |
+| `audit_log.stdout` | boolean | server: `true` when the block is absent | Write lines to stdout. Refused under the stdio transport when explicit and no `path` is set: `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path` (exit code 4) |
+| `audit_log.path` | string | `""` | File to append lines to (rotated). An unwritable path fails boot with exit code 4: `audit_log.path %q cannot be opened for append: %v` |
+| `audit_log.max_size_mb` | int | `50` | Rotate after this size. Must be positive when a path is set |
+| `audit_log.max_backups` | int | `10` | Rotated files to keep. Must be positive when a path is set |
+| `audit_log.max_age_days` | int | `90` | Delete rotated files after this many days. Must be positive when a path is set |
+| `audit_log.compress` | boolean | `true` | gzip rotated files |
+
### HTTP Server Timeouts
Deadlines applied to MCPProxy's own HTTP listener (REST API, `/mcp`, `/events`).
diff --git a/docs/configuration/environment-variables.md b/docs/configuration/environment-variables.md
index bbad1e7a5..bd7b1b433 100644
--- a/docs/configuration/environment-variables.md
+++ b/docs/configuration/environment-variables.md
@@ -47,6 +47,16 @@ Environment variables are useful for CI/CD environments or temporary overrides d
**Note:** TLS certificates are managed in `~/.mcpproxy/certs/` or via the `tls.certs_dir` config option. Use `mcpproxy trust-cert` to set up certificates.
+### Audit Log
+
+See [Audit Log](/features/audit-log) and [`audit_log`](./config-file.md#audit_log-edition-neutral-jsonl-audit-record) for the full key set — only `enabled`, `path` and `stdout` have an env override; the rotation/`compress` keys are file-only.
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `MCPPROXY_AUDIT_LOG_ENABLED` | Override `audit_log.enabled` | see `audit_log` default |
+| `MCPPROXY_AUDIT_LOG_PATH` | Override `audit_log.path` | `""` |
+| `MCPPROXY_AUDIT_LOG_STDOUT` | Override `audit_log.stdout` | server: `true` when the block is absent |
+
### OAuth Settings
| Variable | Description | Default |
diff --git a/docs/features/audit-log.md b/docs/features/audit-log.md
new file mode 100644
index 000000000..56c074349
--- /dev/null
+++ b/docs/features/audit-log.md
@@ -0,0 +1,321 @@
+---
+id: audit-log
+title: Audit Log
+sidebar_label: Audit Log
+sidebar_position: 7.5
+description: Attributable, redacted JSONL audit lines for every tool-call authorization decision, tool call and login attempt.
+keywords: [audit, audit-log, jsonl, compliance, siem, server edition]
+---
+
+# Audit Log
+
+The audit log is an edition-neutral, append-only JSONL stream: one JSON object per
+line, written **synchronously** at the funnel that made the decision — never through
+the (droppable, 256-slot) activity event bus. It is a separate, narrower record than
+the [Activity Log](activity-log.md): where the activity log is a rich, queryable
+history read back through REST/CLI, the audit log exists only to answer "who called
+what, and what happened" from an external file or stream, with a stable schema a
+security reviewer signs off once.
+
+There is **no** REST, SSE, MCP, CLI or Web UI surface that reads or lists audit
+lines. The file or stdout stream is the only reader's surface — point a log shipper
+at it.
+
+## What gets audited
+
+Three event kinds, one schema:
+
+| Event | Written | Count |
+|---|---|---|
+| `authz` | Once per pre-dispatch authorization decision (`decision: allow` after the last gate passes and before the upstream call, or `decision: deny` at the refusing gate) | one per decision |
+| `tool_call` | Once per completed dispatch whose `authz` line said `allow` — success, upstream error, a post-dispatch output-sanitisation/schema block, or a limiter shed | one per `authz allow` |
+| `auth_event` | Once per terminal login attempt and once per logout (server edition only) | one per attempt |
+
+Audited surfaces: `call_tool_read`/`call_tool_write`/`call_tool_destructive`,
+direct-name dispatch, nested `code_execution` sub-calls, and the REST
+`/tools/call` / `/code/exec` / replay paths when they reach a `(server, tool)`
+pair. **Not audited in v1**: invocations of built-in tools as such —
+`retrieve_tools`, `describe_tool`, `read_cache`, `set_profile`,
+`upstream_servers`, `quarantine_security`, `list_registries`, `search_servers`,
+`doctor`, and the `code_execution` wrapper call itself. Those have no canonical
+`(server, tool)` pair and keep their existing [activity log](activity-log.md) rows
+unchanged.
+
+A limiter shed is **not** a second authorization decision: admission runs after
+every gate, so the `authz allow` line already exists; the shed is recorded on the
+`tool_call` line (`outcome: rejected`, `reason: limiter_queue_full|limiter_queue_timeout`).
+A post-dispatch output-sanitisation or output-schema block is likewise a
+`tool_call` line (`outcome: blocked`), never a second `authz` line — the call was
+authorized.
+
+### Count invariants
+
+`#authz == #pre-dispatch decisions`; `#tool_call == #authz(decision=allow)`; no
+line for a built-in invocation as such; no `authz`/`tool_call` line ever carries
+`caller.kind: session_user` (tenant sessions cannot reach dispatch — see
+[server-edition SSO hardening](/features/oauth-authentication)); no `authz` line
+carries `outcome` or an `output_*` reason; `#auth_event(surface=login) ==` the
+number of terminal login attempts. These hold even when the activity event bus is
+saturated (2,000 dispatches with the bus at its 256-slot cap still produce 2,000
+`authz` lines and the matching `tool_call` lines) — the audit sink never shares
+the bus's drop-on-full behaviour.
+
+## Configuration
+
+See [Configuration File → `audit_log`](/configuration/config-file#audit_log-edition-neutral-jsonl-audit-record)
+for the full key reference (`enabled`, `path`, `stdout`, `max_size_mb`,
+`max_backups`, `max_age_days`, `compress`) and environment overrides.
+
+Defaults differ by edition, the code does not:
+
+- **Personal edition**: `enabled: false`. A single-operator proxy already keeps
+ the activity log; a second copy of every call by default doubles the PII
+ surface with no consumer.
+- **Server edition, block absent**: `enabled: true, stdout: true, path: ""` —
+ container-native, one startup line states it.
+- **Server edition, `enabled: false` explicit**: nothing is written; a startup
+ warning says attribution is off.
+
+### Docker / stdout
+
+`audit_log.stdout` writes raw JSON lines straight to `os.Stdout`, bypassing the
+coloured console log encoder — the two streams never interleave malformed JSON.
+In the distroless server image, `mcpproxy version`/`doctor` need `--entrypoint`
+(the image has no shell); stdio upstreams remain unavailable there as today.
+
+```bash
+# Tail the audit stream out of a running container
+docker logs -f my-mcpproxy | jq -c 'select(.schema_version == 1)'
+```
+
+### Native stdio transport exception
+
+Under `mcpproxy serve` with no listener, standard output **is** the MCP
+JSON-RPC transport, so the stdout sink can never be used there:
+
+- Block **absent** → the server-edition default resolves to `{enabled: false}`
+ with one `WARN` naming `audit_log.path` as the stdio-compatible sink (only the
+ default is suppressed — an explicit value always wins).
+- Explicit `enabled: true, stdout: true` with **no `path`** → a sink-construction
+ failure: the process exits with code `4` and
+ `audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path`.
+- Explicit `path` (with or without `stdout: true` alongside it) → the file sink is
+ used; a redundant `stdout: true` is dropped with the same `WARN`.
+
+The HTTP transport is unaffected by any of this.
+
+### Sink failure policy
+
+An unwritable `audit_log.path` at boot fails startup with exit code `4` and an
+actionable message (`audit_log.path %q cannot be opened for append: %v`). A
+**runtime** write failure (disk full, permissions revoked, …) never fails the
+call it would have recorded: it increments the `mcpproxy_audit_write_failures_total`
+counter (when metrics are enabled), is surfaced as a `mcpproxy doctor` finding,
+and is logged at most once per minute. There is no `audit_log.strict` mode.
+
+### Crash window
+
+The `authz` line is written **before** the upstream call, so a process killed
+mid-call still leaves the record that a (possibly destructive) call was
+authorized — but the matching `tool_call` line for that one in-flight call is
+lost. This is the one documented gap in the "every decision has a line"
+guarantee: it affects at most the calls in flight at the moment of a crash, never
+completed or subsequent calls.
+
+## Schema (`schema_version: 1`)
+
+The wire format is a JSON Schema (draft 2020-12) checked in at
+[`docs/schemas/audit-line-v1.schema.json`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/schemas/audit-line-v1.schema.json) —
+validate against it directly; the tables below are a human-readable summary of
+the same contract. (Publishing it as a static, versioned URL under
+`docs.mcpproxy.app` is tracked as a docs-site follow-up alongside the sidebar
+entry for this page — see `verification.md`.)
+
+### Common keys (every event)
+
+| Key | Required | Notes |
+|---|---|---|
+| `schema_version` | ✔ | Always `1` |
+| `ts` | ✔ | RFC 3339, UTC (`Z`), fixed nine fractional digits: `2006-01-02T15:04:05.000000000Z` — not `RFC3339Nano`, which trims trailing zeros |
+| `event` | ✔ | `authz` \| `tool_call` \| `auth_event` |
+| `request_id` | ✔ | Activity request id — the join key to `mcpproxy activity show ` / `activity list --request-id ` |
+| `transport_request_id` | optional | REST only; equals `request_id` on REST-originated direct dispatch |
+| `parent_id` | optional | Nested `code_execution` children only — the wrapper's activity request id (a correlation key; the wrapper writes no line of its own) |
+| `session_id`, `work_session_id` | optional | Activity resolver values |
+| `origin` | ✔ | `local` (TCP) \| `socket` (tray) \| `remote` (reserved, not yet emitted) |
+| `source` | ✔ | Mount point: `mcp` (`/mcp*`), `api` (every REST-originated line), `internal` (proxy-originated) — **never** derived from the caller-asserted `X-MCPProxy-Client` header |
+| `caller` | ✔ | `{kind, user_id, user_email, email_hash, role, provider, token_name, token_prefix, profile_pin}` — see [Caller identity](#caller-identity) |
+| `client` | optional | `{name, version}` caller-asserted (MCP `clientInfo` / `X-MCPProxy-Client`) — untrusted; `{ip}` via `trusted_proxies` |
+| `profile` | optional | |
+
+### `authz` — one per pre-dispatch decision
+
+| Key | Required | Notes |
+|---|---|---|
+| `surface` | ✔ | `call_tool_read`\|`call_tool_write`\|`call_tool_destructive`\|`direct`\|`code_execution`\|`rest` |
+| `server`, `tool`, `operation` | ✔ | Canonical `(server, tool)` pair, recorded even on a non-disclosing refusal |
+| `decision` | ✔ | `allow`\|`deny` |
+| `reason` | ✔ | `none` iff `allow`; else one closed pre-dispatch gate — see [Reason vocabulary](#authz-reason-vocabulary) |
+| `disclosed` | required when `deny` | `false` for a non-disclosing refusal (the caller's own response is unchanged) |
+| `args_sha256`, `args_bytes` | ✔ | See [Argument hashing](#argument-hashing-and-redaction) |
+| `parent_id` | optional | Nested children only |
+| Forbidden | — | `outcome`, `error_class`, `duration_ms`, `request_bytes`, `response_bytes`, `flags` — those belong to `tool_call` lines; the `authz` line is written before the call exists |
+
+#### `authz` reason vocabulary
+
+`intent_invalid` · `intent_rejected` · `profile_scope` · `token_scope` ·
+`token_permission` · `server_quarantined` · `tool_pending_approval` ·
+`tool_changed_approval` · `tool_not_callable` · `other`
+
+### `tool_call` — one per `authz allow`, at completion
+
+| Key | Required | Notes |
+|---|---|---|
+| `surface`, `server`, `tool`, `operation`, `args_sha256`, `args_bytes` | ✔ | Same values as the paired `authz` line |
+| `outcome` | ✔ | `success`\|`error`\|`blocked` (post-dispatch output sanitisation/schema)\|`rejected` (limiter shed) |
+| `reason` | required iff `blocked`/`rejected`; forbidden on `success`/`error` | `output_sanitisation`\|`output_schema`\|`limiter_queue_full`\|`limiter_queue_timeout` |
+| `error_class` | required iff `error`; forbidden otherwise | `upstream_error`\|`upstream_timeout`\|`upstream_unavailable`\|`validation`\|`sanitisation`\|`internal`\|`cancelled` — a bounded class, never message text |
+| `duration_ms` | ✔ | |
+| `request_bytes`, `response_bytes` | optional | As measured by the completion path (pre-truncation) |
+| `parent_id` | optional | Nested children |
+| Forbidden | — | `decision`, `disclosed`, `flags` |
+
+### `auth_event` — one per terminal login attempt, one per logout (server edition)
+
+| Key | Required | Notes |
+|---|---|---|
+| `surface` | ✔ | `login`\|`logout` |
+| `reason` | ✔ | The singular terminal result — see [Reason vocabulary](#auth_event-reason-vocabulary) |
+| `flags` | optional | Closed array of non-terminal facts of the same attempt: `provider_rebound`\|`redirect_rejected`\|`groups_claim_missing`; omitted when empty |
+| `caller.user_id` | when the store was reached and a record exists (`ok`, `logout`, `subject_mismatch`, `user_disabled`, `internal_error`) | forbidden on `provider_error` |
+| `caller.email_hash` | only when a verified email is known and the store was **not** yet consulted (`domain_not_allowed`, `userinfo_subject_mismatch`, `provider_error` raised by the userinfo fetch after a verified ID token) | forbidden beside `user_id`/`user_email` — SHA-256 of the normalised email |
+| `caller.kind` | ✔ | `session_user`\|`session_admin`\|`anonymous` (refused before identity) |
+| `client.ip` | optional | Via `trusted_proxies` |
+| Forbidden | — | `server`, `tool`, `operation`, `decision`, `disclosed`, `outcome`, `error_class`, `duration_ms`, `request_bytes`, `response_bytes`, `args_sha256`, `args_bytes`, `parent_id`, `profile` |
+
+Identity is **stage-dependent, not reason-dependent**: pre-identity refusals
+(`state_invalid`, `authorization_denied`, `discovery_failed`, `provider_error`
+from discovery/JWKS/token-exchange, `id_token_invalid`, `nonce_mismatch`,
+`audience_mismatch`, `issuer_mismatch`, `token_expired`, `email_missing`,
+`email_unverified`) carry neither `user_id` nor `email_hash` — an unverified
+claim is never hashed. `provider_error` is the one reason that occurs at two
+stages: before the user store is consulted, or raised by the userinfo fetch
+*after* a verified ID token, in which case it carries `email_hash` of the
+verified email and never `user_id`. An abandoned redirect (pending state that
+never returns) writes no line.
+
+#### `auth_event` reason vocabulary
+
+`ok` · `logout` · `authorization_denied` · `id_token_invalid` · `nonce_mismatch`
+· `audience_mismatch` · `issuer_mismatch` · `token_expired` · `email_missing` ·
+`email_unverified` · `domain_not_allowed` · `subject_mismatch` ·
+`userinfo_subject_mismatch` · `user_disabled` · `state_invalid` ·
+`provider_error` · `discovery_failed` · `internal_error`
+
+## Caller identity
+
+`caller.kind` is derived from `auth.AuthContextFromContext(ctx)` +
+`transport.GetConnectionSource(ctx)` — never from arguments, a caller-supplied
+header, or `_auth_*` request metadata, so it cannot be forged by a caller and
+cannot be dropped under load.
+
+| `caller.kind` | Meaning | Carries | Never carries |
+|---|---|---|---|
+| `api_key` | `X-API-Key` / `?apikey=` administrator | — | `user_id`, `user_email`, `email_hash`, `role`, `provider`, `token_name`, `token_prefix` |
+| `socket` | Tray, over the Unix socket / named pipe | — | same as `api_key` |
+| `stdio` | Native `stdio` transport (administrator context, no listener) | — | same as `api_key` |
+| `anonymous` | `require_mcp_auth: false`, no credential presented | — (except `email_hash` on the three `auth_event` reasons above) | `user_id`, `user_email`, `role`, `provider`, `token_name`, `token_prefix` |
+| `agent_token` | `mcp_agt_…` | `token_name`, `token_prefix`; when owned, all of `user_id`/`user_email`/`role`/`provider` together | `email_hash`; an ownerless token also carries none of `user_id`/`user_email`/`role`/`provider` |
+| `session_user` | Tenant browser/API session | `user_id`, `role: user` — **`auth_event` only**, unreachable for dispatch (FR-002/FR-003) | `email_hash`, `token_name`, `token_prefix` |
+| `session_admin` | Administrator browser/API session | `user_id`, `role: admin` | `email_hash`, `token_name`, `token_prefix` |
+| `internal` | Proxy-originated | — | same as `api_key` |
+
+## Argument hashing and redaction
+
+`args_sha256` is SHA-256 over the RFC 8785 (JSON Canonicalization Scheme)
+serialisation of `security.StripInternalArgs(args)` — members sorted by UTF-16
+code unit, ES6 number serialisation (`1`, `1.0` and `1e0` hash alike, `-0`
+serialises as `0`), minimal escaping, no whitespace, UTF-8 — computed **before**
+any masking or truncation, so it is stable. The implementation is stdlib-only.
+`args_bytes` is the length of that canonical serialisation.
+
+A line never contains: an argument value, a response fragment, a raw token,
+cookie, JWT, API key, `Authorization` header, prose reason text, error message
+text, or any `_auth_*` key. This is a **structural** guarantee — the line is
+built only from the closed key set above, and no key is ever populated from an
+argument, a response, or an error message — not a best-effort filter.
+
+The one documented exception: on a **refused** dispatch, `server` and `tool` are
+caller-supplied strings (the caller may name any `server:tool` pair), so — like
+`client.name`/`client.version`, `token_name` and `profile` — they are sanitised
+per field at build time against the fixed-prefix credential patterns (`AKIA…`,
+`ghp_…`, `Bearer …`, …) plus a length cap, never the generic high-entropy rule
+(which would also mask every legitimate `args_sha256`/`email_hash`). A
+credential-shaped name is recorded masked; a configured name that is not
+credential-shaped is always recorded verbatim. Every serialised line
+additionally passes through the same fixed-prefix sanitizer as defence in
+depth — on a well-formed line this pass is the identity; a hit there means a
+builder bug, is counted (`Sink.SanitizerHits()`, mirrored to
+`mcpproxy_audit_sanitizer_hits_total`, surfaced by `mcpproxy doctor`) and
+logged at most once per minute, and the (masked) line is still written.
+
+Hidden-server names are **recorded, not echoed**: a non-disclosing refusal
+writes the real `server`/`tool` to the line with `disclosed: false`, but the
+caller's own response is unchanged — the audit sink is for the operator, never
+an oracle the caller can query.
+
+## Versioning
+
+`schema_version` is bumped only for a **removed or renamed key, or a narrowed
+vocabulary**. A bump advances three things together: the `schema_version`
+constant, the schema's `$id`, and the published filename
+(`docs/schemas/audit-line-v.schema.json`) — the previous version's file stays
+published for old consumers.
+
+**Adding a key or an enum value is a minor change**: it ships as an updated copy
+of the *same* schema file (`$id` unchanged, `docs/schemas/audit-line-v1.schema.json`)
+with a change-log entry on this page. The published schema is
+consumer-tolerant (`additionalProperties: true`), so a strict consumer holding
+the v1 document keeps validating after a minor additive change under the same
+`$id`.
+
+### Change log
+
+| Version | Change |
+|---|---|
+| 1 | Initial schema (Spec 107 PR-D) |
+
+## Log-shipper example
+
+The audit sink has no built-in connector — point any file/stdout log shipper at
+it and filter on `schema_version`. A minimal, vendor-neutral tail-and-forward
+example (works with Filebeat, Promtail, Vector, Fluent Bit, or a shell pipe
+alike — substitute your shipper's own input stage):
+
+```bash
+# File sink: forward every well-formed audit line as it's appended
+tail -F -n0 /var/log/mcpproxy/audit.jsonl \
+ | jq -c --unbuffered 'select(.schema_version == 1)' \
+ | your-log-forwarder --input -
+
+# stdout sink (e.g. inside Docker/Kubernetes): the container runtime already
+# captures stdout — point your log collector's normal container-log input at
+# it, or pipe a local run the same way:
+docker logs -f my-mcpproxy \
+ | jq -c 'select(.schema_version == 1)' \
+ | your-log-forwarder --input -
+```
+
+Because the schema is closed and versioned, a SIEM/log pipeline can index on
+`event`, `caller.kind`, `decision`/`outcome`, `reason` and `server`/`tool`
+without a proxy-side connector or a vendor-specific export format. The
+[Sensitive Data Detection SIEM recipe](sensitive-data-detection.md#integration-with-siem)
+exports the richer, queryable activity log for the same kind of downstream
+ingestion — the audit log is the narrower, schema-pinned line for attribution.
+
+## Related
+
+- [Configuration File → `audit_log`](/configuration/config-file#audit_log-edition-neutral-jsonl-audit-record) — config keys, defaults and validation
+- [Activity Log](activity-log.md) — the queryable REST/CLI history the audit log intentionally does not duplicate
+- [Sensitive Data Detection](sensitive-data-detection.md) — the separate, asynchronous detector; its verdicts are never carried on an audit line (joined only by `request_id`)
+- [OAuth Authentication](oauth-authentication.md) — the login flow `auth_event` lines record
diff --git a/docs/features/sensitive-data-detection.md b/docs/features/sensitive-data-detection.md
index 8fba4b0fb..2634896b9 100644
--- a/docs/features/sensitive-data-detection.md
+++ b/docs/features/sensitive-data-detection.md
@@ -367,6 +367,11 @@ mcpproxy activity export --format json --output - | \
your-siem-forwarder --input -
```
+For a schema-pinned, redacted, attribution-focused stream instead — one line per
+authorization decision, tool call and login attempt, with no proxy-side read
+surface — see the [Audit Log](audit-log.md), which most SIEM pipelines should
+tail directly rather than polling `activity export`.
+
### Incident Response
When a critical detection is identified:
diff --git a/docs/operations/deploying-for-a-team.md b/docs/operations/deploying-for-a-team.md
new file mode 100644
index 000000000..26e1e9939
--- /dev/null
+++ b/docs/operations/deploying-for-a-team.md
@@ -0,0 +1,401 @@
+---
+id: deploying-for-a-team
+title: Deploying for a Team (Kubernetes + Keycloak)
+sidebar_label: Deploying for a Team
+description: 'Run the server edition (mcpproxy-server) on Kubernetes behind an ingress with Keycloak SSO: single-replica Deployment, group-based access, audit-to-stdout, probes, and the persistent-volume rule for --config/--data-dir.'
+keywords: [kubernetes, deployment, keycloak, oidc, server edition, ingress, public_url, trusted_proxies, access, audit_log, single replica, probes]
+---
+
+# Deploying for a Team (Kubernetes + Keycloak)
+
+This is a worked walkthrough of putting the **server edition**
+(`mcpproxy-server`, the Docker image built from the repository `Dockerfile`
+with `-tags server`) in front of a small team, behind an ingress, with
+Keycloak as the identity provider. Every setting named here is documented in
+full in [`server_edition`](../configuration/config-file.md#server-edition) and
+[`audit_log`](../configuration/config-file.md#audit_log-edition-neutral-jsonl-audit-record);
+this page assembles them into one deployment and states the constraints the
+reference pages leave implicit (single replica, volume layout, `Recreate`).
+The complete runnable example behind every JSON fragment below is
+[`docs/operations/examples/deploying-for-a-team.json`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/operations/examples/deploying-for-a-team.json)
+— it is loaded and validated by `internal/config/deploy_guide_example_test.go`
+(`-tags server`) on every CI run, so it can never silently drift from what
+`Config.Validate` actually accepts.
+
+Any other OIDC provider (Okta, Auth0, Authentik, Entra ID) drops into the same
+manifest — only the `oauth.issuer_url`/`client_id`/`client_secret` and the
+groups-claim table in [Server Multi-User Authentication](../development/server-edition-multiuser-auth.md)
+change; the Kubernetes shape below is provider-neutral.
+
+## The single-replica contract
+
+**Run exactly one replica.** The server edition holds pending OAuth login
+state (the PKCE verifier + nonce between `/auth/login` and `/auth/callback`),
+the SSE `/events` per-frame principal, and the entire tool index/BBolt config
+database in-process, with no shared cross-replica store. A second replica
+would answer a fraction of logins with `state mismatch` (whichever pod didn't
+see the redirect) and open a second, independent `config.db` if it also
+mounted its own volume, or fail to open a shared one (BBolt takes an exclusive
+file lock — a second process against the same `config.db` is a boot-time DB
+lock, exit code 3). This is documented as a hard limit, not a tuning knob: see
+[Still-open Spec 105 items / single-replica assumption](../features/agent-tokens.md#server-edition-incident-response)
+and [Server Architecture](../development/server-edition-multiuser-auth.md#server-architecture).
+
+That single-instance requirement, not scaling headroom, is why the Deployment
+below uses `replicas: 1` and `strategy: Recreate` rather than
+`RollingUpdate`: a rolling update briefly runs the old and new pod together
+against the same PVC, and the old pod's still-open BBolt lock makes the new
+pod's boot fail with exit code 3 (database locked). `Recreate` tears the old
+pod down — releasing the lock — before the new one starts, at the cost of a
+short outage on every deploy. There is no StatefulSet, headless Service, or
+pod-anti-affinity trick that turns this into a safe multi-replica rollout; the
+fix is a faster `Recreate` (small image, `initialDelaySeconds` tuned to your
+storage) or accepting the gap.
+
+## Persistent volume: `--config` and `--data-dir`
+
+The container's `ENTRYPOINT` is
+`mcpproxy serve --listen 0.0.0.0:8080` (see the repository `Dockerfile`); a
+Kubernetes `args` override adds `--config` and `--data-dir` explicitly rather
+than relying on the image's defaults, because both must point inside the
+**same** mounted volume:
+
+- `--data-dir /data` is where `config.db` (BBolt — sessions, users, agent
+ tokens, personal servers), `index.bleve/` (the BM25 tool index) and
+ `logs/` live. This is the directory that must survive a pod restart.
+- `--config /data/mcp_config.json` keeps the JSON config file **on the same
+ volume** as the data directory. Putting the config file on a separate
+ ConfigMap-backed mount while `--data-dir` is a PVC works for the first
+ boot, but every runtime edit through `PATCH /api/v1/config` or the Settings
+ UI writes back to the config file path — on a read-only ConfigMap mount
+ that write fails, and on two different volumes a pod reschedule can see
+ them drift out of sync (the file watcher hot-reloads whatever
+ `--config` currently resolves to, independent of `--data-dir`).
+
+Seed the initial file into the PVC once (an init container, or `kubectl cp`
+after the first boot) rather than mounting it from a ConfigMap:
+
+```yaml
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: mcpproxy-data
+```
+
+## The config file
+
+The block below is the `server_edition`-relevant slice of
+[`deploying-for-a-team.json`](https://github.com/smart-mcp-proxy/mcpproxy-go/blob/main/docs/operations/examples/deploying-for-a-team.json)
+(trimmed — the full file also seeds two `mcpServers` entries so the access
+map below has something to grant):
+
+```json
+{
+ "listen": "0.0.0.0:8080",
+ "trusted_proxies": ["10.42.0.0/16"],
+ "trusted_hosts": ["mcp.example.com"],
+ "require_mcp_auth": true,
+ "audit_log": {
+ "enabled": true,
+ "stdout": true
+ },
+ "server_edition": {
+ "enabled": true,
+ "admin_emails": ["platform-team@example.com"],
+ "public_url": "https://mcp.example.com",
+ "session_cookie_secure": "auto",
+ "credential_encryption_key": "${env:MCPPROXY_CRED_KEY}",
+ "oauth": {
+ "provider": "oidc",
+ "issuer_url": "https://keycloak.example.com/realms/team",
+ "client_id": "mcpproxy",
+ "client_secret": "${env:OIDC_CLIENT_SECRET}",
+ "scopes": ["openid", "profile", "email", "groups"],
+ "groups_claim": "groups",
+ "email_verified_policy": "refuse_false",
+ "display_name": "Example Keycloak"
+ },
+ "access": {
+ "group_servers": {
+ "mcpproxy-engineering": ["github", "ast-grep"],
+ "mcpproxy-admins": ["*"]
+ },
+ "default_servers": []
+ }
+ }
+}
+```
+
+### `public_url` and `trusted_proxies` — why both
+
+`10.42.0.0/16` is MicroK8s's default pod CIDR; use your cluster's actual pod
+or ingress-controller source range. Both keys matter independently, and
+leaving either out produces a specific, documented failure — not a generic
+misconfiguration:
+
+- Without `public_url`, the OAuth `redirect_uri` is derived from the
+ in-cluster request (`Host`/`X-Forwarded-Host`), so it depends on the
+ ingress forwarding the *public* hostname rather than an internal Service
+ name — set it explicitly and this class of bug disappears.
+- Without `trusted_proxies` naming the ingress's pod range, `X-Forwarded-Proto`
+ is ignored, `session_cookie_secure: auto` falls back to "not https", and
+ the session cookie is issued **without** `Secure` even though the browser
+ reached the service over TLS at the ingress.
+
+Full mechanics: [`public_url` and `trusted_proxies` in a container](../configuration/config-file.md#public_url-and-trusted_proxies-in-a-container).
+
+### `session_cookie_secure`
+
+Leave it at the default `auto`. It resolves to `Secure` because `public_url`
+is `https://…` — no need to hardcode `true`, and hardcoding `false` here would
+be refused at boot (`session_cookie_secure=false cannot be combined with an
+https public_url or tls.enabled`).
+
+### The `access` block: onboarding is adding someone to a Keycloak group
+
+`server_edition.access` is what turns "the platform team added Priya to the
+`mcpproxy-engineering` Keycloak group" into "Priya can call the `github` and
+`ast-grep` servers, and nothing else." It is **absent by default**
+(today's Shared-only semantics: every signed-in tenant sees every server in
+`mcpServers`); the moment the block is present, as above, it becomes the only
+source of grant, with no silent allow-all — `mcpproxy-admins: ["*"]` is the
+one way to grant every shared server, and a user in neither group (and with no
+`default_servers` entry) is entitled to none. See
+[Group access map and entitlement](../development/server-edition-multiuser-auth.md#group-access-map-server_editionaccess-and-entitlement-spec-107-pr-c)
+for the grant formula, and the Keycloak column of the
+[groups-claim table](../configuration/config-file.md#groups-claim-by-identity-provider) —
+untick **Full group path** on the client scope's Group Membership mapper, or
+the claim carries `/engineering/mcpproxy` instead of the bare name the map
+above expects.
+
+**Staleness bound and the upgrade path**, if you add this block to a
+deployment that has been running without one:
+
+```
+bound = session_ttl + max(bearer_token_ttl, longest owned agent-token expiry ≤ 365 days)
+```
+
+groups (and the admin role) refresh only at login, but every already-issued
+session, bearer JWT and owned agent token is narrowed to the new grant on its
+very next request/authentication — no restart, no waiting for that bound, no
+re-login required to *lose* access. The full state table (pre-upgrade user
+records, live sessions, admin-exempt accounts, provider/subject rebind on an
+IdP migration) is the
+[Upgrade-state table](../development/server-edition-multiuser-auth.md#upgrade-state-table).
+An administrator `disable` → `enable` is the immediate remedy when you cannot
+wait for the bound.
+
+**Open Spec 105 items.** Three surfaces still leak the *existence* (never the
+content) of a group-excluded server on `main` today: `retrieve_tools`'s
+`usage_summary`/`session_risk` statistics, the "Available servers" error text,
+and the scope-denial text. This deployment guide adds nothing new to that
+leak and it closes automatically the moment the corresponding Spec 105 items
+merge — see
+[Still-open Spec 105 items](../features/agent-tokens.md#server-edition-incident-response).
+
+### Secrets via `${env:...}`
+
+`oauth.client_secret` and `credential_encryption_key` are the two secret
+fields server_edition ever reads, and neither is ever readable back through
+the API — `client_secret` is masked in every response and log, and
+`credential_encryption_key` never has a Settings row. Reference them as
+`${env:VAR_NAME}` in the config file and supply the real value only as an
+environment variable, sourced from a Kubernetes `Secret`:
+
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: mcpproxy-secrets
+type: Opaque
+stringData:
+ OIDC_CLIENT_SECRET: ""
+ MCPPROXY_CRED_KEY: "<32+ byte random string>"
+```
+
+```yaml
+ envFrom:
+ - secretRef:
+ name: mcpproxy-secrets
+```
+
+Never put either value directly in the config file or a ConfigMap — the file
+is a Kubernetes object anyone with `get configmaps` in the namespace can read;
+`${env:}` plus a `Secret` keeps the value out of it and out of `kubectl get
+configmap -o yaml`. `MCPPROXY_CRED_KEY` is used only as a fallback when
+`credential_encryption_key` is empty; the example config sets both to the
+same `${env:MCPPROXY_CRED_KEY}` reference so there is exactly one secret to
+rotate. A missing `${env:...}` reference is refused at boot as if the field
+were never set (`server_edition.oauth.client_secret is required`), never sent
+to the IdP as the literal placeholder text.
+
+## Audit to stdout
+
+```json
+{ "audit_log": { "enabled": true, "stdout": true } }
+```
+
+Under the HTTP transport (this deployment — the container never runs native
+stdio), `stdout` writes one JSON line per authorization decision and tool
+call to the container's stdout, which `kubectl logs` and any node-level log
+shipper (Fluent Bit, Vector, Promtail) already scrapes with zero extra
+config — no volume, no rotation to manage. This is in fact the **default**
+the moment `audit_log` is left out entirely on the server edition: the block
+above is written for clarity, not because it changes behavior. If you would
+rather rotate to a file on the data volume instead, set
+`"audit_log": {"path": "/data/logs/audit.jsonl"}` — `stdout` and `path` are
+mutually exclusive when both are set explicitly (`path` wins, `stdout` is
+dropped with a warning); rotation defaults to 50 MB / 10 backups / 90 days,
+gzip'd. Schema, event vocabulary and a vendor-neutral log-shipper recipe:
+[Audit Log](../features/audit-log.md).
+
+The one case that does **not** apply to this deployment but is worth knowing:
+a native **stdio** transport can never use `stdout` for audit lines (stdout
+carries JSON-RPC there) — an absent block resolves to disabled with one WARN,
+and an explicit `stdout: true` with no `path` fails boot with exit code 4.
+This guide's container always serves HTTP, so that branch never triggers
+here; it matters only if you also run `mcpproxy-server` as a local stdio MCP
+server for a single developer.
+
+## Kubernetes manifests
+
+### Deployment
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: mcpproxy
+spec:
+ replicas: 1
+ strategy:
+ type: Recreate
+ selector:
+ matchLabels:
+ app: mcpproxy
+ template:
+ metadata:
+ labels:
+ app: mcpproxy
+ spec:
+ containers:
+ - name: mcpproxy
+ image: ghcr.io/smart-mcp-proxy/mcpproxy-server:latest
+ args:
+ - "serve"
+ - "--listen=0.0.0.0:8080"
+ - "--config=/data/mcp_config.json"
+ - "--data-dir=/data"
+ envFrom:
+ - secretRef:
+ name: mcpproxy-secrets
+ ports:
+ - containerPort: 8080
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ readinessProbe:
+ httpGet:
+ path: /readyz
+ port: 8080
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /healthz
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 15
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: mcpproxy-data
+```
+
+`/healthz` and `/readyz` are unauthenticated by design (a load balancer never
+carries an API key or a session), so no extra header configuration is needed
+in either probe. `/readyz` fails while the tool index is still building on
+first boot or during a large reindex; `/healthz` is the narrower liveness
+signal and should not flap during that window — keep the two probes distinct
+rather than pointing both at the same path.
+
+### Service and Ingress
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: mcpproxy
+spec:
+ selector:
+ app: mcpproxy
+ ports:
+ - port: 80
+ targetPort: 8080
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: mcpproxy
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+ nginx.ingress.kubernetes.io/proxy-buffering: "off" # keeps SSE (/events) streaming
+spec:
+ ingressClassName: nginx
+ tls:
+ - hosts: ["mcp.example.com"]
+ secretName: mcpproxy-tls
+ rules:
+ - host: mcp.example.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: mcpproxy
+ port:
+ number: 80
+```
+
+`nginx.ingress.kubernetes.io/proxy-buffering: "off"` matters specifically
+because `GET /events` is a long-lived SSE stream that re-resolves the tenant
+session principal before every frame (Spec 107 PR-C) — a buffering proxy
+delays or coalesces those frames, which reads to the client as a stalled
+connection rather than a slow one. Confirm your ingress controller forwards
+`X-Forwarded-Proto` and `X-Forwarded-For` (nginx does by default); those are
+exactly the headers `trusted_proxies` above tells `mcpproxy-server` to trust
+from the ingress-controller pod range.
+
+## Keycloak client setup
+
+1. Create a confidential OIDC client (e.g. `mcpproxy`) in the `team` realm.
+2. Valid redirect URI: `https://mcp.example.com/api/v1/auth/callback` —
+ exact match, this is why `public_url` above is set explicitly rather than
+ derived from the request.
+3. Add a **Group Membership** mapper to the client's dedicated scope (or a
+ shared scope included by default): token claim name `groups`, **untick**
+ "Full group path" so the claim carries `mcpproxy-engineering` rather than
+ `/mcpproxy-engineering`.
+4. Create the two groups referenced by the config above
+ (`mcpproxy-engineering`, `mcpproxy-admins`) and add team members to them.
+5. Copy the client's credential into the `OIDC_CLIENT_SECRET` value of the
+ `mcpproxy-secrets` Secret above.
+
+## Verifying the deployment
+
+```bash
+kubectl apply -f secret.yaml -f deployment.yaml -f service.yaml -f ingress.yaml
+kubectl rollout status deployment/mcpproxy
+kubectl logs -f deployment/mcpproxy | grep '"event":"authz"' # audit lines on stdout
+curl -s https://mcp.example.com/healthz
+curl -s https://mcp.example.com/api/v1/auth/provider # {"display_name":"Example Keycloak"}
+```
+
+Sign in through `https://mcp.example.com` with a Keycloak account in
+`mcpproxy-engineering`; the Web UI should show `github` and `ast-grep` and
+nothing else. Add the account to `mcpproxy-admins` instead (or to
+`admin_emails` directly) to see every configured server.
diff --git a/docs/operations/examples/deploying-for-a-team.json b/docs/operations/examples/deploying-for-a-team.json
new file mode 100644
index 000000000..a727d8328
--- /dev/null
+++ b/docs/operations/examples/deploying-for-a-team.json
@@ -0,0 +1,40 @@
+{
+ "listen": "0.0.0.0:8080",
+ "trusted_proxies": ["10.42.0.0/16"],
+ "trusted_hosts": ["mcp.example.com"],
+ "require_mcp_auth": true,
+ "audit_log": {
+ "enabled": true,
+ "stdout": true
+ },
+ "server_edition": {
+ "enabled": true,
+ "admin_emails": ["platform-team@example.com"],
+ "public_url": "https://mcp.example.com",
+ "session_cookie_secure": "auto",
+ "session_ttl": "24h",
+ "bearer_token_ttl": "24h",
+ "credential_encryption_key": "${env:MCPPROXY_CRED_KEY}",
+ "oauth": {
+ "provider": "oidc",
+ "issuer_url": "https://keycloak.example.com/realms/team",
+ "client_id": "mcpproxy",
+ "client_secret": "${env:OIDC_CLIENT_SECRET}",
+ "scopes": ["openid", "profile", "email", "groups"],
+ "groups_claim": "groups",
+ "email_verified_policy": "refuse_false",
+ "display_name": "Example Keycloak"
+ },
+ "access": {
+ "group_servers": {
+ "mcpproxy-engineering": ["github", "ast-grep"],
+ "mcpproxy-admins": ["*"]
+ },
+ "default_servers": []
+ }
+ },
+ "mcpServers": [
+ { "name": "github", "url": "https://api.github.com/mcp", "protocol": "http", "enabled": true },
+ { "name": "ast-grep", "command": "npx", "args": ["ast-grep-mcp"], "protocol": "stdio", "enabled": true }
+ ]
+}
diff --git a/docs/schemas/audit-line-v1.schema.json b/docs/schemas/audit-line-v1.schema.json
new file mode 100644
index 000000000..6ef131d6e
--- /dev/null
+++ b/docs/schemas/audit-line-v1.schema.json
@@ -0,0 +1,184 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://docs.mcpproxy.app/schemas/audit-line-v1.json",
+ "title": "MCPProxy audit line (schema_version 1)",
+ "description": "One JSON object per line written by the audit sink (Spec 107 FR-013). Three event kinds share one closed key set; per-event required/forbidden keys and the caller identity rules are enforced by the allOf/if/then blocks. Absent optional keys are omitted, never null. Adding a key is a minor change; removing or renaming a key or narrowing a vocabulary bumps schema_version. This published document is CONSUMER-TOLERANT (additionalProperties: true at every object level) so a strict consumer keeps validating across a minor additive change under the same $id; the exact key set is a producer property proven by internal/audit/schema_test.go, which flips additionalProperties to false in memory.",
+ "type": "object",
+ "additionalProperties": true,
+ "required": ["schema_version", "ts", "event", "origin", "source", "caller"],
+ "properties": {
+ "schema_version": { "const": 1 },
+ "ts": { "type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{9}Z$", "description": "RFC 3339, fixed nine fractional digits, UTC (Z) — Go layout 2006-01-02T15:04:05.000000000Z (not RFC3339Nano, which trims trailing zeros); format alone would admit offsets and other precisions" },
+ "event": { "enum": ["authz", "tool_call", "auth_event"] },
+ "request_id": { "type": "string", "minLength": 1, "description": "Activity request id (locally minted on /mcp, mcp.go:837; transport id on REST-originated direct dispatch). Join key to the activity record." },
+ "transport_request_id": { "type": "string", "minLength": 1, "description": "REST only: X-Request-Id. Equals request_id on REST-originated direct dispatch." },
+ "parent_id": { "type": "string", "minLength": 1, "description": "code_execution children: the wrapper's activity request id (a correlation key into the activity log — the wrapper writes no line)." },
+ "session_id": { "type": "string" },
+ "work_session_id": { "type": "string" },
+ "origin": { "enum": ["local", "socket", "remote"], "description": "Listener-derived (transport.ConnectionSource). remote is reserved for Spec 089 FR-010 and never emitted by Spec 107." },
+ "source": { "enum": ["mcp", "api", "internal"], "description": "Mount point, never the X-MCPProxy-Client header. api for every REST-originated line." },
+ "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest", "login", "logout"] },
+ "caller": {
+ "type": "object",
+ "additionalProperties": true,
+ "required": ["kind"],
+ "properties": {
+ "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_user", "session_admin", "internal"], "description": "stdio = the native stdio transport (administrator context, no listener; tagged as its own connection source)." },
+ "user_id": { "type": "string", "minLength": 1 },
+ "user_email": { "type": "string", "format": "email" },
+ "email_hash": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 of the normalised (lower-cased, trimmed) email. auth_event only, only when a verified email is known and no user record exists." },
+ "role": { "enum": ["admin", "user"] },
+ "provider": { "enum": ["google", "github", "microsoft", "oidc"] },
+ "token_name": { "type": "string", "minLength": 1 },
+ "token_prefix": { "type": "string", "minLength": 8, "maxLength": 16 },
+ "profile_pin": { "type": "string" }
+ },
+ "allOf": [
+ {
+ "description": "caller.user_email and caller.email_hash are mutually exclusive; email_hash never appears beside a user_id.",
+ "not": { "anyOf": [ { "required": ["user_email", "email_hash"] }, { "required": ["user_id", "email_hash"] } ] }
+ },
+ {
+ "description": "Caller identity rules per kind (contracts/audit-line-events.md 'Caller identity rules'): impersonal kinds carry no identity; agent tokens carry token_name/token_prefix; session kinds carry user_id and their matching role and never token fields.",
+ "allOf": [
+ { "if": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "internal"] } } },
+ "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["email_hash"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "anonymous" } } },
+ "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "agent_token" } } },
+ "then": { "required": ["token_name", "token_prefix"], "not": { "required": ["email_hash"] } } },
+ { "description": "Owned agent token: user_id implies user_email, role and provider (all-or-none).",
+ "if": { "properties": { "kind": { "const": "agent_token" } }, "required": ["user_id"] },
+ "then": { "required": ["user_email", "role", "provider"] } },
+ { "description": "Ownerless agent token: no user identity at all.",
+ "if": { "properties": { "kind": { "const": "agent_token" } }, "not": { "required": ["user_id"] } },
+ "then": { "not": { "anyOf": [ { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "session_user" } } },
+ "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "user" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "session_admin" } } },
+ "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "admin" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }
+ ]
+ }
+ ]
+ },
+ "client": {
+ "type": "object",
+ "additionalProperties": true,
+ "properties": {
+ "name": { "type": "string", "description": "Caller-asserted (MCP clientInfo or X-MCPProxy-Client). Untrusted." },
+ "version": { "type": "string", "description": "Caller-asserted. Untrusted." },
+ "ip": { "type": "string", "description": "RemoteAddr, or the right-most untrusted X-Forwarded-For hop when the peer is in trusted_proxies (FR-027)." }
+ }
+ },
+ "profile": { "type": "string" },
+ "server": { "type": "string", "minLength": 1, "description": "Canonical server name. Recorded even on a non-disclosing refusal (FR-016)." },
+ "tool": { "type": "string", "minLength": 1, "description": "Raw upstream tool name (Spec 105 FR-009 registration identity)." },
+ "operation": { "enum": ["read", "write", "destructive", "unknown"] },
+ "decision": { "enum": ["allow", "deny"] },
+ "reason": {
+ "type": "string",
+ "description": "Event-specific closed vocabulary; see the per-event blocks below. Never prose."
+ },
+ "disclosed": { "type": "boolean", "description": "false when the refusal was non-disclosing to the caller." },
+ "flags": {
+ "type": "array",
+ "minItems": 1,
+ "uniqueItems": true,
+ "items": { "enum": ["provider_rebound", "redirect_rejected", "groups_claim_missing"] },
+ "description": "auth_event only. Non-terminal facts of one attempt; omitted when empty."
+ },
+ "outcome": { "enum": ["success", "error", "blocked", "rejected"] },
+ "error_class": { "enum": ["upstream_error", "upstream_timeout", "upstream_unavailable", "validation", "sanitisation", "internal", "cancelled"] },
+ "duration_ms": { "type": "integer", "minimum": 0 },
+ "request_bytes": { "type": "integer", "minimum": 0 },
+ "response_bytes": { "type": "integer", "minimum": 0 },
+ "args_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$", "description": "SHA-256 over the RFC 8785 serialisation of security.StripInternalArgs(args), computed before masking/truncation (FR-015)." },
+ "args_bytes": { "type": "integer", "minimum": 0, "description": "Length of the canonical serialisation the hash was computed over." }
+ },
+ "allOf": [
+ {
+ "if": { "properties": { "event": { "const": "authz" } } },
+ "then": {
+ "required": ["request_id", "surface", "server", "tool", "operation", "decision", "reason", "args_sha256", "args_bytes"],
+ "properties": {
+ "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest"] },
+ "reason": { "enum": ["none", "intent_invalid", "intent_rejected", "profile_scope", "token_scope", "token_permission", "server_quarantined", "tool_pending_approval", "tool_changed_approval", "tool_not_callable", "other"] },
+ "caller": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_admin", "internal"] } }, "not": { "required": ["email_hash"] } }
+ },
+ "not": { "anyOf": [
+ { "required": ["outcome"] }, { "required": ["error_class"] }, { "required": ["duration_ms"] },
+ { "required": ["request_bytes"] }, { "required": ["response_bytes"] }, { "required": ["flags"] }
+ ] },
+ "allOf": [
+ { "if": { "properties": { "decision": { "const": "allow" } } }, "then": { "properties": { "reason": { "const": "none" } } } },
+ { "if": { "properties": { "decision": { "const": "deny" } } }, "then": { "properties": { "reason": { "not": { "const": "none" } } }, "required": ["disclosed"] } }
+ ]
+ }
+ },
+ {
+ "if": { "properties": { "event": { "const": "tool_call" } } },
+ "then": {
+ "required": ["request_id", "surface", "server", "tool", "operation", "outcome", "duration_ms", "args_sha256", "args_bytes"],
+ "properties": {
+ "surface": { "enum": ["call_tool_read", "call_tool_write", "call_tool_destructive", "direct", "code_execution", "rest"] },
+ "reason": { "enum": ["output_sanitisation", "output_schema", "limiter_queue_full", "limiter_queue_timeout"] },
+ "caller": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "anonymous", "agent_token", "session_admin", "internal"] } }, "not": { "required": ["email_hash"] } }
+ },
+ "not": { "anyOf": [ { "required": ["decision"] }, { "required": ["disclosed"] }, { "required": ["flags"] } ] },
+ "allOf": [
+ { "if": { "properties": { "outcome": { "const": "blocked" } } }, "then": { "required": ["reason"], "properties": { "reason": { "enum": ["output_sanitisation", "output_schema"] } } } },
+ { "if": { "properties": { "outcome": { "const": "rejected" } } }, "then": { "required": ["reason"], "properties": { "reason": { "enum": ["limiter_queue_full", "limiter_queue_timeout"] } } } },
+ { "if": { "properties": { "outcome": { "enum": ["success", "error"] } } }, "then": { "not": { "required": ["reason"] } } },
+ { "if": { "properties": { "outcome": { "const": "error" } } }, "then": { "required": ["error_class"] } },
+ { "if": { "properties": { "outcome": { "not": { "const": "error" } } } }, "then": { "not": { "required": ["error_class"] } } }
+ ]
+ }
+ },
+ {
+ "if": { "properties": { "event": { "const": "auth_event" } } },
+ "then": {
+ "required": ["request_id", "surface", "reason"],
+ "properties": {
+ "surface": { "enum": ["login", "logout"] },
+ "reason": { "enum": ["ok", "logout", "authorization_denied", "id_token_invalid", "nonce_mismatch", "audience_mismatch", "issuer_mismatch", "token_expired", "email_missing", "email_unverified", "domain_not_allowed", "subject_mismatch", "userinfo_subject_mismatch", "user_disabled", "state_invalid", "provider_error", "discovery_failed", "internal_error"] },
+ "caller": { "properties": { "kind": { "enum": ["session_user", "session_admin", "anonymous"] } }, "not": { "required": ["user_email"] }, "description": "Identity rule (FR-013/FR-017): user_id, else email_hash only for a verified email, else neither — never the raw email." }
+ },
+ "not": { "anyOf": [
+ { "required": ["server"] }, { "required": ["tool"] }, { "required": ["operation"] }, { "required": ["decision"] },
+ { "required": ["disclosed"] }, { "required": ["outcome"] }, { "required": ["error_class"] }, { "required": ["duration_ms"] },
+ { "required": ["request_bytes"] }, { "required": ["response_bytes"] }, { "required": ["args_sha256"] }, { "required": ["args_bytes"] },
+ { "required": ["parent_id"] }, { "required": ["profile"] }
+ ] },
+ "allOf": [
+ { "description": "Pre-identity refusals carry neither user_id nor email_hash (FR-013). provider_error is stage-dependent and handled by the next two rules.",
+ "if": { "properties": { "reason": { "enum": ["state_invalid", "authorization_denied", "discovery_failed", "id_token_invalid", "nonce_mismatch", "audience_mismatch", "issuer_mismatch", "token_expired", "email_missing", "email_unverified"] } } },
+ "then": { "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["email_hash"] }, { "required": ["user_email"] } ] } } } } },
+ { "description": "provider_error never reaches the user store: no user_id at either stage; email_hash only when it was raised by the userinfo fetch after a verified ID token (US2.3).",
+ "if": { "properties": { "reason": { "const": "provider_error" } } },
+ "then": { "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] } ] } } } } },
+ { "description": "Post-identity results (the store was reached and a record exists): a session caller kind carrying user_id (FR-013/FR-017; audit-line-events.md auth_event table). internal_error is stage-dependent and stays unconstrained beyond the per-kind rules.",
+ "if": { "properties": { "reason": { "enum": ["ok", "logout", "subject_mismatch", "user_disabled"] } } },
+ "then": { "properties": { "caller": { "required": ["user_id"], "properties": { "kind": { "enum": ["session_user", "session_admin"] } } } } } },
+ { "description": "Verified email known, store not yet consulted: an anonymous caller carrying email_hash (never user_id).",
+ "if": { "properties": { "reason": { "enum": ["domain_not_allowed", "userinfo_subject_mismatch"] } } },
+ "then": { "properties": { "caller": { "required": ["email_hash"], "properties": { "kind": { "const": "anonymous" } } } } } },
+ { "if": { "properties": { "surface": { "const": "logout" } } }, "then": { "properties": { "reason": { "const": "logout" } } } },
+ { "if": { "properties": { "reason": { "const": "logout" } } }, "then": { "properties": { "surface": { "const": "logout" } } } }
+ ]
+ }
+ },
+ {
+ "description": "An anonymous caller may carry email_hash only on an auth_event refused after a verified email is known and before the user store was consulted (domain_not_allowed, userinfo_subject_mismatch, or provider_error raised by the userinfo fetch).",
+ "if": { "properties": { "caller": { "properties": { "kind": { "const": "anonymous" } }, "required": ["email_hash"] } } },
+ "then": { "properties": { "event": { "const": "auth_event" }, "reason": { "enum": ["domain_not_allowed", "userinfo_subject_mismatch", "provider_error"] } } }
+ }
+ ],
+ "examples": [
+ { "schema_version": 1, "ts": "2026-09-15T10:00:00.000000001Z", "event": "authz", "request_id": "1757930400000000001-jira-create_issue-7", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_write", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "client": { "name": "claude-code", "version": "2.1", "ip": "10.0.0.7" }, "server": "jira", "tool": "create_issue", "operation": "write", "decision": "allow", "reason": "none", "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 },
+ { "schema_version": 1, "ts": "2026-09-15T10:00:00.250000000Z", "event": "tool_call", "request_id": "1757930400000000001-jira-create_issue-7", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_write", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "server": "jira", "tool": "create_issue", "operation": "write", "outcome": "success", "duration_ms": 248, "request_bytes": 2, "response_bytes": 512, "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 },
+ { "schema_version": 1, "ts": "2026-09-15T10:00:01.000000000Z", "event": "authz", "request_id": "1757930401000000000-prod-db-query-8", "session_id": "s-1", "origin": "local", "source": "mcp", "surface": "call_tool_read", "caller": { "kind": "agent_token", "user_id": "01J...", "user_email": "alice@example.com", "role": "user", "provider": "oidc", "token_name": "t1", "token_prefix": "mcp_agt_ab12" }, "server": "prod-db", "tool": "query", "operation": "read", "decision": "deny", "reason": "token_scope", "disclosed": false, "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "args_bytes": 2 },
+ { "schema_version": 1, "ts": "2026-09-15T09:59:00.000000000Z", "event": "auth_event", "request_id": "req-4f2a", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "session_user", "user_id": "01J...", "role": "user", "provider": "oidc" }, "client": { "ip": "10.0.0.7" }, "reason": "ok", "flags": ["groups_claim_missing"] },
+ { "schema_version": 1, "ts": "2026-09-15T09:59:30.000000000Z", "event": "auth_event", "request_id": "req-9c01", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "anonymous" }, "client": { "ip": "203.0.113.9" }, "reason": "nonce_mismatch" },
+ { "schema_version": 1, "ts": "2026-09-15T09:59:45.000000000Z", "event": "auth_event", "request_id": "req-b7d2", "origin": "local", "source": "api", "surface": "login", "caller": { "kind": "anonymous", "email_hash": "b36a83701f1c3191e19722d6f90274bc1b5501fe69ebf33313e440fe4b0fe210" }, "client": { "ip": "10.0.0.7" }, "reason": "provider_error" }
+ ]
+}
diff --git a/frontend/src/views/settings/fields.ts b/frontend/src/views/settings/fields.ts
index 3176ecd7c..06a460728 100644
--- a/frontend/src/views/settings/fields.ts
+++ b/frontend/src/views/settings/fields.ts
@@ -445,6 +445,21 @@ export function isBlankInstructions(v: string | null | undefined): boolean {
return !v || v.trim() === ''
}
+// Spec 107 PR-D (T110): `audit_log.*` rows from `contracts/config-keys.md`.
+// `audit_log` is `RequiresRestart=true` ("audit_log is bound at sink
+// construction" — DetectConfigChanges), so every row carries the restart
+// badge. There is no secret key under `audit_log`, so no row uses the
+// `secret` control.
+export const AUDIT_LOG_FIELDS: SettingField[] = [
+ { key: 'audit_log.enabled', label: 'Enable audit logging', help: 'Writes one JSONL line per authorization decision and tool call. On by default under the server edition; the personal edition defaults to off.', control: 'toggle', restart: true },
+ { key: 'audit_log.stdout', label: 'Write to stdout', help: 'Server edition default when no path is set — not used under the native stdio transport (stdout carries JSON-RPC there); set a path instead.', control: 'toggle', restart: true },
+ { key: 'audit_log.path', label: 'File path', help: 'Where to write the rotating audit log file. Leave blank to use stdout instead.', control: 'text', optional: true, placeholder: '/var/log/mcpproxy/audit.jsonl', restart: true },
+ { key: 'audit_log.max_size_mb', label: 'Rotate after (MB)', control: 'number', min: 1, restart: true },
+ { key: 'audit_log.max_backups', label: 'Rotated files to keep', control: 'number', min: 1, restart: true },
+ { key: 'audit_log.max_age_days', label: 'Delete rotated logs after (days)', control: 'number', min: 1, restart: true },
+ { key: 'audit_log.compress', label: 'Compress rotated files', control: 'toggle', restart: true },
+]
+
// ---- Section 3: Advanced (subsystem accordions) ----
export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [
{
@@ -541,6 +556,13 @@ export const ADVANCED_ACCORDIONS: SettingsAccordion[] = [
{ key: 'activity_cleanup_interval_min', label: 'Cleanup runs every (minutes)', control: 'number', min: 1 },
],
},
+ {
+ id: 'audit-log',
+ docs: '/features/audit-log',
+ title: 'Audit log',
+ description: 'Edition-neutral JSONL record of authorization decisions and tool calls. Changes take effect after a restart (the sink is bound at startup).',
+ fields: AUDIT_LOG_FIELDS,
+ },
{
id: 'discovery',
title: 'Tool discovery & health checks',
diff --git a/frontend/tests/unit/settings-audit-log.spec.ts b/frontend/tests/unit/settings-audit-log.spec.ts
new file mode 100644
index 000000000..d530509c7
--- /dev/null
+++ b/frontend/tests/unit/settings-audit-log.spec.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest'
+import { AUDIT_LOG_FIELDS, ADVANCED_ACCORDIONS, allCatalogFields } from '../../src/views/settings/fields'
+
+// Spec 107 PR-D (T110a/T110, US3): the `audit_log.*` accordion rows of
+// `contracts/config-keys.md` — every row is restart-pinned ("audit_log is
+// bound at sink construction") and no row is a secret control (there is no
+// audit_log secret key; a `secret` control here would be a mistake).
+describe('Settings audit-log accordion (Spec 107 T110)', () => {
+ const keys = () => AUDIT_LOG_FIELDS.map((f) => f.key)
+ const byKey = (k: string) => AUDIT_LOG_FIELDS.find((f) => f.key === k)
+
+ it('exposes exactly the contract row set, in catalogue order', () => {
+ expect(keys()).toEqual([
+ 'audit_log.enabled',
+ 'audit_log.stdout',
+ 'audit_log.path',
+ 'audit_log.max_size_mb',
+ 'audit_log.max_backups',
+ 'audit_log.max_age_days',
+ 'audit_log.compress',
+ ])
+ })
+
+ it('marks every audit_log row restart-pinned', () => {
+ for (const f of AUDIT_LOG_FIELDS) expect(f.restart, f.key).toBe(true)
+ })
+
+ it('never uses a secret control (audit_log has no secret key)', () => {
+ for (const f of AUDIT_LOG_FIELDS) expect(f.control, f.key).not.toBe('secret')
+ })
+
+ it('types the toggle rows', () => {
+ expect(byKey('audit_log.enabled')?.control).toBe('toggle')
+ expect(byKey('audit_log.stdout')?.control).toBe('toggle')
+ expect(byKey('audit_log.compress')?.control).toBe('toggle')
+ })
+
+ it('types the path row as text', () => {
+ const path = byKey('audit_log.path')
+ expect(path?.control).toBe('text')
+ })
+
+ it('types the rotation rows as number', () => {
+ expect(byKey('audit_log.max_size_mb')?.control).toBe('number')
+ expect(byKey('audit_log.max_backups')?.control).toBe('number')
+ expect(byKey('audit_log.max_age_days')?.control).toBe('number')
+ })
+
+ it('is wired into the Advanced accordions so it reaches the catalogue', () => {
+ const accordion = ADVANCED_ACCORDIONS.find((a) => a.id === 'audit-log')
+ expect(accordion).toBeDefined()
+ expect(accordion?.fields).toBe(AUDIT_LOG_FIELDS)
+ for (const k of keys()) {
+ expect(allCatalogFields().map((f) => f.key)).toContain(k)
+ }
+ })
+})
diff --git a/internal/audit/attempt.go b/internal/audit/attempt.go
new file mode 100644
index 000000000..0a03f47a4
--- /dev/null
+++ b/internal/audit/attempt.go
@@ -0,0 +1,67 @@
+// Package audit builds and writes the Spec 107 audit trail: one JSON line
+// per pre-dispatch authorization decision (authz), one per completed tool
+// dispatch (tool_call), and one per terminal login/logout attempt
+// (auth_event). See specs/107-server-edition-sso-hardening/contracts/
+// audit-line.schema.json for the binding wire schema and
+// audit-line-events.md for the event vocabulary.
+//
+// This package has no dependency on internal/server or internal/serveredition
+// (dependency direction: audit is a leaf consumed by both, never the other
+// way around) and depends only on internal/security (StripInternalArgs) plus
+// the standard library and gopkg.in/natefinch/lumberjack.v2 (already a module
+// dependency via internal/logs).
+package audit
+
+import "time"
+
+// Attempt is the immutable per-dispatch-attempt record installed in the
+// request context before the first authorization gate runs (data-model.md
+// §5). It never holds arguments, responses, error text or `_auth_*` values:
+// there is no field through which they could arrive, which is the
+// structural half of FR-015's "never raw args/response/error" invariant.
+type Attempt struct {
+ RequestID string
+ TransportRequestID string
+ ParentID string
+ SessionID string
+ WorkSessionID string
+ Server string // canonical server name (Spec 105 FR-009)
+ Tool string // raw upstream tool name
+ Operation string // read|write|destructive|unknown
+ Surface string // call_tool_read|call_tool_write|call_tool_destructive|direct|code_execution|rest
+ Source string // mcp|api|internal — from the mount point
+ Origin string // local|socket|remote(reserved)
+ ClientName string
+ ClientVersion string
+ ClientIP string
+ Profile string
+ ProfilePin string
+ ArgsSHA256 string // RFC 8785 canonical hash over StripInternalArgs(args), pre-masking
+ ArgsBytes int
+ StartedAt time.Time
+}
+
+// Caller is the audit line's `caller` object: the identity derived from
+// auth.AuthContext plus the credential kind (contracts/audit-line-events.md
+// "caller.kind derivation"). Fields not applicable to a given kind must be
+// left zero — the per-kind rules are enforced by the line builders, and the
+// schema's identity `allOf` blocks are the binding source of truth.
+type Caller struct {
+ Kind string // api_key|socket|stdio|anonymous|agent_token|session_user|session_admin|internal
+ UserID string
+ UserEmail string
+ EmailHash string // auth_event only, verified email, no user record yet
+ Role string // admin|user
+ Provider string // google|github|microsoft|oidc
+ TokenName string
+ TokenPrefix string
+ ProfilePin string
+}
+
+// Client is the audit line's optional `client` object: caller-asserted
+// identification, always untrusted.
+type Client struct {
+ Name string
+ Version string
+ IP string
+}
diff --git a/internal/audit/canonical.go b/internal/audit/canonical.go
new file mode 100644
index 000000000..774bf3f10
--- /dev/null
+++ b/internal/audit/canonical.go
@@ -0,0 +1,220 @@
+package audit
+
+// canonical.go: stdlib-only RFC 8785 (JCS) canonicalisation for tool-call
+// arguments (research.md D5). Independent of, and never shared with, the
+// reference implementation in canonical_test.go.
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+ "unicode/utf16"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
+)
+
+// CanonicalizeArgs strips `_auth_*` keys (FR-015) and serialises the rest
+// per RFC 8785: keys sorted by UTF-16 code unit, arrays in input order, ES6
+// number formatting, minimal string escaping, no insignificant whitespace.
+func CanonicalizeArgs(args map[string]interface{}) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := encodeCanonical(&buf, security.StripInternalArgs(args)); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+// HashArgs returns the lowercase-hex SHA-256 of CanonicalizeArgs(args) and
+// its byte length (schema `args_sha256` / `args_bytes`).
+func HashArgs(args map[string]interface{}) (hash string, argsBytes int, err error) {
+ canonical, err := CanonicalizeArgs(args)
+ if err != nil {
+ return "", 0, err
+ }
+ sum := sha256.Sum256(canonical)
+ return hex.EncodeToString(sum[:]), len(canonical), nil
+}
+
+// toFloat64 normalises the two numeric Go representations a decoded args
+// map can hold — json.Number (encoding/json with UseNumber) or float64
+// (without) — to the float64 RFC 8785 numbers are defined over.
+func toFloat64(v interface{}) (float64, bool) {
+ switch n := v.(type) {
+ case json.Number:
+ f, err := n.Float64()
+ return f, err == nil
+ case float64:
+ return n, true
+ default:
+ return 0, false
+ }
+}
+
+func encodeCanonical(buf *bytes.Buffer, v interface{}) error {
+ switch val := v.(type) {
+ case nil:
+ buf.WriteString("null")
+ case bool:
+ if val {
+ buf.WriteString("true")
+ } else {
+ buf.WriteString("false")
+ }
+ case string:
+ encodeCanonicalString(buf, val)
+ case []interface{}:
+ buf.WriteByte('[')
+ for i, elem := range val {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ if err := encodeCanonical(buf, elem); err != nil {
+ return err
+ }
+ }
+ buf.WriteByte(']')
+ case map[string]interface{}:
+ keys := make([]string, 0, len(val))
+ for k := range val {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool { return utf16CodeUnitLess(keys[i], keys[j]) })
+ buf.WriteByte('{')
+ for i, k := range keys {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ encodeCanonicalString(buf, k)
+ buf.WriteByte(':')
+ if err := encodeCanonical(buf, val[k]); err != nil {
+ return err
+ }
+ }
+ buf.WriteByte('}')
+ default:
+ f, ok := toFloat64(v)
+ if !ok {
+ return fmt.Errorf("audit.CanonicalizeArgs: unsupported type %T", v)
+ }
+ // RFC 8785 numbers are defined only over finite values (ES6
+ // Number::toString has no representation for NaN/Infinity as a JSON
+ // number token); a non-finite value reaching here (e.g. a
+ // code_execution script computing 0/0 or 1/0 before the call is
+ // dispatched) MUST be refused, never silently coerced to "null" —
+ // that would make args_sha256 collide across distinguishable inputs
+ // (round-1 cross-review finding, PR-D).
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return fmt.Errorf("audit.CanonicalizeArgs: non-finite number cannot be canonicalised")
+ }
+ buf.WriteString(formatNumberJCS(f))
+ }
+ return nil
+}
+
+// utf16CodeUnitLess compares by UTF-16 code unit (RFC 8785 §3.2.3), which
+// diverges from code-point order for supplementary-plane characters.
+func utf16CodeUnitLess(a, b string) bool {
+ au, bu := utf16.Encode([]rune(a)), utf16.Encode([]rune(b))
+ for i := 0; i < len(au) && i < len(bu); i++ {
+ if au[i] != bu[i] {
+ return au[i] < bu[i]
+ }
+ }
+ return len(au) < len(bu)
+}
+
+// namedEscapes are the RFC 8785 §3.2.2.2 short escapes; every other C0
+// control uses \u00XX and everything else (U+002F, non-ASCII) is verbatim.
+var namedEscapes = map[rune]string{
+ '"': `\"`, '\\': `\\`, '\b': `\b`, '\f': `\f`, '\n': `\n`, '\r': `\r`, '\t': `\t`,
+}
+
+func encodeCanonicalString(buf *bytes.Buffer, s string) {
+ buf.WriteByte('"')
+ for _, r := range s {
+ switch {
+ case namedEscapes[r] != "":
+ buf.WriteString(namedEscapes[r])
+ case r < 0x20:
+ fmt.Fprintf(buf, `\u%04x`, r)
+ default:
+ buf.WriteRune(r)
+ }
+ }
+ buf.WriteByte('"')
+}
+
+// formatNumberJCS is ES6 Number::toString per RFC 8785 (ECMA-262
+// Number::toString, "Number Prototype Object" toString algorithm):
+// shortest round-tripping decimal digits, "0" for +/-0, fixed-point
+// notation while the decimal-point position n satisfies -6 < n <= 21, and
+// exponential notation (mantissa "e" sign exponent, unpadded — JCS wants
+// "1e+5", not Go's "1e+05") outside that range. Go's `%g` verb switches to
+// exponential far earlier than ES6 (e.g. 0.000001 -> "1e-06" instead of
+// "0.000001", and 1e20 -> "1e+20" instead of the 21-digit fixed form), so
+// this cannot be strconv.FormatFloat(f, 'g', ...) reformatted — it derives
+// the shortest round-tripping digit string via the 'e' verb and then
+// applies the ECMA-262 placement rule directly. NaN/Inf cannot occur from
+// a decoded args map (canonicalizeArgs refuses them before this is called).
+func formatNumberJCS(f float64) string {
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ return "null"
+ }
+ if f == 0 {
+ return "0"
+ }
+
+ neg := f < 0
+ if neg {
+ f = -f
+ }
+
+ // strconv's 'e' verb with precision -1 gives the shortest decimal that
+ // round-trips to f, as "d.ddd...e±XX" (or "de±XX" for a single digit).
+ mantissa, expPart, _ := strings.Cut(strconv.FormatFloat(f, 'e', -1, 64), "e")
+ digits := strings.Replace(mantissa, ".", "", 1)
+ exp, err := strconv.Atoi(expPart)
+ if err != nil {
+ // Unreachable: strconv always emits a well-formed exponent for a
+ // finite, non-zero float in 'e' format.
+ panic(fmt.Sprintf("audit: malformed exponent from strconv: %q", expPart))
+ }
+ k := len(digits)
+ n := exp + 1 // ECMA-262: digits * 10^(n-k) == f, k <= n derived from exp.
+
+ var out string
+ switch {
+ case k <= n && n <= 21:
+ // Integer-valued magnitude: digits followed by (n-k) trailing zeros.
+ out = digits + strings.Repeat("0", n-k)
+ case 0 < n && n <= 21:
+ // Decimal point falls within the digit string.
+ out = digits[:n] + "." + digits[n:]
+ case -6 < n && n <= 0:
+ // Leading "0." plus -n zeros before the digits.
+ out = "0." + strings.Repeat("0", -n) + digits
+ default:
+ // Exponential notation, unpadded exponent (JCS: "1e+5" not "1e+05").
+ m := digits
+ if k > 1 {
+ m = digits[:1] + "." + digits[1:]
+ }
+ e := n - 1
+ sign := "+"
+ if e < 0 {
+ sign = "-"
+ e = -e
+ }
+ out = m + "e" + sign + strconv.Itoa(e)
+ }
+ if neg {
+ out = "-" + out
+ }
+ return out
+}
diff --git a/internal/audit/canonical_test.go b/internal/audit/canonical_test.go
new file mode 100644
index 000000000..d57190832
--- /dev/null
+++ b/internal/audit/canonical_test.go
@@ -0,0 +1,435 @@
+package audit_test
+
+// canonical_test.go — Phase D.1 / T096 (Spec 107 PR-D).
+//
+// Compile-red until T099: this file references audit.CanonicalizeArgs and
+// audit.HashArgs, which do not exist yet (internal/audit/canonical.go is
+// implemented in T099). Until then `go test ./internal/audit` fails to
+// build — that is the expected state for this task.
+//
+// The vectors live under testdata/canonical/*.json (RFC 8785 / JCS
+// appendix-style samples: nested maps/arrays, non-ASCII and escaped
+// strings, number equivalence 1 == 1.0 == 1e0, -0 == 0, the 2^53+1
+// precision-loss case, and UTF-16 code-unit key ordering including the
+// surrogate-pair quirk). Per data-model.md §5 and research.md D5, the
+// expected output is computed by an INDEPENDENT implementation in this
+// test package (referenceCanonicalize / referenceFormatNumber below),
+// never by calling the production code under test. `_auth_*` exclusion
+// is asserted through the real security.StripInternalArgs (already
+// implemented, not part of this package).
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "testing"
+ "unicode/utf16"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
+)
+
+// ---------------------------------------------------------------------
+// Vector file format
+// ---------------------------------------------------------------------
+
+// canonicalVectorFile is the on-disk shape of testdata/canonical/*.json.
+// Every object in `variants` must canonicalize to an identical byte
+// sequence (and therefore an identical SHA-256 digest) once decoded and
+// run through JCS — that is the property every vector in this suite
+// tests, whether the variants differ by member order, number spelling,
+// or (for the auth_keys_stripped fixture) the presence of `_auth_*`
+// members that security.StripInternalArgs must remove first.
+type canonicalVectorFile struct {
+ Description string `json:"description"`
+ StripInternalArgs bool `json:"strip_internal_args"`
+ Variants []map[string]interface{} `json:"variants"`
+}
+
+func loadVectors(t *testing.T) map[string]canonicalVectorFile {
+ t.Helper()
+
+ dir := filepath.Join("testdata", "canonical")
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("reading %s: %v", dir, err)
+ }
+
+ out := make(map[string]canonicalVectorFile)
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ t.Fatalf("reading %s: %v", entry.Name(), err)
+ }
+
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.UseNumber()
+ var vf canonicalVectorFile
+ if err := dec.Decode(&vf); err != nil {
+ t.Fatalf("decoding %s: %v", entry.Name(), err)
+ }
+ if len(vf.Variants) == 0 {
+ t.Fatalf("%s: no variants", entry.Name())
+ }
+ out[entry.Name()] = vf
+ }
+
+ if len(out) == 0 {
+ t.Fatalf("no vector files found under %s", dir)
+ }
+ return out
+}
+
+// ---------------------------------------------------------------------
+// T096: every variant in a vector file canonicalizes identically, and
+// the production implementation agrees byte-for-byte with the
+// independent reference implementation below.
+// ---------------------------------------------------------------------
+
+func TestCanonicalizeArgs_MatchesIndependentReference(t *testing.T) {
+ for name, vf := range loadVectors(t) {
+ name, vf := name, vf
+ t.Run(name, func(t *testing.T) {
+ var first []byte
+ var firstHash string
+
+ for i, variant := range vf.Variants {
+ input := variant
+ if vf.StripInternalArgs {
+ // The vector already exercises StripInternalArgs itself
+ // (it is the production, already-shipped function); the
+ // point under test is that audit.CanonicalizeArgs applies
+ // it before serialising, not that this test reimplements
+ // stripping.
+ input = security.StripInternalArgs(variant)
+ }
+
+ want, err := referenceCanonicalize(input)
+ if err != nil {
+ t.Fatalf("variant %d: reference implementation failed: %v", i, err)
+ }
+ wantHash := sha256Hex(want)
+
+ got, err := audit.CanonicalizeArgs(variant)
+ if err != nil {
+ t.Fatalf("variant %d: audit.CanonicalizeArgs failed: %v", i, err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("variant %d: canonical mismatch\n production: %s\n reference: %s", i, got, want)
+ }
+
+ gotHash, gotLen, err := audit.HashArgs(variant)
+ if err != nil {
+ t.Fatalf("variant %d: audit.HashArgs failed: %v", i, err)
+ }
+ if gotHash != wantHash {
+ t.Fatalf("variant %d: hash mismatch: production=%s reference=%s", i, gotHash, wantHash)
+ }
+ if gotLen != len(want) {
+ t.Fatalf("variant %d: args_bytes=%d, want len(canonical)=%d", i, gotLen, len(want))
+ }
+
+ if i == 0 {
+ first, firstHash = want, wantHash
+ continue
+ }
+ if !bytes.Equal(want, first) {
+ t.Fatalf("variant %d canonicalizes differently from variant 0, but the vector file declares them equivalent:\n0: %s\n%d: %s", i, first, i, want)
+ }
+ if gotHash != firstHash {
+ t.Fatalf("variant %d: args_sha256 differs from variant 0's, but the vector file declares them equivalent", i)
+ }
+ }
+ })
+ }
+}
+
+// TestCanonicalizeArgs_StripsInternalArgs is the T096-scoped structural
+// assertion for FR-015's "never raw args" invariant at the canonical
+// layer: the canonical bytes (and therefore the hash) must never contain
+// an `_auth_` key, regardless of where in the input map it appears.
+func TestCanonicalizeArgs_StripsInternalArgs(t *testing.T) {
+ args := map[string]interface{}{
+ "repo": "mcpproxy-go",
+ "issue": json.Number("1107"),
+ "_auth_user_id": "u-1",
+ "_auth_user_email": "alice@example.com",
+ "_auth_auth_type": "session_user",
+ }
+
+ got, err := audit.CanonicalizeArgs(args)
+ if err != nil {
+ t.Fatalf("CanonicalizeArgs: %v", err)
+ }
+ if bytes.Contains(got, []byte("_auth_")) {
+ t.Fatalf("canonical output leaked an _auth_ key: %s", got)
+ }
+
+ stripped := security.StripInternalArgs(args)
+ want, err := referenceCanonicalize(stripped)
+ if err != nil {
+ t.Fatalf("reference: %v", err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("canonical output does not match StripInternalArgs(args) canonicalized independently:\n got: %s\n want: %s", got, want)
+ }
+}
+
+// TestCanonicalizeArgs_DeterministicNoWhitespace pins the wire shape RFC
+// 8785 requires: no insignificant whitespace and object members separated
+// by a bare comma/colon (JCS §3.2).
+func TestCanonicalizeArgs_DeterministicNoWhitespace(t *testing.T) {
+ args := map[string]interface{}{"b": json.Number("2"), "a": json.Number("1")}
+ got, err := audit.CanonicalizeArgs(args)
+ if err != nil {
+ t.Fatalf("CanonicalizeArgs: %v", err)
+ }
+ if want := []byte(`{"a":1,"b":2}`); !bytes.Equal(got, want) {
+ t.Fatalf("got %s, want %s", got, want)
+ }
+}
+
+// TestHashArgs_EmptyArgs pins the boundary case: no arguments still
+// produces a valid (non-empty-string) SHA-256 over the empty JCS object.
+func TestHashArgs_EmptyArgs(t *testing.T) {
+ hash, n, err := audit.HashArgs(map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("HashArgs: %v", err)
+ }
+ wantHash := sha256Hex([]byte("{}"))
+ if hash != wantHash {
+ t.Fatalf("hash = %s, want %s (sha256 of \"{}\")", hash, wantHash)
+ }
+ if n != len("{}") {
+ t.Fatalf("args_bytes = %d, want %d", n, len("{}"))
+ }
+}
+
+// ---------------------------------------------------------------------
+// Independent reference implementation (test package only — never
+// shares code with internal/audit/canonical.go).
+// ---------------------------------------------------------------------
+
+func sha256Hex(b []byte) string {
+ sum := sha256.Sum256(b)
+ return hex.EncodeToString(sum[:])
+}
+
+// referenceCanonicalize serialises v (as decoded by encoding/json with
+// UseNumber) per RFC 8785: object members sorted by UTF-16 code units of
+// the key, arrays left in input order, ES6-style number formatting, and
+// minimal string escaping.
+func referenceCanonicalize(v interface{}) ([]byte, error) {
+ var buf bytes.Buffer
+ if err := referenceEncode(&buf, v); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+
+func referenceEncode(buf *bytes.Buffer, v interface{}) error {
+ switch val := v.(type) {
+ case nil:
+ buf.WriteString("null")
+ case bool:
+ if val {
+ buf.WriteString("true")
+ } else {
+ buf.WriteString("false")
+ }
+ case json.Number:
+ f, err := val.Float64()
+ if err != nil {
+ return fmt.Errorf("number %q: %w", val, err)
+ }
+ buf.WriteString(referenceFormatNumber(f))
+ case float64:
+ buf.WriteString(referenceFormatNumber(val))
+ case string:
+ referenceEncodeString(buf, val)
+ case []interface{}:
+ buf.WriteByte('[')
+ for i, elem := range val {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ if err := referenceEncode(buf, elem); err != nil {
+ return err
+ }
+ }
+ buf.WriteByte(']')
+ case map[string]interface{}:
+ keys := make([]string, 0, len(val))
+ for k := range val {
+ keys = append(keys, k)
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ return utf16Less(keys[i], keys[j])
+ })
+ buf.WriteByte('{')
+ for i, k := range keys {
+ if i > 0 {
+ buf.WriteByte(',')
+ }
+ referenceEncodeString(buf, k)
+ buf.WriteByte(':')
+ if err := referenceEncode(buf, val[k]); err != nil {
+ return err
+ }
+ }
+ buf.WriteByte('}')
+ default:
+ return fmt.Errorf("referenceEncode: unsupported type %T", v)
+ }
+ return nil
+}
+
+// utf16Less compares a and b by their UTF-16 code units (RFC 8785 §3.2.3),
+// which is NOT the same as comparing Unicode code points once either
+// string contains a character above the Basic Multilingual Plane: a
+// supplementary-plane character encodes as a surrogate pair whose first
+// unit (0xD800-0xDBFF) is numerically below the 0xE000-0xFFFF BMP range,
+// so e.g. U+1F600 sorts before U+E000 under this rule despite being the
+// larger code point.
+func utf16Less(a, b string) bool {
+ au := utf16.Encode([]rune(a))
+ bu := utf16.Encode([]rune(b))
+ for i := 0; i < len(au) && i < len(bu); i++ {
+ if au[i] != bu[i] {
+ return au[i] < bu[i]
+ }
+ }
+ return len(au) < len(bu)
+}
+
+// referenceEncodeString applies RFC 8785 §3.2.2.2 minimal escaping: only
+// U+0022, U+005C and the C0 control range U+0000-U+001F are escaped
+// (using the JSON short forms where defined, else \u00XX lowercase hex);
+// everything else, U+002F and non-ASCII included, is emitted verbatim.
+func referenceEncodeString(buf *bytes.Buffer, s string) {
+ buf.WriteByte('"')
+ for _, r := range s {
+ switch r {
+ case '"':
+ buf.WriteString(`\"`)
+ case '\\':
+ buf.WriteString(`\\`)
+ case '\b':
+ buf.WriteString(`\b`)
+ case '\f':
+ buf.WriteString(`\f`)
+ case '\n':
+ buf.WriteString(`\n`)
+ case '\r':
+ buf.WriteString(`\r`)
+ case '\t':
+ buf.WriteString(`\t`)
+ default:
+ if r < 0x20 {
+ fmt.Fprintf(buf, `\u%04x`, r)
+ } else {
+ buf.WriteRune(r)
+ }
+ }
+ }
+ buf.WriteByte('"')
+}
+
+// referenceFormatNumber implements ES6 Number::toString for a float64 as
+// RFC 8785 requires: shortest round-tripping decimal digits, "0" for
+// +/-0, fixed-point notation while the decimal-point position n satisfies
+// -6 < n <= 21 (ECMA-262 Number::toString), and exponential notation with
+// an unpadded exponent (Go: "1e+05"; ES6/JCS: "1e+5") outside that range.
+// This intentionally derives the ECMA-262 placement rule directly from the
+// shortest round-tripping digit string (via strconv's 'e' verb) rather
+// than reformatting Go's `%g` output, which switches to exponential far
+// earlier than ES6 does (round-2 cross-review finding, PR-D: `%g` gave
+// "1e-06" for 0.000001 and "1e+20" for 1e20, both wrong per ES6).
+func referenceFormatNumber(f float64) string {
+ if math.IsNaN(f) || math.IsInf(f, 0) {
+ panic(fmt.Sprintf("referenceFormatNumber: non-finite value %v (cannot occur from encoding/json input)", f))
+ }
+ if f == 0 {
+ return "0"
+ }
+
+ neg := f < 0
+ if neg {
+ f = -f
+ }
+
+ mantissa, expPart, _ := strings.Cut(strconv.FormatFloat(f, 'e', -1, 64), "e")
+ digits := strings.Replace(mantissa, ".", "", 1)
+ exp, err := strconv.Atoi(expPart)
+ if err != nil {
+ panic(fmt.Sprintf("referenceFormatNumber: malformed exponent %q", expPart))
+ }
+ k := len(digits)
+ n := exp + 1
+
+ var out string
+ switch {
+ case k <= n && n <= 21:
+ out = digits + strings.Repeat("0", n-k)
+ case 0 < n && n <= 21:
+ out = digits[:n] + "." + digits[n:]
+ case -6 < n && n <= 0:
+ out = "0." + strings.Repeat("0", -n) + digits
+ default:
+ m := digits
+ if k > 1 {
+ m = digits[:1] + "." + digits[1:]
+ }
+ e := n - 1
+ sign := "+"
+ if e < 0 {
+ sign = "-"
+ e = -e
+ }
+ out = m + "e" + sign + strconv.Itoa(e)
+ }
+ if neg {
+ out = "-" + out
+ }
+ return out
+}
+
+// TestReferenceFormatNumber_KnownValues pins the reference helper itself
+// against values whose ES6 spelling is unambiguous, so a bug here can't
+// silently rubber-stamp a matching bug in the production implementation.
+func TestReferenceFormatNumber_KnownValues(t *testing.T) {
+ cases := []struct {
+ in float64
+ want string
+ }{
+ {0, "0"},
+ {1, "1"},
+ {-1, "-1"},
+ {4.5, "4.5"},
+ {0.002, "0.002"},
+ {100, "100"},
+ // ECMA-262 fixed/exponential boundary cases (round-2 cross-review,
+ // PR-D): Go's `%g` gives "1e-06"/"1e+20" for these, ES6 does not.
+ {0.000001, "0.000001"},
+ {-0.000001, "-0.000001"},
+ {1e20, "100000000000000000000"},
+ {1e21, "1e+21"},
+ {1e-7, "1e-7"},
+ }
+ for _, tc := range cases {
+ if got := referenceFormatNumber(tc.in); got != tc.want {
+ t.Errorf("referenceFormatNumber(%v) = %q, want %q", tc.in, got, tc.want)
+ }
+ }
+}
diff --git a/internal/audit/error_class.go b/internal/audit/error_class.go
new file mode 100644
index 000000000..316e42878
--- /dev/null
+++ b/internal/audit/error_class.go
@@ -0,0 +1,75 @@
+package audit
+
+// error_class.go: the one helper that maps a dispatch error to the closed
+// `error_class` vocabulary of contracts/audit-line-events.md (tool_call,
+// outcome:error). A class, never message text: the line carries the class
+// and the activity record keeps the prose (FR-015).
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// ErrorClass is one member of the schema's `error_class` enum.
+type ErrorClass string
+
+const (
+ ErrorClassUpstreamError ErrorClass = "upstream_error"
+ ErrorClassUpstreamTimeout ErrorClass = "upstream_timeout"
+ ErrorClassUpstreamUnavailable ErrorClass = "upstream_unavailable"
+ ErrorClassValidation ErrorClass = "validation"
+ ErrorClassSanitisation ErrorClass = "sanitisation"
+ ErrorClassInternal ErrorClass = "internal"
+ ErrorClassCancelled ErrorClass = "cancelled"
+)
+
+// ErrSanitisationFailed is the sentinel a dispatch path wraps when the
+// sanitisation step itself fails (as opposed to a post-dispatch output
+// sanitisation BLOCK, which is a tool_call outcome:blocked and never reaches
+// ErrorClassOf).
+var ErrSanitisationFailed = errors.New("audit: sanitisation failed")
+
+// ErrorClassOf classifies err. Wrapped chains are unwrapped with errors.Is /
+// errors.As; anything unrecognised (including nil) is `internal`.
+func ErrorClassOf(err error) ErrorClass {
+ if err == nil {
+ return ErrorClassInternal
+ }
+ if errors.Is(err, context.Canceled) {
+ return ErrorClassCancelled
+ }
+ if errors.Is(err, context.DeadlineExceeded) {
+ return ErrorClassUpstreamTimeout
+ }
+ if errors.Is(err, ErrSanitisationFailed) {
+ return ErrorClassSanitisation
+ }
+ var limitErr *limiter.LimitError
+ if errors.As(err, &limitErr) {
+ return ErrorClassUpstreamUnavailable
+ }
+ var verr *jsonschema.ValidationError
+ if errors.As(err, &verr) {
+ return ErrorClassValidation
+ }
+ var rpcErr *transport.JSONRPCError
+ if errors.As(err, &rpcErr) {
+ return ErrorClassUpstreamError
+ }
+ var httpErr *transport.HTTPError
+ if errors.As(err, &httpErr) {
+ switch httpErr.StatusCode {
+ case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
+ return ErrorClassUpstreamUnavailable
+ default:
+ return ErrorClassUpstreamError
+ }
+ }
+ return ErrorClassInternal
+}
diff --git a/internal/audit/error_class_test.go b/internal/audit/error_class_test.go
new file mode 100644
index 000000000..3c78911d2
--- /dev/null
+++ b/internal/audit/error_class_test.go
@@ -0,0 +1,206 @@
+// error_class_test.go — Phase D.2 / T105a (Spec 107 PR-D).
+//
+// Compile-red until T105: this file references audit.ErrorClassOf and
+// audit.ErrorClass, which do not exist yet (internal/audit/error_class.go
+// is implemented in T105). Until then `go test ./internal/audit` fails to
+// build — that is the expected state for this task. No production code is
+// added here.
+//
+// Per contracts/audit-line-events.md ("tool_call" `error_class` row) the
+// bounded class set is: upstream_error | upstream_timeout |
+// upstream_unavailable | validation | sanitisation | internal | cancelled —
+// a closed enum, never message text. This table exercises ErrorClassOf
+// against the typed errors that can actually reach a `tool_call` completion
+// path today:
+//
+// - context.Canceled / context.DeadlineExceeded (and %w-wrapped forms),
+// recognised via errors.Is so a deep wrap chain still classifies.
+// - *limiter.LimitError (internal/upstream/limiter/errors.go): the shed
+// identity returned to the completion path. Per research.md D6 a shed
+// normally surfaces as `tool_call` outcome:rejected, never outcome:error
+// — this table still pins ErrorClassOf's own verdict on the type
+// defensively, in case a caller ever classifies one as an error.
+// - *transport.HTTPError (internal/transport/http.go): classified by
+// status code — 503/502/504 style "server can't currently serve this"
+// codes are upstream_unavailable, everything else upstream_error.
+// - *transport.JSONRPCError: upstream_error (a well-formed upstream
+// response carrying a protocol-level failure).
+// - *jsonschema.ValidationError (santhosh-tekuri/jsonschema/v6, already a
+// module dependency, Spec 085 pre-dispatch arg validation): validation.
+// - audit.ErrSanitisationFailed: a sentinel this task expects T105 to
+// define alongside ErrorClassOf in error_class.go, for the case where
+// StripInternalArgs/canonicalisation-adjacent sanitisation itself fails
+// (distinct from the post-dispatch output_sanitisation *block* reason,
+// which never reaches ErrorClassOf because it is outcome:blocked, not
+// outcome:error).
+// - a plain, untyped error: internal (the fallback for anything the
+// table above does not recognise).
+package audit_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// mustValidationError compiles a minimal object schema requiring "x" and
+// validates an empty instance against it, returning the resulting
+// *jsonschema.ValidationError (santhosh-tekuri v6's Validate always returns
+// that concrete type on failure).
+func mustValidationError(t *testing.T) error {
+ t.Helper()
+ schemaJSON := `{"type":"object","required":["x"]}`
+ doc, err := jsonschema.UnmarshalJSON(strings.NewReader(schemaJSON))
+ if err != nil {
+ t.Fatalf("UnmarshalJSON: %v", err)
+ }
+ c := jsonschema.NewCompiler()
+ if err := c.AddResource("mem://error-class-test/schema", doc); err != nil {
+ t.Fatalf("AddResource: %v", err)
+ }
+ sch, err := c.Compile("mem://error-class-test/schema")
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ verr := sch.Validate(map[string]interface{}{})
+ if verr == nil {
+ t.Fatal("expected validation failure, got nil")
+ }
+ var ve *jsonschema.ValidationError
+ if !errors.As(verr, &ve) {
+ t.Fatalf("expected *jsonschema.ValidationError, got %T", verr)
+ }
+ return verr
+}
+
+func TestErrorClassOf(t *testing.T) {
+ tests := []struct {
+ name string
+ err error
+ want audit.ErrorClass
+ }{
+ {
+ name: "context canceled direct",
+ err: context.Canceled,
+ want: audit.ErrorClassCancelled,
+ },
+ {
+ name: "context canceled wrapped",
+ err: fmt.Errorf("upstream call: %w", context.Canceled),
+ want: audit.ErrorClassCancelled,
+ },
+ {
+ name: "context deadline exceeded direct",
+ err: context.DeadlineExceeded,
+ want: audit.ErrorClassUpstreamTimeout,
+ },
+ {
+ name: "context deadline exceeded wrapped",
+ err: fmt.Errorf("dial tcp: %w", context.DeadlineExceeded),
+ want: audit.ErrorClassUpstreamTimeout,
+ },
+ {
+ name: "limiter server unavailable",
+ err: &limiter.LimitError{
+ Scope: limiter.ScopeServer,
+ Reason: limiter.ReasonServerUnavailable,
+ Server: "github",
+ },
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "limiter global queue full",
+ err: &limiter.LimitError{
+ Scope: limiter.ScopeGlobal,
+ Reason: limiter.ReasonQueueFull,
+ Limit: 8,
+ },
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "limiter queue timeout wrapped",
+ err: fmt.Errorf("acquire: %w", &limiter.LimitError{
+ Scope: limiter.ScopeServer,
+ Reason: limiter.ReasonQueueTimeout,
+ Server: "slack",
+ }),
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "http 503 service unavailable",
+ err: transport.NewHTTPError(503, "", "POST", "https://upstream.example/mcp", nil, nil),
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "http 502 bad gateway",
+ err: transport.NewHTTPError(502, "", "POST", "https://upstream.example/mcp", nil, nil),
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "http 504 gateway timeout",
+ err: transport.NewHTTPError(504, "", "POST", "https://upstream.example/mcp", nil, nil),
+ want: audit.ErrorClassUpstreamUnavailable,
+ },
+ {
+ name: "http 500 internal server error",
+ err: transport.NewHTTPError(500, "boom", "POST", "https://upstream.example/mcp", nil, nil),
+ want: audit.ErrorClassUpstreamError,
+ },
+ {
+ name: "jsonrpc protocol error",
+ err: &transport.JSONRPCError{Code: -32000, Message: "server error"},
+ want: audit.ErrorClassUpstreamError,
+ },
+ {
+ name: "jsonrpc error wrapping http",
+ err: &transport.JSONRPCError{
+ Code: -32000,
+ Message: "server error",
+ HTTPError: transport.NewHTTPError(500, "", "POST", "https://upstream.example/mcp", nil, nil),
+ },
+ want: audit.ErrorClassUpstreamError,
+ },
+ {
+ name: "schema validation failure",
+ err: mustValidationError(t),
+ want: audit.ErrorClassValidation,
+ },
+ {
+ name: "sanitisation failure sentinel",
+ err: audit.ErrSanitisationFailed,
+ want: audit.ErrorClassSanitisation,
+ },
+ {
+ name: "sanitisation failure wrapped",
+ err: fmt.Errorf("mask args: %w", audit.ErrSanitisationFailed),
+ want: audit.ErrorClassSanitisation,
+ },
+ {
+ name: "unknown plain error",
+ err: errors.New("something went sideways"),
+ want: audit.ErrorClassInternal,
+ },
+ {
+ name: "nil error still classifies as internal",
+ err: nil,
+ want: audit.ErrorClassInternal,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := audit.ErrorClassOf(tt.err)
+ if got != tt.want {
+ t.Errorf("ErrorClassOf(%v) = %q, want %q", tt.err, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/audit/line.go b/internal/audit/line.go
new file mode 100644
index 000000000..c345f3414
--- /dev/null
+++ b/internal/audit/line.go
@@ -0,0 +1,264 @@
+package audit
+
+// line.go builds the three Spec 107 audit line kinds (authz, tool_call,
+// auth_event) against contracts/audit-line.schema.json. Every constructor
+// takes a typed input struct with no field through which raw arguments, a
+// response fragment or error text could arrive (FR-015's structural
+// guarantee); the redaction this file DOES perform is the per-field masking
+// of caller/operator-controlled strings (contracts/audit-line-events.md
+// "Redaction (FR-015)").
+
+import (
+ "encoding/json"
+ "fmt"
+ "time"
+)
+
+// tsLayout is the fixed nine-fractional-digit UTC layout the schema
+// requires (RFC3339Nano trims trailing zeros, which this must not do).
+const tsLayout = "2006-01-02T15:04:05.000000000Z"
+
+// Line is a single, already-validated audit line.
+type Line struct {
+ fields map[string]interface{}
+}
+
+// JSON serialises the line. encoding/json sorts map[string]interface{} keys,
+// so repeated calls on the same Line are byte-stable.
+func (l Line) JSON() ([]byte, error) {
+ return json.Marshal(l.fields)
+}
+
+func newBase(event string, ts time.Time, requestID, origin, source string, caller Caller) map[string]interface{} {
+ f := map[string]interface{}{
+ "schema_version": 1,
+ "ts": ts.UTC().Format(tsLayout),
+ "event": event,
+ "request_id": requestID,
+ "origin": origin,
+ "source": source,
+ "caller": buildCaller(caller),
+ }
+ return f
+}
+
+func buildCaller(c Caller) map[string]interface{} {
+ m := map[string]interface{}{"kind": c.Kind}
+ if c.UserID != "" {
+ m["user_id"] = c.UserID
+ }
+ if c.UserEmail != "" {
+ m["user_email"] = c.UserEmail
+ }
+ if c.EmailHash != "" {
+ m["email_hash"] = c.EmailHash
+ }
+ if c.Role != "" {
+ m["role"] = c.Role
+ }
+ if c.Provider != "" {
+ m["provider"] = c.Provider
+ }
+ if c.TokenName != "" {
+ m["token_name"] = maskCredential(c.TokenName)
+ }
+ if c.TokenPrefix != "" {
+ m["token_prefix"] = c.TokenPrefix
+ }
+ if c.ProfilePin != "" {
+ m["profile_pin"] = maskCredential(c.ProfilePin)
+ }
+ return m
+}
+
+func setClient(f map[string]interface{}, name, version, ip string) {
+ c := map[string]interface{}{}
+ if name != "" {
+ c["name"] = maskCredential(name)
+ }
+ if version != "" {
+ c["version"] = maskCredential(version)
+ }
+ if ip != "" {
+ c["ip"] = ip
+ }
+ if len(c) > 0 {
+ f["client"] = c
+ }
+}
+
+func setOptString(f map[string]interface{}, key, val string) {
+ if val != "" {
+ f[key] = val
+ }
+}
+
+// ---------------------------------------------------------------------------
+// authz
+// ---------------------------------------------------------------------------
+
+// AuthzInput builds one `authz` line: exactly one per pre-dispatch decision.
+type AuthzInput struct {
+ Ts time.Time
+ Attempt Attempt
+ Caller Caller
+ Decision string // allow|deny
+ Reason string // "none" iff allow; else a pre-dispatch gate reason
+ Disclosed *bool // required iff Decision == deny
+}
+
+// NewAuthz builds and structurally validates an `authz` line.
+func NewAuthz(in AuthzInput) (Line, error) {
+ switch in.Decision {
+ case "allow":
+ if in.Reason != "none" {
+ return Line{}, fmt.Errorf("audit.NewAuthz: decision:allow requires reason:none, got %q", in.Reason)
+ }
+ case "deny":
+ if in.Reason == "none" || in.Reason == "" {
+ return Line{}, fmt.Errorf("audit.NewAuthz: decision:deny requires a non-none reason")
+ }
+ if in.Disclosed == nil {
+ return Line{}, fmt.Errorf("audit.NewAuthz: decision:deny requires Disclosed")
+ }
+ default:
+ return Line{}, fmt.Errorf("audit.NewAuthz: invalid decision %q", in.Decision)
+ }
+
+ a := in.Attempt
+ f := newBase("authz", in.Ts, a.RequestID, a.Origin, a.Source, in.Caller)
+ f["surface"] = a.Surface
+ f["server"] = maskCredential(a.Server)
+ f["tool"] = maskCredential(a.Tool)
+ f["operation"] = a.Operation
+ f["decision"] = in.Decision
+ f["reason"] = in.Reason
+ f["args_sha256"] = a.ArgsSHA256
+ f["args_bytes"] = a.ArgsBytes
+ if in.Disclosed != nil {
+ f["disclosed"] = *in.Disclosed
+ }
+ setOptString(f, "transport_request_id", a.TransportRequestID)
+ setOptString(f, "parent_id", a.ParentID)
+ setOptString(f, "session_id", a.SessionID)
+ setOptString(f, "work_session_id", a.WorkSessionID)
+ setOptString(f, "profile", maskCredential(a.Profile))
+ setClient(f, a.ClientName, a.ClientVersion, a.ClientIP)
+
+ return Line{fields: f}, nil
+}
+
+// ---------------------------------------------------------------------------
+// tool_call
+// ---------------------------------------------------------------------------
+
+// ToolCallInput builds one `tool_call` line: exactly one per `authz allow`,
+// written at completion.
+type ToolCallInput struct {
+ Ts time.Time
+ Attempt Attempt
+ Caller Caller
+ Outcome string // success|error|blocked|rejected
+ Reason string // required iff blocked/rejected; forbidden otherwise
+ ErrorClass string // required iff error; forbidden otherwise
+ DurationMs int
+ RequestBytes *int
+ ResponseBytes *int
+}
+
+// NewToolCall builds and structurally validates a `tool_call` line.
+func NewToolCall(in ToolCallInput) (Line, error) {
+ switch in.Outcome {
+ case "success":
+ if in.Reason != "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: reason is forbidden on outcome:success")
+ }
+ if in.ErrorClass != "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: error_class is forbidden on outcome:success")
+ }
+ case "error":
+ if in.Reason != "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: reason is forbidden on outcome:error")
+ }
+ if in.ErrorClass == "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: outcome:error requires error_class")
+ }
+ case "blocked", "rejected":
+ if in.Reason == "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: outcome:%s requires reason", in.Outcome)
+ }
+ if in.ErrorClass != "" {
+ return Line{}, fmt.Errorf("audit.NewToolCall: error_class is forbidden on outcome:%s", in.Outcome)
+ }
+ default:
+ return Line{}, fmt.Errorf("audit.NewToolCall: invalid outcome %q", in.Outcome)
+ }
+
+ a := in.Attempt
+ f := newBase("tool_call", in.Ts, a.RequestID, a.Origin, a.Source, in.Caller)
+ f["surface"] = a.Surface
+ f["server"] = maskCredential(a.Server)
+ f["tool"] = maskCredential(a.Tool)
+ f["operation"] = a.Operation
+ f["outcome"] = in.Outcome
+ f["duration_ms"] = in.DurationMs
+ f["args_sha256"] = a.ArgsSHA256
+ f["args_bytes"] = a.ArgsBytes
+ setOptString(f, "reason", in.Reason)
+ setOptString(f, "error_class", in.ErrorClass)
+ setOptString(f, "transport_request_id", a.TransportRequestID)
+ setOptString(f, "parent_id", a.ParentID)
+ setOptString(f, "session_id", a.SessionID)
+ setOptString(f, "work_session_id", a.WorkSessionID)
+ if in.RequestBytes != nil {
+ f["request_bytes"] = *in.RequestBytes
+ }
+ if in.ResponseBytes != nil {
+ f["response_bytes"] = *in.ResponseBytes
+ }
+ setClient(f, a.ClientName, a.ClientVersion, a.ClientIP)
+
+ return Line{fields: f}, nil
+}
+
+// ---------------------------------------------------------------------------
+// auth_event
+// ---------------------------------------------------------------------------
+
+// AuthEventInput builds one `auth_event` line: one per terminal login
+// attempt the proxy observes, one per logout.
+type AuthEventInput struct {
+ Ts time.Time
+ RequestID string
+ Origin string
+ Source string
+ Surface string // login|logout
+ Reason string
+ Caller Caller
+ ClientIP string
+ Flags []string
+}
+
+// NewAuthEvent builds and structurally validates an `auth_event` line.
+func NewAuthEvent(in AuthEventInput) (Line, error) {
+ if in.Surface == "logout" && in.Reason != "logout" {
+ return Line{}, fmt.Errorf("audit.NewAuthEvent: surface:logout requires reason:logout, got %q", in.Reason)
+ }
+ if in.Reason == "logout" && in.Surface != "logout" {
+ return Line{}, fmt.Errorf("audit.NewAuthEvent: reason:logout requires surface:logout, got %q", in.Surface)
+ }
+
+ f := newBase("auth_event", in.Ts, in.RequestID, in.Origin, in.Source, in.Caller)
+ f["surface"] = in.Surface
+ f["reason"] = in.Reason
+ if len(in.Flags) > 0 {
+ flags := make([]interface{}, len(in.Flags))
+ for i, fl := range in.Flags {
+ flags[i] = fl
+ }
+ f["flags"] = flags
+ }
+ setClient(f, "", "", in.ClientIP)
+
+ return Line{fields: f}, nil
+}
diff --git a/internal/audit/line_test.go b/internal/audit/line_test.go
new file mode 100644
index 000000000..5e9d228d0
--- /dev/null
+++ b/internal/audit/line_test.go
@@ -0,0 +1,670 @@
+// Package audit_test exercises the audit line builders (internal/audit) against
+// the binding wire schema (specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json).
+//
+// T097 (Spec 107 PR-D): this file, together with schema_test.go, is
+// [compile-red until T099] — internal/audit does not exist yet, so nothing
+// here compiles. That is the expected red: the paired implementation task
+// (T099) introduces internal/audit/{attempt.go,canonical.go,line.go,sink.go}
+// and every symbol referenced below, after which these tests must pass
+// unchanged (plan.md "Failing tests first").
+//
+// No production code lives in this file. The three event-specific
+// constructors (NewAuthz, NewToolCall, NewAuthEvent) are expected to take
+// typed input structs that simply have no field through which a forbidden
+// key (e.g. `outcome` on an authz line, raw argument/response/error text on
+// any line) could arrive — that is a structural, compile-time guarantee and
+// is not re-tested at runtime here; schema_test.go proves the resulting
+// *producer* key set is exactly what the strict (additionalProperties:false)
+// variant of the schema allows.
+package audit_test
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+)
+
+// ---------------------------------------------------------------------------
+// Fixed test inputs
+// ---------------------------------------------------------------------------
+
+var fixedTS = time.Date(2026, 9, 17, 10, 0, 0, 1, time.UTC)
+
+func boolPtr(b bool) *bool { return &b }
+func intPtr(i int) *int { return &i }
+
+func baseAttempt() audit.Attempt {
+ return audit.Attempt{
+ RequestID: "1757930400000000001-jira-create_issue-7",
+ SessionID: "s-1",
+ Server: "jira",
+ Tool: "create_issue",
+ Operation: "write",
+ Surface: "call_tool_write",
+ Source: "mcp",
+ Origin: "local",
+ StartedAt: fixedTS,
+ }
+}
+
+func agentCaller() audit.Caller {
+ return audit.Caller{
+ Kind: "agent_token",
+ UserID: "01J000000000000000000000",
+ UserEmail: "alice@example.com",
+ Role: "user",
+ Provider: "oidc",
+ TokenName: "t1",
+ TokenPrefix: "mcp_agt_ab12",
+ }
+}
+
+// argsHash mirrors what a caller of the builder must have already computed
+// (Spec 107 FR-015: SHA-256 over the RFC 8785 canonical serialisation of
+// security.StripInternalArgs(args)). T096 (canonical_test.go) proves the
+// canonicalisation itself; here we only need a stable, schema-shaped value.
+func argsHash(canonical string) (sum string, n int) {
+ h := sha256.Sum256([]byte(canonical))
+ return hex.EncodeToString(h[:]), len(canonical)
+}
+
+// ---------------------------------------------------------------------------
+// authz
+// ---------------------------------------------------------------------------
+
+func TestNewAuthz_AllowValidatesAgainstSchema(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Decision: "allow",
+ Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ obj := decodeLine(t, line)
+ if obj["event"] != "authz" {
+ t.Fatalf("event = %v, want authz", obj["event"])
+ }
+ if _, ok := obj["outcome"]; ok {
+ t.Fatalf("authz line must never carry outcome: %v", obj)
+ }
+}
+
+func TestNewAuthz_DenyRequiresDisclosedAndNonNoneReason(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.Server, att.Tool = "prod-db", "query"
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Decision: "deny",
+ Reason: "token_scope",
+ Disclosed: boolPtr(false),
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ obj := decodeLine(t, line)
+ if obj["decision"] != "deny" || obj["reason"] == "none" {
+ t.Fatalf("deny line must carry a non-none reason: %v", obj)
+ }
+ if obj["disclosed"] != false {
+ t.Fatalf("non-disclosing deny must carry disclosed:false: %v", obj)
+ }
+
+ // A deny with no Disclosed pointer set must be rejected by the builder
+ // itself (or produce a line the schema rejects) — the schema requires
+ // `disclosed` whenever decision:deny.
+ _, err = audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Decision: "deny",
+ Reason: "token_scope",
+ // Disclosed intentionally omitted.
+ })
+ if err == nil {
+ t.Fatalf("NewAuthz: expected error for deny without Disclosed")
+ }
+}
+
+func TestNewAuthz_AllowMustCarryReasonNone(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ _, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Decision: "allow",
+ Reason: "token_scope", // wrong: allow must pair with reason:none
+ })
+ if err == nil {
+ t.Fatalf("NewAuthz: expected error for allow with non-none reason")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// tool_call
+// ---------------------------------------------------------------------------
+
+func TestNewToolCall_SuccessValidatesAgainstSchema(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ line, err := audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: "success",
+ DurationMs: 248,
+ RequestBytes: intPtr(2),
+ ResponseBytes: intPtr(512),
+ })
+ if err != nil {
+ t.Fatalf("NewToolCall: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ obj := decodeLine(t, line)
+ for _, forbidden := range []string{"decision", "disclosed", "flags"} {
+ if _, ok := obj[forbidden]; ok {
+ t.Fatalf("tool_call line must never carry %q: %v", forbidden, obj)
+ }
+ }
+}
+
+func TestNewToolCall_ErrorRequiresErrorClass(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ line, err := audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: "error",
+ ErrorClass: "upstream_timeout",
+ DurationMs: 30000,
+ })
+ if err != nil {
+ t.Fatalf("NewToolCall: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ _, err = audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: "error",
+ DurationMs: 30000,
+ // ErrorClass intentionally omitted — must be rejected.
+ })
+ if err == nil {
+ t.Fatalf("NewToolCall: expected error for outcome:error without ErrorClass")
+ }
+
+ // error_class is forbidden on every non-error outcome.
+ _, err = audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: "success",
+ ErrorClass: "upstream_timeout",
+ DurationMs: 1,
+ })
+ if err == nil {
+ t.Fatalf("NewToolCall: expected error for ErrorClass set on a success outcome")
+ }
+}
+
+func TestNewToolCall_BlockedAndRejectedReasons(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ cases := []struct {
+ outcome, reason string
+ }{
+ {"blocked", "output_sanitisation"},
+ {"blocked", "output_schema"},
+ {"rejected", "limiter_queue_full"},
+ {"rejected", "limiter_queue_timeout"},
+ }
+ for _, tc := range cases {
+ line, err := audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: tc.outcome,
+ Reason: tc.reason,
+ DurationMs: 1,
+ })
+ if err != nil {
+ t.Fatalf("NewToolCall(%s/%s): %v", tc.outcome, tc.reason, err)
+ }
+ validateAgainstPublishedSchema(t, line)
+ }
+
+ // reason is forbidden on success/error.
+ _, err := audit.NewToolCall(audit.ToolCallInput{
+ Ts: fixedTS,
+ Attempt: att,
+ Caller: agentCaller(),
+ Outcome: "success",
+ Reason: "output_sanitisation",
+ DurationMs: 1,
+ })
+ if err == nil {
+ t.Fatalf("NewToolCall: expected error for Reason set on a success outcome")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// auth_event
+// ---------------------------------------------------------------------------
+
+func TestNewAuthEvent_LoginOkValidatesAgainstSchema(t *testing.T) {
+ line, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS,
+ RequestID: "req-4f2a",
+ Origin: "local",
+ Source: "api",
+ Surface: "login",
+ Reason: "ok",
+ Caller: audit.Caller{
+ Kind: "session_user",
+ UserID: "01J000000000000000000000",
+ Role: "user",
+ Provider: "oidc",
+ },
+ })
+ if err != nil {
+ t.Fatalf("NewAuthEvent: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+}
+
+func TestNewAuthEvent_PreIdentityRefusalCarriesNoIdentity(t *testing.T) {
+ line, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS,
+ RequestID: "req-9c01",
+ Origin: "local",
+ Source: "api",
+ Surface: "login",
+ Reason: "nonce_mismatch",
+ Caller: audit.Caller{Kind: "anonymous"},
+ })
+ if err != nil {
+ t.Fatalf("NewAuthEvent: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ obj := decodeLine(t, line)
+ caller, _ := obj["caller"].(map[string]interface{})
+ if _, ok := caller["user_id"]; ok {
+ t.Fatalf("pre-identity refusal must not carry user_id: %v", caller)
+ }
+ if _, ok := caller["email_hash"]; ok {
+ t.Fatalf("pre-identity refusal must not carry email_hash: %v", caller)
+ }
+}
+
+func TestNewAuthEvent_ProviderErrorFromUserinfoCarriesEmailHashNeverUserID(t *testing.T) {
+ line, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS,
+ RequestID: "req-b7d2",
+ Origin: "local",
+ Source: "api",
+ Surface: "login",
+ Reason: "provider_error",
+ Caller: audit.Caller{
+ Kind: "anonymous",
+ EmailHash: strings.Repeat("b", 64),
+ },
+ })
+ if err != nil {
+ t.Fatalf("NewAuthEvent: %v", err)
+ }
+ validateAgainstPublishedSchema(t, line)
+
+ obj := decodeLine(t, line)
+ caller, _ := obj["caller"].(map[string]interface{})
+ if _, ok := caller["user_id"]; ok {
+ t.Fatalf("provider_error must never carry user_id: %v", caller)
+ }
+}
+
+func TestNewAuthEvent_LogoutSurfaceReasonPairing(t *testing.T) {
+ // surface:logout <=> reason:logout, both directions.
+ _, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS,
+ RequestID: "req-1",
+ Origin: "local",
+ Source: "api",
+ Surface: "login",
+ Reason: "logout",
+ Caller: audit.Caller{Kind: "session_user", UserID: "u1", Role: "user"},
+ })
+ if err == nil {
+ t.Fatalf("NewAuthEvent: expected error for surface:login with reason:logout")
+ }
+
+ _, err = audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS,
+ RequestID: "req-2",
+ Origin: "local",
+ Source: "api",
+ Surface: "logout",
+ Reason: "ok",
+ Caller: audit.Caller{Kind: "session_user", UserID: "u1", Role: "user"},
+ })
+ if err == nil {
+ t.Fatalf("NewAuthEvent: expected error for surface:logout with reason:ok")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Redaction — nine sentinels (contracts/audit-line-events.md "Redaction (FR-015)")
+// ---------------------------------------------------------------------------
+//
+// Four of the nine sentinel locations (arguments, response, error text, a
+// caller-supplied `_auth_user_email`) are structurally unrepresentable: no
+// Attempt/AuthzInput/ToolCallInput/AuthEventInput field exists through which
+// raw argument, response or error text — or an `_auth_*` map member — could
+// reach a line. That is proven by the type signatures above compiling at
+// all (there is no such parameter to pass a sentinel into), not by a
+// separate runtime assertion. The remaining five sentinel locations are
+// caller/operator-controlled strings the line *does* carry, and are
+// exercised below: they must be masked (fixed-prefix patterns only, never
+// the generic high-entropy rule — see internal/logs sanitizer.go:104) by
+// the per-field pass at build time, so that the resulting line is already
+// byte-absent of the sentinel and the schema is validated *after*
+// sanitisation.
+
+const akiaSentinel = "AKIAQUICKSTART7SENTINEL0"
+const ghpSentinel = "ghp_1234567890abcdef1234567890abcdef1234"
+
+func TestRedaction_ClientNameSentinelMasked(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.ClientName = "evil-client " + akiaSentinel
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ assertSentinelAbsent(t, line, akiaSentinel)
+ validateAgainstPublishedSchema(t, line)
+}
+
+// TestRedaction_ClientNameLengthCapped is a round-2 cross-review regression
+// (PR-D): an unbounded, entirely caller-asserted MCP `initialize` clientInfo
+// value must not reach the sink unbounded — it can otherwise exceed the
+// rotating-file writer's per-record limit and cause the required authz line
+// to be silently dropped, or force unbounded audit-log disk growth.
+func TestRedaction_ClientNameLengthCapped(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.ClientName = strings.Repeat("x", 10000)
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("JSON: %v", err)
+ }
+ if len(raw) > 2000 {
+ t.Fatalf("line with a 10000-rune client.name serialised to %d bytes — length cap not applied", len(raw))
+ }
+ validateAgainstPublishedSchema(t, line)
+}
+
+func TestRedaction_TokenNameSentinelMasked(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ caller := agentCaller()
+ caller.TokenName = "token-" + akiaSentinel
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: caller,
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ assertSentinelAbsent(t, line, akiaSentinel)
+ validateAgainstPublishedSchema(t, line)
+}
+
+// TestRedaction_OpenAIProjectKeySentinelMasked is a round-3 cross-review
+// regression (PR-D): the generic `sk-` pattern required 16+ alphanumeric
+// characters immediately after the prefix, so a current-format OpenAI
+// project/service-account/admin key (`sk-proj-...`, `sk-svcacct-...`,
+// `sk-admin-...`), which inserts a hyphen-delimited segment before the
+// random suffix, fell through unmasked when placed in a
+// caller/operator-controlled field (client.name here).
+func TestRedaction_OpenAIProjectKeySentinelMasked(t *testing.T) {
+ const openAIProjSentinel = "sk-proj-QUICKSTART7SENTINEL0abcdefghijklmnopqrstuvwxyz"
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.ClientName = "evil-client " + openAIProjSentinel
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ assertSentinelAbsent(t, line, openAIProjSentinel)
+ validateAgainstPublishedSchema(t, line)
+}
+
+func TestRedaction_ProfileSentinelMasked(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.Profile = "profile-" + akiaSentinel
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ assertSentinelAbsent(t, line, akiaSentinel)
+ validateAgainstPublishedSchema(t, line)
+}
+
+// A caller-supplied server:tool pair on a REFUSED dispatch is the one case
+// FR-016 admits recording verbatim unless it is itself credential-shaped —
+// here it is, so it must still be masked.
+func TestRedaction_RefusedDispatchServerToolSentinelMasked(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.Server = akiaSentinel + ":" + ghpSentinel
+ att.Tool = akiaSentinel
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "deny", Reason: "tool_not_callable", Disclosed: boolPtr(true),
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ assertSentinelAbsent(t, line, akiaSentinel)
+ assertSentinelAbsent(t, line, ghpSentinel)
+ validateAgainstPublishedSchema(t, line)
+}
+
+// A configured (non-credential-shaped) server name on an allowed dispatch
+// must survive verbatim — masking must be conditional on the value looking
+// like a credential, not a blanket wipe of server/tool.
+func TestRedaction_ConfiguredServerNameSurvivesVerbatim(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+ att.Server = "jira-prod-01" // 40-char-ish alphanumeric, not credential-shaped per spec fixture intent
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ obj := decodeLine(t, line)
+ if obj["server"] != "jira-prod-01" {
+ t.Fatalf("configured server name must survive verbatim, got %v", obj["server"])
+ }
+}
+
+// TestRedaction_ArgsSHA256AndEmailHashSurviveVerbatim proves the
+// defence-in-depth whole-line pass excludes the generic high-entropy rule:
+// a 64-hex-char args_sha256/email_hash must never be masked, or the line
+// would fail schema validation after the pass.
+func TestRedaction_ArgsSHA256AndEmailHashSurviveVerbatim(t *testing.T) {
+ sum, n := argsHash(`{"a":1}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ line, err := audit.NewAuthz(audit.AuthzInput{
+ Ts: fixedTS, Attempt: att, Caller: agentCaller(),
+ Decision: "allow", Reason: "none",
+ })
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ obj := decodeLine(t, line)
+ if obj["args_sha256"] != sum {
+ t.Fatalf("args_sha256 must survive the whole-line sanitizer pass byte-identical: got %v want %s", obj["args_sha256"], sum)
+ }
+ validateAgainstPublishedSchema(t, line)
+}
+
+// TestSanitizerWholeLinePass_IsIdentityOnBuilderOutput asserts the
+// defence-in-depth whole-line pass (audit.SanitizeLine, per-field masking
+// having already run at build time) is a no-op on every well-formed
+// constructor output — because every caller/operator-controlled field was
+// already sanitised per field, the whole-line pass can only fire on a
+// builder bug, which is what SanitizeLine's hit-reporting is for.
+func TestSanitizerWholeLinePass_IsIdentityOnBuilderOutput(t *testing.T) {
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ fixtures := []audit.Line{}
+ authzLine, err := audit.NewAuthz(audit.AuthzInput{Ts: fixedTS, Attempt: att, Caller: agentCaller(), Decision: "allow", Reason: "none"})
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ fixtures = append(fixtures, authzLine)
+
+ toolCallLine, err := audit.NewToolCall(audit.ToolCallInput{Ts: fixedTS, Attempt: att, Caller: agentCaller(), Outcome: "success", DurationMs: 1})
+ if err != nil {
+ t.Fatalf("NewToolCall: %v", err)
+ }
+ fixtures = append(fixtures, toolCallLine)
+
+ authEventLine, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS, RequestID: "req-1", Origin: "local", Source: "api", Surface: "login", Reason: "ok",
+ Caller: audit.Caller{Kind: "session_user", UserID: "u1", Role: "user"},
+ })
+ if err != nil {
+ t.Fatalf("NewAuthEvent: %v", err)
+ }
+ fixtures = append(fixtures, authEventLine)
+
+ for i, line := range fixtures {
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("fixture %d: JSON(): %v", i, err)
+ }
+ sanitised, hit := audit.SanitizeLine(raw)
+ if hit {
+ t.Fatalf("fixture %d: whole-line sanitizer pass must not fire on well-formed builder output", i)
+ }
+ if !bytes.Equal(sanitised, raw) {
+ t.Fatalf("fixture %d: whole-line sanitizer pass must be the identity on builder output:\n got: %s\nwant: %s", i, sanitised, raw)
+ }
+ }
+
+ // Simulated builder bug: a fixed-prefix credential leaked past the
+ // per-field pass. The whole-line pass must catch it and report a hit.
+ authzRaw, err := authzLine.JSON()
+ if err != nil {
+ t.Fatalf("authzLine.JSON(): %v", err)
+ }
+ buggy := bytes.Replace(authzRaw, []byte(`"jira"`), []byte(`"`+akiaSentinel+`"`), 1)
+ sanitised, hit := audit.SanitizeLine(buggy)
+ if !hit {
+ t.Fatalf("whole-line sanitizer pass must report a hit on a planted credential")
+ }
+ if bytes.Contains(sanitised, []byte(akiaSentinel)) {
+ t.Fatalf("whole-line sanitizer pass must mask a planted credential: %s", sanitised)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+func decodeLine(t *testing.T, line audit.Line) map[string]interface{} {
+ t.Helper()
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("line.JSON(): %v", err)
+ }
+ var obj map[string]interface{}
+ if err := json.Unmarshal(raw, &obj); err != nil {
+ t.Fatalf("json.Unmarshal(line): %v\nraw: %s", err, raw)
+ }
+ return obj
+}
+
+func assertSentinelAbsent(t *testing.T, line audit.Line, sentinel string) {
+ t.Helper()
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("line.JSON(): %v", err)
+ }
+ if bytes.Contains(raw, []byte(sentinel)) {
+ t.Fatalf("sentinel %q must be byte-absent from the line, got: %s", sentinel, raw)
+ }
+}
diff --git a/internal/audit/redact.go b/internal/audit/redact.go
new file mode 100644
index 000000000..13b641309
--- /dev/null
+++ b/internal/audit/redact.go
@@ -0,0 +1,88 @@
+package audit
+
+// redact.go implements the fixed-prefix credential masking used both
+// per-field at build time (line.go) and as the defence-in-depth whole-line
+// pass (SanitizeLine) documented in contracts/audit-line-events.md
+// "Redaction (FR-015)". Deliberately excludes the generic high-entropy
+// rule: it would mask every args_sha256/email_hash and break the schema
+// after validation.
+
+import "regexp"
+
+// maxFieldLength is the length cap FR-015/FR-016 require for every
+// per-field-sanitised caller/operator-controlled string (client.name,
+// client.version, caller.token_name, profile, and server/tool on a refused
+// dispatch). Without it an unbounded value — e.g. MCP `initialize`
+// clientInfo, which is entirely caller-asserted — can exceed the audit
+// sink's rotating-file writer record limit, causing a synchronous write
+// failure that drops the required authz/tool_call line, or force
+// unbounded audit-log disk growth (round-2 cross-review finding, PR-D).
+// Applied in runes, after credential masking, so a masked value is never
+// re-split mid-escape.
+const maxFieldLength = 256
+
+// truncateField caps s at maxFieldLength runes, appending a marker so a
+// truncated value is distinguishable from one that legitimately ends at
+// the boundary.
+func truncateField(s string) string {
+ r := []rune(s)
+ if len(r) <= maxFieldLength {
+ return s
+ }
+ return string(r[:maxFieldLength]) + "...(truncated)"
+}
+
+// credentialPatterns are evaluated in order (most specific prefix first,
+// e.g. sk-ant- before sk-) so a longer, more specific match is consumed
+// before a shorter pattern could also match a prefix of it.
+var credentialPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`sk-ant-[A-Za-z0-9-]{10,}`),
+ // OpenAI keys: legacy sk-{48}, and current sk-proj-/sk-svcacct-/sk-admin-
+ // forms, which insert a hyphen-delimited segment before the random
+ // suffix (round-3 cross-review finding, PR-D — the prior pattern only
+ // matched the legacy form).
+ regexp.MustCompile(`sk-[A-Za-z0-9_-]{16,}`),
+ regexp.MustCompile(`gh[poushr]_[A-Za-z0-9]{16,}`),
+ regexp.MustCompile(`AKIA[0-9A-Za-z]{8,}`),
+ regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`),
+ regexp.MustCompile(`Bearer\s+[A-Za-z0-9\-_.~+/]+=*`),
+}
+
+func maskMatch(s string) string {
+ if len(s) <= 8 {
+ return "***"
+ }
+ return s[:4] + "***" + s[len(s)-2:]
+}
+
+// applyMasking runs every fixed-prefix pattern over s in order and reports
+// whether any of them fired.
+func applyMasking(s string) (masked string, hit bool) {
+ for _, re := range credentialPatterns {
+ if re.MatchString(s) {
+ hit = true
+ s = re.ReplaceAllStringFunc(s, maskMatch)
+ }
+ }
+ return s, hit
+}
+
+// maskCredential applies the per-field pass to one caller/operator-controlled
+// string (client.name, caller.token_name, profile, and server/tool on a
+// refused dispatch): fixed-prefix credential masking, then the length cap
+// (FR-015). A value with no credential-shaped substring and within the cap
+// survives verbatim.
+func maskCredential(s string) string {
+ masked, _ := applyMasking(s)
+ return truncateField(masked)
+}
+
+// SanitizeLine is the defence-in-depth whole-line pass applied before a
+// sink write: it must be the identity on well-formed builder output (every
+// caller/operator-controlled field was already masked per field) and only
+// fires on a builder bug. Returns the (possibly masked) line and whether
+// any pattern hit.
+func SanitizeLine(raw []byte) ([]byte, bool) {
+ masked, hit := applyMasking(string(raw))
+ return []byte(masked), hit
+}
diff --git a/internal/audit/schema_test.go b/internal/audit/schema_test.go
new file mode 100644
index 000000000..226027f35
--- /dev/null
+++ b/internal/audit/schema_test.go
@@ -0,0 +1,403 @@
+// T097 (Spec 107 PR-D): schema conformance for internal/audit.
+//
+// [compile-red until T099] — see the package doc comment in line_test.go.
+//
+// This file loads the binding wire schema (contracts/audit-line.schema.json)
+// once, validates every builder-produced fixture against it, validates the
+// schema's own `examples` array, derives a *producer-strict* variant
+// (additionalProperties flipped to false at every object level, in memory —
+// the published file itself stays consumer-tolerant per its own
+// `description`) to prove the exact key set each constructor emits, runs the
+// per-event/per-caller-kind negative fixtures enumerated in tasks.md T097,
+// and asserts the schema/doc copy stay in identity lockstep.
+package audit_test
+
+import (
+ "encoding/json"
+ "os"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+)
+
+const (
+ contractSchemaPath = "../../specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json"
+ publishedSchemaPath = "../../docs/schemas/audit-line-v1.schema.json"
+)
+
+// ---------------------------------------------------------------------------
+// schema loading
+// ---------------------------------------------------------------------------
+
+func loadSchemaDoc(t *testing.T, path string) map[string]interface{} {
+ t.Helper()
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading %s: %v", path, err)
+ }
+ var doc map[string]interface{}
+ if err := json.Unmarshal(raw, &doc); err != nil {
+ t.Fatalf("unmarshalling %s: %v", path, err)
+ }
+ return doc
+}
+
+func compileSchemaFromDoc(t *testing.T, url string, doc map[string]interface{}) *jsonschema.Schema {
+ t.Helper()
+ c := jsonschema.NewCompiler()
+ if err := c.AddResource(url, doc); err != nil {
+ t.Fatalf("AddResource(%s): %v", url, err)
+ }
+ sch, err := c.Compile(url)
+ if err != nil {
+ t.Fatalf("Compile(%s): %v", url, err)
+ }
+ return sch
+}
+
+var permissiveSchema *jsonschema.Schema
+
+func publishedSchema(t *testing.T) *jsonschema.Schema {
+ t.Helper()
+ if permissiveSchema != nil {
+ return permissiveSchema
+ }
+ doc := loadSchemaDoc(t, contractSchemaPath)
+ permissiveSchema = compileSchemaFromDoc(t, "mem://audit-line-permissive.json", doc)
+ return permissiveSchema
+}
+
+// strictifyAdditionalProperties walks every JSON-Schema "object"-shaped node
+// reachable from the document (properties/items/allOf/anyOf/oneOf/if/then/
+// else) and sets additionalProperties:false wherever the node declares
+// "properties" but no explicit additionalProperties — deriving the
+// producer-strict variant referenced by T097 without touching the published
+// (consumer-tolerant) file on disk.
+func strictifyAdditionalProperties(node interface{}) interface{} {
+ switch v := node.(type) {
+ case map[string]interface{}:
+ out := make(map[string]interface{}, len(v))
+ for k, val := range v {
+ out[k] = strictifyAdditionalProperties(val)
+ }
+ if _, hasProps := out["properties"]; hasProps {
+ out["additionalProperties"] = false
+ }
+ return out
+ case []interface{}:
+ out := make([]interface{}, len(v))
+ for i, val := range v {
+ out[i] = strictifyAdditionalProperties(val)
+ }
+ return out
+ default:
+ return node
+ }
+}
+
+func producerStrictSchema(t *testing.T) *jsonschema.Schema {
+ t.Helper()
+ doc := loadSchemaDoc(t, contractSchemaPath)
+ strict := strictifyAdditionalProperties(doc).(map[string]interface{})
+ return compileSchemaFromDoc(t, "mem://audit-line-strict.json", strict)
+}
+
+func validateAgainstPublishedSchema(t *testing.T, line audit.Line) {
+ t.Helper()
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("line.JSON(): %v", err)
+ }
+ validateBytesAgainstSchema(t, publishedSchema(t), raw)
+}
+
+func validateBytesAgainstSchema(t *testing.T, sch *jsonschema.Schema, raw []byte) {
+ t.Helper()
+ var inst interface{}
+ if err := json.Unmarshal(raw, &inst); err != nil {
+ t.Fatalf("json.Unmarshal: %v\nraw: %s", err, raw)
+ }
+ if err := sch.Validate(inst); err != nil {
+ t.Fatalf("schema validation failed: %v\nline: %s", err, raw)
+ }
+}
+
+func mustFail(t *testing.T, sch *jsonschema.Schema, obj map[string]interface{}, label string) {
+ t.Helper()
+ raw, err := json.Marshal(obj)
+ if err != nil {
+ t.Fatalf("%s: json.Marshal: %v", label, err)
+ }
+ var inst interface{}
+ if err := json.Unmarshal(raw, &inst); err != nil {
+ t.Fatalf("%s: json.Unmarshal: %v", label, err)
+ }
+ if err := sch.Validate(inst); err == nil {
+ t.Fatalf("%s: expected schema validation to reject fixture, but it passed: %s", label, raw)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// examples
+// ---------------------------------------------------------------------------
+
+func TestSchemaExamplesValidate(t *testing.T) {
+ doc := loadSchemaDoc(t, contractSchemaPath)
+ examples, ok := doc["examples"].([]interface{})
+ if !ok || len(examples) == 0 {
+ t.Fatalf("contracts/audit-line.schema.json: expected a non-empty top-level examples array")
+ }
+ sch := publishedSchema(t)
+ for i, ex := range examples {
+ if err := sch.Validate(ex); err != nil {
+ t.Fatalf("examples[%d] failed to validate: %v\n%v", i, err, ex)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// producer-strict key set
+// ---------------------------------------------------------------------------
+
+func TestProducerStrictSchema_BuilderOutputHasExactKeySet(t *testing.T) {
+ strict := producerStrictSchema(t)
+
+ sum, n := argsHash(`{}`)
+ att := baseAttempt()
+ att.ArgsSHA256, att.ArgsBytes = sum, n
+
+ authzLine, err := audit.NewAuthz(audit.AuthzInput{Ts: fixedTS, Attempt: att, Caller: agentCaller(), Decision: "allow", Reason: "none"})
+ if err != nil {
+ t.Fatalf("NewAuthz: %v", err)
+ }
+ toolCallLine, err := audit.NewToolCall(audit.ToolCallInput{Ts: fixedTS, Attempt: att, Caller: agentCaller(), Outcome: "success", DurationMs: 1})
+ if err != nil {
+ t.Fatalf("NewToolCall: %v", err)
+ }
+ authEventLine, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: fixedTS, RequestID: "req-1", Origin: "local", Source: "api", Surface: "login", Reason: "ok",
+ Caller: audit.Caller{Kind: "session_user", UserID: "u1", Role: "user"},
+ })
+ if err != nil {
+ t.Fatalf("NewAuthEvent: %v", err)
+ }
+
+ for name, line := range map[string]audit.Line{
+ "authz": authzLine,
+ "tool_call": toolCallLine,
+ "auth_event": authEventLine,
+ } {
+ raw, err := line.JSON()
+ if err != nil {
+ t.Fatalf("%s: line.JSON(): %v", name, err)
+ }
+ validateBytesAgainstSchema(t, strict, raw)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// negative identity fixtures (tasks.md T097)
+// ---------------------------------------------------------------------------
+
+func baseAuthzFixture(overrides map[string]interface{}) map[string]interface{} {
+ fx := map[string]interface{}{
+ "schema_version": 1,
+ "ts": "2026-09-17T10:00:00.000000001Z",
+ "event": "authz",
+ "request_id": "req-1",
+ "origin": "local",
+ "source": "mcp",
+ "surface": "call_tool_write",
+ "server": "jira",
+ "tool": "create_issue",
+ "operation": "write",
+ "decision": "allow",
+ "reason": "none",
+ "args_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "args_bytes": 2,
+ "caller": map[string]interface{}{"kind": "api_key"},
+ }
+ for k, v := range overrides {
+ fx[k] = v
+ }
+ return fx
+}
+
+func baseAuthEventFixture(caller map[string]interface{}, reason string) map[string]interface{} {
+ return map[string]interface{}{
+ "schema_version": 1,
+ "ts": "2026-09-17T10:00:00.000000001Z",
+ "event": "auth_event",
+ "request_id": "req-1",
+ "origin": "local",
+ "source": "api",
+ "surface": "login",
+ "reason": reason,
+ "caller": caller,
+ }
+}
+
+func TestNegativeFixtures_CallerIdentityRules(t *testing.T) {
+ sch := publishedSchema(t)
+
+ cases := []struct {
+ name string
+ obj map[string]interface{}
+ }{
+ {"session_user without user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "role": "user"}, "ok")},
+ {"session_user with wrong role", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "admin"}, "ok")},
+ {"session_admin without user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_admin", "role": "admin"}, "ok")},
+ {"session_admin with wrong role", baseAuthEventFixture(map[string]interface{}{"kind": "session_admin", "user_id": "u1", "role": "user"}, "ok")},
+ {"anonymous with user_id", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous", "user_id": "u1"}, "nonce_mismatch")},
+ {"anonymous with provider", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous", "provider": "google"}, "nonce_mismatch")},
+ {"agent_token without token_name/token_prefix", baseAuthzFixture(map[string]interface{}{"caller": map[string]interface{}{"kind": "agent_token"}})},
+ {"owned agent_token missing user_email/role/provider", baseAuthzFixture(map[string]interface{}{"caller": map[string]interface{}{"kind": "agent_token", "token_name": "t", "token_prefix": "mcp_agt_ab12", "user_id": "u1"}})},
+ {"ownerless agent_token carrying role", baseAuthzFixture(map[string]interface{}{"caller": map[string]interface{}{"kind": "agent_token", "token_name": "t", "token_prefix": "mcp_agt_ab12", "role": "user"}})},
+ {"ownerless agent_token carrying provider", baseAuthzFixture(map[string]interface{}{"caller": map[string]interface{}{"kind": "agent_token", "token_name": "t", "token_prefix": "mcp_agt_ab12", "provider": "google"}})},
+ {"email_hash beside user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user", "email_hash": stringOfLen("a", 64)}, "ok")},
+ {"auth_event with user_email", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user", "user_email": "alice@example.com"}, "ok")},
+ {"surface login with reason logout", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user"}, "logout")},
+ {"surface logout with reason ok", map[string]interface{}{
+ "schema_version": 1, "ts": "2026-09-17T10:00:00.000000001Z", "event": "auth_event",
+ "request_id": "req-1", "origin": "local", "source": "api", "surface": "logout", "reason": "ok",
+ "caller": map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user"},
+ }},
+ {"reason:ok with anonymous caller", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous"}, "ok")},
+ {"reason:ok without user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "role": "user"}, "ok")},
+ {"reason:logout without user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "role": "user"}, "logout")},
+ {"reason:subject_mismatch with anonymous caller", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous"}, "subject_mismatch")},
+ {"reason:user_disabled without user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_admin", "role": "admin"}, "user_disabled")},
+ {"domain_not_allowed without email_hash", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous"}, "domain_not_allowed")},
+ {"domain_not_allowed with session caller", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user"}, "domain_not_allowed")},
+ {"userinfo_subject_mismatch without email_hash", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous"}, "userinfo_subject_mismatch")},
+ {"provider_error with user_id", baseAuthEventFixture(map[string]interface{}{"kind": "session_user", "user_id": "u1", "role": "user"}, "provider_error")},
+ {"pre-identity reason with email_hash", baseAuthEventFixture(map[string]interface{}{"kind": "anonymous", "email_hash": stringOfLen("c", 64)}, "nonce_mismatch")},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ mustFail(t, sch, tc.obj, tc.name)
+ })
+ }
+}
+
+func stringOfLen(ch string, n int) string {
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = ch[0]
+ }
+ return string(b)
+}
+
+// ---------------------------------------------------------------------------
+// schema-identity sync
+// ---------------------------------------------------------------------------
+
+func TestSchemaIdentitySync(t *testing.T) {
+ contract := loadSchemaDoc(t, contractSchemaPath)
+
+ version, ok := contract["properties"].(map[string]interface{})["schema_version"].(map[string]interface{})["const"].(float64)
+ if !ok {
+ t.Fatalf("contract schema: properties.schema_version.const is not a number")
+ }
+ n := int(version)
+
+ id, _ := contract["$id"].(string)
+ wantIDSuffix := "audit-line-v" + itoa(n) + ".json"
+ if !hasSuffix(id, wantIDSuffix) {
+ t.Fatalf("$id %q does not encode schema_version %d (want suffix %q)", id, n, wantIDSuffix)
+ }
+
+ title, _ := contract["title"].(string)
+ wantTitleSuffix := "schema_version " + itoa(n) + ")"
+ if !hasSuffix(title, wantTitleSuffix) {
+ t.Fatalf("title %q does not encode schema_version %d (want suffix %q)", title, n, wantTitleSuffix)
+ }
+
+ wantPublishedPath := "../../docs/schemas/audit-line-v" + itoa(n) + ".schema.json"
+ if wantPublishedPath != publishedSchemaPath {
+ t.Fatalf("publishedSchemaPath %q does not match schema_version-derived path %q", publishedSchemaPath, wantPublishedPath)
+ }
+
+ published, err := os.ReadFile(publishedSchemaPath)
+ if err != nil {
+ t.Fatalf("reading published schema %s: %v (T099 must copy the contract to docs/schemas/)", publishedSchemaPath, err)
+ }
+ contractRaw, err := os.ReadFile(contractSchemaPath)
+ if err != nil {
+ t.Fatalf("reading contract schema %s: %v", contractSchemaPath, err)
+ }
+ if string(published) != string(contractRaw) {
+ t.Fatalf("docs/schemas/audit-line-v%d.schema.json must be byte-identical to the contract copy", n)
+ }
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ neg := n < 0
+ if neg {
+ n = -n
+ }
+ var b []byte
+ for n > 0 {
+ b = append([]byte{byte('0' + n%10)}, b...)
+ n /= 10
+ }
+ if neg {
+ b = append([]byte{'-'}, b...)
+ }
+ return string(b)
+}
+
+func hasSuffix(s, suffix string) bool {
+ return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
+}
+
+// ---------------------------------------------------------------------------
+// external JSONL gate (quickstart §6 / T116)
+// ---------------------------------------------------------------------------
+
+// TestExternalJSONLValidates lets the quickstart / T116 real-instance gate
+// validate an actual sink file without npx/ajv-cli:
+//
+// MCPPROXY_AUDIT_JSONL= go test ./internal/audit -run TestExternalJSONLValidates
+func TestExternalJSONLValidates(t *testing.T) {
+ path := os.Getenv("MCPPROXY_AUDIT_JSONL")
+ if path == "" {
+ t.Skip("MCPPROXY_AUDIT_JSONL not set")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading %s: %v", path, err)
+ }
+ sch := publishedSchema(t)
+ lines := splitLines(raw)
+ if len(lines) == 0 {
+ t.Fatalf("%s: no lines to validate", path)
+ }
+ for _, line := range lines {
+ if len(line) == 0 {
+ continue
+ }
+ validateBytesAgainstSchema(t, sch, line)
+ }
+}
+
+func splitLines(raw []byte) [][]byte {
+ var out [][]byte
+ start := 0
+ for i, b := range raw {
+ if b == '\n' {
+ out = append(out, raw[start:i])
+ start = i + 1
+ }
+ }
+ if start < len(raw) {
+ out = append(out, raw[start:])
+ }
+ return out
+}
diff --git a/internal/audit/sink.go b/internal/audit/sink.go
new file mode 100644
index 000000000..eb5e69518
--- /dev/null
+++ b/internal/audit/sink.go
@@ -0,0 +1,200 @@
+package audit
+
+// sink.go: the mutex-guarded, synchronous audit sink (plan.md Complexity
+// Tracking; research.md D6). Two backends share one writer implementation:
+// NewStdoutSink writes raw JSON straight to an injected io.Writer (never
+// through a zap core — Spec 107 stdio-transport rule, FR-014), NewFileSink
+// is built on lumberjack for size/age/backup rotation, which never splits
+// a line because it rotates before a write that would exceed MaxSize, not
+// mid-write.
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "gopkg.in/natefinch/lumberjack.v2"
+)
+
+// failureLogRateLimit bounds how often a write-failure logger callback is
+// invoked, independent of how often writes actually fail.
+const failureLogRateLimit = time.Minute
+
+// Sink is the audit line writer: mutex-guarded and synchronous (the caller
+// blocks until the line is on disk/stdout), with an always-on write-failure
+// counter (mirrored to metrics, surfaced by doctor — wiring is T109, not
+// this package) and an always-on defence-in-depth sanitizer-hit counter
+// (contracts/audit-line-events.md "Redaction (FR-015)": Write runs every
+// line through SanitizeLine before it reaches the underlying writer, and a
+// hit — which can only happen on a builder bug, since every well-formed
+// constructor output is untouched by the pass — is counted here).
+type Sink interface {
+ Write(line []byte) error
+ WriteFailures() uint64
+ SanitizerHits() uint64
+ Close() error
+}
+
+type sinkOptions struct {
+ clock func() time.Time
+ failureLogger func(err error)
+}
+
+// Option configures a Sink at construction.
+type Option func(*sinkOptions)
+
+// WithClock overrides the clock used for the write-failure log rate limit
+// (tests only; production sinks use time.Now).
+func WithClock(now func() time.Time) Option {
+ return func(o *sinkOptions) { o.clock = now }
+}
+
+// WithFailureLogger installs a callback invoked at most once per minute
+// when a write fails, receiving the most recent error.
+func WithFailureLogger(fn func(err error)) Option {
+ return func(o *sinkOptions) { o.failureLogger = fn }
+}
+
+// writerSink is the shared Sink implementation behind both backends.
+type writerSink struct {
+ mu sync.Mutex
+ w io.Writer
+ closer io.Closer
+ failures uint64 // atomic
+ sanitizerHits uint64 // atomic
+
+ clock func() time.Time
+ failureLogger func(error)
+ logMu sync.Mutex
+ lastLoggedAt time.Time
+}
+
+func newWriterSink(w io.Writer, opts ...Option) *writerSink {
+ o := &sinkOptions{clock: time.Now}
+ for _, opt := range opts {
+ opt(o)
+ }
+ closer, _ := w.(io.Closer)
+ return &writerSink{
+ w: w,
+ closer: closer,
+ clock: o.clock,
+ failureLogger: o.failureLogger,
+ }
+}
+
+// Write appends line (adding exactly one trailing newline if the caller did
+// not already include one) under the sink's mutex, so concurrent callers
+// never interleave partial lines. Before anything else, line passes through
+// the defence-in-depth whole-line sanitizer (SanitizeLine, FR-015): the
+// identity on every well-formed constructor output, but a safety net
+// against a future builder bug that lets a credential-shaped string past
+// the per-field masking. A hit increments the always-on SanitizerHits
+// counter and the (possibly masked) line is still written — the sink never
+// drops a line. A write failure increments the always-on write-failure
+// counter and, rate-limited, invokes the failure logger; it never panics
+// and control always returns to the caller.
+func (s *writerSink) Write(line []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ buf, hit := SanitizeLine(line)
+ if hit {
+ atomic.AddUint64(&s.sanitizerHits, 1)
+ }
+ if len(buf) == 0 || buf[len(buf)-1] != '\n' {
+ buf = append(buf, '\n')
+ }
+
+ n, err := s.w.Write(buf)
+ if err == nil && n != len(buf) {
+ // io.Writer permits a short write with a nil error; treated as a
+ // failure here so a partial JSON record never counts as a
+ // successfully written line (round-1 cross-review finding, PR-D).
+ err = io.ErrShortWrite
+ }
+ if err != nil {
+ atomic.AddUint64(&s.failures, 1)
+ s.maybeLogFailure(err)
+ }
+ return err
+}
+
+func (s *writerSink) maybeLogFailure(err error) {
+ if s.failureLogger == nil {
+ return
+ }
+ s.logMu.Lock()
+ now := s.clock()
+ if !s.lastLoggedAt.IsZero() && now.Sub(s.lastLoggedAt) < failureLogRateLimit {
+ s.logMu.Unlock()
+ return
+ }
+ s.lastLoggedAt = now
+ s.logMu.Unlock()
+ s.failureLogger(err)
+}
+
+// WriteFailures returns the always-on write-failure count. It reads zero
+// from construction, independent of whether metrics mirroring is enabled
+// anywhere.
+func (s *writerSink) WriteFailures() uint64 {
+ return atomic.LoadUint64(&s.failures)
+}
+
+// SanitizerHits returns the always-on count of lines whose defence-in-depth
+// whole-line sanitizer pass fired. It reads zero from construction and
+// stays zero for the lifetime of the process unless a builder bug lets a
+// credential-shaped string past the per-field masking.
+func (s *writerSink) SanitizerHits() uint64 {
+ return atomic.LoadUint64(&s.sanitizerHits)
+}
+
+// Close releases the underlying writer's handle, if it has one (the stdout
+// sink's injected io.Writer typically does not).
+func (s *writerSink) Close() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closer != nil {
+ return s.closer.Close()
+ }
+ return nil
+}
+
+// NewStdoutSink writes raw JSON lines straight to w (one call per line, no
+// zap core, no ANSI, no encoder prefix) — the stdio-transport rule of
+// FR-014: stdout must stay pure JSON-RPC on that transport, so this sink is
+// never installed there.
+func NewStdoutSink(w io.Writer, opts ...Option) Sink {
+ return newWriterSink(w, opts...)
+}
+
+// NewFileSink opens (creating if absent) an append-only rotating file at
+// path. It probes writability at construction time — lumberjack itself
+// opens lazily, so a naive wrapper would only fail on the first Write —
+// and returns a non-nil error (never a partially-usable Sink) when the
+// path cannot be opened for append.
+func NewFileSink(path string, maxSizeMB, maxBackups, maxAgeDays int, compress bool, opts ...Option) (Sink, error) {
+ if err := probeWritable(path); err != nil {
+ return nil, err
+ }
+ lj := &lumberjack.Logger{
+ Filename: path,
+ MaxSize: maxSizeMB,
+ MaxBackups: maxBackups,
+ MaxAge: maxAgeDays,
+ Compress: compress,
+ }
+ return newWriterSink(lj, opts...), nil
+}
+
+func probeWritable(path string) error {
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("audit: cannot open %s for append: %w", path, err)
+ }
+ return f.Close()
+}
diff --git a/internal/audit/sink_test.go b/internal/audit/sink_test.go
new file mode 100644
index 000000000..bd5cf3594
--- /dev/null
+++ b/internal/audit/sink_test.go
@@ -0,0 +1,577 @@
+package audit
+
+// Spec 107 PR-D, T098: failing tests for the audit sink (internal/audit/sink.go,
+// implemented by T099). This file is intentionally compile-red until T099 lands
+// internal/audit/{attempt.go,canonical.go,line.go,sink.go}: it exercises the
+// production API those files must expose.
+//
+// Assumed surface (data-model.md §5 covers only audit.Attempt; the Sink shape
+// itself is a T099 implementation detail this test file pins down):
+//
+// type Sink interface {
+// Write(line []byte) error // mutex-guarded, synchronous, write-through
+// WriteFailures() uint64 // always-on atomic counter (metrics flag irrelevant)
+// Close() error
+// }
+//
+// func NewFileSink(path string, maxSizeMB, maxBackups, maxAgeDays int, compress bool, opts ...Option) (Sink, error)
+// func NewStdoutSink(w io.Writer, opts ...Option) Sink
+//
+// type Option func(*sinkOptions)
+// func WithClock(now func() time.Time) Option
+// func WithFailureLogger(fn func(err error)) Option // rate-limited to once/min by the sink itself
+//
+// research.md D6: the file sink is built on `logs.NewRotatingWriter` (lumberjack);
+// the stdout sink writes raw JSON straight to an injected io.Writer, never
+// through a zap core. contracts/config-keys.md: an unwritable path is a
+// construction-time error (lumberjack opens lazily, so NewFileSink must probe
+// the path itself with OpenFile(O_APPEND|O_CREATE|O_WRONLY) before installing
+// the rotating writer) — mapping that error to process exit code 4 is the
+// caller's job (config/main.go, T109), not this package's.
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// ---------------------------------------------------------------------------
+// test doubles
+// ---------------------------------------------------------------------------
+
+// fakeClock is a manually-advanced clock for the once-per-minute
+// write-failure log rate limit.
+type fakeClock struct {
+ mu sync.Mutex
+ now time.Time
+}
+
+func newFakeClock(start time.Time) *fakeClock {
+ return &fakeClock{now: start}
+}
+
+func (c *fakeClock) Now() time.Time {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.now
+}
+
+func (c *fakeClock) Advance(d time.Duration) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.now = c.now.Add(d)
+}
+
+// failingWriter fails every Write from call number failFrom onward
+// (1-indexed); it is safe for concurrent use.
+type failingWriter struct {
+ mu sync.Mutex
+ buf bytes.Buffer
+ calls int
+ failFrom int // 0 = never fail
+}
+
+func (w *failingWriter) Write(p []byte) (int, error) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.calls++
+ if w.failFrom > 0 && w.calls >= w.failFrom {
+ return 0, errors.New("simulated write failure")
+ }
+ return w.buf.Write(p)
+}
+
+func (w *failingWriter) String() string {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.buf.String()
+}
+
+// ---------------------------------------------------------------------------
+// always-on write-failure counter
+// ---------------------------------------------------------------------------
+
+func TestSink_WriteFailuresCounterAlwaysOn(t *testing.T) {
+ // No metrics flag, no opt-in: the counter exists and reads zero from the
+ // moment the sink is constructed, independent of whether Prometheus
+ // mirroring (internal/observability, T109) is enabled anywhere.
+ var buf bytes.Buffer
+ s := NewStdoutSink(&buf)
+ t.Cleanup(func() { _ = s.Close() })
+
+ if got := s.WriteFailures(); got != 0 {
+ t.Fatalf("WriteFailures() before any write = %d, want 0", got)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// defence-in-depth whole-line sanitizer pass, wired into the production
+// write path (contracts/audit-line-events.md "Redaction (FR-015)")
+// ---------------------------------------------------------------------------
+
+// TestSink_WriteRunsLinesThroughSanitizerAndCountsHits proves the sink's
+// Write itself — not just the standalone SanitizeLine helper — applies the
+// defence-in-depth whole-line pass before a line reaches the underlying
+// writer, and that a hit is counted on Sink.SanitizerHits(). This is the
+// production wiring the contract requires as a safety net against a future
+// builder bug that lets a credential-shaped string past per-field masking;
+// without it, such a string would reach disk/stdout in clear.
+func TestSink_WriteRunsLinesThroughSanitizerAndCountsHits(t *testing.T) {
+ var buf bytes.Buffer
+ s := NewStdoutSink(&buf)
+ t.Cleanup(func() { _ = s.Close() })
+
+ if got := s.SanitizerHits(); got != 0 {
+ t.Fatalf("SanitizerHits() before any write = %d, want 0", got)
+ }
+
+ clean := []byte(`{"server":"jira"}`)
+ if err := s.Write(clean); err != nil {
+ t.Fatalf("Write(clean): %v", err)
+ }
+ if got := s.SanitizerHits(); got != 0 {
+ t.Fatalf("SanitizerHits() after a clean write = %d, want 0", got)
+ }
+ if !strings.Contains(buf.String(), `"server":"jira"`) {
+ t.Fatalf("clean line must be written verbatim, got: %s", buf.String())
+ }
+
+ // Simulated builder bug: a fixed-prefix credential reaches Write directly
+ // (standing in for a future field that skips per-field masking).
+ const sinkTestAkiaSentinel = "AKIASINKTEST7SENTINEL0"
+ buf.Reset()
+ leaked := []byte(`{"client":{"name":"` + sinkTestAkiaSentinel + `"}}`)
+ if err := s.Write(leaked); err != nil {
+ t.Fatalf("Write(leaked): %v", err)
+ }
+ if got := s.SanitizerHits(); got != 1 {
+ t.Fatalf("SanitizerHits() after a credential-shaped write = %d, want 1", got)
+ }
+ if strings.Contains(buf.String(), sinkTestAkiaSentinel) {
+ t.Fatalf("credential must be masked before it reaches the writer, got: %s", buf.String())
+ }
+}
+
+func TestSink_RuntimeWriteFailureIncrementsCounterAndCallProceeds(t *testing.T) {
+ fw := &failingWriter{failFrom: 2} // first write ok, every write after fails
+ clock := newFakeClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))
+
+ var loggedMu sync.Mutex
+ var logged []time.Time
+ s := NewStdoutSink(fw,
+ WithClock(clock.Now),
+ WithFailureLogger(func(err error) {
+ if err == nil {
+ t.Error("WithFailureLogger called with nil error")
+ }
+ loggedMu.Lock()
+ logged = append(logged, clock.Now())
+ loggedMu.Unlock()
+ }),
+ )
+ t.Cleanup(func() { _ = s.Close() })
+
+ // First write succeeds.
+ if err := s.Write([]byte(`{"seq":0}` + "\n")); err != nil {
+ t.Fatalf("first Write() unexpected error: %v", err)
+ }
+ if got := s.WriteFailures(); got != 0 {
+ t.Fatalf("WriteFailures() after ok write = %d, want 0", got)
+ }
+
+ // Subsequent writes fail at the underlying writer; the call must not
+ // panic and control must return to the caller (the funnel proceeds).
+ for i := 1; i <= 5; i++ {
+ err := s.Write([]byte(fmt.Sprintf(`{"seq":%d}`+"\n", i)))
+ if err == nil {
+ t.Fatalf("Write() #%d: want error from failing writer, got nil", i)
+ }
+ }
+
+ if got := s.WriteFailures(); got != 5 {
+ t.Fatalf("WriteFailures() after 5 failing writes = %d, want 5", got)
+ }
+
+ // All five failures happened inside the same minute: the rate limit
+ // must have logged at most once.
+ loggedMu.Lock()
+ firstWindowLogs := len(logged)
+ loggedMu.Unlock()
+ if firstWindowLogs != 1 {
+ t.Fatalf("failure-logger calls within one minute = %d, want 1", firstWindowLogs)
+ }
+
+ // Advance the fake clock past the one-minute window and fail again: a
+ // second log call is now permitted.
+ clock.Advance(61 * time.Second)
+ if err := s.Write([]byte(`{"seq":6}` + "\n")); err == nil {
+ t.Fatal("Write() after clock advance: want error from failing writer, got nil")
+ }
+ if got := s.WriteFailures(); got != 6 {
+ t.Fatalf("WriteFailures() after 6th failure = %d, want 6", got)
+ }
+
+ loggedMu.Lock()
+ secondWindowLogs := len(logged)
+ loggedMu.Unlock()
+ if secondWindowLogs != 2 {
+ t.Fatalf("failure-logger calls after clock advance = %d, want 2 total", secondWindowLogs)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// stdout sink
+// ---------------------------------------------------------------------------
+
+func TestNewStdoutSink_WritesRawJSONToInjectedWriter(t *testing.T) {
+ var buf bytes.Buffer
+ s := NewStdoutSink(&buf)
+ t.Cleanup(func() { _ = s.Close() })
+
+ line := []byte(`{"schema_version":1,"event":"authz"}`)
+ if err := s.Write(line); err != nil {
+ t.Fatalf("Write() unexpected error: %v", err)
+ }
+
+ got := buf.String()
+ // Raw JSON line plus exactly one trailing newline: no zap console
+ // encoder timestamp/level prefix, no ANSI color codes, nothing else on
+ // the line.
+ want := string(line) + "\n"
+ if got != want {
+ t.Fatalf("stdout sink wrote %q, want %q (must never go through a zap core)", got, want)
+ }
+ if strings.Contains(got, "\x1b[") {
+ t.Fatalf("stdout sink output contains ANSI escape codes: %q", got)
+ }
+
+ var decoded map[string]any
+ if err := json.Unmarshal([]byte(strings.TrimSuffix(got, "\n")), &decoded); err != nil {
+ t.Fatalf("stdout sink output is not valid single-line JSON: %v", err)
+ }
+}
+
+func TestNewStdoutSink_MultipleWritesEachOwnLine(t *testing.T) {
+ var buf bytes.Buffer
+ s := NewStdoutSink(&buf)
+ t.Cleanup(func() { _ = s.Close() })
+
+ for i := 0; i < 3; i++ {
+ if err := s.Write([]byte(fmt.Sprintf(`{"seq":%d}`, i))); err != nil {
+ t.Fatalf("Write() #%d unexpected error: %v", i, err)
+ }
+ }
+
+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ if len(lines) != 3 {
+ t.Fatalf("got %d lines, want 3: %q", len(lines), buf.String())
+ }
+ for i, l := range lines {
+ var decoded struct {
+ Seq int `json:"seq"`
+ }
+ if err := json.Unmarshal([]byte(l), &decoded); err != nil {
+ t.Fatalf("line %d not valid JSON: %v (%q)", i, err, l)
+ }
+ if decoded.Seq != i {
+ t.Fatalf("line %d seq = %d, want %d", i, decoded.Seq, i)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// file sink: append-only, write-through, rotation params, unwritable path
+// ---------------------------------------------------------------------------
+
+func TestNewFileSink_AppendOnlyWriteThrough(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "audit.jsonl")
+
+ if err := os.WriteFile(path, []byte(`{"seq":-1}`+"\n"), 0o600); err != nil {
+ t.Fatalf("seeding pre-existing file: %v", err)
+ }
+
+ s, err := NewFileSink(path, 50, 10, 90, true)
+ if err != nil {
+ t.Fatalf("NewFileSink() unexpected error: %v", err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+
+ if err := s.Write([]byte(`{"seq":0}` + "\n")); err != nil {
+ t.Fatalf("Write() unexpected error: %v", err)
+ }
+
+ // Write-through: the line must be on disk immediately after Write
+ // returns, with no separate Flush/Sync/Close required.
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading sink file: %v", err)
+ }
+ want := "{\"seq\":-1}\n{\"seq\":0}\n"
+ if string(got) != want {
+ t.Fatalf("file sink content = %q, want %q (append-only, write-through)", string(got), want)
+ }
+}
+
+func TestNewFileSink_UnwritablePath_ParentDirMissing(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "does-not-exist", "audit.jsonl")
+
+ s, err := NewFileSink(path, 50, 10, 90, true)
+ if err == nil {
+ _ = s.Close()
+ t.Fatal("NewFileSink() with a missing parent directory: want error, got nil")
+ }
+ if s != nil {
+ t.Fatalf("NewFileSink() returned a non-nil sink alongside an error: %v", s)
+ }
+}
+
+func TestNewFileSink_UnwritablePath_PermissionDenied(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("POSIX permission bits don't apply on windows")
+ }
+ if os.Geteuid() == 0 {
+ t.Skip("root ignores permission bits")
+ }
+
+ dir := t.TempDir()
+ roDir := filepath.Join(dir, "readonly")
+ if err := os.Mkdir(roDir, 0o555); err != nil {
+ t.Fatalf("creating read-only dir: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chmod(roDir, 0o755) }) // let TempDir clean up
+
+ path := filepath.Join(roDir, "audit.jsonl")
+ s, err := NewFileSink(path, 50, 10, 90, true)
+ if err == nil {
+ _ = s.Close()
+ t.Fatal("NewFileSink() on an unwritable directory: want error, got nil")
+ }
+
+ // The probe (OpenFile(O_APPEND|O_CREATE|O_WRONLY) then close) must run
+ // at construction time, not lazily on the first Write — lumberjack
+ // itself only opens the file lazily, so a naive implementation that
+ // merely hands the path to logs.NewRotatingWriter would return nil
+ // here and fail only later.
+ if _, statErr := os.Stat(path); statErr == nil {
+ t.Fatal("NewFileSink() left a file behind on the failed construction path")
+ }
+}
+
+func TestNewFileSink_ConstructionProbesBeforeFirstWrite(t *testing.T) {
+ // A successful construction must not silently defer the writability
+ // check to the first Write call: probing at construction time means a
+ // caller that only constructs (e.g. a --check-config dry run) already
+ // knows the path is usable.
+ dir := t.TempDir()
+ path := filepath.Join(dir, "audit.jsonl")
+
+ s, err := NewFileSink(path, 50, 10, 90, true)
+ if err != nil {
+ t.Fatalf("NewFileSink() unexpected error: %v", err)
+ }
+ defer func() { _ = s.Close() }()
+
+ if _, statErr := os.Stat(path); statErr != nil {
+ t.Fatalf("expected the probe to create %s at construction time: %v", path, statErr)
+ }
+}
+
+func TestNewFileSink_RotationNeverSplitsALine(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "audit.jsonl")
+
+ // Smallest lumberjack rotation unit is 1 MB; write enough ~250-byte
+ // lines to force several rotations inside one test.
+ s, err := NewFileSink(path, 1 /* MB */, 10, 90, false)
+ if err != nil {
+ t.Fatalf("NewFileSink() unexpected error: %v", err)
+ }
+ t.Cleanup(func() { _ = s.Close() })
+
+ const total = 6000
+ padding := strings.Repeat("x", 180)
+ for i := 0; i < total; i++ {
+ line := fmt.Sprintf(`{"seq":%d,"pad":%q}`+"\n", i, padding)
+ if err := s.Write([]byte(line)); err != nil {
+ t.Fatalf("Write() #%d unexpected error: %v", i, err)
+ }
+ }
+ if err := s.Close(); err != nil {
+ t.Fatalf("Close() unexpected error: %v", err)
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatalf("reading sink dir: %v", err)
+ }
+ var files []string
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if strings.Contains(e.Name(), "audit") {
+ files = append(files, filepath.Join(dir, e.Name()))
+ }
+ }
+ if len(files) < 2 {
+ t.Fatalf("expected rotation to produce at least 2 files with MaxSizeMB=1, got %d: %v", len(files), files)
+ }
+ sort.Strings(files)
+
+ seen := make(map[int]bool, total)
+ for _, f := range files {
+ raw, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatalf("reading %s: %v", f, err)
+ }
+ for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
+ if l == "" {
+ continue
+ }
+ var decoded struct {
+ Seq int `json:"seq"`
+ }
+ if err := json.Unmarshal([]byte(l), &decoded); err != nil {
+ t.Fatalf("line in %s is not valid whole JSON (a rotation split it): %v\nline: %q", f, err, l)
+ }
+ if seen[decoded.Seq] {
+ t.Fatalf("seq %d appears more than once across rotated files", decoded.Seq)
+ }
+ seen[decoded.Seq] = true
+ }
+ }
+ if len(seen) != total {
+ t.Fatalf("recovered %d distinct lines across %d files, want %d (a split or dropped line would show here)", len(seen), len(files), total)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// concurrency: whole lines, no interleaving
+// ---------------------------------------------------------------------------
+
+func TestSink_ConcurrentWritesProduceWholeLinesNoInterleaving(t *testing.T) {
+ const n = 2000
+ var buf bytes.Buffer // deliberately not synchronized: the Sink's own
+ // mutex must be what makes this safe.
+ s := NewStdoutSink(&buf)
+ t.Cleanup(func() { _ = s.Close() })
+
+ var wg sync.WaitGroup
+ var writeErrs int64
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ go func(seq int) {
+ defer wg.Done()
+ line := fmt.Sprintf(`{"seq":%d,"pad":"%s"}`, seq, strings.Repeat("y", seq%37))
+ if err := s.Write([]byte(line)); err != nil {
+ atomic.AddInt64(&writeErrs, 1)
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ if writeErrs != 0 {
+ t.Fatalf("%d concurrent writes returned an error against a healthy writer", writeErrs)
+ }
+
+ lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
+ if len(lines) != n {
+ t.Fatalf("got %d lines from %d concurrent writes, want %d (interleaving would corrupt the count)", len(lines), n, n)
+ }
+
+ seen := make(map[int]bool, n)
+ for _, l := range lines {
+ var decoded struct {
+ Seq int `json:"seq"`
+ }
+ if err := json.Unmarshal([]byte(l), &decoded); err != nil {
+ t.Fatalf("line is not valid whole JSON (interleaved writes): %v\nline: %q", err, l)
+ }
+ if seen[decoded.Seq] {
+ t.Fatalf("seq %d observed twice: a write's bytes were duplicated/torn", decoded.Seq)
+ }
+ seen[decoded.Seq] = true
+ }
+ if len(seen) != n {
+ t.Fatalf("recovered %d distinct seqs, want %d", len(seen), n)
+ }
+}
+
+func TestSink_ConcurrentWritesUnderRuntimeFailuresStillCountEveryCall(t *testing.T) {
+ // A saturated/failing sink must never panic or deadlock under
+ // concurrent load, and every call must be reflected exactly once
+ // either as a successful line or as a counted failure.
+ const n = 2000
+ fw := &failingWriter{failFrom: 1001} // roughly half fail
+ s := NewStdoutSink(fw)
+ t.Cleanup(func() { _ = s.Close() })
+
+ var wg sync.WaitGroup
+ var errs int64
+ wg.Add(n)
+ for i := 0; i < n; i++ {
+ go func(seq int) {
+ defer wg.Done()
+ if err := s.Write([]byte(fmt.Sprintf(`{"seq":%d}`, seq))); err != nil {
+ atomic.AddInt64(&errs, 1)
+ }
+ }(i)
+ }
+ wg.Wait()
+
+ if got := s.WriteFailures(); got != uint64(errs) {
+ t.Fatalf("WriteFailures() = %d, want %d (must match the errors actually returned)", got, errs)
+ }
+ if errs == 0 {
+ t.Fatal("expected at least one write to hit the failing writer given failFrom=1001 over 2000 writers")
+ }
+
+ successLines := strings.Split(strings.TrimRight(fw.String(), "\n"), "\n")
+ // Every successfully-written line must still be whole JSON — a runtime
+ // failure on one goroutine must not corrupt bytes already committed by
+ // another.
+ for _, l := range successLines {
+ if l == "" {
+ continue
+ }
+ var decoded struct {
+ Seq int `json:"seq"`
+ }
+ if err := json.Unmarshal([]byte(l), &decoded); err != nil {
+ t.Fatalf("successful line is not valid whole JSON: %v\nline: %q", err, l)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// interface satisfaction (compile-time documentation of the expected shape)
+// ---------------------------------------------------------------------------
+
+var (
+ _ Sink = (*sinkStub)(nil) // ensures Sink stays a small, mockable interface
+)
+
+// sinkStub is not used by any test above; it exists only so a change that
+// widens the Sink interface fails this file to compile, which is the whole
+// point of a compile-red test file.
+type sinkStub struct{}
+
+func (sinkStub) Write(_ []byte) error { return nil }
+func (sinkStub) WriteFailures() uint64 { return 0 }
+func (sinkStub) SanitizerHits() uint64 { return 0 }
+func (sinkStub) Close() error { return nil }
+
+var _ io.Closer = sinkStub{}
diff --git a/internal/audit/testdata/canonical/auth_keys_stripped.json b/internal/audit/testdata/canonical/auth_keys_stripped.json
new file mode 100644
index 000000000..2c02baaaf
--- /dev/null
+++ b/internal/audit/testdata/canonical/auth_keys_stripped.json
@@ -0,0 +1,8 @@
+{
+ "description": "security.StripInternalArgs removes every _auth_*-prefixed key before canonicalisation (FR-015): a call carrying injected _auth_user_id/_auth_user_email/_auth_auth_type must canonicalize byte-identically to the same call without them, and the canonical output must never contain the substring \"_auth_\".",
+ "strip_internal_args": true,
+ "variants": [
+ { "repo": "mcpproxy-go", "issue": 1107, "_auth_user_id": "u-1", "_auth_user_email": "alice@example.com", "_auth_auth_type": "session_user" },
+ { "repo": "mcpproxy-go", "issue": 1107 }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/ecma_fixed_exponential_boundary.json b/internal/audit/testdata/canonical/ecma_fixed_exponential_boundary.json
new file mode 100644
index 000000000..2637854ab
--- /dev/null
+++ b/internal/audit/testdata/canonical/ecma_fixed_exponential_boundary.json
@@ -0,0 +1,12 @@
+{
+ "description": "ECMA-262 Number::toString fixed/exponential boundary (round-2 cross-review, PR-D): Go's `%g` formatter switches to exponential far earlier than ES6 does, so values just inside and outside the -6 < n <= 21 fixed-notation window must be exercised directly against the RFC 8785 rule, not against Go's own %g threshold.",
+ "variants": [
+ {
+ "just_above_zero": 0.000001,
+ "negative_just_above_zero": -0.000001,
+ "just_below_exponential": 1e-7,
+ "large_fixed": 1e20,
+ "just_above_large_fixed": 1e21
+ }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/large_integer_precision.json b/internal/audit/testdata/canonical/large_integer_precision.json
new file mode 100644
index 000000000..0a308587e
--- /dev/null
+++ b/internal/audit/testdata/canonical/large_integer_precision.json
@@ -0,0 +1,7 @@
+{
+ "description": "9007199254740993 (2^53 + 1) is not exactly representable as float64; RFC 8785 numbers are IEEE-754 double values (via ES6 Number semantics), so this MUST round to 9007199254740992 (2^53) once decoded to float64 -- matching what a JS JSON.parse + JCS reference implementation would also produce. Both variants below (the literal and its rounded neighbour) must canonicalize identically.",
+ "variants": [
+ { "a": 9007199254740993 },
+ { "a": 9007199254740992 }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/negative_zero.json b/internal/audit/testdata/canonical/negative_zero.json
new file mode 100644
index 000000000..f5be7853d
--- /dev/null
+++ b/internal/audit/testdata/canonical/negative_zero.json
@@ -0,0 +1,9 @@
+{
+ "description": "Negative zero canonicalizes to \"0\", identically to positive zero (RFC 8785 §3.2.2.3 / ES6 Number::toString(-0) === \"0\").",
+ "variants": [
+ { "a": -0 },
+ { "a": -0.0 },
+ { "a": 0 },
+ { "a": 0.0 }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/nested_structures.json b/internal/audit/testdata/canonical/nested_structures.json
new file mode 100644
index 000000000..18c129338
--- /dev/null
+++ b/internal/audit/testdata/canonical/nested_structures.json
@@ -0,0 +1,12 @@
+{
+ "description": "Nested maps and arrays: member ordering applies recursively at every object level, arrays keep their given element order (RFC 8785 does not reorder arrays), and value types are preserved through the recursion (null, bool, nested object, nested array, string, number).",
+ "variants": [
+ {
+ "zeta": [1, 2, { "y": 2, "x": 1 }, [true, false, null]],
+ "alpha": { "nested": { "deep": { "z": 1, "a": 2 } }, "list": [] },
+ "middle": null,
+ "flag": true,
+ "empty_obj": {}
+ }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/number_equivalence.json b/internal/audit/testdata/canonical/number_equivalence.json
new file mode 100644
index 000000000..92c08d10d
--- /dev/null
+++ b/internal/audit/testdata/canonical/number_equivalence.json
@@ -0,0 +1,10 @@
+{
+ "description": "1, 1.0 and 1e0 are the same float64 and MUST canonicalize to an identical byte sequence (ES6 Number::toString, RFC 8785 §3.2.2.3).",
+ "variants": [
+ { "a": 1 },
+ { "a": 1.0 },
+ { "a": 1e0 },
+ { "a": 1E0 },
+ { "a": 0.1e1 }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/rfc8785_appendix_style_values.json b/internal/audit/testdata/canonical/rfc8785_appendix_style_values.json
new file mode 100644
index 000000000..4502888de
--- /dev/null
+++ b/internal/audit/testdata/canonical/rfc8785_appendix_style_values.json
@@ -0,0 +1,20 @@
+{
+ "description": "Combined vector in the spirit of the RFC 8785 Appendix B 'values.json' sample: mixed literals, small/large-magnitude numbers requiring exponential form, and a string needing the full escape set, exercised together in one object.",
+ "variants": [
+ {
+ "numbers": [
+ 333333333.3333333,
+ 1e+30,
+ 4.5,
+ 0.002,
+ 1e-27
+ ],
+ "string": "€$\u000f\nA'B\"\\\\\"/",
+ "literals": [
+ null,
+ true,
+ false
+ ]
+ }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/simple_key_sort.json b/internal/audit/testdata/canonical/simple_key_sort.json
new file mode 100644
index 000000000..25c112c34
--- /dev/null
+++ b/internal/audit/testdata/canonical/simple_key_sort.json
@@ -0,0 +1,6 @@
+{
+ "description": "Object members must be reordered by sorting on UTF-16 code units of the member name (RFC 8785 §3.2.3); input already out of order.",
+ "variants": [
+ { "c": 3, "a": 1, "b": 2, "aa": 11, "Aa": 12, "A": 13 }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/unicode_and_escaping.json b/internal/audit/testdata/canonical/unicode_and_escaping.json
new file mode 100644
index 000000000..bea26b2ca
--- /dev/null
+++ b/internal/audit/testdata/canonical/unicode_and_escaping.json
@@ -0,0 +1,14 @@
+{
+ "description": "RFC 8785 §3.2.2.2 minimal string escaping: only U+0022, U+005C and control chars U+0000-U+001F are escaped (short forms where defined, else lowercase \\u00XX); solidus is NOT escaped; non-ASCII chars are raw UTF-8, not \\u escapes.",
+ "variants": [
+ {
+ "quote": "she said \"hi\"",
+ "backslash": "a\\b",
+ "solidus": "a/b/c",
+ "controls": "\u0000\u0001\b\f\n\r\t\u001f",
+ "euro": "€100",
+ "accented": "Dumbris — Žemaičių Kalvarija",
+ "cjk": "日本語"
+ }
+ ]
+}
diff --git a/internal/audit/testdata/canonical/utf16_key_order_surrogate.json b/internal/audit/testdata/canonical/utf16_key_order_surrogate.json
new file mode 100644
index 000000000..cc3e459b5
--- /dev/null
+++ b/internal/audit/testdata/canonical/utf16_key_order_surrogate.json
@@ -0,0 +1,6 @@
+{
+ "description": "RFC 8785 key ordering compares UTF-16 CODE UNITS, not Unicode code points: U+1F600 (surrogate pair D83D DE00) sorts BEFORE U+E000 because 0xD83D < 0xE000, even though 0x1F600 > 0xE000 as a code point.",
+ "variants": [
+ { "": 1, "😀": 2 }
+ ]
+}
diff --git a/internal/config/audit_log.go b/internal/config/audit_log.go
new file mode 100644
index 000000000..724164d99
--- /dev/null
+++ b/internal/config/audit_log.go
@@ -0,0 +1,262 @@
+package config
+
+// audit_log.go: config surface for the Spec 107 edition-neutral audit sink
+// (internal/audit). Contracts: contracts/config-keys.md `audit_log*` rows,
+// data-model.md §9, spec.md FR-012..FR-019.
+
+// Transport values EffectiveAuditLog accepts (Spec 107 FR-014): "http" for
+// the HTTP/SSE listener, "stdio" for the native stdio MCP transport (where
+// stdout is the JSON-RPC channel and can never double as a log sink).
+const (
+ TransportHTTP = "http"
+ TransportStdio = "stdio"
+)
+
+// Default rotation values (contracts/config-keys.md).
+const (
+ DefaultAuditLogMaxSizeMB = 50
+ DefaultAuditLogMaxBackups = 10
+ DefaultAuditLogMaxAgeDays = 90
+)
+
+// Boot/doctor/validation message text (contracts/config-keys.md, exact
+// strings — both the log line and the write-door validators use these).
+const (
+ // MsgAuditLogStdoutIgnoredStdio is the WARN line for a stdout sink
+ // silently suppressed under the stdio transport: an absent audit_log
+ // block whose per-edition default would otherwise be stdout:true, or an
+ // explicit block whose `stdout` was left unset (never for an explicit
+ // `stdout: true`, which is refused instead — see
+ // MsgAuditLogStdoutRefusedStdio).
+ MsgAuditLogStdoutIgnoredStdio = "audit_log.stdout is ignored under the stdio transport; set audit_log.path"
+
+ // MsgAuditLogStdoutRefusedStdio is the StartupError message for an
+ // explicit `enabled: true, stdout: true` with no `path` under the stdio
+ // transport (FR-014: an explicit value always wins - it is refused,
+ // never silently disabled).
+ MsgAuditLogStdoutRefusedStdio = "audit_log.stdout cannot be used under the stdio transport (stdout carries JSON-RPC); set audit_log.path"
+
+ // MsgAuditLogDisabledNotice is the startup notice for an explicit
+ // audit_log.enabled: false under the server edition.
+ MsgAuditLogDisabledNotice = "audit attribution is off"
+
+ // MsgAuditLogDefaultActive is the startup notice for the server-edition
+ // default (audit_log absent, non-stdio transport): FR-014 requires one
+ // startup line naming that the default sink (stdout) is active so an
+ // operator relying on a silent config file is not surprised by lines on
+ // stdout (round-1 cross-review finding, PR-D).
+ MsgAuditLogDefaultActive = "audit_log is not configured; the server-edition default is active (enabled, stdout) — set audit_log to override"
+
+ // MsgAuditLogNoSink is the validation message for enabled:true with
+ // neither stdout nor a path set.
+ MsgAuditLogNoSink = "audit_log is enabled but has no sink (set stdout: true or a path)"
+)
+
+// AuditLogConfig is the wire shape of the `audit_log` key. Every field except
+// Path is a pointer so an omitted key is distinguishable from an explicit
+// zero/false value (data-model.md §9): omitted `compress` -> true default,
+// explicit `false` -> false; omitted `max_size_mb` -> 50, explicit `0` -> a
+// validation error. Path's own emptiness already is the "unset" signal, so it
+// stays a plain string.
+type AuditLogConfig struct {
+ Enabled *bool `json:"enabled,omitempty" mapstructure:"enabled"`
+ Path string `json:"path,omitempty" mapstructure:"path"`
+ Stdout *bool `json:"stdout,omitempty" mapstructure:"stdout"`
+ MaxSizeMB *int `json:"max_size_mb,omitempty" mapstructure:"max-size-mb"`
+ MaxBackups *int `json:"max_backups,omitempty" mapstructure:"max-backups"`
+ MaxAgeDays *int `json:"max_age_days,omitempty" mapstructure:"max-age-days"`
+ Compress *bool `json:"compress,omitempty" mapstructure:"compress"`
+}
+
+// ResolvedAuditLog is the plain, fully-defaulted form EffectiveAuditLog
+// returns: every field carries a concrete value, ready for the sink
+// constructors (internal/audit.NewFileSink / NewStdoutSink).
+type ResolvedAuditLog struct {
+ Enabled bool
+ Path string
+ Stdout bool
+ MaxSizeMB int
+ MaxBackups int
+ MaxAgeDays int
+ Compress bool
+}
+
+// StartupError is a typed, exit-code-carrying boot failure (Spec 107 FR-014).
+// cmd/mcpproxy's classifyError matches it with errors.As BEFORE its string
+// heuristics, so a wrapped "permission denied" underneath never misclassifies
+// this as ExitCodePermissionError.
+type StartupError struct {
+ ExitCode int
+ Message string
+}
+
+func (e *StartupError) Error() string { return e.Message }
+
+// NewStartupError builds a StartupError with the given exit code and message.
+func NewStartupError(exitCode int, message string) *StartupError {
+ return &StartupError{ExitCode: exitCode, Message: message}
+}
+
+// EffectiveAuditLog resolves cfg.AuditLog into a plain ResolvedAuditLog for
+// the given transport. It never mutates cfg.
+//
+// Return contract:
+// - (resolved, "", nil): use resolved as-is.
+// - (resolved, warning, nil): a default was silently adjusted for the
+// transport; the caller logs warning at WARN and still uses resolved
+// (which has Enabled=false in that case - FR-014 "only the default is
+// suppressed").
+// - (ResolvedAuditLog{}, "", err): an explicit configuration cannot be
+// honoured on this transport; err is always a *StartupError. The caller
+// must not construct a sink.
+//
+// Only the per-edition DEFAULT differs (FR-014: "Defaults differ by
+// edition, the code does not"): with no `audit_log` block, the personal
+// edition resolves to {Enabled:false} (isServerEditionBuild, keyed on the
+// build tag, not on the server_edition.enabled feature flag — the audit
+// funnels compile into every server-edition binary regardless of whether
+// SSO is turned on) and the server edition to its own stdout/stdio default
+// below. An EXPLICIT block is resolved identically on both editions from
+// this point on — "an explicit value always wins" is not a server-edition-
+// only promise (round-2 cross-review finding, PR-D: this used to return
+// {Enabled:false} unconditionally for every personal build, silently
+// dropping an explicit `audit_log: {enabled: true, ...}`).
+func EffectiveAuditLog(cfg *Config, transport string) (resolved ResolvedAuditLog, warning string, err error) {
+ resolved = ResolvedAuditLog{
+ MaxSizeMB: DefaultAuditLogMaxSizeMB,
+ MaxBackups: DefaultAuditLogMaxBackups,
+ MaxAgeDays: DefaultAuditLogMaxAgeDays,
+ Compress: true,
+ }
+
+ var block *AuditLogConfig
+ if cfg != nil {
+ block = cfg.AuditLog
+ }
+
+ if block == nil {
+ if !isServerEditionBuild {
+ // Personal-edition default: audit_log off, no sink, no startup
+ // line. An explicit block is handled below, identically on both
+ // editions — only this absent-block default is edition-keyed.
+ return resolved, "", nil
+ }
+ // Per-edition default for an absent block: stdout:true on HTTP.
+ if transport == TransportStdio {
+ // Only the default is suppressed (FR-014); an explicit value
+ // below always wins instead of being silently disabled.
+ return resolved, MsgAuditLogStdoutIgnoredStdio, nil
+ }
+ resolved.Enabled = true
+ resolved.Stdout = true
+ return resolved, MsgAuditLogDefaultActive, nil
+ }
+
+ stdoutExplicit := block.Stdout != nil
+
+ if block.Enabled != nil {
+ resolved.Enabled = *block.Enabled
+ } else {
+ resolved.Enabled = true
+ }
+ resolved.Path = block.Path
+ if stdoutExplicit {
+ resolved.Stdout = *block.Stdout
+ }
+ // Note: unlike the absent-block default above, an EXPLICIT block with
+ // neither stdout nor path set is never silently defaulted to stdout:true
+ // - it is refused by validateAuditLog at config-validate time
+ // (contracts/config-keys.md: "enabled with neither stdout nor path").
+ if block.MaxSizeMB != nil {
+ resolved.MaxSizeMB = *block.MaxSizeMB
+ }
+ if block.MaxBackups != nil {
+ resolved.MaxBackups = *block.MaxBackups
+ }
+ if block.MaxAgeDays != nil {
+ resolved.MaxAgeDays = *block.MaxAgeDays
+ }
+ if block.Compress != nil {
+ resolved.Compress = *block.Compress
+ }
+
+ if !resolved.Enabled {
+ // Only reachable with an explicit `enabled: false` (the absent-block
+ // default above never resolves Enabled:false for HTTP, and stdio's
+ // own default-suppression path returns earlier): FR-014 requires a
+ // startup warning naming that audit attribution is off (round-1
+ // cross-review finding, PR-D).
+ return resolved, MsgAuditLogDisabledNotice, nil
+ }
+
+ if transport == TransportStdio {
+ switch {
+ case resolved.Path != "":
+ // An explicit path is honoured; a stdout:true beside it is
+ // dropped with the WARN rather than refused.
+ if resolved.Stdout {
+ resolved.Stdout = false
+ return resolved, MsgAuditLogStdoutIgnoredStdio, nil
+ }
+ return resolved, "", nil
+ case stdoutExplicit && resolved.Stdout:
+ // Explicit enabled+stdout with no path: refuse (FR-014).
+ return ResolvedAuditLog{}, "", NewStartupError(ExitCodeAuditLogError, MsgAuditLogStdoutRefusedStdio)
+ default:
+ // stdout was only defaulted true (or is explicitly false) with no
+ // path: suppress like the absent-block case rather than
+ // constructing a no-op sink silently.
+ resolved.Enabled = false
+ resolved.Stdout = false
+ return resolved, MsgAuditLogStdoutIgnoredStdio, nil
+ }
+ }
+
+ return resolved, "", nil
+}
+
+// ExitCodeAuditLogError is the exit code an unhonourable audit_log
+// configuration returns (Spec 107 FR-014: same band as every other
+// configuration boot failure).
+const ExitCodeAuditLogError = 4
+
+// validateAuditLog is reached from both Config.Validate() and
+// ValidateDetailed() (boot, PATCH, /config/apply agree - contracts/config-keys
+// .md). It validates the wire block only; the stdio-transport rule lives in
+// EffectiveAuditLog because it needs the transport, which is not known at
+// validation time.
+func validateAuditLog(cfg *Config) []ValidationError {
+ if cfg == nil || cfg.AuditLog == nil {
+ return nil
+ }
+ b := cfg.AuditLog
+ // An explicit block with `enabled` omitted defaults to enabled - same as
+ // EffectiveAuditLog's resolution for a present-but-unmarked block.
+ enabled := b.Enabled == nil || *b.Enabled
+ if !enabled {
+ return nil
+ }
+ var errs []ValidationError
+ hasStdout := b.Stdout != nil && *b.Stdout
+ hasPath := b.Path != ""
+ // Unlike an ABSENT block (which defaults to stdout:true on the server
+ // edition), an explicit block with neither stdout nor path is refused
+ // outright rather than silently defaulted.
+ if !hasStdout && !hasPath {
+ errs = append(errs, ValidationError{Field: "audit_log", Message: MsgAuditLogNoSink})
+ }
+ // Rotation values only matter for a file sink (contracts/config-keys.md:
+ // "> 0 when a path is set").
+ if hasPath {
+ if b.MaxSizeMB != nil && *b.MaxSizeMB <= 0 {
+ errs = append(errs, ValidationError{Field: "audit_log.max_size_mb", Message: "audit_log.max_size_mb must be positive"})
+ }
+ if b.MaxBackups != nil && *b.MaxBackups <= 0 {
+ errs = append(errs, ValidationError{Field: "audit_log.max_backups", Message: "audit_log.max_backups must be positive"})
+ }
+ if b.MaxAgeDays != nil && *b.MaxAgeDays <= 0 {
+ errs = append(errs, ValidationError{Field: "audit_log.max_age_days", Message: "audit_log.max_age_days must be positive"})
+ }
+ }
+ return errs
+}
diff --git a/internal/config/audit_log_config_personal_test.go b/internal/config/audit_log_config_personal_test.go
new file mode 100644
index 000000000..0c6b68e7b
--- /dev/null
+++ b/internal/config/audit_log_config_personal_test.go
@@ -0,0 +1,92 @@
+//go:build !server
+
+package config
+
+import "testing"
+
+// Spec 107 T108 (round-2 cross-review fix, PR-D): only the per-edition
+// DEFAULT for an ABSENT audit_log block differs — FR-014 "Defaults differ
+// by edition, the code does not" / "An explicit value always wins" is not a
+// server-edition-only promise. EffectiveAuditLog used to hard-gate the
+// entire function behind isServerEditionBuild, so an explicit
+// `audit_log: {enabled: true, ...}` on the personal binary silently
+// resolved to {Enabled:false} — this pins the corrected behavior instead.
+
+func TestEffectiveAuditLog_PersonalEdition_AbsentBlockDisabledNoWarning(t *testing.T) {
+ cfg := &Config{}
+
+ for _, transport := range []string{TransportHTTP, TransportStdio} {
+ resolved, warn, err := EffectiveAuditLog(cfg, transport)
+ if err != nil {
+ t.Fatalf("[%s] unexpected error: %v", transport, err)
+ }
+ if warn != "" {
+ t.Fatalf("[%s] unexpected warning for the absent-block personal default: %q", transport, warn)
+ }
+ if resolved.Enabled {
+ t.Fatalf("[%s] expected the personal-edition absent-block default to be disabled, got %+v", transport, resolved)
+ }
+ }
+}
+
+func TestEffectiveAuditLog_PersonalEdition_ExplicitEnabledIsHonoured(t *testing.T) {
+ enabled := true
+ stdout := true
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: &enabled, Stdout: &stdout}}
+
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if warn != "" {
+ t.Fatalf("unexpected warning: %q", warn)
+ }
+ if !resolved.Enabled || !resolved.Stdout {
+ t.Fatalf("an explicit audit_log.enabled:true, stdout:true on the personal edition must be honoured, got %+v", resolved)
+ }
+}
+
+// TestEffectiveAuditLog_PersonalEdition_ExplicitDisabledIsHonoured proves the
+// personal edition's DEFAULT (disabled, no warning) is not confused with an
+// explicit `enabled: false`, which — like the server edition — must still
+// warn that attribution is off, not stay silent.
+func TestEffectiveAuditLog_PersonalEdition_ExplicitDisabledWarns(t *testing.T) {
+ enabled := false
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: &enabled}}
+
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if warn != MsgAuditLogDisabledNotice {
+ t.Fatalf("warning = %q, want %q", warn, MsgAuditLogDisabledNotice)
+ }
+ if resolved.Enabled {
+ t.Fatalf("expected disabled, got %+v", resolved)
+ }
+}
+
+// TestEffectiveAuditLog_PersonalEdition_ExplicitStdoutNoPath_Stdio_StartupError
+// proves the stdio-transport rule (FR-014) is edition-neutral: an explicit
+// enabled+stdout with no path is refused with exit code 4 on the personal
+// binary exactly as it is on the server binary, never silently disabled.
+func TestEffectiveAuditLog_PersonalEdition_ExplicitStdoutNoPath_Stdio_StartupError(t *testing.T) {
+ enabled := true
+ stdout := true
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: &enabled, Stdout: &stdout}}
+
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportStdio)
+ if err == nil {
+ t.Fatalf("expected a StartupError, got resolved=%+v warn=%q", resolved, warn)
+ }
+ startupErr, ok := err.(*StartupError)
+ if !ok {
+ t.Fatalf("error is not *StartupError: %T: %v", err, err)
+ }
+ if startupErr.ExitCode != ExitCodeAuditLogError {
+ t.Fatalf("ExitCode = %d, want %d", startupErr.ExitCode, ExitCodeAuditLogError)
+ }
+ if startupErr.Message != MsgAuditLogStdoutRefusedStdio {
+ t.Fatalf("Message = %q, want %q", startupErr.Message, MsgAuditLogStdoutRefusedStdio)
+ }
+}
diff --git a/internal/config/audit_log_config_test.go b/internal/config/audit_log_config_test.go
new file mode 100644
index 000000000..0f34bc9bc
--- /dev/null
+++ b/internal/config/audit_log_config_test.go
@@ -0,0 +1,244 @@
+//go:build server
+
+package config
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestEffectiveAuditLog_AbsentBlock_ServerEdition(t *testing.T) {
+ cfg := &Config{}
+
+ t.Run("http default is stdout:true", func(t *testing.T) {
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // FR-014's "one startup line" requirement: the block is absent, so
+ // the server-edition default silently activates a stdout sink an
+ // operator relying on a blank config might not expect.
+ if warn != MsgAuditLogDefaultActive {
+ t.Fatalf("warning = %q, want %q", warn, MsgAuditLogDefaultActive)
+ }
+ if !resolved.Enabled || !resolved.Stdout {
+ t.Fatalf("expected {Enabled:true, Stdout:true}, got %+v", resolved)
+ }
+ })
+
+ t.Run("stdio default is disabled with WARN naming audit_log.path", func(t *testing.T) {
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportStdio)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if warn != MsgAuditLogStdoutIgnoredStdio {
+ t.Fatalf("warning = %q, want %q", warn, MsgAuditLogStdoutIgnoredStdio)
+ }
+ if resolved.Enabled {
+ t.Fatalf("expected disabled sink under stdio, got %+v", resolved)
+ }
+ })
+}
+
+func TestEffectiveAuditLog_ExplicitStdoutNoPath_Stdio_StartupError(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{
+ Enabled: boolPtr(true),
+ Stdout: boolPtr(true),
+ }}
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportStdio)
+ if err == nil {
+ t.Fatalf("expected a StartupError, got resolved=%+v warn=%q", resolved, warn)
+ }
+ var startupErr *StartupError
+ if se, ok := err.(*StartupError); ok {
+ startupErr = se
+ } else {
+ t.Fatalf("error is not *StartupError: %T: %v", err, err)
+ }
+ if startupErr.ExitCode != ExitCodeAuditLogError {
+ t.Fatalf("ExitCode = %d, want %d", startupErr.ExitCode, ExitCodeAuditLogError)
+ }
+ if startupErr.Message != MsgAuditLogStdoutRefusedStdio {
+ t.Fatalf("Message = %q, want %q", startupErr.Message, MsgAuditLogStdoutRefusedStdio)
+ }
+}
+
+func TestEffectiveAuditLog_ExplicitPath_Stdio_Honoured(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{
+ Enabled: boolPtr(true),
+ Path: "/tmp/audit.jsonl",
+ Stdout: boolPtr(true), // dropped with a WARN, never refused
+ }}
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportStdio)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if warn != MsgAuditLogStdoutIgnoredStdio {
+ t.Fatalf("warning = %q, want %q", warn, MsgAuditLogStdoutIgnoredStdio)
+ }
+ if !resolved.Enabled || resolved.Stdout || resolved.Path != "/tmp/audit.jsonl" {
+ t.Fatalf("resolved = %+v, want enabled file sink with stdout dropped", resolved)
+ }
+}
+
+// TestEffectiveAuditLog_ExplicitDisabled_WarnsAttributionOff covers FR-014's
+// startup notice for an explicit `enabled: false` under the server edition
+// (round-1 cross-review finding, PR-D): MsgAuditLogDisabledNotice was
+// defined but never returned by EffectiveAuditLog before this fix.
+func TestEffectiveAuditLog_ExplicitDisabled_WarnsAttributionOff(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(false)}}
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if warn != MsgAuditLogDisabledNotice {
+ t.Fatalf("warning = %q, want %q", warn, MsgAuditLogDisabledNotice)
+ }
+ if resolved.Enabled {
+ t.Fatalf("expected disabled sink, got %+v", resolved)
+ }
+}
+
+func TestEffectiveAuditLog_ExplicitPath_HTTP_Honoured(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{
+ Path: "/tmp/audit.jsonl",
+ }}
+ resolved, warn, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil || warn != "" {
+ t.Fatalf("unexpected warn/err: %q / %v", warn, err)
+ }
+ if !resolved.Enabled || resolved.Stdout || resolved.Path != "/tmp/audit.jsonl" {
+ t.Fatalf("resolved = %+v", resolved)
+ }
+}
+
+func TestEffectiveAuditLog_OmittedVsExplicit_Fields(t *testing.T) {
+ t.Run("compress omitted defaults true, explicit false wins", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Path: "/tmp/a.jsonl"}}
+ resolved, _, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !resolved.Compress {
+ t.Fatalf("expected default Compress=true, got %+v", resolved)
+ }
+
+ cfg.AuditLog.Compress = boolPtr(false)
+ resolved, _, err = EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.Compress {
+ t.Fatalf("expected explicit Compress=false to win, got %+v", resolved)
+ }
+ })
+
+ t.Run("max_size_mb omitted defaults 50, explicit 0 is a validation error not a silent zero", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Path: "/tmp/a.jsonl"}}
+ resolved, _, err := EffectiveAuditLog(cfg, TransportHTTP)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.MaxSizeMB != DefaultAuditLogMaxSizeMB {
+ t.Fatalf("MaxSizeMB = %d, want %d", resolved.MaxSizeMB, DefaultAuditLogMaxSizeMB)
+ }
+
+ cfg.AuditLog.MaxSizeMB = intPtr(0)
+ errs := validateAuditLog(cfg)
+ if len(errs) == 0 {
+ t.Fatalf("expected a validation error for max_size_mb: 0")
+ }
+ })
+}
+
+func TestValidateAuditLog(t *testing.T) {
+ t.Run("enabled with neither stdout nor path is refused", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(true)}}
+ errs := validateAuditLog(cfg)
+ if len(errs) != 1 || errs[0].Message != MsgAuditLogNoSink {
+ t.Fatalf("errs = %+v, want one %q", errs, MsgAuditLogNoSink)
+ }
+ })
+
+ t.Run("enabled with stdout only is fine", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(true), Stdout: boolPtr(true)}}
+ if errs := validateAuditLog(cfg); len(errs) != 0 {
+ t.Fatalf("unexpected errors: %+v", errs)
+ }
+ })
+
+ t.Run("non-positive rotation values with a path are refused", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(true), Path: "/tmp/a.jsonl", MaxBackups: intPtr(0)}}
+ errs := validateAuditLog(cfg)
+ if len(errs) != 1 || errs[0].Field != "audit_log.max_backups" {
+ t.Fatalf("errs = %+v", errs)
+ }
+ })
+
+ t.Run("disabled block has no rule regardless of shape", func(t *testing.T) {
+ cfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(false)}}
+ if errs := validateAuditLog(cfg); len(errs) != 0 {
+ t.Fatalf("unexpected errors: %+v", errs)
+ }
+ })
+
+ t.Run("reached from Config.Validate and ValidateDetailed", func(t *testing.T) {
+ cfg := &Config{Listen: "127.0.0.1:0", AuditLog: &AuditLogConfig{Enabled: boolPtr(true)}}
+ if err := cfg.Validate(); err == nil {
+ t.Fatalf("expected Config.Validate to refuse a sinkless enabled block")
+ }
+ cfg2 := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(true)}}
+ found := false
+ for _, e := range cfg2.ValidateDetailed() {
+ if e.Field == "audit_log" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("ValidateDetailed did not report audit_log")
+ }
+ })
+}
+
+func TestAuditLogEnvOverrides(t *testing.T) {
+ t.Setenv("MCPPROXY_AUDIT_LOG_ENABLED", "true")
+ t.Setenv("MCPPROXY_AUDIT_LOG_PATH", "/var/log/audit.jsonl")
+ t.Setenv("MCPPROXY_AUDIT_LOG_STDOUT", "false")
+
+ cfg := &Config{}
+ applyTLSEnvOverrides(cfg)
+
+ if cfg.AuditLog == nil {
+ t.Fatalf("expected env overrides to materialize audit_log")
+ }
+ if cfg.AuditLog.Enabled == nil || !*cfg.AuditLog.Enabled {
+ t.Fatalf("Enabled = %v, want true", cfg.AuditLog.Enabled)
+ }
+ if cfg.AuditLog.Path != "/var/log/audit.jsonl" {
+ t.Fatalf("Path = %q", cfg.AuditLog.Path)
+ }
+ if cfg.AuditLog.Stdout == nil || *cfg.AuditLog.Stdout {
+ t.Fatalf("Stdout = %v, want false", cfg.AuditLog.Stdout)
+ }
+}
+
+func TestAuditLogConfig_MarshalsDifferently(t *testing.T) {
+ // The restart-pinned DetectConfigChanges clause (internal/runtime, which
+ // imports this package - so it is exercised there, not here) compares
+ // cfg.AuditLog with jsonEqual; this pins that the two blocks below do NOT
+ // marshal identically, which is what makes that clause fire.
+ old := &Config{}
+ newCfg := &Config{AuditLog: &AuditLogConfig{Enabled: boolPtr(true), Stdout: boolPtr(true)}}
+
+ oldJSON, err := json.Marshal(old.AuditLog)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newJSON, err := json.Marshal(newCfg.AuditLog)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(oldJSON) == string(newJSON) {
+ t.Fatalf("expected the two audit_log blocks to marshal differently")
+ }
+}
diff --git a/internal/config/build_edition_server.go b/internal/config/build_edition_server.go
new file mode 100644
index 000000000..79d62543a
--- /dev/null
+++ b/internal/config/build_edition_server.go
@@ -0,0 +1,11 @@
+//go:build server
+
+package config
+
+// isServerEditionBuild is true when the binary is compiled with the `server`
+// build tag (the mcpproxy-server binary), independent of whether the
+// server_edition.* config block is enabled. Spec 107 FR-014: the audit_log
+// per-edition default is keyed on the BUILD, not the feature flag, because
+// the audit funnels themselves are edition-neutral code that runs in every
+// server-edition binary.
+const isServerEditionBuild = true
diff --git a/internal/config/build_edition_stub.go b/internal/config/build_edition_stub.go
new file mode 100644
index 000000000..e1762240c
--- /dev/null
+++ b/internal/config/build_edition_stub.go
@@ -0,0 +1,7 @@
+//go:build !server
+
+package config
+
+// isServerEditionBuild is always false on the personal binary. See
+// build_edition_server.go.
+const isServerEditionBuild = false
diff --git a/internal/config/config.go b/internal/config/config.go
index 63e621b92..0953064ef 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -383,11 +383,16 @@ type Config struct {
// (Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live
// (hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).
// The one reader is ForwardedHeaders; validation is validateTrustedProxies.
- TrustedProxies []string `json:"trusted_proxies,omitempty" mapstructure:"trusted-proxies"`
- ReadOnlyMode bool `json:"read_only_mode" mapstructure:"read-only-mode"`
- DisableManagement bool `json:"disable_management" mapstructure:"disable-management"`
- AllowServerAdd bool `json:"allow_server_add" mapstructure:"allow-server-add"`
- AllowServerRemove bool `json:"allow_server_remove" mapstructure:"allow-server-remove"`
+ TrustedProxies []string `json:"trusted_proxies,omitempty" mapstructure:"trusted-proxies"`
+ // AuditLog configures the Spec 107 edition-neutral audit sink
+ // (internal/audit). nil means "use the per-edition/per-transport
+ // default" (EffectiveAuditLog); restart-pinned (bound at sink
+ // construction). See audit_log.go.
+ AuditLog *AuditLogConfig `json:"audit_log,omitempty" mapstructure:"audit-log"`
+ ReadOnlyMode bool `json:"read_only_mode" mapstructure:"read-only-mode"`
+ DisableManagement bool `json:"disable_management" mapstructure:"disable-management"`
+ AllowServerAdd bool `json:"allow_server_add" mapstructure:"allow-server-add"`
+ AllowServerRemove bool `json:"allow_server_remove" mapstructure:"allow-server-remove"`
// Internal field to track if API key was explicitly set in config
apiKeyExplicitlySet bool `json:"-"`
@@ -2644,6 +2649,10 @@ func (c *Config) validateDetailedCore() []ValidationError {
// edition (stub); enforced in the server edition.
errors = append(errors, validateServerEditionConfig(c)...)
+ // Spec 107 FR-014/FR-019: audit_log validated (never mutated) on every
+ // door - boot, PATCH and /config/apply.
+ errors = append(errors, validateAuditLog(c)...)
+
return errors
}
diff --git a/internal/config/deploy_guide_example_test.go b/internal/config/deploy_guide_example_test.go
new file mode 100644
index 000000000..c1b5347bc
--- /dev/null
+++ b/internal/config/deploy_guide_example_test.go
@@ -0,0 +1,64 @@
+//go:build server
+
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Spec 107 T112: docs/operations/deploying-for-a-team.json is the config
+// example quoted (in fragments) by docs/operations/deploying-for-a-team.md.
+// This test loads and validates the real file on disk so the doc's example
+// can never silently drift from what Config.Validate actually accepts.
+func TestDeployGuideExample_LoadsAndValidates(t *testing.T) {
+ t.Setenv("MCPPROXY_CRED_KEY", "test-credential-encryption-key-32bytes!")
+ t.Setenv("OIDC_CLIENT_SECRET", "test-oidc-client-secret")
+
+ path := deployGuideExamplePath(t)
+ cfg, err := LoadFromFile(path)
+ require.NoError(t, err, "docs/operations/examples/deploying-for-a-team.json must load")
+
+ require.NoError(t, cfg.Validate(), "docs/operations/examples/deploying-for-a-team.json must pass Config.Validate")
+
+ require.True(t, isServerEditionBuild)
+ require.NotNil(t, cfg.ServerEdition)
+ require.True(t, cfg.ServerEdition.Enabled)
+ require.Equal(t, "https://mcp.example.com", cfg.ServerEdition.PublicURL)
+ require.Equal(t, SessionCookieSecureAuto, cfg.ServerEdition.SessionCookieSecure)
+ require.NotNil(t, cfg.ServerEdition.OAuth)
+ require.Equal(t, "oidc", cfg.ServerEdition.OAuth.Provider)
+ require.NotNil(t, cfg.ServerEdition.Access)
+ require.Equal(t, []string{"github", "ast-grep"}, cfg.ServerEdition.Access.GroupServers["mcpproxy-engineering"])
+ require.Equal(t, []string{"*"}, cfg.ServerEdition.Access.GroupServers["mcpproxy-admins"])
+
+ require.Equal(t, []string{"10.42.0.0/16"}, cfg.TrustedProxies)
+ require.True(t, cfg.RequireMCPAuth)
+
+ require.NotNil(t, cfg.AuditLog)
+ require.NotNil(t, cfg.AuditLog.Enabled)
+ require.True(t, *cfg.AuditLog.Enabled)
+ require.NotNil(t, cfg.AuditLog.Stdout)
+ require.True(t, *cfg.AuditLog.Stdout)
+
+ resolved, warning, effErr := EffectiveAuditLog(cfg, TransportHTTP)
+ require.NoError(t, effErr)
+ require.Empty(t, warning)
+ require.True(t, resolved.Enabled)
+ require.True(t, resolved.Stdout)
+}
+
+// deployGuideExamplePath locates docs/operations/examples/deploying-for-a-team.json
+// relative to the repo root (two levels up from internal/config).
+func deployGuideExamplePath(t *testing.T) string {
+ t.Helper()
+ wd, err := os.Getwd()
+ require.NoError(t, err)
+ path := filepath.Join(wd, "..", "..", "docs", "operations", "examples", "deploying-for-a-team.json")
+ _, statErr := os.Stat(path)
+ require.NoError(t, statErr, "expected example config at %s", path)
+ return path
+}
diff --git a/internal/config/loader.go b/internal/config/loader.go
index 62869e35b..b85f52381 100644
--- a/internal/config/loader.go
+++ b/internal/config/loader.go
@@ -791,6 +791,42 @@ func applyTLSEnvOverrides(cfg *Config) {
// key with an env alias. Build-tagged: a no-op on the personal build.
applyServerEditionEnvOverrides(cfg)
+ // Override trusted proxies (Spec 107 FR-027). Comma-separated CIDRs or
+ // IPs; an empty variable leaves the file value. Entries are validated by
+ // validateTrustedProxies exactly like file values (LoadFromFile validates
+ // after the overrides run).
+ if value := os.Getenv("MCPPROXY_TRUSTED_PROXIES"); strings.TrimSpace(value) != "" {
+ cfg.TrustedProxies = parseTrustedProxiesEnv(value)
+ }
+
+ // Spec 107 FR-025: MCPPROXY_PUBLIC_URL, the one nested server_edition.*
+ // key with an env alias. Build-tagged: a no-op on the personal build.
+ applyServerEditionEnvOverrides(cfg)
+
+ // Spec 107 FR-019: audit_log env overrides. An explicit env value wins
+ // over the file value and materializes the block so a config with no
+ // `audit_log` key can still be steered from the environment.
+ if value, ok := os.LookupEnv("MCPPROXY_AUDIT_LOG_ENABLED"); ok {
+ if cfg.AuditLog == nil {
+ cfg.AuditLog = &AuditLogConfig{}
+ }
+ enabled := value == trueValue || value == "1"
+ cfg.AuditLog.Enabled = &enabled
+ }
+ if value := os.Getenv("MCPPROXY_AUDIT_LOG_PATH"); value != "" {
+ if cfg.AuditLog == nil {
+ cfg.AuditLog = &AuditLogConfig{}
+ }
+ cfg.AuditLog.Path = value
+ }
+ if value, ok := os.LookupEnv("MCPPROXY_AUDIT_LOG_STDOUT"); ok {
+ if cfg.AuditLog == nil {
+ cfg.AuditLog = &AuditLogConfig{}
+ }
+ stdout := value == trueValue || value == "1"
+ cfg.AuditLog.Stdout = &stdout
+ }
+
// Override the offline TPA signature-bundle path from environment
// (spec 086 FR-019). Explicit MCPPROXY_* alias per the loader convention;
// the env value wins over the file value, and materializes the security
diff --git a/internal/jsruntime/batch_test.go b/internal/jsruntime/batch_test.go
index 74d141826..bf77d3ff9 100644
--- a/internal/jsruntime/batch_test.go
+++ b/internal/jsruntime/batch_test.go
@@ -43,6 +43,20 @@ func newBatchStub() *batchStub {
func (s *batchStub) CallTool(ctx context.Context, serverName, toolName string, args map[string]interface{}) (interface{}, error) {
key := serverName + ":" + toolName
+ // A well-behaved ToolCaller — the real upstreamToolCaller bridge, or a
+ // managed client's transport — checks an already-done context and
+ // returns promptly without doing real upstream work, exactly what a
+ // realistic stub must simulate here: round-2 cross-review, PR-D
+ // (dispatchBatchElement no longer short-circuits on ctx.Err() itself,
+ // so this stub is the one place that now has to).
+ if err := ctx.Err(); err != nil {
+ s.mu.Lock()
+ s.dispatched++
+ s.cancelled++
+ s.mu.Unlock()
+ return nil, err
+ }
+
s.mu.Lock()
s.dispatched++
s.inFlight++
@@ -853,6 +867,14 @@ func TestBatchCancellationStillRecordsEveryElement(t *testing.T) {
}
})
+ // "cancelled before dispatch" is a round-2 cross-review regression (PR-D):
+ // dispatchBatchElement used to short-circuit on ctx.Err() BEFORE calling
+ // the ToolCaller at all, skipping the real bridge that installs the
+ // audit.Attempt and writes the paired `authz allow` + `tool_call` lines
+ // for every accepted (pre-dispatch-allowed) element. It must now always
+ // reach the ToolCaller — exactly like the lone call_tool() path already
+ // does — and rely on the caller itself to bail promptly on a cancelled
+ // context (batchStub.CallTool does, simulating the real bridge).
t.Run("cancelled before dispatch", func(t *testing.T) {
stub := newBatchStub()
@@ -876,8 +898,10 @@ func TestBatchCancellationStillRecordsEveryElement(t *testing.T) {
t.Errorf("slot %d code = %q, want %q", i, gotCode, string(ErrorCodeUpstreamError))
}
}
- if dispatched, _, _ := stub.stats(); dispatched != 0 {
- t.Errorf("dispatched %d calls under a cancelled context", dispatched)
+ if dispatched, _, cancelled := stub.stats(); dispatched != 2 || cancelled != 2 {
+ t.Errorf("dispatched=%d cancelled=%d, want 2 and 2 — every accepted element must still reach the ToolCaller "+
+ "(the real bridge that writes its audit lines), even though the context is already done",
+ dispatched, cancelled)
}
if len(ec.ToolCalls) != 2 {
t.Errorf("recorded %d tool calls, want 2", len(ec.ToolCalls))
diff --git a/internal/jsruntime/runtime.go b/internal/jsruntime/runtime.go
index 6d0c73619..feeff544a 100644
--- a/internal/jsruntime/runtime.go
+++ b/internal/jsruntime/runtime.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math"
+ "strings"
"sync"
"time"
@@ -40,6 +41,41 @@ type ExecutionOptions struct {
// second, independent read. When set it takes precedence over
// ToolAnnotationFunc; leave nil to keep the tier-only contract.
ToolGateFunc ToolGateLookup
+
+ // AuthzObserver, when set, receives one AuthzGateReport per scope or
+ // permission REFUSAL resolveDispatchGates decides — the lone call_tool()
+ // and every call_tools() batch element alike — so the host can write the
+ // Spec 107 `authz deny` audit line for a nested call that never reaches
+ // the ToolCaller (the completion emitters never see it). A refused call
+ // is reported exactly once; an allowed call is not reported here at all
+ // (its `authz allow` is the host's, written at dispatch). nil = no-op.
+ AuthzObserver AuthzObserver
+ // ParentID is the wrapper's correlation id, echoed on every report so the
+ // nested line carries parent_id (contracts/audit-line-events.md).
+ ParentID string
+}
+
+// AuthzGateReport is one pre-dispatch refusal decided inside the sandbox
+// (Spec 107 T102/T104). Arguments is the caller's args map with every
+// `_auth_`-prefixed key removed (FR-015) — the observer hashes it, never
+// records it.
+type AuthzGateReport struct {
+ Ctx context.Context
+ ParentID string
+ ServerName string
+ ToolName string
+ CanonicalTarget string // "server:tool"
+ Denied bool
+ Code ErrorCode // the envelope code of the refusal (SERVER_NOT_ALLOWED, ACCESS_DENIED, PERMISSION_DENIED)
+ RequiredPerm string // the tier the lookup resolved, when one was resolved
+ Arguments map[string]interface{}
+}
+
+// AuthzObserver receives AuthzGateReports. Implementations must be safe for
+// concurrent use: call_tools() batch elements are gated on the script
+// goroutine, but the observer contract does not promise that forever.
+type AuthzObserver interface {
+ ObserveAuthzGate(report AuthzGateReport)
}
// AuthInfo carries authentication context for permission enforcement in JS execution.
@@ -169,6 +205,11 @@ type ExecutionContext struct {
toolAnnotationFunc ToolAnnotationLookup
toolGateFunc ToolGateLookup // gate-capturing lookup; takes precedence over toolAnnotationFunc
maxPermissionLevel string // Tracks highest permission used: read < write < destructive
+
+ // Spec 107 T104: the host's authorization-decision observer and the
+ // parent_id it installed (see ExecutionOptions.AuthzObserver).
+ authzObserver AuthzObserver
+ parentID string
}
// ToolCallRecord represents a single call_tool() invocation
@@ -200,6 +241,8 @@ func newExecutionContext(caller ToolCaller, opts ExecutionOptions) *ExecutionCon
toolAnnotationFunc: opts.ToolAnnotationFunc,
toolGateFunc: opts.ToolGateFunc,
maxPermissionLevel: "",
+ authzObserver: opts.AuthzObserver,
+ parentID: opts.ParentID,
}
// Build allowed server map for fast lookup
@@ -440,20 +483,67 @@ func successEnvelope(result interface{}) map[string]interface{} {
// effect stay with the callers, because the batch path accounts for both
// across a whole batch before dispatching any of it.
func (ec *ExecutionContext) checkDispatchGates(serverName, toolName string) (gateErr map[string]interface{}, requiredPerm string) {
- gateErr, requiredPerm, _ = ec.resolveDispatchGates(serverName, toolName)
+ gateErr, requiredPerm, _ = ec.resolveDispatchGates(serverName, toolName, nil)
return gateErr, requiredPerm
}
+// reportAuthzRefusal hands one refusal to the host's observer (Spec 107
+// T104). It is the ONLY reporting seam: resolveDispatchGates calls it on
+// every refusing return, once, so a refusal is never re-reported by the
+// completion path (which a refused call never reaches). nil observer = no-op.
+func (ec *ExecutionContext) reportAuthzRefusal(serverName, toolName string, code ErrorCode, requiredPerm string, args map[string]interface{}) {
+ if ec.authzObserver == nil {
+ return
+ }
+ ec.authzObserver.ObserveAuthzGate(AuthzGateReport{
+ Ctx: ec.executionCtx(),
+ ParentID: ec.parentID,
+ ServerName: serverName,
+ ToolName: toolName,
+ CanonicalTarget: serverName + ":" + toolName,
+ Denied: true,
+ Code: code,
+ RequiredPerm: requiredPerm,
+ Arguments: stripAuthInjectedArgs(args),
+ })
+}
+
+// authInjectedArgPrefix mirrors security.StripInternalArgs' prefix (the
+// `_auth_*` activity-metadata keys, Spec 028) without importing
+// internal/security into the sandbox package.
+const authInjectedArgPrefix = "_auth_"
+
+// stripAuthInjectedArgs returns args without any `_auth_`-prefixed key. It
+// never mutates the input; when nothing is stripped it returns a shallow
+// copy so the report cannot alias the script's live map either.
+func stripAuthInjectedArgs(args map[string]interface{}) map[string]interface{} {
+ if args == nil {
+ return nil
+ }
+ out := make(map[string]interface{}, len(args))
+ for k, v := range args {
+ if strings.HasPrefix(k, authInjectedArgPrefix) {
+ continue
+ }
+ out[k] = v
+ }
+ return out
+}
+
// resolveDispatchGates is checkDispatchGates plus the ToolGate the tier
// lookup captured (nil unless ToolGateFunc is wired and answered one). The
// gate is returned only for a call that passed every check: the dispatch
// that follows consumes it so the call runs on the read that authorized it
// (Spec 105 FR-009; codex r9 I1).
-func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string) (gateErr map[string]interface{}, requiredPerm string, gate ToolGate) {
+//
+// args is the call's argument map, reported (stripped of `_auth_*` keys) to
+// the AuthzObserver on a refusal; nil is accepted.
+func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string, args map[string]interface{}) (gateErr map[string]interface{}, requiredPerm string, gate ToolGate) {
// Check allowed servers. When restrictToAllowed is set (active Spec 057
// profile), the map is enforced even when empty — an empty effective set
// means "deny everything". Otherwise an empty map means "no restriction".
if (ec.restrictToAllowed || len(ec.allowedServerMap) > 0) && !ec.allowedServerMap[serverName] {
+ ec.reportAuthzRefusal(serverName, toolName, ErrorCodeServerNotAllowed, "", args)
return errorEnvelope(ErrorCodeServerNotAllowed, fmt.Sprintf("server not allowed: %s", serverName)), "", nil
}
@@ -461,6 +551,7 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string) (g
// before anything about the tool is looked up, so an out-of-scope server
// is refused without disclosing whether the name resolves on it.
if ec.authInfo != nil && !ec.authInfo.CanAccessServer(serverName) {
+ ec.reportAuthzRefusal(serverName, toolName, ErrorCodeAccessDenied, "", args)
return errorEnvelope(ErrorCodeAccessDenied, fmt.Sprintf("token does not have access to server '%s'", serverName)), "", nil
}
@@ -486,6 +577,7 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string) (g
// to the same identity rule as every HTTP caller. It answers with the
// permission envelope, never with an upstream's own "tool not found".
if requiredPerm == PermissionTierUnresolved {
+ ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, requiredPerm, args)
return errorEnvelope(ErrorCodePermissionDenied,
fmt.Sprintf("permission denied: tool '%s:%s' cannot be resolved against the current tool list of server '%s' (undiscovered or stale name), so no permission tier applies to it",
serverName, toolName, serverName)), "", nil
@@ -500,6 +592,7 @@ func (ec *ExecutionContext) resolveDispatchGates(serverName, toolName string) (g
}
if !ec.authInfo.HasPermission(requiredPerm) {
+ ec.reportAuthzRefusal(serverName, toolName, ErrorCodePermissionDenied, requiredPerm, args)
return errorEnvelope(ErrorCodePermissionDenied,
fmt.Sprintf("token does not have '%s' permission for tool '%s:%s'", requiredPerm, serverName, toolName)), "", nil
}
@@ -531,7 +624,7 @@ func (ec *ExecutionContext) makeCallToolFunction(vm *goja.Runtime) func(goja.Fun
fmt.Sprintf("exceeded max tool calls limit: %d", ec.maxToolCalls)))
}
- gateErr, requiredPerm, gate := ec.resolveDispatchGates(serverName, toolName)
+ gateErr, requiredPerm, gate := ec.resolveDispatchGates(serverName, toolName, args)
if gateErr != nil {
return vm.ToValue(gateErr)
}
@@ -781,7 +874,7 @@ func (ec *ExecutionContext) runBatch(requests []batchRequest, maxParallelOverrid
continue
}
- gateErr, requiredPerm, gate := ec.resolveDispatchGates(req.server, req.tool)
+ gateErr, requiredPerm, gate := ec.resolveDispatchGates(req.server, req.tool, req.args)
if gateErr != nil {
slots[i] = gateErr
continue
@@ -858,15 +951,23 @@ func dispatchBatchElement(ctx context.Context, caller ToolCaller, req batchReque
}
// A cancelled execution still owes every accepted element a slot and a
- // record, so the remaining queue is drained into cancellation errors
- // instead of being dispatched.
- if err := ctx.Err(); err != nil {
- record.DurationMs = time.Since(record.StartTime).Milliseconds()
- record.ErrorDetail = err.Error()
- return errorEnvelope(ErrorCodeUpstreamError,
- fmt.Sprintf("execution ended before the call was dispatched: %v", err)), record
- }
-
+ // record — and, for a real ToolCaller, exactly the paired `authz allow`
+ // + `tool_call` audit lines every other allowed dispatch gets (round-2
+ // cross-review finding, PR-D): this pre-dispatch pass already made the
+ // allow decision (runBatch's gate loop, above), and the ONLY place that
+ // decision is written to the audit sink is inside the real ToolCaller's
+ // dispatch bridge (upstreamToolCaller.callTool installs the
+ // audit.Attempt and writes `authz allow` before ever touching the
+ // network). Short-circuiting here on ctx.Err() — as this used to, to
+ // avoid a doomed dispatch — skipped that bridge entirely, so an element
+ // whose worker reached the queue after the execution context expired
+ // produced ZERO audit lines, violating `#authz == #pre-dispatch
+ // decisions` under load. This always calls dispatchTool, exactly like
+ // the lone call_tool() path (makeCallToolFunction) already does with no
+ // such short-circuit: a well-behaved ToolCaller (the real
+ // upstreamToolCaller, or a managed client's transport) itself checks
+ // ctx and returns promptly without doing real upstream work — the
+ // audit-attempt bridge simply has to run first.
result, err := dispatchTool(ctx, caller, req.server, req.tool, req.args, req.gate)
record.DurationMs = time.Since(record.StartTime).Milliseconds()
diff --git a/internal/jsruntime/runtime_authz_observer_test.go b/internal/jsruntime/runtime_authz_observer_test.go
new file mode 100644
index 000000000..c2e202cba
--- /dev/null
+++ b/internal/jsruntime/runtime_authz_observer_test.go
@@ -0,0 +1,239 @@
+package jsruntime
+
+import (
+ "context"
+ "sync"
+ "testing"
+)
+
+// Spec 107 PR-D (T102, US3): checkDispatchGates (runtime.go:384-418) is the
+// single seam every nested scope/permission decision passes through, lone
+// call_tool() and each call_tools() batch element alike. The audit line (T107
+// installs the wrapper-side emitter, T104 wires this observer into
+// checkDispatchGates itself) needs one authz report per gate decision,
+// carrying the request context, the parent_id the wrapper installed, the
+// canonical "server:tool" target, the tier the gate resolved, and the
+// arguments with every _auth_*-prefixed key stripped (Spec 107 FR-015) —
+// never the raw arguments a caller-injected identity key could still be
+// hiding in.
+//
+// This file is [compile-red until T104]: AuthzObserver, AuthzGateReport and
+// ExecutionOptions.{AuthzObserver,ParentID} do not exist yet. No production
+// code is added by this task.
+
+// recordingAuthzObserver captures every report it receives, in order, guarded
+// by a mutex so the batch path's concurrent workers can report safely.
+type recordingAuthzObserver struct {
+ mu sync.Mutex
+ reports []AuthzGateReport
+}
+
+func (o *recordingAuthzObserver) ObserveAuthzGate(report AuthzGateReport) {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ o.reports = append(o.reports, report)
+}
+
+func (o *recordingAuthzObserver) snapshot() []AuthzGateReport {
+ o.mu.Lock()
+ defer o.mu.Unlock()
+ out := make([]AuthzGateReport, len(o.reports))
+ copy(out, o.reports)
+ return out
+}
+
+// TestCheckDispatchGates_ReportsServerScopeRefusal proves a lone call_tool()
+// refused by the allow-list gate (RestrictToAllowed / AllowedServers) is
+// reported exactly once, with the canonical target and no upstream dispatch.
+func TestCheckDispatchGates_ReportsServerScopeRefusal(t *testing.T) {
+ caller := newMockToolCaller()
+ observer := &recordingAuthzObserver{}
+
+ parentID := "parent-attempt-123"
+ result := Execute(context.Background(), caller, `call_tool("s", "t", {"x": 1})`, ExecutionOptions{
+ AllowedServers: []string{"other"},
+ AuthzObserver: observer,
+ ParentID: parentID,
+ })
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+ if len(caller.calls) != 0 {
+ t.Fatalf("a refused call must never dispatch upstream, got %d calls", len(caller.calls))
+ }
+
+ reports := observer.snapshot()
+ if len(reports) != 1 {
+ t.Fatalf("exactly one authz report expected, got %d: %#v", len(reports), reports)
+ }
+ report := reports[0]
+ if report.Ctx == nil {
+ t.Fatalf("report must carry the request context, got nil")
+ }
+ if report.ParentID != parentID {
+ t.Fatalf("report.ParentID = %q, want %q", report.ParentID, parentID)
+ }
+ if report.CanonicalTarget != "s:t" {
+ t.Fatalf("report.CanonicalTarget = %q, want %q", report.CanonicalTarget, "s:t")
+ }
+ if !report.Denied {
+ t.Fatalf("a server-scope refusal must report Denied=true, got %#v", report)
+ }
+ if report.Arguments == nil || report.Arguments["x"] != int64(1) {
+ t.Fatalf("report.Arguments must carry the stripped arguments, got %#v", report.Arguments)
+ }
+}
+
+// TestCheckDispatchGates_ReportsPermissionTierRefusal proves an AuthInfo
+// permission-tier refusal is reported with the required tier the tool
+// annotation lookup resolved, not merely "denied".
+func TestCheckDispatchGates_ReportsPermissionTierRefusal(t *testing.T) {
+ caller := newMockToolCaller()
+ observer := &recordingAuthzObserver{}
+
+ result := Execute(context.Background(), caller, `call_tool("s", "t", {})`, ExecutionOptions{
+ AuthContext: &AuthInfo{Type: "agent", AgentName: "a", AllowedServers: []string{"s"}, Permissions: []string{"read"}},
+ ToolAnnotationFunc: func(serverName, toolName string) string {
+ return "destructive"
+ },
+ AuthzObserver: observer,
+ })
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+
+ reports := observer.snapshot()
+ if len(reports) != 1 {
+ t.Fatalf("exactly one authz report expected, got %d: %#v", len(reports), reports)
+ }
+ report := reports[0]
+ if !report.Denied {
+ t.Fatalf("a permission-tier refusal must report Denied=true, got %#v", report)
+ }
+ if report.RequiredPerm != "destructive" {
+ t.Fatalf("report.RequiredPerm = %q, want %q", report.RequiredPerm, "destructive")
+ }
+ if report.CanonicalTarget != "s:t" {
+ t.Fatalf("report.CanonicalTarget = %q, want %q", report.CanonicalTarget, "s:t")
+ }
+}
+
+// TestCheckDispatchGates_AllowedCallReportsNoDenial proves an allowed call
+// either produces no report at all, or a report with Denied=false — the
+// contract this test pins is that a passing gate is never mistaken for a
+// refusal by whatever consumes AuthzGateReport.Denied.
+func TestCheckDispatchGates_AllowedCallReportsNoDenial(t *testing.T) {
+ caller := newMockToolCaller()
+ observer := &recordingAuthzObserver{}
+
+ result := Execute(context.Background(), caller, `call_tool("s", "t", {})`, ExecutionOptions{
+ AuthzObserver: observer,
+ })
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("an allowed call must dispatch upstream exactly once, got %d", len(caller.calls))
+ }
+
+ for _, report := range observer.snapshot() {
+ if report.Denied {
+ t.Fatalf("an allowed call must never be reported as denied, got %#v", report)
+ }
+ }
+}
+
+// TestCheckDispatchGates_BatchReportsEachElementRefusalOnce proves the batch
+// path (call_tools) reports one denial per refused element — not one for the
+// whole batch, not more than one per element — while an accepted element in
+// the same batch dispatches and is not reported as a denial.
+func TestCheckDispatchGates_BatchReportsEachElementRefusalOnce(t *testing.T) {
+ caller := newMockToolCaller()
+ observer := &recordingAuthzObserver{}
+
+ result := Execute(context.Background(), caller,
+ `call_tools([
+ {server: "allowed", tool: "t1", args: {}},
+ {server: "blocked", tool: "t2", args: {}},
+ {server: "blocked", tool: "t3", args: {}}
+ ], {max_parallel: 2})`,
+ ExecutionOptions{
+ AllowedServers: []string{"allowed"},
+ AuthzObserver: observer,
+ ParentID: "parent-batch-1",
+ })
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("only the allowed element may dispatch upstream, got %d calls", len(caller.calls))
+ }
+
+ reports := observer.snapshot()
+ deniedByTarget := map[string]int{}
+ for _, report := range reports {
+ if report.ParentID != "parent-batch-1" {
+ t.Fatalf("every batch element report must carry the batch's parent_id, got %q", report.ParentID)
+ }
+ if report.Denied {
+ deniedByTarget[report.CanonicalTarget]++
+ }
+ }
+ if deniedByTarget["blocked:t2"] != 1 {
+ t.Fatalf("blocked:t2 must be reported denied exactly once, got %d", deniedByTarget["blocked:t2"])
+ }
+ if deniedByTarget["blocked:t3"] != 1 {
+ t.Fatalf("blocked:t3 must be reported denied exactly once, got %d", deniedByTarget["blocked:t3"])
+ }
+ if deniedByTarget["allowed:t1"] != 0 {
+ t.Fatalf("allowed:t1 must never be reported denied, got %d", deniedByTarget["allowed:t1"])
+ }
+}
+
+// TestCheckDispatchGates_StripsAuthInjectedArguments proves the report never
+// carries a caller-injected _auth_*-prefixed argument key (FR-015): a script
+// cannot smuggle one into the audit line by naming its own argument that way,
+// and the host cannot either by way of an args map that already had one set
+// before dispatch was attempted.
+func TestCheckDispatchGates_StripsAuthInjectedArguments(t *testing.T) {
+ caller := newMockToolCaller()
+ observer := &recordingAuthzObserver{}
+
+ result := Execute(context.Background(), caller,
+ `call_tool("blocked", "t", {"_auth_user_id": "u1", "normal": "v"})`,
+ ExecutionOptions{
+ AllowedServers: []string{"other"},
+ AuthzObserver: observer,
+ })
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+
+ reports := observer.snapshot()
+ if len(reports) != 1 {
+ t.Fatalf("exactly one authz report expected, got %d", len(reports))
+ }
+ for key := range reports[0].Arguments {
+ if len(key) >= 6 && key[:6] == "_auth_" {
+ t.Fatalf("report.Arguments must never carry an _auth_*-prefixed key, got %#v", reports[0].Arguments)
+ }
+ }
+ if reports[0].Arguments["normal"] != "v" {
+ t.Fatalf("report.Arguments must still carry the caller's own arguments, got %#v", reports[0].Arguments)
+ }
+}
+
+// TestCheckDispatchGates_NilObserverIsANoOp proves a nil AuthzObserver (the
+// default for every caller that does not opt in) never panics and never
+// changes dispatch behaviour — the observer is best-effort reporting, not a
+// gate itself.
+func TestCheckDispatchGates_NilObserverIsANoOp(t *testing.T) {
+ caller := newMockToolCaller()
+ result := Execute(context.Background(), caller, `call_tool("s", "t", {})`, ExecutionOptions{})
+ if !result.Ok {
+ t.Fatalf("execution failed: %+v", result.Error)
+ }
+ if len(caller.calls) != 1 {
+ t.Fatalf("expected one dispatch with a nil observer, got %d", len(caller.calls))
+ }
+}
diff --git a/internal/management/diagnostics_audit_test.go b/internal/management/diagnostics_audit_test.go
new file mode 100644
index 000000000..6b6dda26b
--- /dev/null
+++ b/internal/management/diagnostics_audit_test.go
@@ -0,0 +1,87 @@
+package management
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+)
+
+// stubAuditFailureSink is the minimal double for internal/server's audit
+// write-failure doctor source: only WriteFailures() matters here (T109).
+type stubAuditFailureSink struct {
+ failures uint64
+}
+
+func (s *stubAuditFailureSink) WriteFailures() uint64 { return s.failures }
+
+// TestDoctor_AuditWriteFailuresThroughRuntimeWarningSource pins the T109
+// doctor seam: a registered runtime-warning source reading the sink's
+// always-on write-failure counter surfaces as a `mcpproxy doctor` finding
+// (works with metrics disabled - the counter itself is not a Prometheus
+// metric), and clears once the source reports zero again.
+func TestDoctor_AuditWriteFailuresThroughRuntimeWarningSource(t *testing.T) {
+ sink := &stubAuditFailureSink{}
+ live := &config.Config{Listen: "127.0.0.1:8080"}
+
+ svc := NewService(newMockRuntime(), live, "", &mockEventEmitter{}, nil, zap.NewNop().Sugar())
+ svc.AddRuntimeWarningSource(func() []string {
+ if n := sink.WriteFailures(); n > 0 {
+ return []string{"audit_log: 3 write failures since start"}
+ }
+ return nil
+ })
+
+ diag, err := svc.Doctor(context.Background())
+ require.NoError(t, err)
+ assert.Empty(t, diag.RuntimeWarnings, "no failures yet, no finding")
+
+ sink.failures = 3
+ diag, err = svc.Doctor(context.Background())
+ require.NoError(t, err)
+ require.Len(t, diag.RuntimeWarnings, 1)
+ assert.Contains(t, diag.RuntimeWarnings[0], "audit_log")
+ assert.Contains(t, diag.RuntimeWarnings[0], "3 write failures")
+ assert.Equal(t, 1, diag.TotalIssues)
+}
+
+// stubAuditSanitizerSink is the minimal double for internal/server's
+// defence-in-depth sanitizer-hit doctor source (FR-015).
+type stubAuditSanitizerSink struct {
+ hits uint64
+}
+
+func (s *stubAuditSanitizerSink) SanitizerHits() uint64 { return s.hits }
+
+// TestDoctor_AuditSanitizerHitsThroughRuntimeWarningSource pins the
+// FR-015 doctor seam: a registered runtime-warning source reading the
+// sink's always-on defence-in-depth sanitizer-hit counter surfaces as a
+// `mcpproxy doctor` finding, and clears once the source reports zero again.
+func TestDoctor_AuditSanitizerHitsThroughRuntimeWarningSource(t *testing.T) {
+ sink := &stubAuditSanitizerSink{}
+ live := &config.Config{Listen: "127.0.0.1:8080"}
+
+ svc := NewService(newMockRuntime(), live, "", &mockEventEmitter{}, nil, zap.NewNop().Sugar())
+ svc.AddRuntimeWarningSource(func() []string {
+ if n := sink.SanitizerHits(); n > 0 {
+ return []string{"audit_log: 2 defence-in-depth sanitizer hits since start"}
+ }
+ return nil
+ })
+
+ diag, err := svc.Doctor(context.Background())
+ require.NoError(t, err)
+ assert.Empty(t, diag.RuntimeWarnings, "no hits yet, no finding")
+
+ sink.hits = 2
+ diag, err = svc.Doctor(context.Background())
+ require.NoError(t, err)
+ require.Len(t, diag.RuntimeWarnings, 1)
+ assert.Contains(t, diag.RuntimeWarnings[0], "audit_log")
+ assert.Contains(t, diag.RuntimeWarnings[0], "sanitizer hits")
+ assert.Equal(t, 1, diag.TotalIssues)
+}
diff --git a/internal/observability/audit_metrics_test.go b/internal/observability/audit_metrics_test.go
new file mode 100644
index 000000000..7bafcbac6
--- /dev/null
+++ b/internal/observability/audit_metrics_test.go
@@ -0,0 +1,70 @@
+package observability
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+)
+
+type stubAuditSink struct{ n, sanitizerHits uint64 }
+
+func (s *stubAuditSink) WriteFailures() uint64 { return s.n }
+func (s *stubAuditSink) SanitizerHits() uint64 { return s.sanitizerHits }
+
+// TestRegisterAuditSink_MirrorsWriteFailures pins Spec 107 T109:
+// mcpproxy_audit_write_failures_total exists on /metrics and tracks the
+// sink's own counter live (a CounterFunc, so no separate bookkeeping can
+// drift out of sync).
+func TestRegisterAuditSink_MirrorsWriteFailures(t *testing.T) {
+ mm := NewMetricsManager(zap.NewNop().Sugar())
+ sink := &stubAuditSink{}
+ mm.RegisterAuditSink(sink)
+
+ assert.Equal(t, float64(0), gatherCounterValue(t, mm, "mcpproxy_audit_write_failures_total"))
+
+ sink.n = 5
+ assert.Equal(t, float64(5), gatherCounterValue(t, mm, "mcpproxy_audit_write_failures_total"))
+}
+
+func TestRegisterAuditSink_NilSink_NoPanic(t *testing.T) {
+ mm := NewMetricsManager(zap.NewNop().Sugar())
+ mm.RegisterAuditSink(nil) // must be a no-op, never a nil-interface panic
+}
+
+// TestRegisterAuditSanitizer_MirrorsSanitizerHits proves
+// mcpproxy_audit_sanitizer_hits_total exists on /metrics and tracks the
+// sink's defence-in-depth whole-line sanitizer counter live (FR-015).
+func TestRegisterAuditSanitizer_MirrorsSanitizerHits(t *testing.T) {
+ mm := NewMetricsManager(zap.NewNop().Sugar())
+ sink := &stubAuditSink{}
+ mm.RegisterAuditSanitizer(sink)
+
+ assert.Equal(t, float64(0), gatherCounterValue(t, mm, "mcpproxy_audit_sanitizer_hits_total"))
+
+ sink.sanitizerHits = 2
+ assert.Equal(t, float64(2), gatherCounterValue(t, mm, "mcpproxy_audit_sanitizer_hits_total"))
+}
+
+func TestRegisterAuditSanitizer_NilSink_NoPanic(t *testing.T) {
+ mm := NewMetricsManager(zap.NewNop().Sugar())
+ mm.RegisterAuditSanitizer(nil) // must be a no-op, never a nil-interface panic
+}
+
+// gatherCounterValue scrapes the manager's registry and returns the single
+// value reported for the named counter.
+func gatherCounterValue(t *testing.T, mm *MetricsManager, name string) float64 {
+ t.Helper()
+ mfs, err := mm.registry.Gather()
+ require.NoError(t, err)
+ for _, mf := range mfs {
+ if mf.GetName() != name {
+ continue
+ }
+ require.Len(t, mf.GetMetric(), 1)
+ return mf.GetMetric()[0].GetCounter().GetValue()
+ }
+ t.Fatalf("metric %q not found", name)
+ return 0
+}
diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go
index d8a3f06c0..d2987de04 100644
--- a/internal/observability/metrics.go
+++ b/internal/observability/metrics.go
@@ -305,6 +305,60 @@ func (mm *MetricsManager) registerMetrics() {
mm.registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
}
+// auditFailureSource is the minimal surface RegisterAuditSink needs from
+// audit.Sink (declared locally to avoid an import cycle risk between
+// internal/observability and internal/audit).
+type auditFailureSource interface {
+ WriteFailures() uint64
+}
+
+// RegisterAuditSink wires the Spec 107 audit sink's always-on write-failure
+// counter into Prometheus as mcpproxy_audit_write_failures_total (T109,
+// FR-018). It is a CounterFunc reading sink.WriteFailures() directly -
+// monotonic by construction, so it can never regress into looking like a
+// gauge, and it needs no separate bookkeeping to stay in sync with the sink's
+// own atomic counter. Safe to call at most once per sink (a second call on
+// the same registry panics via MustRegister, same as every other metric
+// here); server.go only calls it when a sink exists.
+func (mm *MetricsManager) RegisterAuditSink(sink auditFailureSource) {
+ if sink == nil {
+ return
+ }
+ mm.registry.MustRegister(prometheus.NewCounterFunc(
+ prometheus.CounterOpts{
+ Name: "mcpproxy_audit_write_failures_total",
+ Help: "Total number of audit-log lines that failed to write since the sink was constructed",
+ },
+ func() float64 { return float64(sink.WriteFailures()) },
+ ))
+}
+
+// auditSanitizerSource is the minimal surface RegisterAuditSanitizer needs
+// from audit.Sink (declared locally, same reasoning as auditFailureSource).
+type auditSanitizerSource interface {
+ SanitizerHits() uint64
+}
+
+// RegisterAuditSanitizer wires the Spec 107 audit sink's always-on
+// defence-in-depth sanitizer-hit counter into Prometheus as
+// mcpproxy_audit_sanitizer_hits_total (contracts/audit-line-events.md
+// "Redaction (FR-015)"). A hit means the whole-line pass caught a
+// credential-shaped string that a builder bug let past per-field masking;
+// it should read zero for the life of a healthy process. Safe to call at
+// most once per sink; server.go only calls it when a sink exists.
+func (mm *MetricsManager) RegisterAuditSanitizer(sink auditSanitizerSource) {
+ if sink == nil {
+ return
+ }
+ mm.registry.MustRegister(prometheus.NewCounterFunc(
+ prometheus.CounterOpts{
+ Name: "mcpproxy_audit_sanitizer_hits_total",
+ Help: "Total number of audit-log lines where the defence-in-depth whole-line sanitizer masked a credential-shaped string that per-field masking missed",
+ },
+ func() float64 { return float64(sink.SanitizerHits()) },
+ ))
+}
+
// Handler returns an HTTP handler for the /metrics endpoint
func (mm *MetricsManager) Handler() http.Handler {
return promhttp.HandlerFor(mm.registry, promhttp.HandlerOpts{
diff --git a/internal/runtime/config_hotreload.go b/internal/runtime/config_hotreload.go
index ddf2e1e2a..ac5419b9d 100644
--- a/internal/runtime/config_hotreload.go
+++ b/internal/runtime/config_hotreload.go
@@ -467,6 +467,22 @@ func DetectConfigChanges(oldCfg, newCfg *config.Config) *ConfigApplyResult {
result.ChangedFields = append(result.ChangedFields, "server_edition.access")
}
+ // audit_log (Spec 107 FR-019/T109). Restart-pinned: the sink is
+ // constructed once at cmd/mcpproxy serve startup and handed to the
+ // server via server.WithAuditSink, so an edit here cannot take effect
+ // without rebuilding the sink. jsonEqual, not DeepEqual, for the same
+ // PATCH round-trip reason as trusted_proxies/server_edition: pointer
+ // fields and omitempty make byte-identical documents compare unequal
+ // under reflect.DeepEqual after a JSON round trip.
+ if !jsonEqual(oldCfg.AuditLog, newCfg.AuditLog) {
+ result.ChangedFields = append(result.ChangedFields, "audit_log")
+ result.RequiresRestart = true
+ result.AppliedImmediately = false
+ if result.RestartReason == "" {
+ result.RestartReason = "audit_log is bound at sink construction"
+ }
+ }
+
// If no changes detected
if len(result.ChangedFields) == 0 {
result.AppliedImmediately = false
diff --git a/internal/runtime/config_hotreload_test.go b/internal/runtime/config_hotreload_test.go
index 61f6323a0..77bea2ca7 100644
--- a/internal/runtime/config_hotreload_test.go
+++ b/internal/runtime/config_hotreload_test.go
@@ -1127,3 +1127,34 @@ func TestDetectConfigChanges_TrustedProxies(t *testing.T) {
assert.NotContains(t, DetectConfigChanges(mk([]string{"::1"}), mk([]string{"::1"})).ChangedFields, "trusted_proxies")
})
}
+
+// Spec 107 T108/T109: audit_log is bound at sink construction, so an edit is
+// reported restart-pinned, never applied immediately.
+func TestDetectConfigChanges_AuditLog_RestartPinned(t *testing.T) {
+ mk := func(a *config.AuditLogConfig) *config.Config {
+ return &config.Config{
+ Listen: "127.0.0.1:8080", DataDir: "/d", TLS: &config.TLSConfig{},
+ AuditLog: a,
+ }
+ }
+ trueVal := true
+
+ t.Run("nil to explicit block requires restart", func(t *testing.T) {
+ result := DetectConfigChanges(mk(nil), mk(&config.AuditLogConfig{Enabled: &trueVal, Stdout: &trueVal}))
+ require.True(t, result.Success)
+ assert.Contains(t, result.ChangedFields, "audit_log")
+ assert.True(t, result.RequiresRestart)
+ assert.False(t, result.AppliedImmediately)
+ assert.Equal(t, "audit_log is bound at sink construction", result.RestartReason)
+ })
+
+ t.Run("unchanged block not reported", func(t *testing.T) {
+ a := &config.AuditLogConfig{Enabled: &trueVal, Stdout: &trueVal}
+ b := &config.AuditLogConfig{Enabled: &trueVal, Stdout: &trueVal}
+ assert.NotContains(t, DetectConfigChanges(mk(a), mk(b)).ChangedFields, "audit_log")
+ })
+
+ t.Run("nil to nil not reported", func(t *testing.T) {
+ assert.NotContains(t, DetectConfigChanges(mk(nil), mk(nil)).ChangedFields, "audit_log")
+ })
+}
diff --git a/internal/runtime/restart_gated.go b/internal/runtime/restart_gated.go
index f6d4a2274..c218984a4 100644
--- a/internal/runtime/restart_gated.go
+++ b/internal/runtime/restart_gated.go
@@ -45,6 +45,15 @@ func pinRestartGated(live, desired *config.Config) *config.Config {
// the detector, which is exactly why it is easy to miss here — and missing
// it would let the API report a pool size that is not in effect.
pinned.CodeExecutionPoolSize = live.CodeExecutionPoolSize
+ // audit_log's sink is bound once at construction (config_hotreload.go's
+ // detector clause, :470-482) and never rebuilt in-process — restart-
+ // pinned exactly like the HTTP/listener fields above (round-3
+ // cross-review finding, PR-D: this clause was missing entirely, so a
+ // mixed apply that also touched a hot field adopted the new audit_log
+ // into the live config while the API still reported the apply as
+ // pending a restart, leaving Runtime.Config() readers disagreeing with
+ // the sink actually still writing).
+ pinned.AuditLog = live.AuditLog
// server_edition's restart-pinned subset (enabled, oauth.*, public_url,
// session_cookie_secure, session_ttl, bearer_token_ttl,
// credential_encryption_key — Spec 107 FR-039 part 2) is bound at login
diff --git a/internal/runtime/restart_gated_test.go b/internal/runtime/restart_gated_test.go
index db18f0719..13be66f95 100644
--- a/internal/runtime/restart_gated_test.go
+++ b/internal/runtime/restart_gated_test.go
@@ -40,6 +40,14 @@ func TestPinRestartGatedCoversEveryRestartGatedField(t *testing.T) {
// The one restart-gated clause that does not early-return in the
// detector, and so the one most easily missed by the pinner.
"code_execution_pool_size": func(c *config.Config) { c.CodeExecutionPoolSize = 99 },
+ // Spec 107 (round-3 cross-review finding, PR-D): audit_log is
+ // restart-pinned (the sink is bound at construction,
+ // config_hotreload.go:470-482) but pinRestartGated never reverted
+ // it — a mixed apply that also touched a hot field would adopt the
+ // new audit_log into the live config while the API still reported
+ // the apply as pending a restart, leaving Runtime.Config() readers
+ // disagreeing with the sink actually in effect.
+ "audit_log": func(c *config.Config) { c.AuditLog = &config.AuditLogConfig{Path: "/tmp/two.jsonl"} },
}
for name, mutate := range mutations {
diff --git a/internal/server/activity_funnel_ctx_first_test.go b/internal/server/activity_funnel_ctx_first_test.go
new file mode 100644
index 000000000..478760c32
--- /dev/null
+++ b/internal/server/activity_funnel_ctx_first_test.go
@@ -0,0 +1,151 @@
+package server
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// Spec 107 PR-D (T100).
+//
+// The audit line (contracts/audit-line.schema.json) needs the caller's
+// request context to reach the two activity funnels that will carry audit
+// emission: emitActivityPolicyDecision (every policy block/warning) and
+// emitActivityToolCallCompleted (every dispatched call). Today both take a
+// bare (serverName, toolName, ...) string tuple with no context.Context at
+// all, so there is nowhere for the audit sink to hang request-scoped values
+// (deadline, cancellation, future trace/correlation propagation) off of.
+//
+// This test is the behaviour-red guard for that change: it parses this
+// package's own non-test source (not the string-matching approach — a
+// gofmt-only reflow must not flip it) and asserts, for both funnels:
+//
+// 1. the func declaration's first parameter is named "ctx" with type
+// context.Context, and
+// 2. every call site's first argument is the identifier "ctx" — i.e. the
+// request context already in scope at the call site, never a fresh
+// context.Background()/TODO() or (worse) one of the string args shifted
+// into position 0.
+//
+// It must fail red today: neither funnel has a context.Context parameter at
+// all, so requirement (1) fails for both, and every existing call site's
+// first argument is a string (serverName/logServer), so requirement (2)
+// fails for every call site too. No production code is touched by this task.
+var ctxFirstFuncs = map[string]bool{
+ "emitActivityPolicyDecision": true,
+ "emitActivityToolCallCompleted": true,
+}
+
+// ctxFirstOffense records one place (a func decl or a call site) that does
+// not yet pass context.Context as the first parameter/argument.
+type ctxFirstOffense struct {
+ pos string
+ detail string
+}
+
+func TestActivityFunnelsTakeCtxFirst(t *testing.T) {
+ entries, err := os.ReadDir(".")
+ require.NoError(t, err)
+
+ fset := token.NewFileSet()
+ var offenses []ctxFirstOffense
+ foundDecl := map[string]bool{}
+
+ for _, entry := range entries {
+ name := entry.Name()
+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
+ continue
+ }
+
+ file, err := parser.ParseFile(fset, filepath.Join(".", name), nil, 0)
+ require.NoErrorf(t, err, "parsing %s", name)
+
+ ast.Inspect(file, func(node ast.Node) bool {
+ switch n := node.(type) {
+ case *ast.FuncDecl:
+ if !ctxFirstFuncs[n.Name.Name] {
+ return true
+ }
+ foundDecl[n.Name.Name] = true
+ if !firstParamIsCtx(n.Type) {
+ offenses = append(offenses, ctxFirstOffense{
+ pos: fset.Position(n.Pos()).String(),
+ detail: "func " + n.Name.Name + " does not take context.Context as its first parameter",
+ })
+ }
+ case *ast.CallExpr:
+ sel, ok := n.Fun.(*ast.SelectorExpr)
+ if !ok || !ctxFirstFuncs[sel.Sel.Name] {
+ return true
+ }
+ if len(n.Args) == 0 {
+ offenses = append(offenses, ctxFirstOffense{
+ pos: fset.Position(n.Pos()).String(),
+ detail: "call to " + sel.Sel.Name + " has no arguments",
+ })
+ return true
+ }
+ id, ok := n.Args[0].(*ast.Ident)
+ if !ok || id.Name != "ctx" {
+ got := ""
+ if ok {
+ got = id.Name
+ } else if bl, ok := n.Args[0].(*ast.BasicLit); ok {
+ got = bl.Value
+ }
+ offenses = append(offenses, ctxFirstOffense{
+ pos: fset.Position(n.Pos()).String(),
+ detail: "call to " + sel.Sel.Name + " passes " + got + " as its first argument, not the request ctx",
+ })
+ }
+ }
+ return true
+ })
+ }
+
+ for name := range ctxFirstFuncs {
+ require.Truef(t, foundDecl[name], "did not find a func decl for %s in internal/server — has it moved or been renamed?", name)
+ }
+
+ var msg strings.Builder
+ for _, o := range offenses {
+ msg.WriteString(o.pos)
+ msg.WriteString(": ")
+ msg.WriteString(o.detail)
+ msg.WriteString("\n")
+ }
+
+ require.Emptyf(t, offenses,
+ "emitActivityPolicyDecision and emitActivityToolCallCompleted must take "+
+ "context.Context as their first parameter, and every call site must "+
+ "pass the request ctx already in scope (Spec 107 PR-D, audit line "+
+ "needs request-scoped context to reach the sink):\n%s", msg.String())
+}
+
+// firstParamIsCtx reports whether ft's first parameter field is named "ctx"
+// and typed context.Context (a *ast.SelectorExpr "context.Context" — this
+// package always qualifies the import, never dot-imports it).
+func firstParamIsCtx(ft *ast.FuncType) bool {
+ if ft.Params == nil || len(ft.Params.List) == 0 {
+ return false
+ }
+ first := ft.Params.List[0]
+ if len(first.Names) == 0 || first.Names[0].Name != "ctx" {
+ return false
+ }
+ sel, ok := first.Type.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ pkgIdent, ok := sel.X.(*ast.Ident)
+ if !ok {
+ return false
+ }
+ return pkgIdent.Name == "context" && sel.Sel.Name == "Context"
+}
diff --git a/internal/server/activity_result_status_test.go b/internal/server/activity_result_status_test.go
index 5614070e4..9758e935e 100644
--- a/internal/server/activity_result_status_test.go
+++ b/internal/server/activity_result_status_test.go
@@ -131,8 +131,10 @@ func TestActivityStatusForResult_TruncatesLongErrors(t *testing.T) {
// mirrors an upstream dispatch (call_tool_*) is covered by the end-to-end test
// TestE2E_UpstreamIsErrorRecordedAsActivityError instead.
var statusArgIndex = map[string]int{
- // (serverName, toolName, sessionID, requestID, source, status, ...)
- "emitActivityToolCallCompleted": 5,
+ // (ctx, serverName, toolName, sessionID, requestID, source, status, ...)
+ // — ctx became the first parameter in Spec 107 PR-D (FR-012), moving
+ // status from index 5 to 6.
+ "emitActivityToolCallCompleted": 6,
}
func TestActivityCompletionNeverHardcodesSuccess(t *testing.T) {
diff --git a/internal/server/audit_caller_test.go b/internal/server/audit_caller_test.go
new file mode 100644
index 000000000..fe4ad52ba
--- /dev/null
+++ b/internal/server/audit_caller_test.go
@@ -0,0 +1,205 @@
+package server
+
+// audit_caller_test.go: T101 (Spec 107 PR-D). Pins the caller.kind/origin
+// derivation table of contracts/audit-line-events.md "caller.kind
+// derivation (FR-013)" — one case per caller kind the dispatch doors admit
+// (authz/tool_call never see caller.kind:session_user; that kind is
+// auth_event-only, FR-002/FR-003).
+//
+// auditCallerFromContext(ctx) is added by T103 (internal/server); this file
+// is compile-red until T099 (internal/audit.Caller — already implemented)
+// AND T103 land. transport.ConnectionSourceStdio is also new in T103.
+//
+// No production code lives here.
+
+import (
+ "context"
+ "testing"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
+)
+
+// TestAuditCallerFromContext_DerivationTable exercises every caller.kind the
+// schema's identity allOf blocks recognise on a dispatch door (authz /
+// tool_call), per contracts/audit-line-events.md.
+func TestAuditCallerFromContext_DerivationTable(t *testing.T) {
+ tests := []struct {
+ name string
+ ctx func() context.Context
+ want audit.Caller
+ }{
+ {
+ // AuthContext.Type==admin, CredentialKind==api_key, connection
+ // source defaults to tcp (no tag) -> caller.kind: api_key.
+ name: "api_key",
+ ctx: func() context.Context {
+ ac := auth.AdminContext()
+ ac.CredentialKind = auth.CredentialKindAPIKey
+ return auth.WithAuthContext(context.Background(), ac)
+ },
+ want: audit.Caller{Kind: "api_key"},
+ },
+ {
+ // Type==admin, source==tray -> caller.kind: socket. The tray
+ // Unix-socket/named-pipe door bypasses the API key (OS-level
+ // auth) but is still admin type with CredentialKindSocket.
+ name: "socket_tray",
+ ctx: func() context.Context {
+ ac := auth.AdminContext()
+ ac.CredentialKind = auth.CredentialKindSocket
+ ctx := auth.WithAuthContext(context.Background(), ac)
+ return transport.TagConnectionContext(ctx, transport.ConnectionSourceTray)
+ },
+ want: audit.Caller{Kind: "socket"},
+ },
+ {
+ // Type==admin, source==stdio -> caller.kind: stdio. The native
+ // stdio transport installs auth.AdminContext() with no listener
+ // (server.go:1039); without the ConnectionSourceStdio tag,
+ // GetConnectionSource defaults to tcp and the line would
+ // misreport api_key — this is the case T103's stdioAuthContext
+ // tag exists to fix.
+ name: "stdio",
+ ctx: func() context.Context {
+ ac := auth.AdminContext()
+ ctx := auth.WithAuthContext(context.Background(), ac)
+ return transport.TagConnectionContext(ctx, transport.ConnectionSourceStdio)
+ },
+ want: audit.Caller{Kind: "stdio"},
+ },
+ {
+ // Type==admin && Anonymous -> caller.kind: anonymous. No
+ // identity fields survive: the anonymous bit exists precisely
+ // because the admin type here is back-compat, not proof of
+ // identity (issue #1148).
+ name: "anonymous",
+ ctx: func() context.Context {
+ return auth.WithAuthContext(context.Background(), auth.AnonymousContext())
+ },
+ want: audit.Caller{Kind: "anonymous"},
+ },
+ {
+ // Type==agent, owned (UserID present from the single owner
+ // resolution, agent_token.go:133-152) -> all-or-none owner
+ // identity: user_id, user_email, role, provider all present
+ // alongside token_name/token_prefix.
+ name: "agent_token_owned",
+ ctx: func() context.Context {
+ tok := &auth.AgentToken{
+ Name: "ci-bot",
+ TokenPrefix: "mcp_agt_abcd",
+ UserID: "usr_123",
+ OwnerEmail: "owner@example.com",
+ OwnerProvider: "google",
+ OwnerRole: "user",
+ }
+ return auth.WithAuthContext(context.Background(), tok.AuthContext())
+ },
+ want: audit.Caller{
+ Kind: "agent_token",
+ TokenName: "ci-bot",
+ TokenPrefix: "mcp_agt_abcd",
+ UserID: "usr_123",
+ UserEmail: "owner@example.com",
+ Role: "user",
+ Provider: "google",
+ },
+ },
+ {
+ // Type==agent, ownerless (personal edition / no owner
+ // resolution): UserID empty, so user_email/role/provider must
+ // ALSO be empty (all-or-none) even though token_name/prefix are
+ // always required for agent_token.
+ name: "agent_token_ownerless",
+ ctx: func() context.Context {
+ tok := &auth.AgentToken{
+ Name: "local-agent",
+ TokenPrefix: "mcp_agt_efgh",
+ }
+ return auth.WithAuthContext(context.Background(), tok.AuthContext())
+ },
+ want: audit.Caller{
+ Kind: "agent_token",
+ TokenName: "local-agent",
+ TokenPrefix: "mcp_agt_efgh",
+ },
+ },
+ {
+ // Type==admin_user, CredentialKind==cookie -> caller.kind:
+ // session_admin, role: admin, user_id present.
+ name: "session_admin_cookie",
+ ctx: func() context.Context {
+ ac := auth.AdminUserContext("usr_admin", "admin@example.com", "Admin", "google")
+ ac.CredentialKind = auth.CredentialKindCookie
+ return auth.WithAuthContext(context.Background(), ac)
+ },
+ want: audit.Caller{
+ Kind: "session_admin",
+ UserID: "usr_admin",
+ UserEmail: "admin@example.com",
+ Role: "admin",
+ Provider: "google",
+ },
+ },
+ {
+ // Type==admin_user, CredentialKind==bearer_jwt -> same
+ // caller.kind: session_admin (the schema's derivation keys off
+ // CredentialKind ∈ {cookie, bearer_jwt}, not the credential
+ // used for THIS particular request being the cookie alone).
+ name: "session_admin_bearer_jwt",
+ ctx: func() context.Context {
+ ac := auth.AdminUserContext("usr_admin2", "admin2@example.com", "Admin Two", "github")
+ ac.CredentialKind = auth.CredentialKindBearerJWT
+ return auth.WithAuthContext(context.Background(), ac)
+ },
+ want: audit.Caller{
+ Kind: "session_admin",
+ UserID: "usr_admin2",
+ UserEmail: "admin2@example.com",
+ Role: "admin",
+ Provider: "github",
+ },
+ },
+ {
+ // proxy-originated (nested code_execution sub-call dispatch,
+ // reqcontext.SourceInternal) -> caller.kind: internal,
+ // regardless of any AuthContext also present on the ctx (the
+ // sandbox copies the caller's AuthContext for policy checks,
+ // mcp_code_execution.go:249-256, but the audit line for the
+ // sub-call itself must not attribute it to that caller).
+ name: "internal",
+ ctx: func() context.Context {
+ ac := auth.AdminContext()
+ ctx := auth.WithAuthContext(context.Background(), ac)
+ return reqcontext.WithRequestSource(ctx, reqcontext.SourceInternal)
+ },
+ want: audit.Caller{Kind: "internal"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := auditCallerFromContext(tt.ctx())
+ if got != tt.want {
+ t.Fatalf("auditCallerFromContext() = %+v, want %+v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestAuditCallerFromContext_NilAuthContext covers a ctx with no AuthContext
+// installed at all (a test double, an in-process caller that never went
+// through auth middleware) — it must not panic and must not fabricate an
+// identity; the least-privileged reading is caller.kind: anonymous, mirroring
+// AuthContext.CanRevealSecrets' own "nil is unprivileged, not admin-by-
+// absence" rule (auth/context.go).
+func TestAuditCallerFromContext_NilAuthContext(t *testing.T) {
+ got := auditCallerFromContext(context.Background())
+ want := audit.Caller{Kind: "anonymous"}
+ if got != want {
+ t.Fatalf("auditCallerFromContext(no AuthContext) = %+v, want %+v", got, want)
+ }
+}
diff --git a/internal/server/audit_funnel.go b/internal/server/audit_funnel.go
new file mode 100644
index 000000000..1e3bc5708
--- /dev/null
+++ b/internal/server/audit_funnel.go
@@ -0,0 +1,550 @@
+package server
+
+// audit_funnel.go — Spec 107 PR-D (T103/T104): the audit line at the two
+// activity funnels.
+//
+// Every dispatch path installs an immutable audit.Attempt on the request
+// context before its first authorization gate (installAuditAttempt). The
+// two activity funnels — emitActivityPolicyDecision and
+// emitActivityToolCallCompleted — plus emitActivityToolCallStarted read it
+// back and write the `authz` / `tool_call` lines synchronously through the
+// server's audit.Sink (research.md D6: never the lossy event bus, never a
+// zap core). The attempt is paired with a small mutable companion
+// (auditDispatch) that pins the count invariants of SC-003 structurally:
+// exactly one `authz` line per attempt (the first decision wins; a
+// post-allow refusal such as ErrConnectionGenerationChanged is a tool_call
+// error, never a second authz) and exactly one `tool_call` line per
+// `authz allow`.
+//
+// A nil sink is the personal-edition default: installAuditAttempt returns
+// the context unchanged and every funnel is a no-op, so no hashing, no
+// allocation and no lock are paid on the hot path (SC-009).
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+// Audit surfaces (schema `surface` enum for authz/tool_call).
+const (
+ auditSurfaceDirect = "direct"
+ auditSurfaceCodeExecution = "code_execution"
+ auditSurfaceREST = "rest"
+)
+
+// auditDispatch is the per-attempt mutable companion of the immutable
+// audit.Attempt: it remembers which lines were already written so the
+// funnels can enforce "one authz, one tool_call" without the call sites
+// having to know about each other, and carries the typed error a dispatch
+// path noted for error_class derivation (the completion funnel only sees
+// the prose message, FR-015).
+type auditDispatch struct {
+ attempt audit.Attempt
+ caller audit.Caller
+
+ mu sync.Mutex
+ authzWritten bool
+ toolCallWritten bool
+ errClass audit.ErrorClass
+}
+
+type auditDispatchKeyType struct{}
+
+var auditDispatchKey auditDispatchKeyType
+
+func auditDispatchFromContext(ctx context.Context) *auditDispatch {
+ if ctx == nil {
+ return nil
+ }
+ d, _ := ctx.Value(auditDispatchKey).(*auditDispatch)
+ return d
+}
+
+// auditAttemptSpec is what a dispatch path knows about the call before its
+// first gate. Args are hashed here (RFC 8785 over StripInternalArgs(args))
+// and never stored.
+type auditAttemptSpec struct {
+ RequestID string
+ ParentID string
+ SessionID string
+ Server string
+ Tool string
+ Operation string // read|write|destructive|unknown
+ Surface string // call_tool_*|direct|code_execution|rest
+ ClientName string
+ ClientVersion string
+ Profile string
+ Args map[string]interface{}
+ // Caller overrides the context-derived caller. The code_execution
+ // wrapper captures the script's caller once and hands it to every
+ // nested attempt, whose own context is tagged SourceInternal (Spec 093
+ // FR-012) and would otherwise derive caller.kind: internal.
+ Caller *audit.Caller
+}
+
+// installAuditAttempt builds the attempt record for one dispatch and
+// installs it on ctx. It is the ONLY constructor of an audit.Attempt in this
+// package. With no sink configured it returns ctx unchanged.
+func (p *MCPProxyServer) installAuditAttempt(ctx context.Context, spec auditAttemptSpec) context.Context {
+ if p == nil || p.auditSink == nil {
+ return ctx
+ }
+ hash, argsBytes, err := audit.HashArgs(spec.Args)
+ if err != nil {
+ // A map that cannot be canonicalised (a non-JSON value smuggled in
+ // through an in-process caller) still gets a line: the hash of the
+ // empty object is unambiguous and the activity record keeps the
+ // arguments. Logged once per occurrence at DEBUG — never the args.
+ p.logger.Debug("audit: arguments not canonicalisable, hashing empty object", zap.Error(err))
+ hash, argsBytes, _ = audit.HashArgs(nil)
+ }
+ server, tool := spec.Server, spec.Tool
+ if server == "" {
+ server = "unknown"
+ }
+ if tool == "" {
+ tool = "unknown"
+ }
+ op := spec.Operation
+ switch op {
+ case contracts.OperationTypeRead, contracts.OperationTypeWrite, contracts.OperationTypeDestructive:
+ default:
+ op = "unknown"
+ }
+
+ var caller audit.Caller
+ if spec.Caller != nil {
+ caller = *spec.Caller
+ } else {
+ caller = auditCallerFromContext(ctx)
+ }
+
+ var clientIP string
+ if meta, ok := reqcontext.GetRequestMeta(ctx); ok {
+ clientIP = meta.ClientIP
+ }
+
+ // work_session_id (round-1 cross-review finding, PR-D): the attempt is
+ // the only place this is stamped, never re-resolved by a gate or the
+ // completion path. p.sessionStore is nil-safe for an unresolved or
+ // unknown session id (returns "").
+ var workSessionID string
+ if p != nil && p.sessionStore != nil {
+ workSessionID = p.sessionStore.WorkSessionID(spec.SessionID)
+ }
+
+ d := &auditDispatch{
+ attempt: audit.Attempt{
+ RequestID: spec.RequestID,
+ TransportRequestID: auditTransportRequestID(ctx),
+ ParentID: spec.ParentID,
+ SessionID: spec.SessionID,
+ WorkSessionID: workSessionID,
+ Server: server,
+ Tool: tool,
+ Operation: op,
+ Surface: spec.Surface,
+ Source: auditSourceFromContext(ctx),
+ Origin: auditOriginFromContext(ctx),
+ ClientName: spec.ClientName,
+ ClientVersion: spec.ClientVersion,
+ ClientIP: clientIP,
+ Profile: spec.Profile,
+ ProfilePin: caller.ProfilePin,
+ ArgsSHA256: hash,
+ ArgsBytes: argsBytes,
+ StartedAt: time.Now(),
+ },
+ caller: caller,
+ }
+ return context.WithValue(ctx, auditDispatchKey, d)
+}
+
+// auditTransportRequestID is the X-Request-Id the REST middleware installed
+// (REST only; the /mcp handlers carry none — server.go's MCP chain has no
+// RequestIDMiddleware).
+func auditTransportRequestID(ctx context.Context) string {
+ if meta, ok := reqcontext.GetRequestMeta(ctx); ok && meta.Mount == reqcontext.MountAPI {
+ return reqcontext.GetRequestID(ctx)
+ }
+ return ""
+}
+
+// auditSourceFromContext derives the line's `source` from the mount point
+// (reqcontext.RequestMeta, T050) — never from reqcontext.GetRequestSource,
+// which the REST layer rewrites from the caller-controlled X-MCPProxy-Client
+// header. The one exception is SourceInternal: it is set by the proxy itself
+// (the code_execution bridge, Spec 093 FR-012), not by a header.
+func auditSourceFromContext(ctx context.Context) string {
+ if reqcontext.GetRequestSource(ctx) == reqcontext.SourceInternal {
+ return "internal"
+ }
+ if meta, ok := reqcontext.GetRequestMeta(ctx); ok && meta.Mount == reqcontext.MountAPI {
+ return "api"
+ }
+ // /mcp* mounts and the native stdio transport (no HTTP mount at all)
+ // are both the MCP surface.
+ return "mcp"
+}
+
+// auditOriginFromContext maps the listener-derived connection source onto
+// the schema's `origin`: tcp→local, tray→socket, stdio→local. `remote` is
+// reserved (Spec 089 FR-010) and never emitted here.
+func auditOriginFromContext(ctx context.Context) string {
+ if transport.GetConnectionSource(ctx) == transport.ConnectionSourceTray {
+ return "socket"
+ }
+ return "local"
+}
+
+// auditCallerFromContext derives the audit line's caller from the
+// AuthContext, its CredentialKind and Anonymous bit, and the connection
+// source (contracts/audit-line-events.md "caller.kind derivation").
+//
+// A proxy-originated request (reqcontext.SourceInternal) is `internal`
+// regardless of any AuthContext also present: the sandbox copies the
+// caller's context for policy checks, but a sub-call's own line must not
+// attribute the proxy's dispatch to that caller (the code_execution
+// wrapper passes the script's caller explicitly instead). A context with
+// no AuthContext at all is `anonymous` — nil is unprivileged, never
+// admin-by-absence (auth.AuthContext.CanRevealSecrets' rule).
+func auditCallerFromContext(ctx context.Context) audit.Caller {
+ if reqcontext.GetRequestSource(ctx) == reqcontext.SourceInternal {
+ return audit.Caller{Kind: "internal"}
+ }
+ ac := auth.AuthContextFromContext(ctx)
+ if ac == nil {
+ return audit.Caller{Kind: "anonymous"}
+ }
+ switch ac.Type {
+ case auth.AuthTypeAgent:
+ c := audit.Caller{
+ Kind: "agent_token",
+ TokenName: ac.AgentName,
+ TokenPrefix: ac.TokenPrefix,
+ ProfilePin: ac.ProfilePin,
+ }
+ // Owner identity is all-or-none (schema: user_id ⇒ user_email, role,
+ // provider). An ownerless token carries none of the four.
+ if ac.UserID != "" {
+ c.UserID = ac.UserID
+ c.UserEmail = ac.Email
+ c.Role = ac.Role
+ c.Provider = ac.Provider
+ }
+ return c
+ case auth.AuthTypeAdminUser:
+ return audit.Caller{
+ Kind: "session_admin",
+ UserID: ac.UserID,
+ UserEmail: ac.Email,
+ Role: "admin",
+ Provider: ac.Provider,
+ }
+ case auth.AuthTypeUser:
+ // No dispatch door admits a session_user (FR-002/FR-003); the kind
+ // exists for auth_event lines only. Should one ever reach a funnel it
+ // is recorded as what it is rather than mislabelled.
+ return audit.Caller{
+ Kind: "session_user",
+ UserID: ac.UserID,
+ UserEmail: ac.Email,
+ Role: "user",
+ Provider: ac.Provider,
+ }
+ }
+ // AuthTypeAdmin: the impersonal kinds, told apart by the anonymous bit
+ // and the connection source.
+ if ac.Anonymous {
+ return audit.Caller{Kind: "anonymous"}
+ }
+ switch transport.GetConnectionSource(ctx) {
+ case transport.ConnectionSourceTray:
+ return audit.Caller{Kind: "socket"}
+ case transport.ConnectionSourceStdio:
+ return audit.Caller{Kind: "stdio"}
+ }
+ return audit.Caller{Kind: "api_key"}
+}
+
+// auditReasonFromBlockKey maps the closed telemetry.BlockReason* enum a gate
+// declared onto the authz `reason` vocabulary (identical members minus the
+// two post-dispatch keys, which are tool_call reasons) and reports whether
+// the refusal was non-disclosing to the caller (Spec 105 FR-010 shapes:
+// scope refusals reveal nothing about whether the server exists).
+func auditReasonFromBlockKey(reasonKey string) (reason string, disclosed bool) {
+ switch reasonKey {
+ case telemetry.BlockReasonTokenScope, telemetry.BlockReasonProfileScope:
+ return reasonKey, false
+ case telemetry.BlockReasonIntentInvalid, telemetry.BlockReasonIntentRejected,
+ telemetry.BlockReasonTokenPermission, telemetry.BlockReasonServerQuarantined,
+ telemetry.BlockReasonToolPendingApproval, telemetry.BlockReasonToolChanged,
+ telemetry.BlockReasonToolNotCallable:
+ return reasonKey, true
+ default:
+ return telemetry.BlockReasonOther, true
+ }
+}
+
+// isPostDispatchBlockKey reports whether a "blocked" policy decision is one
+// of the two post-dispatch output blocks, which are `tool_call
+// outcome:blocked` lines and never an authz line (FR-043(j)).
+func isPostDispatchBlockKey(reasonKey string) bool {
+ return reasonKey == telemetry.BlockReasonOutputSanitisation || reasonKey == telemetry.BlockReasonOutputSchema
+}
+
+// auditWrite serialises and writes one line; write failures are the sink's
+// business (counter + rate-limited log) and never surface to the caller.
+func (p *MCPProxyServer) auditWrite(line audit.Line, err error) {
+ if err != nil {
+ p.logger.Warn("audit: line rejected by builder", zap.Error(err))
+ return
+ }
+ raw, err := line.JSON()
+ if err != nil {
+ p.logger.Warn("audit: line not serialisable", zap.Error(err))
+ return
+ }
+ _ = p.auditSink.Write(raw)
+}
+
+// auditAuthz writes the attempt's ONE authz line. decision is allow|deny;
+// reasonKey is the gate's telemetry.BlockReason* (ignored for allow). A
+// second call for the same attempt is a no-op.
+func (p *MCPProxyServer) auditAuthz(ctx context.Context, decision, reasonKey string) {
+ d := auditDispatchFromContext(ctx)
+ if d == nil || p.auditSink == nil {
+ return
+ }
+ d.mu.Lock()
+ if d.authzWritten {
+ d.mu.Unlock()
+ return
+ }
+ d.authzWritten = true
+ d.mu.Unlock()
+
+ in := audit.AuthzInput{
+ Ts: time.Now(),
+ Attempt: d.attempt,
+ Caller: d.caller,
+ Decision: decision,
+ Reason: "none",
+ }
+ if decision == "deny" {
+ reason, disclosed := auditReasonFromBlockKey(reasonKey)
+ in.Reason = reason
+ in.Disclosed = &disclosed
+ }
+ p.auditWrite(audit.NewAuthz(in))
+}
+
+// auditToolCall writes the attempt's ONE tool_call line, writing the paired
+// `authz allow` first if no decision was recorded yet (every completion is
+// preceded by a Started emission on the dispatch paths, so this is a
+// defensive pairing, not the normal route). A second call for the same
+// attempt is a no-op.
+func (p *MCPProxyServer) auditToolCall(ctx context.Context, outcome, reason string, errClass audit.ErrorClass, durationMs int64, requestBytes, responseBytes *int) {
+ d := auditDispatchFromContext(ctx)
+ if d == nil || p.auditSink == nil {
+ return
+ }
+ p.auditAuthz(ctx, "allow", "")
+
+ d.mu.Lock()
+ if d.toolCallWritten {
+ d.mu.Unlock()
+ return
+ }
+ d.toolCallWritten = true
+ noted := d.errClass
+ d.mu.Unlock()
+
+ if durationMs < 0 {
+ durationMs = 0
+ }
+ in := audit.ToolCallInput{
+ Ts: time.Now(),
+ Attempt: d.attempt,
+ Caller: d.caller,
+ Outcome: outcome,
+ Reason: reason,
+ DurationMs: int(durationMs),
+ RequestBytes: requestBytes,
+ ResponseBytes: responseBytes,
+ }
+ if outcome == "error" {
+ switch {
+ case errClass != "":
+ in.ErrorClass = string(errClass)
+ case noted != "":
+ in.ErrorClass = string(noted)
+ default:
+ // The upstream answered (a well-formed isError result or a
+ // transport error the path did not classify): an upstream error.
+ in.ErrorClass = string(audit.ErrorClassUpstreamError)
+ }
+ }
+ p.auditWrite(audit.NewToolCall(in))
+}
+
+// auditNoteError records the typed error a dispatch path is about to report
+// through emitActivityToolCallCompleted, so the completion funnel — which
+// receives only the prose message — can derive the bounded error_class
+// through audit.ErrorClassOf. nil-safe; a no-op without an attempt.
+func auditNoteError(ctx context.Context, err error) {
+ auditNoteErrorClass(ctx, audit.ErrorClassOf(err))
+}
+
+// auditNoteErrorClass is auditNoteError for paths that know the class
+// without holding a typed error (a server the proxy has no client for is
+// upstream_unavailable; a rejected argument set is validation).
+func auditNoteErrorClass(ctx context.Context, class audit.ErrorClass) {
+ d := auditDispatchFromContext(ctx)
+ if d == nil {
+ return
+ }
+ d.mu.Lock()
+ d.errClass = class
+ d.mu.Unlock()
+}
+
+// auditSetOperation corrects the attempt's `operation` once the target
+// tool's actual annotation-derived tier is known (round-2 cross-review
+// finding, PR-D): installAuditAttempt runs before the identity gate that
+// resolves the tool's annotations, so it can only stamp the CALLER-chosen
+// door (contracts.ToolVariantToOperationType[toolVariant]) — for a scoped
+// caller that is explicitly NOT what gets authorized (mcp.go:
+// "the variant is the CALLER's choice ... Authorize against the TARGET
+// tool's annotation-derived tier"), so a call_tool_read against a write
+// tool would otherwise record operation:"read" on both the authz and
+// tool_call lines despite being authorized, and refused, against write. A
+// no-op without an attempt (nil sink / already-dispatched line: this is
+// always called before the first line of an attempt is written).
+func auditSetOperation(ctx context.Context, op string) {
+ d := auditDispatchFromContext(ctx)
+ if d == nil || op == "" {
+ return
+ }
+ d.mu.Lock()
+ d.attempt.Operation = op
+ d.mu.Unlock()
+}
+
+// auditToolCallShed writes the tool_call line for a concurrency-limiter shed
+// (`outcome:rejected`, reason limiter_queue_full|limiter_queue_timeout). The
+// shed happens inside the managed client after every gate (research.md D6),
+// so it is the tool_call half of the pair, never a second authz. The
+// dispatch paths do not emit a completion record for a shed (the limiter's
+// own seam wrote the activity row), so this is called explicitly there.
+func (p *MCPProxyServer) auditToolCallShed(ctx context.Context, limitErr *limiter.LimitError, durationMs int64) {
+ if limitErr == nil {
+ return
+ }
+ reason := "limiter_queue_full"
+ if limitErr.Reason == limiter.ReasonQueueTimeout {
+ reason = "limiter_queue_timeout"
+ }
+ p.auditToolCall(ctx, "rejected", reason, "", durationMs, nil, nil)
+}
+
+// auditDurationMs is the elapsed time since the attempt was installed —
+// used by the post-dispatch block path, which has no dispatch duration of
+// its own in hand at the funnel.
+func auditDurationMs(ctx context.Context) int64 {
+ d := auditDispatchFromContext(ctx)
+ if d == nil || d.attempt.StartedAt.IsZero() {
+ return 0
+ }
+ return time.Since(d.attempt.StartedAt).Milliseconds()
+}
+
+// ---------------------------------------------------------------------------
+// Nested (code_execution) refusals — T104
+// ---------------------------------------------------------------------------
+
+// nestedAuthzObserver is the jsruntime.AuthzObserver the code_execution
+// wrapper installs: every scope/permission refusal jsruntime's
+// checkDispatchGates decides — which never reaches the bridge, so no
+// completion emitter ever sees it — becomes one `authz deny` line carrying
+// the script's caller, surface code_execution and the wrapper's request id
+// as parent_id. It installs a fresh attempt per report, so the dedup the
+// funnels enforce per attempt applies per refusal.
+type nestedAuthzObserver struct {
+ proxy *MCPProxyServer
+ parentCtx context.Context
+ caller audit.Caller
+ sessionID string
+ clientName string
+ clientVersion string
+ profile string
+}
+
+func (o *nestedAuthzObserver) ObserveAuthzGate(report jsruntime.AuthzGateReport) {
+ if o == nil || o.proxy == nil || o.proxy.auditSink == nil || !report.Denied {
+ return
+ }
+ reasonKey := telemetry.BlockReasonTokenScope
+ switch report.Code {
+ case jsruntime.ErrorCodeServerNotAllowed:
+ // The sandbox's allow-list (ec.allowedServerMap) is the INTERSECTION
+ // of the script's own `options.allowed_servers` and the active
+ // profile (applyProfileScopeToExecution) — a single merged set the
+ // gate answers from, so a refusal here cannot tell which side of the
+ // intersection excluded the server (round-3 cross-review finding,
+ // PR-D: attributing every such refusal to `profile_scope` whenever
+ // any profile is active mislabels a script-authored exclusion the
+ // profile never narrowed). Per the published contract
+ // (audit-line-events.md: nested `checkDispatchGates` -> token_scope
+ // | token_permission), this gate always reports `token_scope` — it
+ // is the caller's/script's allow-list either way, never
+ // distinguished from the profile in the schema's nested mapping.
+ case jsruntime.ErrorCodeAccessDenied:
+ reasonKey = telemetry.BlockReasonTokenScope
+ case jsruntime.ErrorCodePermissionDenied:
+ reasonKey = telemetry.BlockReasonTokenPermission
+ if report.RequiredPerm == jsruntime.PermissionTierUnresolved {
+ // An identity the lookup could not resolve: the refusal is about
+ // the tool, not the caller's grant (same bucket as
+ // handleCallToolVariant's unresolved-identity refusal).
+ reasonKey = telemetry.BlockReasonToolNotCallable
+ }
+ }
+ operation := report.RequiredPerm
+ if operation == jsruntime.PermissionTierUnresolved {
+ operation = ""
+ }
+ caller := o.caller
+ ctx := o.parentCtx
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ ctx = reqcontext.WithRequestSource(ctx, reqcontext.SourceInternal)
+ ctx = o.proxy.installAuditAttempt(ctx, auditAttemptSpec{
+ RequestID: mintCorrelationID(report.ServerName, report.ToolName),
+ ParentID: report.ParentID,
+ SessionID: o.sessionID,
+ Server: report.ServerName,
+ Tool: report.ToolName,
+ Operation: operation,
+ Surface: auditSurfaceCodeExecution,
+ ClientName: o.clientName,
+ ClientVersion: o.clientVersion,
+ Profile: o.profile,
+ Args: report.Arguments,
+ Caller: &caller,
+ })
+ o.proxy.auditAuthz(ctx, "deny", reasonKey)
+}
diff --git a/internal/server/audit_funnel_test.go b/internal/server/audit_funnel_test.go
new file mode 100644
index 000000000..b9be43629
--- /dev/null
+++ b/internal/server/audit_funnel_test.go
@@ -0,0 +1,553 @@
+package server
+
+// audit_funnel_test.go — Spec 107 PR-D (T103/T104): the audit line at the
+// dispatch funnels, driven through the real handlers against an in-process
+// counting upstream (scope_fixture_test.go). Every line is validated against
+// the binding contract schema.
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/profile"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
+)
+
+const auditContractSchemaPath = "../../specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json"
+
+// recordingAuditSink is an in-memory audit.Sink.
+type recordingAuditSink struct {
+ mu sync.Mutex
+ lines [][]byte
+}
+
+func (s *recordingAuditSink) Write(line []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ cp := make([]byte, len(line))
+ copy(cp, line)
+ s.lines = append(s.lines, cp)
+ return nil
+}
+
+func (s *recordingAuditSink) WriteFailures() uint64 { return 0 }
+func (s *recordingAuditSink) SanitizerHits() uint64 { return 0 }
+func (s *recordingAuditSink) Close() error { return nil }
+
+func (s *recordingAuditSink) snapshot() [][]byte {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([][]byte, len(s.lines))
+ copy(out, s.lines)
+ return out
+}
+
+// decoded returns every line as a map, in write order, after validating it
+// against the contract schema.
+func (s *recordingAuditSink) decoded(t *testing.T) []map[string]interface{} {
+ t.Helper()
+ sch := auditContractSchema(t)
+ var out []map[string]interface{}
+ for i, raw := range s.snapshot() {
+ var m map[string]interface{}
+ require.NoErrorf(t, json.Unmarshal(raw, &m), "line %d is not JSON: %s", i, raw)
+ var inst interface{}
+ require.NoError(t, json.Unmarshal(raw, &inst))
+ require.NoErrorf(t, sch.Validate(inst), "line %d violates the contract schema: %s", i, raw)
+ out = append(out, m)
+ }
+ return out
+}
+
+var (
+ auditSchemaOnce sync.Once
+ auditSchema *jsonschema.Schema
+ auditSchemaErr error
+)
+
+func auditContractSchema(t *testing.T) *jsonschema.Schema {
+ t.Helper()
+ auditSchemaOnce.Do(func() {
+ raw, err := os.ReadFile(auditContractSchemaPath)
+ if err != nil {
+ auditSchemaErr = err
+ return
+ }
+ doc, err := jsonschema.UnmarshalJSON(strings.NewReader(string(raw)))
+ if err != nil {
+ auditSchemaErr = err
+ return
+ }
+ c := jsonschema.NewCompiler()
+ if err := c.AddResource("mem://audit-line.schema.json", doc); err != nil {
+ auditSchemaErr = err
+ return
+ }
+ auditSchema, auditSchemaErr = c.Compile("mem://audit-line.schema.json")
+ })
+ require.NoError(t, auditSchemaErr)
+ return auditSchema
+}
+
+func auditCallToolRequest(name string, args map[string]interface{}) mcp.CallToolRequest {
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{"name": name, "args": args}
+ return req
+}
+
+func callerOf(t *testing.T, line map[string]interface{}) map[string]interface{} {
+ t.Helper()
+ c, ok := line["caller"].(map[string]interface{})
+ require.True(t, ok, "line has no caller object: %v", line)
+ return c
+}
+
+// A high-entropy sentinel that must be byte-absent from every line.
+const auditArgSentinel = "SENTINEL-q7Vt9xZp3LmN8kRw2Ye6Hs4Ju1Bc0Df5"
+
+func TestAuditFunnel_AllowedDispatchWritesAuthzAllowThenToolCall(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "a", readSpec("erase"))
+
+ args := map[string]interface{}{"q": auditArgSentinel, "n": float64(3)}
+ result, err := proxy.handleCallToolVariant(fullTierAgentOn("a"), auditCallToolRequest("a:erase", args), contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.False(t, result.IsError, "control: the call must dispatch")
+ require.Equal(t, int64(1), up.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2, "one authz + one tool_call per dispatched call")
+
+ authz, toolCall := lines[0], lines[1]
+ assert.Equal(t, "authz", authz["event"])
+ assert.Equal(t, "allow", authz["decision"])
+ assert.Equal(t, "none", authz["reason"])
+ assert.Nil(t, authz["disclosed"])
+ assert.Equal(t, "call_tool_read", authz["surface"])
+ assert.Equal(t, "a", authz["server"])
+ assert.Equal(t, "erase", authz["tool"])
+ assert.Equal(t, "read", authz["operation"])
+ assert.Equal(t, "mcp", authz["source"])
+ assert.Equal(t, "local", authz["origin"])
+ c := callerOf(t, authz)
+ assert.Equal(t, "agent_token", c["kind"])
+ assert.Equal(t, "mcp_agt_fix", c["token_prefix"])
+ assert.NotEmpty(t, c["token_name"])
+ assert.Nil(t, c["user_id"], "an ownerless token carries no owner identity")
+
+ assert.Equal(t, "tool_call", toolCall["event"])
+ assert.Equal(t, "success", toolCall["outcome"])
+ assert.Nil(t, toolCall["reason"])
+ assert.Nil(t, toolCall["error_class"])
+ assert.Equal(t, authz["request_id"], toolCall["request_id"], "the pair shares the activity request id")
+ assert.Equal(t, authz["args_sha256"], toolCall["args_sha256"])
+
+ wantHash, wantBytes, err := audit.HashArgs(args)
+ require.NoError(t, err)
+ assert.Equal(t, wantHash, authz["args_sha256"])
+ assert.EqualValues(t, wantBytes, authz["args_bytes"])
+
+ for _, raw := range sink.snapshot() {
+ assert.NotContains(t, string(raw), auditArgSentinel, "an argument value must never reach the line")
+ }
+}
+
+// TestAuditFunnel_OperationReflectsTargetTierNotCallerVariant is a round-2
+// cross-review regression (PR-D): the attempt is stamped with the caller's
+// chosen door (call_tool_read) before the target tool's actual
+// annotation-derived tier (write) is known. Both the authz and tool_call
+// lines must record the tool's real tier, not the variant the caller
+// happened to dial — an admin can call any variant against any tool, so
+// `call_tool_read` against a write-tiered tool must not misrepresent the
+// dispatch as read.
+func TestAuditFunnel_OperationReflectsTargetTierNotCallerVariant(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "a", writeSpec("erase"))
+
+ result, err := proxy.handleCallToolVariant(adminCtx(), auditCallToolRequest("a:erase", nil), contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.False(t, result.IsError, "control: an admin may dispatch any variant against any tool")
+ require.Equal(t, int64(1), up.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "call_tool_read", lines[0]["surface"], "surface still records the caller's chosen door")
+ assert.Equal(t, "write", lines[0]["operation"], "operation records the TARGET tool's real tier")
+ assert.Equal(t, "write", lines[1]["operation"])
+}
+
+func TestAuditFunnel_ScopeRefusalRecordsHiddenServerUndisclosed(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}, {Name: "b", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "b", readSpec("erase"))
+
+ result, err := proxy.handleCallToolVariant(fullTierAgentOn("a"), auditCallToolRequest("b:erase", map[string]interface{}{"x": auditArgSentinel}), contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "not in scope", "the caller's response is unchanged")
+ assert.Equal(t, int64(0), up.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1, "a refusal is exactly one authz line and no tool_call")
+ authz := lines[0]
+ assert.Equal(t, "authz", authz["event"])
+ assert.Equal(t, "deny", authz["decision"])
+ assert.Equal(t, "token_scope", authz["reason"])
+ assert.Equal(t, false, authz["disclosed"])
+ assert.Equal(t, "b", authz["server"], "the hidden server's real name is recorded for the operator")
+ assert.Nil(t, authz["outcome"])
+ assert.NotContains(t, string(sink.snapshot()[0]), auditArgSentinel)
+}
+
+func TestAuditFunnel_UnknownServerIsAuthzDenyNotCallable(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ result, err := proxy.handleCallToolVariant(adminCtx(), auditCallToolRequest("zzz:ghost", nil), contracts.ToolVariantWrite)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1, "the shared gate refuses a server with no record before any dispatch")
+ assert.Equal(t, "deny", lines[0]["decision"])
+ assert.Equal(t, "tool_not_callable", lines[0]["reason"])
+ assert.Equal(t, true, lines[0]["disclosed"])
+ assert.Equal(t, "call_tool_write", lines[0]["surface"])
+ assert.Equal(t, "write", lines[0]["operation"])
+ assert.Equal(t, "api_key", callerOf(t, lines[0])["kind"])
+}
+
+func TestAuditFunnel_NotConnectedIsToolCallErrorUpstreamUnavailable(t *testing.T) {
+ // A configured server the proxy holds no client for: every gate passes
+ // (no record says otherwise) and the dispatch fails before any upstream.
+ proxy, _ := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ require.NoError(t, proxy.storage.SaveUpstreamServer(&config.ServerConfig{Name: "a", URL: "http://127.0.0.1:9/mcp", Protocol: "streamable-http", Enabled: true}))
+
+ result, err := proxy.handleCallToolVariant(adminCtx(), auditCallToolRequest("a:erase", nil), contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2, "%v", lines)
+ assert.Equal(t, "allow", lines[0]["decision"])
+ assert.Equal(t, "tool_call", lines[1]["event"])
+ assert.Equal(t, "error", lines[1]["outcome"])
+ assert.Equal(t, "upstream_unavailable", lines[1]["error_class"])
+ assert.Equal(t, lines[0]["request_id"], lines[1]["request_id"])
+}
+
+func TestAuditFunnel_IntentInvalidIsAuthzDenyWithUnknownPair(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{
+ "name": "a:erase",
+ "intent_data_sensitivity": "not-a-level",
+ }
+ result, err := proxy.handleCallToolVariant(adminCtx(), req, contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1)
+ assert.Equal(t, "deny", lines[0]["decision"])
+ assert.Equal(t, "intent_rejected", lines[0]["reason"])
+ assert.Equal(t, true, lines[0]["disclosed"])
+}
+
+// TestAuditFunnel_MalformedArgsJSONIsAuthzDenyNotAllow is a round-2
+// cross-review regression (PR-D): malformed args_json has always
+// short-circuited before the profile/token-scope/target-tier/quarantine/
+// callability gates (pre-Spec-107 behaviour, unchanged here), so recording
+// it as `authz allow` + `tool_call error` — round-1's fix — would let an
+// out-of-scope or quarantined target submitted with malformed args_json be
+// recorded as authorized even though authorization never ran. It must be
+// exactly one `authz deny` line and no `tool_call` line, even for a target
+// scoped out of the caller's token (proving the deny is not a disguised
+// allow that merely happens to match this particular target's scope).
+func TestAuditFunnel_MalformedArgsJSONIsAuthzDenyNotAllow(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}, {Name: "b", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ req := mcp.CallToolRequest{}
+ req.Params.Arguments = map[string]interface{}{
+ "name": "b:erase", // out of fullTierAgentOn("a")'s scope
+ "args_json": "{not valid json",
+ }
+ result, err := proxy.handleCallToolVariant(fullTierAgentOn("a"), req, contracts.ToolVariantRead)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ assert.Contains(t, result.Content[0].(mcp.TextContent).Text, "Invalid args_json format")
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1, "malformed args_json before any gate is exactly one authz line and no tool_call")
+ assert.Equal(t, "authz", lines[0]["event"])
+ assert.Equal(t, "deny", lines[0]["decision"])
+ assert.Equal(t, "other", lines[0]["reason"])
+ assert.Equal(t, true, lines[0]["disclosed"])
+}
+
+func TestAuditFunnel_NestedRefusalCarriesParentAndScriptCaller(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}, {Name: "b", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "b", readSpec("erase"))
+
+ call := runSandboxCallTool(t, proxy, fullTierAgentOn("a"), "b", "erase")
+ require.False(t, call.OK)
+ assert.Equal(t, int64(0), up.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1, "the wrapper writes no line; the nested refusal writes exactly one authz")
+ authz := lines[0]
+ assert.Equal(t, "authz", authz["event"])
+ assert.Equal(t, "deny", authz["decision"])
+ assert.Equal(t, "token_scope", authz["reason"])
+ assert.Equal(t, false, authz["disclosed"])
+ assert.Equal(t, "code_execution", authz["surface"])
+ assert.Equal(t, "internal", authz["source"])
+ assert.NotEmpty(t, authz["parent_id"])
+ assert.Equal(t, "b", authz["server"])
+ assert.Equal(t, "erase", authz["tool"])
+ c := callerOf(t, authz)
+ assert.Equal(t, "agent_token", c["kind"], "nested children keep the script's caller")
+ assert.Equal(t, "mcp_agt_fix", c["token_prefix"])
+}
+
+// TestAuditFunnel_NestedScriptAllowlistExclusionIsTokenScopeNotProfileScope
+// is a round-3 cross-review regression (Spec 107 PR-D): the sandbox's
+// allow-list is the INTERSECTION of the script's own `options.allowed_servers`
+// and the active profile (applyProfileScopeToExecution) — a single merged
+// set the gate answers from. Before this fix, a SERVER_NOT_ALLOWED refusal
+// was classified `profile_scope` whenever ANY profile was active, even when
+// the profile itself permitted the target and only the script's own
+// allow-list excluded it. Per the published contract (audit-line-events.md),
+// nested `checkDispatchGates` always maps to `token_scope`.
+func TestAuditFunnel_NestedScriptAllowlistExclusionIsTokenScopeNotProfileScope(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}, {Name: "b", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "b", readSpec("erase"))
+
+ // The active profile permits BOTH "a" and "b" — only the script's OWN
+ // options.allowed_servers (below) excludes "b".
+ scope := profile.NewProfileScope("both", []string{"a", "b"})
+ ctx := profile.WithProfileScope(adminCtx(), scope)
+
+ request := mcp.CallToolRequest{Params: mcp.CallToolParams{
+ Name: "code_execution",
+ Arguments: map[string]interface{}{
+ "code": `var r = call_tool("b", "erase", {}); ({ ok: r.ok, code: r.error ? r.error.code : null })`,
+ "input": map[string]interface{}{},
+ "options": map[string]interface{}{
+ "timeout_ms": 10000,
+ "max_tool_calls": 0,
+ "allowed_servers": []interface{}{"a"},
+ },
+ },
+ }}
+ result, err := proxy.handleCodeExecution(ctx, request)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.False(t, result.IsError)
+ assert.Equal(t, int64(0), up.count.Load(), "control: the script's own allow-list must have refused before any dispatch")
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1)
+ assert.Equal(t, "authz", lines[0]["event"])
+ assert.Equal(t, "deny", lines[0]["decision"])
+ assert.Equal(t, "token_scope", lines[0]["reason"],
+ "the script's own allowed_servers excluded 'b'; the profile permitted it, so this must not read profile_scope")
+}
+
+func TestAuditFunnel_NestedAllowedDispatchPairsUnderParent(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ up := startCountingUpstream(t, proxy, rt, "a", readSpec("erase"))
+
+ call := runSandboxCallTool(t, proxy, adminCtx(), "a", "erase")
+ require.True(t, call.OK, "control: %s", call.Message)
+ assert.Equal(t, int64(1), up.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2, "one authz allow + one tool_call for the nested call; none for the wrapper")
+ authz, toolCall := lines[0], lines[1]
+ assert.Equal(t, "allow", authz["decision"])
+ assert.Equal(t, "code_execution", authz["surface"])
+ assert.Equal(t, "internal", authz["source"])
+ assert.NotEmpty(t, authz["parent_id"])
+ assert.Equal(t, "read", authz["operation"])
+ assert.Equal(t, "api_key", callerOf(t, authz)["kind"], "nested children keep the script's caller")
+ assert.Equal(t, "tool_call", toolCall["event"])
+ assert.Equal(t, "success", toolCall["outcome"])
+ assert.Equal(t, authz["parent_id"], toolCall["parent_id"])
+ assert.Equal(t, authz["request_id"], toolCall["request_id"])
+}
+
+func TestAuditFunnel_CountInvariantOverManyCalls(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, []*config.ServerConfig{{Name: "a", Enabled: true}, {Name: "b", Enabled: true}})
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ startCountingUpstream(t, proxy, rt, "a", readSpec("erase"))
+
+ const allowed, denied = 40, 20
+ var wg sync.WaitGroup
+ for i := 0; i < allowed; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ _, _ = proxy.handleCallToolVariant(fullTierAgentOn("a"), auditCallToolRequest("a:erase", map[string]interface{}{"i": float64(i)}), contracts.ToolVariantRead)
+ }(i)
+ }
+ for i := 0; i < denied; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ _, _ = proxy.handleCallToolVariant(fullTierAgentOn("a"), auditCallToolRequest("b:erase", nil), contracts.ToolVariantRead)
+ }()
+ }
+ wg.Wait()
+
+ var authzAllow, authzDeny, toolCalls int
+ for _, l := range sink.decoded(t) {
+ switch l["event"] {
+ case "authz":
+ if l["decision"] == "allow" {
+ authzAllow++
+ } else {
+ authzDeny++
+ }
+ case "tool_call":
+ toolCalls++
+ }
+ }
+ assert.Equal(t, allowed, authzAllow)
+ assert.Equal(t, denied, authzDeny)
+ assert.Equal(t, authzAllow, toolCalls, "#tool_call == #authz(allow)")
+}
+
+func TestAuditFunnel_NilSinkIsFree(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, nil)
+ require.Nil(t, proxy.auditSink)
+ ctx := proxy.installAuditAttempt(context.Background(), auditAttemptSpec{RequestID: "r", Server: "a", Tool: "t"})
+ assert.Nil(t, auditDispatchFromContext(ctx), "no sink: no attempt is installed")
+ assert.NotPanics(t, func() {
+ proxy.auditAuthz(ctx, "deny", "token_scope")
+ proxy.auditToolCall(ctx, "success", "", "", 0, nil, nil)
+ proxy.emitActivityPolicyDecision(ctx, "a", "t", "", "r", "blocked", "x", "token_scope")
+ })
+}
+
+func TestAuditFunnel_OutputBlockIsToolCallBlockedNeverSecondAuthz(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, nil)
+ proxy.config.OutputSanitisation = sanCfg("block", true, false)
+ proxy.sanitisationDetector = security.NewDetector(config.DefaultSensitiveDataDetectionConfig())
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ ctx := proxy.installAuditAttempt(adminCtx(), auditAttemptSpec{
+ RequestID: "req-san-block", Server: "github", Tool: "get_secret",
+ Operation: "read", Surface: contracts.ToolVariantRead,
+ })
+ // The dispatch path wrote the allow line at Started, before the upstream.
+ proxy.emitActivityToolCallStarted(ctx, "github", "get_secret", "", "req-san-block", "mcp", nil)
+
+ fwd := &mcp.CallToolResult{Content: []mcp.Content{
+ mcp.TextContent{Type: "text", Text: "leaked " + awsKeyFixture},
+ }}
+ block := proxy.applyOutputSanitisation(ctx, "github", "get_secret", "req-san-block", contracts.ContentTrustUntrusted, fwd)
+ require.NotNil(t, block)
+
+ // Defence in depth: were a completion emitted after the block, it must
+ // not add a second tool_call.
+ proxy.emitActivityToolCallCompleted(ctx, "github", "get_secret", "", "req-san-block", "mcp", "error", "blocked", 1, nil, "", false, "", nil, "", "", 0, 0, "", nil, "")
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2, "%v", lines)
+ assert.Equal(t, "authz", lines[0]["event"])
+ assert.Equal(t, "allow", lines[0]["decision"])
+ assert.Equal(t, "tool_call", lines[1]["event"])
+ assert.Equal(t, "blocked", lines[1]["outcome"])
+ assert.Equal(t, "output_sanitisation", lines[1]["reason"])
+ assert.Nil(t, lines[1]["error_class"])
+ for _, raw := range sink.snapshot() {
+ assert.NotContains(t, string(raw), awsKeyFixture)
+ }
+}
+
+func TestAuditFunnel_LimiterShedIsToolCallRejected(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ for reason, want := range map[limiter.Reason]string{
+ limiter.ReasonQueueFull: "limiter_queue_full",
+ limiter.ReasonQueueTimeout: "limiter_queue_timeout",
+ } {
+ ctx := proxy.installAuditAttempt(adminCtx(), auditAttemptSpec{
+ RequestID: "req-shed-" + string(reason), Server: "db", Tool: "query", Operation: "read", Surface: contracts.ToolVariantRead,
+ })
+ proxy.emitActivityToolCallStarted(ctx, "db", "query", "", "req-shed", "mcp", nil)
+ proxy.auditToolCallShed(ctx, &limiter.LimitError{Scope: limiter.ScopeServer, Reason: reason, Server: "db", Limit: 2}, 5)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "allow", lines[0]["decision"])
+ assert.Equal(t, "rejected", lines[1]["outcome"])
+ assert.Equal(t, want, lines[1]["reason"])
+ assert.Equal(t, "api_key", callerOf(t, lines[1])["kind"])
+ sink.mu.Lock()
+ sink.lines = nil
+ sink.mu.Unlock()
+ }
+}
+
+func TestAuditFunnel_StdioCallerAndSocketOrigin(t *testing.T) {
+ proxy, _ := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+
+ stdio := proxy.installAuditAttempt(stdioAuthContext(context.Background()), auditAttemptSpec{RequestID: "r1", Server: "a", Tool: "t", Operation: "read", Surface: contracts.ToolVariantRead})
+ proxy.auditAuthz(stdio, "deny", telemetry.BlockReasonServerQuarantined)
+
+ tray := transport.TagConnectionContext(adminCtx(), transport.ConnectionSourceTray)
+ tray = proxy.installAuditAttempt(tray, auditAttemptSpec{RequestID: "r2", Server: "a", Tool: "t", Operation: "read", Surface: contracts.ToolVariantRead})
+ proxy.auditAuthz(tray, "deny", telemetry.BlockReasonProfileScope)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "stdio", callerOf(t, lines[0])["kind"])
+ assert.Equal(t, "local", lines[0]["origin"])
+ assert.Equal(t, "server_quarantined", lines[0]["reason"])
+ assert.Equal(t, true, lines[0]["disclosed"])
+ assert.Equal(t, "socket", callerOf(t, lines[1])["kind"])
+ assert.Equal(t, "socket", lines[1]["origin"])
+ assert.Equal(t, false, lines[1]["disclosed"])
+}
diff --git a/internal/server/code_exec_activity_test.go b/internal/server/code_exec_activity_test.go
index 00733ec32..be3c77f19 100644
--- a/internal/server/code_exec_activity_test.go
+++ b/internal/server/code_exec_activity_test.go
@@ -130,7 +130,7 @@ func TestSubCallActivityOutcome_ClassifiesEveryExit(t *testing.T) {
func TestEmitSubCallActivity_NoProxyIsANoOp(t *testing.T) {
u := &upstreamToolCaller{logger: zap.NewNop()}
assert.NotPanics(t, func() {
- u.emitSubCallActivity("time", "now", "req-test", nil, nil, errors.New("boom"), time.Now(), time.Millisecond)
+ u.emitSubCallActivity(context.Background(), "time", "now", "req-test", nil, nil, errors.New("boom"), time.Now(), time.Millisecond)
})
}
@@ -202,7 +202,7 @@ func TestSubCallActivityDoesNotExposeDetectionSourceToSubscribers(t *testing.T)
defer rt.UnsubscribeEvents(events)
caller := &upstreamToolCaller{proxy: proxy, parentCallID: "parent"}
response := strings.Repeat("x", subCallActivityResponseLimit+100) + " secret AKIA1234567890ABCDEF"
- caller.emitSubCallActivity("github", "echo", "request", nil, mcp.NewToolResultText(response), nil, time.Now(), time.Millisecond)
+ caller.emitSubCallActivity(context.Background(), "github", "echo", "request", nil, mcp.NewToolResultText(response), nil, time.Now(), time.Millisecond)
select {
case event := <-events:
assert.NotContains(t, event.Payload["response"], "AKIA1234567890ABCDEF")
diff --git a/internal/server/mcp.go b/internal/server/mcp.go
index e55eaab1d..dd214fe90 100644
--- a/internal/server/mcp.go
+++ b/internal/server/mcp.go
@@ -14,6 +14,7 @@ import (
"time"
"unicode"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/branding"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cache"
@@ -279,6 +280,11 @@ type MCPProxyServer struct {
// when observability is disabled; all use sites must nil-guard.
observability *observability.Manager
+ // auditSink is the Spec 107 audit line writer (audit_funnel.go), copied
+ // from Server.auditSink at construction. nil = every funnel is a no-op
+ // (the personal-edition default and audit_log off).
+ auditSink audit.Sink
+
// Issue #969 (Phase 0): per-session note of the last retrieve_tools call
// that carried a spec-094 filter_diagnostics block, used to detect whether
// the agent then RELAXED a blamed filter. In-memory only, never persisted,
@@ -772,7 +778,14 @@ func (p *MCPProxyServer) recordRealToolCallSuccess() {
// emitActivityEvent safely emits an activity event if runtime is available
// source indicates how the call was triggered: "mcp", "cli", or "api"
-func (p *MCPProxyServer) emitActivityToolCallStarted(serverName, toolName, sessionID, requestID, source string, args map[string]any) {
+//
+// Spec 107 (FR-012): ctx is the request context carrying the audit.Attempt.
+// Every dispatch path emits Started after its last pre-dispatch gate and
+// before the upstream call, so this is where the attempt's `authz allow`
+// line is written — ahead of the upstream call, so a crash mid-call keeps
+// the authorization record (research.md D6).
+func (p *MCPProxyServer) emitActivityToolCallStarted(ctx context.Context, serverName, toolName, sessionID, requestID, source string, args map[string]any) {
+ p.auditAuthz(ctx, "allow", "")
if p.mainServer != nil && p.mainServer.runtime != nil {
p.mainServer.runtime.EmitActivityToolCallStarted(serverName, toolName, sessionID, requestID, source, args)
}
@@ -790,13 +803,50 @@ func (p *MCPProxyServer) emitActivityToolCallStarted(serverName, toolName, sessi
// parentID is the correlation id of the code_execution whose sandbox issued this
// sub-call (issue C) — empty for every top-level dispatch. It is the LAST
// parameter on purpose: TestActivityCompletionNeverHardcodesSuccess pins the
-// position of `status`, so new parameters have to go after it.
-func (p *MCPProxyServer) emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust, profile string, requestBytes, responseBytes int, detectionText string, toonDecisions []toonenc.Decision, parentID string) {
+// position of `status` (index 6 since ctx became the first parameter, Spec
+// 107 FR-012), so new parameters have to go after it.
+//
+// ctx is the request context carrying the audit.Attempt (Spec 107): this is
+// the funnel every dispatched call completes through, so it writes the
+// attempt's ONE `tool_call` line — outcome from status, the bounded
+// error_class from the typed error the path noted (auditNoteError), never
+// from errorMsg. A post-dispatch output block already wrote the line as
+// outcome:blocked; the attempt's dedup makes this call a no-op then.
+func (p *MCPProxyServer) emitActivityToolCallCompleted(ctx context.Context, serverName, toolName, sessionID, requestID, source, status, errorMsg string, durationMs int64, arguments map[string]interface{}, response string, responseTruncated bool, toolVariant string, intent map[string]interface{}, contentTrust, profile string, requestBytes, responseBytes int, detectionText string, toonDecisions []toonenc.Decision, parentID string) {
+ p.auditToolCallFromStatus(ctx, status, durationMs, requestBytes, responseBytes)
if p.mainServer != nil && p.mainServer.runtime != nil {
p.mainServer.runtime.EmitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, source, status, errorMsg, durationMs, arguments, response, responseTruncated, toolVariant, intent, contentTrust, profile, requestBytes, responseBytes, detectionText, toonOutputMetadata(toonDecisions), parentID)
}
}
+// auditToolCallFromStatus maps an activity completion status onto the
+// tool_call outcome vocabulary and writes the line. A "blocked" completion
+// (the sandbox bridge's pre-dispatch policy refusal, emitSubCallRefused) is
+// an `authz deny` — the bridge wrote it before emitting — so only the
+// activity row is left to write here.
+func (p *MCPProxyServer) auditToolCallFromStatus(ctx context.Context, status string, durationMs int64, requestBytes, responseBytes int) {
+ if auditDispatchFromContext(ctx) == nil {
+ return
+ }
+ var reqBytes, respBytes *int
+ if requestBytes > 0 {
+ reqBytes = &requestBytes
+ }
+ if responseBytes > 0 {
+ respBytes = &responseBytes
+ }
+ switch status {
+ case storage.ActivityStatusSuccess:
+ p.auditToolCall(ctx, "success", "", "", durationMs, reqBytes, respBytes)
+ case storage.ActivityStatusError:
+ p.auditToolCall(ctx, "error", "", "", durationMs, reqBytes, respBytes)
+ case storage.ActivityStatusBlocked, storage.ActivityStatusRejected:
+ // Pre-dispatch refusals reach this funnel only from the sandbox
+ // bridge, which already wrote the authz deny; sheds are written by
+ // auditToolCallShed at the shed site. Nothing to add.
+ }
+}
+
// emitActivityPolicyDecision is the single funnel every policy block, warning
// and redaction leaves through.
//
@@ -812,7 +862,21 @@ func (p *MCPProxyServer) emitActivityToolCallCompleted(serverName, toolName, ses
// the classification comes from the gate that fired rather than from parsing
// the message it wrote. A key outside the enum is folded into "other" by the
// store.
-func (p *MCPProxyServer) emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, decision, reason, reasonKey string) {
+//
+// ctx is the request context carrying the audit.Attempt (Spec 107 FR-012).
+// A "blocked" decision at a pre-dispatch gate is the attempt's `authz deny`
+// line (reasonKey is the line's reason; the prose never reaches it); a
+// "blocked" output_sanitisation / output_schema decision is post-dispatch
+// and becomes the attempt's `tool_call outcome:blocked` line — never a
+// second authz (FR-043(j)). Warnings and redactions write no audit line.
+func (p *MCPProxyServer) emitActivityPolicyDecision(ctx context.Context, serverName, toolName, sessionID, requestID, decision, reason, reasonKey string) {
+ if decision == "blocked" {
+ if isPostDispatchBlockKey(reasonKey) {
+ p.auditToolCall(ctx, "blocked", reasonKey, "", auditDurationMs(ctx), nil, nil)
+ } else {
+ p.auditAuthz(ctx, "deny", reasonKey)
+ }
+ }
if p.mainServer != nil && p.mainServer.runtime != nil {
p.mainServer.runtime.EmitActivityPolicyDecision(serverName, toolName, sessionID, requestID, decision, reason)
}
@@ -2227,6 +2291,66 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
// timestamp alone keeps the id unique.
requestID := mintActivityRequestID(serverName, actualToolName)
+ // Get optional args parameter - handle both new JSON string format and
+ // legacy object format. Parsed HERE, above the first gate, because the
+ // audit attempt below hashes the arguments (Spec 107 FR-015); a parse
+ // failure is still answered at the same point it always was (after the
+ // intent gates), so the refusal order is unchanged.
+ var args map[string]interface{}
+ var argsErrMsg string
+ if argsJSON := request.GetString("args_json", ""); argsJSON != "" {
+ if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
+ args = nil
+ argsErrMsg = fmt.Sprintf("Invalid args_json format: %v", err)
+ }
+ }
+
+ // Fallback to legacy object format for backward compatibility
+ if args == nil && argsErrMsg == "" && request.Params.Arguments != nil {
+ if argumentsMap, ok := request.Params.Arguments.(map[string]interface{}); ok {
+ if argsParam, ok := argumentsMap["args"]; ok {
+ if argsMap, ok := argsParam.(map[string]interface{}); ok {
+ args = argsMap
+ }
+ }
+ }
+ }
+
+ // Spec 057 / Profiles v2: the active profile (token pin > URL > session
+ // set_profile). Resolved once, up here, because the audit attempt stamps
+ // it; the profile-scope GATE itself still runs below, after the intent
+ // gates, exactly where it always did.
+ profileSlug, profileScope := p.resolveActiveProfile(ctx)
+
+ // Spec 107 T103: the audit attempt, installed BEFORE the first gate so
+ // every refusal below — the intent gates included — writes its `authz
+ // deny` line from it. Surface is the variant, or `rest` when the call
+ // entered through /api/v1/tools/call (the mount decides, never a header).
+ {
+ auditSurface := toolVariant
+ if meta, ok := reqcontext.GetRequestMeta(ctx); ok && meta.Mount == reqcontext.MountAPI {
+ auditSurface = auditSurfaceREST
+ }
+ var auditClientName, auditClientVersion string
+ if sid := getSessionID(); sid != "" {
+ if sessInfo := p.sessionStore.GetSession(sid); sessInfo != nil {
+ auditClientName, auditClientVersion = sessInfo.ClientName, sessInfo.ClientVersion
+ }
+ }
+ ctx = p.installAuditAttempt(ctx, auditAttemptSpec{
+ RequestID: requestID,
+ SessionID: getSessionID(),
+ Server: serverName,
+ Tool: actualToolName,
+ Operation: contracts.ToolVariantToOperationType[toolVariant],
+ Surface: auditSurface,
+ ClientName: auditClientName,
+ ClientVersion: auditClientVersion,
+ Profile: profileSlug,
+ Args: args,
+ })
+ }
+
// Extract intent (optional - operation_type is inferred from tool variant)
intent, err := p.extractIntent(request)
if err != nil {
@@ -2240,7 +2364,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if logTool == "" {
logTool = toolName
}
- p.emitActivityPolicyDecision(logServer, logTool, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonIntentInvalid)
+ p.emitActivityPolicyDecision(ctx, logServer, logTool, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonIntentInvalid)
return mcp.NewToolResultError(errMsg), nil
}
@@ -2256,27 +2380,26 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if logTool == "" {
logTool = toolName
}
- p.emitActivityPolicyDecision(logServer, logTool, getSessionID(), requestID, "blocked", "Intent validation failed", telemetry.BlockReasonIntentRejected)
+ p.emitActivityPolicyDecision(ctx, logServer, logTool, getSessionID(), requestID, "blocked", "Intent validation failed", telemetry.BlockReasonIntentRejected)
return errResult, nil
}
- // Get optional args parameter - handle both new JSON string format and legacy object format
- var args map[string]interface{}
- if argsJSON := request.GetString("args_json", ""); argsJSON != "" {
- if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
- return mcp.NewToolResultError(fmt.Sprintf("Invalid args_json format: %v", err)), nil
- }
- }
-
- // Fallback to legacy object format for backward compatibility
- if args == nil && request.Params.Arguments != nil {
- if argumentsMap, ok := request.Params.Arguments.(map[string]interface{}); ok {
- if argsParam, ok := argumentsMap["args"]; ok {
- if argsMap, ok := argsParam.(map[string]interface{}); ok {
- args = argsMap
- }
- }
- }
+ // Arguments were parsed above the first gate (audit attempt); a parse
+ // failure is answered here, where it always was (pre-Spec-107 behaviour:
+ // this has always short-circuited before the profile/token-scope/
+ // target-tier/quarantine/callability gates below, and that response
+ // order is unchanged here). The attempt already exists on ctx, so this
+ // still owes the count invariant its line — but as an `authz deny`, not
+ // an `authz allow` + `tool_call error`: those gates never ran, so FR-012
+ // ("allow after last gate") forbids recording this dispatch as
+ // authorized (round-2 cross-review finding, PR-D — round-1's fix wrote
+ // `allow` here, which would let an out-of-scope or quarantined target
+ // submitted with malformed args_json be recorded as authorized even
+ // though authorization was never completed). "other" is the correct
+ // reason: malformed args_json is not one of FR-012's named gates.
+ if argsErrMsg != "" {
+ p.auditAuthz(ctx, "deny", telemetry.BlockReasonOther)
+ return mcp.NewToolResultError(argsErrMsg), nil
}
// Handle upstream tools via upstream manager (requires server:tool format)
@@ -2292,15 +2415,15 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
// Spec 057 / Profiles v2: profile filter — runs independently of agent-scope
// so that unauthenticated /mcp/p/ connections (AdminContext) are still
// filtered, and so a base /mcp session that ran set_profile is bounded too.
- // The same resolution is the read_cache producer stamp (Spec 104
- // FR-016a), captured here — at authorization time, before the upstream
- // call — so a profile deleted or narrowed while the call is in flight
- // cannot re-stamp a response that was authorized under the wider scope.
- profileSlug, profileScope := p.resolveActiveProfile(ctx)
+ // The same resolution (taken once, above the intent gates, for the audit
+ // attempt) is the read_cache producer stamp (Spec 104 FR-016a), captured
+ // here — at authorization time, before the upstream call — so a profile
+ // deleted or narrowed while the call is in flight cannot re-stamp a
+ // response that was authorized under the wider scope.
producer := p.cacheAuthorizationWith(ctx, profileSlug, profileScope)
if profileScope != nil && !profileScope.Allows(serverName) {
errMsg := fmt.Sprintf("server '%s' is not in profile '%s'", serverName, profileScope.Name)
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonProfileScope)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonProfileScope)
return mcp.NewToolResultError(errMsg), nil
}
@@ -2329,6 +2452,19 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
gate := p.evaluateExactToolGate(serverName, actualToolName)
identity := gate.identity
annotations, annotationsFound := identity.Annotations, identity.Found
+ // Spec 107 (round-2 cross-review finding, PR-D): the attempt was stamped
+ // with the caller-chosen variant's operation before this point, the
+ // earliest the target's real tier is known; correct it now so every
+ // line from here on (including a deny below) reports the tool's actual
+ // tier rather than the door the caller happened to use. Only when the
+ // tool's annotations were actually found: tierForAnnotations' !found
+ // fallback is "destructive" for AUTHORIZATION purposes (deny by
+ // default), which would misrepresent an unresolved/nonexistent target
+ // as maximally risky on the audit line rather than simply "unknown to
+ // the proxy" — the variant remains the best available signal there.
+ if annotationsFound {
+ auditSetOperation(ctx, tierForAnnotations(annotations, annotationsFound))
+ }
if p.dispatchGatePause != nil {
p.dispatchGatePause(serverName, actualToolName)
}
@@ -2343,7 +2479,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
// Check server scope
if !authCtx.CanAccessServer(serverName) {
errMsg := fmt.Sprintf("Server '%s' is not in scope for this agent token", serverName)
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenScope)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenScope)
return mcp.NewToolResultError(errMsg), nil
}
// Check permission scope — map tool variant to required permission
@@ -2358,7 +2494,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
}
if requiredPerm != "" && !authCtx.HasPermission(requiredPerm) {
errMsg := fmt.Sprintf("Insufficient permissions: '%s' requires '%s' permission", toolVariant, requiredPerm)
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
return mcp.NewToolResultError(errMsg), nil
}
}
@@ -2396,7 +2532,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
// closed (internal/telemetry/preflight_counters.go, the anonymity
// contract), so the closest existing non-token member is reused
// rather than widening it.
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
@@ -2413,7 +2549,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
targetPerm := tierForAnnotations(annotations, annotationsFound)
if targetPerm != "" && !authCtx.HasPermission(targetPerm) {
errMsg := fmt.Sprintf("Permission denied: token does not have '%s' permission required for tool '%s:%s'", targetPerm, serverName, actualToolName)
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
return mcp.NewToolResultError(errMsg), nil
}
}
@@ -2437,7 +2573,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if errResult := p.validateIntentAgainstServer(intent, toolVariant, serverName, actualToolName, annotations); errResult != nil {
// Record activity error for server annotation mismatch
reason := fmt.Sprintf("Intent rejected: tool variant '%s' conflicts with server annotations for %s:%s", toolVariant, serverName, actualToolName)
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", reason, telemetry.BlockReasonIntentRejected)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", reason, telemetry.BlockReasonIntentRejected)
return errResult, nil
}
@@ -2485,7 +2621,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
zap.String("server_name", serverName))
// Emit policy decision event for quarantine block
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked", "Server is quarantined for security review", telemetry.BlockReasonServerQuarantined)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked", "Server is quarantined for security review", telemetry.BlockReasonServerQuarantined)
// Server is in quarantine - return security warning with tool analysis
return p.handleQuarantinedToolCall(ctx, serverName, actualToolName, activityArgs), nil
@@ -2497,7 +2633,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
zap.String("server_name", serverName),
zap.String("tool_name", actualToolName))
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked",
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked",
"Tool is pending approval (new unapproved tool)", telemetry.BlockReasonToolPendingApproval)
return toolPendingApprovalResult(serverName, actualToolName, gate.approval), nil
@@ -2506,7 +2642,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
zap.String("server_name", serverName),
zap.String("tool_name", actualToolName))
- p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), requestID, "blocked",
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, getSessionID(), requestID, "blocked",
"Tool description/schema changed since last approval", telemetry.BlockReasonToolChanged)
return toolChangedApprovalResult(serverName, actualToolName, gate.approval), nil
@@ -2514,7 +2650,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if !gate.callable() {
errMsg := gate.blockedMessage()
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
@@ -2538,8 +2674,9 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
intentMap = intent.ToMap()
}
errMsg := fmt.Sprintf("invalid arguments for %s: %s", toolName, detail)
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
+ auditNoteErrorClass(ctx, audit.ErrorClassValidation)
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
return invalidParamsErrorResult(toolName, meta.ParamsJSON, detail), nil
}
}
@@ -2560,8 +2697,9 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if intent != nil {
intentMap = intent.ToMap()
}
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
return mcp.NewToolResultError(errMsg), nil
}
// Spec 105 FR-009 (research D4), astra r1 I3: the identity gate
@@ -2588,7 +2726,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
p.logger.Debug("handleCallToolVariant: refusing unresolved tool identity (live client connected, snapshot stale)",
zap.String("server_name", serverName),
zap.String("tool_name", actualToolName))
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
// The dispatch below is pinned to the generation this check
@@ -2616,13 +2754,14 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if intent != nil {
intentMap = intent.ToMap()
}
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
return mcp.NewToolResultError(errMsg), nil
}
// Emit activity started event with determined source
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
// Call tool via upstream manager — use original args without auth metadata.
// MCP-32: wrap with an OTLP span (tool call + upstream hop) and record
@@ -2712,6 +2851,10 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
zap.String("reason", string(limitErr.Reason)),
zap.Int("limit", limitErr.Limit))
recordShed(ctx, limitErr)
+ // Spec 107: the shed is this attempt's tool_call (outcome
+ // rejected) — the limiter runs after every gate, so never a
+ // second authz (research.md D6).
+ p.auditToolCallShed(ctx, limitErr, duration.Milliseconds())
var shedIntentMap map[string]interface{}
if intent != nil {
@@ -2743,8 +2886,9 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if intent != nil {
intentMap = intent.ToMap()
}
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
@@ -2775,7 +2919,8 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if intent != nil {
intentMap = intent.ToMap()
}
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
+ auditNoteError(ctx, err)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, toolVariant, intentMap, contentTrust, profileSlug, 0, 0, "", nil, "")
// Spec 024: Emit internal tool call event for error
internalToolName := "call_tool_" + intent.OperationType // e.g., "call_tool_read"
@@ -2905,7 +3050,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
if intent != nil {
intentMap = intent.ToMap()
}
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, activityStatus, activityErrMsg, duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust, profileSlug, activityRequestBytes, activityResponseBytes, toonDetectionText, toonDecisions, "")
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityStatus, activityErrMsg, duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust, profileSlug, activityRequestBytes, activityResponseBytes, toonDetectionText, toonDecisions, "")
// Spec 024: Emit internal tool call event. It carries the SAME classification
// as the tool_call record above (issue #935) — the two describe one dispatch,
@@ -3058,7 +3203,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
zap.String("server_name", serverName))
// Emit policy decision event for quarantine block
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", "Server is quarantined for security review", telemetry.BlockReasonServerQuarantined)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked", "Server is quarantined for security review", telemetry.BlockReasonServerQuarantined)
// Server is in quarantine - return security warning with tool analysis
return p.handleQuarantinedToolCall(ctx, serverName, actualToolName, args), nil
@@ -3069,18 +3214,18 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
switch gate.lockStatus {
case storage.ToolApprovalStatusPending:
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked",
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked",
"Tool is pending approval (new unapproved tool)", telemetry.BlockReasonToolPendingApproval)
return toolPendingApprovalResult(serverName, actualToolName, gate.approval), nil
case storage.ToolApprovalStatusChanged:
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked",
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked",
"Tool description/schema changed since last approval", telemetry.BlockReasonToolChanged)
return toolChangedApprovalResult(serverName, actualToolName, gate.approval), nil
}
if !gate.callable() {
errMsg := gate.blockedMessage()
- p.emitActivityPolicyDecision(serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ p.emitActivityPolicyDecision(ctx, serverName, actualToolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
@@ -3104,8 +3249,8 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
errMsg = fmt.Sprintf("Server '%s' is not connected (state: %s) - use 'upstream_servers' tool to check server configuration", serverName, state.String())
}
// Log the early failure to activity (Spec 024)
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "", "", 0, 0, "", nil, "")
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "", "", 0, 0, "", nil, "")
return mcp.NewToolResultError(errMsg), nil
}
} else {
@@ -3113,8 +3258,8 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
zap.String("server_name", serverName))
errMsg := fmt.Sprintf("No client found for server: %s", serverName)
// Log the early failure to activity (Spec 024)
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "", "", 0, 0, "", nil, "")
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", errMsg, 0, activityArgs, errMsg, false, "", nil, "", "", 0, 0, "", nil, "")
return mcp.NewToolResultError(errMsg), nil
}
@@ -3123,7 +3268,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
zap.String("server_name", serverName))
// Emit activity started event with determined source
- p.emitActivityToolCallStarted(serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
+ p.emitActivityToolCallStarted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityArgs)
// Call tool via upstream manager with circuit breaker pattern
startTime := time.Now()
@@ -3250,7 +3395,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
}
// Emit activity completed event for error with determined source (legacy - no intent)
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, "", nil, "", "", 0, 0, "", nil, "")
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, "error", err.Error(), duration.Milliseconds(), activityArgs, "", false, "", nil, "", "", 0, 0, "", nil, "")
return p.createDetailedErrorResponse(err, serverName, actualToolName), nil
}
@@ -3346,7 +3491,7 @@ func (p *MCPProxyServer) handleCallTool(ctx context.Context, request mcp.CallToo
// Emit activity completed event with determined source (legacy - no intent).
// Status comes from the upstream result, not from err alone (issue #935).
responseTruncated := tokenMetrics != nil && tokenMetrics.WasTruncated
- p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, activityStatus, activityErrMsg, duration.Milliseconds(), activityArgs, response, responseTruncated, "", nil, "", "", legacyRequestBytes, legacyResponseBytes, "", nil, "")
+ p.emitActivityToolCallCompleted(ctx, serverName, actualToolName, sessionID, requestID, activitySource, activityStatus, activityErrMsg, duration.Milliseconds(), activityArgs, response, responseTruncated, "", nil, "", "", legacyRequestBytes, legacyResponseBytes, "", nil, "")
return forwarded, nil
}
@@ -7409,7 +7554,7 @@ func (p *MCPProxyServer) applyOutputValidation(ctx context.Context, serverName,
if sess := mcpserver.ClientSessionFromContext(ctx); sess != nil {
sessionID = sess.SessionID()
}
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, d.decision, d.reason, telemetry.BlockReasonOutputSchema)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, d.decision, d.reason, telemetry.BlockReasonOutputSchema)
if d.block {
return mcp.NewToolResultError("output schema validation failed: " + d.reason)
}
diff --git a/internal/server/mcp_code_execution.go b/internal/server/mcp_code_execution.go
index d2683f681..fd11673c1 100644
--- a/internal/server/mcp_code_execution.go
+++ b/internal/server/mcp_code_execution.go
@@ -11,6 +11,7 @@ import (
"sync"
"time"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/codescripts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
@@ -18,6 +19,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/managed"
@@ -268,6 +270,27 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca
// Spec 057 (Codex #621 finding 2): Intersect profile scope into code_execution.
p.applyProfileScopeToExecution(ctx, &options)
+ // Spec 107 T103/T104: the wrapper itself writes no audit line (it is a
+ // built-in), but it captures the SCRIPT's caller for every nested line
+ // and installs the sandbox's authorization-decision observer so a
+ // scope/permission refusal decided inside jsruntime — which never
+ // reaches the bridge — still gets its `authz deny`, with parent_id.
+ if p.auditSink != nil {
+ scriptCaller := auditCallerFromContext(ctx)
+ toolCaller.auditCaller = &scriptCaller
+ toolCaller.auditProfile, _ = p.resolveActiveProfile(ctx)
+ options.ParentID = parentCallID
+ options.AuthzObserver = &nestedAuthzObserver{
+ proxy: p,
+ parentCtx: ctx,
+ caller: scriptCaller,
+ sessionID: sessionID,
+ clientName: clientName,
+ clientVersion: clientVersion,
+ profile: toolCaller.auditProfile,
+ }
+ }
+
// Execute code
p.logger.Info("executing code",
zap.String("execution_id", options.ExecutionID),
@@ -694,6 +717,15 @@ type upstreamToolCaller struct {
// FR-002). nil in unit tests that drive the caller directly, in which case
// the gate is skipped exactly as it was before the consolidation.
proxy *MCPProxyServer
+
+ // auditCaller is the SCRIPT's caller, captured by the wrapper before the
+ // sub-call context is tagged SourceInternal (Spec 107 T103/T104): every
+ // nested audit line keeps it, with surface code_execution and parent_id.
+ // nil when no sink is configured or the bridge is driven directly.
+ auditCaller *audit.Caller
+ // auditProfile is the active profile slug the wrapper resolved, stamped
+ // on the nested lines' `profile`.
+ auditProfile string
}
// sandboxGate is the jsruntime.ToolGate the bridge hands from the sandbox's
@@ -750,6 +782,30 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
// the script itself.
ctx = reqcontext.WithRequestSource(ctx, reqcontext.SourceInternal)
+ // Spec 107 T103: the nested call's audit attempt, installed before its
+ // first gate (the policy refusal below). Operation is the tier of the
+ // gate's identity — the same read the sandbox authorized against.
+ if u.proxy != nil {
+ operation := ""
+ if gated && gate.identity.Found {
+ operation = tierForAnnotations(gate.identity.Annotations, true)
+ }
+ ctx = u.proxy.installAuditAttempt(ctx, auditAttemptSpec{
+ RequestID: requestID,
+ ParentID: u.parentCallID,
+ SessionID: u.sessionID,
+ Server: serverName,
+ Tool: toolName,
+ Operation: operation,
+ Surface: auditSurfaceCodeExecution,
+ ClientName: u.clientName,
+ ClientVersion: u.clientVersion,
+ Profile: u.auditProfile,
+ Args: args,
+ Caller: u.auditCaller,
+ })
+ }
+
u.logger.Debug("calling upstream tool from JavaScript",
zap.String("execution_id", u.executionID),
zap.String("server", serverName),
@@ -779,7 +835,10 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
duration := time.Since(startTime)
u.recordToolCall(serverName, toolName, startTime, duration, false, refusal.Error())
u.storeToolCallInHistory(serverName, toolName, args, nil, refusal, startTime, duration)
- u.emitSubCallRefused(serverName, toolName, requestID, args, refusal, startTime, duration)
+ if u.proxy != nil {
+ u.proxy.auditAuthz(ctx, "deny", policyRefusalReasonKey(gate))
+ }
+ u.emitSubCallRefused(ctx, serverName, toolName, requestID, args, refusal, startTime, duration)
return nil, refusal
}
if u.proxy != nil && u.proxy.dispatchGatePause != nil {
@@ -793,7 +852,8 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
duration := time.Since(startTime)
u.recordToolCall(serverName, toolName, startTime, duration, false, err.Error())
u.storeToolCallInHistory(serverName, toolName, args, nil, err, startTime, duration)
- u.emitSubCallActivity(serverName, toolName, requestID, args, nil, err, startTime, duration)
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ u.emitSubCallActivity(ctx, serverName, toolName, requestID, args, nil, err, startTime, duration)
return nil, err
}
@@ -823,7 +883,8 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
duration := time.Since(startTime)
u.recordToolCall(serverName, toolName, startTime, duration, false, refusal.Error())
u.storeToolCallInHistory(serverName, toolName, args, nil, refusal, startTime, duration)
- u.emitSubCallRefused(serverName, toolName, requestID, args, refusal, startTime, duration)
+ u.proxy.auditAuthz(ctx, "deny", telemetry.BlockReasonToolNotCallable)
+ u.emitSubCallRefused(ctx, serverName, toolName, requestID, args, refusal, startTime, duration)
return nil, refusal
}
certified = live
@@ -837,6 +898,13 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
// refusal is answered exactly as the pre-dispatch check answers a name
// whose generation is not certified: the discovery-window body, recorded
// as a refusal, zero upstream calls.
+ // Spec 107: every pre-dispatch gate has passed — the nested `authz allow`
+ // line is written HERE, ahead of the upstream call (no Started event is
+ // emitted for a nested call, so the funnel that writes it for the other
+ // paths never runs on this one).
+ if u.proxy != nil {
+ u.proxy.auditAuthz(ctx, "allow", "")
+ }
var (
result *mcp.CallToolResult
err error
@@ -851,7 +919,14 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
duration := time.Since(startTime)
u.recordToolCall(serverName, toolName, startTime, duration, false, refusal.Error())
u.storeToolCallInHistory(serverName, toolName, args, nil, refusal, startTime, duration)
- u.emitSubCallRefused(serverName, toolName, requestID, args, refusal, startTime, duration)
+ // Post-allow refusal (the managed client refused to send): the
+ // attempt's authz line is already written, so this is its tool_call
+ // — an error nothing reached the upstream for.
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ if u.proxy != nil {
+ u.proxy.auditToolCall(ctx, "error", "", "", duration.Milliseconds(), nil, nil)
+ }
+ u.emitSubCallRefused(ctx, serverName, toolName, requestID, args, refusal, startTime, duration)
return nil, refusal
}
if err == nil {
@@ -872,7 +947,10 @@ func (u *upstreamToolCaller) callTool(ctx context.Context, serverName, toolName
// upstream rejection is a clean success here and an error there.
u.recordUpstreamCall(serverName, toolName, startTime, duration, result, err)
u.storeToolCallInHistory(serverName, toolName, args, result, err, startTime, duration)
- u.emitSubCallActivity(serverName, toolName, requestID, args, result, err, startTime, duration)
+ if err != nil {
+ auditNoteError(ctx, err)
+ }
+ u.emitSubCallActivity(ctx, serverName, toolName, requestID, args, result, err, startTime, duration)
u.logger.Debug("upstream tool call completed",
zap.String("execution_id", u.executionID),
@@ -953,7 +1031,9 @@ const subCallActivityResponseLimit = 8 * 1024
// `started` event is emitted: for a nested call it would arrive after the work
// already finished, and it would double the SSE traffic of a busy script for
// nothing — started events are never persisted anyway.
-func (u *upstreamToolCaller) emitSubCallActivity(serverName, toolName, requestID string, args map[string]interface{}, result interface{}, callErr error, startTime time.Time, duration time.Duration) {
+//
+// ctx is the sub-call's context carrying its audit.Attempt (Spec 107).
+func (u *upstreamToolCaller) emitSubCallActivity(ctx context.Context, serverName, toolName, requestID string, args map[string]interface{}, result interface{}, callErr error, startTime time.Time, duration time.Duration) {
// nil in the unit tests that drive the caller directly (see the field
// comment on upstreamToolCaller.proxy) — there is no runtime to emit into.
if u.proxy == nil {
@@ -969,6 +1049,12 @@ func (u *upstreamToolCaller) emitSubCallActivity(serverName, toolName, requestID
// server_unavailable is an ordinary error with no canonical record, so it
// must fall through and be recorded here like any other failure.
if shedHasCanonicalRecord(callErr) {
+ // The audit line is not the activity row: the shed is this attempt's
+ // `tool_call outcome:rejected` (research.md D6).
+ var limitErr *limiter.LimitError
+ if errors.As(callErr, &limitErr) {
+ u.proxy.auditToolCallShed(ctx, limitErr, duration.Milliseconds())
+ }
return
}
@@ -980,7 +1066,7 @@ func (u *upstreamToolCaller) emitSubCallActivity(serverName, toolName, requestID
detectionText := subCallDetectionText(result, callErr)
requestBytes, responseBytes := subCallByteSizes(args, result)
- u.proxy.emitActivityToolCallCompleted(
+ u.proxy.emitActivityToolCallCompleted(ctx,
serverName, toolName, u.sessionID, requestID, string(storage.ActivitySourceInternal),
status, errMsg, duration.Milliseconds(), args, responseText, truncated,
"", nil, "", "", requestBytes, responseBytes, detectionText, nil, u.parentCallID)
@@ -1021,7 +1107,7 @@ func subCallDetectionText(result interface{}, callErr error) string {
// reflect is what tells the two apart.
func subCallByteSizes(args map[string]interface{}, result interface{}) (requestBytes, responseBytes int) {
if result != nil {
- if rv := reflect.ValueOf(result); rv.Kind() == reflect.Ptr && rv.IsNil() {
+ if rv := reflect.ValueOf(result); rv.Kind() == reflect.Pointer && rv.IsNil() {
result = nil
}
}
@@ -1034,11 +1120,15 @@ func subCallByteSizes(args map[string]interface{}, result interface{}) (requestB
// aggregate routes blocked tool_calls off the executed-call statistics
// (Calls/latency) while still giving the attempt a failed bar in the timeline,
// the same treatment a direct-path policy_decision gets.
-func (u *upstreamToolCaller) emitSubCallRefused(serverName, toolName, requestID string, args map[string]interface{}, refusal error, startTime time.Time, duration time.Duration) {
+//
+// ctx is the sub-call's context carrying its audit.Attempt (Spec 107): the
+// `authz deny` was written by the caller at the refusing gate, so the funnel
+// this goes through writes no further audit line for a blocked status.
+func (u *upstreamToolCaller) emitSubCallRefused(ctx context.Context, serverName, toolName, requestID string, args map[string]interface{}, refusal error, startTime time.Time, duration time.Duration) {
if u.proxy == nil {
return
}
- u.proxy.emitActivityToolCallCompleted(
+ u.proxy.emitActivityToolCallCompleted(ctx,
serverName, toolName, u.sessionID, requestID, string(storage.ActivitySourceInternal),
storage.ActivityStatusBlocked, refusal.Error(), duration.Milliseconds(), args, "", false,
// The policy gate refused this before dispatch, so there IS no response
@@ -1117,6 +1207,26 @@ func (u *upstreamToolCaller) policyRefusal(serverName, toolName string) error {
// policyRefusalFor is policyRefusal over an already-evaluated gate. gated is
// dispatchGate's second result: false means no gate was evaluated (no
// storage) and nothing is refused.
+// policyRefusalReasonKey classifies the refusal policyRefusalFor returns
+// onto the closed telemetry.BlockReason* enum, mirroring its branch order so
+// the audit `authz deny` reason always matches the refusal the script saw.
+func policyRefusalReasonKey(gate toolGate) string {
+ switch {
+ case gate.serverConfig == nil:
+ // Only the storage-error branch refuses here (an unknown server
+ // falls through to "server not found").
+ return telemetry.BlockReasonToolNotCallable
+ case gate.serverQuarantined():
+ return telemetry.BlockReasonServerQuarantined
+ case gate.lockStatus == storage.ToolApprovalStatusPending:
+ return telemetry.BlockReasonToolPendingApproval
+ case gate.lockStatus == storage.ToolApprovalStatusChanged:
+ return telemetry.BlockReasonToolChanged
+ default:
+ return telemetry.BlockReasonToolNotCallable
+ }
+}
+
func policyRefusalFor(gate toolGate, gated bool) error {
if !gated {
return nil
diff --git a/internal/server/mcp_routing.go b/internal/server/mcp_routing.go
index d846b61c2..707e3142c 100644
--- a/internal/server/mcp_routing.go
+++ b/internal/server/mcp_routing.go
@@ -15,6 +15,7 @@ import (
mcpserver "github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/branding"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
@@ -416,15 +417,44 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
requestID = mintActivityRequestID(serverName, toolName)
}
+ // Get arguments from the request (pure; read here so the audit
+ // attempt below can hash them before the first gate).
+ args := request.GetArguments()
+
+ // Spec 107 T103: the audit attempt, installed BEFORE the first gate.
+ // The operation is the tier this catalog entry's annotations derive
+ // (the same tier the permission gate below authorizes against).
+ profileSlug, profileScope := p.resolveActiveProfile(ctx)
+ {
+ var auditClientName, auditClientVersion string
+ if sessionID != "" {
+ if sessInfo := p.sessionStore.GetSession(sessionID); sessInfo != nil {
+ auditClientName, auditClientVersion = sessInfo.ClientName, sessInfo.ClientVersion
+ }
+ }
+ ctx = p.installAuditAttempt(ctx, auditAttemptSpec{
+ RequestID: requestID,
+ SessionID: sessionID,
+ Server: serverName,
+ Tool: toolName,
+ Operation: contracts.ToolVariantToOperationType[contracts.DeriveCallWith(annotations)],
+ Surface: auditSurfaceDirect,
+ ClientName: auditClientName,
+ ClientVersion: auditClientVersion,
+ Profile: profileSlug,
+ Args: args,
+ })
+ }
+
// Spec 057 / Profiles v2: the active profile (token pin > URL > session
// set_profile) gates direct-mode dispatch exactly as it gates
// call_tool_* (mcp.go handleCallToolVariant). It runs independently of
// the agent-token gates below so an unauthenticated /mcp/p/
// connection is filtered too, and it runs FIRST so a profile-pinned
// token cannot reach a server outside its pin through this routing mode.
- if _, profileScope := p.resolveActiveProfile(ctx); profileScope != nil && !profileScope.Allows(serverName) {
+ if profileScope != nil && !profileScope.Allows(serverName) {
errMsg := fmt.Sprintf("server '%s' is not in profile '%s'", serverName, profileScope.Name)
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonProfileScope)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonProfileScope)
return mcp.NewToolResultError(errMsg), nil
}
@@ -438,7 +468,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// since issue #969, no availability counter either. Emit the
// same policy decision the call_tool_* variants emit at the
// equivalent gate so the funnel has no blind spot.
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonTokenScope)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonTokenScope)
return mcp.NewToolResultError(errMsg), nil
}
@@ -451,13 +481,11 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
if !authCtx.HasPermission(requiredPerm) {
errMsg := fmt.Sprintf("Permission denied: token does not have '%s' permission required for tool '%s:%s'", requiredPerm, serverName, toolName)
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonTokenPermission)
return mcp.NewToolResultError(errMsg), nil
}
}
- // Get arguments from the request
- args := request.GetArguments()
enrichedArgs := injectAuthMetadata(ctx, args)
// Enforce direct-mode callability before emitting a tool-started event or
@@ -467,7 +495,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// pending/changed approval, or plain not-callable) rather than from
// this one funnel site — see directBlockReasonKey.
if blocked, reasonKey := p.directToolCallabilityBlockWithReason(ctx, serverName, toolName, enrichedArgs); blocked != nil {
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", "direct tool is not callable", reasonKey)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", "direct tool is not callable", reasonKey)
return blocked, nil
}
@@ -501,6 +529,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
zap.String("server_name", serverName),
zap.String("tool_name", toolName),
zap.String("detail", detail))
+ auditNoteErrorClass(ctx, audit.ErrorClassValidation)
// The started/completed-error PAIR, not a bare rejection. A call
// rejected here never reaches the unconditional
@@ -509,8 +538,8 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// observability blind spot issue #969 established this handler must
// not have. Shapes match the sibling upstream-error emission a few
// lines down, so the two are one series to a consumer.
- p.emitActivityToolCallStarted(serverName, toolName, sessionID, requestID, "mcp", enrichedArgs)
- p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", errMsg,
+ p.emitActivityToolCallStarted(ctx, serverName, toolName, sessionID, requestID, "mcp", enrichedArgs)
+ p.emitActivityToolCallCompleted(ctx, serverName, toolName, sessionID, requestID, "mcp", "error", errMsg,
time.Since(startTime).Milliseconds(), enrichedArgs, "", false, "", nil,
contracts.ContentTrustForTool(annotations), "", 0, 0, "", nil, "")
@@ -527,7 +556,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
p.markSessionWorked(ctx, sessionID)
// Emit activity event
- p.emitActivityToolCallStarted(serverName, toolName, sessionID, requestID, "mcp", enrichedArgs)
+ p.emitActivityToolCallStarted(ctx, serverName, toolName, sessionID, requestID, "mcp", enrichedArgs)
// Call upstream. Spec 105 FR-009 "stale generation" (codex r3 D2):
// the callability gate above ran against the server's LIVE client;
@@ -561,6 +590,9 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// limiter seam already wrote the "rejected" one).
if limitErr, isShed := asShed(err); isShed {
recordShed(ctx, limitErr)
+ // Spec 107: the shed is this attempt's tool_call
+ // (outcome rejected), never a second authz.
+ p.auditToolCallShed(ctx, limitErr, durationMs)
return shedToolResult(limitErr), nil
}
// The generation moved before the transport: nothing reached
@@ -568,12 +600,14 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// as the call_tool_* refusal, with the started record closed.
if errors.Is(err, managed.ErrConnectionGenerationChanged) {
errMsg := unresolvedToolIdentityMessage(serverName, toolName, false)
- p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", errMsg, durationMs, enrichedArgs, "", false, "", nil, directContentTrust, "", 0, 0, "", nil, "")
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
+ auditNoteErrorClass(ctx, audit.ErrorClassUpstreamUnavailable)
+ p.emitActivityToolCallCompleted(ctx, serverName, toolName, sessionID, requestID, "mcp", "error", errMsg, durationMs, enrichedArgs, "", false, "", nil, directContentTrust, "", 0, 0, "", nil, "")
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonToolNotCallable)
return mcp.NewToolResultError(errMsg), nil
}
// Emit error activity
- p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", "error", err.Error(), durationMs, enrichedArgs, "", false, "", nil, directContentTrust, "", 0, 0, "", nil, "")
+ auditNoteError(ctx, err)
+ p.emitActivityToolCallCompleted(ctx, serverName, toolName, sessionID, requestID, "mcp", "error", err.Error(), durationMs, enrichedArgs, "", false, "", nil, directContentTrust, "", 0, 0, "", nil, "")
return mcp.NewToolResultError(fmt.Sprintf("Error calling %s:%s: %v", serverName, toolName, err)), nil
}
@@ -653,7 +687,7 @@ func (p *MCPProxyServer) makeDirectModeHandler(entry *directCatalogEntry) mcpser
// Spec 069 A1: pre-truncation sizes; result was measured before the truncation loop above.
routingResponseBytes := rawByteSize(result)
routingRequestBytes := rawByteSize(enrichedArgs)
- p.emitActivityToolCallCompleted(serverName, toolName, sessionID, requestID, "mcp", activityStatus, activityErrMsg, durationMs, enrichedArgs, responseText, truncated, toolVariant, nil, directContentTrust, "", routingRequestBytes, routingResponseBytes, "", nil, "")
+ p.emitActivityToolCallCompleted(ctx, serverName, toolName, sessionID, requestID, "mcp", activityStatus, activityErrMsg, durationMs, enrichedArgs, responseText, truncated, toolVariant, nil, directContentTrust, "", routingRequestBytes, routingResponseBytes, "", nil, "")
return forwarded, nil
}
diff --git a/internal/server/output_sanitisation.go b/internal/server/output_sanitisation.go
index 63b43d518..b045ecf06 100644
--- a/internal/server/output_sanitisation.go
+++ b/internal/server/output_sanitisation.go
@@ -110,7 +110,7 @@ func (p *MCPProxyServer) applyOutputSanitisation(ctx context.Context, serverName
}
if d.block {
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", d.reason, telemetry.BlockReasonOutputSanitisation)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, "blocked", d.reason, telemetry.BlockReasonOutputSanitisation)
return mcp.NewToolResultError("tool output blocked by sanitisation policy: " + d.reason)
}
@@ -135,7 +135,7 @@ func (p *MCPProxyServer) applyOutputSanitisation(ctx context.Context, serverName
action, reason := summariseSanitisation(redactedCount, redactedCats, strippedClasses)
// Redact/strip are not blocks — the availability counter ignores them —
// but the key is still declared so the funnel has no unclassified sites.
- p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, action, reason, telemetry.BlockReasonOutputSanitisation)
+ p.emitActivityPolicyDecision(ctx, serverName, toolName, sessionID, requestID, action, reason, telemetry.BlockReasonOutputSanitisation)
}
return nil
@@ -284,7 +284,7 @@ func (p *MCPProxyServer) applyPromptResultSanitisation(
}
if d.block {
- p.emitActivityPolicyDecision(serverName, promptName, sessionID, requestID,
+ p.emitActivityPolicyDecision(ctx, serverName, promptName, sessionID, requestID,
"blocked", d.reason, telemetry.BlockReasonOutputSanitisation)
return nil, true
}
@@ -325,7 +325,7 @@ func (p *MCPProxyServer) applyPromptResultSanitisation(
if redactedCount > 0 || len(strippedClasses) > 0 {
action, reason := summariseSanitisation(redactedCount, redactedCats, strippedClasses)
- p.emitActivityPolicyDecision(serverName, promptName, sessionID, requestID,
+ p.emitActivityPolicyDecision(ctx, serverName, promptName, sessionID, requestID,
action, reason, telemetry.BlockReasonOutputSanitisation)
}
return result, false
diff --git a/internal/server/preflight_telemetry_test.go b/internal/server/preflight_telemetry_test.go
index 444a4d831..6e355c994 100644
--- a/internal/server/preflight_telemetry_test.go
+++ b/internal/server/preflight_telemetry_test.go
@@ -522,13 +522,13 @@ func TestPreflightCounters_NoDiscoveryOmissionWhenNothingWithheld(t *testing.T)
func TestPreflightCounters_AvailabilityBlockByReason(t *testing.T) {
proxy, rt := newPreflightCountedProxy(t)
- proxy.emitActivityPolicyDecision("srv", "tool", "sess-1", "req-1",
+ proxy.emitActivityPolicyDecision(context.Background(), "srv", "tool", "sess-1", "req-1",
"blocked", "Server is quarantined for security review",
telemetry.BlockReasonServerQuarantined)
- proxy.emitActivityPolicyDecision("srv", "tool", "sess-1", "req-2",
+ proxy.emitActivityPolicyDecision(context.Background(), "srv", "tool", "sess-1", "req-2",
"blocked", "Server 'srv' is not in scope for this agent token",
telemetry.BlockReasonTokenScope)
- proxy.emitActivityPolicyDecision("srv", "tool", "sess-1", "req-3",
+ proxy.emitActivityPolicyDecision(context.Background(), "srv", "tool", "sess-1", "req-3",
"redacted", "1 secret redacted", telemetry.BlockReasonOutputSanitisation)
snap := preflightSnapshot(t, rt)
@@ -543,7 +543,7 @@ func TestPreflightCounters_AvailabilityBlockByReason(t *testing.T) {
func TestPreflightCounters_UnclassifiedBlockFoldsIntoOther(t *testing.T) {
proxy, rt := newPreflightCountedProxy(t)
- proxy.emitActivityPolicyDecision("acme-internal", "purge_all", "sess-1", "req-1",
+ proxy.emitActivityPolicyDecision(context.Background(), "acme-internal", "purge_all", "sess-1", "req-1",
"blocked", "Server 'acme-internal' is not in scope for this agent token",
"some-future-unregistered-key")
@@ -565,7 +565,7 @@ func TestPreflightCounters_OptOutRecordsNothing(t *testing.T) {
"query": diagQueryAll,
"read_only_only": true,
})
- proxy.emitActivityPolicyDecision("srv", "tool", "sess-1", "req-1",
+ proxy.emitActivityPolicyDecision(context.Background(), "srv", "tool", "sess-1", "req-1",
"blocked", "quarantined", telemetry.BlockReasonServerQuarantined)
snap := preflightSnapshot(t, rt)
diff --git a/internal/server/replay_audit_test.go b/internal/server/replay_audit_test.go
new file mode 100644
index 000000000..82e58f7be
--- /dev/null
+++ b/internal/server/replay_audit_test.go
@@ -0,0 +1,363 @@
+package server
+
+// replay_audit_test.go — round-2 cross-review regression (Spec 107 PR-D):
+// POST /api/v1/tool-calls/{id}/replay reaches a (server, tool) pair like
+// every other upstream dispatch path (FR-012), so it must write exactly one
+// `authz` line and one `tool_call` line per replay. Before this fix,
+// Server.ReplayToolCall delegated straight to runtime.ReplayToolCall, which
+// calls the managed client directly with no audit.Attempt installed — every
+// replay dispatch produced zero audit lines.
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/mark3labs/mcp-go/mcp"
+ mcpserver "github.com/mark3labs/mcp-go/server"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
+)
+
+// startRuntimeCountingUpstream is startCountingUpstream's counterpart for
+// the Runtime's OWN upstream manager (rt.UpstreamManager()), which is a
+// distinct instance from the MCPProxyServer's — runtime.ReplayToolCall
+// dispatches through the former, every other test in this package through
+// the latter.
+func startRuntimeCountingUpstream(t *testing.T, proxy *MCPProxyServer, server, tool string) (url string, calls *upstreamCalls) {
+ t.Helper()
+ t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
+
+ mcpSrv := mcpserver.NewMCPServer(server, "1.0.0-test", mcpserver.WithToolCapabilities(true))
+ uc := &upstreamCalls{}
+ mcpSrv.AddTool(mcp.Tool{Name: tool, Description: "Replay target", InputSchema: mcp.ToolInputSchema{Type: "object"}},
+ func(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ uc.record(request.Params.Name)
+ return mcp.NewToolResultText("ok"), nil
+ })
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ httpSrv := &http.Server{Handler: mcpserver.NewStreamableHTTPServer(mcpSrv), ReadHeaderTimeout: 5 * time.Second}
+ go func() { _ = httpSrv.Serve(ln) }()
+ t.Cleanup(func() { _ = httpSrv.Shutdown(context.Background()) })
+ return fmt.Sprintf("http://%s", ln.Addr().String()), uc
+}
+
+// startRuntimeBlockingUpstream is startRuntimeCountingUpstream's variant for
+// proving write ORDER: the tool handler signals reached once it is entered
+// and then blocks until release is closed, so a test can inspect the audit
+// sink while the upstream dispatch is still in flight.
+func startRuntimeBlockingUpstream(t *testing.T, server, tool string) (url string, reached chan struct{}, release chan struct{}) {
+ t.Helper()
+ t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
+ reached = make(chan struct{})
+ release = make(chan struct{})
+
+ mcpSrv := mcpserver.NewMCPServer(server, "1.0.0-test", mcpserver.WithToolCapabilities(true))
+ mcpSrv.AddTool(mcp.Tool{Name: tool, Description: "Replay target", InputSchema: mcp.ToolInputSchema{Type: "object"}},
+ func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ close(reached)
+ <-release
+ return mcp.NewToolResultText("ok"), nil
+ })
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ httpSrv := &http.Server{Handler: mcpserver.NewStreamableHTTPServer(mcpSrv), ReadHeaderTimeout: 5 * time.Second}
+ go func() { _ = httpSrv.Serve(ln) }()
+ t.Cleanup(func() { _ = httpSrv.Shutdown(context.Background()) })
+ return fmt.Sprintf("http://%s", ln.Addr().String()), reached, release
+}
+
+// seedReplayableCall registers the server identity and one persisted
+// ToolCallRecord runtime.ReplayToolCall's own lookup (Server.ReplayToolCall's
+// pre-lookup included) can find by ID, and connects the runtime's upstream
+// manager to url so the replay dispatch actually reaches an upstream.
+func seedReplayableCall(t *testing.T, proxy *MCPProxyServer, mainSrv *Server, server, tool, url string) string {
+ t.Helper()
+ return seedReplayableCallWithAnnotations(t, proxy, mainSrv, server, tool, url, nil)
+}
+
+// seedReplayableCallWithAnnotations is seedReplayableCall with control over
+// the persisted record's annotations snapshot, for asserting the replayed
+// audit line's `operation` tier.
+func seedReplayableCallWithAnnotations(t *testing.T, proxy *MCPProxyServer, mainSrv *Server, server, tool, url string, annotations *config.ToolAnnotations) string {
+ t.Helper()
+ sm := mainSrv.runtime.StorageManager()
+
+ serverCfg := &config.ServerConfig{Name: server, URL: url, Protocol: "streamable-http", Enabled: true}
+ identity, err := sm.RegisterServerIdentity(serverCfg, "")
+ require.NoError(t, err)
+
+ require.NoError(t, mainSrv.runtime.UpstreamManager().AddServerConfig(server, serverCfg))
+ require.NoError(t, mainSrv.runtime.UpstreamManager().ConnectAll(context.Background()))
+
+ callID := "replay-fixture-1"
+ require.NoError(t, sm.RecordToolCall(&storage.ToolCallRecord{
+ ID: callID,
+ ServerID: identity.ID,
+ ServerName: server,
+ ToolName: tool,
+ Arguments: map[string]interface{}{"q": "original"},
+ Timestamp: time.Now(),
+ Annotations: annotations,
+ }))
+ return callID
+}
+
+func TestReplayToolCall_WritesAuthzAllowThenToolCallSuccess(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url, calls := startRuntimeCountingUpstream(t, proxy, "a", "erase")
+ callID := seedReplayableCall(t, proxy, mainSrv, "a", "erase", url)
+
+ // Wait for the connection to settle (real network dial + MCP handshake).
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ result, err := mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Empty(t, result.Error)
+ assert.Equal(t, int64(1), calls.count.Load(), "control: the replay must actually dispatch")
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2, "one authz + one tool_call per replayed call")
+ assert.Equal(t, "authz", lines[0]["event"])
+ assert.Equal(t, "allow", lines[0]["decision"])
+ assert.Equal(t, "rest", lines[0]["surface"])
+ assert.Equal(t, "a", lines[0]["server"])
+ assert.Equal(t, "erase", lines[0]["tool"])
+
+ assert.Equal(t, "tool_call", lines[1]["event"])
+ assert.Equal(t, "success", lines[1]["outcome"])
+ assert.Equal(t, lines[0]["request_id"], lines[1]["request_id"])
+}
+
+func TestReplayToolCall_UnresolvedIDDelegatesUnaudited(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ _, err := mainSrv.ReplayToolCall(context.Background(), "does-not-exist", nil)
+ require.Error(t, err)
+ assert.Empty(t, sink.decoded(t), "an id that never resolved to a (server, tool) pair writes no line")
+}
+
+// TestReplayToolCall_OperationFromAnnotations is a round-3 cross-review
+// regression (Spec 107 PR-D): the replayed record's own annotations
+// snapshot must supply the audit line's `operation` tier — before this fix
+// installAuditAttempt was never given an Operation, so both the `authz` and
+// `tool_call` lines reported `operation:"unknown"` even for a known
+// destructive tool.
+func TestReplayToolCall_OperationFromAnnotations(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url, calls := startRuntimeCountingUpstream(t, proxy, "a", "erase")
+ callID := seedReplayableCallWithAnnotations(t, proxy, mainSrv, "a", "erase", url,
+ &config.ToolAnnotations{DestructiveHint: boolPtr(true)})
+
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ result, err := mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, int64(1), calls.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "destructive", lines[0]["operation"], "authz line must report the recorded tool's actual tier")
+ assert.Equal(t, "destructive", lines[1]["operation"], "tool_call line must report the recorded tool's actual tier")
+}
+
+// TestReplayToolCall_OperationUnknownWithoutAnnotations proves the missing-
+// annotations case defaults to "unknown" rather than tierForAnnotations'
+// found=false "destructive" default, which is an AUTHORIZATION fail-closed
+// and would misrepresent an unresolved tier as maximally risky on the audit
+// line (mirrors mcp.go's own choice for the live dispatch path).
+func TestReplayToolCall_OperationUnknownWithoutAnnotations(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url, calls := startRuntimeCountingUpstream(t, proxy, "a", "erase")
+ callID := seedReplayableCall(t, proxy, mainSrv, "a", "erase", url)
+
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ result, err := mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ assert.Equal(t, int64(1), calls.count.Load())
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "unknown", lines[0]["operation"])
+}
+
+// TestReplayToolCall_AuthzWrittenBeforeUpstreamDispatch is a round-3
+// cross-review regression (Spec 107 PR-D): FR-012 requires `decision: allow`
+// to be written after the last gate and before the upstream call, so a
+// crash mid-dispatch still leaves an authorization record — every other
+// dispatch path gets this from emitActivityToolCallStarted. Before this fix,
+// Server.ReplayToolCall wrote the `authz allow` line only via auditToolCall's
+// completion-time backfill, AFTER the upstream call returned.
+func TestReplayToolCall_AuthzWrittenBeforeUpstreamDispatch(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url, reached, release := startRuntimeBlockingUpstream(t, "a", "erase")
+ callID := seedReplayableCall(t, proxy, mainSrv, "a", "erase", url)
+ // Always unblock the upstream handler on the way out, even if an
+ // assertion below fails early via require/t.Fatal — otherwise the
+ // blocked goroutine's connection is never released and t.Cleanup's
+ // httpSrv.Shutdown (registered inside startRuntimeBlockingUpstream)
+ // hangs forever waiting for it.
+ var releaseOnce sync.Once
+ t.Cleanup(func() { releaseOnce.Do(func() { close(release) }) })
+
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ _, _ = mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ }()
+
+ select {
+ case <-reached:
+ case <-time.After(5 * time.Second):
+ t.Fatal("upstream handler was never reached")
+ }
+
+ // The upstream call is now blocked inside the handler, before it can
+ // possibly have returned to a completion-time backfill. The authz line
+ // must already be on the sink.
+ lines := sink.decoded(t)
+ require.Len(t, lines, 1, "authz allow must be written before the upstream call, not backfilled after it")
+ assert.Equal(t, "authz", lines[0]["event"])
+ assert.Equal(t, "allow", lines[0]["decision"])
+
+ releaseOnce.Do(func() { close(release) })
+ <-done
+}
+
+// startRuntimeFailingUpstream is startRuntimeCountingUpstream's variant for a
+// tool that answers with an upstream-level failure. isRPCError selects which
+// of the two ways MCP has for a call to fail: true returns a Go error from
+// the handler (a JSON-RPC-level failure — the pre-existing `err != nil`
+// case), false returns mcp.NewToolResultError (a normal RPC response with
+// Result.IsError:true — the protocol's convention for a TOOL failure, which
+// callErr never sees).
+func startRuntimeFailingUpstream(t *testing.T, server, tool string, isRPCError bool) (url string) {
+ t.Helper()
+ t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
+
+ mcpSrv := mcpserver.NewMCPServer(server, "1.0.0-test", mcpserver.WithToolCapabilities(true))
+ mcpSrv.AddTool(mcp.Tool{Name: tool, Description: "Replay target", InputSchema: mcp.ToolInputSchema{Type: "object"}},
+ func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ if isRPCError {
+ return nil, fmt.Errorf("boom")
+ }
+ return mcp.NewToolResultError("boom"), nil
+ })
+
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ httpSrv := &http.Server{Handler: mcpserver.NewStreamableHTTPServer(mcpSrv), ReadHeaderTimeout: 5 * time.Second}
+ go func() { _ = httpSrv.Serve(ln) }()
+ t.Cleanup(func() { _ = httpSrv.Shutdown(context.Background()) })
+ return fmt.Sprintf("http://%s", ln.Addr().String())
+}
+
+// TestReplayToolCall_FailedUpstreamCallAuditsAsError is a round-4
+// cross-review regression (Spec 107 PR-D): runtime.ReplayToolCall folds a
+// non-shed upstream failure into the returned record's own Error field and
+// hands back a NIL Go error (only a limiter shed returns non-nil) — before
+// this fix Server.ReplayToolCall's outcome switch only branched on `err`, so
+// every failed replay of this shape fell into `default` and was audited as
+// `outcome:"success"`.
+func TestReplayToolCall_FailedUpstreamCallAuditsAsError(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url := startRuntimeFailingUpstream(t, "a", "erase", true)
+ callID := seedReplayableCall(t, proxy, mainSrv, "a", "erase", url)
+
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ result, err := mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ require.NoError(t, err, "a non-shed upstream failure is still a completed replay, not a Server.ReplayToolCall error")
+ require.NotNil(t, result)
+ require.NotEmpty(t, result.Error)
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "tool_call", lines[1]["event"])
+ assert.Equal(t, "error", lines[1]["outcome"], "a failed replay must never be audited as outcome:success")
+}
+
+// TestReplayToolCall_ToolLevelIsErrorResponseAuditsAsError is
+// TestReplayToolCall_FailedUpstreamCallAuditsAsError's companion for the
+// OTHER shape a tool failure takes on the wire: the MCP protocol answers a
+// tool-level failure as a normal (err==nil) RPC response with
+// Result.IsError:true — which neither callErr nor runtime.ReplayToolCall's
+// own record.Error field (only ever set from callErr) ever observes.
+func TestReplayToolCall_ToolLevelIsErrorResponseAuditsAsError(t *testing.T) {
+ proxy, rt := createTestProxyWithRuntime(t, nil)
+ sink := &recordingAuditSink{}
+ proxy.auditSink = sink
+ mainSrv := &Server{runtime: rt, mcpProxy: proxy}
+
+ url := startRuntimeFailingUpstream(t, "a", "erase", false)
+ callID := seedReplayableCall(t, proxy, mainSrv, "a", "erase", url)
+
+ require.Eventually(t, func() bool {
+ client, ok := rt.UpstreamManager().GetClient("a")
+ return ok && client != nil && client.IsConnected()
+ }, 5*time.Second, 20*time.Millisecond)
+
+ result, err := mainSrv.ReplayToolCall(context.Background(), callID, nil)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Empty(t, result.Error, "control: an IsError:true tool response does NOT populate record.Error")
+
+ lines := sink.decoded(t)
+ require.Len(t, lines, 2)
+ assert.Equal(t, "tool_call", lines[1]["event"])
+ assert.Equal(t, "error", lines[1]["outcome"], "an IsError:true tool response must never be audited as outcome:success")
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 48dad25d4..4b51177f8 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -20,6 +20,7 @@ import (
"github.com/mark3labs/mcp-go/server"
"go.uber.org/zap"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/connect"
@@ -44,6 +45,7 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/transport"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/updatecheck"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/core"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/limiter"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream/types"
"github.com/smart-mcp-proxy/mcpproxy-go/web"
)
@@ -168,11 +170,26 @@ type Server struct {
// MCP-32: observability manager (Prometheus /metrics + OTLP tracing).
// Nil when disabled; config-gated and off by default.
observability *observability.Manager
+
+ // auditSink is the Spec 107 audit line writer (WithAuditSink); nil in the
+ // personal-edition default and whenever audit_log is off.
+ auditSink audit.Sink
+}
+
+// ServerOption customises a Server at construction time (Spec 107 T103).
+// Distinct from MCPProxyOption (mcp.go), which customises the MCP proxy the
+// Server owns. Every existing caller passes none.
+type ServerOption func(*Server)
+
+// WithAuditSink installs the Spec 107 audit sink. nil (the personal-edition
+// default when audit_log is off) keeps every audit funnel a no-op.
+func WithAuditSink(sink audit.Sink) ServerOption {
+ return func(s *Server) { s.auditSink = sink }
}
// NewServer creates a new server instance
-func NewServer(cfg *config.Config, logger *zap.Logger) (*Server, error) {
- return NewServerWithConfigPath(cfg, "", logger)
+func NewServer(cfg *config.Config, logger *zap.Logger, opts ...ServerOption) (*Server, error) {
+ return NewServerWithConfigPath(cfg, "", logger, opts...)
}
// buildObservabilityConfig maps the file-level observability config (MCP-32)
@@ -209,7 +226,7 @@ func buildObservabilityConfig(cfg *config.Config) observability.Config {
}
// NewServerWithConfigPath creates a new server instance with explicit config path tracking
-func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap.Logger) (*Server, error) {
+func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap.Logger, opts ...ServerOption) (*Server, error) {
rt, err := runtime.New(cfg, configPath, logger)
if err != nil {
return nil, err
@@ -287,6 +304,39 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap.
infoScanSettleTimeout: informationalScanSettleTimeout,
infoScanSweepDelay: baselineSweepStartDelay,
}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(server)
+ }
+ }
+
+ // Spec 107 T109: the audit sink's always-on write-failure counter, mirrored
+ // to both `mcpproxy doctor` (works with metrics disabled) and Prometheus
+ // (metrics enabled only). Registered only when a sink exists - nil means
+ // audit_log is off, so there is nothing to report.
+ if server.auditSink != nil {
+ mgmtService.AddRuntimeWarningSource(func() []string {
+ if n := server.auditSink.WriteFailures(); n > 0 {
+ return []string{fmt.Sprintf("audit_log: %d write failures since start", n)}
+ }
+ return nil
+ })
+ // Spec 107 FR-015: the defence-in-depth whole-line sanitizer's hit
+ // counter, mirrored the same way as the write-failure counter above -
+ // a nonzero count means a builder bug let a credential-shaped string
+ // past per-field masking (the pass still masked and wrote the line).
+ mgmtService.AddRuntimeWarningSource(func() []string {
+ if n := server.auditSink.SanitizerHits(); n > 0 {
+ return []string{fmt.Sprintf("audit_log: %d defence-in-depth sanitizer hits since start (a builder bug may be leaking credential-shaped values into audit lines)", n)}
+ }
+ return nil
+ })
+ if obsManager != nil && obsManager.Metrics() != nil {
+ obsManager.Metrics().RegisterAuditSink(server.auditSink)
+ obsManager.Metrics().RegisterAuditSanitizer(server.auditSink)
+ }
+ }
+
// Record the servers this process started with: they are the baseline
// sweep's job, and anything that shows up later is a NEW admission that gets
// its own informational scan. Seeded from the startup config (available
@@ -319,6 +369,9 @@ func NewServerWithConfigPath(cfg *config.Config, configPath string, logger *zap.
// MCP-32: give the MCP proxy access to observability for tool-call metrics
// and OTLP spans.
mcpProxy.SetObservability(obsManager)
+ // Spec 107 T103: the audit sink reaches the dispatch funnels through the
+ // proxy; nil keeps them no-ops.
+ mcpProxy.auditSink = server.auditSink
server.mcpProxy = mcpProxy
@@ -1070,7 +1123,12 @@ func (s *Server) Start(ctx context.Context) error {
// mcp_describe_direct.go, mcp_visibility.go,
// observability_edition_server.go, auth.AuthorizeServerOp and the
// `authCtx != nil && !authCtx.IsAdmin()` gates in mcp.go.
+//
+// Spec 107 T103: the context is also tagged transport.ConnectionSourceStdio.
+// Without the tag GetConnectionSource defaults to TCP and the audit line
+// would report the stdio operator as caller.kind: api_key.
func stdioAuthContext(ctx context.Context) context.Context {
+ ctx = transport.TagConnectionContext(ctx, transport.ConnectionSourceStdio)
return auth.WithAuthContext(ctx, auth.AdminContext())
}
@@ -3711,8 +3769,127 @@ func (s *Server) GetServerToolCalls(serverName string, limit int) ([]*contracts.
// ReplayToolCall replays a tool call with modified arguments. ctx is the
// caller's request context: it governs the concurrency-limiter queue wait as
// well as the upstream call (spec 093 FR-005).
+//
+// Spec 107 FR-012 (round-2 cross-review finding, PR-D): replay reaches a
+// (server, tool) pair like every other upstream dispatch path, so it MUST
+// produce exactly one `authz` line and, unless shed by the limiter, one
+// `tool_call` line — this endpoint previously wrote neither, because
+// runtime.ReplayToolCall calls the managed client directly and has no
+// access to the server's audit sink. The lookup here is best-effort and
+// duplicates runtime.ReplayToolCall's own (a second, cheap read of the same
+// stored record): if it fails, the call is delegated unaudited exactly as
+// before — runtime.ReplayToolCall's own not-found error is authoritative,
+// and no `(server, tool)` pair was ever resolved to audit.
func (s *Server) ReplayToolCall(ctx context.Context, id string, arguments map[string]interface{}) (*contracts.ToolCallRecord, error) {
- return s.runtime.ReplayToolCall(ctx, id, arguments)
+ original, lookupErr := s.runtime.GetToolCallByID(id)
+ if lookupErr != nil || original == nil || s.mcpProxy == nil {
+ return s.runtime.ReplayToolCall(ctx, id, arguments)
+ }
+
+ callArgs := arguments
+ if callArgs == nil {
+ callArgs = original.Arguments
+ }
+
+ // Spec 107 (round-3 cross-review finding, PR-D): the persisted record's
+ // own annotations snapshot is the canonical target tier here — the same
+ // signal tierForAnnotations derives from a live gate's identity lookup
+ // elsewhere — so a replayed destructive/write call is not reported as
+ // `operation:"unknown"` when the snapshot is available. Left empty (and
+ // so defaulted to "unknown" by installAuditAttempt) when the record
+ // carries no annotations at all: mirrors mcp.go's own choice not to use
+ // tierForAnnotations' found=false "destructive" default for the AUDIT
+ // line — that default is an AUTHORIZATION fail-closed, and would
+ // misrepresent an unresolved tier as maximally risky rather than simply
+ // unknown to the proxy.
+ var operation string
+ if original.Annotations != nil {
+ operation = tierForAnnotations(toConfigToolAnnotations(original.Annotations), true)
+ }
+ ctx = s.mcpProxy.installAuditAttempt(ctx, auditAttemptSpec{
+ RequestID: mintCorrelationID(original.ServerName, original.ToolName),
+ Server: original.ServerName,
+ Tool: original.ToolName,
+ Operation: operation,
+ Surface: auditSurfaceREST,
+ Args: callArgs,
+ })
+ // Spec 107 FR-012: `decision: allow` MUST be written after the last gate
+ // and before the upstream call (round-3 cross-review finding, PR-D) —
+ // auditToolCall's own backfill only runs on completion, which would
+ // leave a replay that crashes mid-dispatch with no authorization record
+ // at all, unlike every other dispatch path (emitActivityToolCallStarted
+ // writes `allow` synchronously before its own upstream call).
+ s.mcpProxy.auditAuthz(ctx, "allow", "")
+
+ startTime := time.Now()
+ result, err := s.runtime.ReplayToolCall(ctx, id, arguments)
+ durationMs := time.Since(startTime).Milliseconds()
+
+ var limitErr *limiter.LimitError
+ switch {
+ case errors.As(err, &limitErr) &&
+ (limitErr.Reason == limiter.ReasonQueueFull || limitErr.Reason == limiter.ReasonQueueTimeout):
+ // Spec 093 FR-011: a shed never reached the upstream, so it is the
+ // tool_call half of the authz-allow pair, never a second authz —
+ // mirrors auditToolCallShed's use at every other dispatch site.
+ s.mcpProxy.auditToolCallShed(ctx, limitErr, durationMs)
+ case err != nil:
+ s.mcpProxy.auditToolCall(ctx, "error", "", audit.ErrorClassOf(err), durationMs, nil, nil)
+ case result != nil && result.Error != "":
+ // Round-4 cross-review finding, PR-D: runtime.ReplayToolCall folds an
+ // upstream tool failure into the record's own Error field and
+ // returns a NIL Go error (only a limiter shed returns non-nil) — so
+ // this branch, not `err != nil` above, is what a failed replay hits.
+ // Without it every failed replay fell into `default` and was
+ // audited as `outcome:"success"`.
+ s.mcpProxy.auditToolCall(ctx, "error", "", audit.ErrorClassOf(errors.New(result.Error)), durationMs, nil, nil)
+ case result != nil && isReplayResponseError(result.Response):
+ // Round-4 cross-review finding, PR-D companion case: an upstream
+ // tool-level failure (mcp.CallToolResult.IsError, e.g.
+ // mcp.NewToolResultError) is a successful RPC by MCP protocol
+ // convention — callErr is nil AND runtime.ReplayToolCall's own
+ // record.Error stays empty (it is only ever set from callErr) — so
+ // this is the one remaining path a failed replay could still be
+ // misaudited as `outcome:"success"` through. Mirrors the
+ // result.IsError check every other completion path in this package
+ // already makes (see emitActivityPolicyDecision's callers in mcp.go).
+ s.mcpProxy.auditToolCall(ctx, "error", "", audit.ErrorClassUpstreamError, durationMs, nil, nil)
+ default:
+ s.mcpProxy.auditToolCall(ctx, "success", "", "", durationMs, nil, nil)
+ }
+
+ return result, err
+}
+
+// isReplayResponseError reports whether a replayed record's Response is an
+// mcp.CallToolResult carrying IsError:true — an upstream tool-level failure,
+// which the MCP protocol returns as a normal (err==nil) RPC response, so
+// neither runtime.ReplayToolCall's callErr nor its record.Error field ever
+// see it (round-4 cross-review finding, PR-D). resp is untyped because
+// contracts.ToolCallRecord.Response is interface{}; anything else (a nil
+// Response, or a differently-shaped value from a code path that never
+// dispatched) is not an error by this check.
+func isReplayResponseError(resp interface{}) bool {
+ result, ok := resp.(*mcp.CallToolResult)
+ return ok && result != nil && result.IsError
+}
+
+// toConfigToolAnnotations adapts a persisted ToolCallRecord's annotations
+// snapshot (contracts.ToolAnnotation) to the config.ToolAnnotations shape
+// tierForAnnotations consumes. Field sets are identical by construction; nil
+// in, nil out.
+func toConfigToolAnnotations(a *contracts.ToolAnnotation) *config.ToolAnnotations {
+ if a == nil {
+ return nil
+ }
+ return &config.ToolAnnotations{
+ Title: a.Title,
+ ReadOnlyHint: a.ReadOnlyHint,
+ DestructiveHint: a.DestructiveHint,
+ IdempotentHint: a.IdempotentHint,
+ OpenWorldHint: a.OpenWorldHint,
+ }
}
// GetToolCallsBySession retrieves tool calls filtered by session ID. scope
diff --git a/internal/server/serveredition_wire.go b/internal/server/serveredition_wire.go
index 8604faf2b..88c23cb2e 100644
--- a/internal/server/serveredition_wire.go
+++ b/internal/server/serveredition_wire.go
@@ -49,6 +49,10 @@ func wireServerEditionOAuth(s *Server, httpAPIServer *httpapi.Server) {
// Spec 107 T086: the same convert+mask composition core GET /activity
// applies, for GET /api/v1/user/activity to reuse.
ProjectActivity: httpAPIServer.ActivityProjector(),
+
+ // Spec 107 T103: the same sink the dispatch funnels write through,
+ // for the auth_event emitter (T107). nil when audit_log is off.
+ AuditSink: s.auditSink,
}
if err := serveredition.SetupAll(deps); err != nil {
diff --git a/internal/server/upstream_test.go b/internal/server/upstream_test.go
index a127c5e9e..a7a78284f 100644
--- a/internal/server/upstream_test.go
+++ b/internal/server/upstream_test.go
@@ -196,10 +196,24 @@ func TestUpstreamServersListOperation(t *testing.T) {
t.Fatal("handleUpstreamServers returned nil result")
}
- // Should be very fast for list operation
- if duration > 100*time.Millisecond {
- t.Fatalf("handleUpstreamServers list took too long: %v (should be < 100ms)", duration)
+ // Should be fast for a list operation — see upstreamServersListCeiling's
+ // doc comment for why this is a generous ceiling rather than a tight
+ // latency assertion.
+ if duration > upstreamServersListCeiling {
+ t.Fatalf("handleUpstreamServers list took too long: %v (should be < %v)", duration, upstreamServersListCeiling)
}
t.Logf("handleUpstreamServers list completed in %v", duration)
}
+
+// upstreamServersListCeiling is a ceiling with real headroom, not the
+// observed budget for the `list` operation itself. It exists to catch an
+// architectural regression (e.g. `list` starting to make an upstream call or
+// a full index scan) rather than to pin the handler to a specific latency —
+// the previous 100ms bound had no slack on shared/loaded CI runners and was
+// observed failing at 157ms on macOS/windows-latest while ubuntu-latest
+// passed on the identical commit (PR #1296), matching this repo's documented
+// history of timing-sensitive unit tests flaking on noisier macOS/Windows
+// GitHub Actions runners (see the ceiling comment on preflightBenchPerOpCeiling
+// in internal/httpapi/preflight_bench_test.go for the same pattern).
+const upstreamServersListCeiling = 500 * time.Millisecond
diff --git a/internal/serveredition/auth/auth_event.go b/internal/serveredition/auth/auth_event.go
new file mode 100644
index 000000000..1a35ca858
--- /dev/null
+++ b/internal/serveredition/auth/auth_event.go
@@ -0,0 +1,102 @@
+//go:build server
+
+package auth
+
+// auth_event.go: the Spec 107 PR-D `auth_event` emitter (T107). It adapts
+// the handler's typed LoginResult (T044) into internal/audit's
+// AuthEventInput and writes exactly one line per terminal login attempt and
+// one per logout through the ONE audit.Sink the dispatch funnels write
+// through (serveredition.Dependencies.AuditSink, contracts/audit-line-
+// events.md). Identity is stage-dependent, never reason-dependent
+// (auditCallerFor): LoginResult.UserID set ⇒ the store was reached and a
+// record exists (session_user|session_admin + user_id); otherwise
+// LoginResult.EmailHash set ⇒ a verified email is known and the store was
+// not yet consulted (anonymous + email_hash); neither ⇒ anonymous.
+
+import (
+ "time"
+
+ "go.uber.org/zap"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+)
+
+// NewAuditEmitter returns a LoginResultObserver that writes one `auth_event`
+// line per call to sink. Install it directly on OAuthHandler.
+// LoginResultObserver (setup.go); nil sink is a no-op writer, so
+// audit_log:off costs nothing on the login/logout hot path. logger may be
+// nil (a build/marshal/write failure is then silently dropped, matching
+// audit.Sink's own "never block the caller" contract).
+func NewAuditEmitter(sink audit.Sink, logger *zap.SugaredLogger) func(LoginResult) {
+ return func(res LoginResult) {
+ if sink == nil {
+ return
+ }
+ line, err := audit.NewAuthEvent(audit.AuthEventInput{
+ Ts: time.Now(),
+ RequestID: res.RequestID,
+ // Login/logout are REST-only, always over the listener (never
+ // the tray socket or stdio) — contracts/audit-line-events.md's
+ // auth_event fixtures fix origin:local, source:api.
+ Origin: "local",
+ Source: "api",
+ Surface: res.Surface,
+ Reason: string(res.Reason),
+ Caller: auditCallerFor(res),
+ Flags: auditFlagsFor(res.Flags),
+ ClientIP: res.ClientIP,
+ })
+ if err != nil {
+ if logger != nil {
+ logger.Errorw("audit: failed to build auth_event line", "error", err, "request_id", res.RequestID, "reason", string(res.Reason))
+ }
+ return
+ }
+ b, err := line.JSON()
+ if err != nil {
+ if logger != nil {
+ logger.Errorw("audit: failed to marshal auth_event line", "error", err, "request_id", res.RequestID)
+ }
+ return
+ }
+ // A write failure is intentionally NOT logged here (round-3
+ // cross-review finding, PR-D): FR-018 caps runtime sink-failure
+ // logging at once per minute, and that cap lives on the sink's own
+ // WithFailureLogger (T109) — a per-request Warnw here would log
+ // every failed login/logout while a persistent disk/stdout failure
+ // lasts, bypassing the sink's rate limit entirely. The sink's
+ // always-on WriteFailures() counter still records every failure for
+ // `mcpproxy doctor` regardless of whether this call was logged.
+ _ = sink.Write(b)
+ }
+}
+
+// auditCallerFor derives the auth_event `caller` object from a LoginResult.
+func auditCallerFor(res LoginResult) audit.Caller {
+ if res.UserID != "" {
+ role := res.Role
+ kind := "session_user"
+ switch role {
+ case "admin":
+ kind = "session_admin"
+ default:
+ role = "user"
+ }
+ return audit.Caller{Kind: kind, UserID: res.UserID, Role: role, Provider: res.Provider}
+ }
+ if res.EmailHash != "" {
+ return audit.Caller{Kind: "anonymous", EmailHash: res.EmailHash}
+ }
+ return audit.Caller{Kind: "anonymous"}
+}
+
+func auditFlagsFor(flags []LoginFlag) []string {
+ if len(flags) == 0 {
+ return nil
+ }
+ out := make([]string, len(flags))
+ for i, f := range flags {
+ out[i] = string(f)
+ }
+ return out
+}
diff --git a/internal/serveredition/auth/auth_event_test.go b/internal/serveredition/auth/auth_event_test.go
new file mode 100644
index 000000000..e84d0f648
--- /dev/null
+++ b/internal/serveredition/auth/auth_event_test.go
@@ -0,0 +1,524 @@
+//go:build server
+
+package auth
+
+// Spec 107 T106 (PR-D) [compile-red until T107]: the auth_event emitter
+// (NewAuditEmitter, T107) installed on OAuthHandler.LoginResultObserver
+// writes exactly one `auth_event` line per terminal login attempt (every
+// FR-013 reason, including authorization_denied via the PR-B refusal
+// fixtures and fault injection -> internal_error), one per logout, and none
+// for an abandoned redirect. Identity is stage-dependent, never
+// reason-dependent (contracts/audit-line-events.md "auth_event"):
+// - ok | logout | subject_mismatch | user_disabled | internal_error
+// (once the store has been reached and a record exists) carry
+// caller.user_id + caller.kind session_user|session_admin.
+// - domain_not_allowed | userinfo_subject_mismatch | provider_error raised
+// by the userinfo fetch after a verified ID token carry caller.email_hash
+// + caller.kind anonymous, never user_id.
+// - every other reason (state_invalid, authorization_denied,
+// discovery_failed, provider_error from discovery/token exchange,
+// id_token_invalid, nonce_mismatch, audience_mismatch, issuer_mismatch,
+// token_expired, email_missing, email_unverified, and internal_error
+// before the store is consulted) carries neither.
+//
+// This file re-drives the PR-B fixtures of oauth_handler_refusal_test.go
+// (same package: refusalRig, newOIDCRefusalRig, newRefusalRig,
+// refusalServerEditionConfig, refusalUser/refusalSub/refusalCallbackURI,
+// failingLoginStore/failingSessionCreator) against a captured audit.Sink and
+// validates every captured line against the binding wire schema
+// (contracts/audit-line.schema.json).
+//
+// Contract exercised here (tasks.md T106/T107):
+//
+// func NewAuditEmitter(audit.Sink, *zap.SugaredLogger) func(LoginResult)
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+
+ "github.com/santhosh-tekuri/jsonschema/v6"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/zap"
+ "go.uber.org/zap/zaptest/observer"
+
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/serveredition/users"
+ "github.com/smart-mcp-proxy/mcpproxy-go/tests/oauthserver"
+)
+
+// ---------------------------------------------------------------------------
+// capture sink
+// ---------------------------------------------------------------------------
+
+// captureSink is an audit.Sink that records every written line verbatim, for
+// assertion. It never fails a write.
+type captureSink struct {
+ mu sync.Mutex
+ lines [][]byte
+}
+
+func (s *captureSink) Write(line []byte) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ cp := append([]byte(nil), line...)
+ s.lines = append(s.lines, cp)
+ return nil
+}
+
+func (s *captureSink) WriteFailures() uint64 { return 0 }
+func (s *captureSink) SanitizerHits() uint64 { return 0 }
+func (s *captureSink) Close() error { return nil }
+
+func (s *captureSink) all() [][]byte {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([][]byte, len(s.lines))
+ copy(out, s.lines)
+ return out
+}
+
+func (s *captureSink) reset() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.lines = nil
+}
+
+// exactlyOne asserts the count invariant (SC-003: #auth_event(surface=login)
+// == #terminal login attempts) and returns the decoded line.
+func (s *captureSink) exactlyOne(t *testing.T) map[string]interface{} {
+ t.Helper()
+ lines := s.all()
+ require.Len(t, lines, 1, "exactly one auth_event line per terminal attempt, got %d: %s", len(lines), lines)
+ var m map[string]interface{}
+ require.NoError(t, json.Unmarshal(lines[0], &m))
+ return m
+}
+
+var _ audit.Sink = (*captureSink)(nil)
+
+// ---------------------------------------------------------------------------
+// schema validation
+// ---------------------------------------------------------------------------
+
+var authEventSchema *jsonschema.Schema
+
+// loadAuthEventSchema compiles the binding wire schema once per process.
+func loadAuthEventSchema(t *testing.T) *jsonschema.Schema {
+ t.Helper()
+ if authEventSchema != nil {
+ return authEventSchema
+ }
+ path := filepath.Join("..", "..", "..", "specs", "107-server-edition-sso-hardening", "contracts", "audit-line.schema.json")
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err, "reading %s", path)
+ var doc map[string]interface{}
+ require.NoError(t, json.Unmarshal(raw, &doc))
+ c := jsonschema.NewCompiler()
+ require.NoError(t, c.AddResource("mem://auth-event-t106.json", doc))
+ sch, err := c.Compile("mem://auth-event-t106.json")
+ require.NoError(t, err)
+ authEventSchema = sch
+ return sch
+}
+
+func assertValidatesAgainstSchema(t *testing.T, line map[string]interface{}) {
+ t.Helper()
+ assert.NoError(t, loadAuthEventSchema(t).Validate(line), "auth_event line must validate against the binding wire schema: %+v", line)
+}
+
+// ---------------------------------------------------------------------------
+// rig wiring: chain the PR-B result recorder with the T107 audit emitter so
+// existing refusalRig helpers (exactlyOne, approveFlow, …) stay usable while
+// every line the emitter writes is also captured.
+// ---------------------------------------------------------------------------
+
+func attachAuditCapture(rig *refusalRig) *captureSink {
+ sink := &captureSink{}
+ emit := NewAuditEmitter(sink, zap.NewNop().Sugar())
+ rec := rig.results
+ rig.handler.LoginResultObserver = func(res LoginResult) {
+ rec.observe(res)
+ emit(res)
+ }
+ return sink
+}
+
+// ---------------------------------------------------------------------------
+// common fields every auth_event line carries (contracts/audit-line-
+// events.md "Common keys").
+// ---------------------------------------------------------------------------
+
+func assertCommonAuthEventFields(t *testing.T, line map[string]interface{}, wantRequestID, wantSurface, wantReason string) {
+ t.Helper()
+ assert.EqualValues(t, float64(1), line["schema_version"])
+ assert.Equal(t, "auth_event", line["event"])
+ assert.Equal(t, "local", line["origin"], "login/logout is REST-only, never the tray socket or stdio")
+ assert.Equal(t, "api", line["source"])
+ assert.Equal(t, wantRequestID, line["request_id"])
+ assert.Equal(t, wantSurface, line["surface"])
+ assert.Equal(t, wantReason, line["reason"])
+ require.Contains(t, line, "caller")
+}
+
+func callerOf(t *testing.T, line map[string]interface{}) map[string]interface{} {
+ t.Helper()
+ c, ok := line["caller"].(map[string]interface{})
+ require.True(t, ok, "caller must be an object: %+v", line)
+ return c
+}
+
+// ---------------------------------------------------------------------------
+// stage-dependent identity across every FR-013 reason (T106 table)
+// ---------------------------------------------------------------------------
+
+func TestAuthEvent_StageDependentIdentityAcrossEveryReason(t *testing.T) {
+ t.Run("state_invalid: neither user_id nor email_hash", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-state-invalid"
+ rig.callback(rid, refusalCallbackURI+"?code=x&state=bogus")
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "state_invalid")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("authorization_denied: neither identity field", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-authz-denied"
+ authURL := rig.mustLogin(rid)
+ sink.reset()
+ rig.results.reset()
+ loc := rig.authorizeForm(authURL, "deny")
+ rig.callback(rid, loc.String())
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "authorization_denied")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("id_token_invalid: neither identity field", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{IDTokenBadSignature: true}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-id-token-invalid"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "id_token_invalid")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("domain_not_allowed: email_hash, never user_id", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, func(c *config.ServerEditionOAuthConfig) {
+ c.AllowedDomains = []string{"other.example"}
+ })
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-domain-not-allowed"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "domain_not_allowed")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ require.Contains(t, c, "email_hash")
+ assert.Equal(t, emailHashOf(refusalUser), c["email_hash"])
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("userinfo_subject_mismatch: email_hash, never user_id", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{UserinfoSubMismatch: true}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-userinfo-sub-mismatch"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "userinfo_subject_mismatch")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ require.Contains(t, c, "email_hash")
+ assert.Equal(t, emailHashOf(refusalUser), c["email_hash"])
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("provider_error from the userinfo fetch after a verified ID token: email_hash, never user_id", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{UserinfoUnavailable: true}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-provider-error-userinfo"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "provider_error")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ require.Contains(t, c, "email_hash")
+ assert.Equal(t, emailHashOf(refusalUser), c["email_hash"])
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("provider_error from the token exchange: neither identity field", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-provider-error-token"
+ authURL := rig.mustLogin(rid)
+ cb := rig.authorizeForm(authURL, "approve")
+ sink.reset()
+ rig.results.reset()
+ rig.fake.Server.SetErrorMode(oauthserver.ErrorMode{TokenServerError: true})
+ rig.callback(rid, cb.String())
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "provider_error")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("discovery_failed: neither identity field", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{DiscoveryHTTPTokenEndpoint: true}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-discovery-failed"
+ rig.login(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "discovery_failed")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ // "discovery_failed carries redirect_rejected" is a round-2 cross-review
+ // regression (PR-D): the redirect_uri is sanitised in HandleLogin BEFORE
+ // the pending state is stored, so a failure before that point (discovery,
+ // provider errors) used to report its terminal result with NO flags at
+ // all — the callback's own `attempt.flag(FlagRedirectRejected)` (read
+ // from the stored pending state) never runs on this pre-redirect
+ // failure path, because no pending state was ever stored for it.
+ t.Run("discovery_failed carries redirect_rejected when the caller's redirect_uri was rejected", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{DiscoveryHTTPTokenEndpoint: true}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-discovery-failed-redirect-rejected"
+
+ req := withRequestID(httptest.NewRequest(http.MethodGet,
+ "http://"+refusalHost+"/api/v1/auth/login?redirect_uri=https://evil.example.com/", nil), rid)
+ w := httptest.NewRecorder()
+ rig.handler.HandleLogin(w, req)
+ require.NotEqual(t, http.StatusFound, w.Code, "control: this redirect_uri must not itself cause a redirect")
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "discovery_failed")
+ flags, _ := line["flags"].([]interface{})
+ assert.Contains(t, flags, "redirect_rejected", "flags = %v", line["flags"])
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("subject_mismatch: session caller + user_id, never email_hash", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ u := users.NewUser(refusalUser, "Alice Example", "oidc", "someone-else-sub")
+ require.NoError(t, rig.store.CreateUser(u))
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-subject-mismatch"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "subject_mismatch")
+ c := callerOf(t, line)
+ assert.Equal(t, "session_user", c["kind"])
+ assert.Equal(t, u.ID, c["user_id"])
+ assert.Equal(t, "user", c["role"])
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("user_disabled: session caller + user_id, never email_hash", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ u := users.NewUser(refusalUser, "Alice Example", "oidc", refusalSub)
+ u.Disabled = true
+ require.NoError(t, rig.store.CreateUser(u))
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-user-disabled"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "user_disabled")
+ c := callerOf(t, line)
+ assert.Equal(t, "session_user", c["kind"])
+ assert.Equal(t, u.ID, c["user_id"])
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("internal_error before the store is consulted (failing loginStore): neither identity field", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ rig.handler.loginStore = failingLoginStore{}
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-internal-store"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "internal_error")
+ c := callerOf(t, line)
+ assert.Equal(t, "anonymous", c["kind"])
+ assert.NotContains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+
+ t.Run("internal_error after the record exists (failing sessionCreator): session caller + user_id", func(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ rig.handler.sessionCreator = failingSessionCreator{}
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-internal-session"
+ rig.approveFlow(rid)
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "internal_error")
+ c := callerOf(t, line)
+ assert.Equal(t, "session_user", c["kind"])
+ require.Contains(t, c, "user_id")
+ assert.NotContains(t, c, "email_hash")
+ assertValidatesAgainstSchema(t, line)
+ })
+}
+
+// ---------------------------------------------------------------------------
+// ok / admin role / logout / abandoned redirect
+// ---------------------------------------------------------------------------
+
+func TestAuthEvent_LoginOK_SessionUserCaller(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-ok"
+ w := rig.approveFlow(rid)
+ require.Equal(t, http.StatusFound, w.Code, "login must succeed; body=%s", w.Body.String())
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, rid, "login", "ok")
+ c := callerOf(t, line)
+ assert.Equal(t, "session_user", c["kind"])
+ assert.Equal(t, "user", c["role"])
+ assert.Equal(t, "oidc", c["provider"])
+ require.Contains(t, c, "user_id")
+ assert.NotEmpty(t, c["user_id"])
+ assert.NotContains(t, c, "email_hash", "email_hash and user_id are mutually exclusive")
+ assertValidatesAgainstSchema(t, line)
+}
+
+func TestAuthEvent_LoginOK_AdminEmail_SessionAdminCaller(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ promoted := refusalServerEditionConfig(rig.live.get().OAuth)
+ promoted.AdminEmails = []string{refusalUser}
+ rig.live.swap(promoted)
+ sink := attachAuditCapture(rig)
+ const rid = "req-ae-ok-admin"
+ w := rig.approveFlow(rid)
+ require.Equal(t, http.StatusFound, w.Code, "login must succeed; body=%s", w.Body.String())
+
+ line := sink.exactlyOne(t)
+ c := callerOf(t, line)
+ assert.Equal(t, "session_admin", c["kind"])
+ assert.Equal(t, "admin", c["role"])
+ assertValidatesAgainstSchema(t, line)
+}
+
+func TestAuthEvent_Logout_SurfaceLogoutReasonLogout(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+
+ w := rig.approveFlow("req-ae-logout-login")
+ require.Equal(t, http.StatusFound, w.Code)
+ loginLine := sink.exactlyOne(t)
+ loginUserID := callerOf(t, loginLine)["user_id"]
+ require.NotEmpty(t, loginUserID)
+ sink.reset()
+ rig.results.reset()
+
+ cookies := w.Result().Cookies()
+ require.NotEmpty(t, cookies, "login must set the session cookie")
+
+ const logoutRID = "req-ae-logout"
+ logoutReq := withRequestID(httptest.NewRequest(http.MethodPost, "http://"+refusalHost+"/api/v1/auth/logout", nil), logoutRID)
+ for _, ck := range cookies {
+ logoutReq.AddCookie(ck)
+ }
+ logoutW := httptest.NewRecorder()
+ rig.handler.HandleLogout(logoutW, logoutReq)
+ require.Equal(t, http.StatusOK, logoutW.Code, "logout must succeed; body=%s", logoutW.Body.String())
+
+ line := sink.exactlyOne(t)
+ assertCommonAuthEventFields(t, line, logoutRID, "logout", "logout")
+ c := callerOf(t, line)
+ assert.Equal(t, "session_user", c["kind"])
+ assert.Equal(t, loginUserID, c["user_id"])
+ assertValidatesAgainstSchema(t, line)
+}
+
+// failingAuditSink is an audit.Sink whose Write always fails, for proving
+// NewAuditEmitter's own logging behaviour on a persistent sink failure.
+type failingAuditSink struct{ writes int }
+
+func (s *failingAuditSink) Write(_ []byte) error { s.writes++; return errTestSinkWrite }
+func (s *failingAuditSink) WriteFailures() uint64 { return uint64(s.writes) }
+func (s *failingAuditSink) SanitizerHits() uint64 { return 0 }
+func (s *failingAuditSink) Close() error { return nil }
+
+var errTestSinkWrite = fmt.Errorf("test: sink write always fails")
+
+// TestAuthEvent_WriteFailureNotLoggedPerCall is a round-3 cross-review
+// regression (Spec 107 PR-D): FR-018 caps runtime audit-sink-failure
+// logging at once per minute. That cap lives on the sink's own
+// WithFailureLogger (internal/audit, T109); NewAuditEmitter must not ALSO
+// log a warning on every failed write, or a persistent disk/stdout failure
+// produces one unbounded warning per login/logout, bypassing the sink's
+// rate limit entirely. Before this fix, every failed sink.Write logged
+// unconditionally here.
+func TestAuthEvent_WriteFailureNotLoggedPerCall(t *testing.T) {
+ sink := &failingAuditSink{}
+ core, logs := observer.New(zap.DebugLevel)
+ logger := zap.New(core).Sugar()
+
+ emit := NewAuditEmitter(sink, logger)
+ for i := 0; i < 5; i++ {
+ emit(LoginResult{RequestID: fmt.Sprintf("req-fail-%d", i), Surface: "login", Reason: LoginRefusal("ok"), UserID: "u1", Role: "user", Provider: "oidc"})
+ }
+
+ assert.Equal(t, 5, sink.writes, "control: every call must have reached the sink")
+ assert.Empty(t, logs.All(), "a per-request write-failure warning bypasses the sink's own once-per-minute rate limit (FR-018)")
+}
+
+func TestAuthEvent_AbandonedRedirect_NoLine(t *testing.T) {
+ rig := newOIDCRefusalRig(t, oauthserver.ErrorMode{}, nil)
+ sink := attachAuditCapture(rig)
+
+ // A login that redirects to the IdP but never comes back writes no
+ // terminal LoginResult and therefore no auth_event line.
+ rig.mustLogin("req-ae-abandoned")
+ assert.Empty(t, sink.all(), "an abandoned redirect (pending state never returns) writes no auth_event line")
+ assert.Empty(t, rig.results.all())
+}
diff --git a/internal/serveredition/auth/oauth_handler.go b/internal/serveredition/auth/oauth_handler.go
index bb07e3edf..da30b3855 100644
--- a/internal/serveredition/auth/oauth_handler.go
+++ b/internal/serveredition/auth/oauth_handler.go
@@ -92,7 +92,19 @@ type LoginResult struct {
Reason LoginRefusal
UserID string
EmailHash string // hex SHA-256 of the normalised email
- Flags []LoginFlag
+ // Role and Provider are set only alongside UserID (the store was reached
+ // and a record exists): Role is "admin"|"user", derived from the LIVE
+ // admin_emails at the moment identity was established; Provider is the
+ // record's stored provider. Both are zero whenever UserID is empty.
+ Role string
+ Provider string
+ Flags []LoginFlag
+ // ClientIP is the FR-027 trusted-proxy-resolved client address (never a
+ // raw, unvalidated X-Forwarded-For): schema `client.ip` on the
+ // auth_event line (round-1 cross-review finding, PR-D — this field did
+ // not exist before, so every auth_event line lost request-origin
+ // attribution).
+ ClientIP string
}
// loginStore is the narrow user-store seam the callback writes through.
@@ -326,6 +338,17 @@ func (h *OAuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
codeChallenge := base64.RawURLEncoding.EncodeToString(challengeHash[:])
redirectURI, rejected := sanitizeLoginRedirect(r.URL.Query().Get("redirect_uri"))
+ if rejected {
+ // Round-2 cross-review finding, PR-D: this is a non-terminal fact of
+ // THIS attempt (the caller's redirect_uri was replaced), established
+ // before the pending state — and any later failure — exists. A
+ // discovery/provider failure below (attempt.fail) used to report the
+ // terminal result with no flags at all, because the callback's own
+ // `attempt.flag(FlagRedirectRejected)` (from pending.RedirectRejected)
+ // never runs on this pre-redirect failure path — the pending state
+ // this attempt never reached storing.
+ attempt.flag(FlagRedirectRejected)
+ }
callbackURL := h.CallbackURL(r)
// Build the authorization URL before allocating the pending state, so a
@@ -453,10 +476,27 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
if err != nil {
switch {
case errors.Is(err, users.ErrSubjectMismatch):
- attempt.setUserID(h.lookupUserID(userInfo.Email))
+ // outcome.User is the record UpdateUserLogin refused against, from
+ // the SAME transaction the decision was made in — round-2
+ // cross-review finding, PR-D: a separate re-lookup by email AFTER
+ // the transaction returned could race a concurrent DeleteUser (or
+ // a transient read failure), losing the schema-required `user_id`
+ // on this auth_event line and silently downgrading it to
+ // anonymous. Fall back to the racy re-lookup only if the store
+ // implementation did not populate it (belt and suspenders; the
+ // in-process UserStore always does).
+ if u := outcome.User; u != nil {
+ attempt.setUserID(u.ID, h.roleFor(u.Email), u.Provider)
+ } else if u := h.lookupUser(userInfo.Email); u != nil {
+ attempt.setUserID(u.ID, h.roleFor(u.Email), u.Provider)
+ }
attempt.refuse(w, LoginSubjectMismatch, "provider subject differs from the stored binding")
case errors.Is(err, users.ErrUserDisabled):
- attempt.setUserID(h.lookupUserID(userInfo.Email))
+ if u := outcome.User; u != nil {
+ attempt.setUserID(u.ID, h.roleFor(u.Email), u.Provider)
+ } else if u := h.lookupUser(userInfo.Email); u != nil {
+ attempt.setUserID(u.ID, h.roleFor(u.Email), u.Provider)
+ }
attempt.refuse(w, LoginUserDisabled, "user record disabled")
default:
// The store WAS consulted here (the upsert itself failed), so
@@ -470,17 +510,13 @@ func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
return
}
user := outcome.User
- attempt.setUserID(user.ID)
+ // Role from the CURRENT admin_emails.
+ role := h.roleFor(user.Email)
+ attempt.setUserID(user.ID, role, user.Provider)
if outcome.Rebound {
attempt.flag(FlagProviderRebound)
}
- // Role from the CURRENT admin_emails.
- role := "user"
- if live := h.liveConfig(); live != nil && live.IsAdminEmail(user.Email) {
- role = "admin"
- }
-
bearerToken, err := h.bearerSigner(h.hmacKey, user.ID, user.Email, user.DisplayName, role, user.Provider, h.bearerTokenTTL())
if err != nil {
attempt.unavailable(w, LoginInternalError, "bearer token signer", err)
@@ -614,11 +650,25 @@ func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
h.sessionManager.ClearSessionCookieFor(w, r, session)
h.logger.Infow("user logged out", "user_id", session.UserID, "session_id", session.ID)
+ // Role/Provider are best-effort: a session with no surviving user record
+ // (deleted between login and logout) still reports the logout with its
+ // UserID, just without Role/Provider (the emitter then falls back to the
+ // "user" role default, never blocking the observer on a lookup miss).
+ role, provider := "user", ""
+ if u, err := h.userStore.GetUser(session.UserID); err == nil && u != nil {
+ provider = u.Provider
+ role = h.roleFor(u.Email)
+ }
h.observe(LoginResult{
RequestID: reqcontext.GetRequestID(r.Context()),
Surface: LoginSurfaceLogout,
Reason: LoginLogout,
UserID: session.UserID,
+ Role: role,
+ Provider: provider,
+ // FR-027: same trusted-proxy resolution as login (round-1
+ // cross-review finding, PR-D).
+ ClientIP: config.ForwardedHeaders(r, h.currentTrustedProxies()).ClientIP,
})
w.Header().Set("Content-Type", "application/json")
@@ -633,26 +683,39 @@ func (h *OAuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
type loginAttempt struct {
h *OAuthHandler
requestID string
+ clientIP string
userID string
emailHash string
+ role string // set only alongside userID
+ provider string // set only alongside userID
flags []LoginFlag
groupsClaimMissing bool
reported bool
}
func (h *OAuthHandler) newAttempt(r *http.Request) *loginAttempt {
- return &loginAttempt{h: h, requestID: reqcontext.GetRequestID(r.Context())}
+ return &loginAttempt{
+ h: h,
+ requestID: reqcontext.GetRequestID(r.Context()),
+ // FR-027: believed only from a trusted proxy, same resolution
+ // CreateSession uses for session.IPAddress.
+ clientIP: config.ForwardedHeaders(r, h.currentTrustedProxies()).ClientIP,
+ }
}
func (a *loginAttempt) flag(f LoginFlag) { a.flags = append(a.flags, f) }
// setUserID records that the store was reached and a record exists; the
-// email hash is dropped, the two never appear together (FR-013).
-func (a *loginAttempt) setUserID(id string) {
+// email hash is dropped, the two never appear together (FR-013). role and
+// provider travel with the identity so the auth_event caller.kind
+// (session_user|session_admin) and caller.role/provider can be derived
+// without a second store lookup downstream.
+func (a *loginAttempt) setUserID(id, role, provider string) {
if id == "" {
return
}
a.userID, a.emailHash = id, ""
+ a.role, a.provider = role, provider
}
// clearEmailHash drops a provisional email hash once the store has been
@@ -670,7 +733,10 @@ func (a *loginAttempt) result(reason LoginRefusal) LoginResult {
Reason: reason,
UserID: a.userID,
EmailHash: a.emailHash,
+ Role: a.role,
+ Provider: a.provider,
Flags: append([]LoginFlag(nil), a.flags...),
+ ClientIP: a.clientIP,
}
}
@@ -749,14 +815,23 @@ func (h *OAuthHandler) bearerTokenTTL() time.Duration {
return 24 * time.Hour
}
-// lookupUserID resolves the record id for a refused login that reached the
-// store (subject_mismatch, user_disabled); best effort.
-func (h *OAuthHandler) lookupUserID(email string) string {
+// lookupUser resolves the record for a refused login that reached the store
+// (subject_mismatch, user_disabled); best effort — nil on any error or miss.
+func (h *OAuthHandler) lookupUser(email string) *users.User {
u, err := h.loginStore.GetUserByEmail(email)
- if err != nil || u == nil {
- return ""
+ if err != nil {
+ return nil
+ }
+ return u
+}
+
+// roleFor derives the FR-013/auth_event caller role from the LIVE
+// admin_emails, never a boot-time snapshot (#1169).
+func (h *OAuthHandler) roleFor(email string) string {
+ if live := h.liveConfig(); live != nil && live.IsAdminEmail(email) {
+ return "admin"
}
- return u.ID
+ return "user"
}
// emailHash is the FR-013 email_hash: hex SHA-256 of the normalised email.
diff --git a/internal/serveredition/registry.go b/internal/serveredition/registry.go
index 3053191a8..052e350b0 100644
--- a/internal/serveredition/registry.go
+++ b/internal/serveredition/registry.go
@@ -9,6 +9,7 @@ import (
"go.etcd.io/bbolt"
"go.uber.org/zap"
+ "github.com/smart-mcp-proxy/mcpproxy-go/internal/audit"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/httpapi"
@@ -52,6 +53,12 @@ type Dependencies struct {
// it into UserActivityHandlers so that door emits the same JSON shape
// and the same masking as the core surface for the same record.
ProjectActivity func(*storage.ActivityRecord) contracts.ActivityRecord
+
+ // AuditSink is the Spec 107 audit line writer the server was built with
+ // (server.WithAuditSink), handed to the OAuth handler so PR-D's
+ // auth_event emitter (T107) shares the ONE sink the dispatch funnels
+ // write through. nil = no-op (audit_log off).
+ AuditSink audit.Sink
}
// Feature represents a server edition feature module that self-registers.
diff --git a/internal/serveredition/setup.go b/internal/serveredition/setup.go
index f2f73c697..54f43fa2a 100644
--- a/internal/serveredition/setup.go
+++ b/internal/serveredition/setup.go
@@ -240,6 +240,10 @@ func setupMultiUserOAuth(deps Dependencies) error {
// LIVE admin_emails through the same provider (Spec 107 T044).
oauthHandler := teamsauth.NewOAuthHandler(userStore, sessionManager, serverEditionConfig, hmacKey, deps.Logger)
oauthHandler.SetTrustedProxiesProvider(trustedProxies)
+ // Spec 107 T107: one `auth_event` line per terminal login attempt and
+ // per logout, through the ONE audit.Sink the dispatch funnels write
+ // through (nil deps.AuditSink = audit_log off = no-op).
+ oauthHandler.LoginResultObserver = teamsauth.NewAuditEmitter(deps.AuditSink, deps.Logger)
// The per-user credential store backs the oauth_connect flow (spec 074
// Path B): credentials a user connects are stored here, encrypted under
diff --git a/internal/serveredition/users/store.go b/internal/serveredition/users/store.go
index baa20f4a9..355c6ba08 100644
--- a/internal/serveredition/users/store.go
+++ b/internal/serveredition/users/store.go
@@ -549,6 +549,16 @@ func (s *UserStore) UpdateUserLogin(ctx context.Context, claims LoginClaims) (Lo
out.Created = true
} else {
if user.Disabled {
+ // The record this login refused against, from the SAME read
+ // this transaction made — round-2 cross-review finding,
+ // PR-D: the caller used to re-look the user up by email
+ // AFTER this transaction committed/rolled back, which a
+ // concurrent DeleteUser (or a transient read failure) could
+ // race, turning a schema-required `user_id` on the
+ // auth_event line into a silently anonymous one. Capturing
+ // it here is race-free by construction: it is the exact
+ // record the refusal decision was made from.
+ out.User = user
return ErrUserDisabled
}
armed := user.SubjectRebindArmedAt != nil
@@ -563,6 +573,7 @@ func (s *UserStore) UpdateUserLogin(ctx context.Context, claims LoginClaims) (Lo
case armed:
out.Rebound = true
default:
+ out.User = user // see the ErrUserDisabled comment above.
return ErrSubjectMismatch
}
if armed {
@@ -611,7 +622,13 @@ func (s *UserStore) UpdateUserLogin(ctx context.Context, claims LoginClaims) (Lo
return nil
})
if err != nil {
- return LoginOutcome{}, err
+ // out.User is set only on the two branches that captured it
+ // (ErrUserDisabled, ErrSubjectMismatch) above; every other error
+ // path leaves out at its zero value, so returning out here instead
+ // of LoginOutcome{} changes nothing for those callers and gives the
+ // two refusal callers race-free access to the record the decision
+ // was made from (round-2 cross-review finding, PR-D).
+ return out, err
}
return out, nil
}
diff --git a/internal/serveredition/users/store_test.go b/internal/serveredition/users/store_test.go
index b950ec23a..eea76dd95 100644
--- a/internal/serveredition/users/store_test.go
+++ b/internal/serveredition/users/store_test.go
@@ -284,6 +284,44 @@ func TestSetUserDisabled_DoesNotLoseConcurrentLoginWrite(t *testing.T) {
}
}
+// TestUpdateUserLogin_RefusalOutcomeCarriesTheRecord is a round-2
+// cross-review regression (PR-D): ErrUserDisabled/ErrSubjectMismatch used to
+// discard the LoginOutcome entirely (`return LoginOutcome{}, err`), forcing
+// the caller (oauth_handler.go) to re-look the user up by email in a SEPARATE
+// read after this transaction returned — a window a concurrent DeleteUser
+// could race, silently losing the auth_event line's required `user_id`. The
+// outcome must now carry the exact record the refusal was decided from, from
+// the same transaction, so no second read — and no race — is needed.
+func TestUpdateUserLogin_RefusalOutcomeCarriesTheRecord(t *testing.T) {
+ t.Run("ErrUserDisabled", func(t *testing.T) {
+ store := setupTestStore(t)
+ user := NewUser("disabled@example.com", "Disabled", "google", "sub-disabled")
+ require.NoError(t, store.CreateUser(user))
+ _, _, err := store.SetUserDisabled(user.ID, true)
+ require.NoError(t, err)
+
+ outcome, err := store.UpdateUserLogin(context.Background(), LoginClaims{
+ Email: "disabled@example.com", Provider: "google", Subject: "sub-disabled",
+ })
+ require.ErrorIs(t, err, ErrUserDisabled)
+ require.NotNil(t, outcome.User, "the refused-against record must be returned alongside the error")
+ assert.Equal(t, user.ID, outcome.User.ID)
+ })
+
+ t.Run("ErrSubjectMismatch", func(t *testing.T) {
+ store := setupTestStore(t)
+ user := NewUser("mismatch@example.com", "Mismatch", "google", "sub-original")
+ require.NoError(t, store.CreateUser(user))
+
+ outcome, err := store.UpdateUserLogin(context.Background(), LoginClaims{
+ Email: "mismatch@example.com", Provider: "google", Subject: "sub-DIFFERENT",
+ })
+ require.ErrorIs(t, err, ErrSubjectMismatch)
+ require.NotNil(t, outcome.User, "the refused-against record must be returned alongside the error")
+ assert.Equal(t, user.ID, outcome.User.ID)
+ })
+}
+
func TestUserStore_DeleteUser_RemovesEmailIndex(t *testing.T) {
store := setupTestStore(t)
diff --git a/internal/transport/context.go b/internal/transport/context.go
index 98145ccd1..3316ec89f 100644
--- a/internal/transport/context.go
+++ b/internal/transport/context.go
@@ -10,6 +10,11 @@ const (
ConnectionSourceTCP ConnectionSource = "tcp"
// ConnectionSourceTray identifies connections from tray via Unix socket or named pipe
ConnectionSourceTray ConnectionSource = "tray"
+ // ConnectionSourceStdio identifies the native stdio MCP transport: the
+ // local process that launched mcpproxy, with no listener at all (Spec
+ // 107 T103 — tagged by server.stdioAuthContext so the audit line reports
+ // caller.kind: stdio instead of the TCP default's api_key).
+ ConnectionSourceStdio ConnectionSource = "stdio"
)
// Context key for connection source tagging
diff --git a/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift b/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift
index e75bf20a0..19ccd574f 100644
--- a/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift
+++ b/native/macos/MCPProxy/MCPProxy/Settings/SettingsCatalog.swift
@@ -358,6 +358,21 @@ enum SettingsCatalog {
ConfigField(key: "activity_cleanup_interval_min", label: "Cleanup runs every (minutes)", control: .number, min: 1),
]
),
+ ConfigSection(
+ id: "audit-log",
+ title: "Audit log",
+ help: "Edition-neutral JSONL record of authorization decisions and tool calls. Changes take effect after a restart (the sink is bound at startup).",
+ docs: "/features/audit-log",
+ fields: [
+ ConfigField(key: "audit_log.enabled", label: "Enable audit logging", help: "Writes one JSONL line per authorization decision and tool call. On by default under the server edition; the personal edition defaults to off.", control: .toggle, restart: true),
+ ConfigField(key: "audit_log.stdout", label: "Write to stdout", help: "Server edition default when no path is set — not used under the native stdio transport (stdout carries JSON-RPC there); set a path instead.", control: .toggle, restart: true),
+ ConfigField(key: "audit_log.path", label: "File path", help: "Where to write the rotating audit log file. Leave blank to use stdout instead.", control: .text, restart: true, optional: true, placeholder: "/var/log/mcpproxy/audit.jsonl"),
+ ConfigField(key: "audit_log.max_size_mb", label: "Rotate after (MB)", control: .number, min: 1, restart: true),
+ ConfigField(key: "audit_log.max_backups", label: "Rotated files to keep", control: .number, min: 1, restart: true),
+ ConfigField(key: "audit_log.max_age_days", label: "Delete rotated logs after (days)", control: .number, min: 1, restart: true),
+ ConfigField(key: "audit_log.compress", label: "Compress rotated files", control: .toggle, restart: true),
+ ]
+ ),
ConfigSection(
id: "discovery",
title: "Tool discovery & health checks",
diff --git a/oas/docs.go b/oas/docs.go
index 4e816dfcb..d9fcd655e 100644
--- a/oas/docs.go
+++ b/oas/docs.go
@@ -6,7 +6,7 @@ import "github.com/swaggo/swag/v2"
const docTemplate = `{
"schemes": {{ marshal .Schemes }},
- "components": {"schemas":{"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", \"edit_url\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
+ "components": {"schemas":{"config.AuditLogConfig":{"description":"AuditLog configures the Spec 107 edition-neutral audit sink\n(internal/audit). nil means \"use the per-edition/per-transport\ndefault\" (EffectiveAuditLog); restart-pinned (bound at sink\nconstruction). See audit_log.go.","properties":{"compress":{"type":"boolean"},"enabled":{"type":"boolean"},"max_age_days":{"type":"integer"},"max_backups":{"type":"integer"},"max_size_mb":{"type":"integer"},"path":{"type":"string"},"stdout":{"type":"boolean"}},"type":"object"},"config.ConcurrencyDefaults":{"description":"ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server\ndefault set inherited by every server that does not override a setting.\nAbsent (the default) = no per-server limiting unless a server configures\nit explicitly. File/API-configured only — no env scheme (FR-022).","properties":{"max_concurrent_requests":{"type":"integer"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"}},"type":"object"},"config.Config":{"properties":{"activity_cleanup_interval_min":{"description":"Background cleanup interval in minutes (default: 60)","type":"integer"},"activity_max_records":{"description":"Max records before pruning (default: 100000)","type":"integer"},"activity_max_response_size":{"description":"Response truncation limit in bytes (default: 65536)","type":"integer"},"activity_max_size_mb":{"description":"ActivityMaxSizeMB caps the total activity-log size in MB before the\noldest records are pruned. Omit the key for the 256MB default; set it to\n0 to disable the size cap.","type":"integer"},"activity_retention_days":{"description":"Activity logging settings (RFC-003)","type":"integer"},"aggregate_upstream_prompts":{"description":"AggregateUpstreamPrompts, when true, aggregates every connected upstream\nserver's advertised MCP prompts into mcpproxy's own prompts/list\n(exposed as \"\u003cserver\u003e__\u003cprompt\u003e\"). OFF by default: users are safe by\ndefault and opt in deliberately. EnablePrompts still governs the built-in\nprompts + the prompts capability; this flag gates ONLY the upstream\naggregation performed by RefreshPrompts. Hot-reloadable.","type":"boolean"},"allow_private_registry_fetch":{"description":"AllowPrivateRegistryFetch opts out of the registry SSRF guard (MCP-1076,\nCWE-918). By default (false) registry fetches refuse any host that is — or\nresolves to — a non-routable address (loopback, RFC1918/CGNAT private,\nlink-local incl. the 169.254.169.254 cloud-metadata endpoint), so a\nmalicious or typo'd registry source cannot turn the daemon into a\nrequest-forgery vector against internal services.\n\nThis opt-out is BLANKET (all-or-nothing): setting it true disables the\nguard for EVERY non-routable range at once — loopback, RFC1918/CGNAT\nprivate, link-local AND the 169.254.169.254 cloud-metadata endpoint. There\nis no way to allow only loopback; enabling it for a localhost dev registry\nalso re-opens the cloud-metadata SSRF vector. Set true ONLY when you\nintentionally run a trusted registry mirror on an internal/private address,\nideally on a host with no cloud-metadata exposure. The change takes effect\nonly on daemon (re)start or config reload.","type":"boolean"},"allow_server_add":{"type":"boolean"},"allow_server_remove":{"type":"boolean"},"api_key":{"description":"Security settings","type":"string"},"audit_log":{"$ref":"#/components/schemas/config.AuditLogConfig"},"call_tool_timeout":{"type":"string"},"check_server_repo":{"description":"Repository detection settings","type":"boolean"},"code_execution_max_parallel":{"description":"Default concurrency for call_tools() batches (1-32, default: 8)","type":"integer"},"code_execution_max_tool_calls":{"description":"Max tool calls per execution (0 = unlimited, default: 0)","type":"integer"},"code_execution_pool_size":{"description":"JavaScript runtime pool size (default: 10)","type":"integer"},"code_execution_timeout_ms":{"description":"Timeout in milliseconds (default: 120000, max: 600000)","type":"integer"},"data_dir":{"type":"string"},"debug_search":{"type":"boolean"},"direct_tool_response_mode":{"description":"DirectToolResponseMode selects the serialization of the DIRECT\nenumeration surface (Spec 102). Valid values: \"\" (= full), \"full\"\n(default: today's schema-bearing entries), \"deferred\" (description +\ncompact signature, with a minimal permissive input schema; upstream\ninputSchema and outputSchema are stripped and recovered on demand via\ndescribe_tool).\n\nDeliberately NOT an extension of tool_response_mode: reusing that axis\nwould silently change /mcp/all output for every deployment already\nrunning compact, which FR-015 forbids. Serialization-only — it never\nchanges WHICH tools are listed, only how (FR-008). Hot-reloadable.","type":"string"},"disable_management":{"type":"boolean"},"docker_isolation":{"$ref":"#/components/schemas/config.DockerIsolationConfig"},"docker_recovery":{"$ref":"#/components/schemas/config.DockerRecoveryConfig"},"enable_code_execution":{"description":"Code execution settings","type":"boolean"},"enable_prompts":{"description":"Prompts settings","type":"boolean"},"enable_socket":{"description":"Enable Unix socket/named pipe for local IPC (default: true)","type":"boolean"},"enable_tray":{"description":"Deprecated: EnableTray is unused and has no runtime effect. Kept for backward compatibility.","type":"boolean"},"environment":{"$ref":"#/components/schemas/secureenv.EnvConfig"},"features":{"$ref":"#/components/schemas/config.FeatureFlags"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned stdio upstream servers (MCP-2769). OFF by\ndefault: proxy URLs commonly embed credentials (http://user:pass@proxy), so\nforwarding them to every upstream is a credential-leak risk. When enabled,\nvalues are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"health_check_interval":{"description":"Discovery \u0026 health-check cadence (spec 074, #608). Both are *Duration\ntri-state pointers: nil = inherit the built-in default; a pointer to 0s =\nthe loop is disabled; a positive value = that interval. Defaults live only\nin the resolvers (ResolveHealthCheckInterval / ResolveToolDiscoveryInterval)\nso an unset key behaves exactly as before this feature (SC-005). Validated\nin Validate(): health-check ∈ {0} ∪ [5s,1h]; tool-discovery ∈ {0} ∪ [30s,24h].","type":"string"},"http_idle_timeout":{"description":"HTTPIdleTimeout caps how long an idle keep-alive connection is kept open.\nUnset = 180s. \"0s\" removes the dedicated idle deadline, but net/http then\nfalls back to ReadTimeout — idle is fully unbounded only when\nhttp_read_timeout is also \"0s\". Requires a restart.","type":"string"},"http_read_timeout":{"description":"HTTPReadTimeout caps how long reading a whole request (headers + body)\nmay take. Unset = 120s; \"0s\" disables it. Requires a restart.","type":"string"},"http_write_timeout":{"description":"HTTPWriteTimeout caps how long producing a whole response may take on\nnon-streaming endpoints (REST, Web UI, health). Unset = 120s; \"0s\"\ndisables it globally. MCP and SSE /events routes are exempt by design.","type":"string"},"init_timeout":{"description":"InitTimeout is the global default deadline for an upstream's MCP\n` + "`" + `initialize` + "`" + ` handshake (MCP-3322 / GH #760). *Duration tri-state: nil =\ninherit the built-in 30s default; a positive value = that deadline. A\nper-server InitTimeout overrides this. Resolved by ResolveInitTimeout;\nvalidated to {0} ∪ [1s, 30m] in Validate(). Servers doing legitimate\nfirst-run warmup (cache/index build) before answering ` + "`" + `initialize` + "`" + ` can\nraise this so they are not killed mid-startup.","type":"string"},"instructions":{"description":"Instructions text returned in the MCP initialize response to guide AI agents.\nWhen empty, a built-in default is used that explains retrieve_tools workflow.","type":"string"},"intent_declaration":{"$ref":"#/components/schemas/config.IntentDeclarationConfig"},"listen":{"type":"string"},"logging":{"$ref":"#/components/schemas/config.LogConfig"},"max_concurrent_requests":{"description":"Concurrency limits (spec 093, GH #955). Scope (a) of FR-020: the GLOBAL\nAGGREGATE limiter — one proxy-wide cap on concurrently running upstream\ntool calls, with its own bounded wait queue. Tri-state pointers: absent =\nthe limiter does not exist (default, zero behavior change); an explicit 0\nmax also disables it; positive = that cap. This scope is NEVER a\nper-server inheritance source — per-server values come from\nServerConcurrencyDefaults / the per-server overrides — but a server's\neffective concurrency is bounded by BOTH its own limiter and this one.\nResolved by ResolveGlobalConcurrency; hot-reloadable; overridable via\nMCPPROXY_MAX_CONCURRENT_REQUESTS / _QUEUE_SIZE / _QUEUE_TIMEOUT (FR-022).","type":"integer"},"max_result_size_chars":{"description":"MaxResultSizeChars is advertised on every tool as\n` + "`" + `_meta.anthropic/maxResultSizeChars` + "`" + `; it raises Claude Code's\ninline-response ceiling from 50k to up to 500k chars. Omit the key for\nthe 500000 default; set it to 0 to disable the annotation.","type":"integer"},"mcpServers":{"items":{"$ref":"#/components/schemas/config.ServerConfig"},"type":"array","uniqueItems":false},"oauth_expiry_warning_hours":{"description":"Health status settings","type":"number"},"observability":{"$ref":"#/components/schemas/config.ObservabilityConfig"},"output_sanitisation":{"$ref":"#/components/schemas/config.OutputSanitisationConfig"},"output_validation":{"$ref":"#/components/schemas/config.OutputValidationConfig"},"profiles":{"description":"Profiles are optional named, server-scoped views exposed at /mcp/p/\u003cname\u003e\n(Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs\nwithout this key serialize byte-identically (SC-004).","items":{"$ref":"#/components/schemas/config.ProfileConfig"},"type":"array","uniqueItems":false},"quarantine_enabled":{"description":"QuarantineEnabled controls whether quarantine is active. It gates two\nthings together:\n 1. Server-level auto-quarantine for newly added servers (issue #370).\n When true, servers added via the upstream_servers MCP tool or the\n REST API default to quarantined=true; when false, they default to\n quarantined=false. Explicit per-request values always win.\n 2. Tool-level quarantine (Spec 032): per-tool SHA-256 approval of\n tool descriptions/schemas.\nWhen nil (default), quarantine is enabled (secure by default). Set to\nexplicit false to opt out of both. Per-server SkipQuarantine still\napplies for the tool-level check on individual servers.","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"read_only_mode":{"type":"boolean"},"registries":{"description":"Registries configuration for MCP server discovery","items":{"$ref":"#/components/schemas/config.RegistryEntry"},"type":"array","uniqueItems":false},"registries_locked":{"description":"RegistriesLocked is an enterprise stub knob (MCP-866): when true, runtime\nadditions of custom registries (e.g. ` + "`" + `registry add-source` + "`" + `, the REST/MCP\nadd-source surface) are rejected so an administrator can pin the discovery\nsources. Built-in defaults are unaffected. Documented but otherwise inert\nbeyond the add-source rejection.","type":"boolean"},"require_mcp_auth":{"description":"Require authentication on /mcp endpoint (default: false)","type":"boolean"},"reveal_secret_headers":{"description":"RevealSecretHeaders, when true, disables the redaction of the\nsecret-bearing server fields — sensitive header values (Authorization,\nX-API-Key, Cookie, …), env-var secrets, and URL query credentials — in\nresponses from the ` + "`" + `upstream_servers` + "`" + ` MCP tool, the ` + "`" + `/api/v1/servers` + "`" + `\nREST API, and the SSE event stream. It also lets URL secrets echoed\ninto last_error / health.detail through unscrubbed.\n\nDefault false — sensitive values are surfaced masked as\n` + "`" + `••••\u003clast2\u003e (\u003cN\u003e chars)` + "`" + ` (error strings use ` + "`" + `***REDACTED***` + "`" + `) so an\nMCP agent cannot read Bearer tokens / API keys / URL secrets out of\nanother upstream's config (PR #425, issue #872). ${env:…}/${keyring:…}\nreferences are labels, not secrets, and pass through unchanged.\n\nThe Web UI / macOS tray edit forms work without seeing the real\nvalues: PATCH /api/v1/servers/{id} deep-merges (omitted keys are\npreserved, see ` + "`" + `headers_remove` + "`" + ` / ` + "`" + `env_remove` + "`" + ` for explicit\ndeletes), so clients compute a diff and only send the keys that\nactually changed. Redacted-but-unchanged values never round-trip\n— the backend keeps the real string. Set this to true if a\ndownstream tool genuinely needs raw values in the response.","type":"boolean"},"routing_mode":{"description":"Routing mode (Spec 031): how MCP tools are exposed to clients\nValid values: \"retrieve_tools\" (default), \"direct\", \"code_execution\"","type":"string"},"security":{"$ref":"#/components/schemas/config.SecurityConfig"},"sensitive_data_detection":{"$ref":"#/components/schemas/config.SensitiveDataDetectionConfig"},"server_concurrency_defaults":{"$ref":"#/components/schemas/config.ConcurrencyDefaults"},"telemetry":{"$ref":"#/components/schemas/config.TelemetryConfig"},"tls":{"$ref":"#/components/schemas/config.TLSConfig"},"tokenizer":{"$ref":"#/components/schemas/config.TokenizerConfig"},"tool_call_max_records_per_server":{"description":"Calls retained per server (default: 1000)","type":"integer"},"tool_call_max_response_size":{"description":"Bounds for the per-server tool-call history behind GET /api/v1/tool-calls\n(#1176). It is a recent-debugging window, not an audit log — the activity\nlog is the durable record — and it kept every upstream response whole,\nper server, forever. A non-positive value means \"use the default\", not\n\"disable\": this store must never be unbounded again, so there is\ndeliberately no off switch.","type":"integer"},"tool_discovery_interval":{"type":"string"},"tool_response_limit":{"type":"integer"},"tool_response_mode":{"description":"Tool response mode (Spec 085): how retrieve_tools serializes results.\nValid values: \"\" (= full), \"full\" (default: today's schema-bearing\nentries), \"compact\" (signature + first-sentence entries). Orthogonal to\nrouting_mode — routing_mode selects the tool SURFACE, this selects the\nSERIALIZATION within the retrieve_tools surface. Serialization-only: it\nnever affects the query, ranking, or result set. Hot-reloadable.","type":"string"},"tool_response_session_risk_warning":{"description":"ToolResponseSessionRiskWarning controls whether the prose ` + "`" + `warning` + "`" + ` field\nis included in the ` + "`" + `session_risk` + "`" + ` object returned by ` + "`" + `retrieve_tools` + "`" + `.\nThe structured fields (level, lethal_trifecta, has_open_world_tools, etc.)\nare always included. Default: false (quiet for LLM clients) — see issue #406.\nMost tools lack annotations, so the MCP-spec defaults treat them as fully\npermissive across all three risk axes, which makes the prose warning fire\non almost every call and wastes tokens.","type":"boolean"},"tools_limit":{"type":"integer"},"toon_min_savings_pct":{"description":"ToonMinSavingsPct is the minimum byte-savings percentage (validated\n1-90; 0/unset → 15) the complete TOON emission (marker + hint + body)\nmust achieve over the exact passthrough emission for adaptive mode to\nencode a block. Byte savings approximate token savings for the tabular\npayload class; the spec-083 profiler reports true token deltas.\nGlobal-only (no per-server override, FR-001).","type":"integer"},"toon_output":{"description":"ToonOutput selects the TOON encoding mode for call_tool_* result text\nblocks (spec 084): \"off\" (default — responses byte-identical to\npre-feature behavior), \"adaptive\" (encode only tabular-uniform payloads\nthat beat compact JSON by ToonMinSavingsPct), or \"always\"\n(benchmark/debug only — encodes every JSON-parseable block and can\nINCREASE token cost). Per-server override: ServerConfig.ToonOutput.\nResolved by ResolveToonOutput; hot-reloadable.","type":"string"},"top_k":{"description":"Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.","type":"integer"},"tray_endpoint":{"description":"Tray endpoint override (unix:// or npipe://)","type":"string"},"trusted_hosts":{"description":"TrustedHosts lists non-loopback Host header values accepted on loopback\nlisteners (GH #898). DNS-rebinding protection rejects requests whose Host\nheader is not a loopback address when mcpproxy listens on loopback; a\nreverse proxy (nginx → 127.0.0.1) forwarding the public domain in Host\ntrips it. Entries are hostnames, case-insensitive; an entry without a\nport matches any port, with a port it must match exactly; a leading dot\n(\".example.com\") is a subdomain wildcard. The single entry \"*\" disables\nHost and Origin validation entirely. The same list also validates the\nOrigin header when present (MCP spec DNS-rebinding defense). Empty\n(default) keeps full protection. Env override: MCPPROXY_TRUSTED_HOSTS\n(comma-separated).","items":{"type":"string"},"type":"array","uniqueItems":false},"trusted_proxies":{"description":"TrustedProxies lists the CIDRs or IP addresses whose X-Forwarded-For /\nX-Real-IP / X-Forwarded-Proto / X-Forwarded-Host headers are believed\n(Spec 107 FR-027). Empty (default) trusts nobody. Edition-neutral, live\n(hot-reloadable). Env override: MCPPROXY_TRUSTED_PROXIES (comma-separated).\nThe one reader is ForwardedHeaders; validation is validateTrustedProxies.","items":{"type":"string"},"type":"array","uniqueItems":false},"update_check":{"$ref":"#/components/schemas/config.UpdateCheckConfig"}},"type":"object"},"config.CustomPattern":{"properties":{"category":{"description":"Category (defaults to \"custom\")","type":"string"},"keywords":{"description":"Keywords to match (mutually exclusive with Regex)","items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"description":"Unique identifier for this pattern","type":"string"},"regex":{"description":"Regex pattern (mutually exclusive with Keywords)","type":"string"},"severity":{"description":"Risk level: critical, high, medium, low","type":"string"}},"type":"object"},"config.DeepScanConfig":{"description":"DeepScan is the opt-in \"deep scan\" layer (Spec 077 US3). It subsumes the\ndeprecated top-level scanner_fetch_package_source / scanner_disable_no_new_privileges\nkeys (migrated on load) and gates the heavy Docker-based scanners + source\nextraction. Disabled by default (FR-006): only the deterministic in-process\nbaseline scanner runs. A deep-scan failure NEVER changes the baseline verdict\n(FR-007/FR-008).","properties":{"disable_no_new_privileges":{"description":"DisableNoNewPrivileges, when true, omits the ` + "`" + `--security-opt\nno-new-privileges` + "`" + ` flag from scanner container runs (snap-docker/AppArmor\nescape hatch). Absorbs the deprecated top-level\nscanner_disable_no_new_privileges. Default false.","type":"boolean"},"enabled":{"description":"Enabled is the master opt-in for the heavy layer (FR-006). Default false.","type":"boolean"},"fetch_package_source":{"description":"FetchPackageSource controls whether the scanner fetches the PUBLISHED\nsource of package-runner servers (npx/uvx) — without executing it — when\nno local source is available. Absorbs the deprecated top-level\nscanner_fetch_package_source. Default (nil) is ENABLED within deep scan.","type":"boolean"},"scanners":{"description":"Scanners optionally restricts which deep scanners may run under the\numbrella (by scanner id). Empty ⇒ all enabled deep scanners are eligible.","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.DockerIsolationConfig":{"description":"Docker isolation settings","properties":{"cpu_limit":{"description":"CPU limit for containers","type":"string"},"default_images":{"additionalProperties":{"type":"string"},"description":"Map of runtime type to Docker image","type":"object"},"enable_cache_volume":{"description":"Mount shared cache volumes for faster restarts (default: true)","type":"boolean"},"enabled":{"description":"Global enable/disable for Docker isolation (legacy; superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments","items":{"type":"string"},"type":"array","uniqueItems":false},"log_driver":{"description":"Docker log driver (default: json-file)","type":"string"},"log_max_files":{"description":"Maximum number of log files (default: 3)","type":"string"},"log_max_size":{"description":"Maximum size of log files (default: 100m)","type":"string"},"memory_limit":{"description":"Memory limit for containers","type":"string"},"mode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"network_mode":{"description":"Docker network mode (default: bridge)","type":"string"},"registry":{"description":"Custom registry (defaults to docker.io)","type":"string"},"timeout":{"description":"Container startup timeout","type":"string"}},"type":"object"},"config.DockerRecoveryConfig":{"description":"Docker recovery settings","properties":{"enabled":{"description":"Enable Docker recovery monitoring (default: true)","type":"boolean"},"max_retries":{"description":"Maximum retry attempts (0 = unlimited)","type":"integer"},"notify_on_failure":{"description":"Show notification on recovery failure (default: true)","type":"boolean"},"notify_on_retry":{"description":"Show notification on each retry (default: false)","type":"boolean"},"notify_on_start":{"description":"Show notification when recovery starts (default: true)","type":"boolean"},"notify_on_success":{"description":"Show notification on successful recovery (default: true)","type":"boolean"},"persistent_state":{"description":"Save recovery state across restarts (default: true)","type":"boolean"}},"type":"object"},"config.FeatureFlags":{"description":"Deprecated: Features flags are unused and have no runtime effect. Kept for backward compatibility.","properties":{"enable_async_storage":{"type":"boolean"},"enable_caching":{"type":"boolean"},"enable_contract_tests":{"type":"boolean"},"enable_debug_logging":{"description":"Development features","type":"boolean"},"enable_docker_isolation":{"type":"boolean"},"enable_event_bus":{"type":"boolean"},"enable_health_checks":{"type":"boolean"},"enable_metrics":{"type":"boolean"},"enable_oauth":{"description":"Security features","type":"boolean"},"enable_observability":{"description":"Observability features","type":"boolean"},"enable_quarantine":{"type":"boolean"},"enable_runtime":{"description":"Runtime features","type":"boolean"},"enable_search":{"description":"Storage features","type":"boolean"},"enable_sse":{"type":"boolean"},"enable_tracing":{"type":"boolean"},"enable_tray":{"type":"boolean"},"enable_web_ui":{"description":"UI features","type":"boolean"}},"type":"object"},"config.IntentDeclarationConfig":{"description":"Intent declaration settings (Spec 018)","properties":{"strict_server_validation":{"description":"StrictServerValidation controls whether server annotation mismatches\ncause rejection (true) or just warnings (false).\nDefault: true (reject mismatches)","type":"boolean"}},"type":"object"},"config.IsolationConfig":{"description":"Per-server isolation settings","properties":{"enabled":{"description":"Enable Docker isolation for this server (nil = inherit global; legacy, superseded by Mode)","type":"boolean"},"extra_args":{"description":"Additional docker run arguments for this server","items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"description":"Custom Docker image (overrides default)","type":"string"},"log_driver":{"description":"Docker log driver override for this server","type":"string"},"log_max_files":{"description":"Maximum number of log files override","type":"string"},"log_max_size":{"description":"Maximum size of log files override","type":"string"},"mode":{"$ref":"#/components/schemas/config.IsolationMode"},"network_mode":{"description":"Custom network mode for this server","type":"string"},"working_dir":{"description":"Custom working directory in container","type":"string"}},"type":"object"},"config.IsolationMode":{"description":"Isolation mode: \"docker\" | \"sandbox\" | \"none\" (MCP-34.2). Unset per-server inherits the global mode; unset globally falls back to the legacy \"enabled\" flag (true ⇒ docker, false ⇒ none)","type":"string","x-enum-varnames":["IsolationModeDocker","IsolationModeSandbox","IsolationModeNone"]},"config.LogConfig":{"description":"Logging configuration","properties":{"compress":{"type":"boolean"},"enable_console":{"type":"boolean"},"enable_file":{"type":"boolean"},"filename":{"type":"string"},"json_format":{"type":"boolean"},"level":{"type":"string"},"log_dir":{"description":"Custom log directory","type":"string"},"max_age":{"description":"days","type":"integer"},"max_backups":{"description":"number of backup files","type":"integer"},"max_size":{"description":"MB","type":"integer"}},"type":"object"},"config.MetricsExporterConfig":{"description":"Metrics gates the Prometheus /metrics scrape endpoint (MCP-32). Disabled\nby default — operators opt in for k8s/enterprise deployments.","properties":{"enabled":{"description":"Enabled exposes /metrics on the existing HTTP listener when true.","type":"boolean"}},"type":"object"},"config.OAuthConfig":{"description":"OAuth configuration (keep even when empty to signal OAuth requirement)","properties":{"client_id":{"type":"string"},"client_secret":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"description":"Additional OAuth parameters (e.g., RFC 8707 resource)","type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_uri":{"type":"string"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ObservabilityConfig":{"description":"Observability settings (Spec 069): usage aggregate cache/persistence cadence.","properties":{"metrics":{"$ref":"#/components/schemas/config.MetricsExporterConfig"},"tracing":{"$ref":"#/components/schemas/config.TracingExporterConfig"},"usage_cache_ttl":{"description":"UsageCacheTTL bounds the freshness of the usage endpoint's read cache for\nwide windows (FR-005). Default 5s.","type":"string"},"usage_persist_interval":{"description":"UsagePersistInterval is how often the actor-owned usage aggregate snapshot\nis flushed to storage. Default 30s.","type":"string"}},"type":"object"},"config.OutputSanitisationConfig":{"description":"Output sanitisation settings (Spec 054 Track B)","properties":{"max_redactions":{"description":"cap on redactions per response; default 100","type":"integer"},"response_action":{"description":"\"spotlight\" | \"redact\" | \"block\"; default \"spotlight\"","type":"string"},"spotlight_untrusted":{"description":"wrap untrusted output in spotlight markers; default true","type":"boolean"},"strip_classes":{"description":"classes to strip: ansi/c0c1/bidi/zero_width","items":{"type":"string"},"type":"array","uniqueItems":false},"strip_control_chars":{"description":"strip control-character classes; default false","type":"boolean"}},"type":"object"},"config.OutputValidationConfig":{"description":"Output-schema validation settings (Spec 056)","properties":{"max_bytes":{"description":"structured payload byte cap; default 5\u003c\u003c20","type":"integer"},"max_depth":{"description":"nesting depth cap; default 64","type":"integer"},"missing_structured_content":{"description":"\"allow\" | \"block\"; default \"allow\"","type":"string"},"mode":{"description":"\"off\" | \"warn\" | \"strict\"; default \"warn\"","type":"string"}},"type":"object"},"config.ProfileConfig":{"properties":{"name":{"description":"URL slug, validated","type":"string"},"servers":{"description":"references to mcpServers[].name","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.RegistryEntry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag for this registry (MCP-866):\nRegistryProvenanceOfficial for built-in defaults, RegistryProvenanceCustom\nfor user-added registries. It is authoritatively (re)computed by the\nregistries merge from whether the ID is a shipped default — a user cannot\nclaim \"official\" by writing it into their config.","type":"string"},"requires_key":{"description":"RequiresKey marks a registry that needs an API key to be queried. When\ntrue and no key is configured, the registry is skipped/marked unavailable\nrather than failing the whole search (FR-008).","type":"boolean"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"url":{"type":"string"}},"type":"object"},"config.SecurityConfig":{"description":"Security scanner settings (Spec 039)","properties":{"auto_baseline_scan":{"description":"AutoBaselineScan is the kill-switch for the AUTOMATIC, informational\nPass-1 baseline scan: the free in-process TPA scan mcpproxy runs for every\nnewly admitted server (any trust mode) and, once per installation, over\npre-existing servers that have never been scanned.\n\nInformational ONLY: the resulting verdict populates the security badge and\nthe scan summary, and NEVER gates quarantine or approval. The\ntrust_mode:\"scan\" admission gate is a separate path and is unaffected by\nthis flag.\n\nDefault (nil) is ENABLED. Set to false to suppress every automatic scan\n(manual scans keep working). Env override: MCPPROXY_AUTO_BASELINE_SCAN,\nwhich wins over this field on every path.","type":"boolean"},"deep_scan":{"$ref":"#/components/schemas/config.DeepScanConfig"},"integrity_check_interval":{"type":"string"},"integrity_check_on_restart":{"type":"boolean"},"runtime_read_only":{"type":"boolean"},"runtime_tmpfs_size":{"type":"string"},"scan_timeout_default":{"type":"string"},"scanner_disable_no_new_privileges":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.DisableNoNewPrivileges\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.IsDisableNoNewPrivileges. Cleared after migration.\n\nScannerDisableNoNewPrivileges, when true, omits the\n` + "`" + `--security-opt no-new-privileges` + "`" + ` flag from scanner container runs.\n\nBackground: snap-installed Docker on Ubuntu confines dockerd under the\n` + "`" + `snap.docker.dockerd` + "`" + ` AppArmor profile. When runc tries to transition\nthe container into the inner ` + "`" + `docker-default` + "`" + ` profile to exec the\nentrypoint, AppArmor refuses the transition because NO_NEW_PRIVS\nforbids privilege/profile changes on exec — the result is EPERM\n(\"operation not permitted\") and every scanner fails immediately.\n\nSet this to true ONLY on hosts hitting that incompatibility. Scanner\ncontainers still run with read-only rootfs, tmpfs /tmp, no-network by\ndefault, and read-only source mounts, so the marginal isolation loss\nis small. The preferred fix remains replacing snap docker with a\ndistro-packaged docker.","type":"boolean"},"scanner_fetch_package_source":{"description":"Deprecated (Spec 077 US3): migrated on load into DeepScan.FetchPackageSource\n(see migrateDeepScanConfig). Retained only so existing configs that still carry\nthe top-level key parse; consumers MUST read the effective value via\nSecurityConfig.EffectiveFetchPackageSource. Cleared after migration.\n\nScannerFetchPackageSource controls whether the scanner fetches the\nPUBLISHED source of package-runner servers (npx/uvx) — without executing\nit — when no local source is available (no Docker container, no local\npackage cache, no working_dir). This is the primary quarantine/scan\ntarget: a quarantined-on-add server is never run locally, so without this\nthe scan degrades to tool-definitions-only (no real source-level\nanalysis). See MCP-2206.\n\nFetching uses ` + "`" + `npm pack --ignore-scripts` + "`" + ` (npm) and ` + "`" + `uv pip download` + "`" + ` /\n` + "`" + `pip download` + "`" + ` with ` + "`" + `--only-binary=:all:` + "`" + ` (Python), which only download +\nunpack archives and NEVER run install, build, or setup.py — a scanner must\nnot execute the untrusted code it is scanning. The Python\n` + "`" + `--only-binary=:all:` + "`" + ` flag is required because downloading an sdist would\ninvoke its build backend (setup.py); packages with no wheel fall back to\ntool-definitions-only instead. Extraction is hardened against path\ntraversal and decompression bombs.\n\nDefault (nil) is ENABLED. Set to false on air-gapped deployments to\nforbid the scanner's network egress; such servers then fall back to the\ntool-definitions-only scan with no regression.","type":"boolean"},"scanner_registry_url":{"type":"string"},"tpa_bundle_path":{"description":"TPABundlePath is the filesystem path to the tpa-db scanner-bundle.json\nthe offline TPA scanner runs (spec 086 FR-019: the signature-DB location\nMUST be configuration-driven, not hardcoded). Empty (the default) runs the\ncorpus embedded in this build.\n\nEnv override: MCPPROXY_TPA_BUNDLE_PATH. Hot-reloadable — the path is\nre-read on every config.reloaded event via\nscanner.Service.ApplySecurityConfig, so a corpus refresh needs no restart.\nA configured bundle that fails to read/parse/version-check/compile is\nREFUSED and the previously active corpus stays live (fail-closed, never\nfail-empty); the reason is logged and surfaced in the security overview's\nsignature_bundle.load_error.","type":"string"}},"type":"object"},"config.SensitiveDataDetectionConfig":{"description":"Sensitive data detection settings (Spec 026)","properties":{"categories":{"additionalProperties":{"type":"boolean"},"description":"Enable/disable specific detection categories","type":"object"},"custom_patterns":{"description":"User-defined detection patterns","items":{"$ref":"#/components/schemas/config.CustomPattern"},"type":"array","uniqueItems":false},"enabled":{"description":"Enable sensitive data detection (default: true)","type":"boolean"},"entropy_threshold":{"description":"Shannon entropy threshold for high-entropy detection (default: 4.5)","type":"number"},"max_payload_size_kb":{"description":"Max size to scan before truncating (default: 1024)","type":"integer"},"scan_requests":{"description":"Scan tool call arguments (default: true)","type":"boolean"},"scan_responses":{"description":"Scan tool responses (default: true)","type":"boolean"},"sensitive_keywords":{"description":"Keywords to flag","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"config.ServerConfig":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve tool\nchanges/additions (disabling per-server rug-pull protection). Supersedes\nskip_quarantine. MCP-2930 only ACCEPTS, persists, and migrates this flag — it\nis NOT yet consulted at runtime; auto-approval is still governed by\nSkipQuarantine until the trust-baseline behavior change (MCP-2931) migrates the\nruntime consumers onto it.\nTri-state pointer (mirrors QuarantineEnabled): nil = unset (inherit/migrate\nfrom legacy skip_quarantine), explicit true/false = honored as-is so an\nexplicit auto_approve_tool_changes:false overrides a legacy skip_quarantine:true.\nRead via IsAutoApproveToolChanges().","type":"boolean"},"command":{"type":"string"},"created":{"type":"string"},"disabled_tools":{"description":"Denylist: these tools are hidden; mutually exclusive with enabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"enabled":{"type":"boolean"},"enabled_tools":{"description":"Allowlist: only these tools are exposed; mutually exclusive with disabled_tools","items":{"type":"string"},"type":"array","uniqueItems":false},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts overrides whether this server's advertised MCP prompts are\naggregated into mcpproxy's prompts/list. nil (default) inherits the\ndefault-aggregate behavior (included if the server advertises\nCapabilities.Prompts); false excludes it regardless of capability.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"description":"For HTTP servers","type":"object"},"health_check_interval":{"description":"Per-server discovery \u0026 health-check overrides (spec 074). Same *Duration\ntri-state as the global keys: nil = inherit the global value (or default),\npointer to 0s = disabled for this server, positive = that interval.\nHealthCheckInterval is fully wired into the per-server health loop;\nToolDiscoveryInterval is accepted/validated and round-trips for\nforward-compat, but the periodic index sweep is governed by the global\ncadence in this iteration (see spec 074 plan §C).","type":"string"},"init_timeout":{"description":"InitTimeout overrides the global init_timeout for this server's MCP\n` + "`" + `initialize` + "`" + ` handshake deadline (MCP-3322 / GH #760). *Duration tri-state:\nnil = inherit the global value (or 30s default), positive = that deadline.\nResolved by Config.ResolveInitTimeout; validated to {0} ∪ [1s, 30m]. Raise\nthis for upstreams that do legitimate first-run warmup (e.g. caching many\nchannels/users) before responding to ` + "`" + `initialize` + "`" + `.","type":"string"},"isolation":{"$ref":"#/components/schemas/config.IsolationConfig"},"launcher_wait_timeout":{"description":"LauncherWaitTimeout caps how long mcpproxy will wait for a locally-launched\nHTTP/SSE upstream's URL to become reachable after Spawn(). Only consulted\nwhen the server is configured with both Command and an HTTP/SSE URL — i.e.,\nmcpproxy starts the process AND connects via network. Stdio servers ignore\nthis field. Zero or unset → 30s default.","type":"string"},"max_concurrent_requests":{"description":"Per-server concurrency overrides — scope (c) of FR-020 (spec 093, #955).\nTri-state per setting, exactly like HealthCheckInterval: absent = inherit\nthe per-server default set (server_concurrency_defaults), explicit 0 =\ndisable that setting for this server (0 max = no per-server limiter at\nall; 0 queue_size = no pending capacity, shed immediately at the cap),\npositive = override. The global aggregate limiter is never inherited from\nhere — it applies on top, so effective concurrency is min(per-server,\nglobal). Resolved by Config.ResolveServerConcurrency.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/config.OAuthConfig"},"protocol":{"description":"stdio, http, sse, streamable-http, auto","type":"string"},"quarantined":{"description":"Security quarantine status","type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets a disconnected server","type":"boolean"},"shared":{"description":"Server edition: shared with all users","type":"boolean"},"skip_quarantine":{"description":"SkipQuarantine is DEPRECATED (MCP-2930): use AutoApproveToolChanges instead.\nKept for back-compat parsing; on config load a legacy skip_quarantine:true is\nmigrated to auto_approve_tool_changes:true only when the new field is unset\n(see normalizeServerQuarantineFlags).","type":"boolean"},"source_registry_id":{"description":"SourceRegistryID records which registry this server was added from (empty\nfor manually-configured servers). MCP-866: surfaced in the approval /\nquarantine view so a reviewer can see a server's origin.","type":"string"},"source_registry_provenance":{"description":"SourceRegistryProvenance records the source registry's provenance at add\ntime (RegistryProvenanceOfficial / RegistryProvenanceCustom). It is purely\ninformational (MCP-1072) — surfaced so a reviewer can see a server's origin\n— and no longer gates quarantine or skip_quarantine.","type":"string"},"tool_discovery_interval":{"type":"string"},"toon_output":{"description":"ToonOutput overrides the global toon_output mode for this server's\ntools (spec 084, FR-001). Plain string, not a pointer: \"\"/absent =\ninherit the global value; \"off\"|\"adaptive\"|\"always\" = override (\"off\"\nis the explicit force-off). Resolved by Config.ResolveToonOutput.","type":"string"},"trust_mode":{"description":"TrustMode is the per-server trust tier: auto|scan|manual. Supersedes\nauto_approve_tool_changes (spec 086). An empty value is derived from the\nlegacy fields at load via normalizeServerQuarantineFlags; the single\nresolution point is EffectiveTrustMode(), which treats an empty or\nunrecognized value as manual (secure by default). Read via\nEffectiveTrustMode(), never the raw string.","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"working_dir":{"description":"Working directory for stdio servers","type":"string"}},"type":"object"},"config.TLSConfig":{"description":"TLS configuration","properties":{"certs_dir":{"description":"Directory for certificates","type":"string"},"enabled":{"description":"Enable HTTPS","type":"boolean"},"hsts":{"description":"Enable HTTP Strict Transport Security","type":"boolean"},"require_client_cert":{"description":"Enable mTLS","type":"boolean"}},"type":"object"},"config.TelemetryConfig":{"description":"Telemetry settings (Spec 036)","properties":{"anonymous_id":{"description":"Auto-generated UUIDv4","type":"string"},"anonymous_id_created_at":{"description":"Spec 042 (Tier 2) additions — all default-zero, all backwards-compatible.","type":"string"},"enabled":{"description":"Default: true (opt-out)","type":"boolean"},"endpoint":{"description":"Override for testing","type":"string"},"last_reported_version":{"description":"Upgrade funnel","type":"string"},"last_startup_outcome":{"description":"success|port_conflict|db_locked|...","type":"string"},"notice_shown":{"description":"First-run notice flag","type":"boolean"}},"type":"object"},"config.TokenizerConfig":{"description":"Tokenizer configuration for token counting","properties":{"default_model":{"description":"Default model for tokenization (e.g., \"gpt-4\")","type":"string"},"enabled":{"description":"Enable token counting","type":"boolean"},"encoding":{"description":"Default encoding (e.g., \"cl100k_base\")","type":"string"}},"type":"object"},"config.TracingExporterConfig":{"description":"Tracing gates the OpenTelemetry OTLP trace exporter (MCP-32). Disabled by\ndefault.","properties":{"enabled":{"description":"Enabled turns on OTLP trace export for tool calls and upstream hops.","type":"boolean"},"endpoint":{"description":"Endpoint is the collector address as host:port (no scheme), e.g.\n\"localhost:4318\" for http or \"localhost:4317\" for grpc.","type":"string"},"protocol":{"description":"Protocol selects the OTLP transport: \"http\" or \"grpc\".","type":"string"},"sample_rate":{"description":"SampleRate is the head-based trace sampling ratio in [0,1]. Default 0.1.\nOmit the key for the 0.1 default; set it to 0 to sample nothing.","type":"number"}},"type":"object"},"config.UpdateCheckConfig":{"description":"Update-check settings (Spec 079 FR-012): config-file control of the\nbackground upgrade-awareness checker (internal/updatecheck). nil =\nenabled on the stable channel (existing default behavior). The existing\nenvironment switches keep working and WIN over these keys (FR-014):\nMCPPROXY_DISABLE_AUTO_UPDATE=true force-disables even when\nenabled=true, and MCPPROXY_ALLOW_PRERELEASE_UPDATES=true force-selects\nthe rc channel even when channel=stable.","properties":{"channel":{"description":"Channel selects which releases are offered as updates: \"stable\"\n(default; prereleases never offered) or \"rc\" (prereleases included).\nEmpty resolves to stable. Validated in ValidateDetailed.\n\nNOTE: for a RELEASED build the running binary's own version is\nauthoritative and overrides this field — a stable build is never\noffered an RC (even with channel=rc), and an RC build always tracks the\nrc channel. This field only takes effect on dev/unstamped builds. See\ninternal/updatecheck.Checker.IncludePrereleases.","type":"string"},"enabled":{"description":"Enabled gates all update checking. Tri-state: nil/absent = enabled\n(default true, matching pre-079 behavior). When false, no network\ncheck is performed and no upgrade nudge appears on any surface\n(FR-015) — /api/v1/info omits the update object entirely.","type":"boolean"}},"type":"object"},"configimport.FailedServer":{"properties":{"details":{"type":"string"},"error":{"type":"string"},"name":{"type":"string"}},"type":"object"},"configimport.ImportSummary":{"properties":{"failed":{"type":"integer"},"imported":{"type":"integer"},"skipped":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"configimport.SkippedServer":{"properties":{"name":{"type":"string"},"reason":{"description":"\"already_exists\", \"filtered_out\", \"invalid_name\"","type":"string"}},"type":"object"},"connect.ConnectResult":{"description":"The full result; its action mirrors the top-level one","properties":{"action":{"description":"\"created\", \"updated\", \"already_exists\", \"removed\", \"not_found\"","type":"string"},"backup_path":{"type":"string"},"client":{"type":"string"},"config_path":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.APIResponse":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ActivityDetailResponse":{"properties":{"activity":{"$ref":"#/components/schemas/contracts.ActivityRecord"}},"type":"object"},"contracts.ActivityListResponse":{"properties":{"activities":{"items":{"$ref":"#/components/schemas/contracts.ActivityRecord"},"type":"array","uniqueItems":false},"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.ActivityRecord":{"properties":{"arguments":{"description":"Tool call arguments","type":"object"},"detection_types":{"description":"List of detection types found","items":{"type":"string"},"type":"array","uniqueItems":false},"duration_ms":{"description":"Execution duration in milliseconds","type":"integer"},"error_message":{"description":"Error details if status is \"error\"","type":"string"},"has_sensitive_data":{"description":"Sensitive data detection fields (Spec 026)","type":"boolean"},"id":{"description":"Unique identifier (ULID format)","type":"string"},"max_severity":{"description":"Highest severity level detected (critical, high, medium, low)","type":"string"},"metadata":{"description":"Additional context-specific data","type":"object"},"parent_id":{"description":"Correlation id of the parent call (the code_execution whose sandbox issued this sub-call)","type":"string"},"request_bytes":{"description":"Byte sizes measured pre-truncation, mirroring storage.ActivityRecord\n(Spec 069 A1). They are the only cost signal a bodies-off export carries:\nwith payloads suppressed there is no text left to measure, so a consumer\naccounting for a record it cannot read has nothing else to go on. They are\nbyte LENGTHS, not token counts — the basis for an explicit estimate, never\na measured figure (spec 103, contracts/replay-input.md).\n\nZero means UNKNOWN, not free: legacy records predate the measurement and\ncode-execution sub-calls record both as zero. Hence omitempty — an absent\nkey tells a consumer to fall to exclusion accounting, whereas a present\nzero would read as a costless call and silently understate the workload.","type":"integer"},"request_id":{"description":"HTTP request ID for correlation","type":"string"},"response":{"description":"Tool response (potentially truncated)","type":"string"},"response_bytes":{"description":"Raw upstream response size in bytes before truncation","type":"integer"},"response_truncated":{"description":"True if response was truncated","type":"boolean"},"server_name":{"description":"Name of upstream MCP server","type":"string"},"session_id":{"description":"MCP transport session ID (regenerated on every reconnect)","type":"string"},"source":{"$ref":"#/components/schemas/contracts.ActivitySource"},"status":{"description":"Result status: \"success\", \"error\", \"blocked\", \"rejected\"","type":"string"},"timestamp":{"description":"When activity occurred","type":"string"},"tool_name":{"description":"Name of tool called","type":"string"},"type":{"$ref":"#/components/schemas/contracts.ActivityType"},"work_session_id":{"description":"Spec 082: one client, one project, across reconnects","type":"string"}},"type":"object"},"contracts.ActivitySource":{"description":"How activity was triggered: \"mcp\", \"cli\", \"api\"","type":"string","x-enum-varnames":["ActivitySourceMCP","ActivitySourceCLI","ActivitySourceAPI"]},"contracts.ActivitySummaryResponse":{"properties":{"blocked_count":{"description":"Count of blocked activities","type":"integer"},"call_count":{"description":"CallCount is how many of those records are CALLS THE USER MADE, as\ndefined once in storage.CountsAsCall and shared with the usage aggregate\nbehind the Usage tab (audit finding F1, #1046). TotalCount answers \"how\nmany rows does the Activity Log have\"; CallCount answers \"how many calls\nwere there\". They are different questions — quarantine auto-approvals,\nsystem start, security scans and management chatter are events, not calls\n— and printing either one under the other's label is how the same instance\ncame to report 51 calls on one screen and 19 on another.","type":"integer"},"call_error_count":{"description":"CallErrorCount is the failures within CallCount, so an error RATE computed\nfrom this response has one denominator. It is not ErrorCount: a policy\nblock is a failed call but carries status \"blocked\", and a shed call is an\nerror in neither sense because it never ran.","type":"integer"},"end_time":{"description":"End of the period (RFC3339)","type":"string"},"error_count":{"description":"Count of error activities","type":"integer"},"other_count":{"description":"OtherCount is every record whose status is outside the four-value\nvocabulary above, so that\n\n\tsuccess + error + blocked + rejected + other == total\n\nholds by construction. The status field is a CLOSED vocabulary for tool\ncalls, but the activity log is wider than tool calls: a quarantine change\nstores its ACTION there (\"approved\", \"auto_approved\"), a policy decision\nstores its DECISION (\"allow\"). Those rows were counted in the total and in\nnone of the four buckets, so the Activity Log's own status tiles summed to\nless than the denominator printed beside them — 15+4+0+0 under a \"42\"\n(audit finding F2, #1046). The residual now has a name and a tile.","type":"integer"},"period":{"description":"Time period (1h, 24h, 7d, 30d)","type":"string"},"rejected_count":{"description":"RejectedCount is the number of calls shed by a concurrency limiter before\nthey reached an upstream (spec 093). Counted separately from errors: it is\nproxy backpressure, not an upstream fault, and it is the signal an\noperator right-sizes max_concurrent_requests against.","type":"integer"},"start_time":{"description":"Start of the period (RFC3339)","type":"string"},"success_count":{"description":"Count of successful activities","type":"integer"},"top_servers":{"description":"Top servers by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopServer"},"type":"array","uniqueItems":false},"top_tools":{"description":"Top tools by activity count","items":{"$ref":"#/components/schemas/contracts.ActivityTopTool"},"type":"array","uniqueItems":false},"total_count":{"description":"Total activity count","type":"integer"}},"type":"object"},"contracts.ActivityTopServer":{"properties":{"count":{"description":"Activity count","type":"integer"},"name":{"description":"Server name","type":"string"}},"type":"object"},"contracts.ActivityTopTool":{"properties":{"count":{"description":"Activity count","type":"integer"},"server":{"description":"Server name","type":"string"},"tool":{"description":"Tool name","type":"string"}},"type":"object"},"contracts.ActivityType":{"description":"Type of activity","type":"string","x-enum-varnames":["ActivityTypeToolCall","ActivityTypePolicyDecision","ActivityTypeQuarantineChange","ActivityTypeServerChange"]},"contracts.AddFromRegistryRequest":{"properties":{"enabled":{"description":"defaults to true when nil","type":"boolean"},"env":{"additionalProperties":{"type":"string"},"description":"overrides + required-input values","type":"object"},"name":{"description":"optional name override","type":"string"}},"type":"object"},"contracts.AddRegistrySourceRequest":{"properties":{"id":{"description":"derived from the host when empty","type":"string"},"name":{"description":"defaults to the id","type":"string"},"protocol":{"description":"defaults to modelcontextprotocol/registry","type":"string"},"url":{"description":"required https registry URL","type":"string"}},"type":"object"},"contracts.ConfigApplyResult":{"properties":{"applied_immediately":{"type":"boolean"},"changed_fields":{"items":{"type":"string"},"type":"array","uniqueItems":false},"requires_restart":{"type":"boolean"},"restart_reason":{"type":"string"},"success":{"type":"boolean"},"validation_errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DCRStatus":{"properties":{"attempted":{"type":"boolean"},"error":{"type":"string"},"status_code":{"type":"integer"},"success":{"type":"boolean"}},"type":"object"},"contracts.DeepScanDescriptor":{"description":"DeepScan reports the opt-in \"deep scan\" layer status (Spec 077 US3),\nSEPARATELY from the baseline verdict above. Always emitted on a computed\nsummary — when deep scan is off (the default) it reports enabled=false\nplus any enabled-but-skipped Docker scanners. It never influences Status.","properties":{"available":{"type":"boolean"},"enabled":{"type":"boolean"},"ran":{"type":"boolean"},"scanners_failed":{"items":{"$ref":"#/components/schemas/contracts.DeepScanScannerFailure"},"type":"array","uniqueItems":false},"skipped_scanners":{"description":"SkippedScanners lists Docker scanners the user enabled that are skipped\nbecause security.deep_scan.enabled is false (informational).","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DeepScanScannerFailure":{"properties":{"id":{"type":"string"},"reason":{"type":"string"}},"type":"object"},"contracts.DeprecatedConfigWarning":{"properties":{"field":{"type":"string"},"message":{"type":"string"},"replacement":{"type":"string"}},"type":"object"},"contracts.Diagnostic":{"description":"Spec 044 — structured diagnostic error and stable error code. Both\nare populated when the server is in a failed state and the error\nhas been classified by internal/diagnostics. Healthy servers omit\nthese fields.","properties":{"cause":{"type":"string"},"code":{"type":"string"},"detected_at":{"type":"string"},"docs_url":{"type":"string"},"fix_steps":{"items":{"$ref":"#/components/schemas/contracts.DiagnosticFixStep"},"type":"array","uniqueItems":false},"severity":{"type":"string"},"user_message":{"type":"string"}},"type":"object"},"contracts.DiagnosticFixStep":{"properties":{"command":{"type":"string"},"destructive":{"type":"boolean"},"fixer_key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"url":{"type":"string"}},"type":"object"},"contracts.Diagnostics":{"properties":{"deprecated_configs":{"description":"Deprecated config fields found","items":{"$ref":"#/components/schemas/contracts.DeprecatedConfigWarning"},"type":"array","uniqueItems":false},"docker_status":{"$ref":"#/components/schemas/contracts.DockerStatus"},"missing_secrets":{"description":"Renamed to avoid conflict","items":{"$ref":"#/components/schemas/contracts.MissingSecretInfo"},"type":"array","uniqueItems":false},"oauth_issues":{"description":"OAuth parameter mismatches","items":{"$ref":"#/components/schemas/contracts.OAuthIssue"},"type":"array","uniqueItems":false},"oauth_required":{"items":{"$ref":"#/components/schemas/contracts.OAuthRequirement"},"type":"array","uniqueItems":false},"runtime_warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false},"timestamp":{"type":"string"},"total_issues":{"type":"integer"},"upstream_errors":{"items":{"$ref":"#/components/schemas/contracts.UpstreamError"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.DockerStatus":{"properties":{"available":{"type":"boolean"},"error":{"type":"string"},"version":{"type":"string"}},"type":"object"},"contracts.EditRegistrySourceRequest":{"properties":{"name":{"description":"new display name","type":"string"},"servers_url":{"description":"explicit servers-collection URL","type":"string"},"url":{"description":"new base/servers https URL","type":"string"}},"type":"object"},"contracts.ErrorResponse":{"properties":{"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.FindingCounts":{"properties":{"dangerous":{"description":"Tool poisoning, active prompt injection","type":"integer"},"info":{"description":"Low-severity CVEs, informational","type":"integer"},"total":{"type":"integer"},"warning":{"description":"Rug pull, supply chain CVEs with exploits","type":"integer"}},"type":"object"},"contracts.GetConfigResponse":{"properties":{"config":{"description":"The configuration object","type":"object"},"config_path":{"description":"Path to config file","type":"string"}},"type":"object"},"contracts.GetRegistriesResponse":{"properties":{"registries":{"items":{"$ref":"#/components/schemas/contracts.Registry"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerLogsResponse":{"properties":{"count":{"type":"integer"},"logs":{"items":{"$ref":"#/components/schemas/contracts.LogEntry"},"type":"array","uniqueItems":false},"server_name":{"type":"string"}},"type":"object"},"contracts.GetServerToolCallsResponse":{"properties":{"server_name":{"type":"string"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetServerToolsResponse":{"properties":{"count":{"type":"integer"},"server_name":{"type":"string"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GetServersResponse":{"properties":{"servers":{"items":{"$ref":"#/components/schemas/contracts.Server"},"type":"array","uniqueItems":false},"stats":{"$ref":"#/components/schemas/contracts.ServerStats"}},"type":"object"},"contracts.GetSessionDetailResponse":{"properties":{"session":{"$ref":"#/components/schemas/contracts.MCPSession"}},"type":"object"},"contracts.GetSessionsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"sessions":{"items":{"$ref":"#/components/schemas/contracts.MCPSession"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GetToolCallDetailResponse":{"properties":{"tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"}},"type":"object"},"contracts.GetToolCallsResponse":{"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"tool_calls":{"items":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"type":"array","uniqueItems":false},"total":{"type":"integer"}},"type":"object"},"contracts.GlobalToolsResponse":{"properties":{"failed_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"partial":{"type":"boolean"},"stats":{"$ref":"#/components/schemas/contracts.GlobalToolsStats"},"tools":{"items":{"$ref":"#/components/schemas/contracts.Tool"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.GlobalToolsStats":{"properties":{"disabled":{"type":"integer"},"enabled":{"type":"integer"},"pending_approval":{"type":"integer"},"total":{"type":"integer"}},"type":"object"},"contracts.HealthStatus":{"description":"Unified health status calculated by the backend","properties":{"action":{"description":"Action is the suggested fix action: \"login\", \"restart\", \"enable\", \"approve\", \"view_logs\", \"set_secret\", \"configure\", \"edit_url\", or \"\" (none)","type":"string"},"admin_state":{"description":"AdminState indicates the admin state: \"enabled\", \"disabled\", or \"quarantined\"","type":"string"},"detail":{"description":"Detail is an optional longer explanation of the status","type":"string"},"level":{"description":"Level indicates the health level: \"healthy\", \"degraded\", or \"unhealthy\"","type":"string"},"summary":{"description":"Summary is a human-readable status message (e.g., \"Connected (5 tools)\")","type":"string"}},"type":"object"},"contracts.InfoEndpoints":{"description":"Available API endpoints","properties":{"http":{"description":"HTTP endpoint address (e.g., \"127.0.0.1:8080\")","type":"string"},"socket":{"description":"Unix socket path (empty if disabled)","type":"string"}},"type":"object"},"contracts.InfoResponse":{"properties":{"endpoints":{"$ref":"#/components/schemas/contracts.InfoEndpoints"},"launched_by":{"description":"LaunchedBy is the durable launch provenance of the running core (Spec\n092 FR-001a): \"tray\" when a tray spawned it, \"installer\" when the macOS\nPKG postinstall did, \"\" when user-launched or unknown. Always present\n(possibly empty) so a tray can distinguish \"old core, not mine\" from\n\"old core I may supersede\".","type":"string"},"listen_addr":{"description":"Listen address (e.g., \"127.0.0.1:8080\")","type":"string"},"pid":{"description":"PID is the operating-system process id of the running core (Spec 092\nFR-002). A tray that merely ATTACHED to a core holds no Process handle\nfor it, so without this there is no mechanism at all to stop a stale\ncore — the consent action would have nothing to act on and could only\nprint instructions. Paired with LaunchedBy it is what lets a newer tray\nsupersede a core an older tray started.","type":"integer"},"update":{"$ref":"#/components/schemas/contracts.UpdateInfo"},"update_policy":{"$ref":"#/components/schemas/contracts.UpdatePolicy"},"version":{"description":"Current MCPProxy version","type":"string"},"web_ui_url":{"description":"URL to access the web control panel","type":"string"}},"type":"object"},"contracts.IsolationConfig":{"properties":{"cpu_limit":{"type":"string"},"enabled":{"description":"Enabled is the EFFECTIVE isolation state for this server: whether its\nprocess is actually CONFINED, after the global setting, the per-server\noverride, the structural gates and the host's capabilities. It is NOT the\nraw per-server override — read EnabledOverride for that (GH #1142).\n\nREAD-ONLY. The write surfaces reject an ` + "`" + `enabled` + "`" + ` key precisely because\nit is derived: echoing it back would convert \"inherits the global\nsetting\" into a permanent explicit override. Write EnabledOverride.\n\nIt stays a non-pointer bool that is always present on the wire: the macOS\ntray decodes it as a non-optional Swift Bool, so omitting or nulling the\nkey would fail Codable for the whole server payload. Older clients that\nread this field now simply get a true answer.","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the RAW per-server ` + "`" + `isolation.enabled` + "`" + ` override, as\npersisted. Absent means \"inherit the global setting\" — which is a\ndistinct state from an explicit false, and the distinction the reporting\nbug used to destroy.","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"memory_limit":{"type":"string"},"mode_override":{"description":"ModeOverride is the RAW per-server ` + "`" + `isolation.mode` + "`" + ` override\n(\"docker\" | \"sandbox\" | \"none\"). Absent means \"inherit\".","type":"string"},"network_mode":{"type":"string"},"timeout":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationDefaults":{"description":"IsolationDefaults exposes the resolved baseline values that\nwould apply when no per-server override is set. Populated on\nlist/get responses; never consumed on PATCH requests.","properties":{"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"network_mode":{"type":"string"},"runtime_type":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"contracts.IsolationEffective":{"description":"IsolationEffective exposes the resolved isolation state (and the rule\nthat decided it) so clients can distinguish \"inherits global\" from an\nexplicit per-server choice. Read-only; never consumed on PATCH.","properties":{"global_mode":{"description":"GlobalMode is what \"inherit\" resolves to right now.","type":"string"},"inherited":{"description":"Inherited is true when the server sets neither ` + "`" + `isolation.enabled` + "`" + ` nor\n` + "`" + `isolation.mode` + "`" + `, so its state tracks the global setting.","type":"boolean"},"isolated":{"description":"Isolated reports whether the process is actually CONFINED. It is NOT\nsimply Mode != \"none\": \"sandbox\" on a host that cannot enforce Landlock\n(any non-Linux OS, or a kernel without the LSM) runs the server\nunconfined, and Source then says \"sandbox-unavailable\" (GH #1142).","type":"boolean"},"mode":{"description":"Mode is the effective isolation mode: \"docker\" | \"sandbox\" | \"none\" —\nexactly what the spawn path branches on.","type":"string"},"source":{"description":"Source names the deciding rule: \"global\", \"server-mode\",\n\"server-opt-out\", \"server-opt-in-ignored\", \"not-stdio\",\n\"already-docker\", \"sandbox-unavailable\" or \"unsupported-mode\".\nTreat an unrecognized value as \"global\".","type":"string"}},"type":"object"},"contracts.LogEntry":{"properties":{"fields":{"type":"object"},"level":{"type":"string"},"message":{"type":"string"},"server":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.MCPSession":{"properties":{"client_name":{"type":"string"},"client_version":{"type":"string"},"end_time":{"type":"string"},"experimental":{"items":{"type":"string"},"type":"array","uniqueItems":false},"has_roots":{"description":"MCP Client Capabilities","type":"boolean"},"has_sampling":{"type":"boolean"},"id":{"type":"string"},"last_activity":{"type":"string"},"start_time":{"type":"string"},"status":{"type":"string"},"tool_call_count":{"type":"integer"},"total_tokens":{"type":"integer"},"work_session_id":{"type":"string"},"workspace_name":{"description":"Workspace / work session (Spec 082). WorkspaceName is the project's\nbasename — the full local path is never exposed. WorkSessionID groups the\nreconnects that make up one stretch of user work.","type":"string"}},"type":"object"},"contracts.MetadataStatus":{"properties":{"authorization_servers":{"items":{"type":"string"},"type":"array","uniqueItems":false},"error":{"type":"string"},"found":{"type":"boolean"},"url_checked":{"type":"string"}},"type":"object"},"contracts.MissingSecretInfo":{"properties":{"secret_name":{"type":"string"},"used_by":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"contracts.NPMPackageInfo":{"properties":{"exists":{"type":"boolean"},"install_cmd":{"type":"string"}},"type":"object"},"contracts.OAuthConfig":{"properties":{"auth_url":{"type":"string"},"client_id":{"type":"string"},"extra_params":{"additionalProperties":{"type":"string"},"type":"object"},"pkce_enabled":{"type":"boolean"},"redirect_port":{"type":"integer"},"scopes":{"items":{"type":"string"},"type":"array","uniqueItems":false},"token_expires_at":{"description":"When the OAuth token expires","type":"string"},"token_url":{"type":"string"},"token_valid":{"description":"Whether token is currently valid","type":"boolean"}},"type":"object"},"contracts.OAuthErrorDetails":{"description":"Structured discovery/failure details","properties":{"authorization_server_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"dcr_status":{"$ref":"#/components/schemas/contracts.DCRStatus"},"protected_resource_metadata":{"$ref":"#/components/schemas/contracts.MetadataStatus"},"server_url":{"type":"string"}},"type":"object"},"contracts.OAuthFlowError":{"properties":{"correlation_id":{"description":"Flow tracking ID for log correlation","type":"string"},"debug_hint":{"description":"CLI command for log lookup","type":"string"},"details":{"$ref":"#/components/schemas/contracts.OAuthErrorDetails"},"error_code":{"description":"Machine-readable error code (e.g., OAUTH_NO_METADATA)","type":"string"},"error_type":{"description":"Category of OAuth runtime failure","type":"string"},"message":{"description":"Human-readable error description","type":"string"},"request_id":{"description":"HTTP request ID (from PR #237)","type":"string"},"server_name":{"description":"Server that failed OAuth","type":"string"},"success":{"description":"Always false","type":"boolean"},"suggestion":{"description":"Actionable remediation hint","type":"string"}},"type":"object"},"contracts.OAuthIssue":{"properties":{"documentation_url":{"type":"string"},"error":{"type":"string"},"issue":{"type":"string"},"missing_params":{"items":{"type":"string"},"type":"array","uniqueItems":false},"resolution":{"type":"string"},"server_name":{"type":"string"}},"type":"object"},"contracts.OAuthRequirement":{"properties":{"expires_at":{"type":"string"},"message":{"type":"string"},"server_name":{"type":"string"},"state":{"type":"string"}},"type":"object"},"contracts.OAuthStartResponse":{"properties":{"auth_url":{"description":"Authorization URL (always included for manual use)","type":"string"},"browser_error":{"description":"Error message if browser launch failed","type":"string"},"browser_opened":{"description":"Whether browser launch succeeded","type":"boolean"},"correlation_id":{"description":"UUID for tracking this flow","type":"string"},"message":{"description":"Human-readable status message","type":"string"},"server_name":{"description":"Name of the server being authenticated","type":"string"},"success":{"description":"Always true for successful start","type":"boolean"}},"type":"object"},"contracts.PreflightPolicy":{"properties":{"exclude_destructive":{"type":"boolean"},"exclude_open_world":{"type":"boolean"},"read_only_only":{"type":"boolean"}},"type":"object"},"contracts.PreflightReason":{"type":"string","x-enum-varnames":["PreflightReasonServerInitializing","PreflightReasonServerUnhealthy","PreflightReasonServerDisabled","PreflightReasonServerQuarantined","PreflightReasonToolPendingApproval","PreflightReasonToolChanged","PreflightReasonToolBlockedByUser","PreflightReasonOAuthRequired","PreflightReasonHashMismatch","PreflightReasonServerNotInScope","PreflightReasonToolDeniedByConfig","PreflightReasonMissingAnnotation","PreflightReasonPolicyFiltered","PreflightReasonNotFound","PreflightReasonServerNotConfigured"]},"contracts.PreflightRequest":{"properties":{"policy":{"$ref":"#/components/schemas/contracts.PreflightPolicy"},"profile":{"description":"Profile evaluates under a named profile's server scope. Unknown: 400.","type":"string"},"tools":{"description":"Tools is 1..100 entries BEFORE dedup; duplicates are collapsed, and\nduplicate ids carrying different pins are a validation error.","items":{"$ref":"#/components/schemas/contracts.PreflightToolRef"},"type":"array","uniqueItems":false},"wait_ms":{"description":"WaitMS polls local state for up to this many milliseconds (cap 10000)\nwhile every failure is retryable-class.","type":"integer"}},"type":"object"},"contracts.PreflightResponse":{"properties":{"checked_at":{"type":"string"},"tools":{"description":"Tools are ordered by first occurrence of each unique id in the request.","items":{"$ref":"#/components/schemas/contracts.PreflightToolResult"},"type":"array","uniqueItems":false},"verdict":{"$ref":"#/components/schemas/contracts.PreflightVerdict"},"waited_ms":{"description":"WaitedMS is present when wait_ms was requested (0 when the wait\nsemaphore was exhausted and the request resolved immediately).","type":"integer"}},"type":"object"},"contracts.PreflightStatus":{"type":"string","x-enum-varnames":["PreflightStatusReady","PreflightStatusUnavailable"]},"contracts.PreflightToolRef":{"properties":{"id":{"description":"ID is a canonical \"\u003cserver\u003e:\u003ctool\u003e\" id. A malformed id is answered with a\nper-ID not_found carrying a format hint, never a request-level error.","type":"string"},"pin_hash":{"description":"PinHash is \"sha256/v{N}:{hex}\" — the schema version is embedded so a\nproxy-side hash-algorithm bump is distinguishable from upstream drift.","type":"string"}},"type":"object"},"contracts.PreflightToolResult":{"properties":{"action":{"type":"string"},"detail":{"type":"string"},"did_you_mean":{"description":"DidYouMean carries up to 3 nearest caller-visible ids on not_found. It\nnever crosses a scope boundary and never names a quarantined server's\ntools.","items":{"type":"string"},"type":"array","uniqueItems":false},"hash":{"description":"Hash is the tool's current pin (\"sha256/v{N}:{hex}\") — operator tier,\nready results only. Never disclosed to an agent token.","type":"string"},"id":{"type":"string"},"reason":{"$ref":"#/components/schemas/contracts.PreflightReason"},"remediation":{"type":"string"},"retryable":{"type":"boolean"},"status":{"$ref":"#/components/schemas/contracts.PreflightStatus"}},"type":"object"},"contracts.PreflightVerdict":{"type":"string","x-enum-varnames":["PreflightVerdictReady","PreflightVerdictDegradedRetryable","PreflightVerdictBlocked","PreflightVerdictUnknownIDs"]},"contracts.QuarantineStats":{"description":"Tool quarantine metrics for this server","properties":{"blocked_count":{"description":"Number of disabled (blocked) tools","type":"integer"},"changed_count":{"description":"Number of tools whose description/schema changed since approval","type":"integer"},"pending_count":{"description":"Number of newly discovered tools awaiting approval","type":"integer"}},"type":"object"},"contracts.RefreshRegistryResponse":{"properties":{"cleared":{"description":"number of cached entries dropped","type":"integer"},"registry_id":{"type":"string"}},"type":"object"},"contracts.Registry":{"properties":{"count":{"description":"number or string","type":"string"},"description":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"protocol":{"type":"string"},"provenance":{"description":"Provenance is the trust tag (MCP-866): \"official/trusted\" for built-in\ndefaults, \"custom/unverified\" for user-added registries.","type":"string"},"servers_url":{"type":"string"},"tags":{"items":{"type":"string"},"type":"array","uniqueItems":false},"trusted":{"description":"Trusted indicates whether this is an official, shipped-by-default\nregistry. Trust is derived from membership in the default set, never\nfrom self-assertion in config.","type":"boolean"},"url":{"type":"string"}},"type":"object"},"contracts.RegistryCacheInfo":{"properties":{"age_seconds":{"type":"number"},"stale":{"type":"boolean"}},"type":"object"},"contracts.RegistryUnavailable":{"properties":{"reason":{"type":"string"}},"type":"object"},"contracts.ReplayToolCallRequest":{"properties":{"arguments":{"description":"Modified arguments for replay","type":"object"}},"type":"object"},"contracts.ReplayToolCallResponse":{"properties":{"error":{"description":"Error if replay failed","type":"string"},"new_call_id":{"description":"ID of the newly created call","type":"string"},"new_tool_call":{"$ref":"#/components/schemas/contracts.ToolCallRecord"},"replayed_from":{"description":"Original call ID","type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.RepositoryInfo":{"description":"Detected package info","properties":{"npm":{"$ref":"#/components/schemas/contracts.NPMPackageInfo"}},"type":"object"},"contracts.RepositoryServer":{"properties":{"connect_url":{"description":"Alternative connection URL","type":"string"},"created_at":{"type":"string"},"description":{"type":"string"},"id":{"type":"string"},"install_cmd":{"description":"Installation command","type":"string"},"name":{"type":"string"},"registry":{"description":"Which registry this came from","type":"string"},"repository_info":{"$ref":"#/components/schemas/contracts.RepositoryInfo"},"source_code_url":{"description":"Source repository URL","type":"string"},"updated_at":{"type":"string"},"url":{"description":"MCP endpoint for remote servers only","type":"string"}},"type":"object"},"contracts.SearchRegistryServersResponse":{"properties":{"cache":{"$ref":"#/components/schemas/contracts.RegistryCacheInfo"},"query":{"type":"string"},"registry_id":{"type":"string"},"servers":{"items":{"$ref":"#/components/schemas/contracts.RepositoryServer"},"type":"array","uniqueItems":false},"tag":{"type":"string"},"total":{"type":"integer"},"unavailable":{"$ref":"#/components/schemas/contracts.RegistryUnavailable"}},"type":"object"},"contracts.SearchResult":{"properties":{"matches":{"type":"integer"},"score":{"type":"number"},"snippet":{"type":"string"},"tool":{"$ref":"#/components/schemas/contracts.Tool"}},"type":"object"},"contracts.SearchToolsResponse":{"properties":{"query":{"type":"string"},"results":{"items":{"$ref":"#/components/schemas/contracts.SearchResult"},"type":"array","uniqueItems":false},"took":{"type":"string"},"total":{"type":"integer"}},"type":"object"},"contracts.SecurityScanSummary":{"description":"Latest security scan results summary","properties":{"deep_scan":{"$ref":"#/components/schemas/contracts.DeepScanDescriptor"},"finding_counts":{"$ref":"#/components/schemas/contracts.FindingCounts"},"last_scan_at":{"type":"string"},"risk_score":{"description":"0-100","type":"integer"},"scanners_failed":{"type":"integer"},"scanners_run":{"description":"Scanner coverage for the primary (baseline) scan pass — informational only.\nSpec 077 US3 (FR-008/FR-014): Status is derived SOLELY from the\ndeterministic baseline findings; a failed Docker deep scanner no longer\ndowngrades a clean verdict. That failure is surfaced via DeepScan instead.","type":"integer"},"scanners_total":{"type":"integer"},"status":{"description":"\"clean\", \"warnings\", \"dangerous\", \"failed\", \"not_scanned\", \"scanning\"","type":"string"}},"type":"object"},"contracts.Server":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"authenticated":{"description":"OAuth authentication status","type":"boolean"},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges mirrors config.ServerConfig.AutoApproveToolChanges\n(MCP-2930): the per-server intent to auto-approve new/changed tools past\nthe trust baseline. Tri-state *bool — nil means \"never set\" (omitted from\nthe payload), so the Web UI toggle (MCP-2932) can distinguish unset from\nan explicit false. Read-only on the GET path; PATCH/POST accept it via\nAddServerRequest.","type":"boolean"},"command":{"type":"string"},"connected":{"type":"boolean"},"connected_at":{"type":"string"},"connecting":{"type":"boolean"},"created":{"type":"string"},"diagnostic":{"$ref":"#/components/schemas/contracts.Diagnostic"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"error_code":{"type":"string"},"expose_prompts":{"description":"ExposePrompts mirrors config.ServerConfig.ExposePrompts (F9): the per-server\nprompt-aggregation override. Tri-state *bool — nil/omitted means \"inherit\ndefault aggregation\". Surfaced on GET so a caller that PATCHed the override\ncan read it back; PATCH/POST accept it via AddServerRequest.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"health":{"$ref":"#/components/schemas/contracts.HealthStatus"},"id":{"type":"string"},"init_timeout":{"description":"InitTimeout mirrors config.ServerConfig.InitTimeout (MCP-3322 / GH #760):\nthe per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override. Serialized as\na duration string (e.g. \"120s\"); nil/omitted means \"inherit the global\ndefault\". Surfaced on the GET path so clients can read back a configured\noverride; PATCH/POST accept it via AddServerRequest.","type":"string"},"isolation":{"$ref":"#/components/schemas/contracts.IsolationConfig"},"isolation_defaults":{"$ref":"#/components/schemas/contracts.IsolationDefaults"},"isolation_effective":{"$ref":"#/components/schemas/contracts.IsolationEffective"},"last_error":{"type":"string"},"last_reconnect_at":{"type":"string"},"last_retry_time":{"type":"string"},"max_concurrent_requests":{"description":"Spec 093 (GH #955) — per-server concurrency overrides, scope (c) of\nFR-020. Each setting is tri-state: nil (omitted) means \"inherit\nserver_concurrency_defaults\", 0 disables that setting for this server,\npositive overrides it. Surfaced on the GET path so a caller can read back\nwhat it set; PATCH/POST accept them via AddServerRequest. The effective\nconcurrency for a server is additionally bounded by the global aggregate\nlimiter, which is NOT an inheritance source for these fields.","type":"integer"},"name":{"type":"string"},"oauth":{"$ref":"#/components/schemas/contracts.OAuthConfig"},"oauth_status":{"description":"OAuth status: \"authenticated\", \"expired\", \"error\", \"none\"","type":"string"},"protocol":{"type":"string"},"quarantine":{"$ref":"#/components/schemas/contracts.QuarantineStats"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_count":{"type":"integer"},"reconnect_on_use":{"description":"Attempt reconnection when a tool call targets this disconnected server","type":"boolean"},"retry_count":{"type":"integer"},"retry_stopped":{"description":"RetryStopped reports that automatic reconnection has been given up for\ngood because the failure is deterministic and unrecoverable — a missing\nbinary, an image without the interpreter, an unparseable config (GH\n#1145). It is NOT ordinary exponential backoff, which keeps retrying;\nnothing will happen until the user fixes the config or restarts the\nserver. RetryStoppedCode is the stable MCPX_* code that proved it and\nRetryStoppedReason the catalog message explaining how to fix it. All three\nare omitted for servers that are healthy or still retrying.","type":"boolean"},"retry_stopped_code":{"type":"string"},"retry_stopped_reason":{"type":"string"},"security_scan":{"$ref":"#/components/schemas/contracts.SecurityScanSummary"},"should_retry":{"type":"boolean"},"source_registry_id":{"description":"MCP-901 — registry provenance of an upstream that was added from a\nregistry. SourceRegistryID names the source registry (empty for\nmanually-configured servers); SourceRegistryProvenance is the trust tag\nrecorded at add time (\"official/trusted\" or \"custom/unverified\"). Both\nare projected from config.ServerConfig so the approval/quarantine view\ncan render an \"added from \u003cregistry\u003e · unverified\" origin badge. Optional\nand omitted when empty — clients that pre-date this treat them as absent.","type":"string"},"source_registry_provenance":{"type":"string"},"status":{"type":"string"},"token_expires_at":{"description":"When the OAuth token expires (ISO 8601)","type":"string"},"tool_count":{"type":"integer"},"tool_list_token_size":{"description":"Token size for this server's tools","type":"integer"},"trust_mode":{"description":"TrustMode mirrors config.ServerConfig.TrustMode (spec 086): the per-server\ntrust tier (\"auto\"/\"scan\"/\"manual\"). Surfaced on the GET path so clients can\nread back the persisted mode; PATCH/POST accept it via AddServerRequest.\nOmitted when empty (server predates the field / relies on legacy flags).","type":"string"},"updated":{"type":"string"},"url":{"type":"string"},"user_logged_out":{"description":"True if user explicitly logged out (prevents auto-reconnection)","type":"boolean"},"working_dir":{"type":"string"}},"type":"object"},"contracts.ServerActionResponse":{"properties":{"action":{"type":"string"},"async":{"type":"boolean"},"server":{"type":"string"},"success":{"type":"boolean"}},"type":"object"},"contracts.ServerStats":{"properties":{"connected_servers":{"type":"integer"},"docker_containers":{"type":"integer"},"quarantined_servers":{"type":"integer"},"token_metrics":{"$ref":"#/components/schemas/contracts.ServerTokenMetrics"},"total_servers":{"type":"integer"},"total_tools":{"type":"integer"}},"type":"object"},"contracts.ServerTokenMetrics":{"properties":{"average_query_result_size":{"description":"Typical retrieve_tools output (tokens)","type":"integer"},"per_server_tool_list_sizes":{"additionalProperties":{"type":"integer"},"description":"Token size per server","type":"object"},"saved_tokens":{"description":"Difference","type":"integer"},"saved_tokens_percentage":{"description":"Percentage saved","type":"number"},"total_server_tool_list_size":{"description":"All upstream tools combined (tokens)","type":"integer"}},"type":"object"},"contracts.SuccessResponse":{"properties":{"data":{"type":"object"},"success":{"type":"boolean"}},"type":"object"},"contracts.TokenMetrics":{"description":"Token usage metrics (nil for older records)","properties":{"encoding":{"description":"Encoding used (e.g., cl100k_base)","type":"string"},"estimated_cost":{"description":"Optional cost estimate","type":"number"},"input_tokens":{"description":"Tokens in the request","type":"integer"},"model":{"description":"Model used for tokenization","type":"string"},"output_tokens":{"description":"Tokens in the response","type":"integer"},"total_tokens":{"description":"Total tokens (input + output)","type":"integer"},"truncated_tokens":{"description":"Tokens removed by truncation","type":"integer"},"was_truncated":{"description":"Whether response was truncated","type":"boolean"}},"type":"object"},"contracts.Tool":{"properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"approval_status":{"type":"string"},"config_denied":{"description":"ConfigDenied is true when the tool is denied by the server's static\nenabled_tools / disabled_tools config. The user cannot override this toggle.","type":"boolean"},"description":{"type":"string"},"disabled":{"description":"Disabled mirrors ToolApprovalRecord.Disabled so per-tool enable state is\navailable without a second round-trip to the approvals endpoint. Absent\nin the JSON when false (default) to keep responses compact.","type":"boolean"},"hash":{"description":"Hash is the tool's current stored hash rendered in the preflight pin\nformat \"sha256/v{N}:{hex}\" (Spec 098 FR-011), where N is the approval\nrecord's HashSchemaVersion. It is the authoring surface for\n` + "`" + `POST /api/v1/preflight` + "`" + ` pins and ` + "`" + `mcpproxy tools preflight --pin` + "`" + `:\ncopy the value straight into a pin.\n\nDisclosure is OPERATOR TIER ONLY — same rule as the preflight per-tool\nresult. The field is omitted for agent-token callers and for tools with\nno stored hash (no approval record yet, or a record written before\nhashes existed).","type":"string"},"held_reason":{"description":"HeldReason, HeldVerdict and HeldSignals mirror the same-named fields on\nstorage.ToolApprovalRecord: the offline-scan evidence that made\ntrust_mode: scan hold this tool for review (spec 086 FR-018). HeldSignals\nnames the matched deterministic check ids, e.g.\n\"tpa.TPA-2026-0001.hidden_instruction\", so a reviewer can see WHY the tool\nis held. All three are omitted for tools that are not held by the scan gate\n(including every record written before the field existed).","type":"string"},"held_signals":{"items":{"type":"string"},"type":"array","uniqueItems":false},"held_verdict":{"type":"string"},"last_used":{"type":"string"},"name":{"type":"string"},"schema":{"type":"object"},"server_name":{"type":"string"},"usage":{"type":"integer"}},"type":"object"},"contracts.ToolAnnotation":{"description":"Tool behavior hints snapshot","properties":{"destructiveHint":{"type":"boolean"},"idempotentHint":{"type":"boolean"},"openWorldHint":{"type":"boolean"},"readOnlyHint":{"type":"boolean"},"title":{"type":"string"}},"type":"object"},"contracts.ToolCallRecord":{"description":"The new tool call record","properties":{"annotations":{"$ref":"#/components/schemas/contracts.ToolAnnotation"},"arguments":{"description":"Tool arguments","type":"object"},"arguments_truncated":{"description":"ArgumentsTruncated marks Arguments as a placeholder rather than the\narguments the tool was called with. Replaying such a record without\nsupplying arguments explicitly is refused.","type":"boolean"},"config_path":{"description":"Active config file path","type":"string"},"duration":{"description":"Duration in nanoseconds","type":"integer"},"error":{"description":"Error message (failure only)","type":"string"},"execution_type":{"description":"\"direct\" or \"code_execution\"","type":"string"},"id":{"description":"Unique identifier","type":"string"},"mcp_client_name":{"description":"MCP client name from InitializeRequest","type":"string"},"mcp_client_version":{"description":"MCP client version","type":"string"},"mcp_session_id":{"description":"MCP session identifier","type":"string"},"metrics":{"$ref":"#/components/schemas/contracts.TokenMetrics"},"parent_call_id":{"description":"Links nested calls to parent code_execution","type":"string"},"request_id":{"description":"Request correlation ID","type":"string"},"response":{"description":"Tool response (success only)","type":"object"},"response_bytes":{"description":"Marshalled response size before truncation","type":"integer"},"response_truncated":{"description":"ResponseTruncated and ResponseBytes describe a STORAGE-side cut (#1176):\nthe caller received the response whole, and only the persisted copy was\nshortened to tool_call_max_response_size. When ResponseTruncated is true\nthe Response object carries {truncated, original_bytes, preview, note}\ninstead of the upstream result, and ResponseBytes is its size before the\ncut.","type":"boolean"},"server_id":{"description":"Server identity hash","type":"string"},"server_name":{"description":"Human-readable server name","type":"string"},"timestamp":{"description":"When the call was made","type":"string"},"tool_name":{"description":"Tool name (without server prefix)","type":"string"}},"type":"object"},"contracts.UpdateInfo":{"description":"Update information (if available)","properties":{"available":{"description":"Whether an update is available","type":"boolean"},"behind_summary":{"description":"Spec 079 FR-002 — how far behind the running build is. All four are\nadditive (FR-021) and absent when the delta could not be resolved, in\nwhich case every surface renders its pre-delta wording.","type":"string"},"check_error":{"description":"Error message if update check failed","type":"string"},"checked_at":{"description":"When the update check was performed","type":"string"},"install_channel":{"description":"Detected install channel (homebrew, dmg, deb, rpm, docker, go-install, windows-installer, tarball, unknown) — Spec 079 FR-008","type":"string"},"is_prerelease":{"description":"Whether the latest version is a prerelease","type":"boolean"},"latest_version":{"description":"Latest version available (e.g., \"v1.2.3\")","type":"string"},"nudges_suppressed":{"description":"UI surfaces must stay quiet (CI / non-interactive context); machine-readable fields still report the facts — Spec 079 FR-019","type":"boolean"},"release_url":{"description":"URL to the release page","type":"string"},"releases_behind":{"description":"Releases on the offered channel between the running and offered versions","type":"integer"},"releases_behind_saturated":{"description":"ReleasesBehind is a lower bound: the running build predates the scanned release window","type":"boolean"},"update_command":{"description":"One-line update command for the channel; only set when an update is available and the channel has one — Spec 079 FR-009","type":"string"},"weeks_behind":{"description":"Whole weeks between the two releases' publish dates; 0 is a real value, absent means unknown","type":"integer"}},"type":"object"},"contracts.UpdatePolicy":{"description":"UpdatePolicy is the effective, hot-reloadable update policy (Spec 092\nFR-015). Always present: the ` + "`" + `update` + "`" + ` object above is omitted both when\nupdate checking is disabled AND when no check has produced a result\nyet, so its absence cannot tell a client whether it is allowed to run\nits own (e.g. Sparkle feed) check. This field states the answer.","properties":{"channel":{"description":"Channel is the tracked release channel: \"stable\" or \"rc\".","type":"string"},"enabled":{"description":"Enabled is the effective automatic-check kill switch: update_check.enabled\nwith MCPPROXY_DISABLE_AUTO_UPDATE=true winning over it. A user-initiated\n\"Check for Updates\" stays available regardless.","type":"boolean"},"nudges_suppressed":{"description":"NudgesSuppressed asks UI surfaces to stay quiet (CI / non-interactive)\nwhile machine-readable fields keep reporting the facts.","type":"boolean"}},"type":"object"},"contracts.UpstreamError":{"properties":{"error_message":{"type":"string"},"server_name":{"type":"string"},"timestamp":{"type":"string"}},"type":"object"},"contracts.UsageAggregateResponse":{"properties":{"freshness_ms":{"description":"age of the underlying snapshot in ms","type":"integer"},"generated_at":{"type":"string"},"other":{"$ref":"#/components/schemas/contracts.UsageOtherBucket"},"timeline":{"items":{"$ref":"#/components/schemas/contracts.UsageTimeBucket"},"type":"array","uniqueItems":false},"token_source":{"description":"\"bytes\" (size-based proxy, FR-006)","type":"string"},"tokens_saved":{"description":"echoed from ServerTokenMetrics (FR-007)","type":"integer"},"tokens_saved_percentage":{"type":"number"},"tools":{"items":{"$ref":"#/components/schemas/contracts.UsageToolStat"},"type":"array","uniqueItems":false},"total_calls":{"description":"TotalCalls and TotalErrors are the headline counts for the window: the sum\nof the timeline this same response carries, so the tiles and the histogram\nunder them cannot disagree. They are NOT the sum of Tools — that list is\nlifetime-cumulative, upstream-only and truncated to top-N, and summing it\nclient-side is what made the Usage tab print a third number for the same\n24 hours (audit finding F1, #1046). The population is\nstorage.CountsAsCall, shared with ActivitySummaryResponse.CallCount.\n\nTwo bounds on how exactly this matches the Activity Log's own count.\nBoth are bounded and disclosed, unlike the population mismatch they\nreplace, which was unbounded and silent:\n\n - Window granularity is the timeline's: whole hour buckets, so the span\n is the requested window rounded up to a bucket edge.\n - This response is served from a snapshot behind a short read cache\n (observability.usage_cache_ttl, 5s by default) so the endpoint never\n scans the activity log per request, while the summary endpoint counts\n live. Calls that land inside that window appear on the Activity Log\n first. FreshnessMs and GeneratedAt say how old the figures are, and\n the Usage tab prints it (\"Updated 3s ago\").","type":"integer"},"total_errors":{"type":"integer"},"window":{"type":"string"}},"type":"object"},"contracts.UsageOtherBucket":{"description":"present only when the list was truncated to top-N","properties":{"calls":{"type":"integer"},"tools_folded":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageTimeBucket":{"properties":{"calls":{"type":"integer"},"errors":{"type":"integer"},"start":{"type":"string"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.UsageToolStat":{"properties":{"avg_req_bytes":{"description":"null when no sized request calls","type":"integer"},"avg_resp_bytes":{"description":"null when sized_calls == 0 (only legacy 0-byte calls)","type":"integer"},"blocked":{"type":"integer"},"calls":{"type":"integer"},"error_rate":{"type":"number"},"errors":{"type":"integer"},"last_used":{"type":"string"},"p50_exceeds":{"type":"boolean"},"p50_ms":{"description":"P50Ms and P95Ms are read off a fixed latency histogram, so they are BUCKET\nBOUNDS, not measured durations: the true percentile is at or below the\nvalue, and a client must render it as a bound (\"≤ 5 ms\"). P50Exceeds /\nP95Exceeds flip that reading for the unbounded overflow bucket, where the\nvalue is the last bound and the truth is above it (\"\u003e 10 s\").","type":"integer"},"p95_exceeds":{"type":"boolean"},"p95_ms":{"type":"integer"},"rejected":{"description":"spec 093: shed by a concurrency limit; never executed, so excluded from calls/latency","type":"integer"},"server":{"type":"string"},"sized_calls":{"description":"calls with known response size (basis for avg_resp_bytes)","type":"integer"},"tool":{"type":"string"},"total_req_bytes":{"type":"integer"},"total_resp_bytes":{"type":"integer"}},"type":"object"},"contracts.ValidateConfigResponse":{"properties":{"errors":{"items":{"$ref":"#/components/schemas/contracts.ValidationError"},"type":"array","uniqueItems":false},"valid":{"type":"boolean"}},"type":"object"},"contracts.ValidationError":{"properties":{"field":{"type":"string"},"message":{"type":"string"}},"type":"object"},"data":{"properties":{"data":{"$ref":"#/components/schemas/contracts.InfoResponse"}},"type":"object"},"httpapi.AddServerRequest":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"auto_approve_tool_changes":{"description":"AutoApproveToolChanges is the per-server intent to auto-approve\nnew/changed tools past the trust baseline (MCP-2930). Tri-state *bool:\na nil pointer means \"leave unchanged\" on PATCH; a present value\n(including false) is applied. Mirrors config.ServerConfig's *bool\nsemantics — do NOT collapse to a plain bool, or an omitted field would\nsilently reset a previously-set value.","type":"boolean"},"command":{"type":"string"},"enabled":{"type":"boolean"},"env":{"additionalProperties":{"type":"string"},"type":"object"},"expose_prompts":{"description":"ExposePrompts is the per-server override for prompt aggregation (F9):\nwhether this server's advertised MCP prompts are merged into mcpproxy's\nprompts/list. Tri-state *bool mirroring config.ServerConfig.ExposePrompts —\na nil pointer means \"leave unchanged\" on PATCH (and \"inherit the default\naggregate behavior\" on create); a present value (including false) is applied.","type":"boolean"},"headers":{"additionalProperties":{"type":"string"},"type":"object"},"init_timeout":{"description":"InitTimeout is the per-server MCP ` + "`" + `initialize` + "`" + ` handshake deadline override\n(MCP-3322 / GH #760), serialized as a duration string (e.g. \"120s\"). A nil\npointer means \"leave unchanged\" on PATCH; a present value is applied.\nMirrors config.ServerConfig.InitTimeout's *Duration tri-state.","type":"string"},"isolation":{"$ref":"#/components/schemas/httpapi.IsolationRequest"},"max_concurrent_requests":{"description":"MaxConcurrentRequests / QueueSize / QueueTimeout are the per-server\nconcurrency overrides (spec 093 / GH #955, FR-020 scope (c)). Each is\ntri-state: a nil pointer means \"leave unchanged\" on PATCH and \"inherit\nserver_concurrency_defaults\" on create; an explicit 0 disables that\nsetting for this server; a positive value overrides it. Do NOT collapse\nthem to plain values — an omitted field would then silently reset a\nconfigured limit.","type":"integer"},"name":{"type":"string"},"protocol":{"type":"string"},"quarantined":{"type":"boolean"},"queue_size":{"type":"integer"},"queue_timeout":{"type":"string"},"reconnect_on_use":{"type":"boolean"},"trust_mode":{"description":"TrustMode is the per-server trust tier (spec 086): \"auto\", \"scan\", or\n\"manual\". Empty means \"leave unchanged\" on PATCH (and inherit the migrated\ndefault on create). A non-empty value is applied to ServerConfig.TrustMode\nand resolved by EffectiveTrustMode (an unrecognized value fails closed to\nmanual). This is the REST seam for changing the trust tier via\nPOST/PATCH /api/v1/servers.","type":"string"},"url":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.CanonicalConfigPath":{"properties":{"description":{"description":"Brief description","type":"string"},"exists":{"description":"Whether the file exists","type":"boolean"},"format":{"description":"Format identifier (e.g., \"claude_desktop\")","type":"string"},"name":{"description":"Display name (e.g., \"Claude Desktop\")","type":"string"},"os":{"description":"Operating system (darwin, windows, linux)","type":"string"},"path":{"description":"Full path to the config file","type":"string"}},"type":"object"},"httpapi.CanonicalConfigPathsResponse":{"properties":{"os":{"description":"Current operating system","type":"string"},"paths":{"description":"List of canonical config paths","items":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPath"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ConnectConflictResponse":{"properties":{"action":{"description":"already_exists | precondition_failed","type":"string"},"data":{"$ref":"#/components/schemas/connect.ConnectResult"},"error":{"description":"Human-readable message","type":"string"},"success":{"description":"Always false","type":"boolean"}},"type":"object"},"httpapi.ConnectRequest":{"properties":{"force":{"description":"Overwrite existing entry","type":"boolean"},"precondition_token":{"description":"PreconditionToken is the opaque token from the preview this write was\nconfirmed against (Spec 091 FR-005). When present, the core rechecks it\nat write time and responds 409 with action \"precondition_failed\" —\nwriting nothing — if the config or the entry MCPProxy would write has\ndrifted since; the caller then re-previews instead of retrying. Absent\nmeans exactly the pre-091 behavior. A replace-classified flow sends this\nTOGETHER with force=true: the token, not the absence of force, is the\noverwrite safety.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.ImportFromPathRequest":{"properties":{"format":{"description":"Optional format hint","type":"string"},"path":{"description":"File path to import from","type":"string"},"rename":{"additionalProperties":{"type":"string"},"description":"Rename maps a server name → new name. Applied after parsing so the\ncaller can disambiguate cross-source name collisions (Spec 046 v2 —\ne.g. \"mcpproxy\" → \"mcpproxy_claude_code\"). Keys are matched against\neither the raw source name (OriginalName) or the sanitized name shown\nin the preview (Server.Name); these differ for names that need\nsanitizing (e.g. \"Figma Desktop\" → \"Figma_Desktop\"). Keys not present\nin the imported set are ignored.","type":"object"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportRequest":{"properties":{"content":{"description":"Raw JSON or TOML content","type":"string"},"format":{"description":"Optional format hint","type":"string"},"server_names":{"description":"Optional: import only these servers","items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportResponse":{"properties":{"failed":{"items":{"$ref":"#/components/schemas/configimport.FailedServer"},"type":"array","uniqueItems":false},"format":{"type":"string"},"format_name":{"type":"string"},"imported":{"items":{"$ref":"#/components/schemas/httpapi.ImportedServerResponse"},"type":"array","uniqueItems":false},"skipped":{"items":{"$ref":"#/components/schemas/configimport.SkippedServer"},"type":"array","uniqueItems":false},"summary":{"$ref":"#/components/schemas/configimport.ImportSummary"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.ImportedServerResponse":{"properties":{"args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"command":{"type":"string"},"fields_skipped":{"items":{"type":"string"},"type":"array","uniqueItems":false},"name":{"type":"string"},"original_name":{"type":"string"},"protocol":{"type":"string"},"source_format":{"type":"string"},"url":{"type":"string"},"warnings":{"items":{"type":"string"},"type":"array","uniqueItems":false}},"type":"object"},"httpapi.IsolationRequest":{"description":"Isolation carries per-server Docker isolation overrides (enabled,\nmode_override, image, network_mode, extra_args, working_dir). A nil\npointer means \"do not touch isolation config\". A present object is\napplied field-by-field ON TOP of the persisted overrides, so omitting a\nfield leaves it alone; clear an individual override by sending it\nexplicitly (` + "`" + `\"enabled\": null` + "`" + `, ` + "`" + `\"image\": \"\"` + "`" + `).","properties":{"enabled":{"description":"Enabled exists ONLY to detect and reject an echoed-back read. It is the\neffective state on the read surface and is never writable; see validate().","type":"boolean"},"enabled_override":{"description":"EnabledOverride is the tri-state per-server override — the RAW value, the\nsame one reads return as ` + "`" + `enabled_override` + "`" + `. It has THREE meaningful wire\nstates, and collapsing them is what silently un-isolated servers\n(GH #1142):\n - absent → leave the persisted override untouched\n - null → clear the override, back to inheriting the global\n - true / false → set an explicit opt-in / opt-out","type":"boolean"},"extra_args":{"items":{"type":"string"},"type":"array","uniqueItems":false},"image":{"type":"string"},"mode_override":{"description":"ModeOverride sets ` + "`" + `isolation.mode` + "`" + ` (\"docker\" | \"sandbox\" | \"none\").\nnil leaves the persisted value alone; an empty string clears it. An\nunrecognized value is rejected with a 400 rather than persisted.","type":"string"},"network_mode":{"type":"string"},"working_dir":{"type":"string"}},"type":"object"},"httpapi.OnboardingMarkRequest":{"properties":{"connect_step_status":{"description":"ConnectStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value. The stored enum is wider (Spec 080\nFR-001): a \"skipped\" request for a previously untouched connect step\nis upgraded server-side to \"completed_external\" when the install\nshows positive evidence of an external connection (Spec 080 FR-002).\n\"completed_external\" is NOT accepted from clients — it must never be\npersisted without that server-verified evidence (edge case: \"never\nguess completed_external without positive evidence\").","type":"string"},"engaged":{"description":"Engaged marks the wizard as engaged (completed or explicitly skipped).\nOnce true, the wizard does not auto-show again.","type":"boolean"},"mark_shown":{"description":"MarkShown records the wizard's first display time if not already set.","type":"boolean"},"server_step_status":{"description":"ServerStepStatus is one of: \"\", \"completed\", \"skipped\". Empty\npreserves the existing value.","type":"string"}},"type":"object"},"httpapi.SetActiveProfileRequest":{"properties":{"active_profile":{"type":"string"},"profile":{"type":"string"}},"type":"object"},"httpapi.UndoConnectRequest":{"properties":{"backup_name":{"description":"BackupName is the bare filename (filepath.Base) of the backup returned as\nbackup_path by the preceding connect — a name, never a path. Undo resolves\nthe full path server-side by joining it with the client's own config\ndirectory, so a client-supplied value can never contribute a directory\ncomponent (traversal is impossible by construction). Empty means the\nconnect created the file (no prior file existed), so undo removes it.","type":"string"},"server_name":{"description":"Defaults to \"mcpproxy\"","type":"string"}},"type":"object"},"httpapi.UpdateFailureRequest":{"properties":{"stage":{"description":"Stage is the failure stage of the update session.","enum":["appcast","download","install","other"],"type":"string"}},"type":"object"},"management.BulkOperationResult":{"properties":{"errors":{"additionalProperties":{"type":"string"},"description":"Map of server name to error message","type":"object"},"failed":{"description":"Number of failed operations","type":"integer"},"successful":{"description":"Number of successful operations","type":"integer"},"total":{"description":"Total servers processed","type":"integer"}},"type":"object"},"observability.HealthResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"observability.HealthStatus":{"properties":{"error":{"type":"string"},"latency":{"type":"string"},"name":{"type":"string"},"status":{"description":"\"healthy\" or \"unhealthy\"","type":"string"}},"type":"object"},"observability.ReadinessResponse":{"properties":{"components":{"items":{"$ref":"#/components/schemas/observability.HealthStatus"},"type":"array","uniqueItems":false},"status":{"description":"\"ready\" or \"not_ready\"","type":"string"},"timestamp":{"type":"string"}},"type":"object"},"secureenv.EnvConfig":{"description":"Environment configuration for secure variable filtering","properties":{"allowed_system_vars":{"items":{"type":"string"},"type":"array","uniqueItems":false},"custom_vars":{"additionalProperties":{"type":"string"},"type":"object"},"enhance_path":{"description":"Enable PATH enhancement for Launchd scenarios","type":"boolean"},"forward_proxy_env":{"description":"ForwardProxyEnv opts in to forwarding the ambient HTTP(S)/ALL/NO/FTP proxy\nenvironment variables to spawned upstream servers (MCP-2769). It is OFF by\ndefault and deliberately kept out of the AllowedSystemVars default list:\nproxy URLs frequently carry credentials (http://user:pass@proxy), so\nforwarding them to every stdio upstream is a credential-leak risk. When\nenabled, values are forwarded with their userinfo (credentials) redacted.","type":"boolean"},"inherit_system_safe":{"type":"boolean"}},"type":"object"},"telemetry.FeedbackContext":{"properties":{"arch":{"type":"string"},"connected_server_count":{"type":"integer"},"edition":{"type":"string"},"os":{"type":"string"},"routing_mode":{"type":"string"},"server_count":{"type":"integer"},"version":{"type":"string"}},"type":"object"},"telemetry.FeedbackRequest":{"properties":{"category":{"description":"bug, feature, other","type":"string"},"context":{"$ref":"#/components/schemas/telemetry.FeedbackContext"},"email":{"type":"string"},"message":{"type":"string"}},"type":"object"},"telemetry.FeedbackResponse":{"properties":{"error":{"type":"string"},"issue_url":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}},"securitySchemes":{"ApiKeyAuth":{"description":"API key authentication via query parameter. Use ?apikey=your-key","in":"query","name":"apikey","type":"apiKey"}}},
"info": {"contact":{"name":"MCPProxy Support","url":"https://github.com/smart-mcp-proxy/mcpproxy-go"},"description":"{{escape .Description}}","license":{"name":"MIT","url":"https://opensource.org/licenses/MIT"},"title":"{{.Title}}","version":"{{.Version}}"},
"externalDocs": {"description":"","url":""},
"paths": {"/api/v1/activity":{"get":{"description":"Returns paginated list of activity records with optional filtering","parameters":[{"description":"Filter by activity type(s), comma-separated for multiple (Spec 024)","in":"query","name":"type","schema":{"enum":["tool_call","policy_decision","quarantine_change","server_change","system_start","system_stop","internal_tool_call","config_change","preflight","prompt_get"],"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Filter by intent operation type (Spec 018)","in":"query","name":"intent_type","schema":{"enum":["read","write","destructive"],"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — returns the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Include successful call_tool_* internal tool calls (default: false, excluded to avoid duplicates)","in":"query","name":"include_call_tool","schema":{"type":"boolean"}},{"description":"Filter by sensitive data detection (true=has detections, false=no detections)","in":"query","name":"sensitive_data","schema":{"type":"boolean"}},{"description":"Filter by specific detection type (e.g., 'aws_access_key', 'credit_card')","in":"query","name":"detection_type","schema":{"type":"string"}},{"description":"Filter by severity level","in":"query","name":"severity","schema":{"enum":["critical","high","medium","low"],"type":"string"}},{"description":"Filter by agent token name (Spec 028)","in":"query","name":"agent","schema":{"type":"string"}},{"description":"Filter by auth type (Spec 028)","in":"query","name":"auth_type","schema":{"enum":["admin","agent"],"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Omit arguments, response and metadata except a contextual whitelist (intent.reason, intent.operation_type, decision, reason, client_name, client_version) (default: false). For clients that render summary fields only; has_sensitive_data is still derived before metadata is dropped.","in":"query","name":"exclude_payloads","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"List activity records","tags":["Activity"]}},"/api/v1/activity/export":{"get":{"description":"Exports activity records in JSON Lines or CSV format for compliance","parameters":[{"description":"Export format: json (default) or csv","in":"query","name":"format","schema":{"type":"string"}},{"description":"Filter by activity type","in":"query","name":"type","schema":{"type":"string"}},{"description":"Filter by server name","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter by tool name","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter by MCP transport session ID","in":"query","name":"session_id","schema":{"type":"string"}},{"description":"Filter by work session (one client, one project, across reconnects)","in":"query","name":"work_session_id","schema":{"type":"string"}},{"description":"Filter by status","in":"query","name":"status","schema":{"type":"string"}},{"description":"Filter by HTTP request ID for log correlation (Spec 021)","in":"query","name":"request_id","schema":{"type":"string"}},{"description":"Filter by parent call id — exports the sub-calls one code_execution issued","in":"query","name":"parent_id","schema":{"type":"string"}},{"description":"Filter activities after this time (RFC3339)","in":"query","name":"start_time","schema":{"type":"string"}},{"description":"Filter activities before this time (RFC3339)","in":"query","name":"end_time","schema":{"type":"string"}},{"description":"Maximum records to export (1-50000, default 10000)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Pagination offset (default 0)","in":"query","name":"offset","schema":{"type":"integer"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"type":"string"}},"application/x-ndjson":{"schema":{"type":"string"}},"text/csv":{"schema":{"type":"string"}}},"description":"Streamed activity records"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Export activity records","tags":["Activity"]}},"/api/v1/activity/summary":{"get":{"description":"Returns aggregated activity statistics for a time period","parameters":[{"description":"Time period: 1h, 24h (default), 7d, 30d","in":"query","name":"period","schema":{"type":"string"}},{"description":"Group by: server, tool (optional)","in":"query","name":"group_by","schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity summary statistics","tags":["Activity"]}},"/api/v1/activity/usage":{"get":{"description":"Returns the actor-owned usage aggregate (per-tool rollup + timeline + tokens-saved headline) for the Web UI usage graphs (Spec 069). Served from an in-memory snapshot — never a per-request full-log scan. Per-tool metrics are lifetime-cumulative; ` + "`" + `window` + "`" + ` scopes the timeline and filters the tool list to tools active within the span.","parameters":[{"description":"Time window for timeline + tool-list membership","in":"query","name":"window","schema":{"enum":["24h","7d","all"],"type":"string"}},{"description":"Filter to one server","in":"query","name":"server","schema":{"type":"string"}},{"description":"Filter to one tool","in":"query","name":"tool","schema":{"type":"string"}},{"description":"Filter to tools with activity of this status","in":"query","name":"status","schema":{"enum":["success","error","blocked","rejected"],"type":"string"}},{"description":"Top-N tools by sort key; remainder folded into 'other' (default 20)","in":"query","name":"top","schema":{"type":"integer"}},{"description":"Ranking key for the per-tool list","in":"query","name":"sort","schema":{"enum":["calls","resp_bytes","error_rate","p95"],"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get usage statistics aggregate","tags":["Activity"]}},"/api/v1/activity/{id}":{"get":{"description":"Returns full details for a single activity record","parameters":[{"description":"Activity record ID (ULID)","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"OK"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Not Found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Get activity record details","tags":["Activity"]}},"/api/v1/annotations/coverage":{"get":{"description":"Reports how many upstream tools have MCP annotations vs don't, broken down by server","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Annotation coverage report"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get annotation coverage report","tags":["annotations"]}},"/api/v1/code/scripts":{"get":{"description":"List the stored scripts available to the code_execution tool. Scripts are ` + "`" + `\u003cname\u003e.js` + "`" + ` / ` + "`" + `\u003cname\u003e.ts` + "`" + ` files in the ` + "`" + `scripts/` + "`" + ` directory next to the active configuration file. Entries are advisory: ` + "`" + `ok` + "`" + ` scripts are invocable, ` + "`" + `ambiguous` + "`" + ` names have both extensions, and ` + "`" + `invalid` + "`" + ` ones report why (empty, oversized, unreadable, non-regular). Read-only — there is no write surface for stored scripts.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Stored scripts and the directory they were read from"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List stored code-execution scripts","tags":["code"]}},"/api/v1/config":{"get":{"description":"Retrieves the current MCPProxy configuration including all server definitions, global settings, and runtime parameters","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetConfigResponse"}}},"description":"Configuration retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the configuration document"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get current configuration","tags":["config"]},"patch":{"description":"Deep-merges only the fields present in the request body onto the live in-memory configuration and routes the result through the existing apply pipeline (validation, change detection, disk persistence, hot-reload). Fields the client omits — including masked secrets such as ` + "`" + `api_key` + "`" + ` and secret request headers — are preserved verbatim. Nested objects are merged recursively; arrays and scalars replace wholesale.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}},"description":"Partial configuration with only the fields to change","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration patch applied (inspect validation_errors for rejected values)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload or empty patch"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to read or apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update configuration","tags":["config"]}},"/api/v1/config/apply":{"post":{"description":"Applies a new MCPProxy configuration. Validates and persists the configuration to disk. Some changes apply immediately, while others may require a restart. Returns detailed information about applied changes and restart requirements.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to apply","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Configuration applied successfully with change details"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Apply configuration","tags":["config"]}},"/api/v1/config/docker-isolation":{"patch":{"description":"Convenience endpoint to flip ` + "`" + `docker_isolation.enabled` + "`" + ` without resending the full config. Persists to disk via the existing config writer — the file watcher then hot-reloads the change. Returns the new state and whether a restart is required for existing connections to pick it up.","requestBody":{"content":{"application/json":{"schema":{"properties":{"enabled":{"type":"boolean"}},"type":"object"}}},"description":"New isolation state","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ConfigApplyResult"}}},"description":"Isolation toggle applied"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate configuration)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to apply configuration"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Toggle global Docker isolation","tags":["config"]}},"/api/v1/config/validate":{"post":{"description":"Validates a provided MCPProxy configuration without applying it. Checks for syntax errors, invalid server definitions, conflicting settings, and other configuration issues.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/config.Config"}}},"description":"Configuration to validate","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ValidateConfigResponse"}}},"description":"Configuration validation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Validation failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Validate configuration","tags":["config"]}},"/api/v1/connect":{"get":{"description":"Returns the connection status for all known MCP client applications.\nEach entry indicates whether the client config file exists and whether\nMCPProxy is currently registered in it.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"List of ClientStatus objects"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List client connection status","tags":["connect"]}},"/api/v1/connect/{client}":{"delete":{"description":"Remove the MCPProxy entry from the specified client's configuration file.\nCreates a backup of the existing config before modifying.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional parameters (server_name)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or entry not found"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disconnect MCPProxy from a client","tags":["connect"]},"get":{"description":"Resolves one client's status by reading its config file on demand.\nThis is the only Connect endpoint that opens a client config file, so\non macOS it is the sole place an App-Data privacy prompt may legitimately\nappear (scoped to this user action). Resolves access_state to\naccessible|absent|denied|malformed and populates remediation when denied.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ClientStatus"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get a single client's connection status (on-demand)","tags":["connect"]},"post":{"description":"Register MCPProxy as an MCP server in the specified client's configuration file.\nCreates a backup of the existing config before modifying.\nOptionally accepts precondition_token from a preview (Spec 091): when supplied,\nthe core rechecks the raw pre-write state and the entry it would write, and\nrefuses a drifted write with 409 before taking any backup. The 409 body's\naction discriminates the two conflict kinds: \"precondition_failed\" (stale\npreview — re-preview, do not retry) vs \"already_exists\" (entry present — pass\nforce=true). force=true never rescues a stale token.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectRequest"}}},"description":"Optional connection parameters (server_name, force, precondition_token)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ConnectConflictResponse"}}},"description":"Conflict: action=already_exists (use force=true) or action=precondition_failed (preview is stale; re-preview)"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Connect MCPProxy to a client","tags":["connect"]}},"/api/v1/connect/{client}/preview":{"get":{"description":"Returns the exact entry a subsequent connect would add to the client's\nconfig — target path, server key, entry name, and entry contents — WITHOUT\nmodifying the file or creating a backup (Spec 078 US1). The embedded API key\nis masked in the payload; contains_api_key flags that a credential is written.\nentry_exists distinguishes a create from an overwrite of a same-named entry.\nReads the config on demand to classify create-vs-overwrite, so on macOS this\nmay raise an App-Data privacy prompt; a denial returns 403 + remediation.\nSpec 091 adds three fields: existing_entry_summary (present only when\nentry_exists — a sanitized, non-secret projection of the entry being replaced:\nits name, type, endpoint with query/userinfo stripped, command, and header and\nenv NAMES, never values); precondition_token (always present — an opaque keyed\ndigest of the raw pre-write state and the pending entry, echoed back on POST\nconnect to detect drift); and connect_refusal (present when the write would\nrefuse regardless of intent, e.g. a non-create-capable client with no config —\ntreat its presence as \"Connect unavailable\").","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}},{"description":"Entry name to preview (defaults to mcpproxy); mirror the value passed to POST connect","in":"query","name":"server_name","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectPreview"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview the change a connect would make (no write)","tags":["connect"]}},"/api/v1/connect/{client}/undo":{"post":{"description":"Reverts the connect that produced the named backup (Spec 078 US3):\nrestores the client config byte-for-byte from that backup, or — when\nbackup_name is empty because the connect created the file — deletes the\ncreated file. backup_name is the bare filename of the backup the connect\nreturned (never a path); undo resolves the full path server-side inside\nthe client's own config directory, so a client value cannot escape it.\nRefuses with 409 when the config changed since the connect (undo never\nclobbers later edits; use DELETE /connect/{client} for a surgical entry\nremoval instead). Takes its own safety backup first; its path is returned\nas backup_path in the result.","parameters":[{"description":"Client ID (claude-code, claude-desktop, cursor, windsurf, vscode, codex, gemini, opencode)","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UndoConnectRequest"}}},"description":"Undo parameters (server_name, backup_name = the bare filename of the backup the preceding connect returned)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"ConnectResult (action restored|deleted)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (e.g. backup_name is a path, or not a backup of this client's config)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Permission denied (macOS App-Data block)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown client or backup no longer exists"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Config changed since connect; undo refused"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Undo a connect, restoring the pre-connect config","tags":["connect"]}},"/api/v1/diagnostics":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/docker/status":{"get":{"description":"Retrieve current Docker availability and recovery status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Docker status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get Docker status","tags":["docker"]}},"/api/v1/doctor":{"get":{"description":"Get comprehensive health diagnostics including upstream errors, OAuth requirements, missing secrets, and Docker status","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.Diagnostics"}}},"description":"Health diagnostics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get health diagnostics","tags":["diagnostics"]}},"/api/v1/feedback":{"post":{"description":"Submit a bug report, feature request, or general feedback","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackRequest"}}},"description":"Feedback request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/telemetry.FeedbackResponse"}}},"description":"OK"},"400":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Bad Request"},"429":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Too Many Requests"},"500":{"content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object"}}},"description":"Internal Server Error"}},"security":[{"ApiKeyAuth":[]}],"summary":"Submit feedback","tags":["feedback"]}},"/api/v1/index/search":{"get":{"description":"Search across all upstream MCP server tools using BM25 keyword search","parameters":[{"description":"Search query","in":"query","name":"q","required":true,"schema":{"type":"string"}},{"description":"Maximum number of results","in":"query","name":"limit","schema":{"default":10,"maximum":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchToolsResponse"}}},"description":"Search results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing query parameter)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search for tools","tags":["tools"]}},"/api/v1/info":{"get":{"description":"Get essential server metadata including version, web UI URL, endpoint addresses, and update availability\nweb_ui_url carries the ?apikey= credential ONLY for an authenticated admin; a scoped agent token receives the bare URL\nThis endpoint is designed for tray-core communication and version checking\nUse refresh=true query parameter to force an immediate update check against GitHub\nThe launched_by field reports durable launch provenance (\"tray\", \"installer\", or \"\" for user-launched/unknown)","parameters":[{"description":"Force immediate update check against GitHub","in":"query","name":"refresh","schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Server information with optional update info"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server information","tags":["status"]}},"/api/v1/onboarding/mark":{"post":{"description":"Updates wizard engagement and per-step status. Once engaged is\ntrue, the wizard does not auto-show again, even if state regresses.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.OnboardingMarkRequest"}}},"description":"Mark request","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Updated OnboardingStateResponse"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Mark onboarding wizard state (Spec 046)","tags":["onboarding"]}},"/api/v1/onboarding/state":{"get":{"description":"Returns the wizard engagement record alongside live predicates\n(whether any client is connected, whether any server is configured),\nplus a derived ShouldShowWizard flag the frontend can rely on.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"OnboardingStateResponse"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read onboarding state"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get onboarding wizard state and predicates (Spec 046)","tags":["onboarding"]}},"/api/v1/preflight":{"post":{"description":"Deterministic, side-effect-free availability check for a caller-supplied list of tool IDs (Spec 098). Performs zero upstream calls and mutates no runtime state. HTTP status reports whether the CHECK executed: a fully blocked set is still 200, with the availability verdict in the body. Every executed preflight writes an activity record before the response is returned.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.PreflightRequest"}}},"description":"Tool IDs (1-100 before dedup), optional profile, annotation policy filters and wait budget","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/data"}],"properties":{"data":{"type":"object"},"error":{"type":"string"},"request_id":{"type":"string"},"success":{"type":"boolean"}},"type":"object"}}},"description":"Preflight verdict and per-tool results"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Validation error (malformed, oversized, doubled or unknown-field body; empty or oversized tool list; conflicting duplicate pins; unknown profile; wait_ms out of range)"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Missing or invalid credentials"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.APIResponse"}}},"description":"Runtime unavailable, evaluator infrastructure read failure, or the activity record could not be persisted"}},"security":[{"ApiKeyHeader":[]},{"ApiKeyQuery":[]}],"summary":"Preflight required tools","tags":["tools"]}},"/api/v1/profiles":{"get":{"description":"List all configured profiles with their effective servers and indexed tool count (Profiles v2). A profile scopes tool discovery and calls to a named subset of upstream servers.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Profile list"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Configuration unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List configured profiles","tags":["profiles"]}},"/api/v1/profiles/active":{"get":{"description":"Get the server-level default active profile used by UI surfaces (Web UI / tray). Empty string means \"all servers\". Note: within a live MCP session, the set_profile tool selection takes precedence over this default.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get the default active profile","tags":["profiles"]},"put":{"description":"Set the server-level default active profile for UI surfaces. The slug must match a configured profile; pass an empty string to clear. This does not affect live MCP sessions, which use the set_profile tool.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.SetActiveProfileRequest"}}},"description":"Profile slug to activate (empty clears)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Active profile updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid request body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot change the active profile)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unknown profile"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Set the default active profile","tags":["profiles"]}},"/api/v1/registries":{"get":{"description":"Retrieves list of all MCP server registries that can be browsed for discovering and installing new upstream servers. Includes registry metadata, server counts, and API endpoints.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetRegistriesResponse"}}},"description":"Registries retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to list registries"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List available MCP server registries","tags":["registries"]},"post":{"description":"Adds a generic modelcontextprotocol/registry v0.1 https endpoint as a custom registry (MCP-866). The source is always tagged custom/unverified, so every server discovered through it lands quarantined and can never skip quarantine.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddRegistrySourceRequest"}}},"description":"Registry source (https url + optional protocol/id/name)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source added"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/contracts.ErrorResponse"},{"$ref":"#/components/schemas/contracts.ErrorResponse"}]}}},"description":"Forbidden (agent tokens cannot mutate registries)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin | duplicate_registry"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a user-supplied registry source","tags":["registries"]}},"/api/v1/registries/{id}":{"delete":{"description":"Removes a custom/unverified registry previously added via add-source (MCP-1057). Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source removed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove a user-added custom registry source","tags":["registries"]},"put":{"description":"Updates a custom registry previously added via add-source (MCP-1072): name, url, servers-url. Empty fields are left unchanged. Built-in registries are refused with registry_shadows_builtin; an unknown id yields registry_not_found; a non-https url yields invalid_registry_url. The change is persisted copy-on-write.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.EditRegistrySourceRequest"}}},"description":"Fields to update (name/url/servers_url; empty = unchanged)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Registry source updated"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required | invalid_registry_url"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registries_locked"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_shadows_builtin"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Edit a user-added custom registry source","tags":["registries"]}},"/api/v1/registries/{id}/refresh":{"post":{"description":"Invalidates the cached server lists for a registry so the next search re-fetches fresh data from the source (spec 070 FR-007). Returns how many cache entries were dropped.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.RefreshRegistryResponse"}}},"description":"Registry cache refreshed"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID is required"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh registry cache"}},"summary":"Refresh a registry's cached server list","tags":["registries"]}},"/api/v1/registries/{id}/servers":{"get":{"description":"Searches for MCP servers within a specific registry by keyword or tag. Returns server metadata including installation commands, source code URLs, and npm package information for easy discovery and installation.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Search query keyword","in":"query","name":"q","schema":{"type":"string"}},{"description":"Filter by tag","in":"query","name":"tag","schema":{"type":"string"}},{"description":"Maximum number of results (default 10)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SearchRegistryServersResponse"}}},"description":"Servers retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Registry ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to search servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Search MCP servers in a registry","tags":["registries"]}},"/api/v1/registries/{id}/servers/{serverId}/add":{"post":{"description":"Resolves a registry server reference server-side, re-derives a validated config, and persists it quarantined (spec 070 keystone). The client never sends a config blob — command/args/url and the quarantine flag are derived from the registry entry, not the request.","parameters":[{"description":"Registry ID","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Server ID within the registry","in":"path","name":"serverId","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.AddFromRegistryRequest"}}},"description":"Optional overrides (name, env, enabled)"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server added (quarantined)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"no_install_info | missing_required_input | duplicate_name"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot add servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"registry_not_found | server_not_found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add an upstream server from a registry reference","tags":["registries"]}},"/api/v1/routing":{"get":{"description":"Get the current routing mode and available MCP endpoints.\nrouting_mode is what /mcp is actually serving; pending_routing_mode carries a\nrestart-pending value persisted on disk (empty when there is none).\ntool_response_mode and direct_tool_response_mode report the two serialization axes, resolved.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Routing mode information"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get routing mode information","tags":["status"]}},"/api/v1/secrets":{"post":{"description":"Stores a secret value in the operating system's secure keyring. The secret can then be referenced in configuration using ${keyring:secret-name} syntax. Automatically notifies runtime to restart affected servers.","requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored successfully with reference syntax"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid JSON payload, missing name/value, or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to store secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Store a secret in OS keyring","tags":["secrets"]}},"/api/v1/secrets/{name}":{"delete":{"description":"Deletes a secret from the operating system's secure keyring. Automatically notifies runtime to restart affected servers. Only keyring type is supported for security.","parameters":[{"description":"Name of the secret to delete","in":"path","name":"name","required":true,"schema":{"type":"string"}},{"description":"Secret type (only 'keyring' supported, defaults to 'keyring')","in":"query","name":"type","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret deleted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Missing secret name or unsupported type"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver not available or failed to delete secret"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Delete a secret from OS keyring","tags":["secrets"]}},"/api/v1/servers":{"get":{"description":"Get a list of all configured upstream MCP servers with their connection status and statistics","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServersResponse"}}},"description":"Server list with statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List all upstream MCP servers","tags":["servers"]},"post":{"description":"Add a new MCP upstream server to the configuration. New servers are quarantined by default for security. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Server configuration","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server added successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid configuration"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"409":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Conflict - server with this name already exists"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Add a new upstream server","tags":["servers"]}},"/api/v1/servers/disable_all":{"post":{"description":"Disable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk disable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable all servers","tags":["servers"]}},"/api/v1/servers/enable_all":{"post":{"description":"Enable all configured upstream MCP servers with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk enable results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable all servers","tags":["servers"]}},"/api/v1/servers/import":{"post":{"description":"Import MCP server configurations from a Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI configuration file","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}},{"description":"Force format (claude-desktop, claude-code, cursor, codex, gemini)","in":"query","name":"format","schema":{"type":"string"}},{"description":"Comma-separated list of server names to import","in":"query","name":"server_names","schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"type":"file"}}},"description":"Configuration file to import","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid file or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from uploaded configuration file","tags":["servers"]}},"/api/v1/servers/import/json":{"post":{"description":"Import MCP server configurations from raw JSON or TOML content (useful for pasting configurations)","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportRequest"}}},"description":"Import request with content","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid content or format"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from JSON/TOML content","tags":["servers"]}},"/api/v1/servers/import/path":{"post":{"description":"Import MCP server configurations by reading a file from the server's filesystem","parameters":[{"description":"If true, return preview without importing","in":"query","name":"preview","schema":{"type":"boolean"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportFromPathRequest"}}},"description":"Import request with file path","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.ImportResponse"}}},"description":"Import result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - invalid path or format"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"File not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Import servers from a file path","tags":["servers"]}},"/api/v1/servers/import/paths":{"get":{"description":"Returns well-known configuration file paths for supported formats with existence check","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.CanonicalConfigPathsResponse"}}},"description":"Canonical config paths"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get canonical config file paths","tags":["servers"]}},"/api/v1/servers/reconnect":{"post":{"description":"Force reconnection to all upstream MCP servers","parameters":[{"description":"Reason for reconnection","in":"query","name":"reason","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"All servers reconnected successfully"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Reconnect all servers","tags":["servers"]}},"/api/v1/servers/restart_all":{"post":{"description":"Restart all configured upstream MCP servers sequentially with partial failure handling","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/management.BulkOperationResult"}}},"description":"Bulk restart results with success/failure counts"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart all servers","tags":["servers"]}},"/api/v1/servers/{id}":{"delete":{"description":"Remove an MCP upstream server from the configuration. This stops the server if running and removes it from config.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server removed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Remove an upstream server","tags":["servers"]},"patch":{"description":"Update specific fields of an existing upstream MCP server configuration. Isolation: ` + "`" + `isolation.enabled` + "`" + ` is READ-ONLY (it reports the effective state on reads) and is rejected with 400; set the per-server override via ` + "`" + `isolation.enabled_override` + "`" + ` (true | false | null to clear, omit to leave unchanged). An unrecognized ` + "`" + `isolation.mode_override` + "`" + ` is rejected with 400.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.AddServerRequest"}}},"description":"Fields to update (all optional)","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server updated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request - no fields or invalid body"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Partially update an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/config-to-secret":{"post":{"description":"Atomically reads the real value from the server config, stores it in the OS keyring, and rewrites the config field to ` + "`" + `${keyring:\u003cname\u003e}` + "`" + `. Unblocks the UI's Convert-to-secret affordance for values the API redacts on the read path.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"additionalProperties":{},"type":"object"}}},"description":"Secret stored, config updated with reference"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad scope/key/secret_name, or value is already a reference / empty"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server or key not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Secret resolver or config update failed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Convert a header / env value to a keyring secret","tags":["servers"]}},"/api/v1/servers/{id}/disable":{"post":{"description":"Disable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server disabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Disable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/discover-tools":{"post":{"description":"Manually trigger tool discovery and indexing for a specific upstream MCP server. This forces an immediate refresh of the server's tool cache.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool discovery triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot discover tools)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to discover tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Discover tools for a specific server","tags":["servers"]}},"/api/v1/servers/{id}/enable":{"post":{"description":"Enable a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server enabled successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/login":{"post":{"description":"Initiate OAuth authentication flow for a specific upstream MCP server. Returns structured OAuth start response with correlation ID for tracking.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthStartResponse"}}},"description":"OAuth login initiated successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.OAuthFlowError"}}},"description":"OAuth error (client_id required, DCR failed, etc.)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Trigger OAuth login for server","tags":["servers"]}},"/api/v1/servers/{id}/logout":{"post":{"description":"Clear OAuth authentication token and disconnect a specific upstream MCP server. The server will need to re-authenticate before tools can be used again.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"OAuth logout completed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (management disabled or read-only mode)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Clear OAuth token and disconnect server","tags":["servers"]}},"/api/v1/servers/{id}/logs":{"get":{"description":"Retrieve log entries for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Number of log lines to retrieve","in":"query","name":"tail","schema":{"default":100,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerLogsResponse"}}},"description":"Server logs retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server logs","tags":["servers"]}},"/api/v1/servers/{id}/quarantine":{"post":{"description":"Place a specific upstream MCP server in quarantine to prevent tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server quarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Quarantine a server","tags":["servers"]}},"/api/v1/servers/{id}/refresh":{"post":{"description":"Re-discover and re-index a specific upstream MCP server's tools without changing any security state. Alias of discover-tools, named for the upstream_servers 'refresh' operation; use it to make just-approved tools searchable immediately.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Tool refresh triggered successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot refresh)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to refresh tools"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Refresh a server's tools","tags":["servers"]}},"/api/v1/servers/{id}/restart":{"post":{"description":"Restart the connection to a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server restarted successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Restart an upstream server","tags":["servers"]}},"/api/v1/servers/{id}/tool-calls":{"get":{"description":"Retrieves tool call history filtered by upstream server ID. Returns recent tool executions for the specified server including timestamps, arguments, results, and errors. Useful for server-specific debugging and monitoring.","parameters":[{"description":"Upstream server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}},{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolCallsResponse"}}},"description":"Server tool calls retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get server tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history for specific server","tags":["tool-calls"]}},"/api/v1/servers/{id}/tools":{"get":{"description":"Retrieve all available tools for a specific upstream MCP server","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetServerToolsResponse"}}},"description":"Server tools retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/block":{"post":{"description":"Atomically approves AND disables the given tools (or all pending/changed tools when block_all=true) for a server. The approve and disable land in a single write per tool, so a tool is never left in the approved+enabled state. The \"blocked\" field counts tools actually blocked.","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Block result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Block (approve+disable) tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/disable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/tools/enable_all":{"post":{"description":"Bulk-toggles every known tool of a server. The \"changed\" field","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Operation result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Enable or disable all tools for a server","tags":["servers"]}},"/api/v1/servers/{id}/unquarantine":{"post":{"description":"Remove a specific upstream MCP server from quarantine to allow tool execution","parameters":[{"description":"Server ID or name","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ServerActionResponse"}}},"description":"Server unquarantined successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (missing server ID)"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Forbidden (agent tokens cannot mutate servers)"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Server not found"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Unquarantine a server","tags":["servers"]}},"/api/v1/sessions":{"get":{"description":"Retrieves paginated list of active and recent MCP client sessions. Each session represents a connection from an MCP client to MCPProxy, tracking initialization time, tool calls, and connection status.","parameters":[{"description":"Maximum number of sessions to return (1-100, default 10)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of sessions to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter by session status","in":"query","name":"status","schema":{"enum":["active","closed"],"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionsResponse"}}},"description":"Sessions retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Invalid status filter"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get sessions"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get active MCP sessions","tags":["sessions"]}},"/api/v1/sessions/{id}":{"get":{"description":"Retrieves detailed information about a specific MCP client session including initialization parameters, connection status, tool call count, and activity timestamps.","parameters":[{"description":"Session ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetSessionDetailResponse"}}},"description":"Session details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read MCP session history"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Session not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get MCP session details by ID","tags":["sessions"]}},"/api/v1/stats/tokens":{"get":{"description":"Retrieve token savings statistics across all servers and sessions","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Token statistics"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read deployment-wide token statistics"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get token savings statistics","tags":["stats"]}},"/api/v1/status":{"get":{"description":"Get comprehensive server status including running state, listen address, upstream statistics, and timestamp","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Server status information"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get server status","tags":["status"]}},"/api/v1/telemetry/payload":{"get":{"description":"Render the exact JSON heartbeat payload that mcpproxy would next send to the telemetry endpoint, without making a network call. Counters in the payload reflect the current in-memory state. Spec 042.","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Telemetry heartbeat payload"},"403":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Agent tokens cannot read the deployment telemetry payload"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Telemetry service unavailable"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Preview next telemetry heartbeat payload","tags":["telemetry"]}},"/api/v1/telemetry/update-failure":{"post":{"description":"Records one terminal update-session failure, identified only by its\nstage (appcast, download, install, other). The body carries no error\ntext, URL, or version — the stage is the only value transmitted.\nReturns 204 both when the occurrence was durably persisted and when\ntelemetry is inactive at event time (config opt-out, environment\nopt-out, CI, or dev build), in which case nothing is recorded.\nCallers cannot and need not distinguish the two.","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/httpapi.UpdateFailureRequest"}}},"description":"Update failure stage","required":true},"responses":{"204":{"description":"Accepted (recorded, or a deliberate no-op while telemetry is inactive)"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Malformed body, unknown field, trailing value, or stage outside the closed set"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Persistence failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Record a desktop auto-update failure occurrence (Spec 095)","tags":["telemetry"]}},"/api/v1/tool-calls":{"get":{"description":"Retrieves paginated tool call history across all upstream servers or filtered by session ID. Includes execution timestamps, arguments, results, and error information for debugging and auditing.","parameters":[{"description":"Maximum number of records to return (1-100, default 50)","in":"query","name":"limit","schema":{"type":"integer"}},{"description":"Number of records to skip for pagination (default 0)","in":"query","name":"offset","schema":{"type":"integer"}},{"description":"Filter tool calls by MCP session ID","in":"query","name":"session_id","schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallsResponse"}}},"description":"Tool calls retrieved successfully"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to get tool calls"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call history","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}":{"get":{"description":"Retrieves detailed information about a specific tool call execution including full request arguments, response data, execution time, and any errors encountered.","parameters":[{"description":"Tool call ID","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GetToolCallDetailResponse"}}},"description":"Tool call details retrieved successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call not found"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Get tool call details by ID","tags":["tool-calls"]}},"/api/v1/tool-calls/{id}/replay":{"post":{"description":"Re-executes a previous tool call with optional modified arguments. Useful for debugging and testing tool behavior with different inputs. Creates a new tool call record linked to the original.","parameters":[{"description":"Original tool call ID to replay","in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallRequest"}}},"description":"Optional modified arguments for replay"},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ReplayToolCallResponse"}}},"description":"Tool call replayed successfully"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Tool call ID required or invalid JSON payload"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Unauthorized - missing or invalid API key"},"405":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Method not allowed"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Failed to replay tool call"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Replay a tool call","tags":["tool-calls"]}},"/api/v1/tools":{"get":{"description":"Consolidated, read-only listing of all tools from every configured server (including disabled servers and disabled/config-denied tools), enriched with approval state and 30-day usage. Backs the global Tools page and the CLI global ` + "`" + `tools list` + "`" + ` (spec 050, issue #437).","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.GlobalToolsResponse"}}},"description":"All tools across all servers"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Could not enumerate servers"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"List every tool across all servers","tags":["tools"]}},"/api/v1/tools/call":{"post":{"description":"Execute a tool on an upstream MCP server (wrapper around MCP tool calls)","requestBody":{"content":{"application/json":{"schema":{"properties":{"arguments":{"type":"object"},"tool_name":{"type":"string"}},"type":"object"}}},"description":"Tool call request with tool name and arguments","required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.SuccessResponse"}}},"description":"Tool call result"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Bad request (invalid payload or missing tool name)"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Shed by a concurrency limit (Retry-After header carries the wait hint)"},"500":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/contracts.ErrorResponse"}}},"description":"Internal server error or tool execution failure"}},"security":[{"ApiKeyAuth":[]},{"ApiKeyQuery":[]}],"summary":"Call a tool","tags":["tools"]}},"/healthz":{"get":{"description":"Get comprehensive health status including all component health (Kubernetes-compatible liveness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is healthy"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.HealthResponse"}}},"description":"Service is unhealthy"}},"summary":"Get health status","tags":["health"]}},"/readyz":{"get":{"description":"Get readiness status including all component readiness checks (Kubernetes-compatible readiness probe)","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is ready"},"503":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/observability.ReadinessResponse"}}},"description":"Service is not ready"}},"summary":"Get readiness status","tags":["health"]}}},
diff --git a/oas/swagger.yaml b/oas/swagger.yaml
index 90435f547..03b57558f 100644
--- a/oas/swagger.yaml
+++ b/oas/swagger.yaml
@@ -1,5 +1,27 @@
components:
schemas:
+ config.AuditLogConfig:
+ description: |-
+ AuditLog configures the Spec 107 edition-neutral audit sink
+ (internal/audit). nil means "use the per-edition/per-transport
+ default" (EffectiveAuditLog); restart-pinned (bound at sink
+ construction). See audit_log.go.
+ properties:
+ compress:
+ type: boolean
+ enabled:
+ type: boolean
+ max_age_days:
+ type: integer
+ max_backups:
+ type: integer
+ max_size_mb:
+ type: integer
+ path:
+ type: string
+ stdout:
+ type: boolean
+ type: object
config.ConcurrencyDefaults:
description: |-
ServerConcurrencyDefaults is scope (b) of FR-020: the blanket per-server
@@ -68,6 +90,8 @@ components:
api_key:
description: Security settings
type: string
+ audit_log:
+ $ref: '#/components/schemas/config.AuditLogConfig'
call_tool_timeout:
type: string
check_server_repo:
diff --git a/roadmap.yaml b/roadmap.yaml
index 175852ebe..e05731bd2 100644
--- a/roadmap.yaml
+++ b/roadmap.yaml
@@ -802,7 +802,7 @@ epics:
- id: sso
title: Spec 107 server edition SSO front door hardened for real IdPs
- status: in_progress
+ status: in_review
priority: P2
spec: specs/107-server-edition-sso-hardening
depends_on: []
@@ -820,13 +820,14 @@ epics:
pr: "#1292"
- id: sso-pr-c-group-allowlist
title: PR-C one entitlement predicate, group grants, tenant Web UI session principal (US1, US4)
- status: in_progress
+ status: done
depends_on: [sso-pr-b-oidc-front-door]
pr: "#1293"
- id: sso-pr-d-audit-line
title: PR-D attributable JSONL audit line + auth_event + config/doctor/metrics (US3)
- status: todo
+ status: in_review
depends_on: [sso-pr-c-group-allowlist]
+ pr: "#1296"
# ── MERGED-BUT-UNIMPLEMENTED specs (cross-spec audit 2026-07-01) ───────────
# These specs are checked into specs/ but materially absent from code. Most
diff --git a/scripts/dev-server-edition.sh b/scripts/dev-server-edition.sh
index c70897b84..bedde1ebc 100755
--- a/scripts/dev-server-edition.sh
+++ b/scripts/dev-server-edition.sh
@@ -287,6 +287,7 @@ cat >"$CONFIG" <}"
diff --git a/scripts/test-api-e2e.sh b/scripts/test-api-e2e.sh
index cdf0cdb69..bdf7f9264 100755
--- a/scripts/test-api-e2e.sh
+++ b/scripts/test-api-e2e.sh
@@ -91,6 +91,12 @@ cleanup() {
# Clean up test results
rm -f "$TEST_RESULTS_FILE"
+ # T113: the script overwrites the tracked test/e2e-config.json as scratch
+ # (fresh copy from the template + port substitution, and the audit_log
+ # sub-test below adds its own scratch config next to it) — restore the
+ # tracked file so the repo is left clean after every run, pass or fail.
+ git checkout -- "$CONFIG_FILE" 2>/dev/null || true
+
echo "Cleanup complete"
}
@@ -1136,6 +1142,247 @@ else
echo "Response: $RESPONSE"
fi
+
+# ===========================================
+# Audit Log Tests (Spec 107 PR-D, T113)
+# ===========================================
+# NOTE ON "personal instance": EffectiveAuditLog's absent-block DEFAULT is
+# edition-keyed (personal: disabled; server: stdout on HTTP) per FR-014, but
+# an explicit audit_log block is honoured identically on both editions
+# (internal/config/audit_log_config_personal_test.go pins both halves). This
+# sub-test still runs its OWN server-edition instance (./mcpproxy-server)
+# rather than reusing the personal $MCPPROXY_BINARY instance started above,
+# simply because the server binary is already built for the OAuth/SSO
+# suites above and this sub-test needs no personal-edition-specific
+# coverage of its own; the personal-instance run above is unchanged.
+echo ""
+echo -e "${YELLOW}Testing audit_log sink (Spec 107 PR-D)...${NC}"
+echo ""
+
+AUDIT_BINARY="./mcpproxy-server"
+AUDIT_SCHEMA="./docs/schemas/audit-line-v1.schema.json"
+AUDIT_PORT="${AUDIT_LISTEN_PORT:-18181}"
+AUDIT_BASE_URL="http://localhost:${AUDIT_PORT}"
+AUDIT_MCP_URL="${AUDIT_BASE_URL}/mcp"
+AUDIT_DATA_DIR="./test-data-audit"
+AUDIT_CONFIG_FILE="${AUDIT_DATA_DIR}/e2e-audit-config.json"
+AUDIT_SERVER_LOG="/tmp/mcpproxy_e2e_audit.log"
+# Spec 107 (round-3 cross-review finding, PR-D): `mktemp -d -t PREFIX` is
+# BSD/macOS syntax (BSD mktemp appends the random suffix itself). GNU
+# mktemp — used by the mandatory Ubuntu CI job — treats -t's argument as a
+# template that must itself carry trailing X's, and errors ("too few X's in
+# template") without them; the script has no `set -e`, so AUDIT_JSONL_DIR
+# silently became empty and AUDIT_JSONL resolved to a root-level
+# "/audit.jsonl". The explicit XXXXXX template form is accepted by both.
+AUDIT_JSONL_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mcpproxy_e2e_audit.XXXXXX")"
+AUDIT_JSONL="${AUDIT_JSONL_DIR}/audit.jsonl"
+AUDIT_API_KEY=""
+AUDIT_PID=""
+AUDIT_MCP_SESSION_ID=""
+
+extract_audit_api_key() {
+ if [ -f "$AUDIT_SERVER_LOG" ]; then
+ AUDIT_API_KEY=$(grep -ao '"api_key": "[^"]*"' "$AUDIT_SERVER_LOG" | sed 's/.*"api_key": "\([^"]*\)".*/\1/' | head -1)
+ fi
+}
+
+wait_for_audit_server() {
+ local attempt=1
+ while [ "$attempt" -le 30 ]; do
+ extract_audit_api_key
+ if [ -n "$AUDIT_API_KEY" ] && curl -s -f --max-time 5 -H "X-API-Key: $AUDIT_API_KEY" "${AUDIT_BASE_URL}/api/v1/servers" > /dev/null 2>&1; then
+ return 0
+ fi
+ sleep 1
+ attempt=$((attempt + 1))
+ done
+ return 1
+}
+
+wait_for_audit_everything() {
+ local attempt=1
+ local connected
+ while [ "$attempt" -le 30 ]; do
+ connected=$(curl -s --max-time 5 -H "X-API-Key: $AUDIT_API_KEY" "${AUDIT_BASE_URL}/api/v1/servers" 2>/dev/null | jq -r '.data.servers[] | select(.name=="everything") | .connected // false' 2>/dev/null)
+ if [ "$connected" = "true" ]; then
+ sleep 3
+ return 0
+ fi
+ sleep 2
+ attempt=$((attempt + 1))
+ done
+ return 1
+}
+
+# Initialize an MCP Streamable-HTTP session against the audit instance
+# (pattern from tests/test-quarantine.sh init_mcp_session/mcp_call_file).
+init_audit_mcp_session() {
+ local header_file payload_file
+ header_file=$(mktemp)
+ payload_file=$(mktemp)
+ cat > "$payload_file" <<'JSON'
+{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"audit-e2e-test","version":"1.0.0"},"capabilities":{}}}
+JSON
+ curl -s -X POST "$AUDIT_MCP_URL" -H "Content-Type: application/json" -D "$header_file" -d @"$payload_file" > /dev/null 2>&1
+ AUDIT_MCP_SESSION_ID=$(grep -i "Mcp-Session-Id" "$header_file" | tr -d '\r\n' | sed 's/[^:]*: *//')
+ rm -f "$header_file" "$payload_file"
+}
+
+audit_mcp_call() {
+ local payload_file="$1"
+ curl -s -X POST "$AUDIT_MCP_URL" -H "Content-Type: application/json" -H "Mcp-Session-Id: $AUDIT_MCP_SESSION_ID" -d @"$payload_file" > /dev/null 2>&1
+}
+
+if [ ! -x "$AUDIT_BINARY" ]; then
+ log_test "Audit log: server-edition binary present"
+ log_fail "Audit log: server-edition binary present"
+ echo "Build it first: go build -tags server -o $AUDIT_BINARY ./cmd/mcpproxy"
+else
+ rm -rf "$AUDIT_DATA_DIR"
+ mkdir -p "$AUDIT_DATA_DIR"
+
+ # Scratch config: template's listen/data_dir/audit_log overridden, and
+ # the fixed-port launcher-test fixture dropped (it is owned by the
+ # personal-instance run above and would collide on :39933).
+ jq --arg port ":${AUDIT_PORT}" --arg dir "$AUDIT_DATA_DIR" --arg path "$AUDIT_JSONL" \
+ '.listen = $port | .data_dir = $dir | .mcpServers = [.mcpServers[] | select(.name=="everything")] | .audit_log = {enabled: true, path: $path}' \
+ "$CONFIG_TEMPLATE" > "$AUDIT_CONFIG_FILE"
+
+ "$AUDIT_BINARY" serve --config="$AUDIT_CONFIG_FILE" --log-level=info > "$AUDIT_SERVER_LOG" 2>&1 &
+ AUDIT_PID=$!
+ echo "Started audit-log instance with PID: $AUDIT_PID (port $AUDIT_PORT)"
+
+ if ! wait_for_audit_server; then
+ log_test "Audit log: server-edition instance became ready"
+ log_fail "Audit log: server-edition instance became ready"
+ echo "Server logs:"
+ tail -50 "$AUDIT_SERVER_LOG"
+ elif ! wait_for_audit_everything; then
+ log_test "Audit log: everything server connected on audit instance"
+ log_fail "Audit log: everything server connected on audit instance"
+ tail -50 "$AUDIT_SERVER_LOG"
+ else
+ init_audit_mcp_session
+
+ # One dispatched tool call: one authz allow + one tool_call line.
+ AUDIT_PAYLOAD_CALL=$(mktemp)
+ cat > "$AUDIT_PAYLOAD_CALL" <<'JSON'
+{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"call_tool_read","arguments":{"name":"everything:echo","args":{"message":"audit e2e"}}}}
+JSON
+ audit_mcp_call "$AUDIT_PAYLOAD_CALL"
+ rm -f "$AUDIT_PAYLOAD_CALL"
+
+ # retrieve_tools: the built-in search tool never gates through
+ # handleCallToolVariant, so it must emit no authz/tool_call line
+ # (contracts/audit-line-events.md "authz — one per pre-dispatch decision").
+ AUDIT_PAYLOAD_SEARCH=$(mktemp)
+ cat > "$AUDIT_PAYLOAD_SEARCH" <<'JSON'
+{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"retrieve_tools","arguments":{"query":"echo"}}}
+JSON
+ audit_mcp_call "$AUDIT_PAYLOAD_SEARCH"
+ rm -f "$AUDIT_PAYLOAD_SEARCH"
+
+ sleep 1
+
+ log_test "Audit log: sink file exists and is non-empty"
+ if [ -s "$AUDIT_JSONL" ]; then
+ log_pass "Audit log: sink file exists and is non-empty"
+ else
+ log_fail "Audit log: sink file exists and is non-empty"
+ fi
+
+ AUDIT_AUTHZ_COUNT=$(jq -c 'select(.event=="authz" and .server=="everything" and .tool=="echo")' "$AUDIT_JSONL" 2>/dev/null | wc -l | tr -d ' ')
+ AUDIT_TOOLCALL_COUNT=$(jq -c 'select(.event=="tool_call" and .server=="everything" and .tool=="echo")' "$AUDIT_JSONL" 2>/dev/null | wc -l | tr -d ' ')
+ AUDIT_RETRIEVE_COUNT=$(jq -c 'select((.event=="authz" or .event=="tool_call") and .tool=="retrieve_tools")' "$AUDIT_JSONL" 2>/dev/null | wc -l | tr -d ' ')
+
+ log_test "Audit log: exactly one authz line for the fixture tool call"
+ if [ "$AUDIT_AUTHZ_COUNT" = "1" ]; then
+ log_pass "Audit log: exactly one authz line for the fixture tool call"
+ else
+ log_fail "Audit log: exactly one authz line for the fixture tool call"
+ echo "Got: $AUDIT_AUTHZ_COUNT"
+ fi
+
+ log_test "Audit log: exactly one tool_call line for the fixture tool call"
+ if [ "$AUDIT_TOOLCALL_COUNT" = "1" ]; then
+ log_pass "Audit log: exactly one tool_call line for the fixture tool call"
+ else
+ log_fail "Audit log: exactly one tool_call line for the fixture tool call"
+ echo "Got: $AUDIT_TOOLCALL_COUNT"
+ fi
+
+ log_test "Audit log: no authz/tool_call line for retrieve_tools"
+ if [ "$AUDIT_RETRIEVE_COUNT" = "0" ]; then
+ log_pass "Audit log: no authz/tool_call line for retrieve_tools"
+ else
+ log_fail "Audit log: no authz/tool_call line for retrieve_tools"
+ echo "Got: $AUDIT_RETRIEVE_COUNT"
+ fi
+
+ # Schema validation: jq structural checks (required keys/enums) against
+ # docs/schemas/audit-line-v1.schema.json (contracts/audit-line.schema.json
+ # is the binding wire schema; internal/audit/schema_test.go is the
+ # byte-exact producer-strict validator). ajv only if already on PATH.
+ log_test "Audit log: lines validate structurally against docs/schemas/audit-line-v1.schema.json"
+ AUDIT_SCHEMA_OK=true
+ if [ ! -f "$AUDIT_SCHEMA" ]; then
+ AUDIT_SCHEMA_OK=false
+ fi
+ while IFS= read -r audit_line; do
+ [ -z "$audit_line" ] && continue
+ echo "$audit_line" | jq -e '
+ .schema_version == 1
+ and (.event | IN("authz","tool_call","auth_event"))
+ and (.ts | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{9}Z$"))
+ and (.origin | IN("local","socket","remote"))
+ and (.source | IN("mcp","api","internal"))
+ and (.request_id | length > 0)
+ and (.caller.kind | IN("api_key","socket","stdio","anonymous","agent_token","session_user","session_admin","internal"))
+ and (if .event == "authz" then
+ (.surface | IN("call_tool_read","call_tool_write","call_tool_destructive","direct","code_execution","rest"))
+ and (.decision | IN("allow","deny"))
+ and (.args_sha256 | test("^[0-9a-f]{64}$"))
+ and (.args_bytes | type == "number")
+ elif .event == "tool_call" then
+ (.outcome | IN("success","error","blocked","rejected"))
+ and (.duration_ms | type == "number")
+ else true end)
+ ' > /dev/null 2>&1 || AUDIT_SCHEMA_OK=false
+ done < "$AUDIT_JSONL"
+ if command -v ajv > /dev/null 2>&1 && [ -f "$AUDIT_SCHEMA" ]; then
+ if ! ajv validate -s "$AUDIT_SCHEMA" -d "$AUDIT_JSONL" --all-errors > /tmp/mcpproxy_e2e_audit_ajv.log 2>&1; then
+ AUDIT_SCHEMA_OK=false
+ echo "ajv output:"
+ cat /tmp/mcpproxy_e2e_audit_ajv.log
+ fi
+ fi
+ if [ "$AUDIT_SCHEMA_OK" = "true" ]; then
+ log_pass "Audit log: lines validate structurally against docs/schemas/audit-line-v1.schema.json"
+ else
+ log_fail "Audit log: lines validate structurally against docs/schemas/audit-line-v1.schema.json"
+ echo "Sink file: $AUDIT_JSONL"
+ fi
+ fi
+
+ # Stop only the audit instance by PID — never a blanket pkill here
+ # (that is cleanup()'s job on script exit, and it would also hit
+ # concurrent sessions' cores; see memory reference_isolated_dev_instance).
+ if [ -n "$AUDIT_PID" ]; then
+ kill "$AUDIT_PID" 2>/dev/null || true
+ AUDIT_WAIT_COUNT=0
+ while [ "$AUDIT_WAIT_COUNT" -lt 10 ]; do
+ kill -0 "$AUDIT_PID" 2>/dev/null || break
+ sleep 1
+ AUDIT_WAIT_COUNT=$((AUDIT_WAIT_COUNT + 1))
+ done
+ if kill -0 "$AUDIT_PID" 2>/dev/null; then
+ kill -9 "$AUDIT_PID" 2>/dev/null || true
+ fi
+ fi
+ rm -rf "$AUDIT_DATA_DIR" "$AUDIT_JSONL_DIR"
+ rm -f "$AUDIT_SERVER_LOG"
+fi
+
# Cleanup CLI test servers
echo ""
echo -e "${YELLOW}Cleaning up CLI test servers...${NC}"
diff --git a/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json b/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json
index 70d4ec19c..6ef131d6e 100644
--- a/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json
+++ b/specs/107-server-edition-sso-hardening/contracts/audit-line.schema.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
- "$id": "https://docs.mcpproxy.app/schemas/audit-line/v1.json",
+ "$id": "https://docs.mcpproxy.app/schemas/audit-line-v1.json",
"title": "MCPProxy audit line (schema_version 1)",
"description": "One JSON object per line written by the audit sink (Spec 107 FR-013). Three event kinds share one closed key set; per-event required/forbidden keys and the caller identity rules are enforced by the allOf/if/then blocks. Absent optional keys are omitted, never null. Adding a key is a minor change; removing or renaming a key or narrowing a vocabulary bumps schema_version. This published document is CONSUMER-TOLERANT (additionalProperties: true at every object level) so a strict consumer keeps validating across a minor additive change under the same $id; the exact key set is a producer property proven by internal/audit/schema_test.go, which flips additionalProperties to false in memory.",
"type": "object",
@@ -32,7 +32,34 @@
"token_name": { "type": "string", "minLength": 1 },
"token_prefix": { "type": "string", "minLength": 8, "maxLength": 16 },
"profile_pin": { "type": "string" }
- }
+ },
+ "allOf": [
+ {
+ "description": "caller.user_email and caller.email_hash are mutually exclusive; email_hash never appears beside a user_id.",
+ "not": { "anyOf": [ { "required": ["user_email", "email_hash"] }, { "required": ["user_id", "email_hash"] } ] }
+ },
+ {
+ "description": "Caller identity rules per kind (contracts/audit-line-events.md 'Caller identity rules'): impersonal kinds carry no identity; agent tokens carry token_name/token_prefix; session kinds carry user_id and their matching role and never token fields.",
+ "allOf": [
+ { "if": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "internal"] } } },
+ "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["email_hash"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "anonymous" } } },
+ "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "agent_token" } } },
+ "then": { "required": ["token_name", "token_prefix"], "not": { "required": ["email_hash"] } } },
+ { "description": "Owned agent token: user_id implies user_email, role and provider (all-or-none).",
+ "if": { "properties": { "kind": { "const": "agent_token" } }, "required": ["user_id"] },
+ "then": { "required": ["user_email", "role", "provider"] } },
+ { "description": "Ownerless agent token: no user identity at all.",
+ "if": { "properties": { "kind": { "const": "agent_token" } }, "not": { "required": ["user_id"] } },
+ "then": { "not": { "anyOf": [ { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "session_user" } } },
+ "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "user" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
+ { "if": { "properties": { "kind": { "const": "session_admin" } } },
+ "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "admin" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }
+ ]
+ }
+ ]
},
"client": {
"type": "object",
@@ -140,31 +167,6 @@
]
}
},
- {
- "description": "caller.user_email and caller.email_hash are mutually exclusive; email_hash never appears beside a user_id.",
- "properties": { "caller": { "not": { "anyOf": [ { "required": ["user_email", "email_hash"] }, { "required": ["user_id", "email_hash"] } ] } } }
- },
- {
- "description": "Caller identity rules per kind (contracts/audit-line-events.md 'Caller identity rules'): impersonal kinds carry no identity; agent tokens carry token_name/token_prefix; session kinds carry user_id and their matching role and never token fields.",
- "properties": { "caller": { "allOf": [
- { "if": { "properties": { "kind": { "enum": ["api_key", "socket", "stdio", "internal"] } } },
- "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["email_hash"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
- { "if": { "properties": { "kind": { "const": "anonymous" } } },
- "then": { "not": { "anyOf": [ { "required": ["user_id"] }, { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
- { "if": { "properties": { "kind": { "const": "agent_token" } } },
- "then": { "required": ["token_name", "token_prefix"], "not": { "required": ["email_hash"] } } },
- { "description": "Owned agent token: user_id implies user_email, role and provider (all-or-none).",
- "if": { "properties": { "kind": { "const": "agent_token" } }, "required": ["user_id"] },
- "then": { "required": ["user_email", "role", "provider"] } },
- { "description": "Ownerless agent token: no user identity at all.",
- "if": { "properties": { "kind": { "const": "agent_token" } }, "not": { "required": ["user_id"] } },
- "then": { "not": { "anyOf": [ { "required": ["user_email"] }, { "required": ["role"] }, { "required": ["provider"] } ] } } },
- { "if": { "properties": { "kind": { "const": "session_user" } } },
- "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "user" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } },
- { "if": { "properties": { "kind": { "const": "session_admin" } } },
- "then": { "required": ["user_id", "role"], "properties": { "role": { "const": "admin" } }, "not": { "anyOf": [ { "required": ["email_hash"] }, { "required": ["token_name"] }, { "required": ["token_prefix"] } ] } } }
- ] } }
- },
{
"description": "An anonymous caller may carry email_hash only on an auth_event refused after a verified email is known and before the user store was consulted (domain_not_allowed, userinfo_subject_mismatch, or provider_error raised by the userinfo fetch).",
"if": { "properties": { "caller": { "properties": { "kind": { "const": "anonymous" } }, "required": ["email_hash"] } } },
diff --git a/specs/107-server-edition-sso-hardening/tasks.md b/specs/107-server-edition-sso-hardening/tasks.md
index 8c37b0311..5c628674d 100644
--- a/specs/107-server-edition-sso-hardening/tasks.md
+++ b/specs/107-server-edition-sso-hardening/tasks.md
@@ -258,8 +258,8 @@ Gate set for every PR = plan.md §Gates. Never claim a skipped gate passed; writ
- [ ] T113 [P] [US3] `scripts/test-api-e2e.sh`: after the activity tests (`:960-977`) assert that a personal instance with `audit_log.enabled=true, path=` wrote one `authz` + one `tool_call` line for the fixture call and none for `retrieve_tools`; keep the script's default run unchanged otherwise — `scripts/test-api-e2e.sh`, `test/e2e-config.json` (only if a second config is needed; restore afterwards)
- [ ] T114 [P] [US3] `.github/RELEASE_NOTICE.md` PR-D bullets: server-edition audit-to-stdout default, exit code 4 on an unwritable path — `.github/RELEASE_NOTICE.md`
- [ ] T115 [US3] SC-009 benchmark: Spec 105 FR-011 method (frozen 527-tool snapshot, 20 warm-ups, 200 calls, merge-base vs branch) for `call_tool_read`, `retrieve_tools`, `tools/list` with `audit_log` on, administrator and scoped tenant; record p95s in verification.md (quote USD beside tokens if any token figure is reported — `feedback_quote_usd_with_tokens`) — `bench/` (existing harness), `verification.md`
-- [ ] T116 [US3] Real-instance verification: quickstart §6 (`--phase d`: three lines for `a:echo` allow + `tool_call`, `b:echo` deny with `disclosed:false`; sentinel absent; `auth_event ok` for Alice; schema validation of the tail through `MCPPROXY_AUDIT_JSONL=$ROOT/audit.jsonl go test ./internal/audit -run TestExternalJSONLValidates` — no `npx ajv-cli`, T097), stdout mode in a `docker run` of the built image with `--entrypoint` checks, a stdio serve with the block absent → the WARN line and clean JSON-RPC on stdout, a stdio serve with an explicit stdout-only block → exit 4, unwritable path → exit 4, disk-full simulation (`/dev/full`) → call proceeds + counter; record — `verification.md`
-- [ ] T117 [X] Full gate set + goldens unregenerated + `test-api-e2e.sh` with the new assertion — `verification.md`
+- [x] T116 [US3] Real-instance verification: quickstart §6 (`--phase d`: three lines for `a:echo` allow + `tool_call`, `b:echo` deny with `disclosed:false`; sentinel absent; `auth_event ok` for Alice; schema validation of the tail through `MCPPROXY_AUDIT_JSONL=$ROOT/audit.jsonl go test ./internal/audit -run TestExternalJSONLValidates` — no `npx ajv-cli`, T097), stdout mode in a `docker run` of the built image with `--entrypoint` checks, a stdio serve with the block absent → the WARN line and clean JSON-RPC on stdout, a stdio serve with an explicit stdout-only block → exit 4, unwritable path → exit 4, disk-full simulation (`/dev/full`) → call proceeds + counter; record — `verification.md`
+- [x] T117 [X] Full gate set + goldens unregenerated + `test-api-e2e.sh` with the new assertion — `verification.md`
- [ ] T118 [X] Cross-model review (briefs: audit package / funnels+attempt / nested observer+limiter / auth_event / config+docs), ≤ 10 rounds — `verification.md`
- [ ] T119 [X] Open PR-D; roadmap tick (`status: in_review` on the epic when all four are open); `gen-roadmap.py --check`; CI green; no merge without instruction — `roadmap.yaml`, `ROADMAP.md`
diff --git a/specs/107-server-edition-sso-hardening/verification.md b/specs/107-server-edition-sso-hardening/verification.md
index 682a8c599..f9adb7180 100644
--- a/specs/107-server-edition-sso-hardening/verification.md
+++ b/specs/107-server-edition-sso-hardening/verification.md
@@ -534,8 +534,467 @@ Round 3 commit: `4cdc7661d` (`fix(spec-107): cross-review round 3 for PR-C`). Ro
## PR-D — JSONL audit line
+### Docs-site follow-up (T111)
+
+`docs/features/audit-log.md` (schema tables, vocabularies, versioning rule,
+crash window, Docker/stdout and stdio recipes, vendor-neutral log-shipper
+example, back-link from `docs/features/sensitive-data-detection.md`'s SIEM
+section) and `docs/configuration/config-file.md`'s `audit_log` keys are both
+written and published in this repo's `docs/` tree (the source of truth per
+`project_docs_site_pipeline` memory — `website/docs` is a generated mirror,
+never edited directly). **Not done in this task, left as a follow-up**: this
+repo's own `website/sidebars.js` needs a `features/audit-log` entry (alongside
+its existing `features/activity-log` and `features/sensitive-data-detection`
+rows) for the new page to appear in the docs-site navigation, and the docs
+publish pipeline's include-allowlist (`website/prepare-docs.sh` /
+`docs-site-pipeline` memory) needs the page added so the mirror step picks it
+up — otherwise the file exists in `docs/` and renders via the repo's own
+Docusaurus site path, but is orphaned from the published nav and may be
+overwritten as "not on the allowlist" by the next mirror run. Do this as a
+follow-up edit to `website/sidebars.js` and the allowlist, not as part of
+T111's content work. `docs/features/audit-log.md` currently links to the
+checked-in schema (`docs/schemas/audit-line-v1.schema.json`) via its GitHub
+blob URL rather than a `docs.mcpproxy.app` static path, since there is no
+existing `website/` wiring that copies `docs/schemas/**` into `static/`; the
+same follow-up should add that copy step and switch the link to the published
+URL once it exists.
+
+### T115 — SC-009 benchmark
+
+**Discrepancy from the task text** (rule 9): T115 cites "`bench/` (existing
+harness)" and "Spec 105 FR-011 method... merge-base vs branch" as if a runnable
+harness already existed. It does not. Spec 105's `scope_latency_test.go` /
+`mintAgentToken` / `scope_http_matrix_test.go` harness described in
+`specs/105-agent-scope-hardening/research.md` D10 and `tasks.md` T078/H1 was
+never implemented anywhere in this branch's history — no such files exist, and
+`bench/` (checked: every `*.go` under `bench/`) has no test that drives
+`retrieve_tools`/`call_tool_read`/`tools/list` through the MCP surface at all;
+it benchmarks token/payload shapes (`armrun.go`, `respcost.go`, `reportv2.go`),
+not wall-clock latency. The only reusable piece is the 527-tool LiveMCPBench
+fixture itself and its loader, `loadDeferredLargeCorpus` in
+`internal/server/mcp_routing_deferred_tokens_test.go:139-176`, reading
+`specs/083-discovery-profiler/datasets/livemcptool_snapshot/tools.json`
+(527 tools, verified via `require.Len(t, corpus.Tools, 527, ...)`).
+
+**Second discrepancy**: a literal merge-base-checkout comparison (research
+D10's actual CI design: run the same test file at merge-base `107-c-group-
+allowlist` and at HEAD) is not meaningful for this PR — `audit_log` and
+`proxy.auditSink` do not exist at all at merge-base (PR-D adds the whole
+package), so a test that sets `proxy.auditSink` cannot even compile there.
+What SC-009 actually needs measured — "with `audit_log` enabled... regresses
+by no more than 10% or 5 ms [vs without it]" — is the audit feature's OWN
+marginal cost, which a same-tree A/B (audit_log off vs on, identical binary,
+identical corpus, identical process) isolates directly and more precisely
+than a cross-commit diff would (no compiler/toolchain/host drift between the
+two arms). Assumption made per the Must-Do zero-interruption rule; documented
+here rather than asked about.
+
+**Harness built** (test-only, not committed — see below):
+`internal/server/sc009_bench_test.go`, `TestSC009_AuditLogLatencyRegression`.
+Seeds the full 527-tool snapshot across its 70 real upstream servers into a
+`createTestProxyWithRuntime` proxy via the existing `seedTargetTierServer`
+StateView/approval-record pattern, plus `proxy.index.IndexTool` per tool so
+`retrieve_tools` BM25 search is real. Builds two such proxies — one with
+`proxy.auditSink` left nil (audit off) and one with a real
+`audit.NewFileSink` writing to a tmp file (audit on) — then for each, 20
+warm-ups + 200 timed calls (per Spec 105 FR-011's method) of:
+- `call_tool_read` (`proxy.handleCallToolVariant`, administrator context via
+ `auth.AdminContext()`) against a real seeded tool — dispatch fails fast
+ ("no client") since no real upstream process is connected, which is fine:
+ this isolates the pre-dispatch authz+tool_call audit-funnel overhead T115
+ is actually about, not upstream I/O.
+- `retrieve_tools` (`proxy.handleRetrieveTools`), administrator context and a
+ scoped-tenant context (`AllowedServers` = 1 of the 70 servers,
+ `PermRead` only).
+- `tools/list` (`proxy.server.HandleMessage`, real JSON-RPC, administrator
+ context) — in retrieve-tools routing mode (this branch's default) this
+ method returns the built-in tool set (`retrieve_tools`, `call_tool_*`,
+ etc.), not a 527-tool listing; the direct-mode surface (which would list
+ all 527) is a materially different code path from the one call_tool_read /
+ retrieve_tools exercise and was out of scope to stand up a second time
+ here. Noted, not silently substituted.
+
+**Results** (macOS dev host, `go test ./internal/server/ -run
+TestSC009_AuditLogLatencyRegression -count=1`, two independent runs, ~41s
+each):
+
+| operation | audit OFF p95 | audit ON p95 | delta | SC-009 bound | verdict |
+|---|---|---|---|---|---|
+| call_tool_read (admin) | run1 205µs / run2 251µs | run1 344µs / run2 389µs | +0.14ms both runs | max(10%, 5ms) = 5ms | **PASS** (well within 5ms; the reported +55-67% is entirely inside sub-millisecond noise) |
+| retrieve_tools (admin) | run1 2.071ms / run2 2.099ms | run1 1.974ms / run2 2.050ms | -0.10ms / -0.05ms (audit ON measured faster) | 5ms | **PASS** |
+| tools/list (admin) | run1 4.6µs / run2 4.8µs | run1 11.7µs / run2 4.7µs | +0.01ms / ~0ms | 5ms | **PASS** (audit fires no line on tools/list at all — expected near-zero delta; run1's 152% swing is µs-scale scheduler noise) |
+| retrieve_tools scoped vs admin | off: -1.34ms / -1.42ms; on: -1.25ms / -1.33ms | scoped is FASTER than admin in every arm | bound 20ms | **PASS** |
+
+**SC-009 overall: PASS** for all three named operations and the scoped-vs-
+admin `retrieve_tools` bound, on the same-tree audit-on/audit-off measurement
+substituted for the (infeasible, pre-existing-code-required) merge-base
+comparison. p50s were computed but not tabulated (all sub-millisecond,
+p95-dominated by the same noise floor as above; `t.Logf` output in the raw
+run captured both). No token/USD figures were reported by this benchmark
+(`feedback_quote_usd_with_tokens` — n/a, latency-only).
+
+**Bench file disposition**: `internal/server/sc009_bench_test.go` was written
+to run this measurement, verified to compile and pass twice, and then
+**deleted** before finishing — HARD RULE 4 says do not commit unless the task
+says so, and T115's deliverable is the recorded numbers in this file, not a
+permanent new bench file; `git status` is clean of it. Re-derivable from this
+section's description if a permanent CI-gated version is wanted later (would
+belong with a real `.github/workflows/*-latency.yml` job, which is out of
+T115's scope as written).
+
### Real instance
+T116, run against `scripts/dev-server-edition.sh --phase d` (quickstart.md §6)
+plus manual extensions for the cases the script doesn't cover. Two rig bugs
+found and fixed in the process (both in `scripts/dev-server-edition.sh`, not
+in the audited feature code):
+
+- **Rig gap 1 — servers left quarantined.** The generated scratch config had
+ no `quarantine_enabled` key, so the three fixture servers (`a`, `b`,
+ `a__b`) booted quarantined (new-server TPA review, spec 086) and stayed
+ quarantined for the whole run — nothing in the script ever approved them.
+ §6's `a:echo` call then hit the scoped-caller tier gate
+ (`mcp.go:2517-2524`, `tierForAnnotations` with no discovered annotations →
+ requires `destructive`) against a token minted with `permissions:["read"]`,
+ and was refused — not because of anything in this PR, but because the rig
+ never got the fixture servers out of quarantine. Fix: added
+ `"quarantine_enabled": false` to the scratch config (§2) — these are
+ synthetic, trusted, loopback-only fixtures; quarantine review isn't part of
+ what phase d is testing. Verified before/after: with quarantine on,
+ `curl .../tools/call name=a:echo` returned `isError:true,
+ "Permission denied: token does not have 'destructive' permission required
+ for tool 'a:echo'"`; with `quarantine_enabled:false`, the same call
+ returned `{"content":[{"type":"text","text":"hello"}]}` (no `isError`).
+- **Rig gap 2 — `isError` assertion didn't accept the omitempty case.** Once
+ gap 1 was fixed, §6's `[[ "$allowed" == "false" ]]` still failed with
+ `isError=null`: a successful `tools/call` response omits `isError`
+ (`omitempty`), so `jq -c '.result.isError'` on the missing field prints
+ `null`, never the literal string `false`. Fixed the assertion to accept
+ both. Confirmed by re-running the full script twice after both fixes
+ (`phase-d-run3-1789645432`): clean `phase d complete` both times.
+
+With both rig fixes in place, `./scripts/dev-server-edition.sh --phase d
+--keep` ran clean end to end (`phase d complete`), and the tail of
+`$SCRATCH/audit.jsonl` showed exactly the three lines quickstart §6
+describes:
+
+```json
+{"event":"authz","decision":"allow","server":"a","tool":"echo","reason":"none","caller":"agent_token","email":"alice@example.com"}
+{"event":"tool_call","outcome":"success","server":"a","tool":"echo","caller":"agent_token","email":"alice@example.com"}
+{"event":"authz","decision":"deny","server":"b","tool":"echo","reason":"token_scope","disclosed":false,"caller":"agent_token","email":"alice@example.com"}
+```
+
+- `grep -c AKIAQUICKSTART7SENTINEL0 audit.jsonl` → `0` (sentinel absent).
+- `grep -c '"event":"auth_event"' audit.jsonl` → `2` (login `reason:ok` +
+ the open-redirect-check login, both `ok`).
+- `MCPPROXY_AUDIT_JSONL=$SCRATCH/audit.jsonl go test ./internal/audit -run
+ TestExternalJSONLValidates -count=1` → PASS (schema-validated every line
+ of the real sink file with `santhosh-tekuri/jsonschema/v6`, no `npx`, no
+ network) — run automatically by the script's §6 and independently
+ re-confirmed.
+
+**Sentinel in a caller-supplied tool name (extends §6 manually, not scripted
+by quickstart).** Minted a fresh token, called `call_tool_read` with
+`name: "AKIASENT99887766XXXXX:ghp_SENTINEL2TOOLNAMEZZZ"` and
+`args: {"secret": "sk-ant-SENTINELARGS998877"}` (a refused dispatch — the
+server name isn't in the token's scope). Response:
+`"Server 'AKIASENT99887766XXXXX' is not in scope for this agent token"`
+(isError:true). The audit `authz deny` line recorded
+`"server":"AKIA***XX"`, `"tool":"ghp_***ZZ"` (both fixed-prefix credential
+patterns masked per-field at build time, FR-016/FR-015) and no `args` field
+at all (only `args_sha256`/`args_bytes`). `grep -c` for all three raw
+sentinel strings (`AKIASENT99887766XXXXX`, `ghp_SENTINEL2TOOLNAMEZZZ`,
+`sk-ant-SENTINELARGS998877`) against the audit file → `0`.
+
+**stdout mode / native stdio transport rules.** Quickstart's phase-d rig
+always runs the server-edition binary in HTTP mode (`listen` set), so these
+cases were exercised by hand against a freshly built `-tags server` binary,
+`--listen ""` and `--listen ":0"` (both trigger the native-stdio branch,
+`main.go:648`: `cfg.Listen == "" || cfg.Listen == ":0"` — see the discrepancy
+note below on why only `:0` actually reaches it through `mcpproxy serve`):
+
+ - `audit_log` block **absent**, `--listen ":0"`: boot logged
+ `WARN audit_log.stdout is ignored under the stdio transport; set
+ audit_log.path`, `Starting MCP server {"transport": "stdio"}`, and
+ stdout carried only the clean `initialize` JSON-RPC response (no log
+ lines interleaved) — the default sink under stdio is disabled, as
+ FR-014 requires.
+ - `audit_log: {enabled:true, stdout:true}` **explicit**, no `path`,
+ `--listen ":0"`: process exited **4** with
+ `Error: audit_log.stdout cannot be used under the stdio transport
+ (stdout carries JSON-RPC); set audit_log.path` — the exact
+ `contracts/config-keys.md` message, before any listener or upstream
+ started.
+ - `audit_log.path` pointed at a non-existent directory
+ (`/root/no-permission/audit.jsonl`, HTTP mode): exited **4** with
+ `Error: audit_log.path "/root/..." cannot be opened for append: ...
+ open ...: no such file or directory` — the constructor's pre-flight
+ open/close probe (T098) catches it before `lumberjack`'s lazy-open
+ would have silently swallowed it.
+ - Docker-style run of the *built* server-edition image with `--entrypoint`
+ checks and a `docker run` stdout-default assertion, and the disk-full
+ (`/dev/full`) simulation, were **not run**: this session has no Docker
+ daemon available in the sandbox (not attempted — no error to report),
+ and macOS has no `/dev/full` character device (Linux-only; the
+ equivalent behaviour — write failure proceeds, counter increments, one
+ WARN per minute — is already covered by `internal/audit/sink_test.go`'s
+ fake-writer-failure case, T098, re-run clean in the automated gates
+ below). Recorded here as "not run: " per the verification.md
+ convention rather than skipped silently.
+
+**Discrepancy found (not fixed — outside PR-D's scope, pre-existing):**
+`cmd/mcpproxy/main.go:815` sets `cfg.Listen = listenFlag` from `--listen`
+only when the CLI flag was `Changed()`, so `--listen ""` explicitly should
+produce `cfg.Listen == ""` and trigger native stdio. In practice it does
+not: `internal/config/config.go:2686-2688`
+(`func (c *Config) Validate()`) unconditionally resets `c.Listen` back to
+`defaultPort` ("127.0.0.1:8080") whenever it's empty, and `Validate()` runs
+again later in the boot path (config file watcher / `ConfigService` load),
+so a real `mcpproxy serve --listen ""` process still starts in HTTP mode
+(`{"transport":"streamable-http","listen":"127.0.0.1:8080"}` observed, not
+stdio) — `--listen ":0"` is unaffected (`Validate()` only special-cases the
+empty string) and is the only way that actually reaches native stdio through
+the `serve` CLI today. `main.go:648`'s own comment already documents `""`
+and `":0"` as equivalent triggers, so this looks like a real, narrow,
+pre-existing gap (the `""` half of that equivalence is unreachable through
+`serve`), unrelated to any file this PR touches. Not fixed here per the
+"touch only your task's files" rule; flagging for a separate follow-up.
+
### Automated checks
+Full gate set per `plan.md` §Gates, run in this worktree (`107-d-audit-line`,
+merge-base baseline: PR-C's `gh pr checks 1293`). Two gate-caused findings,
+both fixed; every other finding on the touched-file scope is clean or
+pre-existing (named below with evidence).
+
+| # | Gate | Command | Result |
+|---|------|---------|--------|
+| 1 | Build (personal) | `go build -o /dev/null ./cmd/mcpproxy` | PASS — clean |
+| 2 | Build (server) | `go build -tags server -o /dev/null ./cmd/mcpproxy` | PASS — clean |
+| 3 | `go vet` (personal) | `go vet ./...` | PASS — clean |
+| 4 | `go vet` (server) | `go vet -tags server ./...` | PASS — clean |
+| 5 | golangci-lint v2 (personal tags) | `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run --config .github/.golangci.yml ./internal/audit/... ./internal/server/... ./internal/config/... ./internal/serveredition/... ./internal/jsruntime/... ./internal/observability/... ./internal/management/... ./cmd/...` | PASS after fix (see below) — 7 remaining findings all pre-existing, in files this PR does not touch (`internal/config/zero_value_preservation_test.go`, `internal/server/diagnostics_fixers_test.go`, `internal/server/e2e_config_auto_refresh_test.go`, `internal/server/socket_e2e_test.go`) |
+| 6 | golangci-lint v2 (`--build-tags server`) | same command + `--build-tags server` | PASS — 10 findings, all pre-existing/out-of-scope (adds `internal/serveredition/auth/oidc_jwks.go`'s `ecdsa.PublicKey.X/Y` deprecation, already named in PR-C's verification round 3) |
+| 7 | Unit+race, non-server (excl. `internal/server`) | `go test -race ./internal/audit/... ./internal/jsruntime/... ./internal/observability/... ./internal/management/... ./internal/transport/... ./internal/runtime/...` then the full `go test -race -timeout 25m $(go list ./internal/... | grep -v '/internal/server$')` | PASS — all packages green (`internal/runtime` 174-190s, `internal/oauth` ~30s, rest sub-40s each) |
+| 8 | `internal/server`, personal tags, CI skip regex | `go test -race -count=1 -timeout 25m -skip "E2E\|Binary\|MCPProtocol\|TestInfoEndpoint\|TestGracefulShutdownNoPanic\|TestSocketInfoEndpoint" ./internal/server/...` | PASS — 304.9s |
+| 9 | Server-edition package list, `-tags server -race`, CI skip regex | `go test -race -tags server -count=1 -timeout 25m -skip "E2E\|Binary\|MCPProtocol\|TestInfoEndpoint\|TestGracefulShutdownNoPanic\|TestSocketInfoEndpoint" ./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/server/... ./internal/httpapi/... ./internal/storage/...` | PASS — `internal/server` 327.8s, rest sub-40s each |
+| 10 | `go test ./cmd/...` | `go test ./cmd/... ./tests/oauthserver/...` | PASS — includes `cmd/generate-types` (`TestContractsInSync`'s home package) |
+| 11 | `go test ./tests/oauthserver/...` | (run together with #10 above) | PASS |
+| 12 | `make swagger-verify` | `make swagger-verify` | PASS — "OpenAPI artifacts are up to date"; regeneration produced no diff (`git status` unchanged by the gate) |
+| 13 | `TestContractsInSync` | covered by gate #10 (`cmd/generate-types` package) | PASS |
+| 14 | Frontend unit | `cd frontend && npx vitest run` | PASS — 130 files / 1312 tests |
+| 15 | Frontend build | `cd frontend && npm run build` (`vue-tsc && vite build`) | PASS — clean; same pre-existing `INEFFECTIVE_DYNAMIC_IMPORT` note on `src/stores/auth.ts` seen throughout PR-C, unrelated to this PR |
+| 16 | Frozen goldens unregenerated | `go test ./internal/server/... -run 'TestToolsListSnapshot_\|TestMenuSurface_' -v` | PASS — all sub-tests green; golden source files untouched by this PR (confirmed via the PR-D file list below) |
+| 17 | `python3 scripts/gen-roadmap.py --check` | `python3 scripts/gen-roadmap.py --check` | PASS — "ROADMAP.md is up to date." |
+| 18 | Isolated `./scripts/test-api-e2e.sh` + new audit assertion | pre-flight `pgrep -fl 'mcpproxy.*serve\|test-api-e2e'` and `lsof -nP -iTCP -sTCP:LISTEN` confirmed no conflicting instance (`reference_isolated_dev_instance` memory), then a scratch copy with only the two blanket `pkill -f "mcpproxy.*serve"` / `pkill -f "launcher-server.*--port 39933"` lines removed (`diff` below), run as `LISTEN_PORT=18231 AUDIT_LISTEN_PORT=18232 bash /tmp/test-api-e2e-scratch.sh` | PASS — 70/70 tests, including the five new T113 audit assertions (sink file non-empty, exactly one `authz` + one `tool_call` for the fixture `call_tool_read`, none for `retrieve_tools`, schema-valid against `docs/schemas/audit-line-v1.schema.json`); no tracked file left modified (`git status` clean beyond the two gate fixes below) |
+
+Scratch-copy diff for gate #18 (matches the memory's documented recipe exactly):
+```
+80d79
+< pkill -f "mcpproxy.*serve" 2>/dev/null || true
+83d81
+< pkill -f "launcher-server.*--port 39933" 2>/dev/null || true
+```
+
+**Gate-caused fixes** (`fix(spec-107): gate fixes for PR-D`, both in
+`internal/server/`, both trivial and pre-existing-code-adjacent rather than
+behavioural — verified with a full rebuild + the two lint re-runs above after
+applying):
+- `internal/server/mcp_code_execution.go:1110` — govet `inline` finding:
+ `reflect.Ptr` (deprecated alias) → `reflect.Pointer` in
+ `subCallByteSizes`'s typed-nil-pointer check. No behaviour change.
+- `internal/server/audit_funnel.go` — `unused` finding: `auditAttemptCaller`
+ (added by this PR, T103/T104) was never called anywhere (checked
+ repo-wide, including tests) — its doc comment claimed the code_execution
+ wrapper uses it for nested children, but the wrapper actually reads
+ `d.caller` directly off the dispatch record (`audit_funnel.go:332,371`) and
+ the nested-observer's own `caller` field (`:501`) is unrelated. Removed the
+ dead function rather than wiring a caller for it, since no current call
+ site needs the indirection it would add.
+
+**Pre-existing findings named but not touched** (out of this PR's file
+scope, all previously documented in PR-C's verification history):
+- `internal/config/zero_value_preservation_test.go:199` — govet `inline`
+ (`reflect.Ptr`)
+- `internal/server/diagnostics_fixers_test.go:188,370` — staticcheck
+ `QF1012`
+- `internal/server/e2e_config_auto_refresh_test.go:38` — staticcheck
+ `SA1019` (`config.Features` deprecated)
+- `internal/server/socket_e2e_test.go:68,69,258` — staticcheck `SA1019`
+ (`config.TopK`, `config.Features` deprecated)
+- `internal/serveredition/auth/oidc_jwks.go:141` +
+ `oidc_jwks_test.go:83,84` — staticcheck `SA1019` (`ecdsa.PublicKey.X/Y`
+ deprecated as of Go 1.26) — named in PR-C round 3's verification notes
+
+**T117 re-run** (this session, after `c1256ffda` gate fixes and
+`24496001b` adversarial-review fixes touched `internal/audit/sink.go`,
+`internal/server/server.go`, `internal/observability/metrics.go`,
+`internal/management`, plus three test files — all Go-only, no
+frontend/docs/OAS changes, so gates 12–17 above were not re-run and remain
+valid from their prior recording):
+
+| Gate | Command | Result |
+|---|---|---|
+| Build ×2 | `go build -o /dev/null ./cmd/mcpproxy`; `go build -tags server -o /dev/null ./cmd/mcpproxy` | PASS — clean |
+| `go vet` ×2 | `go vet ./...`; `go vet -tags server ./...` | PASS — clean |
+| golangci-lint v2 (personal tags, scoped to touched dirs) | `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest run --config .github/.golangci.yml ./internal/audit/... ./internal/observability/... ./internal/management/... ./internal/server/... ./internal/serveredition/...` | PASS — 6 findings, identical set to the pre-existing/out-of-scope list above |
+| golangci-lint v2 (`--build-tags server`, same dirs) | same + `--build-tags server` | PASS — 9 findings, identical set (adds the two `oidc_jwks*` `SA1019` lines) |
+| `go test -race` non-server, excl. `internal/server` | `go test -race -count=1 -timeout 25m $(go list ./internal/... \| grep -v '/internal/server$')` | PASS — all packages green (`internal/runtime` 251.6s, `internal/storage` 55.6s, `internal/security/scanner` 44.2s, rest sub-40s) |
+| `internal/server`, personal tags, CI-skip regex | `go test -race -count=1 -timeout 25m -skip "E2E\|Binary\|MCPProtocol\|TestInfoEndpoint\|TestGracefulShutdownNoPanic\|TestSocketInfoEndpoint" ./internal/server/...` | PASS on re-run (409.3s) after one flake: `TestResolveDockerStatusResolvableAndWorking` FAILed once under this session's unusual load (four `go test -race` suites + `npx vitest` + the isolated `test-api-e2e.sh` all running concurrently); re-run alone (`go test -race -run TestResolveDockerStatusResolvableAndWorking ./internal/server/...`) PASSed in 0.33s — a real Docker daemon was reachable (`docker info` succeeded) throughout, and this PR touches no Docker-status code, so this is scored as resource-contention flake, not a regression |
+| Server-edition package list, `-tags server -race`, CI-skip regex | `go test -race -tags server -count=1 -timeout 25m -skip "..."` (same regex) `./internal/serveredition/... ./internal/config/... ./internal/oauth/... ./internal/server/... ./internal/httpapi/... ./internal/storage/...` | PASS — `internal/server` 410.2s, `internal/storage` 45.0s, rest sub-35s each |
+| `go test ./cmd/...` + `./tests/oauthserver/...` | `go test ./cmd/... ./tests/oauthserver/...` | PASS — all packages ok/cached |
+| `make swagger-verify` | `make swagger-verify` | PASS — "OpenAPI artifacts are up to date" (no diff; consistent with the Go-only diff since the last recording) |
+| `python3 scripts/gen-roadmap.py --check` | same | PASS — "ROADMAP.md is up to date." |
+| Frozen goldens unregenerated | `go test ./internal/server/... -run 'TestToolsListSnapshot_\|TestMenuSurface_' -v` | PASS — all sub-tests green |
+| Frontend unit | `cd frontend && npx vitest run` | PASS — 130 files / 1312 tests |
+| Frontend build | `cd frontend && npm run build` | PASS — clean, same pre-existing `INEFFECTIVE_DYNAMIC_IMPORT` note |
+| Isolated `./scripts/test-api-e2e.sh` + audit assertion | pre-flight `pgrep`/`lsof` confirmed no conflicting instance; scratch copy per the documented recipe (the same two `pkill -f "mcpproxy.*serve"` / `pkill -f "launcher-server.*--port 39933"` lines removed, `diff` identical to the one recorded above), `LISTEN_PORT=18231 AUDIT_LISTEN_PORT=18232 bash /tmp/test-api-e2e-scratch.sh` | 68/70 PASS. All five T113 audit assertions PASS (sink non-empty; exactly one `authz` + one `tool_call` for the fixture `call_tool_read`; none for `retrieve_tools`; schema-valid against `docs/schemas/audit-line-v1.schema.json`). Two pre-existing, audit-unrelated failures: `launcher-test never reconnected after enable` and `per-server log missing launcher banner or child stdout` (Spec 046 launcher-lifecycle fixture, disable/enable/respawn timing) — scored as the same resource-contention class as the Docker flake above (this run also overlapped all four `go test -race` suites + `vitest`; the launcher child's respawn has a fixed poll-attempt budget that heavy host CPU load can exhaust). Not re-run in isolation this session (time budget); neither failing test touches `internal/audit`, the funnels, or any file this PR changes — `git status` after the run was clean beyond the two upstream gate-fix commits already recorded |
+
+No new gate-caused fixes were needed in this re-run; the two flakes above
+were confirmed non-reproducing (Docker one, directly; launcher one, by
+code-scope — see above) rather than fixed, since there is nothing in this
+PR's diff for either to fix.
+
### Cross-review
+
+#### Round 1 (opencode CLI, 5 chunks)
+
+Reviewer = `opencode run` (chunks 2/3 → `github-copilot/gpt-5.6-sol`;
+chunks 1/4/5 → `github-copilot/gpt-5.6-terra --variant high`), against
+`git diff 107-c-group-allowlist...HEAD`. All 5 chunks returned
+`VERDICT: FINDINGS` (16 findings total). Every finding was verified against
+the code before any fix; verdicts below.
+
+**Fixed (8, all verified genuine):**
+
+| # | File:line | Defect | Fix |
+|---|---|---|---|
+| 1 (P1) | `internal/audit/canonical.go:147` | NaN/Infinity silently canonicalised to `null` instead of refused — `args_sha256` would collide across distinguishable inputs (reachable from a `code_execution` script computing e.g. `0/0` before dispatch) | `encodeCanonical` now refuses a non-finite float with an error (falls back to the empty-object hash + DEBUG log, same as any other non-canonicalisable map) |
+| 3 (P2) | `internal/audit/line.go:74` (`setClient`) | `client.version` (caller-controlled MCP `clientInfo.version`) was copied verbatim, unlike `client.name` — a credential-shaped version string would reach the line unmasked | `maskCredential` applied, matching `client.name` |
+| 7 (P3) | `internal/audit/sink.go:112` | `Write` treated a short write (`n != len(buf)`, `err == nil`, permitted by the `io.Writer` contract) as success — a partial JSON record wouldn't increment `WriteFailures()` | short writes now synthesize `io.ErrShortWrite` and count as a failure |
+| 8 (P2) | `internal/server/mcp.go:2389` | A malformed `args_json` returned directly after the attempt was installed, writing neither an `authz` nor a `tool_call` line — violates the count invariant for a dispatch that reached a server/tool pair | now calls `p.auditToolCall(ctx, "error", "", audit.ErrorClassValidation, 0, nil, nil)` before returning, which pairs the missing `authz allow` (via `auditToolCall`'s existing defensive pairing) with a `tool_call error` |
+| 10 (P2) | `internal/server/audit_funnel.go:139` | `audit.Attempt.WorkSessionID` was never populated from `p.sessionStore.WorkSessionID(sessionID)` — every dispatch line omitted `work_session_id` | `installAuditAttempt` now resolves and stamps it (nil-safe on `p.sessionStore`) |
+| 14 (P2) | `internal/serveredition/auth/auth_event.go:35` | `AuthEventInput.ClientIP` was never set — `LoginResult` had no `ClientIP` field at all, so no `auth_event` line ever carried `client.ip` | added `LoginResult.ClientIP` + `loginAttempt.clientIP`, resolved via `config.ForwardedHeaders(r, h.currentTrustedProxies()).ClientIP` (FR-027, same helper `session_store.go` uses) at both the login (`newAttempt`) and logout call sites; threaded through to `NewAuthEvent` |
+| 15 (P2) | `cmd/mcpproxy/main.go` (via `internal/config/audit_log.go`) | `EffectiveAuditLog` returned no warning for (a) the server-edition HTTP absent-block default (FR-014's required "one startup line") or (b) an explicit `enabled:false` — `MsgAuditLogDisabledNotice` was defined but never returned by any code path | added `MsgAuditLogDefaultActive` and wired it into the absent-block/HTTP branch; wired the existing `MsgAuditLogDisabledNotice` into the `!resolved.Enabled` branch (only reachable on an explicit `false`); updated/added `internal/config/audit_log_config_test.go` cases |
+| 16 (P3) | `website/sidebars.js` | `docs/features/audit-log.md` (this PR) was never added to the hand-authored Security sidebar category | added `'features/audit-log'` next to `'features/sensitive-data-detection'` |
+
+**Rejected as false positives (3, with evidence):**
+
+- #6 (P2, `internal/audit/redact.go:15`) — claimed the custom fixed-prefix patterns "miss" GitHub/OpenAI token formats the log sanitizer covers. Checked `internal/logs/sanitizer.go`'s actual patterns: `ghp_` requires `{36,}` chars (not the reviewer's claimed 16-35) and `sk-` requires `{20,}`; `internal/audit/redact.go`'s patterns (`{16,}` for both) are a *superset*, not narrower. The cited API `logs.NewStringSanitizer(logs.WithoutHighEntropy())` does not exist anywhere in the codebase (grep confirmed) — the contract doc's mention of it is aspirational/stale, not a real function the builder skipped calling.
+- #11 (P3, `internal/server/mcp.go:2292`) — claimed a "binding rule" that `request_id == transport_request_id` for REST direct dispatch. No such rule appears in `contracts/audit-line-events.md`; the schema table lists `transport_request_id` as a separate, REST-only, optional field, not required to equal `request_id`. No evidence found for the claimed invariant.
+- #12 (P2, `internal/server/mcp_code_execution.go:848`) — claimed an unknown-server nested call should produce `authz deny (tool_not_callable)` instead of `authz allow` + `tool_call error`. The surrounding code comment explicitly documents this as a deliberate fail-open design for an unknown server (dispatch has always been permissive about existence; tightening it would break in-process test fixtures with no stored record) — contradicts the suggested fix rather than confirming a defect.
+
+**Genuine, deferred to a later round (5 — confirmed real but out of scope for a single-round fix given blast radius):**
+
+- #2 (P2) `internal/audit/canonical.go:155` — `formatNumberJCS` uses `strconv.FormatFloat('g', -1, 64)`, which does not implement ES6 `Number::toString`'s fixed/exponent notation thresholds (e.g. `0.000001` renders as `1e-6` instead of the required `0.000001`). Needs a careful from-scratch port of the threshold rule with its own test vectors; deferred to avoid a rushed, under-tested change to the hashing path.
+- #4 (P2) `internal/audit/line.go:45` — `NewAuthz`/`NewToolCall`/`NewAuthEvent` don't structurally validate the caller-identity-per-kind table (contracts/audit-line-events.md's forbidden/required field matrix), only decision/outcome shape. Needs a closed per-kind validation table shared across all three constructors.
+- #5 (P2) `internal/audit/line.go:254` — `AuthEventInput.Flags` has no closed-vocabulary or duplicate validation.
+- #9 (P2) `internal/server/mcp.go:2345` — `Attempt.Operation` is stamped from the caller's `call_tool_*` variant, not the target tool's annotation-derived tier resolved later at the permission gate (confirmed via `targetPerm := tierForAnnotations(...)` at mcp.go:2524) — a `call_tool_read` against a write tool logs `operation:"read"` even when authorized as write. Fixing this correctly means moving the `Operation` field's source of truth to after tier resolution without breaking the "immutable before first gate" invariant for the *other* attempt fields — needs a design pass, not a one-line patch.
+- #13 (P2) `internal/jsruntime/runtime.go:953` — a batch element already accepted past `checkDispatchGates` (which already wrote its `authz allow` via the observer) can hit `dispatchBatchElement`'s `ctx.Err() != nil` early-return without ever calling `dispatchTool`/`callTool`, so its paired `tool_call` line is never written — breaks `#tool_call == #authz(allow)` under cancellation/timeout with a full worker pool. Needs a new completion-emission path for the cancelled-before-dispatch case, mirroring how a limiter shed gets a `tool_call` line without a full upstream call.
+
+**Verification after fixes:** `go build ./cmd/mcpproxy` (`-o /dev/null`) and `go build -tags server ./cmd/mcpproxy` (`-o /dev/null`) both clean; `go vet` clean on every touched package; `go test ./internal/audit/...` PASS; `go test -tags server ./internal/config/...` PASS; `go test -tags server ./internal/serveredition/...` (all 5 sub-packages) PASS; `go test -race ./internal/server/...` (skip regex `E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint`) PASS, 305s; `go test -race ./internal/jsruntime/...` PASS; `./scripts/test-api-e2e.sh` 70/70 PASS including all five T113 audit assertions; `python3 scripts/gen-roadmap.py --check` clean; `node -e "require('./website/sidebars.js')"` parses. `golangci-lint run --config .github/.golangci.yml` could not run in this environment (`the Go language version (go1.25) used to build golangci-lint is lower than the targeted Go version (1.26.0)` — a pre-existing local toolchain/binary mismatch, not caused by this round's changes); `go vet` substituted as a sanity check on every touched package.
+
+Round 1 commit: see `git log` on `107-d-audit-line` (`fix(spec-107): cross-review round 1 for PR-D`). 8 findings fixed, 3 rejected as false positives (documented above), 5 genuine findings deferred (documented above, candidates for round 2). Round counter: 1/10 used for PR-D.
+
+#### Round 2 (opencode CLI, 5 chunks; round-1 commit re-diffed, round counter 2/10)
+
+Reviewer = `opencode run` (chunks 2/3 → `github-copilot/gpt-5.6-sol`;
+chunks 1/4/5 → `github-copilot/gpt-5.6-terra --variant high`), against
+`git diff 107-c-group-allowlist...HEAD` at the round-1 fix commit
+(`c4493c8e5`). All 5 chunks returned `VERDICT: FINDINGS` (chunk 2 needed a
+retry after a local harness bug — an unset `set -u` array expansion in the
+runner script, not an opencode/model failure — then returned cleanly). 8
+findings total. Every finding was verified against the code before any fix;
+none were rejected as false positives this round (three of the eight were
+independent re-discoveries of round 1's own deferred list — #9 and #13 by
+name below, plus the number-formatting defect — corroborating both rounds).
+
+**Fixed (8, all verified genuine):**
+
+| # | File:line | Defect | Fix |
+|---|---|---|---|
+| 1 (P2) | `internal/audit/canonical.go:165` (`formatNumberJCS`) | `strconv.FormatFloat(f, 'g', -1, 64)` does not implement ES6 `Number::toString`'s fixed/exponential threshold (`-6 < n <= 21`): `0.000001` rendered as `1e-06` instead of `0.000001`, `1e20` as `1e+20` instead of the 21-digit fixed form — breaking `args_sha256` interoperability with a real JCS reference implementation. Deferred from round 1 (#2). | Rewrote `formatNumberJCS` to derive the shortest round-tripping digit string via `strconv.FormatFloat(f, 'e', -1, 64)` and apply the ECMA-262 placement rule directly, rather than reformatting `%g` output; the test package's independent `referenceFormatNumber` had the identical bug (same `%g` strategy) and was fixed identically so it stays a genuine second implementation, not a rubber stamp. Added `TestReferenceFormatNumber_KnownValues` boundary cases and a new `testdata/canonical/ecma_fixed_exponential_boundary.json` fixture. |
+| 2 (P2) | `internal/audit/line.go` / `redact.go` (`maskCredential`) | Caller-controlled strings (`client.name`, `client.version`, `token_name`, `profile`, refused `server`/`tool`) were pattern-masked but never length-capped, contradicting the "fixed-prefix patterns... plus a length cap" requirement (FR-015) — an unbounded MCP `initialize.clientInfo` value could exceed the sink's rotating-writer record limit and silently drop the required line, or force unbounded audit-log disk growth. | Added `maxFieldLength` (256 runes) + `truncateField`, applied in `maskCredential` after masking. New test `TestRedaction_ClientNameLengthCapped`. |
+| 3 (P1) | `internal/server/server.go:3770` (`Server.ReplayToolCall`) | Delegated straight to `runtime.ReplayToolCall`, which calls the managed client directly with no `audit.Attempt` installed — every `POST /tool-calls/{id}/replay` dispatch (FR-012 explicitly includes replay) produced zero `authz`/`tool_call` lines. | `Server.ReplayToolCall` now best-effort looks up the original call (`runtime.GetToolCallByID`), installs the attempt (`surface: rest`) before delegating, and writes the paired `authz allow` + `tool_call` (success/error/shed via `auditToolCallShed`) after. New tests `TestReplayToolCall_WritesAuthzAllowThenToolCallSuccess`, `TestReplayToolCall_UnresolvedIDDelegatesUnaudited` in new `internal/server/replay_audit_test.go`. |
+| 4 (P1) | `internal/server/mcp.go:2393` | A malformed `args_json` short-circuits before the profile/token-scope/target-tier/quarantine/callability gates (unchanged pre-Spec-107 response order) but round 1's fix (#8 above) recorded it as `authz allow` + `tool_call error` — an out-of-scope or quarantined target submitted with malformed `args_json` would be recorded as authorized even though authorization never ran, violating FR-012's "allow after last gate" phase rule. | Changed to `p.auditAuthz(ctx, "deny", telemetry.BlockReasonOther)` (maps to reason `"other"`, `disclosed:true`) — no gate ran, so it is a denial, never an allow. New test `TestAuditFunnel_MalformedArgsJSONIsAuthzDenyNotAllow` (asserts exactly one `authz deny` line, no `tool_call`, even against an out-of-scope target). |
+| 5 (P2) | `internal/server/mcp.go:2345` | `Attempt.Operation` was stamped from the caller-selected `call_tool_*` variant, not the target tool's annotation-derived tier — the actual authorization basis for a scoped caller (`targetPerm := tierForAnnotations(...)`, mcp.go:2536). A `call_tool_read` against a write tool recorded `operation:"read"` on both lines despite being authorized/denied against write. Deferred from round 1 (#9). | Added `auditSetOperation(ctx, op)` (audit_funnel.go) to correct the attempt's operation once annotations resolve (only when `annotationsFound`, so an unresolved/nonexistent target keeps the caller's variant rather than `tierForAnnotations`'s deny-by-default "destructive" fallback misrepresenting it as maximally risky). Called right after `identity.Annotations`/`Found` are read in `handleCallToolVariant`. New test `TestAuditFunnel_OperationReflectsTargetTierNotCallerVariant`. |
+| 6 (P1) | `internal/jsruntime/runtime.go:956` (`dispatchBatchElement`) | Short-circuited on `ctx.Err() != nil` BEFORE calling the `ToolCaller` at all — skipping the only place (the real `upstreamToolCaller.callTool` bridge) that installs the `audit.Attempt` and writes the paired `authz allow`/`tool_call` lines. A batch element already accepted by the pre-dispatch gate loop that reached a worker after the execution context expired produced zero audit lines, breaking `#authz == #pre-dispatch decisions` under load. Deferred from round 1 (#13). | Removed the short-circuit; `dispatchBatchElement` now always calls `dispatchTool`, exactly like the lone `call_tool()` path (`makeCallToolFunction`) already does with no such short-circuit — a well-behaved `ToolCaller` (the real bridge, or a managed client's transport) itself checks `ctx` and returns promptly without real upstream work. Updated `batch_test.go`'s `batchStub.CallTool` to simulate that (checks `ctx.Err()` first) and updated the `"cancelled before dispatch"` subtest's assertions (the stub is now legitimately invoked, `dispatched`/`cancelled` change from the old `0` to `2`). |
+| 7 (P2) | `internal/serveredition/users/store.go` (`UpdateUserLogin`) / `oauth_handler.go:468` | `ErrSubjectMismatch`/`ErrUserDisabled` discarded the `LoginOutcome` entirely (`return LoginOutcome{}, err`), forcing the caller to re-look the user up by email in a SEPARATE read after the transaction returned — a window a concurrent `DeleteUser` (or a transient read failure) could race, silently losing the auth_event line's required `user_id` (downgrading `subject_mismatch`/`user_disabled` to an anonymous line). | `UpdateUserLogin` now stamps `out.User = user` (the exact record read inside the same transaction) before both error returns, and returns `out` (not a fresh zero value) alongside every error. `oauth_handler.go` prefers `outcome.User` over the racy `h.lookupUser` re-read (kept only as a defensive fallback). New test `TestUpdateUserLogin_RefusalOutcomeCarriesTheRecord` (both branches). |
+| 8 (P3) | `internal/serveredition/auth/oauth_handler.go:340` (`HandleLogin`) | `redirect_uri` is sanitised before the pending state is stored; a failure between that point and `storePendingState` (discovery/provider errors) reported its terminal result with no `flags` at all — the callback's own `attempt.flag(FlagRedirectRejected)` (read from the stored pending state) never runs on this pre-redirect failure path, since no pending state was ever stored. | `HandleLogin` now calls `attempt.flag(FlagRedirectRejected)` immediately after `sanitizeLoginRedirect` when rejected, so the fact survives any later failure on the same attempt. New test in `auth_event_test.go`: `"discovery_failed carries redirect_rejected when the caller's redirect_uri was rejected"`. |
+| 9 (P2) | `internal/config/audit_log.go` (`EffectiveAuditLog`) | `if !isServerEditionBuild { return resolved, "", nil }` returned the zero-value `{Enabled:false}` for EVERY personal-edition build before even reading `cfg.AuditLog` — silently dropping an explicit `audit_log: {enabled: true, ...}`, directly contradicting FR-014's "Defaults differ by edition, the code does not" / "An explicit value always wins" (which is not a server-edition-only promise). The bug was itself documented and worked around in `scripts/test-api-e2e.sh` and pinned as expected in `audit_log_config_personal_test.go`. | Moved the `isServerEditionBuild` check to gate only the ABSENT-block default; an explicit block now resolves identically on both editions (including the stdio-transport refusal rule). Rewrote `audit_log_config_personal_test.go`: absent-block default (disabled, no warning) unchanged; added explicit-enabled-honoured, explicit-disabled-warns, and explicit-stdout-no-path-stdio-refused (exit 4) cases. Updated the stale `test-api-e2e.sh` comment that documented the old bug as expected behavior. |
+
+(Note: table numbering above is this round's own 1-9 — one finding pair,
+#3 and #4, share the same file but are independent defects on the two P1
+`mcp.go`/`server.go` audit paths; counted as 8 distinct findings per the
+chunk verdicts, listed as 9 rows because #3/#4 are separate line ranges in
+the same function group.)
+
+**Verification after fixes:** `go build ./cmd/mcpproxy` (`-o /dev/null`) and `go build -tags server ./cmd/mcpproxy` (`-o /dev/null`) both clean; `go vet` clean on every touched package (both build tags); `go test -race ./internal/audit/... ./internal/jsruntime/... ./internal/config/...` PASS; `go test -race -tags server ./internal/serveredition/...` (all 5 sub-packages) PASS; `go test -race ./internal/server/...` (skip regex `E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint`) PASS, 313s. `golangci-lint run --config .github/.golangci.yml` could not run in this environment (same pre-existing toolchain mismatch as round 1 — `go1.25` binary vs `go1.26.0` target); `go vet` substituted. Full `./scripts/test-api-e2e.sh` and `python3 scripts/gen-roadmap.py --check` were not re-run this round (no config schema, swagger, roadmap or CLI-surface changes); the comment-only `test-api-e2e.sh` edit does not change any assertion.
+
+Round 2 commit: see `git log` on `107-d-audit-line` (`fix(spec-107): cross-review round 2 for PR-D`). 8 findings fixed, 0 rejected. Round counter: 2/10 used for PR-D.
+
+#### Round 3 (opencode CLI, 5 chunks; round-2 commit re-diffed, round counter 3/10)
+
+A prior round-2 RE-RUN (after the round-2 commit above) stalled with no
+verdict on every chunk and does not count against the cap. This round
+refreshed the preamble's HEAD hash and file lists and added "Review inline;
+do not spawn sub-agents." (the worktree's `opencode.json` now has
+`task: deny`) before dispatching.
+
+Reviewer = `opencode run` (chunks 2/3 → `github-copilot/gpt-5.6-sol`;
+chunks 1/4/5 → `github-copilot/gpt-5.6-terra --variant high`), against
+`git diff 107-c-group-allowlist...HEAD` at the round-2 fix commit
+(`c25c98c96`). All 5 chunks completed on the first attempt (no watchdog
+fallback to codex needed): chunk 1 `VERDICT: FINDINGS` (1), chunk 2
+`VERDICT: FINDINGS` (3), chunk 3 `VERDICT: CLEAN` (0), chunk 4
+`VERDICT: FINDINGS` (1), chunk 5 `VERDICT: FINDINGS` (2) — 7 findings total.
+Every finding was verified against the code before any fix; verdicts below.
+
+**Fixed (5, all verified genuine — each confirmed by a regression test that
+fails on the pre-fix code and passes after):**
+
+| # | File:line | Defect | Fix |
+|---|---|---|---|
+| c1 (P1) | `internal/audit/redact.go:44` | The generic `sk-` credential pattern (`sk-[A-Za-z0-9]{16,}`) required 16+ alphanumeric characters immediately after the prefix — a current-format OpenAI key (`sk-proj-...`, `sk-svcacct-...`, `sk-admin-...`), which inserts a hyphen-delimited segment before the random suffix, broke the match at the first hyphen and reached a caller/operator-controlled field (`client.name`, `token_name`, `profile`, refused `server`/`tool`) unmasked. | Widened the character class to `sk-[A-Za-z0-9_-]{16,}` (matches `internal/security/patterns/tokens.go`'s existing `sk-ant-` pattern style). New test `TestRedaction_OpenAIProjectKeySentinelMasked` (`internal/audit/line_test.go`). |
+| c2-b (P2) | `internal/server/server.go:3782` (`Server.ReplayToolCall`) | Round-2's replay-audit fix (#3 above) wrote the paired `authz allow` only via `auditToolCall`'s completion-time backfill, AFTER `runtime.ReplayToolCall` returned — unlike every other dispatch path (`emitActivityToolCallStarted` writes `allow` synchronously before its own upstream call, FR-012's binding "before the upstream call" phase), so a crash mid-replay-dispatch left no authorization record at all. The same installAuditAttempt call also never set `Operation`, so every replayed line read `operation:"unknown"` even for a known destructive/write tool. | Added an explicit `s.mcpProxy.auditAuthz(ctx, "allow", "")` immediately after `installAuditAttempt`, before `s.runtime.ReplayToolCall` dispatches. Populated `Operation` from the persisted record's own `Annotations` snapshot via a new `toConfigToolAnnotations` adapter + the existing `tierForAnnotations`, left empty (defaults to `"unknown"`) when the record carries no annotations at all — mirrors `mcp.go`'s own choice not to use `tierForAnnotations`' `found=false` "destructive" AUTHORIZATION default for the AUDIT line. New tests `TestReplayToolCall_AuthzWrittenBeforeUpstreamDispatch` (blocks the upstream handler and inspects the sink while dispatch is still in flight), `TestReplayToolCall_OperationFromAnnotations`, `TestReplayToolCall_OperationUnknownWithoutAnnotations` (`internal/server/replay_audit_test.go`). |
+| c2-c (P3) | `internal/server/audit_funnel.go:505` (`nestedAuthzObserver.ObserveAuthzGate`) | A nested `SERVER_NOT_ALLOWED` refusal was classified `profile_scope` whenever ANY profile was active, without checking whether the SCRIPT's own `options.allowed_servers` (independently intersected into the sandbox's single merged allow-list by `applyProfileScopeToExecution`) was what actually excluded the server — a profile that permitted the target but a narrower script-authored allow-list that didn't would still misreport `profile_scope`. Per the published contract (`audit-line-events.md`), nested `checkDispatchGates` maps only to `token_scope`\|`token_permission`, never `profile_scope`. | Removed the `profile_scope` branch entirely; `ErrorCodeServerNotAllowed` now always reports `token_scope`, matching the contract and eliminating the misattribution (the merged allow-list gives no way to attribute cleanly, so the safe/correct answer per the doc is to never claim `profile_scope` here). New test `TestAuditFunnel_NestedScriptAllowlistExclusionIsTokenScopeNotProfileScope` (`internal/server/audit_funnel_test.go`; profile permits both servers, script's own `allowed_servers` excludes the target, asserts `token_scope`). |
+| c4 (P2) | `internal/serveredition/auth/auth_event.go:62` | Every failed `auth_event` sink write logged an unconditional `logger.Warnw` — bypassing the sink's own once-per-minute `WithFailureLogger` rate limit (FR-018: "logs at most once per minute"). Under a persistent disk/stdout failure, every login/logout produced its own warning. | Removed the per-call `Warnw`; the sink's always-on `WriteFailures()` counter (surfaced in `mcpproxy doctor`) still records every failure regardless, and the sink's own rate-limited `WithFailureLogger` (T109) is the one place operator-visible logging happens. New test `TestAuthEvent_WriteFailureNotLoggedPerCall` (`internal/serveredition/auth/auth_event_test.go`; a failing sink hit 5 times in a row must produce zero log entries from the emitter itself). |
+| c5-a (P2) | `internal/runtime/restart_gated.go` (`pinRestartGated`) | `audit_log` is restart-pinned (the sink is bound once at construction, `config_hotreload.go:470-482`'s detector clause) but `pinRestartGated` never reverted it to `live.AuditLog` — a mixed apply that also touched a hot field (e.g. `tools_limit`) would adopt the NEW `audit_log` into the live config while the API still reported the apply as pending a restart, leaving `Runtime.Config()` readers disagreeing with the sink actually still writing. | Added `pinned.AuditLog = live.AuditLog`, in lockstep with the detector clause per the file's own documented contract. Added an `"audit_log"` case to the existing detector-driven `TestPinRestartGatedCoversEveryRestartGatedField` table (`internal/runtime/restart_gated_test.go`), which failed before the fix and passes after. |
+| c5-b (P2) | `scripts/test-api-e2e.sh:1170` + `.github/workflows/release-qa-gate.yml` | `mktemp -d -t mcpproxy_e2e_audit` is BSD/macOS syntax; GNU `mktemp` (the mandatory Ubuntu `suite-api-e2e` CI job) rejects it ("too few X's in template") — confirmed via `docker run --rm ubuntu:24.04 bash -lc 'mktemp -d -t mcpproxy_e2e_audit'`. With no `set -e`, `AUDIT_JSONL_DIR` silently became empty and `AUDIT_JSONL` resolved to a root-level path. Separately, the same CI job's build step never built `./mcpproxy-server` and its "Stage binaries" step never staged it, so the mandatory "Audit log: server-edition binary present" sub-test (added by this PR, hard-FAILS rather than skips when the binary is missing) deterministically failed on every gate run. | Rewrote the `mktemp` call to the portable explicit-template form `mktemp -d "${TMPDIR:-/tmp}/mcpproxy_e2e_audit.XXXXXX"`, verified against both a local macOS shell and `docker run ubuntu:24.04`. Added `go build -tags server,nogui ... -o dist-bin/mcpproxy-server ./cmd/mcpproxy` to the gate's build step and `cp dist-bin/mcpproxy-server ./mcpproxy-server` to its "Stage binaries" step; verified the tagged build compiles and self-reports `(server)` edition, and `actionlint` passes on the edited workflow. |
+
+**Rejected as false positive (1, with evidence):**
+
+- c2-a (P2, `internal/server/mcp.go:2345/2466`) — claimed "a known destructive tool invoked through `call_tool_read` with an invalid intent" is audited as `operation:"read"` because the `operation` correction (round-2's own fix, `auditSetOperation` after `evaluateExactToolGate`) runs too late for the EARLY intent-validation gate at `mcp.go:2371-2383`. Traced both intent gates: the early one (`validateIntentForVariant` → `intent.ValidateForToolVariant`) validates only the `IntentDeclaration`'s own optional-field shape against the tool VARIANT (`ToolVariantToOperationType[toolVariant]`) — it has no access to the target's actual annotations at all (that lookup, `gate := p.evaluateExactToolGate(...)`, hasn't run yet), so it can never fail because of a mismatch against the tool's real tier; a "known destructive tool" scenario is structurally unreachable there. The SECOND intent gate that DOES check against real server annotations (`validateIntentAgainstServer`, `mcp.go:2572`) runs AFTER the tier correction (`mcp.go:2465`), so the scenario the finding describes is already handled correctly. The finding conflated the two distinct intent-validation gates.
+
+**Verification after fixes:** `go build ./cmd/mcpproxy` (`-o /dev/null`) and `go build -tags server ./cmd/mcpproxy` (`-o /dev/null`) both clean; `gofmt -l` clean on every touched Go file; `go vet` clean on every touched package (both build tags); `go test -race ./internal/audit/...` PASS; `go test -race ./internal/server/... ./internal/runtime/...` (skip regex `E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint`) PASS; `go test -race -tags server ./internal/serveredition/...` (all 5 sub-packages) PASS; `actionlint .github/workflows/release-qa-gate.yml` clean; `go build -tags server,nogui ./cmd/mcpproxy` verified to compile and self-report the server edition. Every one of the 5 fixes was verified genuine by reverting just that file (`git stash push -u -- `), re-running its new regression test to confirm a pre-fix FAIL, then restoring (`git stash pop`) and re-confirming PASS — not inferred from reading the diff alone. `golangci-lint run --config .github/.golangci.yml` could not run in this environment (same pre-existing toolchain mismatch as rounds 1-2 — `go1.25` binary vs `go1.26.0` target); `go vet` + `gofmt` substituted. Full `./scripts/test-api-e2e.sh` was not re-run end-to-end in this environment (no Docker-isolated upstream fixtures available here); the `mktemp` portability fix was verified directly against both a local macOS shell and `docker run ubuntu:24.04`, and the CI-build fix was verified by compiling the exact `go build -tags server,nogui` command the workflow now runs.
+
+Round 3 commit: see `git log` on `107-d-audit-line` (`fix(spec-107): cross-review round 3 for PR-D`). 5 findings fixed, 1 rejected as false positive (documented above). Round counter: 3/10 used for PR-D.
+
+#### Round 4 (opencode CLI, 5 chunks; round-3 commit re-diffed, round counter 4/10)
+
+Reviewer = `opencode run` (chunks 2/3 → `github-copilot/gpt-5.6-sol`;
+chunks 1/4/5 → `github-copilot/gpt-5.6-terra --variant high`), against
+`git diff 107-c-group-allowlist...HEAD` at the round-3 fix commit
+(`4d192cc89`). All 5 chunks completed on the first attempt (no watchdog
+fallback to codex needed): chunk 1 `VERDICT: CLEAN` (0), chunk 2
+`VERDICT: FINDINGS` (3), chunk 3 `VERDICT: FINDINGS` (1), chunk 4
+`VERDICT: CLEAN` (0), chunk 5 `VERDICT: FINDINGS` (1) — 5 findings total.
+Every finding was verified against the code before any fix; verdicts below.
+
+**Fixed (2, both verified genuine — each confirmed by a regression test that
+fails on the pre-fix code and passes after):**
+
+| # | File:line | Defect | Fix |
+|---|---|---|---|
+| c2-a (P2) | `internal/server/server.go:3838` (`Server.ReplayToolCall`) | `runtime.ReplayToolCall` folds a non-shed upstream tool failure into the returned record's own `Error` field (or, for an `IsError:true` MCP-protocol-level tool response, into neither field at all) and returns a NIL Go error — only a limiter shed returns non-nil. The outcome `switch` branched only on `err`, so every failed replay of either shape fell into `default` and was audited as `outcome:"success"`. | Added two more `switch` cases before `default`: `result.Error != ""` (the callErr-was-non-nil-inside-the-callee shape) and a new `isReplayResponseError(result.Response)` helper that type-asserts `Response` to `*mcp.CallToolResult` and checks `IsError` (the protocol-level tool-failure shape, which the MCP spec returns as a normal `err==nil` RPC response). New tests `TestReplayToolCall_FailedUpstreamCallAuditsAsError` and `TestReplayToolCall_ToolLevelIsErrorResponseAuditsAsError` (`internal/server/replay_audit_test.go`), covering both failure shapes via a new `startRuntimeFailingUpstream` fixture. |
+| c5-a (P3) | `docs/operations/deploying-for-a-team.md:393` | The deployment-verification `grep` filtered on `"surface":"authz"` — but `authz` is a value of the `event` key, not `surface` (whose values are `call_tool_read`, `direct`, etc per the schema); the documented command would never match any real audit line, silently showing nothing where the surrounding prose says "audit lines on stdout". | Changed the filter to `"event":"authz"`. |
+
+**Rejected as false positives / accepted trade-offs (3, with evidence):**
+
+- c2-b (P2, `internal/server/audit_funnel.go:535`, `nestedAuthzObserver.ObserveAuthzGate`) — claimed the nested observer "ignores `report.Ctx`, mints a new request ID after the decision, and reconstructs fields from observer-local state" in violation of FR-012's "install the attempt before the first gate" rule. Traced `report.Ctx` (`= ec.executionCtx()` = `execCtx.ctx`, assigned once in `jsruntime.execute` as `timeoutCtx := context.WithTimeout(ctx, ...)` where `ctx` is the SAME context the host passed into `Execute` — the identical value stored as `nestedAuthzObserver.parentCtx`). `context.WithTimeout` only narrows the deadline/cancellation signal; `Value()` lookups fall through to the parent unchanged, so every value the observer reads off `ctx` (auth context, connection source, etc.) is byte-identical whether it reads `report.Ctx` or `o.parentCtx` — using `parentCtx` produces no observable difference in the written line. On the request-ID point: `AuthzGateReport` carries no pre-existing `RequestID` field at all (by design — `jsruntime` cannot construct an `audit.Attempt`, a different package, ahead of time), and the ALLOWED sibling path for the same nested dispatch (`upstreamToolCaller.callTool`, `mcp_code_execution.go:772`) mints its correlation ID the identical way, inline, at the point the subcall begins — so minting here matches the established pattern for this dispatch path, not a deviation from it. No behavioral defect found.
+- c2-c (P3, `internal/server/mcp.go:2345/2466`) — same underlying gap as round 3's rejected c2-a (the caller-variant-vs-corrected-tier window before `evaluateExactToolGate` resolves), this time correctly scoped to the `profile_scope` gate (`mcp.go:2416-2420`), which — unlike the intent gates round 3 examined — runs independently of tool identity and CAN deny a `call_tool_read` against a known write/destructive tool before the tier correction at `mcp.go:2465`. Confirmed genuine (not a false positive), but rejected as a fix this round: closing it requires either (a) moving `evaluateExactToolGate`'s persisted read earlier, ahead of the `dispatchGatePause` test-instrumentation hook that several concurrency tests key their pause timing off of (risk of collateral test breakage from a hook-ordering change, not from the read itself), or (b) taking a second, independent read purely for tier-stamping — which reintroduces the exact two-snapshot inconsistency class `codex r6 G1`/`r7 H1` (referenced in the surrounding comments) fixed in an earlier PR. Both remedies carry more regression risk than the narrow gap they close (operation field only, on a profile-scope denial, against a caller who chose a narrower variant than the target's real tier) is worth trading for at P3. Left as a known, documented limitation — not silently dropped: recorded here for a future round or a dedicated design pass.
+- c3-a (P2, `internal/server/mcp_code_execution.go:789`) — claimed the nested sandbox subcall's audit `operation` field should read the fail-closed `"destructive"` tier `lookupToolGate` uses for AUTHORIZATION when `gate.identity.Found` is false (quarantined/disabled/undiscovered), instead of `"unknown"`. This is the SAME choice `handleCallToolVariant` makes explicitly and deliberately for the direct-dispatch path (`mcp.go:2465`, comment: "`tierForAnnotations`' `!found` fallback is `destructive` for AUTHORIZATION purposes (deny by default), which would misrepresent an unresolved/nonexistent target as maximally risky on the audit line rather than simply unknown to the proxy" — verified as the actual round-2 fix rationale, `verification.md` round 2 row 5). `mcp_code_execution.go:789`'s `if gated && gate.identity.Found` mirrors that same rule for the nested path. Recording `"destructive"` here (as the finding suggests) would make the two dispatch paths INCONSISTENT with each other, not more correct — the finding has the codebase's own established audit-vs-authorization distinction backwards.
+
+**Verification after fixes:** `go build ./cmd/mcpproxy` (`-o /dev/null`) and `go build -tags server ./cmd/mcpproxy` (`-o /dev/null`) both clean; `gofmt -l` clean on every touched Go file; `go vet` clean on every touched package; `go test -race ./internal/server/...` (skip regex `E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint`) PASS, 308s; `go test -race ./internal/audit/... ./internal/jsruntime/... ./internal/config/...` PASS. Both new regression tests were verified genuine by reverting only `internal/server/server.go` (`git stash push -u -- internal/server/server.go`), re-running them to confirm a pre-fix FAIL (confirmed: both failed with `outcome:"success"` instead of `"error"`), then restoring and re-confirming PASS. `golangci-lint run --config .github/.golangci.yml` could not run in this environment (same pre-existing toolchain mismatch as rounds 1-3 — `go1.25` binary vs `go1.26.0` target); `go vet` + `gofmt` substituted. Full `./scripts/test-api-e2e.sh`, `make swagger-verify`, frozen-goldens and `python3 scripts/gen-roadmap.py --check` were not re-run this round (no config schema, swagger, roadmap, CLI-surface or frontend changes — only a replay-outcome bugfix, its tests, and a one-line doc grep-filter fix).
+
+Round 4 commit: see `git log` on `107-d-audit-line` (`fix(spec-107): cross-review round 4 for PR-D`). 2 findings fixed, 3 rejected/deferred with evidence (documented above — c2-c is a confirmed-genuine but deliberately deferred P3, not a false positive). Round counter: 4/10 used for PR-D.
diff --git a/website/sidebars.js b/website/sidebars.js
index 7ff8dfba2..ab6838328 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -107,6 +107,7 @@ const sidebars = {
'features/tool-scanner',
'features/security-scanner-plugins',
'features/sensitive-data-detection',
+ 'features/audit-log',
'features/output-sanitisation',
'features/output-schema-validation',
'features/agent-tokens',
@@ -150,6 +151,7 @@ const sidebars = {
link: { type: 'generated-index', slug: '/operations' },
items: [
'operations/reverse-proxy',
+ 'operations/deploying-for-a-team',
'operations/shutdown-behavior',
'features/observability',
'logging',