Skip to content

SEP-2817: AI Invocation Audit Context in Request _meta#2817

Open
hangum wants to merge 6 commits into
modelcontextprotocol:mainfrom
hangum:sep-ai-invocation-audit-context
Open

SEP-2817: AI Invocation Audit Context in Request _meta#2817
hangum wants to merge 6 commits into
modelcontextprotocol:mainfrom
hangum:sep-ai-invocation-audit-context

Conversation

@hangum

@hangum hangum commented May 29, 2026

Copy link
Copy Markdown

This PR adds a new Standards Track SEP (Status: Draft, seeking a sponsor): AI Invocation Audit Context in Request _meta.

It standardizes one optional reserved _meta key — io.modelcontextprotocol/aiInvocation — carrying optional, client-asserted input-audit context for AI-initiated MCP requests:

  • invocationReason — why the AI/client made this call
  • model — which model produced the invocation
  • userIntent — the user-level intent that caused the work (when safe to provide)
  • turnId — groups MCP requests from the same user turn (servers MAY echo only turnId in response _meta)

All fields are optional and explicitly not authorization evidence. The proposal is deliberately minimal; server-side decision records, stable tool-call identity, agent/session correlation, and taxonomies are left to follow-up SEPs.

Operationalizes Discussion #2704: #2704

Prior art reconciled in the SEP: SEP-2787 (tool-call attestation), SEP-414 (OTel trace context), SEP-1788 / #775 / #2758 (_meta key reservation), SEP-2643 (structured authorization denials), SEP-2061 (action security metadata), SEP-2448 / SEP-2028 (telemetry).

AI assistance disclosure

This SEP was prepared with AI assistance for structure, wording, and review. The proposal direction, implementation experience, and final technical judgment were reviewed and edited by the author.

hangum and others added 2 commits May 30, 2026 01:43
Standards Track, Draft. Defines one optional reserved _meta key
(io.modelcontextprotocol/aiInvocation) carrying client-asserted
input-audit context (invocationReason, model, userIntent, turnId) for
AI-initiated MCP requests. Operationalizes discussion modelcontextprotocol#2704.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hangum hangum changed the title SEP-0000: AI Invocation Audit Context in Request _meta SEP-2817: AI Invocation Audit Context in Request _meta May 29, 2026
@hangum hangum requested review from a team as code owners May 29, 2026 16:52
@rpelevin

Copy link
Copy Markdown

Thanks @hangum — this looks like the right SEP-0 boundary to me.

From an implementer perspective, the valuable split is clear:

  • request _meta carries client-asserted input-audit context: why this call, which model, optional user intent, and turn correlation
  • server-side decision records stay out of scope until a follow-up SEP can define stable tool-call identity, approval lifecycle, policy outcome, and decision-record semantics

The latest clarifications around per-emitted-request invocationReason, provider-canonical model.name, multi-model routing, and turnId not being ordering/idempotency/tool-call identity make the shape much easier to implement consistently.

I would keep the security boundary sharp: these fields can help explain how an invocation was produced, but they should not be treated as evidence that the invocation was authorized.

That preserves a clean path for a follow-up decision-record SEP without overloading this first input-audit layer.

@hangum

hangum commented May 29, 2026

Copy link
Copy Markdown
Author

Thanks @rpelevin — that matches the intended boundary.

I agree that the most important part is keeping this layer as client-asserted input-audit metadata only. It can explain how an invocation was produced, but it must not be treated as authorization evidence.

That should leave a clean path for a follow-up decision-record SEP to define the server-authoritative layer separately: stable tool-call identity, approval/policy outcome, and execution/decision semantics.

@vaaraio

vaaraio commented May 29, 2026

Copy link
Copy Markdown

Implementer note from the SEP-2787 side. We shipped a proxy that emits a signed execution receipt paired with 2787 attestation per tools/call, and posted proposed-shape test vectors in #2789.

