Skip to content

tools: ToolSet.filter() — allow/deny/predicate filtering and descriptor token accounting #42

Open

Description

@amirkhan-ak-sf

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 tokens

TypeScript

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, predicate supported
  • 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_filtering asserting 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

Activity

  1. added this to the milestone on Sep 1, 2026
  2. modified the milestones: , Phase 2 — Differentiate, go beyond (0.2.0) on Sep 4, 2026
  3. amirkhan-ak-sf commented on Sep 4, 2026

    @amirkhan-ak-sf
    CollaboratorAuthor

    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.

  4. tommasobolis commented on Sep 21, 2026

    @tommasobolis
    Collaborator

    Scope note from the #462 fix (PR #468, merging into develop).

    That PR made filter() return a new ToolSet instead of mutating and returning self, 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 fresh ToolFilter from only its own allow/deny/predicate, so

    final = (tools
             .filter(allow=["get_*", "search_*"])
             .filter(deny=["get_ssn"]))

    yields a view carrying only deny={"get_ssn"} — the allow from 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:

    • Filter composition — chaining must intersect with the parent's filter rather than replace it. ToolFilter is a frozen dataclass with no combining operator today; deciding the semantics (allow-set intersection, deny-set union, predicate conjunction) is the work.
    • Glob patterns — examples 1 and 3 here use get_* / list_*, and website/content/tool-access/binding.mdx documents deny=["delete_*"], but ToolFilter.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.

  5. tommasobolis commented on Sep 21, 2026

    @tommasobolis
    Collaborator

    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 first ToolFilter with 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:toolstools/ — MCP session, ToolSet, filtering (Pillar 2)

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions