Skip to content

Commit 1ac1d75

Browse files
authored
release(v0.40.0): streamable HTTP, fan-out, multi-tenant policy (#154)
One Vaara process now serves a fleet of upstream MCP servers over Streamable HTTP, with multi-tenant policy, audit chain, and OVERT attestation on the same substrate. v0.39 ran one Vaara process per upstream; v0.40 collapses that into a single multi-tenant deployment. Streamable HTTP transport on the proxy. `vaara-mcp-proxy --transport http --http-host H --http-port P` runs POST /mcp via FastAPI / uvicorn. The endpoint reads `X-Vaara-Tenant` and `X-Vaara-Upstream` per request, pushes them into ContextVars, and dispatches into the existing `_handle_request` path so policy, perimeter, OVERT, and progress-notification handling all light up unchanged. Notifications return 202. Bodies above 1 MiB return 413. Unknown upstream returns 404. Fan-out via repeatable `--upstream NAME=CMD`. One Vaara process holds N UpstreamMCPClient instances in a name -> client map. Bare `--upstream CMD` keeps the v0.39 single-upstream contract (lands in the "default" slot). When more than one upstream is configured, a request with no `X-Vaara-Upstream` header returns 400 with the list of valid slots in the error envelope. Single-upstream deployments keep the silent-default contract. tenant_id end-to-end. ScoreRequest, AuditEventRequest, PolicyReloadRequest accept a `tenant_id` body field, with `X-Vaara-Tenant` as the HTTP-header alternative (body wins over header). AuditRecord gains a `tenant_id` field, excluded from `compute_hash()` so pre-v0.40 chains still re-verify. AuditTrail keeps an `action_id -> tenant_id` map seeded by `record_action_requested`, soft-capped at 50k entries. SQLiteAuditBackend.write_record prefers per-record tenant. OVERT envelopes carry `tenant_id` as a `non_content_metadata` claim. Per-tenant policy plane. `vaara.policy.registry.PolicyRegistry` holds one PolicyController per tenant with the empty string slot reserved as the default fallback. `vaara serve --policy-dir DIR` loads one YAML/JSON policy per file (filename stem = tenant_id). `POST /v1/policy/reload` routes per tenant via body field or header. Installs `vaara-mcp-proxy` as a top-level console script so the proxy CLI matches what every v0.39+ docs surface advertises. Earlier releases only shipped the proxy as `python -m vaara.integrations.mcp_proxy`; v0.40 closes that gap. v0.41 will fold the proxy into the main `vaara` verb tree (`vaara mcp-proxy ...`) and keep `vaara-mcp-proxy` as a thin alias for one release cycle. Per-tenant threshold dispatch at evaluate-time. `AdaptiveScorer.evaluate` consults the registry on every call. A new `policy_lookup` constructor arg (and `set_policy_lookup` for late binding from ServerState) lets the scorer ask which tenant policy applies right now and use its allow/deny thresholds for THIS evaluation. Unknown tenant or no lookup configured falls back to the scorer-bound defaults that the default-slot listener keeps fresh on reload. The backend decision dict surfaces the applied threshold_allow and threshold_deny so operators can confirm which tenant's policy ran. MWU expert state, the conformal calibrator, agent profiles, and sequence patterns stay shared across tenants; only threshold application is per-tenant in v0.40. Scope notes. HTTP transport is POST-only (GET-SSE is v0.41). Per-tenant policy reload is hot; classifier hot-reload still restart-only. Cancellation routing across fan-out is v0.41 hardening. Fan-out latency bench is v0.40.1 measurement. 862 passed, 12 skipped. 45 new tests across tests/test_v040_tenant.py, tests/test_v040_policy_registry.py, tests/test_v040_mcp_http_transport.py, tests/test_v040_per_tenant_threshold.py. References modelcontextprotocol/modelcontextprotocol#2787 for the SEP-2787 envelope shape v0.40 builds on top of. Co-authored-by: vaaraio <[email protected]>
1 parent 0f30fb2 commit 1ac1d75

24 files changed

Lines changed: 1734 additions & 62 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ jobs:
3030
- name: Install Vaara (editable, no deps)
3131
run: pip install -e . --no-deps
3232

33+
- name: Install server extra (fastapi, uvicorn, httpx) for HTTP transport tests
34+
run: pip install 'fastapi>=0.110' 'uvicorn>=0.27' 'httpx>=0.27'
35+
3336
- name: Lint (ruff)
3437
run: ruff check .
3538

CHANGELOG.md

Lines changed: 96 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,97 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
66

77
## [Unreleased]
88

9+
## [0.40.0] - 2026-05-28
10+
11+
**Theme: deployment shape. One Vaara process now serves a fleet of
12+
upstream MCP servers, with multi-tenant policy, audit, and attestation
13+
on the same substrate.**
14+
15+
The v0.39 sidecar shape ran one Vaara process per upstream. v0.40
16+
turns that into a single process that speaks Streamable HTTP, holds
17+
N upstream MCP-server connections, picks the upstream per request
18+
from a header, scopes every score, audit record, and OVERT envelope
19+
to a tenant, and reloads per-tenant policy in place.
20+
21+
### Added
22+
- `vaara-mcp-proxy --transport http --http-host H --http-port P`:
23+
Streamable HTTP transport at `POST /mcp`, backed by FastAPI /
24+
uvicorn (the `vaara[server]` extra already shipped in v0.39 for
25+
`vaara serve`). The endpoint reads `X-Vaara-Tenant` and
26+
`X-Vaara-Upstream` per request, pushes them into ContextVars, and
27+
dispatches into the existing `_handle_request` path so the policy,
28+
perimeter, OVERT, and progress-notification handling all light up
29+
unchanged. Notifications (no JSON-RPC `id`) return 202 Accepted.
30+
Bodies above 1 MiB return 413.
31+
- `vaara-mcp-proxy --upstream NAME=CMD` (repeatable) for fan-out.
32+
One Vaara process holds N `UpstreamMCPClient` instances in a name
33+
-> client map. Bare `--upstream CMD` keeps the v0.39 single-
34+
upstream contract; it lands in the "default" slot. Commands that
35+
themselves contain `=` (e.g. `python -m foo --bar=baz`) stay
36+
intact because the name-side regex only matches short alphanumeric
37+
slugs. When more than one upstream is configured, a request with
38+
no `X-Vaara-Upstream` header returns 400 with the list of valid
39+
slots; silent fallback to whichever slot won the sort would be a
40+
failure mode that surfaces only in production. Single-upstream
41+
deployments keep the silent-default contract.
42+
- `tenant_id` is first-class through the request, decision, audit,
43+
and attestation layers:
44+
- `ScoreRequest`, `AuditEventRequest`, and `PolicyReloadRequest`
45+
accept a `tenant_id` body field, with `X-Vaara-Tenant` as the
46+
HTTP-header alternative. Body wins over header.
47+
- `AuditRecord` gains a `tenant_id` field, excluded from
48+
`compute_hash()` so pre-v0.40 chains still re-verify on load.
49+
- `AuditTrail` keeps an `action_id -> tenant_id` map seeded by
50+
`record_action_requested`, so every follow-up record
51+
(`risk_scored`, `decision`, `execution`, `escalation`,
52+
`outcome`, `policy_override`) inherits the same scope without
53+
every caller threading `tenant_id` through every signature.
54+
The map is soft-capped (50k entries, 12.5% eviction on
55+
pressure) so long-running deployments cannot leak memory.
56+
- `SQLiteAuditBackend.write_record` prefers the per-record
57+
`tenant_id` when set, with the instance-scoped `tenant_id`
58+
(legacy CLI tooling path) as fallback. A single backend
59+
instance can now serve a multi-tenant runtime.
60+
- OVERT envelopes carry `tenant_id` as a `non_content_metadata`
61+
claim when present.
62+
- `vaara.policy.registry.PolicyRegistry`: one `PolicyController` per
63+
tenant, with the empty string slot reserved as the default
64+
fallback for unmatched lookups.
65+
- `vaara serve --policy-dir DIR`: loads one YAML/JSON policy per
66+
file. Filename stem = `tenant_id`; `default.yaml` lands in the
67+
fallback slot. Mutually exclusive with `--policy`.
68+
- `POST /v1/policy/reload` accepts a `tenant_id` body field (or
69+
`X-Vaara-Tenant` header) and routes to the right registry slot;
70+
creates the slot on first reload.
71+
72+
### Changed
73+
- `Pipeline.intercept` takes a `tenant_id` keyword that flows onto
74+
the `ActionRequest` and into the audit trail. Default `""` keeps
75+
the v0.39 single-tenant contract.
76+
- `AdaptiveScorer.evaluate` dispatches allow / deny thresholds per
77+
tenant at call time. A new `policy_lookup` constructor arg (and
78+
`set_policy_lookup` setter for late binding from `ServerState`)
79+
takes a `Callable[[str], Optional[Policy]]`; on every evaluate, the
80+
scorer asks the registry for the calling tenant's policy and uses
81+
its thresholds. An unknown or unmapped tenant falls back to the
82+
scorer-bound defaults that the default-slot listener keeps fresh on
83+
reload. The backend decision dict surfaces the applied
84+
`threshold_allow` and `threshold_deny` so operators can confirm
85+
which tenant's policy ran. MWU expert state, the conformal
86+
calibrator, agent profiles, and sequence patterns stay shared
87+
across tenants; only threshold application is per-tenant in v0.40.
88+
89+
### Scope notes
90+
- The HTTP transport on the proxy is POST-only. GET-SSE for
91+
server-initiated notifications (sampling, server-pushed progress)
92+
is v0.41. The audit + OVERT emission path for upstream-originated
93+
notifications still works unchanged on stdio.
94+
- Classifier bundle and conformal-calibrator hot-reload remain a
95+
restart operation in v0.40. Per-tenant policy reload IS hot; that
96+
is the configuration plane that needed to be live across tenants.
97+
Classifier reload waits on a shared singleton lifecycle plus
98+
per-tenant scoping question (v0.41 candidate).
99+
9100
## [0.39.2] - 2026-05-27
10101

11102
**Theme: SEP-2787 envelope v2 shape, full wire round-trip, versioned
@@ -1713,7 +1804,8 @@ and backward-compatible. Together they reposition Vaara from a Python
17131804
library to a runtime kernel that control planes, audit consumers, and
17141805
orchestration frameworks reference. The HTTP contract at
17151806
`docs/openapi.yaml` is versioned `/v1/` independently of the project
1716-
version, following the OPA pattern.
1807+
version, so the wire surface can stabilise without locking the
1808+
library cadence.
17171809

17181810
### Added
17191811
- **HTTP API reference server (`vaara[server]` extra).** Exposes the
@@ -1789,10 +1881,9 @@ it governs.
17891881
action class declared, matched sequences known).
17901882
- **`vaara.policy.test_cases_io` module.** `load_test_cases(path)`
17911883
reads a YAML or JSON cases document and returns a list of
1792-
`PolicyTestCase`. Document shape mirrors typical OPA / Conftest
1793-
test files: a top-level `cases:` list with `action_class`,
1794-
`risk_score`, optional `matched_sequences`, and an `expect:` block
1795-
carrying `verdict` and optional `route`.
1884+
`PolicyTestCase`. Document shape: a top-level `cases:` list with
1885+
`action_class`, `risk_score`, optional `matched_sequences`, and an
1886+
`expect:` block carrying `verdict` and optional `route`.
17961887
- **`vaara policy validate POLICY_PATH [--json]`** and **`vaara
17971888
policy test POLICY_PATH --cases CASES_PATH [--json]`** CLI
17981889
subcommands. Both honour standard CI exit codes: validate returns

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,32 @@ if (r.decision === "deny") throw new Error("blocked");
168168
`vaara.integrations.mcp_proxy.VaaraMCPProxy` sits between an MCP client (Claude Code, Cursor, any MCP-capable host) and an upstream MCP server. Every `tools/call` from the client routes through Vaara's interception pipeline before reaching the upstream. Allowed calls forward transparently and report the upstream outcome back to the scorer. Blocked calls return an MCP `isError: true` response with the block reason. The initialization handshake and `notifications/*` forward unchanged. `tools/list`, `resources/list`, `resources/read`, `prompts/list`, and `prompts/get` route through the operator perimeter before reaching the client or upstream.
169169