The "explicitly not authorization evidence" line is the right call, and it's worth saying why. invocationReason, userIntent and turnId are client-asserted, so a host can record them but can't prove them. They describe intent, not what executed.

The other half is a server- or proxy-side signed record of what actually ran: the resolved tool, an arguments digest, a result digest, and the 2787 attestation, bound to the same turn. Neither half stands alone as evidence. Client-asserted context with no signed execution record is unverifiable. A signed execution record with no intent context is hard to interpret. Bound together they give an auditable trail from why the model made the call to what the server actually did, which is the gap your follow-up bullet on server-side decision records points at.

Concretely, the turnId echo you already allow in response _meta is a clean correlation hook. If a server, or a proxy in front of it, echoes turnId alongside a signed receipt reference, a verifier can line up the client-asserted aiInvocation block with the signed server-side record for the same turn without any new message shape.

If the receipt field layout we settled on is useful input for the stable-tool-call-identity follow-up, I can post it.

@AgentGymLeader

Copy link
Copy Markdown

+1 to @vaaraio’s framing. Using the turnId echo as a correlation hook between client-asserted aiInvocation context and a server/proxy-side signed execution receipt seems like a clean way to preserve the boundary here: intent context is not authorization evidence, but it can still help a verifier interpret the server-side record.

If the SEP-2787 receipt field layout is ready to share, I think posting it here would be useful input for the follow-up stable-tool-call-identity / decision-record discussion.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @vaaraio and @AgentGymLeader — this framing matches the intended boundary.

I agree that aiInvocation context by itself is not evidence of what executed. It explains the client/model-side intent, while a server/proxy-side signed receipt can prove what actually ran.

Using turnId echo as a correlation hook between the client-asserted input-audit context and a signed execution receipt seems like a good composition point, without expanding SEP-2817 beyond its current scope.

If you can share the SEP-2787 receipt field layout, that would be useful prior art for the follow-up stable-tool-call-identity / decision-record SEP.

@XuebinMa

Copy link
Copy Markdown

As committed in #2704, here's how SEP-2817's _meta["io.modelcontextprotocol/aiInvocation"] maps onto a non-MCP-native runtime's Guard → AuditRecord → ExecutionProof path (agent-guard), plus a working sketch. Offered as a cross-implementation data point, not a conformance claim.

The load-bearing point first: these fields enter the audit stage, never the policy stage.

agent-guard's pipeline is Check → Filter → Audit → Sandbox. Policy evaluation (Check, via an evalexpr DSL) reads only deterministic context — tool, payload, trust_level, matched rule. The aiInvocation.* fields are ingested at the boundary and flow only into the Audit stage. So "invocationReason/userIntent are not authorization evidence" isn't a doc promise here — it's enforced by which pipeline stage can see them. A policy condition cannot reference aiInvocation because it never reaches the evaluator.

Field mapping

SEP-2817 _meta.aiInvocation Pipeline stage Lands in (agent-guard) Notes
invocationReason {kind?, text} Audit (not Check) AuditEvent.details.invocationReason text carried as the stable fallback; unrecognized kind stored verbatim, never rejected
model {name, provider?, version?} Audit AuditEvent.details.model model that produced this request, distinct from host/client identity
userIntent {text?, hash?, redacted?} Audit AuditEvent.details.userIntent stored as-asserted; redacted:true w/o text recorded as "intent withheld"; never read by Check
turnId Audit + correlation first-class correlation key on AuditEvent, echoed in response _meta the missing middle granularity — finer than Context.session_id, coarser than request_id

agent-guard's existing Context { agent_id, session_id, actor, trust_level } already covers the agent/session correlation raised in the follow-up discussion; turnId slots cleanly between session_id and the per-request request_id. It's the one new correlation field this SEP motivates on the ingestion side.

Where the SEP-0 / follow-up boundary falls out naturally

