diff --git a/docker-compose.yml b/docker-compose.yml index a60f598..24ece3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,47 +1,28 @@ +# Runs the cMCP gateway against the examples/minimal bundle, with a mock +# upstream so an allowed tool call forwards end to end. +# +# The mock shares the gateway's network namespace (network_mode: service:gateway), +# so the catalog's http://localhost:9001/mcp upstream resolves to the mock without +# a separate hostname. Bring it up with: docker compose up --build services: gateway: build: . + working_dir: /etc/cmcp ports: - "8443:8443" environment: CMCP_DEV_MODE: "1" volumes: - - ./examples/config.yaml:/etc/cmcp/config.yaml:ro - - ./examples/policy:/etc/cmcp/policy:ro - - ./examples/catalog.json:/etc/cmcp/catalog.json:ro - depends_on: - - mock-mcp-server + - ./examples/minimal/cmcp-config.yaml:/etc/cmcp/cmcp-config.yaml:ro + - ./examples/minimal/policies:/etc/cmcp/policies:ro + - ./examples/minimal/catalog.json:/etc/cmcp/catalog.json:ro + command: ["cmcp", "start", "--config", "/etc/cmcp/cmcp-config.yaml"] mock-mcp-server: image: python:3.11-slim - ports: - - "8080:8080" - command: > - python -c " - import http.server, json, sys - - class Handler(http.server.BaseHTTPRequestHandler): - def log_message(self, format, *args): - pass - def do_POST(self): - length = int(self.headers.get('Content-Length', 0)) - body = self.rfile.read(length) - try: - msg = json.loads(body) - except Exception: - msg = {} - response = json.dumps({ - 'jsonrpc': '2.0', - 'id': msg.get('id'), - 'result': {'content': [{'type': 'text', 'text': 'mock response'}]} - }).encode() - self.send_response(200) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(response))) - self.end_headers() - self.wfile.write(response) - - server = http.server.HTTPServer(('0.0.0.0', 8080), Handler) - print('mock-mcp-server listening on :8080', flush=True) - server.serve_forever() - " + network_mode: "service:gateway" + volumes: + - ./scripts/mock_upstream.py:/mock_upstream.py:ro + command: ["python", "/mock_upstream.py", "--port", "9001"] + depends_on: + - gateway diff --git a/docs/quickstart.md b/docs/quickstart.md index 991865a..d8b9a61 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,5 +1,5 @@ --- -description: cMCP quickstart. From zero to your first signed TRACE Claim in under 30 minutes using CMCP_DEV_MODE=1, no hardware TEE required. Install, write a Cedar policy and tool catalog, run the gateway, and verify the claim. +description: cMCP quickstart. From zero to your first signed TRACE Claim in under 30 minutes using CMCP_DEV_MODE=1, no hardware TEE required. Install, write a Cedar policy and tool catalog, run the gateway, watch a policy block a call, then verify the signed claim. --- # Quickstart - cMCP Runtime @@ -10,13 +10,18 @@ From zero to first TRACE Claim in under 30 minutes. Uses `CMCP_DEV_MODE=1` so no ## What you'll build -You'll run a cMCP Runtime that intercepts tool calls from a demo agent, enforces a Cedar policy bundle, and produces a signed TRACE Claim at the end of the session. The demo scenario uses a mock `salesforce.contacts` tool. The TRACE Claim records which tools were called, what data classes they touched, and that the policy bundle hash matches what was measured at startup. +You'll run a cMCP Runtime that intercepts tool calls from a demo agent and enforces a Cedar policy bundle. You'll see the runtime do two things: + +1. **Block** a call to a sensitive tool (`salesforce.contacts`). The gateway returns HTTP 403 and the call never reaches any upstream. +2. **Allow** a call to a non-sensitive tool (`echo`) and forward it to a small mock upstream. + +At the end you close the session and get a signed TRACE Claim that records both calls, which policy decided each one, and the policy bundle hash measured at startup. You verify the claim without trusting the operator. --- ## Prerequisites -- Ubuntu 24.04 (or any Linux distro with Python 3.11+) +- Ubuntu 24.04 (or any Linux distro with Python 3.11+); macOS also works - Python 3.11 or newer - pip @@ -55,13 +60,14 @@ Write `cmcp-config.yaml`: ```yaml attestation: provider: auto - enforcement_mode: advisory + enforcement_mode: enforcing policy_bundle_path: ./policies/ catalog_path: ./catalog.json +audit_db_path: ./audit.db ``` -- `provider: auto` detects hardware TEE if present; falls back to software-only when `CMCP_DEV_MODE=1` -- `enforcement_mode: advisory` logs policy denies but does not hard-block calls (safe for first run; switch to `enforcing` in production) +- `provider: auto` detects a hardware TEE if present; falls back to software-only when `CMCP_DEV_MODE=1` +- `enforcement_mode: enforcing` means a policy deny returns HTTP 403 and the call is not forwarded. Use `advisory` instead if you want denies logged but not blocked while you tune a new policy. - `policy_bundle_path` is the directory containing `.cedar` policy files and `manifest.json` - `catalog_path` is the JSON file listing approved tools @@ -83,33 +89,25 @@ Write `policies/manifest.json`: Write `policies/demo.cedar`: ```cedar -// Rule 1: permit tool calls from the demo-agent workflow +// Rule 1: permit calls from the demo-agent workflow. permit ( principal, - action == cMCP::Action::"call_tool", + action, resource ) when { context.workflow_id == "demo-agent" }; -// Rule 2: deny salesforce.contacts when the session contains PII +// Rule 2: block the sensitive tool by resource name. forbid overrides permit, +// so this call is denied at the gateway and never reaches any upstream. forbid ( principal, - action == cMCP::Action::"call_tool", - resource == cMCP::Resource::"salesforce.contacts" -) when { - context.session_max_sensitivity == "pii" -}; - -// Rule 3: permit everything else -permit ( - principal, - action == cMCP::Action::"call_tool", - resource + action, + resource == Resource::"salesforce.contacts" ); ``` -Rule 1 scopes the demo agent to its workflow. Rule 2 blocks `salesforce.contacts` once PII has entered the session, preventing a data class elevation path. Rule 3 is the default allow. Cedar evaluates `forbid` before `permit`, so rule 2 takes precedence when both match. +The gateway builds the Cedar `resource` from the tool name, so `resource == Resource::"salesforce.contacts"` matches a call to that tool. Cedar evaluates `forbid` before `permit`, so rule 2 wins when both match. Rule 1 scopes everything else to the `demo-agent` workflow. Write `policies/schema.cedarschema` (one line): @@ -121,7 +119,9 @@ Write `policies/schema.cedarschema` (one line): ## Catalog -Write `catalog.json`. The `definition_hash` is the SHA-256 of the canonical JSON of `approved_definition` (sorted keys, no whitespace, ASCII-safe). For the entry below it is precomputed. +Write `catalog.json`. It lists two tools: `salesforce.contacts` (sensitive, the policy blocks it) and `echo` (non-sensitive, the policy allows it). Both point at the mock upstream you start below. + +The `definition_hash` is the SHA-256 of the canonical JSON of `approved_definition` (sorted keys, no whitespace, ASCII-safe). The values below are precomputed to match. ```json [ @@ -131,8 +131,7 @@ Write `catalog.json`. The `definition_hash` is the SHA-256 of the canonical JSON "display_name": "Salesforce Contacts MCP Server (mock)", "url": "http://localhost:9001/mcp", "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "transport": "http-sse", - "rotation_mode": "key-pinned" + "transport": "http-sse" }, "approved_definition": { "description": "Query Salesforce contacts by account name or contact ID.", @@ -158,39 +157,56 @@ Write `catalog.json`. The `definition_hash` is the SHA-256 of the canonical JSON "sensitivity_level": "pii", "added_at": "2026-06-05T00:00:00Z", "approved_by": "developer@example.com" + }, + { + "tool_name": "echo", + "server": { + "display_name": "Echo MCP Server (mock)", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Returns its input unchanged. For testing only.", + "input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}, + "output_schema": {"type": "object", "properties": {"message": {"type": "string"}}} + }, + "definition_hash": "sha256:130436578985268754fd1925a01a7a12e2f94bcfd439f4f43050f816f866e8d6", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-05T00:00:00Z", + "approved_by": "developer@example.com" } ] ``` -The `definition_hash` is computed from the exact bytes of `approved_definition` in canonical form: +The `tls_fingerprint` values above are placeholders (they only need to match the `SHA256:` format for the demo). If you change any field in an `approved_definition`, recompute its `definition_hash`; the runtime rejects catalog entries where the hash does not match: ```bash python3 -c " import json, hashlib d = { - 'description': 'Query Salesforce contacts by account name or contact ID.', - 'input_schema': { - 'type': 'object', - 'required': ['query'], - 'properties': { - 'query': {'type': 'string', 'description': 'Account name or contact ID'}, - 'max_records': {'type': 'integer', 'default': 50} - } - }, - 'output_schema': { - 'type': 'object', - 'properties': { - 'contacts': {'type': 'array'}, - 'total_count': {'type': 'integer'} - } - } + 'description': 'Returns its input unchanged. For testing only.', + 'input_schema': {'type': 'object', 'properties': {'message': {'type': 'string'}}}, + 'output_schema': {'type': 'object', 'properties': {'message': {'type': 'string'}}} } s = json.dumps(d, sort_keys=True, separators=(',', ':'), ensure_ascii=True) print('sha256:' + hashlib.sha256(s.encode()).hexdigest()) " ``` -If you change any field in `approved_definition`, rerun the command and update `definition_hash`. The runtime rejects catalog entries where the hash does not match. +--- + +## Confirm your setup + +Before starting the gateway, check that the config, policy bundle, and catalog all parse: + +```bash +cmcp validate-config --config cmcp-config.yaml +``` + +If this reports an error, fix it now. It is easier to read here than mixed into the startup logs. --- @@ -200,23 +216,26 @@ If you change any field in `approved_definition`, rerun the command and update ` CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml ``` -In dev mode the runtime uses a software-only TEE provider (no hardware required). The startup log ends with the listen address: +In dev mode the runtime uses a software-only TEE provider (no hardware required). You will see a few informational warnings before it starts listening. These are expected in dev mode and do not mean anything is broken: ``` +No hardware TEE detected. Running in development mode: attestation is not hardware-backed. ... +SPIFFE SVID not available (SPIRE agent socket not found ...) - gateway will use self-signed TLS for mTLS +CMCP_NRAS_API_KEY is not set -- skipping NRAS post-attestation appraisal. ... cMCP Runtime starting: TEE: software-only, listen: 0.0.0.0:8443 INFO: Uvicorn running on http://0.0.0.0:8443 (Press CTRL+C to quit) ``` -The gateway is now listening and blocks the terminal, so open a second terminal for the next steps. The policy-bundle and tool-catalog hashes are recorded inside the TRACE Claim you retrieve below (`trace.policy.bundle_hash` and `gateway.catalog.hash`), so there is no need to copy them from the log. +The gateway now holds this terminal open. Leave it running and open a **second terminal** for the next steps. In that second terminal, `cd` back into `cmcp-quickstart` (and re-activate your Python environment if you use one) so the commands run from the right place. --- -## Make a tool call +## Make a blocked call -In a second terminal: +In the second terminal, call the sensitive tool. The policy forbids it, so the gateway denies it before contacting any upstream: ```bash -curl -X POST http://localhost:8443/mcp \ +curl -i -X POST http://localhost:8443/mcp \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", @@ -225,21 +244,81 @@ curl -X POST http://localhost:8443/mcp \ "params": { "name": "salesforce.contacts", "arguments": {"query": "Acme Corp", "max_records": 10}, - "_cmcp": { - "session_id": "demo-session-001", - "workflow_id": "demo-agent" - } + "_cmcp": {"session_id": "demo-session-001", "workflow_id": "demo-agent"} } }' ``` -The runtime intercepts the call, evaluates the Cedar policy (rule 1 matches because `workflow_id == "demo-agent"`), records an audit entry, and forwards to the upstream mock server. +You get back `HTTP/1.1 403 Forbidden` and a JSON-RPC error: + +```json +{"jsonrpc": "2.0", "error": {"code": -32000, "message": "Request denied by policy", "data": {"error_code": "POLICY_DENY", "call_id": "..."}}, "id": 1} +``` + +This is the point of the gateway: the sensitive call was stopped at the policy boundary. No upstream needed to be running for this to work. + +--- + +## Make an allowed call + +Now the `echo` tool, which the policy permits. For an allowed call the gateway forwards to the upstream, so start a small mock upstream first. + +If you cloned the repo, run the bundled one: + +```bash +python3 scripts/mock_upstream.py --port 9001 +``` + +If you only installed the package, write a compact mock into a file and run it: + +```bash +cat > mock_upstream.py <<'PY' +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): pass + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + msg = json.loads(self.rfile.read(n) or b"{}") + body = json.dumps({"jsonrpc": "2.0", "id": msg.get("id"), + "result": {"content": [{"type": "text", "text": "mock response"}]}}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + +print("mock upstream listening on :9001", flush=True) +HTTPServer(("0.0.0.0", 9001), H).serve_forever() +PY +python3 mock_upstream.py +``` + +The mock holds its terminal open too, so run it in a **third terminal** (or background it). Then, back in the second terminal, make the allowed call: + +```bash +curl -i -X POST http://localhost:8443/mcp \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "echo", + "arguments": {"message": "hello"}, + "_cmcp": {"session_id": "demo-session-001", "workflow_id": "demo-agent"} + } + }' +``` + +You get `HTTP/1.1 200 OK` and the mock's response. The policy permitted the call (rule 1 matched on `workflow_id`), the gateway recorded an audit entry, and forwarded to the upstream. --- ## Get the TRACE Claim -The TRACE Claim is finalized and signed when the session is **closed**. Closing takes the session's internal id (a UUID) — not the `_cmcp.session_id` label (`demo-session-001`) you sent with the call. Read that id from the audit export, then close the session: +The TRACE Claim is finalized and signed when the session is **closed**. Closing takes the session's internal id (a UUID), not the `_cmcp.session_id` label (`demo-session-001`) you sent with the call. Read that id from the audit export, then close the session: ```bash # 1. Look up the session's internal id @@ -253,67 +332,14 @@ curl -s -X POST "http://localhost:8443/sessions/$SESSION_UUID/close" \ The closed session's claim stays available at `GET /sessions/$SESSION_UUID/trace-claim`. -The response is a signed `GatewayClaim`. It looks like: +The `gateway.call_summary` in `claim.json` records both calls: ```json -{ - "cmcp_version": "1.0", - "trace": { - "eat_profile": "tag:agentrust.io,2026:trace-v0.1", - "iat": 1749081600, - "subject": "spiffe://cmcp.gateway/tee/", - "runtime": { - "platform": "tpm2", - "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", - "firmware_version": "software-only-dev-mode" - }, - "policy": { - "bundle_hash": "sha256:", - "enforcement_mode": "advisory", - "version": "0.1.0" - }, - "data_class": "pii", - "tool_transcript": { - "hash": "sha256:", - "call_count": 1 - }, - "cnf": { - "jwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "", - "kid": "cmcp-" - } - } - }, - "gateway": { - "session_id": "demo-session-001", - "audit_chain": { - "root": "sha256:", - "tip": "sha256:", - "length": 1 - }, - "call_summary": { - "tool_calls_total": 1, - "tool_calls_allowed": 1, - "tool_calls_denied": 0, - "tool_calls_faulted": 0, - "tools_invoked": ["salesforce.contacts"], - "session_max_sensitivity": "pii", - "call_graph_summary": { - "compliance_domains_touched": ["pii"], - "cross_boundary_events": [] - } - }, - "catalog": { - "hash": "sha256:", - "drift_detected": false - }, - "attestation_generated_at": "2026-06-05T00:00:00Z", - "attestation_validity_seconds": 86400, - "attestation_stale": false - }, - "signature": "" +"call_summary": { + "tool_calls_total": 2, + "tool_calls_allowed": 1, + "tool_calls_denied": 1, + "tools_invoked": ["echo", "salesforce.contacts"] } ``` @@ -321,7 +347,7 @@ The response is a signed `GatewayClaim`. It looks like: ## Verify -Verify the claim with the bundled `cmcp verify` command — no code required. It checks the Ed25519 signature, schema, attestation freshness, and audit-chain consistency without trusting the runtime operator: +Verify the claim with the bundled `cmcp verify` command - no code required. It checks the Ed25519 signature, schema, attestation freshness, and audit-chain consistency without trusting the runtime operator: ```bash cmcp verify claim.json @@ -330,17 +356,17 @@ cmcp verify claim.json Expected output in dev mode: ``` -[cmcp verify] schema PASS -[cmcp verify] signature PASS -[cmcp verify] policy_bundle.hash PASS (not pinned - pass --policy-hash to pin) -[cmcp verify] tool_catalog.hash PASS (not pinned - pass --catalog-hash to pin) -[cmcp verify] attestation_freshness PASS -[cmcp verify] audit_chain PASS -[cmcp verify] hardware_attestation FAIL software-only mode - not hardware-backed +[cmcp verify] schema PASS +[cmcp verify] signature PASS +[cmcp verify] policy_bundle.hash PASS (not pinned - pass --policy-hash to pin) +[cmcp verify] tool_catalog.hash PASS (not pinned - pass --catalog-hash to pin) +[cmcp verify] attestation_freshness PASS +[cmcp verify] audit_chain PASS +[cmcp verify] hardware_attestation FAIL software-only mode - not hardware-backed [cmcp verify] RESULT: FAIL (partially_verified) ``` -`partially_verified` is expected in dev mode: every cryptographic field verifies, but there is no hardware attestation to bind them to. To pin the policy and catalog hashes, read them from the claim (`trace.policy.bundle_hash`, `gateway.catalog.hash`) and pass them explicitly: +`partially_verified` is expected in dev mode: every cryptographic field verifies, but there is no hardware attestation to bind them to. To pin the policy and catalog hashes, read them from the claim and pass them explicitly: ```bash cmcp verify claim.json \ @@ -362,7 +388,7 @@ The `cmcp_verify` Python library is also available for programmatic checks (`fro | `trace.runtime.measurement` | PCR/measurement recorded by hardware at enclave boot - all zeros in dev mode | | `trace.policy.bundle_hash` | SHA-256 of the Cedar policy bundle loaded at startup - changing any policy file changes this hash | | `trace.policy.enforcement_mode` | Whether policy denies are hard (`enforcing`) or logged-only (`advisory`) | -| `trace.data_class` | Highest sensitivity level touched in the session (`pii` in this demo) | +| `trace.data_class` | Highest sensitivity level touched in the session | | `trace.tool_transcript.hash` | SHA-256 of the audit chain tip - binds the call log to this Trust Record | | `trace.tool_transcript.call_count` | Number of tool calls in the session | | `trace.cnf.jwk` | Ed25519 public key used to sign this claim - bound to the TEE signing key | @@ -377,5 +403,5 @@ The `cmcp_verify` Python library is also available for programmatic checks (`fro - **Full financial-services scenario**: see `examples/bfsi-demo/` for a multi-tool scenario with MNPI and PHI policies, cross-boundary events, and a KYC workflow. - **Spec reference**: see `docs/SPEC.md` for the full product specification and `docs/spec/` for individual component specs. -- **Switch to enforcing mode**: set `enforcement_mode: enforcing` in `cmcp-config.yaml`. Policy denies will return HTTP 403 and the call will not be forwarded. +- **Advisory mode**: set `enforcement_mode: advisory` in `cmcp-config.yaml`. Policy denies are logged and flagged in the claim (`would_have_denied`) but the call is still forwarded - useful while tuning a new policy. - **Hardware TEE**: remove `CMCP_DEV_MODE=1` on an Azure DCasv5 (SEV-SNP) or DCedsv5 (TDX) VM. The `trace.runtime.measurement` will reflect real hardware values and verification status becomes `verified`. diff --git a/examples/bfsi-demo/catalog.json b/examples/bfsi-demo/catalog.json index bdaf3a6..3e35ffe 100644 --- a/examples/bfsi-demo/catalog.json +++ b/examples/bfsi-demo/catalog.json @@ -4,25 +4,36 @@ "server": { "display_name": "CRM MCP Server (mock)", "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:REPLACE_WITH_REAL_FINGERPRINT_FROM_openssl_s_client", - "transport": "http-sse", - "rotation_mode": "key-pinned" + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" }, "approved_definition": { "description": "Query CRM records by account ID or customer name", "input_schema": { "type": "object", - "required": ["query"], + "required": [ + "query" + ], "properties": { - "query": {"type": "string", "description": "SOQL-style query string"}, - "max_records": {"type": "integer", "default": 100} + "query": { + "type": "string", + "description": "SOQL-style query string" + }, + "max_records": { + "type": "integer", + "default": 100 + } } }, "output_schema": { "type": "object", "properties": { - "records": {"type": "array"}, - "total_count": {"type": "integer"} + "records": { + "type": "array" + }, + "total_count": { + "type": "integer" + } } } }, @@ -30,32 +41,51 @@ "requires_baa": false, "sensitivity_level": "pii", "added_at": "2026-06-04T00:00:00Z", - "approved_by": "security-team@example.com" + "approved_by": "security-team@example.com", + "definition_hash": "sha256:1314112be140a3f895b573b51788408d627e218d96d72fba2c637024b77a6a24" }, { "tool_name": "kyc.verify", "server": { "display_name": "KYC Verification MCP Server (mock)", "url": "http://localhost:9002/mcp", - "tls_fingerprint": "SHA256:REPLACE_WITH_REAL_FINGERPRINT", - "transport": "http-sse", - "rotation_mode": "key-pinned" + "tls_fingerprint": "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=", + "transport": "http-sse" }, "approved_definition": { "description": "Verify customer identity for KYC compliance. Returns PASS/FAIL with risk score.", "input_schema": { "type": "object", - "required": ["customer_id"], + "required": [ + "customer_id" + ], "properties": { - "customer_id": {"type": "string"}, - "check_level": {"type": "string", "enum": ["basic", "enhanced"]} + "customer_id": { + "type": "string" + }, + "check_level": { + "type": "string", + "enum": [ + "basic", + "enhanced" + ] + } } }, "output_schema": { "type": "object", "properties": { - "result": {"type": "string", "enum": ["PASS", "FAIL", "REVIEW"]}, - "risk_score": {"type": "number"} + "result": { + "type": "string", + "enum": [ + "PASS", + "FAIL", + "REVIEW" + ] + }, + "risk_score": { + "type": "number" + } } } }, @@ -63,6 +93,7 @@ "requires_baa": false, "sensitivity_level": "confidential", "added_at": "2026-06-04T00:00:00Z", - "approved_by": "security-team@example.com" + "approved_by": "security-team@example.com", + "definition_hash": "sha256:158652a9b5127c6567dd555bd7a3412a9eb31ffae61e33e84dc74d79f01413a1" } ] diff --git a/examples/bfsi-demo/policies/allow-approved-tools.cedar b/examples/bfsi-demo/policies/allow-approved-tools.cedar index fd0f533..fe3c05c 100644 --- a/examples/bfsi-demo/policies/allow-approved-tools.cedar +++ b/examples/bfsi-demo/policies/allow-approved-tools.cedar @@ -2,6 +2,6 @@ // (the catalog itself is the primary allowlist; this policy adds workflow scope) permit ( principal, - action == cMCP::Action::"call_tool", + action, resource ); diff --git a/examples/bfsi-demo/policies/block-mnpi-to-communication.cedar b/examples/bfsi-demo/policies/block-mnpi-to-communication.cedar index ec957c9..4795dd9 100644 --- a/examples/bfsi-demo/policies/block-mnpi-to-communication.cedar +++ b/examples/bfsi-demo/policies/block-mnpi-to-communication.cedar @@ -2,8 +2,8 @@ // communication tools (Slack, email, public channels) to prevent leakage. forbid ( principal, - action == cMCP::Action::"call_tool", - resource == cMCP::Resource::"communication.send" + action, + resource == Resource::"communication.send" ) when { context.session_max_sensitivity == "mnpi" diff --git a/examples/bfsi-demo/policies/block-phi-to-uncovered.cedar b/examples/bfsi-demo/policies/block-phi-to-uncovered.cedar index 793137c..13b0d35 100644 --- a/examples/bfsi-demo/policies/block-phi-to-uncovered.cedar +++ b/examples/bfsi-demo/policies/block-phi-to-uncovered.cedar @@ -2,10 +2,10 @@ // session has touched PHI data, prevents HIPAA cross-boundary violations. forbid ( principal, - action == cMCP::Action::"call_tool", + action, resource ) when { context.session_max_sensitivity == "hipaa_phi" && - !resource.baa_covered + !context.baa_covered }; diff --git a/examples/minimal/catalog.json b/examples/minimal/catalog.json index 206c2c8..5880c81 100644 --- a/examples/minimal/catalog.json +++ b/examples/minimal/catalog.json @@ -4,19 +4,33 @@ "server": { "display_name": "Echo MCP Server (mock)", "url": "http://localhost:9001/mcp", - "tls_fingerprint": "SHA256:REPLACE_WITH_REAL_FINGERPRINT", - "transport": "http-sse", - "rotation_mode": "key-pinned" + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" }, "approved_definition": { "description": "Returns its input unchanged. For testing only.", - "input_schema": {"type": "object", "properties": {"message": {"type": "string"}}}, - "output_schema": {"type": "object", "properties": {"message": {"type": "string"}}} + "input_schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "output_schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + } }, "compliance_domain": "public", "requires_baa": false, "sensitivity_level": "public", "added_at": "2026-06-04T00:00:00Z", - "approved_by": "developer@example.com" + "approved_by": "developer@example.com", + "definition_hash": "sha256:130436578985268754fd1925a01a7a12e2f94bcfd439f4f43050f816f866e8d6" } ] diff --git a/examples/minimal/policies/allow-all.cedar b/examples/minimal/policies/allow-all.cedar index a94a9d8..3167aff 100644 --- a/examples/minimal/policies/allow-all.cedar +++ b/examples/minimal/policies/allow-all.cedar @@ -1,6 +1,7 @@ -// Minimal policy: allow all calls to catalog tools +// Minimal policy: allow every call to a catalogued tool. +// The catalog is the primary allowlist; this permit adds nothing beyond it. permit ( principal, - action == cMCP::Action::"call_tool", + action, resource ); diff --git a/scripts/mock_upstream.py b/scripts/mock_upstream.py new file mode 100644 index 0000000..afabf56 --- /dev/null +++ b/scripts/mock_upstream.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Minimal mock MCP upstream server for the cMCP quickstart and docker-compose demo. + +Listens for JSON-RPC `tools/call` requests over HTTP POST and returns a canned +result. It exists so the quickstart's "allowed call" step has a real upstream to +forward to; it is not part of the runtime and must not be used in production. + +Usage: + python scripts/mock_upstream.py [--host HOST] [--port PORT] + +Defaults to 0.0.0.0:9001, which matches the catalog entries shipped in +examples/ and docs/quickstart.md. +""" +from __future__ import annotations + +import argparse +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + + +class MockMCPHandler(BaseHTTPRequestHandler): + def log_message(self, *_args) -> None: # silence per-request logging + pass + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) + try: + msg = json.loads(raw) + except (ValueError, TypeError): + msg = {} + + params = msg.get("params", {}) if isinstance(msg, dict) else {} + tool_name = params.get("name", "unknown") + arguments = params.get("arguments", {}) + text = f"mock upstream: {tool_name} called with {json.dumps(arguments, sort_keys=True)}" + + response = json.dumps( + { + "jsonrpc": "2.0", + "id": msg.get("id") if isinstance(msg, dict) else None, + "result": {"content": [{"type": "text", "text": text}]}, + } + ).encode() + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Mock MCP upstream for the cMCP demo") + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=9001) + args = parser.parse_args() + + server = HTTPServer((args.host, args.port), MockMCPHandler) + print(f"mock upstream listening on {args.host}:{args.port}/mcp", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + server.shutdown() + + +if __name__ == "__main__": + main()