Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"source": "git-subdir",
"url": "https://github.com/vaaraio/vaara.git",
"path": "plugins/claude-code-vaara-governance",
"ref": "v0.44.0"
"ref": "v0.45.0"
},
"homepage": "https://vaara.io"
}
Expand Down
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,47 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

## [0.45.0] - 2026-05-30

**Theme: reach remote MCP upstreams over HTTP, and make the proxy's Streamable HTTP handling conform to the spec.**

### Added
- `--upstream-url NAME=URL` on `vaara-mcp-proxy`: front a remote MCP server
over Streamable HTTP instead of a local stdio subprocess. A bare
`--upstream-url URL` lands under the `default` slot. The connector speaks the
2025-03-26 and 2025-06-18 protocol revisions: it POSTs JSON-RPC and reads
either `application/json` or `text/event-stream` replies, captures and echoes
the `Mcp-Session-Id`, sends the negotiated `MCP-Protocol-Version`, and holds a
standing GET SSE channel for server-initiated notifications with
`Last-Event-ID` resume and bounded reconnect. Built on the standard-library
`urllib` only, so the zero-dependency core is preserved (httpx is not a
dependency; only fastapi and uvicorn ship behind the `server` extra). The
deprecated 2024-11-05 two-endpoint transport and interactive OAuth are out of
scope; remote auth is static-header only.
- `--upstream-header NAME=HEADER` on `vaara-mcp-proxy`: attach a static request
header such as a bearer token to a URL upstream. The header name splits on the
first `=`, so a base64 token's trailing `=` survives. Startup rejects headers
aimed at an unknown slot and stdio/url slot-name collisions.
- In-repo SEP-2787 attestation conformance vectors at
`tests/vectors/sep2787_attestation_v0/`: pinned HS256, ES256, and RS256 keys
and six cases (`hs256_digest_identity`, `es256_projection_identity`,
`rs256_signature_ttl_only`, `neg_bad_signature`, `neg_expired`,
`neg_args_mismatch`) spanning the signature, TTL, and args-commitment
dimensions, with a standard-library-only independent checker that imports no
Vaara code, a generator script, and a pytest cross-check against the library
verifier. `docs/sep2787-conformance.md` now points at these in-repo vectors
rather than a planned follow-up.

### Fixed
- Streamable HTTP conformance in the proxy's HTTP transport. The `Mcp-Session-Id`
is now validated as visible ASCII (0x21 to 0x7E) on both POST and GET alongside
the existing 128-character cap. The `MCP-Protocol-Version` header is read and
validated against the supported set (`2025-03-26`, `2025-06-18`); an absent
header is treated as `2025-03-26`, an unsupported value returns 400. The POST
`Accept` header must offer both `application/json` and `text/event-stream`; the
check is wildcard-aware, so `*/*` and an absent header still pass and existing
clients are not broken, and a violation returns 406.

## [0.44.0] - 2026-05-30

**Theme: a runnable reference verifier. Generate the attestation key, then verify attestations and receipts offline from the command line.**
Expand Down
2 changes: 1 addition & 1 deletion clients/ts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vaara/client",
"version": "0.44.0",
"version": "0.45.0",
"mcpName": "io.github.vaaraio/vaara",
"description": "TypeScript client for the Vaara HTTP API. Conformal risk scoring, hash-chained audit, policy reload, named detectors.",
"main": "dist/index.js",
Expand Down
13 changes: 8 additions & 5 deletions docs/sep2787-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,14 @@ null`.
`tests/vectors/execution_receipt_v0/` (five cases, pinned keys, a
stdlib-only `_check_independent.py` walker that verifies them without
importing Vaara).
- SEP-2787 attestation vectors are published on the fork PR
`modelcontextprotocol/modelcontextprotocol#2789`. Mirroring them in-repo as
`tests/vectors/sep2787_attestation_v0/` is a planned follow-up so the
`vaara attest verify` command can be exercised against pinned fixtures the
same way the receipt vectors are.
- SEP-2787 attestation vectors live in-repo at
`tests/vectors/sep2787_attestation_v0/` (six cases across HS256/ES256/RS256,
pinned keys, a stdlib-only `_check_independent.py` walker that verifies
signature, TTL, and the step-5 argument commitment without importing Vaara).
They mirror the proposed-shape vectors on the fork PR
`modelcontextprotocol/modelcontextprotocol#2789` so the `vaara attest verify`
command can be exercised against pinned fixtures the same way the receipt
vectors are.