The signed outcome — ExecutionProof { payload_hash, sandbox_type, exit_code, timestamp, signature } (Ed25519) — is the server-authoritative record, i.e. the follow-up SEP's territory. The input-audit _meta fields stay on the AuditEvent side. turnId is the single field that bridges the two layers: it can be carried alongside the signed ExecutionProof so a tamper-evident outcome can be joined back to the user turn that caused it — while invocationReason/model/userIntent stay strictly client-asserted input audit and never enter the signed outcome. That matches the split drawn in this PR: turnId = correlation in SEP-0; signed decision/outcome identity = follow-up.

Working sketch

Input request _meta:

{
  "io.modelcontextprotocol/aiInvocation": {
    "invocationReason": { "text": "User asked to view employee rows; employees table identified as target." },
    "model": { "name": "example-model" },
    "userIntent": { "text": "Show me 10 employees", "redacted": false },
    "turnId": "opaque-client-generated-id"
  }
}

Resulting audit record (AuditRecord::ToolCall, serde JSON, abbreviated):

{
  "type": "tool_call",
  "timestamp": "2026-05-30T00:00:00Z",
  "request_id": "req-7f3a",
  "session_id": "sess-abc",
  "turn_id": "opaque-client-generated-id",
  "agent_id": "coder-1",
  "tool": "bash",
  "payload_hash": "sha256:…",
  "decision": "allow",
  "policy_version": "0.2.0",
  "matched_rule": "read_only_select",
  "details": {
    "aiInvocation": {
      "invocationReason": { "text": "User asked to view employee rows; …" },
      "model": { "name": "example-model" },
      "userIntent": { "text": "Show me 10 employees", "redacted": false }
    }
  }
}

decision / matched_rule are computed without ever reading details.aiInvocation — the audit context rides alongside the decision, not into it. The only structural addition this maps to is promoting turnId to a first-class correlation field next to session_id; the rest lands in the existing audit details bag today.

Happy to refine this against the SEP as the schema firms up, and to run the implementation pass once it's open for review.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @XuebinMa — this is a very useful implementation mapping.

The most important part is that aiInvocation enters only the audit path and is not visible to policy evaluation. That is exactly the security boundary SEP-2817 is trying to preserve: client-asserted context can explain the invocation, but authorization must rely on deterministic server-side context.

The session_id → turnId → request_id layering is also a helpful data point. It shows why turnId belongs in SEP-2817 while stable execution proof / signed outcome identity belongs in the follow-up decision-record layer.

I will treat this as a cross-implementation reference for the PR discussion, without expanding SEP-2817’s scope.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

For another implementation data point, TadpoleDBHub / Tadpole AI CLI currently implements a similar audit shape, but with app-specific arguments and headers because MCP does not yet have a standard _meta location for this context.

Current mapping:

  • user_query: captured as the user's original turn input and persisted in the AI audit path, joined by mcp_request_id.
  • invocation_reason: generated per tool call by the AI CLI tool schema, forwarded as a tool argument, and persisted on the SQL/service audit row as the per-call reason.
  • model: Tadpole AI CLI sends X-MCP-Client-Model when available; the server stores it in the AI audit session as the model that triggered the MCP request.
  • correlation: the server creates/reuses a turn-level request_id; SQL audit rows store executed_sql_resource.request_id, while AI chat rows store ai_session_body.mcp_request_id, so the integrated audit view can join user intent -> MCP tool call -> SQL execution -> events.

For strict audit configurations, TadpoleDBHub can reject calls missing the user intent or per-call invocation reason, but these fields remain audit-completeness inputs. Authorization and approval decisions still rely on server-side user/service/DB policy and execution controls, not on client-asserted rationale.

This is the implementation pressure behind SEP-2817: the context is operationally useful, but without a standard _meta location each server has to encode it as custom tool arguments or transport headers.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

