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:
9
demo/act-ect-mcp/.gitignore
vendored
Normal file
9
demo/act-ect-mcp/.gitignore
vendored
Normal 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
159
demo/act-ect-mcp/README.md
Normal 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
55
demo/act-ect-mcp/demo.sh
Executable 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"
|
||||||
0
demo/act-ect-mcp/keys/.gitkeep
Normal file
0
demo/act-ect-mcp/keys/.gitkeep
Normal file
13
demo/act-ect-mcp/keys/ledger.jsonl
Normal file
13
demo/act-ect-mcp/keys/ledger.jsonl
Normal 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}}
|
||||||
11
demo/act-ect-mcp/keys/server-audit.jsonl
Normal file
11
demo/act-ect-mcp/keys/server-audit.jsonl
Normal 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"}
|
||||||
48
demo/act-ect-mcp/pyproject.toml
Normal file
48
demo/act-ect-mcp/pyproject.toml
Normal 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"
|
||||||
3
demo/act-ect-mcp/src/poc/__init__.py
Normal file
3
demo/act-ect-mcp/src/poc/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""ACT + ECT + MCP + LangGraph end-to-end PoC."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
447
demo/act-ect-mcp/src/poc/agent.py
Normal file
447
demo/act-ect-mcp/src/poc/agent.py
Normal 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()
|
||||||
238
demo/act-ect-mcp/src/poc/http_sig.py
Normal file
238
demo/act-ect-mcp/src/poc/http_sig.py
Normal 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
|
||||||
97
demo/act-ect-mcp/src/poc/keys.py
Normal file
97
demo/act-ect-mcp/src/poc/keys.py
Normal 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
|
||||||
300
demo/act-ect-mcp/src/poc/server.py
Normal file
300
demo/act-ect-mcp/src/poc/server.py
Normal 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()
|
||||||
197
demo/act-ect-mcp/src/poc/tokens.py
Normal file
197
demo/act-ect-mcp/src/poc/tokens.py
Normal 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)
|
||||||
188
demo/act-ect-mcp/src/poc/verify_cli.py
Normal file
188
demo/act-ect-mcp/src/poc/verify_cli.py
Normal 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()
|
||||||
0
demo/act-ect-mcp/tests/__init__.py
Normal file
0
demo/act-ect-mcp/tests/__init__.py
Normal file
17
demo/act-ect-mcp/tests/conftest.py
Normal file
17
demo/act-ect-mcp/tests/conftest.py
Normal 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)
|
||||||
84
demo/act-ect-mcp/tests/test_http_sig.py
Normal file
84
demo/act-ect-mcp/tests/test_http_sig.py
Normal 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,
|
||||||
|
)
|
||||||
191
demo/act-ect-mcp/tests/test_server.py
Normal file
191
demo/act-ect-mcp/tests/test_server.py
Normal 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
|
||||||
136
demo/act-ect-mcp/tests/test_tokens.py
Normal file
136
demo/act-ect-mcp/tests/test_tokens.py
Normal 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)
|
||||||
Reference in New Issue
Block a user