## Quick start

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "vaara"
version = "0.44.0"
version = "0.45.0"
description = "Tamper-evident runtime evidence layer for AI agents: risk scoring, audit trails, and regulatory compliance"
requires-python = ">=3.10"
license = "Apache-2.0"
Expand Down
174 changes: 174 additions & 0 deletions scripts/generate_sep2787_attestation_vectors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Generate the v0 SEP-2787 attestation conformance vectors.

Writes pinned keys and signed fixtures under
``tests/vectors/sep2787_attestation_v0/``. Run once and commit the
output; re-running regenerates the asymmetric keys and signatures. A
second implementation reads the committed fixtures with
``_check_independent.py`` and must reproduce the same canonical bytes
and verification verdicts.

The receipt vectors (``tests/vectors/execution_receipt_v0/``) cover the
post-execution sibling. These cover the attestation itself: the three
verification dimensions ``verify_attestation`` owns (signature, TTL) plus
the step-5 argument commitment exposed as ``verify_args_commitment``.

TTL is evaluated at a fixed instant, ``EVAL_NOW_ISO`` below, which the
independent checker pins too. The expired case carries an older ``iat``
so its deadline falls before that instant while every other case stays
inside its window.

Usage: python scripts/generate_sep2787_attestation_vectors.py
"""

from __future__ import annotations

import json
from pathlib import Path

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa

from vaara.attestation.sep2787 import (
PayloadDerived,
PlannerDeclared,
ToolCallBinding,
emit_attestation,
make_args_digest,
make_args_projection,
)

ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "tests" / "vectors" / "sep2787_attestation_v0"
HS_SECRET = bytes.fromhex("42" * 32)
IAT = "2026-05-29T10:00:00Z"
OLD_IAT = "2026-05-29T08:00:00Z"
# The independent checker evaluates TTL at this instant. 30s past IAT,
# well inside the 300s default window; the expired case (OLD_IAT, two
# hours earlier) is already past its deadline here.
EVAL_NOW_ISO = "2026-05-29T10:00:30Z"

ARGS = {"path": "/archive/2024-Q3.md", "recursive": False}
OTHER_ARGS = {"path": "/keep/forever.md", "recursive": False}
COMMON = dict(iss="issuer://test", sub="agent:archiver", secret_version="v1")


def _write(path: Path, obj) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(obj, indent=2, sort_keys=True) + "\n")

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
Comment thread
vaaraio marked this conversation as resolved.
Dismissed


def _attest(*, alg, signing_material, args, nonce, iat=IAT):
payload = PayloadDerived(tool_calls=(ToolCallBinding(
name="delete_file",
server_fingerprint="sha256:" + "1" * 64,
args=args,
),))
return emit_attestation(
planner_declared=PlannerDeclared(intent="archive obsolete report"),
payload_derived=payload,
alg=alg, signing_material=signing_material,
nonce=nonce, iat=iat, **COMMON,
)


def _emit_keys() -> dict:
es = ec.generate_private_key(ec.SECP256R1())
rs = rsa.generate_private_key(public_exponent=65537, key_size=2048)
keys = OUT / "keys"
keys.mkdir(parents=True, exist_ok=True)
(keys / "hs256_secret.bin").write_bytes(HS_SECRET)

Check failure

Code scanning / CodeQL

Clear-text storage of sensitive information High

This expression stores
sensitive data (secret)
as clear text.
Comment thread
vaaraio marked this conversation as resolved.
Dismissed
(keys / "es256_private.pem").write_bytes(es.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
(keys / "es256_public.pem").write_bytes(es.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
))
(keys / "rs256_private.pem").write_bytes(rs.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
))
(keys / "rs256_public.pem").write_bytes(rs.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
))
return {"ES256": es, "RS256": rs}


def _case(name: str, *, attestation_dict: dict, expected: dict,
runtime_args=None) -> None:
d = OUT / "normative" / name
_write(d / "attestation.json", attestation_dict)
_write(d / "expected.json", expected)
if runtime_args is not None:
_write(d / "runtime_args.json", runtime_args)


def main() -> None:
asym = _emit_keys()

# Positive: HS256, hash-only-identity commitment, runtime args in hand.
att = _attest(alg="HS256", signing_material=HS_SECRET,
args=make_args_digest(ARGS), nonce="att-nonce-hs-0001")
_case("hs256_digest_identity", attestation_dict=att.to_dict(),
runtime_args=ARGS,
expected={"signature_ok": True, "ttl_ok": True,
"args_commitment_ok": True, "projection_match": True})

# Positive: ES256, identity projection of the full args.
att = _attest(alg="ES256", signing_material=asym["ES256"],
args=make_args_projection(ARGS), nonce="att-nonce-es-0002")
_case("es256_projection_identity", attestation_dict=att.to_dict(),
runtime_args=ARGS,
expected={"signature_ok": True, "ttl_ok": True,
"args_commitment_ok": True, "projection_match": True})

# Positive: RS256, signature + TTL only. No runtime args supplied, so
# the argument-commitment step is not composed (verify_attestation
# covers steps 1 and 3; step 5 is the caller's, run once the runtime
# arguments are in hand). args_commitment_ok is null.
att = _attest(alg="RS256", signing_material=asym["RS256"],
args=make_args_projection(ARGS), nonce="att-nonce-rs-0003")
_case("rs256_signature_ttl_only", attestation_dict=att.to_dict(),
expected={"signature_ok": True, "ttl_ok": True,
"args_commitment_ok": None, "projection_match": None})

# Negative: signature tampered. The signed body is unchanged, so TTL
# and the argument commitment still hold; only the signature fails.
att = _attest(alg="HS256", signing_material=HS_SECRET,
args=make_args_digest(ARGS), nonce="att-nonce-hs-0004")
bad = att.to_dict()
last = bad["signature"][-1]
bad["signature"] = bad["signature"][:-1] + ("0" if last != "0" else "1")
_case("neg_bad_signature", attestation_dict=bad, runtime_args=ARGS,
expected={"signature_ok": False, "ttl_ok": True,
"args_commitment_ok": True, "projection_match": True})

# Negative: signature valid but the envelope is past its TTL deadline
# at EVAL_NOW (older iat, default 300s window).
att = _attest(alg="ES256", signing_material=asym["ES256"],
args=make_args_projection(ARGS), nonce="att-nonce-es-0005",
iat=OLD_IAT)
_case("neg_expired", attestation_dict=att.to_dict(), runtime_args=ARGS,
expected={"signature_ok": True, "ttl_ok": False,
"args_commitment_ok": True, "projection_match": True})

# Negative: signature and TTL valid, but the hash-only-identity
# commitment binds ARGS while the runtime arguments are OTHER_ARGS.
att = _attest(alg="HS256", signing_material=HS_SECRET,
args=make_args_digest(ARGS), nonce="att-nonce-hs-0006")
_case("neg_args_mismatch", attestation_dict=att.to_dict(),
runtime_args=OTHER_ARGS,
expected={"signature_ok": True, "ttl_ok": True,
"args_commitment_ok": False, "projection_match": None})

print(f"wrote vectors under {OUT}")
print(f"TTL evaluated at {EVAL_NOW_ISO}")


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions server-vaara-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
"url": "https://github.com/vaaraio/vaara",
"source": "github"
},
"version": "0.44.0",
"version": "0.45.0",
"packages": [
{
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "vaara",
"version": "0.44.0",
"version": "0.45.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
"url": "https://github.com/vaaraio/vaara",
"source": "github"
},
"version": "0.44.0",
"version": "0.45.0",
"packages": [
{
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "vaara",
"version": "0.44.0",
"version": "0.45.0",
"runtimeHint": "uvx",
"transport": {
"type": "stdio"
Expand Down
2 changes: 1 addition & 1 deletion src/vaara/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
oversight.
"""

