Skip to main content

request_access

request_access is the one Metatate MCP tool that writes. When authorize_use has told an agent it may not proceed, this tool turns that dead end into a durable review request for a human steward — citing the exact authorization it is asking about.

It writes workflow state only: a row in your workspace's request inbox. It never modifies policies, deployment plans, publications, or served decision rows, and it never touches your data platforms. Protocol alias: request-access.

Before you wire this upDirect link to Before you wire this up

Three things make this tool different from the other eight, and you should design for all three.

1. It advertises itself as a write. Its MCP registration is readOnlyHint: false, with destructiveHint: false and idempotentHint: true set explicitly — the MCP specification would otherwise default destructiveHint to true for any non-read-only tool.

2. Confirm with your human before calling it. Metatate performs no server-side confirmation step: there is no elicitation round-trip, no two-phase commit, and no approval token. A well-behaved MCP client uses the readOnlyHint: false annotation to prompt its user before invoking a write tool, and that client-side prompt is the only place a human sees the request before it is filed. If you are building a custom client, implement that prompt yourself. Filing a request is a visible, attributable act in someone else's inbox — treat it as one.

What Metatate does provide on the server side, instead of a confirmation dialog: a separate token scope that must be deliberately granted, a re-evaluation that refuses pointless requests, per-token idempotency that absorbs exact duplicates, and a hard cap on open requests per token.

3. It requires the request token scope. A token carries {read} by default. {read, request} is granted only by deliberate issuance or rotation in the app's Tokens tab — see Connect an agent. A token without the scope is refused with insufficient_scope before its input is even parsed, so a caller outside the lane learns nothing about parameter validity, and the refusal is byte-identical for every tool and every token.

InputDirect link to Input

FieldTypeRequiredDescription
sourceobjectYesThe durable record being cited.
source.kindstringYes"authorization" — the only value today.
source.idstring (UUID)YesThe authorization_id from a prior authorize_use answer.
notestringNoFree-text context for the steward. Trimmed at the boundary, then 1–2,000 characters.

The input is strict, and the omissions are deliberate and permanent. There is no purpose, no role, no eligibility claim, and no correlation id:

  • Identity comes only from the verified token row.
  • Purpose and scope (asset, scenario, purpose category) are derived server-side from the cited record — never from anything you send.
  • Eligibility is derived server-side from a fresh re-evaluation — never asserted by the caller.
  • The causal chain is server-derived end to end: authorization_id → the request row → the granted exception → the verified exception ids on the retry's ledger record. There are no client correlation ids anywhere in it.

A whitespace-only or over-long note is invalid_parameters at the wire boundary, before any write is attempted — never a half-completed submission.

What the server doesDirect link to What the server does

  1. Loads the cited record, pinned to your workspace and your token. A record belonging to another workspace, another token, or no record at all are byte-identical asset_not_found.
  2. Re-evaluates it against current serving state, using the cited record's canonical inputs — same asset, same scenario, same transfer context. Fresh state wins. If the estate has changed since the answer you are citing, the fresh answer is what gets stored and judged.
  3. Refuses if there is nothing to request. If the fresh answer is proceedable, you get the typed not_eligible / use_not_blocked answer and nothing is written.
  4. Derives the eligibility class in SQL from the fresh answer, per the closed matrix below.
  5. Awaits the durable write. If it fails, the tool call fails with internal_error — no "submitted" answer is ever returned without its row.

EligibilityDirect link to Eligibility

Eligibility is a fact about the fresh answer, not about your request. It tells the steward — and you — whether an exception is even a legal remedy.

Fresh answerRequestableeligibilityResolutions a steward may choose
conditional with an approval_required conditionYesexception_grantablegrant · deny · policy change
conditional with an anonymize_first conditionYesexception_grantablegrant · deny · policy change
conditional with only role_restricted / ai_restriction conditionsYesnot_waiverabledeny · policy change
review_required with reason decision_requires_reviewYesexception_grantablegrant · deny · policy change
review_required with reason conflicted_published_stateYesnot_waiverabledeny · policy change
not_enough_published_state (any reason)Yesnot_waiverabledeny · policy change
answered · denyYesnot_waiverabledeny · policy change
answered and proceedable (allow, log_only, mask_full, mask_partial, retain)Nonot_eligible / use_not_blocked

Rules that make this predictable: a mixed condition set takes the most permissive enumerated row, so an answer carrying both approval_required and role_restricted is exception_grantable. A condition kind outside the enumerated set contributes nothing to grantability — unknown never grants.

Hard denies are requestable but never grantable. The set of denies an exception may waive is empty in this version, and growing it requires a contract amendment plus a migration. There is no other path. Likewise, conflicted and insufficient-state answers can never be exception-overridden: their remedy is fixing the policy and republishing, which is exactly what the policy_change_required resolution says.

