Repository navigation
tools: a typed refusal for a tool outside the consumer's MCP access profile #312
Description
What this is
A typed refusal for "this tool is not in your access profile", distinct from
"this call broke a content policy", plus the discovery-side behaviour that goes
with least-privileged MCP access.
Why it is needed
The roadmap adds "Least-Privileged MCP Access Profiles" — each consumer gets a
scoped subset of tools instead of all-or-nothing MCP access. That produces a new
class of refusal the SDK cannot currently express.
classify() today has no branch for it, so an out-of-profile tool call lands in
the generic PolicyViolation fall-through. The agent is told a governance policy
refused its call. That is technically true and operationally useless, because the
two situations demand opposite reactions:
- out of profile — a configuration problem. Nothing the agent does at
runtime fixes it. It should stop, and a human should widen the profile. - content policy — a runtime problem. Rephrasing, redacting or escalating to
a human may well succeed.
An agent that retries an out-of-profile call is burning budget on something that
can never succeed; an agent that gives up on a content refusal it could have
rephrased is failing unnecessarily. BG §1.2's whole premise is that the refusal
type drives the reaction, and this is a refusal type with no type.
Depends on
#303 — whether discovery is profile-scoped server-side, and the exact denial
shape. The typed exception cannot be written against a guessed discriminator
(§0.3). #221 (discovery itself) is also _verify.blocked.
Scope
- A typed
ToolNotInProfile(name to settle at implementation) under
PolicyViolation, carrying the tool name and, if the gateway supplies it, the
profile identity. - Remediation text that names the fix — widen the access profile in API Manager —
rather than suggesting a retry. - Discovery behaviour follows verify: the MCP access-profile denial shape #303's answer: if the gateway scopes discovery
server-side,ToolSetsimply reflects it; if not, the SDK filters after
resolution and the difference is documented rather than hidden. - A
ToolSetaccessor for what was filtered out and why, so "the agent can't see
the tool" is diagnosable without a gateway log. - Origin-agnostic. The denial is produced by the gateway, so it must
classify the same whether the server behind it is Mule-hosted (MCP Bridge, a
Mule app) or framework-built (e.g. FastMCP). A tool failure returned by the
server itself (an MCP tool result withisError: true) is not a refusal and
must not classify asToolNotInProfile.
Sketch
from donkey_kit import Donkey
from donkey_kit.core.errors import ToolNotInProfile, PolicyViolation
donkey = Donkey.from_env()
tools = await donkey.tools.discover(query="customer-360")
# Deep adapter: the same typed refusal surfaces through LangGraph's tool node.
graph = build_graph(
model=donkey.langgraph.chat_model("gpt-4o"),
tools=donkey.langgraph.tools(tools),
)
try:
await graph.ainvoke(state)
except ToolNotInProfile as exc:
log.error("not provisioned: %s (profile %s)", exc.tool_name, exc.profile)
raise # configuration problem: stop, don't retry
except PolicyViolation as exc:
return await rephrase_and_retry(exc) # runtime problem: recoverableDiagnosing the empty-toolset case:
tools.excluded # [ToolExclusion(name="refund.issue", reason="out-of-profile")]Raw client — MCP sessions come from donkey.tools, so the no-framework path
raises the same type:
async with donkey.tools.session(tools) as session:
await session.call("refund.issue", {...}) # -> ToolNotInProfileTypeScript parity (planned, Phase 5):
catch (e) {
if (e instanceof ToolNotInProfile) { /* configuration, not runtime */ }
}Acceptance
- The denial fixture from verify: the MCP access-profile denial shape #303 classifies to the typed exception, not the fall-through.
- The exception carries the tool name, and the profile when available.
- Remediation text names the configuration fix, not a retry.
- Discovery scoping documented to match whatever verify: the MCP access-profile denial shape #303 establishes.
-
ToolSetreports what was excluded and why. - The verify: the MCP access-profile denial shape #303 denial fixtures for a Mule-hosted and a framework-built server
classify to the same type. - A server-side
isErrortool result does not classify as a refusal.
Cross-surface impact
- core:
errors.py(classify()and the new type). - tools:
session.py,filter.py,ToolSet. - registry: the discovery query, if profile scoping is a parameter.
- integrations: LangGraph tool binding surfaces it; the shallow frameworks get
it through the shared transport. - TypeScript parity (planned): flag.
- Provisioning CLI: not affected.
- Nextra docs site: the tool-access section and the refusal table.
- docs/verified-apis.md: consumes the row verify: the MCP access-profile denial shape #303 adds.
Related
- verify: the MCP access-profile denial shape #303 — the blocking verification row.
- tools: donkey.tools.discover() returns governed MCP tools as native framework tools #221 — discovery. tools: allow/deny filtering and descriptor token accounting #222 — allow/deny filtering. classify: a registry so custom gateway policies become typed exceptions #210 — the classification registry.
Activity