__version__ = "0.44.0"
__version__ = "0.45.0"

from vaara.pipeline import InterceptionPipeline, InterceptionResult

Expand Down
26 changes: 25 additions & 1 deletion src/vaara/integrations/_mcp_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,35 @@
import sys
import threading
from dataclasses import dataclass
from typing import Any, Callable, Optional
from typing import Any, Callable, Optional, Protocol, runtime_checkable

logger = logging.getLogger(__name__)


@runtime_checkable
class UpstreamClient(Protocol):
"""Transport-agnostic surface the proxy uses to talk to one upstream.

The proxy never cares whether the upstream is a local stdio subprocess
(:class:`UpstreamMCPClient`) or a remote HTTP/SSE server
(``HttpUpstreamClient``); it only calls these three methods and is
handed an ``on_notification`` callback at construction time. Construction
differs per transport, so it is deliberately not part of the protocol.
"""

def request(self, payload: dict, timeout: float = 30.0) -> dict:
"""Send a JSON-RPC request, block for the response matching its id."""
...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

def notify(self, payload: dict) -> None:
"""Send a JSON-RPC notification (no response expected)."""
...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

def close(self) -> None:
"""Release the transport (kill the subprocess / close the session)."""
...

Check notice

Code scanning / CodeQL

Statement has no effect Note

This statement has no effect.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed


class ProxyError(Exception):
"""The proxy itself cannot serve a request.

Expand Down
Loading