feat: add ACT+ECT over MCP demo with LangGraph agent

End-to-end PoC demonstrating Agent Context Token authorization and
Execution Context Token accountability over MCP tool calls, using a
LangGraph agent with ES256-signed JWT tokens and DAG verification.
This commit is contained in:
2026-04-12 12:43:22 +00:00
parent 45cb13fbe8
commit 9a0dc899a8
19 changed files with 2193 additions and 0 deletions

9
demo/act-ect-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
keys/*.pem
keys/*.json
!keys/.gitkeep
__pycache__/
*.egg-info/
.pytest_cache/
.venv/
build/
dist/

159
demo/act-ect-mcp/README.md Normal file
View File

@@ -0,0 +1,159 @@
# ACT + ECT + MCP + LangGraph — end-to-end PoC
A working demonstration of `draft-nennemann-act-01` (Agent Context Token)
and `draft-nennemann-wimse-ect-01` (Execution Context Token) in a realistic
agent stack:
* a **LangGraph** ReAct agent driven by a local **Ollama** LLM;
* talking over **MCP** streamable-HTTP to a FastMCP server;
* every request carries an **ACT** mandate, a per-call **ECT**, and an
**RFC 9421** HTTP signature with the `wimse-aud` parameter from
`draft-ietf-wimse-http-signature-03`;
* the server rejects any request where ACT / ECT / HTTP-signature /
capability / body-hash binding fails;
* a verifier CLI replays the run's ledger, re-runs the two refimpls, and
prints the resulting DAG.
## Why this exists
The two drafts (ACT and ECT) claim to fit together — ACT giving the
lifecycle (mandate → execution record) and ECT giving the per-call
execution context on the wire. This PoC proves the claim end to end:
the same refimpls that ship in `workspace/packages/{act,ect}/` are the
only crypto/verification layer used here. There is no token forgery
shortcut.
## Requirements
* `uv` ([install](https://docs.astral.sh/uv/))
* Local Ollama with a chat model pulled (`qwen3:8b` by default)
* Python 3.11+ (uv will fetch one if missing)
## Run the demo
```bash
./demo.sh
```
This script:
1. Syncs deps (uv installs the sibling `ietf-act` and `ietf-ect`
packages in editable mode, plus `mcp`, `langgraph`,
`langchain-ollama`, `langchain-mcp-adapters`, `fastapi`, …).
2. Launches the MCP server on `127.0.0.1:8765`.
3. Runs the agent (`poc-agent`) against it with a canned research task.
4. Runs the verifier (`poc-verify`) over the ledger the agent emitted.
5. Shows the last five server-audit entries.
Expected tail of output:
```
mandate verified jti=64f5ec87
ects verified n=7 (tool-calls=2, session=5)
record verified jti=64f5ec87 status=completed
ect-dag wellformed every pred is the mandate or a prior ECT
Run
===
mandate 64f5ec87 task='Search for quantum entanglement, …'
iss=user sub=agent aud=mcp-server
cap=['mcp.session.initialize', 'mcp.session.list_tools', 'mcp.session.other', 'mcp.search', 'mcp.summarize']
Tool-call ECT DAG:
ect 73af4cd3 exec_act=mcp.search pred=['64f5ec87']
ect 0e3ffa01 exec_act=mcp.summarize pred=['64f5ec87', '73af4cd3']
ACT Phase 2 record:
jti=64f5ec87 exec_act=mcp.summarize
status=completed pred=[]
inp_hash=… out_hash=…
```
The mandate jti and the record jti are identical — this is ACT §3.2:
the Phase 2 token records the same task it started as. The tool-call
ECT DAG captures the per-HTTP-request ordering.
## Architecture
```
user ──(mints ACT mandate)──► agent
│ create_react_agent(ChatOllama, tools)
┌──────────────────────────────┐
│ LangGraph ReAct loop │
│ LLM decides tools to call │
│ │
│ langchain-mcp-adapters │
│ ↑ streamable_http session │
└──────────┬───────────────────┘
httpx.AsyncClient with event hooks
┌───────────▼─────────────┐
│ on_request: │
│ - mint ECT(inp_hash) │
│ - RFC 9421 sign │
│ - attach Authorization │
│ + Wimse-ECT + sig │
└───────────┬──────────────┘
POST /mcp
┌───────────▼──────────────┐
│ FastMCP streamable-http │
│ + ActEctAuthMiddleware │
│ verifies: │
│ ACT mandate │
│ ECT │
│ HTTP-signature │
│ inp_hash == body │
│ exec_act in cap │
│ ECT.iss == sub │
│ then dispatches tool │
└──────────────────────────┘
```
## Files
* `src/poc/keys.py` — ES256 keys for the three PoC identities
(`user`, `agent`, `mcp-server`).
* `src/poc/tokens.py` — thin wrappers around `ietf-act` and `ietf-ect`
that fix the PoC's shape.
* `src/poc/http_sig.py` — minimal RFC 9421 signer/verifier covering
`@method`, `@target-uri`, `content-digest`, `wimse-ect`, with the
`wimse-aud` metadata parameter from http-signature-03.
* `src/poc/server.py` — FastMCP server with ACT + ECT + signature
middleware. Writes `keys/server-audit.jsonl`.
* `src/poc/agent.py` — LangGraph + Ollama agent. Writes
`keys/ledger.jsonl` — one mandate, N ECTs, one final ACT record.
* `src/poc/verify_cli.py` — ledger verifier, prints the DAG.
* `tests/` — pytest suite (see below).
## Non-goals
* No real LLM API costs (Ollama is local).
* No distributed SCITT anchoring — server audit log is a plain JSONL.
* No Go-side client in this PoC; Python ↔ Python. Go refimpl lives in
`workspace/drafts/ietf-wimse-ect/refimpl/go-lang/`.
* The PoC uses a single mandate per run and a single Phase 2 record
(ACT §3.2 jti preservation). If you want a multi-record DAG of ACT
tasks, you'd need to exercise the delegation machinery
(`act.delegation.create_delegated_mandate`) — this PoC does not, to
keep the wire story tight.
## Tests
```bash
uv run pytest
```
Covers:
* Minting + round-trip verification of each token type.
* HTTP-signature round-trip.
* Middleware rejection paths (missing headers, wrong audience, stolen
ECT on a mutated body).
* End-to-end: launches an in-process server via httpx's ASGI transport
and runs the agent's token-injection hooks over it (no Ollama
required — uses a fake LLM).

55
demo/act-ect-mcp/demo.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# End-to-end demo: start MCP server, run the LangGraph agent, verify the DAG.
#
# Requirements:
# - uv installed (https://docs.astral.sh/uv/)
# - Ollama running locally with `qwen3:8b` pulled
# (override via POC_MODEL and OLLAMA_HOST env vars)
#
# The script is idempotent — keys and ledgers are written under ./keys/.
set -euo pipefail
cd "$(dirname "$0")"
POC_MODEL="${POC_MODEL:-qwen3:8b}"
POC_PORT="${POC_PORT:-8765}"
POC_PURPOSE="${POC_PURPOSE:-Search for quantum entanglement, then summarise the top result.}"
mkdir -p keys
rm -f keys/ledger.jsonl keys/server-audit.jsonl
echo "==> syncing dependencies"
uv sync --quiet
echo "==> starting MCP server on 127.0.0.1:${POC_PORT}"
uv run python -m poc.server --port "${POC_PORT}" >/tmp/poc-server.log 2>&1 &
SERVER_PID=$!
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
for _ in $(seq 1 25); do
if curl -sSf -o /dev/null -X POST "http://127.0.0.1:${POC_PORT}/mcp" \
-H 'content-type: application/json' \
--data '{"jsonrpc":"2.0","id":0,"method":"initialize"}' 2>/dev/null \
|| curl -sS "http://127.0.0.1:${POC_PORT}/mcp" -o /dev/null; then
break
fi
sleep 0.2
done
echo "==> running agent (model=${POC_MODEL})"
uv run poc-agent \
--purpose "${POC_PURPOSE}" \
--model "${POC_MODEL}" \
--mcp-url "http://127.0.0.1:${POC_PORT}/mcp"
echo
echo "==> verifying ledger"
uv run poc-verify
echo
echo "==> server audit log (last 5 lines)"
tail -n 5 keys/server-audit.jsonl || true
echo
echo "demo OK"

View File

View File

@@ -0,0 +1,13 @@
{"kind":"mandate","jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","compact":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImFjdCtqd3QiLCJraWQiOiJraWQ6dXNlcjp2MSJ9.eyJpc3MiOiJ1c2VyIiwic3ViIjoiYWdlbnQiLCJhdWQiOiJtY3Atc2VydmVyIiwiaWF0IjoxNzc1OTc2NzY5LCJleHAiOjE3NzU5Nzc2NjksImp0aSI6IjJhOTJjNWI3LWRkMGYtNDRmNS04NzY4LWQxYzNiZjhmMWNmNyIsInRhc2siOnsicHVycG9zZSI6IlNlYXJjaCBmb3IgcXVhbnR1bSBlbnRhbmdsZW1lbnQsIHRoZW4gc3VtbWFyaXNlIHRoZSB0b3AgcmVzdWx0LiIsImNyZWF0ZWRfYnkiOiJ1c2VyIn0sImNhcCI6W3siYWN0aW9uIjoibWNwLnNlc3Npb24uaW5pdGlhbGl6ZSJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ubGlzdF90b29scyJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ub3RoZXIifSx7ImFjdGlvbiI6Im1jcC5zZWFyY2gifSx7ImFjdGlvbiI6Im1jcC5zdW1tYXJpemUifV0sIndpZCI6ImFnZW50In0.X6uhGB4FYY4PsS7np1GFL-4z-lhMdClKhq5G8T9Nic4DUxUeFKwW6aaqYxf20TjbhLfs23Zwq_Nv4jRy8KjTZA","metadata":{"iss":"user","sub":"agent","aud":"mcp-server","task":{"purpose":"Search for quantum entanglement, then summarise the top result.","created_by":"user"},"cap":[{"action":"mcp.session.initialize"},{"action":"mcp.session.list_tools"},{"action":"mcp.session.other"},{"action":"mcp.search"},{"action":"mcp.summarize"}]}}
{"kind":"ect","jti":"c5655719-7187-47db-8080-d0ed95ad1740","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiYzU2NTU3MTktNzE4Ny00N2RiLTgwODAtZDBlZDk1YWQxNzQwIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.9Gy14pINnRn5WYvz2XW4LdDvV_G8ZqJb5TVd-hpO_q0aTbL0HrgNL5bK_zKf_FoFYU9DTcWkd_ukJPhd_eF11A","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}}
{"kind":"ect","jti":"9c31c1b8-0b50-44e9-a58f-a751a659a16a","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiOWMzMWMxYjgtMGI1MC00NGU5LWE1OGYtYTc1MWE2NTlhMTZhIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.dSNnWYdk35hiz_7rVa8BOrz2fRtLDonGBlGCIdprdVUF7qD1pLnZJY6koG04gE2Ayn5yXnN_jAr4e_KRYhfyNQ","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}}
{"kind":"ect","jti":"f6d45c98-fede-4d85-9fd6-39993a68cb42","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NjksImV4cCI6MTc3NTk3NzA2OSwianRpIjoiZjZkNDVjOTgtZmVkZS00ZDg1LTlmZDYtMzk5OTNhNjhjYjQyIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImRmREhZbm8xeC1scUNvaElKaUtnMGV4LWdjRHJWQlNvaTZlcXVVVU05enMifQ.5xqO6a4OEliM2QXB1ZnVIPSdJVZtffwcdILnbnKsnMFI5lRTScE2DGBjMC_fo67dxgmfbR950wE-BpkHga03Ig","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}}
{"kind":"ect","jti":"eeca8478-4df0-46a5-8c80-6f31913226e3","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiZWVjYTg0NzgtNGRmMC00NmE1LThjODAtNmYzMTkxMzIyNmUzIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.vnxBJGIq25E6kQpwkmkG3AyiXYXTCumuyVq_BNJ4fA61xaR4kO2_zNokxo3uJ9hBP6JDCEXpTGlvoLHgtgcTEA","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}}
{"kind":"ect","jti":"c359aebf-d687-44f6-8c94-12493b387969","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiYzM1OWFlYmYtZDY4Ny00NGY2LThjOTQtMTI0OTNiMzg3OTY5IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.3baO9jqD74qXgb70Ffh0FjmhYX4Kc974La6uu__PNv15KydtaOoo530NlKhCEJ5Y-B10eoeXd51P9emtXGNlxg","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}}
{"kind":"ect","jti":"7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiN2FjMjAzNGItYWMyNy00MmI4LWJiOWMtMWQ3OTBhYzlhNGYwIiwiZXhlY19hY3QiOiJtY3Auc2VhcmNoIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IlFrVUZBUmJVTDJMQzhMb2VDcTItVWpldFlfWG5oWWs1UjRYRUZOZG1SQlkifQ.u0bwRvmuUC1IDv7fmBy0pFb1ozeDnJHbnUr3X4jd3ClbeFX2aSZOVodB7HO97MBJdiavFebo01ZAQXJsbhb6MQ","metadata":{"method":"tools/call","tool_name":"search","exec_act":"mcp.search","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"]}}
{"kind":"ect","jti":"295f6c79-df73-4851-abeb-07e5c9282268","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzIsImV4cCI6MTc3NTk3NzA3MiwianRpIjoiMjk1ZjZjNzktZGY3My00ODUxLWFiZWItMDdlNWM5MjgyMjY4IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IkZBb1FWQkpXZTdMWlJUMFJfS0RNMDBWUk9hdmpCcjVveTJGblVUaDh4VzgifQ.oy7Ayuz-xlwx6E-VWi5-71vhUwtPzjl-SejZRiO0vxO4rxO8K5Qua2IjN4D1w77VZcSq-3EhWOuu-BZ0eSKYvg","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}}
{"kind":"ect","jti":"d242256f-f17c-4b3d-995d-60f13beacc25","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiZDI0MjI1NmYtZjE3Yy00YjNkLTk5NWQtNjBmMTNiZWFjYzI1IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5pbml0aWFsaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6ImxmUDBFbWUwcDBjeVNUa01fbzFfc1ZGNzhRQnBOdF9LVjRXRGtjdE1oZkUifQ.otfuVqTG-nmSpv_WOA9G-1BoJqs_Hpv-Y6BmwRFBgxMvhM0e4KhfUKqh0BPFnweQNqoJvoLW_jP2uh6wepBkxg","metadata":{"method":"initialize","exec_act":"mcp.session.initialize","session_only":true}}
{"kind":"ect","jti":"2eb7eef6-6195-44a1-a88f-78be075cdf17","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiMmViN2VlZjYtNjE5NS00NGExLWE4OGYtNzhiZTA3NWNkZjE3IiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5vdGhlciIsInByZWQiOlsiMmE5MmM1YjctZGQwZi00NGY1LTg3NjgtZDFjM2JmOGYxY2Y3Il0sIndpZCI6ImFnZW50IiwiaW5wX2hhc2giOiJHWkZFREhRemRjd19jUjdEZUl4ekxQXzNHWVNUT292M053SF9WalFuaGcwIn0.qVPXigUqNu_bUMhYJrJQW6teu626-AZANpX7m-4o42ZsKAoNYE9D-xnOKdRnkcMybUVVKJlICPMWO9EU9ds_Hw","metadata":{"method":"notifications/initialized","exec_act":"mcp.session.other","session_only":true}}
{"kind":"ect","jti":"27c2682f-3b0a-4658-8710-113ae45864c1","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiMjdjMjY4MmYtM2IwYS00NjU4LTg3MTAtMTEzYWU0NTg2NGMxIiwiZXhlY19hY3QiOiJtY3Auc3VtbWFyaXplIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciLCI3YWMyMDM0Yi1hYzI3LTQyYjgtYmI5Yy0xZDc5MGFjOWE0ZjAiXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6Ilg5b2VtUFlUTUx3anZCclBYSTZfQ0s0WWZ5T01KemZYTnRpTk5FY3FHTU0ifQ.kjapcaU417lSbAxjL9qzhKJdq3WxK5AwytOfAItWZxhTOnQ11HgGQ6nGmwdKxRv47mYxVo8xe5XsMSuwF9mLqw","metadata":{"method":"tools/call","tool_name":"summarize","exec_act":"mcp.summarize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0"]}}
{"kind":"ect","jti":"e220a1d8-ec56-43f8-9952-a1f4387f607b","compact":"eyJhbGciOiJFUzI1NiIsImtpZCI6ImtpZDphZ2VudDp2MSIsInR5cCI6ImV4ZWMrand0In0.eyJpc3MiOiJhZ2VudCIsImF1ZCI6Im1jcC1zZXJ2ZXIiLCJpYXQiOjE3NzU5NzY3NzcsImV4cCI6MTc3NTk3NzA3NywianRpIjoiZTIyMGExZDgtZWM1Ni00M2Y4LTk5NTItYTFmNDM4N2Y2MDdiIiwiZXhlY19hY3QiOiJtY3Auc2Vzc2lvbi5saXN0X3Rvb2xzIiwicHJlZCI6WyIyYTkyYzViNy1kZDBmLTQ0ZjUtODc2OC1kMWMzYmY4ZjFjZjciXSwid2lkIjoiYWdlbnQiLCJpbnBfaGFzaCI6IkZBb1FWQkpXZTdMWlJUMFJfS0RNMDBWUk9hdmpCcjVveTJGblVUaDh4VzgifQ.10ySm9JKxbH1ytgl0R-9NfApjED8qJw5awRUWwpzRVuA8CU5nsiyni0VYuyaOstfsrTUKtb71NmRLjcPBaSF1Q","metadata":{"method":"tools/list","exec_act":"mcp.session.list_tools","session_only":true}}
{"kind":"record","jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","compact":"eyJhbGciOiJFUzI1NiIsInR5cCI6ImFjdCtqd3QiLCJraWQiOiJraWQ6YWdlbnQ6djEifQ.eyJpc3MiOiJ1c2VyIiwic3ViIjoiYWdlbnQiLCJhdWQiOiJtY3Atc2VydmVyIiwiaWF0IjoxNzc1OTc2NzY5LCJleHAiOjE3NzU5Nzc2NjksImp0aSI6IjJhOTJjNWI3LWRkMGYtNDRmNS04NzY4LWQxYzNiZjhmMWNmNyIsInRhc2siOnsicHVycG9zZSI6IlNlYXJjaCBmb3IgcXVhbnR1bSBlbnRhbmdsZW1lbnQsIHRoZW4gc3VtbWFyaXNlIHRoZSB0b3AgcmVzdWx0LiIsImNyZWF0ZWRfYnkiOiJ1c2VyIn0sImNhcCI6W3siYWN0aW9uIjoibWNwLnNlc3Npb24uaW5pdGlhbGl6ZSJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ubGlzdF90b29scyJ9LHsiYWN0aW9uIjoibWNwLnNlc3Npb24ub3RoZXIifSx7ImFjdGlvbiI6Im1jcC5zZWFyY2gifSx7ImFjdGlvbiI6Im1jcC5zdW1tYXJpemUifV0sImV4ZWNfYWN0IjoibWNwLnN1bW1hcml6ZSIsInByZWQiOltdLCJleGVjX3RzIjoxNzc1OTc2NzgxLCJzdGF0dXMiOiJjb21wbGV0ZWQiLCJ3aWQiOiJhZ2VudCIsImlucF9oYXNoIjoiLWJMNF9Wb2JDWkNxZnFIc2RqN1hCR2tyeFpYaGE5VVp2YURCNkVTNGFYVSIsIm91dF9oYXNoIjoicFhXdjdKSjVtSmVSRDk3czlrMk41bkF4cVo0ajJPc3ZPNFM1WmJHNzVFdyJ9.PXoZHHCBizZiTLB_NL5o1j2O-wCEt6Px7syWP53pAaAUohdzNsbUGVgKR4LcXriP98yG8JnvbfPSa7tG1rA6qQ","metadata":{"exec_act":"mcp.summarize","status":"completed","pred":[],"inp_hash":"-bL4_VobCZCqfqHsdj7XBGkrxZXha9UZvaDB6ES4aXU","out_hash":"pXWv7JJ5mJeRD97s9k2N5nAxqZ4j2OsvO4S5ZbG75Ew","n_tool_ects":2}}

View File

@@ -0,0 +1,11 @@
{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"c5655719-7187-47db-8080-d0ed95ad1740","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"9c31c1b8-0b50-44e9-a58f-a751a659a16a","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976769,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"f6d45c98-fede-4d85-9fd6-39993a68cb42","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"dfDHYno1x-lqCohIJiKg0ex-gcDrVBSoi6equUUM9zs","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"eeca8478-4df0-46a5-8c80-6f31913226e3","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"c359aebf-d687-44f6-8c94-12493b387969","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0","exec_act":"mcp.search","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"QkUFARbUL2LC8LoeCq2-UjetY_XnhYk5R4XEFNdmRBY","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976772,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"295f6c79-df73-4851-abeb-07e5c9282268","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"FAoQVBJWe7LZRT0R_KDM00VROavjBr5oy2FnUTh8xW8","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"d242256f-f17c-4b3d-995d-60f13beacc25","exec_act":"mcp.session.initialize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"lfP0Eme0p0cySTkM_o1_sVF78QBpNt_KV4WDkctMhfE","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"2eb7eef6-6195-44a1-a88f-78be075cdf17","exec_act":"mcp.session.other","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"GZFEDHQzdcw_cR7DeIxzLP_3GYSTOov3NwH_VjQnhg0","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"27c2682f-3b0a-4658-8710-113ae45864c1","exec_act":"mcp.summarize","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","7ac2034b-ac27-42b8-bb9c-1d790ac9a4f0"],"inp_hash":"X9oemPYTMLwjvBrPXI6_CK4YfyOMJzfXNtiNNEcqGMM","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}
{"ts":1775976777,"mandate_jti":"2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7","ect_jti":"e220a1d8-ec56-43f8-9952-a1f4387f607b","exec_act":"mcp.session.list_tools","pred":["2a92c5b7-dd0f-44f5-8768-d1c3bf8f1cf7"],"inp_hash":"FAoQVBJWe7LZRT0R_KDM00VROavjBr5oy2FnUTh8xW8","wimse_aud":"mcp-server","keyid":"kid:agent:v1"}

View File

@@ -0,0 +1,48 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "act-ect-poc"
version = "0.1.0"
description = "End-to-end PoC: LangGraph agent calling MCP tools with ACT mandate + ECT execution context"
requires-python = ">=3.11"
dependencies = [
# Our refimpls (installed from sibling packages)
"ietf-act",
"ietf-ect",
# MCP server + client
"mcp>=1.6.0",
# LangGraph + LLM plumbing
"langgraph>=0.3.0",
"langchain>=0.3.0",
"langchain-core>=0.3.0",
"langchain-ollama>=0.2.0",
"langchain-mcp-adapters>=0.1.0",
# HTTP server + client
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"httpx>=0.27",
# Crypto / JWT
"cryptography>=42.0",
"PyJWT>=2.8.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23"]
[project.scripts]
poc-server = "poc.server:main"
poc-agent = "poc.agent:main"
poc-verify = "poc.verify_cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.uv.sources]
ietf-act = { path = "../../workspace/packages/act", editable = true }
ietf-ect = { path = "../../workspace/packages/ect", editable = true }
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"

View File

@@ -0,0 +1,3 @@
"""ACT + ECT + MCP + LangGraph end-to-end PoC."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,447 @@
"""LangGraph ReAct agent that calls MCP tools with ACT + ECT on every request.
Flow per run
------------
1. ``mint_mandate`` — user issues a Phase 1 ACT mandate that authorises the
agent to use ``mcp.search``, ``mcp.summarize``, plus session-level actions.
2. ``MultiServerMCPClient`` opens a streamable-HTTP session to the MCP
server. The session's ``httpx.AsyncClient`` has event hooks installed
(``_install_ect_hooks``) that, on every outgoing POST to /mcp:
* build an ECT over the request body (inp_hash),
* sign the request per RFC 9421 with ``wimse-aud=mcp-server``,
* attach ``Authorization: Bearer <ACT>``, ``Wimse-ECT: <ect>``,
``Content-Digest``, ``Signature-Input`` and ``Signature``.
Each ECT's ``pred`` chains to the mandate plus all prior tool-call ECTs
in this run, so the ECT DAG captures the per-tool-call ordering.
3. ``create_react_agent`` runs a LangGraph ReAct loop with ChatOllama; the
LLM decides when/what to call. The token plumbing is transparent to
the model.
4. After the agent finishes its response, a single Phase 2 ACT execution
record is minted that summarises the run (ACT §3.2: one mandate → one
record; jti preserved). The record's ``inp_hash`` covers the task
purpose and ``out_hash`` covers the final assistant message.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, AsyncIterator
import httpx
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_ollama import ChatOllama
from langgraph.prebuilt import create_react_agent
from act.crypto import b64url_sha256
from .http_sig import SignedRequest, content_digest, sign_request
from .keys import Identity, load_identities
from .tokens import (
MintedMandate,
MintedRecord,
MintedECT,
exec_act_for_rpc_method,
mint_ect,
mint_exec_record,
mint_mandate,
)
LOG = logging.getLogger("poc.agent")
SERVER_IDENTITY_NAME = "mcp-server"
# ---- Session ledger ---------------------------------------------------------
@dataclass
class LedgerEntry:
kind: str # "mandate" | "ect" | "record"
compact: str
jti: str
metadata: dict[str, Any] = field(default_factory=dict)
def to_json(self) -> str:
return json.dumps(
{
"kind": self.kind,
"jti": self.jti,
"compact": self.compact,
"metadata": self.metadata,
},
separators=(",", ":"),
)
@dataclass
class SessionLedger:
"""Mutable per-run state: mandate + growing chain of ECT tool invocations.
The ECT ``pred`` set grows with each successful tool call, giving a
DAG of execution contexts. There is exactly one ACT Phase 2 record per
run (minted at the end), whose jti equals the mandate jti per ACT §3.2.
"""
path: Path
mandate: MintedMandate
tool_ects: list[MintedECT] = field(default_factory=list)
final_record: MintedRecord | None = None
def write_entry(self, entry: LedgerEntry) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a", encoding="utf-8") as fh:
fh.write(entry.to_json() + "\n")
def tool_ect_pred(self) -> list[str]:
"""pred list for the *next* tool-call ECT: mandate + prior tool ECTs."""
return [self.mandate.mandate.jti] + [e.payload.jti for e in self.tool_ects]
# ---- httpx event hooks ------------------------------------------------------
def _rpc_method_and_tool(body: bytes) -> tuple[str | None, str | None]:
"""Sniff a JSON-RPC request body for (method, tool_name)."""
try:
obj = json.loads(body.decode("utf-8"))
except Exception:
return None, None
if not isinstance(obj, dict):
return None, None
method = obj.get("method")
if not isinstance(method, str):
return None, None
tool_name = None
if method == "tools/call":
params = obj.get("params") or {}
name = params.get("name") if isinstance(params, dict) else None
if isinstance(name, str):
tool_name = name
return method, tool_name
def _install_ect_hooks(
client: httpx.AsyncClient,
*,
agent: Identity,
audience: str,
ledger: SessionLedger,
mcp_path: str = "/mcp",
) -> None:
"""Attach request/response event hooks that inject ACT+ECT+sig headers."""
state_key = "_poc_ect_state"
async def on_request(request: httpx.Request) -> None:
if not request.url.path.endswith(mcp_path):
return
# httpx may have already serialized body into request.content.
body = request.content or b""
method, tool_name = _rpc_method_and_tool(body)
if method is None:
# Not JSON-RPC — still attach mandate so middleware can 403
# rather than 401, but skip ECT/record minting. The PoC never
# triggers this path; keep it permissive to ease debugging.
request.headers["authorization"] = f"Bearer {ledger.mandate.compact}"
return
try:
exec_act = exec_act_for_rpc_method(method, tool_name)
except ValueError:
LOG.warning("unknown tool in tools/call: %r", tool_name)
return
# Session-setup calls (initialize, tools/list, ping, …) don't grow
# the tool-call DAG — they point only at the mandate. Tool-call
# ECTs chain off the mandate plus every prior tool-call ECT.
is_tool_call = method == "tools/call"
if is_tool_call:
pred_jtis = ledger.tool_ect_pred()
else:
pred_jtis = [ledger.mandate.mandate.jti]
ect = mint_ect(
agent=agent,
audience=audience,
exec_act=exec_act,
pred_jtis=pred_jtis,
inp_body=body,
)
signed: SignedRequest = sign_request(
method=request.method,
target_uri=str(request.url),
body=body,
wimse_ect=ect.compact,
wimse_aud=audience,
keyid=agent.kid,
private_key=agent.private_key,
)
request.headers["authorization"] = f"Bearer {ledger.mandate.compact}"
request.headers["wimse-ect"] = ect.compact
request.headers["content-digest"] = signed.content_digest
request.headers["signature-input"] = signed.signature_input
request.headers["signature"] = signed.signature
# Stash so response hook can mint the exec record correlating the
# HTTP exchange with the ECT we just sent.
setattr(request, state_key, {
"ect": ect,
"exec_act": exec_act,
"method": method,
"tool_name": tool_name,
"inp_hash": b64url_sha256(body),
"pred_jtis": pred_jtis,
"request_body": body,
})
async def on_response(response: httpx.Response) -> None:
request = response.request
st = getattr(request, state_key, None)
if not st:
return
method: str = st["method"]
ect = st["ect"]
if method == "tools/call":
ledger.tool_ects.append(ect)
ledger.write_entry(
LedgerEntry(
kind="ect",
compact=ect.compact,
jti=ect.payload.jti,
metadata={
"method": method,
"tool_name": st["tool_name"],
"exec_act": st["exec_act"],
"pred": list(ect.payload.pred),
},
)
)
else:
ledger.write_entry(
LedgerEntry(
kind="ect",
compact=ect.compact,
jti=ect.payload.jti,
metadata={
"method": method,
"exec_act": st["exec_act"],
"session_only": True,
},
)
)
client.event_hooks["request"].append(on_request)
client.event_hooks["response"].append(on_response)
# ---- MCP client factory -----------------------------------------------------
def make_httpx_client_factory(agent: Identity, audience: str, ledger: SessionLedger):
"""Return an httpx_client_factory that installs our hooks on each client."""
from mcp.shared._httpx_utils import (
MCP_DEFAULT_SSE_READ_TIMEOUT,
MCP_DEFAULT_TIMEOUT,
)
def factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
kwargs: dict[str, Any] = {"follow_redirects": True}
if timeout is None:
kwargs["timeout"] = httpx.Timeout(
MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT
)
else:
kwargs["timeout"] = timeout
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
client = httpx.AsyncClient(**kwargs)
_install_ect_hooks(client, agent=agent, audience=audience, ledger=ledger)
return client
return factory
# ---- Run an agent turn ------------------------------------------------------
@asynccontextmanager
async def open_mcp_client(
*, agent: Identity, audience: str, ledger: SessionLedger, url: str
) -> AsyncIterator[MultiServerMCPClient]:
factory = make_httpx_client_factory(agent, audience, ledger)
client = MultiServerMCPClient(
{
"poc": {
"transport": "streamable_http",
"url": url,
"httpx_client_factory": factory,
}
}
)
try:
yield client
finally:
# MultiServerMCPClient does not expose an explicit close in 0.2.x;
# sessions are closed per get_tools() call. Nothing to do here.
pass
async def run_once(
*,
purpose: str,
model: str,
mcp_url: str,
keys_dir: str,
ledger_path: str,
ollama_host: str | None,
) -> dict[str, Any]:
identities = load_identities(keys_dir)
user = identities["user"]
agent = identities["agent"]
mandate = mint_mandate(
user=user,
agent=agent,
audience=SERVER_IDENTITY_NAME,
purpose=purpose,
)
ledger = SessionLedger(path=Path(ledger_path), mandate=mandate)
ledger.write_entry(
LedgerEntry(
kind="mandate",
compact=mandate.compact,
jti=mandate.mandate.jti,
metadata={
"iss": mandate.mandate.iss,
"sub": mandate.mandate.sub,
"aud": mandate.mandate.aud,
"task": mandate.mandate.task.to_dict(),
"cap": [c.to_dict() for c in mandate.mandate.cap],
},
)
)
async with open_mcp_client(
agent=agent, audience=SERVER_IDENTITY_NAME, ledger=ledger, url=mcp_url
) as client:
tools = await client.get_tools()
LOG.info("loaded %d MCP tools: %s", len(tools), [t.name for t in tools])
llm_kwargs: dict[str, Any] = {"model": model, "temperature": 0.0}
if ollama_host:
llm_kwargs["base_url"] = ollama_host
llm = ChatOllama(**llm_kwargs)
graph = create_react_agent(llm, tools)
system = SystemMessage(
content=(
"You are a research assistant with access to two tools: "
"search(query) and summarize(text). "
"For the user's task, first call search to gather material, "
"then call summarize on the joined results. "
"After the summary, reply with the summary and stop."
)
)
human = HumanMessage(content=purpose)
result = await graph.ainvoke({"messages": [system, human]})
final_msg = result["messages"][-1]
final_text = getattr(final_msg, "content", str(final_msg))
if isinstance(final_text, list):
final_text = json.dumps(final_text, sort_keys=True)
# ACT §3.2: one mandate → one Phase 2 record (jti preserved). The
# record summarises the whole invocation; per-tool-call DAG structure
# lives in the ECTs we already logged.
final_record = mint_exec_record(
agent=agent,
mandate=mandate.mandate,
exec_act="mcp.summarize", # terminal exec_act; picked from cap
pred_jtis=[], # root task within this run's ACT view
inp_body=purpose.encode("utf-8"),
out_body=final_text.encode("utf-8"),
)
ledger.final_record = final_record
ledger.write_entry(
LedgerEntry(
kind="record",
compact=final_record.compact,
jti=final_record.record.jti,
metadata={
"exec_act": final_record.record.exec_act,
"status": final_record.record.status,
"pred": list(final_record.record.pred),
"inp_hash": final_record.record.inp_hash,
"out_hash": final_record.record.out_hash,
"n_tool_ects": len(ledger.tool_ects),
},
)
)
return {
"mandate_jti": mandate.mandate.jti,
"record_jti": final_record.record.jti,
"tool_ects": [e.payload.jti for e in ledger.tool_ects],
"final_message": final_text,
}
def main() -> None:
logging.basicConfig(
level=os.environ.get("POC_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="ACT/ECT MCP PoC agent")
parser.add_argument(
"--purpose",
default="Summarise recent research on agent authorization tokens.",
help="High-level task the mandate authorises.",
)
parser.add_argument("--model", default=os.environ.get("POC_MODEL", "qwen3:8b"))
parser.add_argument(
"--mcp-url", default=os.environ.get("POC_MCP_URL", "http://127.0.0.1:8765/mcp")
)
parser.add_argument("--keys-dir", default=os.environ.get("POC_KEYS_DIR", "keys"))
parser.add_argument(
"--ledger", default=os.environ.get("POC_LEDGER", "keys/ledger.jsonl")
)
parser.add_argument("--ollama-host", default=os.environ.get("OLLAMA_HOST"))
args = parser.parse_args()
summary = asyncio.run(
run_once(
purpose=args.purpose,
model=args.model,
mcp_url=args.mcp_url,
keys_dir=args.keys_dir,
ledger_path=args.ledger,
ollama_host=args.ollama_host,
)
)
print(json.dumps(summary, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,238 @@
"""Minimal RFC 9421 HTTP Message Signatures for the PoC.
Covers just enough of RFC 9421 + draft-ietf-wimse-http-signature-03 to
bind an ECT-bearing request to its method, target URI, body digest, and
the Wimse-ECT header itself. Not a general-purpose implementation — the
signed-component serialization follows RFC 9421 §2.3 for this fixed set
of components only.
Signature metadata parameters per draft-ietf-wimse-http-signature-03:
keyid — kid of the signing workload
alg — "ecdsa-p256-sha256"
created — NumericDate seconds
wimse-aud — target audience workload identity (new in -03, replaces
the removed Wimse-Audience HTTP header)
Format:
Signature-Input: sig1=(\"@method\" \"@target-uri\" \"content-digest\" \
\"wimse-ect\");created=...;keyid=\"...\";\
alg=\"ecdsa-p256-sha256\";wimse-aud=\"...\"
Signature: sig1=:<base64>:
"""
from __future__ import annotations
import base64
import hashlib
import time
from dataclasses import dataclass
from typing import Iterable
from urllib.parse import urlsplit
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
)
from act.crypto import sign as act_sign, verify as act_verify
from act.errors import ACTSignatureError
COVERED_COMPONENTS: tuple[str, ...] = (
"@method",
"@target-uri",
"content-digest",
"wimse-ect",
)
SIG_ALG = "ecdsa-p256-sha256"
def content_digest(body: bytes) -> str:
"""RFC 9530 Content-Digest header value using sha-256."""
digest = hashlib.sha256(body).digest()
return "sha-256=:" + base64.b64encode(digest).decode("ascii") + ":"
def _serialize_components(
*,
method: str,
target_uri: str,
content_digest_hdr: str,
wimse_ect_hdr: str,
params: str,
) -> bytes:
"""RFC 9421 §2.3 signature base for the fixed PoC component set."""
lines = [
f'"@method": {method.upper()}',
f'"@target-uri": {target_uri}',
f'"content-digest": {content_digest_hdr}',
f'"wimse-ect": {wimse_ect_hdr}',
f'"@signature-params": {params}',
]
return "\n".join(lines).encode("ascii")
def _signature_params(
*,
created: int,
keyid: str,
wimse_aud: str,
) -> str:
"""Render the signature-params string (quoted inner-list + params)."""
components = " ".join(f'"{c}"' for c in COVERED_COMPONENTS)
return (
f"({components})"
f";created={created}"
f';keyid="{keyid}"'
f';alg="{SIG_ALG}"'
f';wimse-aud="{wimse_aud}"'
)
@dataclass
class SignedRequest:
content_digest: str
signature_input: str
signature: str
def sign_request(
*,
method: str,
target_uri: str,
body: bytes,
wimse_ect: str,
wimse_aud: str,
keyid: str,
private_key: EllipticCurvePrivateKey,
created: int | None = None,
) -> SignedRequest:
"""Produce the three headers needed to send a signed PoC request."""
created = int(created if created is not None else time.time())
cd = content_digest(body)
params = _signature_params(created=created, keyid=keyid, wimse_aud=wimse_aud)
base = _serialize_components(
method=method,
target_uri=target_uri,
content_digest_hdr=cd,
wimse_ect_hdr=wimse_ect,
params=params,
)
sig = act_sign(private_key, base)
sig_b64 = base64.b64encode(sig).decode("ascii")
return SignedRequest(
content_digest=cd,
signature_input=f"sig1={params}",
signature=f"sig1=:{sig_b64}:",
)
@dataclass
class ParsedSignature:
covered: tuple[str, ...]
created: int
keyid: str
alg: str
wimse_aud: str
raw_params: str
signature_b64: str
def _parse_signature_input(value: str) -> ParsedSignature:
"""Parse 'sig1=(...);created=...;keyid="...";alg="...";wimse-aud="..."'."""
if "=" not in value:
raise ValueError("signature-input: missing label")
_label, _, inner = value.partition("=")
# inner: '("a" "b" ...);created=...;keyid="...";...'
if not inner.startswith("("):
raise ValueError("signature-input: missing covered components list")
close = inner.index(")")
components_raw = inner[1:close]
covered = tuple(
part.strip().strip('"') for part in components_raw.split() if part.strip()
)
rest = inner[close + 1 :]
params: dict[str, str] = {}
for part in rest.split(";"):
part = part.strip()
if not part or "=" not in part:
continue
k, _, v = part.partition("=")
params[k.strip()] = v.strip().strip('"')
return ParsedSignature(
covered=covered,
created=int(params["created"]),
keyid=params["keyid"],
alg=params["alg"],
wimse_aud=params["wimse-aud"],
raw_params=inner, # full params string (for sig base reconstruction)
signature_b64="",
)
def _parse_signature(value: str) -> str:
"""Parse 'sig1=:<base64>:' → base64 string."""
if "=" not in value:
raise ValueError("signature: missing label")
_label, _, inner = value.partition("=")
inner = inner.strip()
if not (inner.startswith(":") and inner.endswith(":")):
raise ValueError("signature: expected byte-sequence form :...:" )
return inner[1:-1]
def verify_request(
*,
method: str,
target_uri: str,
body: bytes,
wimse_ect_header: str,
content_digest_header: str,
signature_input_header: str,
signature_header: str,
expected_audience: str,
public_key: EllipticCurvePublicKey,
max_age_seconds: int = 300,
now: int | None = None,
) -> ParsedSignature:
"""Verify the signature covers the expected components and matches.
Returns the parsed signature metadata (keyid, alg, wimse-aud, created)
so the caller can cross-check against ECT/ACT claims.
"""
parsed = _parse_signature_input(signature_input_header)
if parsed.covered != COVERED_COMPONENTS:
raise ACTSignatureError(
f"signed components {parsed.covered!r} differ from expected "
f"{COVERED_COMPONENTS!r}"
)
if parsed.alg != SIG_ALG:
raise ACTSignatureError(f"unexpected alg {parsed.alg!r}")
if parsed.wimse_aud != expected_audience:
raise ACTSignatureError(
f"wimse-aud {parsed.wimse_aud!r} != expected {expected_audience!r}"
)
current = int(now if now is not None else time.time())
if current - parsed.created > max_age_seconds:
raise ACTSignatureError(
f"signature too old: created={parsed.created}, now={current}"
)
expected_digest = content_digest(body)
if content_digest_header != expected_digest:
raise ACTSignatureError("content-digest does not match body")
sig_b64 = _parse_signature(signature_header)
sig = base64.b64decode(sig_b64)
base = _serialize_components(
method=method,
target_uri=target_uri,
content_digest_hdr=content_digest_header,
wimse_ect_hdr=wimse_ect_header,
params=parsed.raw_params,
)
act_verify(public_key, sig, base) # raises ACTSignatureError on bad sig
parsed.signature_b64 = sig_b64
return parsed

View File

@@ -0,0 +1,97 @@
"""Key material for the three PoC identities.
The PoC uses three ES256 (P-256) keys — the common algorithm for both
ACT and ECT per draft-nennemann-act-01 §5 and draft-nennemann-wimse-ect-01 §5.
Identities:
user — issues the ACT mandate (iss in Phase 1)
agent — subject of the mandate, signs Phase 2 record, signs ECT on
every MCP tool call (sub in ACT, iss in ECT)
mcp-server — audience / verifier (aud in both ACT and ECT)
Keys are written to ``keys/`` as PEM files on first run; subsequent runs
load them. This mimics pre-shared-key deployment per ACT §5.2 Tier 1.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey,
EllipticCurvePublicKey,
)
from act.crypto import KeyRegistry, generate_p256_keypair
IDENTITIES = ("user", "agent", "mcp-server")
@dataclass
class Identity:
name: str
kid: str
private_key: EllipticCurvePrivateKey
public_key: EllipticCurvePublicKey
def _pem_paths(keys_dir: Path, name: str) -> tuple[Path, Path]:
return keys_dir / f"{name}.priv.pem", keys_dir / f"{name}.pub.pem"
def _load_or_generate(keys_dir: Path, name: str) -> Identity:
priv_path, pub_path = _pem_paths(keys_dir, name)
if priv_path.exists() and pub_path.exists():
priv_bytes = priv_path.read_bytes()
priv = serialization.load_pem_private_key(priv_bytes, password=None)
assert isinstance(priv, EllipticCurvePrivateKey), (
f"{name}.priv.pem is not a P-256 private key"
)
pub = priv.public_key()
else:
priv, pub = generate_p256_keypair()
keys_dir.mkdir(parents=True, exist_ok=True)
priv_path.write_bytes(
priv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
)
pub_path.write_bytes(
pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
)
kid = f"kid:{name}:v1"
return Identity(name=name, kid=kid, private_key=priv, public_key=pub)
def load_identities(keys_dir: str | Path = "keys") -> dict[str, Identity]:
"""Load all three PoC identities, generating key material if missing."""
keys_dir = Path(keys_dir)
return {name: _load_or_generate(keys_dir, name) for name in IDENTITIES}
def build_key_registry(identities: dict[str, Identity]) -> KeyRegistry:
"""Assemble an ACT KeyRegistry with every identity's public key."""
reg = KeyRegistry()
for ident in identities.values():
reg.register(ident.kid, ident.public_key)
return reg
def build_ect_key_resolver(identities: dict[str, Identity]):
"""Return an ECT KeyResolver callable that maps kid → public key."""
kid_to_pub: dict[str, EllipticCurvePublicKey] = {
ident.kid: ident.public_key for ident in identities.values()
}
def _resolve(kid: str) -> EllipticCurvePublicKey | None:
return kid_to_pub.get(kid)
return _resolve

View File

@@ -0,0 +1,300 @@
"""MCP server with ACT + ECT + HTTP-signature enforcement middleware.
The server exposes two tools via FastMCP streamable-HTTP:
search(query: str) — returns a list of fake hits (str[])
summarize(text: str) — returns a short synthetic summary (str)
Every POST to /mcp goes through ``ActEctAuthMiddleware`` which:
1. parses ``Authorization: Bearer <act-mandate>`` and verifies the
Phase 1 mandate (ACT §8.1);
2. parses ``Wimse-ECT: <ect-compact>`` and verifies the ECT
(draft-nennemann-wimse-ect-01 §7);
3. verifies the RFC 9421 HTTP-signature over
@method/@target-uri/content-digest/wimse-ect with
wimse-aud=mcp-server (per draft-ietf-wimse-http-signature-03);
4. cross-checks exec_act ∈ mandate.cap[].action, mandate.sub == ECT.iss,
ECT.inp_hash == sha256(body).
On failure any check returns HTTP 401/403 before the request reaches
FastMCP. Successful audits are appended to ``AUDIT_LOG_PATH`` so the
verifier CLI can reconstruct the server's view of the DAG.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
import uvicorn
from mcp.server.fastmcp import FastMCP
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from act.crypto import ACTKeyResolver, b64url_sha256
from act.errors import ACTError, ACTSignatureError
from act.verify import ACTVerifier
import ect as ect_pkg # noqa: F401 (ensures package import)
from ect.verify import verify as ect_verify, VerifyOptions
from .http_sig import verify_request
from .keys import Identity, build_ect_key_resolver, build_key_registry, load_identities
LOG = logging.getLogger("poc.server")
AUDIT_LOG_PATH = Path(os.environ.get("POC_AUDIT_LOG", "keys/server-audit.jsonl"))
KEYS_DIR = os.environ.get("POC_KEYS_DIR", "keys")
SERVER_IDENTITY_NAME = "mcp-server"
AGENT_IDENTITY_NAME = "agent"
# ---- FastMCP tools ----------------------------------------------------------
def _build_mcp() -> FastMCP:
"""Fresh FastMCP instance with the two PoC tools registered.
A new instance per ``build_app`` call keeps ``StreamableHTTPSessionManager``
usable in tests that start the Starlette lifespan multiple times (the
session manager is a single-use object).
"""
from mcp.server.transport_security import TransportSecuritySettings
mcp = FastMCP(
"act-ect-poc",
transport_security=TransportSecuritySettings(
# Allow the test hostname ``testserver`` in addition to loopback.
allowed_hosts=[
"127.0.0.1:*", "localhost:*", "[::1]:*", "testserver",
],
allowed_origins=[
"http://127.0.0.1:*", "http://localhost:*",
"http://[::1]:*", "http://testserver",
],
),
)
@mcp.tool()
def search(query: str) -> list[str]:
"""Return three fake search hits for the query."""
q = query.strip() or "empty"
return [f"[{q}] result {i + 1}: lorem ipsum about {q}" for i in range(3)]
@mcp.tool()
def summarize(text: str) -> str:
"""Return a deterministic one-line summary of the text."""
snippet = text.strip().replace("\n", " ")
if len(snippet) > 120:
snippet = snippet[:117] + "..."
return f"Summary: {snippet}"
return mcp
# ---- Auth middleware --------------------------------------------------------
class ActEctAuthMiddleware(BaseHTTPMiddleware):
"""Enforce ACT mandate + ECT + HTTP signature on every tool invocation."""
def __init__(self, app, identities: dict[str, Identity]) -> None:
super().__init__(app)
self._identities = identities
server = identities[SERVER_IDENTITY_NAME]
agent = identities[AGENT_IDENTITY_NAME]
registry = build_key_registry(identities)
resolver = ACTKeyResolver(registry=registry)
self._act_verifier = ACTVerifier(
resolver,
verifier_id=SERVER_IDENTITY_NAME,
trusted_issuers={ident.name for ident in identities.values()},
)
self._ect_resolver = build_ect_key_resolver(identities)
self._agent_public_key = agent.public_key
self._server_name = server.name
self._agent_name = agent.name
async def dispatch(self, request: Request, call_next):
# Only enforce on the MCP endpoint; let everything else through.
if request.url.path != "/mcp" or request.method.upper() != "POST":
return await call_next(request)
try:
auth_ctx = await self._authorize(request)
except _AuthFailure as e:
LOG.warning("auth rejected: %s", e.detail)
return JSONResponse({"error": e.detail}, status_code=e.status)
# Pass the verified context downstream so a handler could read it.
request.state.act_ect = auth_ctx
response: Response = await call_next(request)
_append_audit(AUDIT_LOG_PATH, auth_ctx)
return response
async def _authorize(self, request: Request) -> dict[str, Any]:
body = await request.body()
# 1. Authorization: Bearer <act-mandate-compact>
auth_hdr = request.headers.get("authorization", "")
if not auth_hdr.lower().startswith("bearer "):
raise _AuthFailure(401, "missing Authorization: Bearer")
act_compact = auth_hdr[len("bearer ") :].strip()
# 2. Wimse-ECT: <ect-compact>
ect_compact = request.headers.get("wimse-ect", "").strip()
if not ect_compact:
raise _AuthFailure(401, "missing Wimse-ECT header")
# 3. RFC 9421 HTTP signature (over method/target/content-digest/wimse-ect)
sig_input = request.headers.get("signature-input", "")
sig = request.headers.get("signature", "")
content_digest_hdr = request.headers.get("content-digest", "")
if not (sig_input and sig and content_digest_hdr):
raise _AuthFailure(
401, "missing Signature / Signature-Input / Content-Digest"
)
# Starlette's request.url gives us a URL; canonicalize target URI.
# Use the path+query as the target since scheme/host differs behind
# reverse proxies. For the PoC we match client and server on the
# exact full URL the client signed.
target_uri = str(request.url)
try:
parsed_sig = verify_request(
method=request.method,
target_uri=target_uri,
body=body,
wimse_ect_header=ect_compact,
content_digest_header=content_digest_hdr,
signature_input_header=sig_input,
signature_header=sig,
expected_audience=self._server_name,
public_key=self._agent_public_key,
)
except ACTSignatureError as e:
raise _AuthFailure(401, f"http-signature failed: {e}") from e
# 4. ACT mandate
try:
mandate = self._act_verifier.verify_mandate(
act_compact, check_sub=False
)
except ACTError as e:
raise _AuthFailure(401, f"ACT mandate rejected: {e}") from e
if mandate.sub != self._agent_name:
raise _AuthFailure(
403, f"mandate.sub {mandate.sub!r} != agent {self._agent_name!r}"
)
# 5. ECT
try:
ect_opts = VerifyOptions(
verifier_id=self._server_name,
resolve_key=self._ect_resolver,
)
parsed_ect = ect_verify(ect_compact, ect_opts)
except Exception as e: # ECT refimpl raises ValueError subclasses
raise _AuthFailure(401, f"ECT rejected: {e}") from e
if parsed_sig.keyid != parsed_ect.header.get("kid"):
raise _AuthFailure(
401,
f"http-sig keyid {parsed_sig.keyid!r} != ect kid "
f"{parsed_ect.header.get('kid')!r}",
)
# 6. Cross-check inp_hash binds to this body
body_hash = b64url_sha256(body)
if parsed_ect.payload.inp_hash and parsed_ect.payload.inp_hash != body_hash:
raise _AuthFailure(401, "ECT.inp_hash does not match request body")
# 7. exec_act must be authorised by mandate.cap
cap_actions = {c.action for c in mandate.cap}
if parsed_ect.payload.exec_act not in cap_actions:
raise _AuthFailure(
403,
f"exec_act {parsed_ect.payload.exec_act!r} not in "
f"mandate.cap {sorted(cap_actions)!r}",
)
# 8. ECT issuer should equal mandate subject (the executing agent)
if parsed_ect.payload.iss != mandate.sub:
raise _AuthFailure(
403,
f"ECT.iss {parsed_ect.payload.iss!r} != mandate.sub "
f"{mandate.sub!r}",
)
return {
"ts": int(time.time()),
"mandate_jti": mandate.jti,
"ect_jti": parsed_ect.payload.jti,
"exec_act": parsed_ect.payload.exec_act,
"pred": list(parsed_ect.payload.pred),
"inp_hash": body_hash,
"wimse_aud": parsed_sig.wimse_aud,
"keyid": parsed_sig.keyid,
}
class _AuthFailure(Exception):
def __init__(self, status: int, detail: str) -> None:
super().__init__(detail)
self.status = status
self.detail = detail
def _append_audit(path: Path, entry: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, separators=(",", ":")) + "\n")
# ---- ASGI assembly ----------------------------------------------------------
def build_app(identities: dict[str, Identity]):
mcp = _build_mcp()
app = mcp.streamable_http_app()
app.add_middleware(ActEctAuthMiddleware, identities=identities)
return app
def main() -> None:
logging.basicConfig(
level=os.environ.get("POC_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
parser = argparse.ArgumentParser(description="ACT/ECT MCP PoC server")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--keys-dir", default=KEYS_DIR)
args = parser.parse_args()
identities = load_identities(args.keys_dir)
app = build_app(identities)
LOG.info(
"serving MCP at http://%s:%d/mcp; audit=%s",
args.host,
args.port,
AUDIT_LOG_PATH,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,197 @@
"""Mint and verify ACT mandates/records and ECT payloads for the PoC.
Thin convenience wrappers around ietf-act and ietf-ect that fix the
PoC's shape (one user issues to one agent, one MCP-server audience)
while leaving all cryptography and claim validation to the refimpls.
"""
from __future__ import annotations
import time
import uuid
from dataclasses import dataclass
from typing import Optional
from act.crypto import b64url_sha256, sign as act_sign
from act.token import (
ACTMandate,
ACTRecord,
Capability,
TaskClaim,
encode_jws,
)
import ect
from ect.create import CreateOptions, create as ect_create
from ect.types import Payload as ECTPayload
from .keys import Identity
# Capability action names the agent may exercise on the MCP server.
# mcp.session.* covers JSON-RPC plumbing (initialize, tools/list, ping, …)
# that precedes real tool invocations.
MCP_CAPS = (
"mcp.session.initialize",
"mcp.session.list_tools",
"mcp.session.other",
"mcp.search",
"mcp.summarize",
)
# Map tool-name → exec_act for real tool calls.
TOOL_ACTION = {
"search": "mcp.search",
"summarize": "mcp.summarize",
}
def exec_act_for_rpc_method(method: str, tool_name: str | None = None) -> str:
"""Return the ACT/ECT exec_act string for a JSON-RPC call.
Rules:
* ``tools/call`` dispatches to ``TOOL_ACTION[tool_name]``
* ``initialize`` and ``tools/list`` have dedicated ``mcp.session.*`` actions
* anything else collapses to ``mcp.session.other`` so the mandate can
still authorise session-level JSON-RPC without enumerating every
method on both sides.
"""
if method == "tools/call":
if tool_name is None or tool_name not in TOOL_ACTION:
raise ValueError(f"unknown tool: {tool_name!r}")
return TOOL_ACTION[tool_name]
if method == "initialize":
return "mcp.session.initialize"
if method == "tools/list":
return "mcp.session.list_tools"
return "mcp.session.other"
@dataclass
class MintedMandate:
compact: str
mandate: ACTMandate
def mint_mandate(
*,
user: Identity,
agent: Identity,
audience: str,
purpose: str,
ttl_seconds: int = 900,
now: Optional[int] = None,
) -> MintedMandate:
"""User issues a Phase 1 ACT mandate to the agent.
Reference: ACT §3.1, §4.2.
"""
iat = int(now if now is not None else time.time())
mandate = ACTMandate(
alg="ES256",
kid=user.kid,
iss=user.name,
sub=agent.name,
aud=audience,
iat=iat,
exp=iat + ttl_seconds,
jti=str(uuid.uuid4()),
wid=agent.name,
task=TaskClaim(purpose=purpose, created_by=user.name),
cap=[Capability(action=a) for a in MCP_CAPS],
)
mandate.validate()
signature = act_sign(user.private_key, mandate.signing_input())
compact = encode_jws(mandate, signature)
return MintedMandate(compact=compact, mandate=mandate)
@dataclass
class MintedRecord:
compact: str
record: ACTRecord
def mint_exec_record(
*,
agent: Identity,
mandate: ACTMandate,
exec_act: str,
pred_jtis: list[str],
inp_body: bytes,
out_body: bytes,
now: Optional[int] = None,
status: str = "completed",
) -> MintedRecord:
"""Agent mints a Phase 2 ACT execution record after a tool call.
``pred_jtis`` should include the mandate jti and any prior exec record
jtis for this run (DAG semantics, ACT §4.3 ``pred``).
Reference: ACT §3.2, §4.3.
"""
exec_ts = int(now if now is not None else time.time())
record = ACTRecord.from_mandate(
mandate,
kid=agent.kid,
exec_act=exec_act,
pred=pred_jtis,
exec_ts=exec_ts,
status=status,
inp_hash=b64url_sha256(inp_body),
out_hash=b64url_sha256(out_body),
)
# The record's jti MUST equal the mandate's jti per ACT §3.2 / §4 (the
# Phase 2 token records the same task). ``from_mandate`` already copies
# mandate.jti, so no override here.
record.validate()
signature = act_sign(agent.private_key, record.signing_input())
compact = encode_jws(record, signature)
return MintedRecord(compact=compact, record=record)
@dataclass
class MintedECT:
compact: str
payload: ECTPayload
def mint_ect(
*,
agent: Identity,
audience: str,
exec_act: str,
pred_jtis: list[str],
inp_body: bytes,
now: Optional[int] = None,
ttl_seconds: int = 300,
) -> MintedECT:
"""Agent mints an ECT binding the MCP request body to the execution.
The ECT's ``jti`` identifies this specific invocation; ``pred`` links
back to the mandate (and earlier ECT invocations, if any); the request
body is hashed into ``inp_hash``.
Reference: draft-nennemann-wimse-ect-01 §4.
"""
iat = int(now if now is not None else time.time())
payload = ECTPayload(
iss=agent.name,
aud=[audience],
iat=iat,
exp=iat + ttl_seconds,
jti=str(uuid.uuid4()),
exec_act=exec_act,
pred=pred_jtis,
wid=agent.name,
inp_hash=b64url_sha256(inp_body),
)
# ECT refimpl wants a real ES256 key and the same kid shape as ACT.
compact = ect_create(
payload,
agent.private_key,
CreateOptions(key_id=agent.kid),
)
# Reflect the defaulted iat/exp back onto our payload copy for logging.
return MintedECT(compact=compact, payload=payload)

View File

@@ -0,0 +1,188 @@
"""Replay an agent run from ledger.jsonl, verify every token, print the DAG.
Model (spec-consistent)
-----------------------
Per run:
* 1 ACT Phase 1 mandate (user → agent)
* N ECT tokens — one per outgoing MCP HTTP request. Tool-call ECTs form
a DAG via their ``pred`` field; session ECTs (initialize/tools-list)
only point at the mandate.
* 1 ACT Phase 2 record summarising the run (jti = mandate.jti per
ACT §3.2).
The verifier re-runs the ietf-act and ietf-ect refimpls on each compact
form and prints both the coarse ACT summary and the fine-grained ECT DAG.
Run ``poc-verify [--ledger keys/ledger.jsonl] [--keys-dir keys]``.
"""
from __future__ import annotations
import argparse
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from act.crypto import ACTKeyResolver
from act.errors import ACTError
from act.verify import ACTVerifier
from ect.verify import verify as ect_verify, VerifyOptions
from .keys import build_ect_key_resolver, build_key_registry, load_identities
SERVER_IDENTITY_NAME = "mcp-server"
@dataclass
class Row:
kind: str
jti: str
compact: str
metadata: dict[str, Any]
def _read_ledger(path: Path) -> list[Row]:
rows: list[Row] = []
for line in path.read_text().splitlines():
if not line.strip():
continue
obj = json.loads(line)
rows.append(
Row(
kind=obj["kind"],
jti=obj["jti"],
compact=obj["compact"],
metadata=obj.get("metadata", {}),
)
)
return rows
def _fmt_jti(jti: str) -> str:
return jti.split("-")[0]
def run(ledger_path: Path, keys_dir: Path) -> int:
identities = load_identities(keys_dir)
registry = build_key_registry(identities)
resolver = ACTKeyResolver(registry=registry)
ect_resolver = build_ect_key_resolver(identities)
trusted_issuers = {ident.name for ident in identities.values()}
verifier = ACTVerifier(
resolver,
verifier_id=SERVER_IDENTITY_NAME,
trusted_issuers=trusted_issuers,
)
rows = _read_ledger(ledger_path)
mandates = [r for r in rows if r.kind == "mandate"]
records = [r for r in rows if r.kind == "record"]
ect_rows = [r for r in rows if r.kind == "ect"]
if len(mandates) != 1:
raise SystemExit(
f"expected exactly one mandate, got {len(mandates)} in {ledger_path}"
)
if len(records) != 1:
raise SystemExit(
f"expected exactly one record, got {len(records)} in {ledger_path}"
)
try:
mandate = verifier.verify_mandate(mandates[0].compact, check_sub=False)
except ACTError as e:
raise SystemExit(f"mandate verification failed: {e}")
print(f"mandate verified jti={_fmt_jti(mandate.jti)}")
# ECT verification — includes refimpl DAG walk when we supply a store.
# We don't supply one here because ECTStore would need cross-run scoping.
# Each ECT still passes its own Section-7 verification individually.
ect_parsed: list[Any] = []
ect_sessions = 0
ect_tool_calls = 0
for row in ect_rows:
parsed = ect_verify(
row.compact,
VerifyOptions(verifier_id=SERVER_IDENTITY_NAME, resolve_key=ect_resolver),
)
ect_parsed.append(parsed)
if row.metadata.get("session_only"):
ect_sessions += 1
else:
ect_tool_calls += 1
print(
f"ects verified n={len(ect_parsed)} "
f"(tool-calls={ect_tool_calls}, session={ect_sessions})"
)
# Final ACT record — verify without the DAG store (pred=[] for our model).
try:
record = verifier.verify_record(records[0].compact, store=None)
except ACTError as e:
raise SystemExit(f"record verification failed: {e}")
if record.jti != mandate.jti:
raise SystemExit(
f"record.jti {record.jti!r} != mandate.jti {mandate.jti!r} "
"— ACT §3.2 violation"
)
print(f"record verified jti={_fmt_jti(record.jti)} status={record.status}")
# Cross-check: ECT DAG well-formedness within this run.
known_jtis = {mandate.jti} | {p.payload.jti for p in ect_parsed}
dangling = 0
for p in ect_parsed:
for pred in p.payload.pred:
if pred not in known_jtis:
dangling += 1
if dangling:
raise SystemExit(f"ECT DAG has {dangling} dangling predecessor ref(s)")
print("ect-dag wellformed every pred is the mandate or a prior ECT")
# ---- Render ------------------------------------------------------------
print()
print("Run")
print("===")
print(f" mandate {_fmt_jti(mandate.jti)} task={mandate.task.purpose!r}")
print(f" iss={mandate.iss} sub={mandate.sub} aud={mandate.aud}")
print(f" cap={[c.action for c in mandate.cap]}")
print()
print("Tool-call ECT DAG:")
tool_only = [
p
for p, row in zip(ect_parsed, ect_rows)
if not row.metadata.get("session_only")
]
if not tool_only:
print(" (none — model called no tools)")
for p in tool_only:
preds = [_fmt_jti(x) for x in p.payload.pred]
print(
f" ect {_fmt_jti(p.payload.jti)} exec_act={p.payload.exec_act} "
f"pred={preds}"
)
print()
print("ACT Phase 2 record:")
print(f" jti={_fmt_jti(record.jti)} exec_act={record.exec_act}")
print(f" status={record.status} pred={list(record.pred)}")
print(f" inp_hash={record.inp_hash}")
print(f" out_hash={record.out_hash}")
return 0
def main() -> None:
parser = argparse.ArgumentParser(description="Verify ACT+ECT PoC ledger")
parser.add_argument(
"--ledger", default=os.environ.get("POC_LEDGER", "keys/ledger.jsonl")
)
parser.add_argument("--keys-dir", default=os.environ.get("POC_KEYS_DIR", "keys"))
args = parser.parse_args()
raise SystemExit(run(Path(args.ledger), Path(args.keys_dir)))
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,17 @@
import shutil
from pathlib import Path
import pytest
@pytest.fixture
def tmp_keys_dir(tmp_path) -> Path:
d = tmp_path / "keys"
d.mkdir()
return d
@pytest.fixture
def identities(tmp_keys_dir):
from poc.keys import load_identities
return load_identities(tmp_keys_dir)

View File

@@ -0,0 +1,84 @@
"""RFC-9421-shaped HTTP signature round-trip and tamper-detection."""
from __future__ import annotations
import pytest
from act.errors import ACTSignatureError
from poc.http_sig import sign_request, verify_request
def _sign_verify_ok(identities, body: bytes):
agent = identities["agent"]
target = "http://127.0.0.1:8765/mcp"
signed = sign_request(
method="POST",
target_uri=target,
body=body,
wimse_ect="ect.placeholder.compact",
wimse_aud=identities["mcp-server"].name,
keyid=agent.kid,
private_key=agent.private_key,
)
parsed = verify_request(
method="POST",
target_uri=target,
body=body,
wimse_ect_header="ect.placeholder.compact",
content_digest_header=signed.content_digest,
signature_input_header=signed.signature_input,
signature_header=signed.signature,
expected_audience=identities["mcp-server"].name,
public_key=agent.public_key,
)
return signed, parsed
def test_signature_round_trips(identities):
signed, parsed = _sign_verify_ok(identities, body=b'{"method":"tools/call"}')
assert parsed.keyid == identities["agent"].kid
assert parsed.wimse_aud == "mcp-server"
assert parsed.alg == "ecdsa-p256-sha256"
def test_signature_fails_on_tampered_body(identities):
agent = identities["agent"]
signed, _ = _sign_verify_ok(identities, body=b"original")
with pytest.raises(ACTSignatureError):
verify_request(
method="POST",
target_uri="http://127.0.0.1:8765/mcp",
body=b"tampered", # different body → different digest → no match
wimse_ect_header="ect.placeholder.compact",
content_digest_header=signed.content_digest,
signature_input_header=signed.signature_input,
signature_header=signed.signature,
expected_audience="mcp-server",
public_key=agent.public_key,
)
def test_signature_fails_on_wrong_audience(identities):
agent = identities["agent"]
signed = sign_request(
method="POST",
target_uri="http://example/mcp",
body=b"{}",
wimse_ect="ect.placeholder",
wimse_aud="the-wrong-workload", # signed for the wrong audience
keyid=agent.kid,
private_key=agent.private_key,
)
with pytest.raises(ACTSignatureError):
verify_request(
method="POST",
target_uri="http://example/mcp",
body=b"{}",
wimse_ect_header="ect.placeholder",
content_digest_header=signed.content_digest,
signature_input_header=signed.signature_input,
signature_header=signed.signature,
expected_audience="mcp-server",
public_key=agent.public_key,
)

View File

@@ -0,0 +1,191 @@
"""In-process tests that exercise the server's auth middleware via ASGI.
Uses ``httpx.AsyncClient`` with ``ASGITransport`` so no uvicorn / network is
required. Validates that a request forged with the real token-minting
pipeline reaches the FastMCP layer, and that tampering with any of the
pieces is rejected with 4xx.
"""
from __future__ import annotations
import json
import httpx
import pytest
from poc.http_sig import sign_request
from poc.server import build_app
from poc.tokens import mint_ect, mint_mandate
pytestmark = pytest.mark.asyncio
def _headers_for(
identities,
*,
body: bytes,
audience: str,
exec_act: str,
tamper_body: bool = False,
tamper_aud: bool = False,
) -> tuple[dict[str, str], bytes]:
"""Build a full set of ACT+ECT+signature headers for one request."""
agent = identities["agent"]
user = identities["user"]
mandate = mint_mandate(
user=user, agent=agent, audience=audience, purpose="test"
)
ect = mint_ect(
agent=agent,
audience=audience,
exec_act=exec_act,
pred_jtis=[mandate.mandate.jti],
inp_body=body,
)
sign_body = b"tampered" if tamper_body else body
sign_aud = "wrong-audience" if tamper_aud else audience
signed = sign_request(
method="POST",
target_uri="http://testserver/mcp",
body=sign_body,
wimse_ect=ect.compact,
wimse_aud=sign_aud,
keyid=agent.kid,
private_key=agent.private_key,
)
headers = {
"content-type": "application/json",
"accept": "application/json, text/event-stream",
"authorization": f"Bearer {mandate.compact}",
"wimse-ect": ect.compact,
"content-digest": signed.content_digest,
"signature-input": signed.signature_input,
"signature": signed.signature,
}
return headers, body
from contextlib import asynccontextmanager
@asynccontextmanager
async def _client_for(identities):
"""Return an httpx client wired to the ASGI app with lifespan started.
FastMCP's streamable-HTTP transport allocates a task group during
``lifespan.startup``; ``ASGITransport`` does not run lifespan by
default, so we manage it explicitly here.
"""
app = build_app(identities)
async with app.router.lifespan_context(app):
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
yield client
async def test_no_auth_headers_returns_401(identities):
async with _client_for(identities) as c:
r = await c.post("/mcp", content=b'{"jsonrpc":"2.0","method":"initialize"}')
assert r.status_code == 401
assert "Authorization" in r.text
async def test_valid_initialize_request_is_accepted(identities, tmp_path, monkeypatch):
monkeypatch.setenv("POC_AUDIT_LOG", str(tmp_path / "audit.jsonl"))
body = json.dumps({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "poc-test", "version": "0"},
},
}).encode("utf-8")
headers, _ = _headers_for(
identities, body=body, audience="mcp-server",
exec_act="mcp.session.initialize",
)
async with _client_for(identities) as c:
r = await c.post("/mcp", content=body, headers=headers)
# FastMCP may respond 200 with a session id, or 202, or stream SSE.
assert r.status_code < 400, f"unexpected status {r.status_code}: {r.text}"
async def test_tampered_body_is_rejected(identities):
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
headers, _ = _headers_for(
identities, body=body, audience="mcp-server",
exec_act="mcp.session.initialize", tamper_body=True,
)
async with _client_for(identities) as c:
r = await c.post("/mcp", content=body, headers=headers)
assert r.status_code == 401
assert "content-digest" in r.text.lower() or "http-signature" in r.text.lower()
async def test_wrong_wimse_aud_is_rejected(identities):
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
headers, _ = _headers_for(
identities, body=body, audience="mcp-server",
exec_act="mcp.session.initialize", tamper_aud=True,
)
async with _client_for(identities) as c:
r = await c.post("/mcp", content=body, headers=headers)
assert r.status_code == 401
async def test_unauthorised_tool_is_rejected(identities):
"""A tools/call whose exec_act is not in mandate.cap → 403."""
body = json.dumps({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "search", "arguments": {"query": "x"}},
}).encode()
# Craft a mandate where we strip search out of cap by going through
# the token API: we fabricate headers with the right exec_act but a
# mandate whose cap doesn't contain it.
from poc.keys import Identity # noqa
from poc.tokens import MCP_CAPS, mint_mandate
agent = identities["agent"]
user = identities["user"]
# Replace MCP_CAPS monkey-patch-free: build mandate directly.
from act.token import ACTMandate, Capability, TaskClaim
from act.crypto import sign as act_sign
from act.token import encode_jws
import time, uuid
iat = int(time.time())
mandate = ACTMandate(
alg="ES256", kid=user.kid, iss="user", sub="agent",
aud="mcp-server", iat=iat, exp=iat + 600, jti=str(uuid.uuid4()),
wid="agent",
task=TaskClaim(purpose="p", created_by="user"),
cap=[Capability(action="mcp.summarize")], # search MISSING
)
mandate.validate()
mandate_compact = encode_jws(mandate, act_sign(user.private_key, mandate.signing_input()))
ect = mint_ect(
agent=agent, audience="mcp-server",
exec_act="mcp.search", pred_jtis=[mandate.jti], inp_body=body,
)
signed = sign_request(
method="POST", target_uri="http://testserver/mcp",
body=body, wimse_ect=ect.compact, wimse_aud="mcp-server",
keyid=agent.kid, private_key=agent.private_key,
)
headers = {
"content-type": "application/json",
"authorization": f"Bearer {mandate_compact}",
"wimse-ect": ect.compact,
"content-digest": signed.content_digest,
"signature-input": signed.signature_input,
"signature": signed.signature,
}
async with _client_for(identities) as c:
r = await c.post("/mcp", content=body, headers=headers)
assert r.status_code == 403
assert "exec_act" in r.text or "cap" in r.text

View File

@@ -0,0 +1,136 @@
"""Token minting + round-trip verification for all three PoC token types."""
from __future__ import annotations
import pytest
from poc.keys import build_ect_key_resolver, build_key_registry
from poc.tokens import mint_ect, mint_exec_record, mint_mandate
from act.crypto import ACTKeyResolver
from act.errors import ACTError
from act.verify import ACTVerifier
from ect.verify import verify as ect_verify, VerifyOptions
SERVER = "mcp-server"
def _act_verifier(identities) -> ACTVerifier:
reg = build_key_registry(identities)
return ACTVerifier(
ACTKeyResolver(registry=reg),
verifier_id=SERVER,
trusted_issuers={i.name for i in identities.values()},
)
def test_mandate_round_trips(identities):
m = mint_mandate(
user=identities["user"],
agent=identities["agent"],
audience=SERVER,
purpose="research task",
)
v = _act_verifier(identities).verify_mandate(m.compact, check_sub=False)
assert v.jti == m.mandate.jti
assert v.iss == "user"
assert v.sub == "agent"
assert {c.action for c in v.cap} >= {"mcp.search", "mcp.summarize"}
def test_record_preserves_mandate_jti(identities):
"""ACT §3.2: Phase 2 record carries the mandate's jti."""
m = mint_mandate(
user=identities["user"],
agent=identities["agent"],
audience=SERVER,
purpose="research task",
)
rec = mint_exec_record(
agent=identities["agent"],
mandate=m.mandate,
exec_act="mcp.search",
pred_jtis=[],
inp_body=b"input",
out_body=b"output",
)
assert rec.record.jti == m.mandate.jti
vr = _act_verifier(identities).verify_record(rec.compact)
assert vr.jti == m.mandate.jti
assert vr.exec_act == "mcp.search"
assert vr.status == "completed"
def test_record_rejects_unauthorised_exec_act(identities):
"""Verifier must raise ACTCapabilityError when exec_act ∉ cap."""
from act.errors import ACTCapabilityError
from act.token import Capability
m = mint_mandate(
user=identities["user"],
agent=identities["agent"],
audience=SERVER,
purpose="p",
)
# Narrow the mandate to only mcp.search so mcp.summarize is unauthorised.
m.mandate.cap = [Capability(action="mcp.search")]
# Build the record locally so we can bypass the local validate() guard
# and produce a compact that only the verifier can spot as malformed.
rec = mint_exec_record(
agent=identities["agent"],
mandate=m.mandate,
exec_act="mcp.search",
pred_jtis=[],
inp_body=b"i",
out_body=b"o",
)
# Swap exec_act *after* signing to simulate a forged record. The
# verifier should reject it on capability-consistency grounds (ACT §7.1).
import act.crypto as _crypto
from act.token import encode_jws
rec.record.exec_act = "mcp.summarize"
rec.record.cap = [Capability(action="mcp.search")]
tampered = encode_jws(
rec.record,
_crypto.sign(identities["agent"].private_key, rec.record.signing_input()),
)
with pytest.raises(ACTCapabilityError):
_act_verifier(identities).verify_record(tampered)
def test_ect_round_trips(identities):
et = mint_ect(
agent=identities["agent"],
audience=SERVER,
exec_act="mcp.search",
pred_jtis=["some-prior-jti"],
inp_body=b'{"query":"x"}',
)
parsed = ect_verify(
et.compact,
VerifyOptions(
verifier_id=SERVER,
resolve_key=build_ect_key_resolver(identities),
),
)
assert parsed.payload.iss == "agent"
assert parsed.payload.exec_act == "mcp.search"
assert parsed.payload.pred == ["some-prior-jti"]
assert parsed.payload.inp_hash # present
def test_wrong_audience_rejected_by_act_verifier(identities):
m = mint_mandate(
user=identities["user"],
agent=identities["agent"],
audience="some-other-workload",
purpose="p",
)
# mcp-server is not the mandate's aud → verifier MUST refuse.
verifier = _act_verifier(identities)
with pytest.raises(ACTError):
verifier.verify_mandate(m.compact, check_sub=False)