Repository navigation
tools: ToolSet.filter() — allow/deny/predicate filtering and descriptor token accounting #42
Copy link
Copy link
Open
Labels
area:toolstools/ — MCP session, ToolSet, filtering (Pillar 2)tools/ — MCP session, ToolSet, filtering (Pillar 2)
Description
Summary
"Enterprise MCP servers can expose dozens of tools; handing 60 tool descriptors
to a model degrades it and inflates token cost. Make filtering prominent in the
docs, and log the descriptor token count at debug level." — §4.3
Filtering is a model-quality feature, not just a security one. That framing
should drive where it appears in the docs: prominently, not in a reference
appendix.
Current state — built
ToolSet.filter() plus ToolFilter.accepts() in python/src/donkey_kit/tools/filter.py,
unit-tested with no network. The token-count logging is not wired yet.
Python
from donkey_kit import Donkey
async with Donkey.from_env() as donkey:
tools = await donkey.tools.discover(domain="hr") # say, 47 tools
# 1. Allow-list — the safest default for a production agent.
read_only = tools.filter(allow=["get_employee", "search_employees",
"get_leave_balance"])
# 2. Deny-list — keep the catalog, drop the dangerous parts.
safe = tools.filter(deny=["delete_employee", "approve_leave"])
# 3. Glob patterns.
getters = tools.filter(allow=["get_*", "list_*"])
# 4. Arbitrary predicate, e.g. exclude poorly documented tools before they
# confuse the model.
documented = tools.filter(
predicate=lambda t: t.description and len(t.description) > 40
)
# 5. Chainable, since each call returns a ToolSet.
final = (tools
.filter(allow=["get_*", "search_*"])
.filter(deny=["get_ssn"])
.filter(predicate=lambda t: "internal" not in (t.tags or [])))
print(f"{len(tools)} -> {len(final)} tools, "
f"{final.descriptor_tokens()} descriptor tokens")
# 47 -> 6 tools, 812 descriptor tokensTypeScript
const tools = await donkey.tools.discover({ domain: "hr" });
const final = tools
.filter({ allow: ["get_*", "search_*"] })
.filter({ deny: ["get_ssn"] })
.filter({ predicate: (t) => (t.description?.length ?? 0) > 40 });
console.log(`${tools.length} -> ${final.length}, ${final.descriptorTokens()} tokens`);Acceptance criteria
-
allow,deny,predicatesupported - Glob patterns in
allow/deny - Pure logic, unit-tested with no network
- Returns a
ToolSet, so calls chain -
descriptor_tokens()estimating what the descriptors cost the prompt - Debug log of the count on every bind, and a warning above a threshold
- An allow-list entry matching nothing raises or warns — a typo'd tool
name silently yielding an empty set is a bad failure - Filtering documented prominently as a model-quality feature
- Conformance
tool_filteringasserting a denied tool is absent from the
descriptors the model receives, not merely uncallable - TypeScript port
References
python/src/donkey_kit/tools/filter.py,tools/session.py- Build plan §4.3
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
area:toolstools/ — MCP session, ToolSet, filtering (Pillar 2)tools/ — MCP session, ToolSet, filtering (Pillar 2)
Activity
amirkhan-ak-sf commentedon Sep 4, 2026
Scope change from the phase-based backlog rebuild — the body below predates it and needs rewriting.
ToolSet.filter() survives as the filtering half of 2.7 MCP discovery. Rewrite against the new discovery API rather than the old registry.
tommasobolis commentedon Sep 21, 2026
Scope note from the #462 fix (PR #468, merging into
develop).That PR made
filter()return a newToolSetinstead of mutating and returningself, so the identity half of "Chainable, since each call returns a ToolSet" (example 5 in this issue) is now true. The composition half is not: each call builds a freshToolFilterfrom only its ownallow/deny/predicate, soyields a view carrying only
deny={"get_ssn"}— theallowfrom the first call is discarded. Not a regression (the old in-place version had the same net effect), but independent views make chaining read as though it narrows, so the gap is now more visible.Two pieces of this issue remain open, and they are related:
ToolFilteris a frozen dataclass with no combining operator today; deciding the semantics (allow-set intersection, deny-set union, predicate conjunction) is the work.get_*/list_*, andwebsite/content/tool-access/binding.mdxdocumentsdeny=["delete_*"], butToolFilter.accepts()does exact set membership, so a pattern never matches. The docs currently promise a behaviour the code does not have.Both stay in Phase 2 with this issue; recording them here so the #462 fix isn't read as closing them.
tommasobolis commentedon Sep 21, 2026
PR #468 fixes the identity/isolation half of
ToolSet.filter(): each call returns a distinct view without mutating the parent, and each view owns its server-list container.The remaining chainability acceptance criterion still needs composition semantics in this issue. Today
tools.filter(allow=[...]).filter(deny=[...])replaces the firstToolFilterwith the second rather than narrowing through both. That behavior predates #468; the PR deliberately does not expand the Phase 1 bug fix into the Phase 2 composition design.