Skip to main content

Toolsets, and the URL that picks them

A hosted MCP server serves one endpoint to everybody, and not everybody wants the same tools. A client that came for notebooks should not have to read past twenty tools it will never call, and a model given every tool a deployment can offer chooses worse than one given the tools for the job — at a cost paid on every turn.

So tools belong to named toolsets, and the URL says which ones this client wants:

URLWhat it gets
/mcpThe toolsets that are on by default
/mcp?benchmarksThe defaults and benchmarks
/mcp?toolsets=benchmarks,spacesThe same, for a client that builds query strings with values
/mcp?only=spacesOnly spaces (and any toolset declared always)
/mcp?without=sandboxesThe defaults, less one

A bare flag is the spelling worth having: it is what somebody types into an agent's configuration, and ?benchmarks says what it means without anybody reading a manual. A key with a value that is not one of the three reserved ones is read as a name too, so ?benchmarks=1 does what whoever typed it meant.

Declaring one​

from reactor_mcp_server import McpExtension, Toolset


class Benchmarks(McpExtension):
def toolsets(self) -> tuple[Toolset, ...]:
return (
Toolset(
name="benchmarks",
title="Benchmarks",
description="Read what a benchmark run did.",
instructions="Read a run's summary before its traces.",
default=False,
),
)
FieldWhat it means
nameWhat a URL says.
titleA human-readable name for a client's UI. Empty falls back to name.
descriptionWhat /toolsets and list_server_toolsets tell a client.
instructionsWhat a model should know to use these tools well — added to the server's instructions only in a session that has this toolset.
defaultOn when the URL says nothing. False makes it opt-in.
alwaysOn whatever the URL says — so only= cannot produce an endpoint nobody can use.

An extension that declares nothing gets one toolset named after its plugin, on by default. A tool that names no toolset goes in its extension's first declared one.

Instructions ride with the toolset​

MCP gives a server one place to say how it should be used: the instructions a client reads when it opens a session. A toolset's own instructions are added there only when the toolset is active, after the host's, under the toolset's title:

Be brief.

## Benchmarks

Read a run's summary before its traces.

A client that did not ask for benchmarks does not read how to use it — the same reason it does not list its tools.

A name is a subject, not a package​

Two extensions may declare the same name, and that is the point: a deployment adding its own sandbox tools declares sandboxes too, and both arrive together under one name a client can ask for.

Naming a toolset after the package that ships it splits what a client asked for. The failure is quiet and specific: ?only=sandboxes answers with every tool that needs a sandbox and no way to launch one.

The host lists a toolset once even when several extensions declare it; the first declaration is the one described.

Activation: an extension that costs nothing until asked​

A toolset that is opt-in usually belongs to an extension that should not even be registered until somebody wants it. The manifest says so:

from reactor import PluginManifest
from reactor_mcp_server import on_toolset


def manifest(self) -> PluginManifest:
return PluginManifest(
name="benchmarks",
version="1.0.0",
activation_events=[on_toolset("benchmarks")],
)

The host fires onToolset:<name> for every name a URL asked for before it reads the contributions, so a plugin held back until somebody wanted it is registered, has contributed, and is in the list that build returns.

Woken while the host runs, it still gets on_start — once, when it wakes — and an extension stood down by a deactivation event gets on_stop then, not at shutdown when it is no longer there. The reactor-mcp-server command stops the host when it exits, so every running extension gets its on_stop.

A name nobody declared​

Served anyway, and reported. A URL is written by a person configuring an agent, and a deployment that refused the whole connection over a stale toolset name would take a working client down over a typo:

{"active": ["notebooks", "spaces"], "unknown": ["benchmrks"]}

The endpoint answers that at /toolsets — see Serving — and logs it, which is also how a client finds out it is asking for something that no longer exists.

Asking from inside a session​

/toolsets answers a client that can make an HTTP request before it connects. A model already in a session cannot, and a stdio client has no route to read. list_server_toolsets is the same answer, as a tool:

host = build_host(extensions, name="my-server", toolsets_tool=True)
{
"toolsets": [
{"name": "benchmarks", "title": "Benchmarks", "description": "…", "default": false, "always": false, "active": false},
{"name": "notebooks", "title": "Notebooks", "description": "…", "default": true, "always": false, "active": true}
],
"active": ["notebooks"],
"tools": ["list_notebooks", "list_server_toolsets"],
"unknown": [],
"how_to_select": "Reconnect with the toolset in the URL: ?name adds it, ?only=a,b asks for exactly those, ?without=name leaves one out."
}

It is read-only, idempotent and closed-world, and it is on every server the host builds whatever the selection. The name says server toolsets because a deployment may already have a list_toolsets of its own — Datalayer's lists the tools a Content source lends — and an extension offering a tool named list_server_toolsets is served instead of the host's, never shadowed. It is off by default in code, so a deployment's tool list does not grow a tool it did not choose; the reactor-mcp-server command turns it on.

What the deployment decides​

Each toolset says whether it is on by default. A deployment can overrule that for a URL that names nothing — one process serving an opt-in toolset to a bare /mcp — and can keep query keys of its own from being read as names:

from reactor_mcp_server import create_mcp_app, parse_selection

app = create_mcp_app(
host,
path="/mcp",
# What `/mcp` gets. A URL that names anything is read as it says.
default_selection=parse_selection("only=earthdata"),
# `?scopes=data:read` is for the authorization server, not a toolset.
ignore_query_keys=["scopes"],
)

The reactor-mcp-server command takes the same two as --toolsets and --ignore-query-key — see Serving.

What a request is about to get​

Something beside the MCP application sometimes has to know which toolsets a request selects before any tool runs — a middleware that fetches a user's credentials only for the requests that need them. Ask the host, without building anything:

from reactor_mcp_server.toolsets import selection_from_scope

active = host.active_for(selection_from_scope(scope))
if "odoo" in active:
...

Checking whether the name is in the URL answers a different question: ?only=odoo never names it the way ?odoo does, a default toolset is on without being named, and ?without= takes one away. active_for applies the same rules build does.

Read once per connection​

MCP is a session: a client lists the tools when it opens one and works from that list. A server that changed its tools mid-session would have clients calling tools that are gone and never seeing the ones that are — so the selection is read from the URL, which is the one place a client can state what it wants before the session exists. list_server_toolsets tells a model what else there is; reaching it is a new connection with a new URL.

A worked example​

examples/mcp-toolsets ships two toolsets in one wheel — math, on by default, and geo, opt-in and not woken until asked for. See Example toolsets.