170170
```bash
171-
python -m vaara.integrations.mcp_proxy \
171+
vaara-mcp-proxy \
172172
--upstream npx --upstream-arg -y --upstream-arg @sap/mdk-mcp-server \
173173
--db ./mcp_audit.db
174174
```
175175

176176
Point your MCP client at the proxy instead of the upstream. The audit chain captures every tool call without changing client or upstream behavior. Distinct from `mcp_server`, which exposes Vaara itself as an MCP server for agents that consult Vaara as a tool.
177177

178+
<details>
179+
<summary>Fleet shape (v0.40): one proxy, many upstreams, multi-tenant policy</summary>
180+
181+
`vaara-mcp-proxy` also runs over Streamable HTTP with fan-out, so one process can serve a fleet of upstream MCP servers:
182+
183+
```bash
184+
vaara-mcp-proxy \
185+
--transport http \
186+
--http-host 127.0.0.1 \
187+
--http-port 8765 \
188+
--upstream 'github=npx -y @github/mcp-server' \
189+
--upstream 'sap=npx -y @sap/mdk-mcp-server'
190+
```
191+
192+
Each `POST /mcp` reads two headers. `X-Vaara-Upstream` picks the upstream slot. `X-Vaara-Tenant` scopes the policy, audit chain, and OVERT envelope for that call. Single-upstream deployments keep the v0.39 silent-default contract. Multi-upstream deployments require `X-Vaara-Upstream` per call and return 400 with the available slot list when the header is missing.
193+
194+
The reference HTTP API server (`vaara serve --policy-dir DIR`) loads one YAML or JSON policy per file in the directory (filename stem becomes the `tenant_id`, `default.yaml` lands in the fallback slot) and hot-reloads per tenant via `POST /v1/policy/reload` with a `tenant_id` body field or `X-Vaara-Tenant` header. The scorer dispatches allow and deny thresholds per call against the calling tenant's policy at `evaluate()` time.
195+
</details>
196+
178197
<details>
179198
<summary>Operator perimeter: tool, resource, prompt filtering</summary>
180199