@hangum here's the layout. It's shipped in v0.42 (vaara.attestation.receipt), with v0 normative vectors and a stdlib-only independent checker in the repo, so the field names below are what actually ships and there are vectors to check them against.

Three blocks plus the signature, mirroring the SEP-2787 trust-surface layout so both envelopes verify with the same canonicalization (RFC 8785 JCS) and the same HS256/ES256/RS256 stack. A 2787 verifier needs no new crypto to check a receipt.

{
  "version": 0,
  "alg": "ES256",
  "backLink": {
    "attestationDigest": "sha256:...",   // over the full 2787 wire envelope, signature included
    "attestationNonce": "..."            // echo of issuerAsserted.nonce, for fast correlation
  },
  "receiptAsserted": {
    "iss": "...", "sub": "...", "iat": "...",
    "nonce": "...", "secretVersion": "...", "alg": "ES256"
  },
  "outcomeDerived": {
    "status": "executed | refused | errored",
    "completedAt": "...",
    "resultCommitment": { }              // optional; absent for a refused call
  },
  "signature": "..."
}

The field that carries the follow-up SEP is backLink. It pins the exact attestation instance two ways: a digest over the attestation's full canonical wire bytes with the signature included, and the nonce echo. So the outcome is cryptographically joined to the request it answers, not joined by a shared id any party could reassert.

No exp, no TTL. A receipt is a durable record, not a capability, and the verifier enforces no expiry. Same split this thread keeps drawing: the attestation can expire, the record of what ran does not.

resultCommitment reuses 2787's argument-commitment shapes (ArgsRef / ArgsProjection) unchanged, since a result commitment is the same structure, a commitment over a JSON value. Full value, projection, or hash-only all work. status is an enum, so a refused call still produces a signed record with no result commitment; a denied tool call is audit evidence too.

@XuebinMa your ExecutionProof { payload_hash, sandbox_type, exit_code, timestamp, signature } is the same category, the server-authoritative signed outcome. The one thing worth lifting into the follow-up SEP is the explicit back-link. Pinning the outcome to a specific signed request attestation, instead of letting it stand alone, is what lets a verifier prove this outcome answers that exact attested call. turnId composes on top: the backLink pins request to outcome cryptographically, turnId carries the human-turn join the nonce can't, so 2817's turnId plus the receipt's backLink give the full chain from user turn to attested request to signed outcome.

Layout, vectors, and the offline verifier (vaara receipt verify, v0.44): docs/execution-receipts.md and tests/vectors/execution_receipt_v0 in vaaraio/vaara.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks @vaaraio — this is very useful prior art for the follow-up decision-record / execution-receipt SEP.

I agree with the split: SEP-2817 should keep turnId as human/user-turn correlation for client-asserted input-audit context, while a receipt backLink can cryptographically bind the server/proxy-side outcome to the exact attested request.

That gives a clean composition:

user turn context (turnId) → attested request → signed outcome receipt

I will keep this out of SEP-2817’s normative scope, but treat the receipt layout and vectors as strong input for the follow-up server-authoritative execution/decision record work.

@AgentGymLeader

Copy link
Copy Markdown

Thanks, this is useful prior art for the follow-up layer.

The part I would preserve most carefully is the split between the two joins:

  • backLink pins a signed outcome to the exact attested request instance.
  • turnId carries the human-turn correlation that a nonce or digest should not try to replace.

That keeps SEP-2817’s boundary clean: client-asserted intent context remains input-audit metadata, while the server/proxy-side receipt proves what actually happened. I would be cautious about standardizing the full receipt envelope here, but the backLink + turnId composition seems like a good implementation data point for the decision-record follow-up.

@chopmob-cloud

This comment was marked as spam.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

Two things worth separating in the composition, since they decide whether the follow-up SEP needs one new shape or two.

