audit event mcp
@kajaril/audit-event-mcp
Agent steward infrastructure — hash-chained audit log with Merkle notarisation and a GDPR/AI-Act compliance skill.
What it does
Records agent events (tool calls, decisions, human turns) to a per-client SQLite log with a SHA-256 hash chain — every record is cryptographically linked to the previous one
Optionally notarises batches with a Merkle root + Ed25519 signature (paid tier) — external auditors can verify without contacting kajaril
Exports all events for a data subject as NDJSON on demand (GDPR Art. 20 portability)
Ships a Claude Code skill that runs 8 GDPR/AI-Act axes against any event store
Related MCP server: Agent Receipts
MCP endpoint
https://audit-event.kajaril.com/mcpJSON-RPC 2.0 over HTTPS, authenticated via Cloudflare Access (service token) or an OAuth client-credentials access token — see Authentication. Contact studio@kajaril.com for onboarding.
Tools
Tool | Description |
| Write one audit event. Returns |
| Recompute |
| Filter by session, agent, event type, or date range. |
| Export all events for a |
| Ask a human to approve an action. Returns |
| Poll an approval: |
Quick start
Record an event:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "record_event",
"arguments": {
"agentId": "travel-assistant",
"eventType": "tool.call",
"purpose": "User requested flight search via travel assistant",
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"input": { "tool": "search_flights", "origin": "LHR", "dest": "JFK" },
"lawfulBasis": "contract",
"subjectId": "user-8821"
}
}
}Verify the chain:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "verify_chain", "arguments": { "limit": 100 } } }Export a data subject's records:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "export_dossier", "arguments": { "subjectId": "user-8821" } } }Authentication
Three ways in; all end at a signature-verified client_id that selects your tenant —
nothing user-supplied ever does.
Cloudflare Access service token (manual onboarding): send the token headers, CF Access injects a verified JWT. Full surface.
OAuth browser flow (MCP clients, zero copy-paste): point your client at the server and let it discover the rest —
claude mcp add --transport http audit-event https://audit-event.kajaril.com/mcpRFC 8414 metadata advertises dynamic client registration (/oauth/register),
authorization with PKCE S256 (/oauth/authorize), and the shared token endpoint. Consent
is approved in the browser by a signed-in tenant operator; the granted scope (agent or
admin) is bound into the issued token.
OAuth client-credentials (M2M): exchange a client secret for a 1-hour Bearer token at
POST /oauth/token (application/x-www-form-urlencoded, HTTP Basic or client_id /
client_secret body params):
curl -s https://audit-event.kajaril.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials -d scope=agentTwo scopes, issued and rotated independently:
Scope | May call |
|
|
| everything, plus |
Secrets are shown once at issue/rotation (POST /credentials/rotate, body {"scope":"agent"})
and stored only as hashes in your tenant's Durable Object. Rotating a scope invalidates its
old secret immediately; outstanding access tokens expire within the hour.
The approval tools are also plain REST for production backends: POST /approvals (same body
as request_approval) and GET /approvals/:id, with Authorization: Bearer <access token>.
How it works
Hash chain
Every event commits to all events before it. The chain is recomputable from first principles:
input_hash_slot = input_hash ?? input_hash_omitted_reason
chain_hash = SHA-256(id + "|" + event_type + "|" + input_hash_slot + "|" + prev_hash)When a caller supplies inputHashOmittedReason instead of input, the reason string enters the chain — the omission itself is tamper-evident.
Merkle notary (paid tier)
Every 15 minutes (or at 1,000 pending events), the notary Worker:
Collects
{ id, chain_hash }pairs from pending recordsSorts them by id and builds a SHA-256 binary Merkle tree
Signs the root with Ed25519
Writes
merkle_root+notary_sigback to each record
The notary public key is published at /.well-known/notary-pubkey (also proxied
same-origin on go.kajaril.com for the verify page). Any auditor can verify signatures
offline without contacting kajaril. The notary never receives input_hash, payload_ref,
or any payload content.
Verifying a dossier
export_dossier returns a link to a human-readable dossier on go.kajaril.com with a
raw JSONL evidence file attached. Anyone can drop that file onto
https://go.kajaril.com/verify — the browser recomputes every record's chain
fingerprint from its exported preimage (id | event_type | input_hash ?? omitted_reason | prev_hash) and checks each notary Ed25519 signature against the published key. Nothing is
uploaded; the verdict is computed client-side.
Privacy design
inputis hashed locally; the raw value is not stored unless an R2 bucket is explicitly boundinput_hashis excluded fromquery_eventsresponses; dossiers export it (withprev_hash) as the chain preimage that makes independent verification possible — they are digests, never contentpayload_refis never returned to callers — enforced at every handler boundaryAn empty
export_dossierresult (eventCount: 0) is valid GDPR evidence that no data exists for a subject
Event types
tool.call | tool.result | decision.made |
human.turn | memory.read | memory.write | error.raised |
approval.requested | approval.decidedapproval.* types are reserved: the witness records them itself when a human approval is
requested and decided. record_event refuses them — an agent must never be able to fabricate
human-decision evidence. (approval.deferred and approval.escalated are reserved for later.)
Decision webhooks
When an approval carries a callback_url, the decision (approve/deny) is POSTed to it the
moment a human decides — this is what lets an interrupted agent resume instead of poll.
Timeouts never fire a webhook; polling resolves those.
Every delivery is signed. The body is JSON:
{
"type": "approval.decided",
"approval": { "id": "…", "status": "approved", "reason": null, "responderId": "…",
"agentId": "…", "sessionId": "…", "actionSummary": "…",
"actionPayloadHash": "…", "createdAt": "…", "decidedAt": "…", "expiresAt": "…" },
"chainEvent": { "id": "…", "chainHash": "…" }
}chainEvent points at the approval.decided entry in the hash chain — cite it as evidence.
Verifying the signature. Your webhook secret (whsec_…) is returned in every
request_approval response as webhookSecret — no dashboard needed. Each delivery carries:
X-Kajaril-Signature: t=<unix seconds>,v1=<hex>To verify:
Read
tandv1from the header. Reject if|now − t|exceeds 300 seconds.Compute
HMAC-SHA256(key = the literal secret string (UTF-8, whsec_ prefix included), message = "{t}." + raw request body).Hex-encode and compare to
v1with a constant-time comparison.
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));The reference implementation is verifyWebhookSignature in src/lib/webhook.ts — the same
code path our tests run.
Self-hosting note: webhook signing derives per-tenant secrets from the WEBHOOK_SIGNING_SECRET
Workers Secret. If it is unset, webhooks are not sent at all (never unsigned) and
request_approval returns webhookSecret: null; polling still works.
Notification channels
request_approval accepts channels: ["telegram", "email"] and notifies the approver over a
ladder: instant first, email as the fallback.
Telegram (instant). Connect once — POST /channels/telegram/connect (admin scope) returns a
one-time t.me deep link (15-minute expiry). Open it, press Start, and approval cards arrive in
that chat with inline Approve / Deny / Deny with reason buttons. Deny-with-reason
prompts for a short reply that is recorded with the decision — stronger Art. 14 evidence than a
binary stamp. Connecting again replaces the bound chat.
Email (fallback). Set the approver address once — POST /channels/email (admin scope) with
{"address": "…"}. The email fires only when it is needed:
immediately, if no instant channel delivered (not connected, send failed, or not requested);
after 10 minutes, if an instant card was delivered but the approval is still undecided;
never, if the approval would expire before the email could matter.
Mail is sent from approvals@send.kajaril.com with the decide link; replies reach a human via
studio@kajaril.com.
Dispatch report. Every request_approval response includes what actually happened:
"notifications": { "telegram": "sent", "email": "scheduled" }(telegram: sent | failed | not_connected | unconfigured | not_requested; email:
immediate | scheduled | expires_first | no_address | unconfigured | arm_failed | not_requested.)
TTL defaults follow the channels: 30 minutes when an instant channel is in play, 4 hours when
the request is email-only, explicit ttlSeconds always wins.
Self-hosting note: channels are optional. Without TELEGRAM_BOT_TOKEN / RESEND_API_KEY /
CHANNELS_KV, dispatch reports unconfigured and the approval still works through
approvalUrl + polling. The bindings are documented in wrangler.jsonc and wrangler.go.jsonc.
Lawful basis (GDPR Art. 6)
legitimate_interest | contract | legal_obligation |
vital_interest | public_task | consentProvide lawfulBasis for any event that processes personal data. Omit it for events that do not.
Tiers
Capability | Free | Paid |
Hash-chained event writes | yes | yes |
| yes | yes |
| yes | yes |
R2 payload storage | optional | optional |
Merkle root + Ed25519 notarisation | — | yes |
compliance-audit axis 6 (notary coverage) | skipped | evaluated |
compliance-audit skill
skills/compliance-audit/SKILL.md is a Claude Code skill that runs 8 GDPR/AI-Act compliance axes against any event store. Copy it into your workspace:
cp -r node_modules/@kajaril/audit-event-mcp/skills/compliance-audit .claude/skills/Axes:
Lawful basis present — GDPR Art. 5–6
Purpose specificity — GDPR Art. 5(1)(b)
Subject linkage on
human.turneventsRetention bounded (≤ 730 days)
Chain integrity — 10% sample recompute
Notary coverage ≥ 95% (paid tier only)
High-risk event completeness — AI Act Art. 12–13
Data minimisation signal —
payload_reffraction
Each axis produces pass | fail | warn with an evidence snippet. Output is JSON + a markdown summary block.
Self-hosted deployment
Prerequisites
Cloudflare account with Workers and Durable Objects enabled
wranglerCLI authenticated
Steps
# 1. Create R2 bucket
wrangler r2 bucket create audit-payloads
# 2. Set notary signing key (paid tier)
wrangler secret put NOTARY_PRIVATE_KEY --config wrangler.notary.jsonc
# Value: hex-encoded Ed25519 private key — never committed to source
# 3. Deploy Workers
wrangler deploy
wrangler deploy --config wrangler.notary.jsonc
# 4. Add audit-event.kajaril.com as a CF Access Self-hosted Application
# Issue one service token per client with custom.client_id claim
# 5. Onboard a client
npx tsx src/scripts/onboard-client.ts --client-id acme-crm --tier free --region eu
# 6. Verify
curl https://audit-event.kajaril.com/healthNever run wrangler deploy from a dirty working tree. The deployed code must match git HEAD.
Development
npm install
npm test # 81 tests (node:sqlite adapter, no cloudflare/vitest-pool-workers)
npm run typecheck
npm run lintLicense
MIT. Copyright © 2026 Kajaril Ltd.
This server cannot be installed
Maintenance
Latest Blog Posts
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/mightbesaad/audit-event-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server