Skip to content

feat: Add filter_tools_list and custom_method_handler extension hooks#183

Open
bhbryant wants to merge 1 commit intoseuros:masterfrom
sparrow-io:feature/tools-list-filter-hook
Open

feat: Add filter_tools_list and custom_method_handler extension hooks#183
bhbryant wants to merge 1 commit intoseuros:masterfrom
sparrow-io:feature/tools-list-filter-hook

Conversation

@bhbryant
Copy link
Copy Markdown

Summary

Applications embedding ActionMCP often need to customize MCP behavior per-session or per-deployment — OAuth scope narrowing, feature flags, vendor-specific JSON-RPC methods — but today the only option is monkey-patching internals. This PR adds two opt-in extension points that let host applications customize behavior cleanly, with no changes to existing behavior when unused.

Both hooks are fully additive: no existing APIs change, no configuration defaults change, and all pre-existing tests continue to pass with identical results.

Changes

1. filter_tools_list — session-level display filter

File: lib/action_mcp/server/tools.rb

After session.registered_tools is resolved in send_tools_list, the server checks whether the session object responds to filter_tools_list(tools, params). If it does, the return value narrows which tools appear in tools/list responses.

# In your session class or via define_singleton_method:
def filter_tools_list(tools, params)
  tools.select { |t| current_scopes.include?(t.tool_name) }
end

Design decisions:

  • Display filter only. Controls what appears in tools/list responses. Does not restrict tools/call execution — clients can still invoke any registered tool by name. Authorization belongs in the tool or gateway layer, not in a listing filter.
  • Fail-open. If the filter raises a StandardError, returns nil, or returns a non-Array, the server falls back to the full unfiltered list and logs the error. A buggy filter never breaks tool discoverability.
  • Input sanitization. Returned array entries are rejected unless they are Class objects that respond to :tool_name. This prevents accidental pollution from filters that return strings, nils, or other non-tool values.

2. custom_method_handler — configuration-level callable for vendor methods

Files: lib/action_mcp/configuration.rb, lib/action_mcp/server/handlers/custom_method_routing.rb, lib/action_mcp/server/handlers/router.rb, lib/action_mcp/server/json_rpc_handler.rb

A global callable that handles vendor-specific JSON-RPC methods (e.g. myapp/toolsets/list) that fall outside the core MCP namespaces (prompts/, resources/, tools/, tasks/, completion/complete, etc.).

# config/initializers/action_mcp.rb
ActionMCP.configure do |config|
  config.custom_method_handler = ->(rpc_method, id, params, transport) {
    case rpc_method
    when "myapp/toolsets/list"
      transport.send_jsonrpc_response(id, result: { toolsets: Toolset.all.as_json })
      true
    end
    # Return falsy for unrecognized methods → triggers method_not_found
  }
end

Design decisions:

  • Truthy/falsy contract. Return truthy if the method was handled; falsy falls through to method_not_found.
  • Assignment validation. The writer rejects values that don't respond to #call (raises ArgumentError). nil is accepted to clear the handler.
  • Core namespace limitation. Only invoked for methods outside core MCP namespaces. Core methods are always handled by ActionMCP internals.
  • Shared routing module. A new Handlers::CustomMethodRouting module provides the private route_custom_method_or_raise method, included by both JsonRpcHandler and Handlers::Router. This eliminates inline duplication and ensures identical behavior across both code paths.
  • Error handling. StandardError from the handler is wrapped as a JsonRpcError(:internal_error) with a generic message. JsonRpcError raised intentionally by the handler is re-raised as-is, so applications can return specific error codes.

Test coverage

File Tests Covers
test/action_mcp/filter_tools_list_test.rb 9 No-op without hook, filters with hook, error fallback, nil/non-array fallback, sanitizes non-class entries, sanitizes non-tool classes, [nil] entries, mixed valid/invalid entries
test/action_mcp/custom_method_handler_router_test.rb 6 method_not_found without handler, method_not_found with falsy handler, successful handling, correct arguments, StandardError → internal_error, JsonRpcError passthrough
test/integration/custom_method_handler_test.rb 6 Same scenarios via full HTTP integration (end-to-end), including JsonRpcError passthrough
test/configuration_test.rb +4 Defaults to nil, accepts callable, clearable with nil, rejects non-callable

Full suite: 702 runs, 2864 assertions, 0 failures, 0 errors, 14 skips. Rubocop: 0 offenses.

Breaking changes

None. Both hooks are fully additive and opt-in:

  • filter_tools_list is only invoked if the session object responds to it.
  • custom_method_handler defaults to nil; when nil, behavior is identical to before (unknown methods raise method_not_found).
  • No existing method signatures, return types, or configuration defaults are changed.

Adds two application-level hooks for MCP customization without
monkey-patching:

