feat: Add filter_tools_list and custom_method_handler extension hooks#183
feat: Add filter_tools_list and custom_method_handler extension hooks#183bhbryant wants to merge 1 commit intoseuros:masterfrom
Conversation
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.
|
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. |
|
Thanks for the feedback — you're absolutely right that 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
There's no built-in hook where we have both the authenticated identity and the session to call 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 # In Gateway subclass:
def after_authentication(session)
scoped_tools = resolve_tools_for(user.oauth_scopes)
session.sync_tools(scoped_tools)
endThis would keep the Separately — the PR also included a Either way, happy to close this PR. Thanks for the guidance on the right approach. |
|
hey, was reading through the gateway/session code trying to understand the tool scoping story and wanted to add some context here.
def configure_session(session)
session.session_data = { "user_id" => user.id, "scopes" => user.oauth_scopes }
endbut it's explicitly scoped to and even if you tried, there's a problem. sessions default to 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 # => 1so 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. |
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 filterFile:
lib/action_mcp/server/tools.rbAfter
session.registered_toolsis resolved insend_tools_list, the server checks whether the session object responds tofilter_tools_list(tools, params). If it does, the return value narrows which tools appear intools/listresponses.Design decisions:
tools/listresponses. Does not restricttools/callexecution — clients can still invoke any registered tool by name. Authorization belongs in the tool or gateway layer, not in a listing filter.StandardError, returnsnil, 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.Classobjects 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 methodsFiles:
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.rbA 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.).Design decisions:
method_not_found.#call(raisesArgumentError).nilis accepted to clear the handler.Handlers::CustomMethodRoutingmodule provides the privateroute_custom_method_or_raisemethod, included by bothJsonRpcHandlerandHandlers::Router. This eliminates inline duplication and ensures identical behavior across both code paths.StandardErrorfrom the handler is wrapped as aJsonRpcError(:internal_error)with a generic message.JsonRpcErrorraised intentionally by the handler is re-raised as-is, so applications can return specific error codes.Test coverage
test/action_mcp/filter_tools_list_test.rb[nil]entries, mixed valid/invalid entriestest/action_mcp/custom_method_handler_router_test.rbStandardError→ internal_error,JsonRpcErrorpassthroughtest/integration/custom_method_handler_test.rbJsonRpcErrorpassthroughtest/configuration_test.rbFull 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_listis only invoked if the session object responds to it.custom_method_handlerdefaults tonil; when nil, behavior is identical to before (unknown methods raisemethod_not_found).