OutputDirect link to Output

Discriminated on state.

submittedDirect link to submitted

Built from the committed row, so every field is a fact about durable state.

FieldDescription
state"submitted".
request_idPass to check_request.
request_statusAlways "open".
resubmittedtrue when an exact live open duplicate absorbed this submission — see Idempotency.
eligibilityexception_grantable or not_waiverable, derived from the matrix above.
assetThe canonical db.schema.table[.column] key, from the cited record.
scenario_keyFrom the cited record, or null.
purpose_categoryThe purpose category recorded on the cited authorization, or null when it was a purposeless call.
sourceEcho of the citation.
note_recordedThe honest signal: false when your workspace's privacy switch is off and the note was dropped.
request_expires_atServer-set, 30 days after creation. Never caller-supplied.
created_atCreation timestamp.

not_eligibleDirect link to not_eligible

Nothing was written.

FieldDescription
state"not_eligible".
reason_codeuse_not_blocked — the fresh re-evaluation is proceedable, so there is nothing to request. Or open_request_limit — this token already holds the cap of 100 live open requests.
sourceEcho of the citation.
evaluated_answer_stateThe fresh re-evaluation's state.
evaluated_decisionThe fresh re-evaluation's decision, or null.

IdempotencyDirect link to Idempotency

A request's identity is (token, asset, scenario, purpose category). Submitting the same combination while a live open request already exists absorbs the submission into that request and answers resubmitted: true — you get the same request_id back, and the steward's inbox does not fill with duplicates. Because purpose is part of the key, two requests for the same asset and scenario under different purposes stay distinct requests.

A previously open request that has passed its expiry is stamped expired in the same transaction that admits its replacement, so a dead request can never hold the slot.

LimitsDirect link to Limits

LimitValue
Request lifetime30 days from creation, server-set. Enforced at read time: every surface reports a lapsed open request as expired.
Open requests per token100 live open. The 101st submission is not_eligible / open_request_limit.
Note length1–2,000 characters after trimming.
Note retentionRecorded notes are pruned after 90 days. The request rows and audit events — the durable record — are never touched by that prune.

Notes and privacyDirect link to Notes and privacy

The note is the one free-text field in this lane, and it is handled conservatively:

  • It is recorded only when your workspace has enabled the raw-text privacy switch. That switch defaults to off, and note_recorded tells you the truth either way.
  • When recorded, it goes into a protected store readable only by workspace admins and owners.
  • It is never returned by any tool. check_request does not echo it, and neither does anything else on the MCP surface.

What the steward seesDirect link to What the steward sees

Stewards work requests in the app on the Review requests tab of the Activity module, at /[tenantSlug]/activity/review-requests. It is admin/owner only — editors and viewers get neither the tab nor the route. The inbox shows an open-count badge, and each row carries the asset and scenario, the eligibility class, the status, and the requesting actor.

The detail page puts the evaluation snapshot stored at request time side by side with a fresh preview of current state, so a steward can see whether the estate has moved since the request was filed. It also shows the request history for the same asset and scenario, and — once an exception has been consumed — a "Consumed by" list of the verified retries, reconstructed from the ledger.

A steward can resolve a request three ways:

  • Grant an exception — offered only when eligibility is exception_grantable. The only input is a lifetime in days: default 30, maximum 90. The exception's scope is not steward-editable; it is copied from the request row, so it is bound to the requesting token and to that exact asset, scenario, and purpose category.
  • Deny — terminal. The agent may re-file later if published state changes.
  • Policy change required — the remedy is authoring and republishing. No exception exists or ever will for that request; the agent retries after a new publication.

Stewards can also withdraw an open request (moot or duplicate) and revoke an active exception. Both are terminal, both are admin/owner-only, and both are recorded as audit events. Agents cannot withdraw a request — there is no MCP tool for it, deliberately.

Version 1 sends no emails. The inbox badge is the notification, and agents learn outcomes by polling check_request.

See Review requests in the app for the full steward walkthrough.

Worked example: request → approval → retryDirect link to Worked example: request → approval → retry

Start from a blocked answer. On the Customer 360 sample, the CRM export is conditional:

{
"asset": { "database": "master", "schema": "public", "table": "customers" },
"use": "sync approved customer fields to the CRM",
"scenario_key": "residency.cross_border_transfer",
"operation": "export",
"destination": { "system": "SALESFORCE", "jurisdiction": "US" },
"consumer_jurisdiction": "EU"
}
{
"state": "answered",
"decision": "conditional",
"can_proceed_now": false,
"authorization_id": "6f0a1c58-2e4f-4a3a-9c5b-1f70b2a5d9c4",
"conditions": [
{ "kind": "anonymize_first", "satisfied": false, "satisfied_by_exception_id": null, "...": "..." },
{ "kind": "approval_required", "required_role": "PRIVACY_ADMIN", "satisfied": false, "satisfied_by_exception_id": null, "...": "..." }
]
}

Step 1 — ask the human, then file the request. Your client should surface the intent ("I want to file an access request for master.public.customers, citing this authorization") and get an explicit yes. Then:

{
"source": { "kind": "authorization", "id": "6f0a1c58-2e4f-4a3a-9c5b-1f70b2a5d9c4" },
"note": "Quarterly CRM sync; approved fields only, no free-text columns."
}
{
"state": "submitted",
"request_id": "b1d4c0a7-5e88-4d21-8a0e-3c6b9f2e1d55",
"request_status": "open",
"resubmitted": false,
"eligibility": "exception_grantable",
"asset": "master.public.customers",
"scenario_key": "residency.cross_border_transfer",
"purpose_category": null,
"source": { "kind": "authorization", "id": "6f0a1c58-2e4f-4a3a-9c5b-1f70b2a5d9c4" },
"note_recorded": false,
"request_expires_at": "2026-08-28T10:20:00.000Z",
"created_at": "2026-07-29T10:20:00.000Z"
}

note_recorded: false here is the honest signal that this workspace has the raw-text privacy switch off, so the note was dropped rather than stored.

Step 2 — poll for the outcome. Do not busy-loop; a request is a human review.

{ "request_id": "b1d4c0a7-5e88-4d21-8a0e-3c6b9f2e1d55" }

Once a steward has granted:

{
"request_id": "b1d4c0a7-5e88-4d21-8a0e-3c6b9f2e1d55",
"status": "resolved",
"eligibility": "exception_grantable",
"resolution_kind": "exception_granted",
"exception": {
"exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11",
"expires_at": "2026-08-28T12:00:00.000Z",
"state": "active"
},
"asset": "master.public.customers",
"scenario_key": "residency.cross_border_transfer",
"purpose_category": null,
"created_at": "2026-07-29T10:20:00.000Z",
"resolved_at": "2026-07-29T12:00:00.000Z",
"request_expires_at": "2026-08-28T10:20:00.000Z"
}

Step 3 — retry, citing the exception. Re-issue the original authorize_use call with satisfied_conditions added:

{
"asset": { "database": "master", "schema": "public", "table": "customers" },
"use": "sync approved customer fields to the CRM",
"scenario_key": "residency.cross_border_transfer",
"operation": "export",
"destination": { "system": "SALESFORCE", "jurisdiction": "US" },
"consumer_jurisdiction": "EU",
"satisfied_conditions": [
{ "kind": "approval", "exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11" }
]
}

The server verifies the exception in SQL — subject, liveness, and scope — and the answer reports what it found:

{
"state": "answered",
"decision": "conditional",
"can_proceed_now": true,
"authorization_id": "d3e6b4f1-8a72-4c19-b5a0-77c2e4a91b38",
"satisfied_conditions": [
{ "kind": "approval", "exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11", "result": "verified" }
],
"satisfied_exceptions": [
{ "exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11", "expires_at": "2026-08-28T12:00:00.000Z" }
],
"conditions": [
{ "kind": "anonymize_first", "satisfied": true, "satisfied_by_exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11", "...": "..." },
{ "kind": "approval_required", "satisfied": true, "satisfied_by_exception_id": "0b7a9e2c-6c1f-4f5f-9f2e-2a2d0c9f4a11", "...": "..." }
]
}

Gate your automation on can_proceed_now, never on the presence of an exception_id. A not_verified echo leaves the answer exactly as it was.

If the exception is revoked before or between retries, the next retry's echo is not_verified, satisfied_exceptions is empty, and can_proceed_now goes back to false. Revocation is immediate and terminal. Prior consumptions are not rewritten — the earlier ledger records keep their verified exception ids, because they are a record of what was true at the time.

ErrorsDirect link to Errors

CodeWhen
insufficient_scopeThe token lacks the request scope. Refused before input parsing; not metered.
invalid_parametersMalformed input, unknown key, non-UUID source.id, or a whitespace-only / over-long note. Not metered.
asset_not_foundThe cited authorization does not resolve for this workspace and token. Unknown, foreign-workspace, and foreign-token citations are indistinguishable.
internal_errorThe durable write failed. No request exists; retry.

Token rotationDirect link to Token rotation

Requests and exceptions are bound to a specific token id, and rotating a token mints a new one. A rotated token can no longer see its predecessor's requests (they read as not found) and cannot consume its exceptions. This is deliberate — grants name their subject explicitly, and there are no identity chains. Resolve or re-file after a rotation.