An allow/deny decision and the execution outcome are the same server-authoritative record, not two stacked layers. A denied call is audit evidence, so the decision belongs as a field on the outcome rather than a slot above it. In the receipt that field is outcomeDerived.status with executed | refused | errored; a refused call still produces a signed record, just with no result commitment. If you want the pre-execution decision captured as well, it's the same envelope written before the side effect and bound to the same attestation. One shape, two timestamps, not two SEPs.

What you bind to matters more than where the decision sits. Binding a decision to SHA-256(JCS({agent_id, action_type, scope, timestamp_ms})) pins it to a description of the action that anyone holding those four fields can recompute. The receipt's backLink pins the digest over the full 2787 wire envelope with the signature included, plus the nonce echo, so it names the exact signed request instance and no party can reassert it. A pre-execution decision record wants that same binding. Otherwise the space between the declared scope and the resolved tool-and-arguments is the admission-to-execution gap the record is meant to close. Bind to the attestation instance, not to a recomputable scope hash.

On the SETTLED/REVERSED tail: settlement and reversal are transaction semantics, and most tools/call invocations don't have them. A read or a shell command either executes or it doesn't. I'd keep the normative outcome enum small (executed, refused, errored) and let domains that actually have a settlement lifecycle model it above the receipt, rather than baking a finance-shaped state machine into the general decision record.

So the composition already on the table is the one I'd keep: turnId for the human-turn join, backLink for the cryptographic request-to-outcome bind, and the allow/deny decision as a field on that signed outcome. v0.45.1 ships the rebind-safe proxy plus the offline vaara receipt verify against the v0 vectors, if checking the shape against something running is useful.

@hangum

hangum commented May 30, 2026

Copy link
Copy Markdown
Author

Thanks both — this is useful follow-up material.

My current read is that SEP-2817 should stay unchanged: turnId provides user-turn correlation for client-asserted input-audit context.

For the follow-up decision/execution-record work, I agree the general MCP shape should stay small and domain-neutral: a server-authoritative record with a request backlink and an outcome status such as executed / refused / errored. Domain-specific settlement states can layer above that where needed.

@XuebinMa

Copy link
Copy Markdown

Agreed on the back-link — that's the right critique, and it's the gap. agent-guard's ExecutionProof signs payload_hash (SHA-256 of the request payload), so today it binds the request content, not a specific signed request instance. That's enough to detect content tampering but it doesn't let a verifier prove "this outcome answers that exact attested call" — which is precisely what your backLink (digest over the full attestation wire bytes incl. signature, plus the nonce echo) provides. Content binding is recomputable; instance binding is not. For the follow-up that's the property worth making normative.

So the composition you've drawn is the one I'd build to as well: turnId (SEP-2817) for the human-turn join, backLink for the cryptographic request→outcome bind, decision-as-a-field on the signed outcome rather than a stacked layer.

One implementer data point on that last part, since agent-guard already separates the two timestamps you mention. Its pre-execution decision is GuardDecision { Allow, Deny, AskUser }, written before the side effect; the post-execution outcome (exit_code, sandbox type) is written after. A Deny produces a signed record with no execution — "refused is audit evidence," same as your outcomeDerived.status: refused. The one state worth a normative slot beyond executed | refused | errored is the human-review/deferral case (this thread's REFER): a decision record exists but the outcome is pending, not yet executed. Whether that's a fourth status or just "decision written, outcome absent" is a real modeling choice for the follow-up.

Keeping the core enum small and domain-neutral (as you and @hangum landed) is right; settlement lifecycles layer above. Happy to align ExecutionProof with an explicit request-attestation back-link when the follow-up shape firms up.

@vaaraio

vaaraio commented May 30, 2026

Copy link
Copy Markdown

@XuebinMa agreed, and REFER is the right thing to pin down before the enum freezes.

I'd keep it off the outcome status. executed | refused | errored answers "what happened when it ran." REFER answers "what was decided," and a deferral is a decision with no outcome yet, not a fourth kind of outcome. Folding it into the outcome enum forces every consumer reading status to handle a value that means "ignore the rest of this record, nothing ran."

The cleaner cut is the two-record shape from earlier in the thread: a decision record written before the side effect, an outcome record written after, both bound to the same attestation by backLink. The decision record carries the decision axis (allow / deny / refer); the outcome record carries the outcome axis (executed / errored, or absent). A denied call collapses to a single record because deny is terminal. A deferral is a signed decision record with decision: refer and no sibling outcome record yet; if the human approves later, the outcome record is written then and back-links to the same attestation. So "decision written, outcome pending" is observable as exactly that, a decision record with no outcome record, without growing the outcome enum or inventing a pending state that later has to be reconciled.

That keeps the normative outcome enum as small and domain-neutral as @hangum landed on, and gives the human-review lifecycle a home on the decision axis rather than the outcome axis. agent-guard's GuardDecision { Allow, Deny, AskUser } is already that axis; AskUser is your refer. The only addition is binding that decision record to the attestation instance the same way the outcome record is, so a verifier can prove the deferral and the eventual execution answer the same attested call.

@chopmob-cloud

This comment was marked as spam.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown

On the follow-up server-authoritative record work, two updates.

Vaara v0.48.0 ships the external time anchor for the audit chain. The chain head gets a trusted timestamp (an RFC 3161 token by default, or an eIDAS qualified timestamp where one is required), so the head's existence is provable against a clock the runtime does not control, even after a signing-key compromise. That is the post-compromise backdating defense a server-authoritative execution record needs, and it verifies offline.

I also drafted the follow-up signed-execution-record SEP this thread keeps pointing at: a decision record before the side effect, an outcome record after, paired by backLink, server-signed (issuerAsserted, a different trust surface from the client-asserted plannerDeclared claims in SEP-2787), JCS-canonical, offline-verifiable. It reuses the ExecutionReceipt fields already shipping in Vaara, so the spec has a reference implementation behind it, not just text.

Draft: https://github.com/vaaraio/vaara/blob/main/docs/sep/sep-server-execution-record.md

I can open it against the SEP process if that's useful.

@chopmob-cloud

This comment was marked as spam.

@hangum

hangum commented May 31, 2026

Copy link
Copy Markdown
Author

Thanks — this looks like useful material for a separate server-authoritative execution-record SEP.

For this PR, I would keep SEP-2817 focused on client-asserted input-audit context only. If you open the signed execution record draft separately, I’d be happy to review it there so this PR can stay scoped.

@vaaraio

vaaraio commented May 31, 2026

Copy link
Copy Markdown

Opened it as #2828, kept to the server-authoritative half so SEP-2817 stays scoped to client-asserted input audit.

It defines a decision record before the side effect (the allow/block/escalate verdict plus the risk basis) and an outcome record after (executed/refused/errored plus a result commitment), paired by backLink to the SEP-2787 attestation instance and signed by the enforcement point. It reuses the SEP-2787 canonicalization and signing stack, so a 2787 verifier needs no new cryptographic code. Reference implementation and an offline standard-library verifier are in the repo.

Thanks for the offer to review there.

@hangum

hangum commented Jun 7, 2026

Copy link
Copy Markdown
Author

I rebased/updated the branch against current main and resolved the merge conflict. All checks are passing again.

The scope remains unchanged: SEP-2817 is limited to client-asserted input-audit context in request _meta. Server-side decision records, stable invocation identity, approval lifecycle, and agent/session correlation remain follow-up SEP topics.

I would appreciate maintainer guidance on whether this is ready for sponsorship/review, or if the proposal should be adjusted before moving forward.

@localden localden added SEP draft SEP proposal with a sponsor. labels Jun 8, 2026
@localden localden added proposal SEP proposal without a sponsor. and removed draft SEP proposal with a sponsor. labels Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proposal SEP proposal without a sponsor. SEP

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

7 participants