1. filter_tools_list (Session hook)
   - Narrows tools/list responses via session.filter_tools_list(tools, params)
   - Display filter only — does NOT restrict tools/call execution
   - Fail-open: errors or invalid returns fall back to unfiltered list
   - Sanitizes returned array entries (rejects non-tool objects)

2. custom_method_handler (Configuration callable)
   - Handles vendor-specific JSON-RPC methods (e.g. sparrow/toolsets/list)
   - Receives (rpc_method, id, params, transport); returns truthy if handled
   - Assignment validated: must respond_to?(:call) or be nil
   - Only invoked for methods outside core MCP namespaces
   - Shared via Handlers::CustomMethodRouting module (no duplication)

Includes 40 new tests (100 assertions) across 4 test files.
@seuros
Copy link
Copy Markdown
Owner

seuros commented Feb 24, 2026

Hi

Sorry for the late answer ..

I understand the intent, but this the wrong type of workaround.

The MCP spec does have the dynamic capability since the initial spec in 2024.

I implemented it on ActionMCP with session.register_tool and unregister_tool

The tool has to exist as class.

This is the reason why ActionMCP requires a database backend, because each request could hit different server or process in production.

Kudos in using dynamic tooling instead of harding 847 tools and calling MCP are useless.

Btw Claude code, crush , opencode do support Dynamic tools, OpenAI Codex refused my PR few days ago and called it an unwanted feature request.

We can close this PR if there is nothing that i missed.

@bhbryant
Copy link
Copy Markdown
Author

Thanks for the feedback — you're absolutely right that filter_tools_list was the wrong abstraction. We should be using session.register_tool / unregister_tool to manage per-session capabilities, not filtering at query time. Appreciate the pointer.

We're refactoring to use dynamic registration now, but hit one practical gap I wanted to flag:

The Gateway auth layer doesn't have access to the session. The controller flow is:

# application_controller.rb
authenticate_gateway!          # → Gateway.new(request).call → sets Current.user
session = mcp_session          # → finds/creates session
TransportHandler.new(session)  # → processes JSON-RPC

Gateway#call receives only the request — it never sees the session. And apply_profile_from_authentication can call use_profile(:admin), but profiles are static (defined in mcp.yml at deploy time). For OAuth, the scope combinations are dynamic and per-token, so a static profile per combination isn't practical.

There's no built-in hook where we have both the authenticated identity and the session to call session.register_tool(...) / session.unregister_tool(...) based on the user's scopes.

Today our workaround is subclassing the controller to add logic between auth and handler execution, which works but feels like the kind of monkey-patching you'd rather avoid.

Would a small upstream hook be welcome here? Something like passing the session to Gateway#call or adding a post-auth callback that receives both the gateway and session:

# In Gateway subclass:
def after_authentication(session)
  scoped_tools = resolve_tools_for(user.oauth_scopes)
  session.sync_tools(scoped_tools)
end

This would keep the register_tool/unregister_tool model you've built while giving applications a clean place to wire in dynamic auth. Happy to submit a focused PR for just that if it's something you'd consider.

Separately — the PR also included a custom_method_handler hook for vendor-specific JSON-RPC methods (e.g., myapp/toolsets/list) that fall outside the core MCP namespaces. Is there an existing pattern for that, or would a separate PR for just that hook be welcome?

Either way, happy to close this PR. Thanks for the guidance on the right approach.

@crisnahine
Copy link
Copy Markdown
Contributor

hey, was reading through the gateway/session code trying to understand the tool scoping story and wanted to add some context here.

configure_session (from #193) does give you the session at auth time — it runs right after gateway.call and before the JSON-RPC handler picks up the request. so the seam @bhbryant was asking about technically exists now:

def configure_session(session)
  session.session_data = { "user_id" => user.id, "scopes" => user.oauth_scopes }
end

but it's explicitly scoped to session_data only — the GATEWAY.md docs say "avoid side effects beyond setting session_data." so calling register_tool/unregister_tool from there would go against the documented intent.

and even if you tried, there's a problem. sessions default to tool_registry = ["*"] (wildcard = all tools). if you call register_tool("some_tool") on a wildcard session, it appends to the array making it ["*", "some_tool"]. then registered_tools checks tool_registry == ["*"] which is now false, falls to the else branch, tries ToolsRegistry.find("*") which fails silently, and you go from 36 tools down to 1.

quick repro:

session = ActionMCP::Session.create!(id: SecureRandom.hex(6), protocol_version: "2025-06-18", status: "initialized")
session.registered_tools.count  # => 36
session.register_tool(session.registered_tools.first.tool_name)
session.registered_tools.count  # => 1

so the gap @bhbryant identified is still real — there's a seam but no safe API to do dynamic tool scoping from it. the wildcard-to-explicit transition isn't handled.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants