askfirst
Ask the user before an MCP tool deletes, sends or pays for anything, on every client.
MCP servers are meant to confirm destructive tool calls through elicitation, but many clients
don't support it, declare it and then auto-decline, or (on stateless and 2026-07-28 connections)
can't receive the prompt at all, so servers either skip the check or break. askfirst is one
await that shows a yes/no prompt when the client can, and otherwise falls back to a signed,
single-use confirmation token bound to the exact call. It also adds a read-only mode that
really removes write tools.
pip install askfirst
Python 3.9+, no dependencies of its own (it uses the pydantic and anyio your MCP SDK already
installs). Works with the official MCP Python SDK 1.x (FastMCP) and 2.x
(MCPServer).
Confirm a destructive tool
from mcp.server.fastmcp import FastMCP, Context # SDK 2.x: from mcp.server.mcpserver import MCPServer, Context
from mcp.types import ToolAnnotations
from askfirst import Confirmer
mcp = FastMCP("git-host")
ask = Confirmer()
@mcp.tool(annotations=ToolAnnotations(destructiveHint=True))
async def delete_branch(repo: str, branch: str, ctx: Context, confirm_token: str = "") -> str:
"""Delete a branch. Asks the user first."""
await ask(ctx, f"Delete branch {branch} in {repo}? This cannot be undone.",
action="delete_branch", args={"repo": repo, "branch": branch}, token=confirm_token)
# Only reached once the user said yes.
return f"Deleted {branch}"
What happens:
| Client | First call | Then |
|---|---|---|
| Supports elicitation | The user sees the message with a Confirm checkbox | Yes: the tool runs. No, cancel, or no answer within timeout: the model gets "The user declined ... Nothing was done." |
| No elicitation, or the prompt can't be sent | The model gets an error: "Confirmation required. This call was not run. Ask the user whether to go ahead ... call delete_branch again with exactly the same arguments plus confirm_token="..."" |
The second call with the token runs the tool |
The token is an HMAC over the action, the arguments and an optional subject, valid for 5
minutes and only once. A token issued for delete_branch("old") does not work for
delete_branch("main"), an expired or reused token gets a fresh one with the reason, and on a
client that can show prompts a token never replaces the prompt.
The errors are the SDK's own ToolError, so their text reaches the model as the tool result
(SDK 2.x hides the text of other exceptions).
Settings
Confirmer(
secret=None, # HMAC key; default random at start-up (tokens die on restart)
ttl=300, # seconds a token is valid
mode="auto", # "auto" | "elicit" (prompt or refuse) | "token" (always two calls)
timeout=None, # seconds to wait for the user's answer; no answer = no
token_param="confirm_token",
store=None, # shared used-token store for multi-process servers
)
Pass subject= (the authenticated user id or session id) so one user's token cannot be used
by another. await askfirst.confirm(ctx, ...) is the same call with default settings.
Read-only mode and tool allow/deny lists
from askfirst import restrict_from_env
# ... register all tools first ...
restrict_from_env(mcp) # MCP_READ_ONLY=true, MCP_ALLOW_TOOLS=list_*,get_*, MCP_DENY_TOOLS=delete_*
mcp.run()
Or in code: restrict(mcp, read_only=True, allow=["list_*"], deny=["delete_*"]). It returns the
removed names.
- Tools are removed from the server, so they are gone from
tools/listand calling one by name fails. - Read-only keeps only tools annotated
readOnlyHint=True. A tool with no annotations counts as a write tool, so a new tool someone forgot to annotate is hidden, not leaked. - Deny beats allow. Patterns are case-sensitive globs, and a pattern that matches no tool is an
error, so a typo can't silently leave
delete_repoexposed.
Limits
- The token fallback trusts the model to relay the question to the user. It stops the model
from running a destructive call on its own initiative or with changed arguments, but a model
that ignores the instruction could still resend the token. For hard guarantees use
mode="elicit", which refuses on clients that cannot prompt. - On SDK 2.x connections using the 2026-07-28 protocol, prompts are carried by multi-round-trip
requests (
InputRequiredResult); askfirst does not produce those yet and uses the token fallback there. Native support is planned. - A declined prompt is a no, even when the client declined it automatically (a known client bug). That is the safe side.
- The used-token store is in memory. With several workers, pass a shared
store(an object withuse(nonce, expires_at) -> bool) and a fixedsecret. restrictacts on tools registered when you call it. Call it last.- The standalone
fastmcppackage:confirmworks with itsContext;restrictdoes not (use fastmcp's own tag filters there). - Python only for now; the TypeScript SDK is next if people ask.
Why this exists
Server authors keep asking for this, and building it by hand is easy to get wrong:
- MemPalace/mempalace#1981: opt-in read-only MCP mode plus guarded writes with explicit user confirmation.
- pulsemcp/mcp-servers#384: an elicitation fallback pattern for clients without native elicitation.
- erayendes/app-store-connect-mcp#112: keep the write guard on clients without elicitation with a preview and a request-bound confirmation token.
- TojiiSamaa/netbox-mcp-rust#4: destructive tools run without confirmation when the client does not support elicitation.
- PrefectHQ/fastmcp#4929: declined and
cancelled elicitation results are truthy, so the obvious
if await ctx.elicit(...)guard lets refused actions through. - anthropics/claude-code#98256: a client that declares elicitation and auto-declines it, so servers can't rely on prompts alone.
- Read-only mode requests: strausmann/mcp-dockhand#254, signadot/community#122, microsoft/azure-skills#197.
License
MIT
Metadata
Release files for askfirst 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| askfirst-0.1.0.tar.gz | 17.2 kB | Without provenance | Details for askfirst-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| askfirst-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for askfirst-0.1.0-py3-none-any.whl |
Total release size: 31.6 kB
Release files / askfirst-0.1.0.tar.gz
| Download URL | askfirst-0.1.0.tar.gz |
|---|---|
| Size | 17.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
344b195d8537ddf1fe8470d67a7432889bc11dc722b807b4aba32a70874b90a2
|
|
BLAKE2b-256 checksum How to use checksums |
6c50f84495a52bbb4bb4a7dad11b260d9a142c3e74dd12c7863d6a9ff0ec7566
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / askfirst-0.1.0-py3-none-any.whl
| Download URL | askfirst-0.1.0-py3-none-any.whl |
|---|---|
| Size | 14.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
368a0e84b78eec01f96f24f5fcadcc3a0ca41321e0aa6579efe0ffd2cfd1e09a
|
|
BLAKE2b-256 checksum How to use checksums |
5d9b5261afea8f3c938eda3b65cbe7f92f119066d61a177eb8459a1a988386e0
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for askfirst 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.