@@ -194,7 +213,7 @@ vaara keygen --dev --out signing.pem
194213
head -c 32 /dev/urandom > op.key
195214

196215
# 3. Run the proxy with OVERT emission turned on.
197-
python -m vaara.integrations.mcp_proxy \
216+
vaara-mcp-proxy \
198217
--upstream npx --upstream-arg -y --upstream-arg @sap/mdk-mcp-server \
199218
--overt-signing-key signing.pem \
200219
--overt-operator-key op.key \

clients/ts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@vaara/client",
3-
"version": "0.39.2",
3+
"version": "0.40.0",
44
"description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "vaara"
7-
version = "0.39.2"
7+
version = "0.40.0"
88
description = "Adaptive AI Agent Execution Layer for risk scoring, audit trails, and regulatory compliance"
99
requires-python = ">=3.10"
1010
license = "Apache-2.0"
@@ -58,6 +58,7 @@ rebuff = ["rebuff>=0.1"]
5858
[project.scripts]
5959
vaara = "vaara.cli:main"
6060
vaara-audit = "vaara.audit_cli:main"
61+
vaara-mcp-proxy = "vaara.integrations.mcp_proxy:main"
6162

6263
[tool.setuptools.packages.find]
6364
where = ["src"]

src/vaara/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
oversight.
77
"""
88

9-
__version__ = "0.39.2"
9+
__version__ = "0.40.0"
1010

1111
from vaara.pipeline import InterceptionPipeline, InterceptionResult
1212

src/vaara/audit/sqlite_backend.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,11 @@ def write_record(self, record: AuditRecord) -> None:
357357
_strict_json_dumps(record.regulatory_articles),
358358
record.previous_hash,
359359
record.record_hash,
360-
self._tenant_id,
360+
# Per-record tenant_id wins so a single backend instance
361+
# can serve a multi-tenant runtime (v0.40+). Empty record
362+
# tenant_id falls back to instance scope for the legacy
363+
# single-tenant init path.
364+
record.tenant_id or self._tenant_id,
361365
record.system_operation,
362366
record.data_usage,
363367
record.decision_making,
@@ -674,6 +678,7 @@ def _row_to_record(self, row: tuple) -> AuditRecord:
674678
agent_id = self._redaction_cache[agent_id]
675679
# Defensive indexing: rows from older queries may not include
676680
# the v3 columns. Use a guard so loading old DBs still works.
681+
tenant_id = row[11] if len(row) > 11 else ""
677682
sys_op = row[12] if len(row) > 12 else None
678683
data_use = row[13] if len(row) > 13 else None
679684
dec_mk = row[14] if len(row) > 14 else None
@@ -689,6 +694,7 @@ def _row_to_record(self, row: tuple) -> AuditRecord:
689694
regulatory_articles=json.loads(row[7]),
690695
previous_hash=row[8],
691696
record_hash=row[9],
697+
tenant_id=tenant_id or "",
692698
system_operation=sys_op,
693699
data_usage=data_use,
694700
decision_making=dec_mk,

src/vaara/audit/trail.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ class AuditRecord:
258258
data_usage: Optional[str] = None
259259
decision_making: Optional[str] = None
260260
limitations: Optional[str] = None
261+
# v0.40: multi-tenant scoping. Empty string = single-tenant deployment.
262+
# Excluded from compute_hash() to preserve pre-v0.40 chain re-verification.
263+
tenant_id: str = ""
261264

262265
def __post_init__(self) -> None:
263266
# Loaded-from-DB records carry a non-empty record_hash. Skip
@@ -492,6 +495,12 @@ def __init__(
492495
self._by_action: dict[str, list[AuditRecord]] = defaultdict(list)
493496
self._last_hash = ""
494497
self._on_record = on_record
498+
# v0.40 multi-tenant: action_id -> tenant_id, seeded by
499+
# record_action_requested. Subsequent record_* calls (decision,
500+
# execution, escalation) look up the action_id so every record in
501+
# the lifecycle carries the same tenant scope without forcing
502+
# every caller to thread tenant_id through every method signature.
503+
self._tenant_for_action: dict[str, str] = {}
495504
# Counts on_record callback failures so callers can detect
496505
# persistence divergence at runtime (e.g., DB gone, disk full).
497506
# Without this, a silent logger.error is the only signal and the
@@ -554,9 +563,33 @@ def verify_chain(self) -> Optional[str]:
554563

555564
# ── Recording events ──────────────────────────────────────────
556565

566+
# Defense-in-depth cap for direct-trail callers that bypass the pipeline's
567+
# length cap on tenant_id. The HTTP boundary already caps at 256 via the
568+
# Pydantic schema, but the AuditTrail public API is reachable from
569+
# embedders that construct ActionRequest directly. A 50MB tenant_id would
570+
# otherwise balloon every record on the hash chain and the in-memory
571+
# action -> tenant map.
572+
_MAX_TENANT_ID_LEN = 256
573+
# Soft cap on the action -> tenant map. Long-running multi-tenant
574+
# deployments would otherwise leak memory at one entry per action,
575+
# because OUTCOME_RECORDED arrives well after ACTION_REQUESTED and the
576+
# map cannot be cleared at decision time. When the cap is reached the
577+
# oldest 1/8 of the map is evicted; subsequent lookups for evicted
578+
# actions fall back to "" tenant, which is the legacy single-tenant
579+
# contract — correct fail-soft behaviour.
580+
_MAX_ACTION_TENANT_MAP = 50_000
581+
557582
def record_action_requested(self, request: ActionRequest) -> str:
558583
"""Record that an agent requested an action. Returns the action_id."""
559584
action_id = str(uuid.uuid4())
585+
tenant_id = getattr(request, "tenant_id", "") or ""
586+
if tenant_id:
587+
tenant_id = self._cap_record_str(tenant_id, self._MAX_TENANT_ID_LEN)
588+
if len(self._tenant_for_action) >= self._MAX_ACTION_TENANT_MAP:
589+
evict = max(1, self._MAX_ACTION_TENANT_MAP // 8)
590+
for stale in list(self._tenant_for_action)[:evict]:
591+
self._tenant_for_action.pop(stale, None)
592+
self._tenant_for_action[action_id] = tenant_id
560593

561594
articles = self._get_regulatory_articles(
562595
EventType.ACTION_REQUESTED,
@@ -600,10 +633,20 @@ def record_action_requested(self, request: ActionRequest) -> str:
600633
"sequence_position": request.sequence_position,
601634
},
602635
regulatory_articles=articles,
636+
tenant_id=tenant_id,
603637
))
604638

605639
return action_id
606640

641+
def _tenant_for(self, action_id: str) -> str:
642+
"""Resolve the tenant scope for an existing action lifecycle.
643+
644+
Returns the tenant_id captured at record_action_requested time so
645+
every follow-up record (risk_scored, decision, execution,
646+
escalation, outcome) carries the same scope automatically.
647+
"""
648+
return self._tenant_for_action.get(action_id, "")
649+
607650
def record_risk_scored(
608651
self,
609652
action_id: str,
@@ -631,6 +674,7 @@ def record_risk_scored(
631674
tool_name=self._cap_record_str(tool_name, self._MAX_TOOL_NAME_LEN),
632675
data=safe_assessment,
633676
regulatory_articles=articles,
677+
tenant_id=self._tenant_for(action_id),
634678
))
635679

636680
def record_decision(
@@ -666,6 +710,7 @@ def record_decision(
666710
"risk_score": risk_score,
667711
},
668712
regulatory_articles=articles,
713+
tenant_id=self._tenant_for(action_id),
669714
))
670715

671716
def record_execution(
@@ -702,6 +747,7 @@ def record_execution(
702747
data={"result_summary": self._cap_record_dict_bytes(
703748
safe_result, self._MAX_EXECUTION_RESULT_JSON_BYTES
704749
)},
750+
tenant_id=self._tenant_for(action_id),
705751
))
706752

707753
def record_escalation(
@@ -731,6 +777,7 @@ def record_escalation(
731777
"risk_score": risk_score,
732778
},
733779
regulatory_articles=articles,
780+
tenant_id=self._tenant_for(action_id),
734781
))
735782

736783
def record_escalation_resolved(
@@ -760,6 +807,7 @@ def record_escalation_resolved(
760807
"justification": self._cap_record_str(justification, self._MAX_JUSTIFICATION_LEN),
761808
},
762809
regulatory_articles=articles,
810+
tenant_id=self._tenant_for(action_id),
763811
))
764812

765813
def record_outcome(
@@ -794,6 +842,7 @@ def record_outcome(
794842
),
795843
},
796844
regulatory_articles=articles,
845+
tenant_id=self._tenant_for(action_id),
797846
))
798847

799848
# Length caps for caller-controlled free-text fields on this direct
@@ -923,6 +972,7 @@ def record_policy_override(
923972
"new_decision": new_decision,
924973
},
925974
regulatory_articles=articles,
975+
tenant_id=self._tenant_for(action_id),
926976
))
927977

928978
# ── Querying ──────────────────────────────────────────────────

0 commit comments

Comments
 (0)