Agent discovery

Agent discovery extension: listing, scoped tools, per-caller filtering, step-up and roster changes. Experimental.

Agent discovery: listing, scoping, per-caller filtering and step-up

⚠ Experimental. Implements the agent discovery extension proposed in agents-wg#22, which is still a draft. The wire shape may change before it merges.

An MCP server can group its tools into agents. Each agent has a name, a short description, some instructions and a list of the server’s own tools. A host asks for the roster with agents/list, opens the agent it wants with agents/get, and then runs that agent itself, with its own model, calling the agent’s tools through the ordinary tools/call. There is no “invoke agent” method. The server only describes the grouping, and the host does the work.

This example runs one such server with three agents and walks a scripted host through it as three different callers, so you can see what each one is allowed to see and open.

Quick start

just serve   # terminal 1, the agents server on :8080
just demo    # terminal 2, the scripted walkthrough

Add --wire=stateless to both commands to run the same walkthrough over the 2026-07-28 stateless wire. Everything in it works on both.

The roster

Agent Tools Who sees it Who can open it
pipeline-agent list_failed_pipelines, get_pipeline_log everyone everyone
billing-agent list_invoices callers with billing:read callers with billing:read
deploy-agent list_failed_pipelines, deploy_service everyone callers with ops:deploy

The server takes three demo bearer tokens in place of an authorization server. alice-read grants ops:read, alice-deploy grants ops:read ops:deploy (Alice after stepping up), and bob-billing grants ops:read billing:read. fine-grained-auth shows step-up against a real authorization server, if you want that half.

What the walkthrough shows

  • Discovery. The server advertises the extension with listChanged: true, and agents/list returns routing fields only, so even a large roster is cheap to fetch.
  • Scoping. agents/get returns the instructions, an advisory modelRoutingHint, and the full schemas of the agent’s tools. Each schema matches the caller’s tools/list entry field for field, which the walkthrough checks.
  • Running an agent. The host calls the scoped tools through tools/call. The walkthrough stands in for the model and calls each tool once.
  • Per-caller filtering. Bob sees billing-agent and Alice does not. When Alice asks for it by name she gets agent_not_found, the same answer as for a name that was never registered, so she can’t probe for it.
  • Step-up. Alice can see deploy-agent but can’t open it with her first token. agents/get answers with an HTTP 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="ops:deploy", the host swaps in a broader token for the same user, and the retry succeeds.
  • Roster changes. A demo tool publishes a fourth agent, the server sends notifications/agents/list_changed, and the host’s cache drops its 60-second roster early and picks up the new agent.

Server side

reg, err := agents.Register(agents.Config{
    Server:  srv,
    Agents:  []agents.AgentDef{{
        Name:           "deploy-agent",
        Description:    "Ships a service to production",
        Tools:          []string{"list_failed_pipelines", "deploy_service"},
        ScopeChallenge: core.RequireScopes("ops:deploy"), // step-up on agents/get
    }},
    TTLMs:   &ttl,
    Visible: func(ctx core.MethodContext, name string) bool {
        return name != "billing-agent" || core.HasScope(ctx, "billing:read")
    },
})

Visible hides an agent from a caller entirely. ScopeChallenge keeps it listed but asks for more authorization before it can be opened. An agent the caller can’t see never produces a challenge, so the challenge can’t leak its existence.

Host side

experimental/ext/agents/clients/go carries the host helpers the walkthrough uses:

  • agentsclient.Cache keeps the roster and each agent’s details for the server’s ttlMs, and drops them on agent_not_found.
  • agentsclient.WatchListChanged opens a subscriptions/listen stream on the stateless wire. On the session wire the notification arrives on the GET stream instead, through client.WithNotificationCallback, which is how host.go handles both.
  • agentsclient.RoleTable maps a modelRoutingHint role to one of the host’s own models.
  • agentsclient.StepUpScopes reads the scopes out of a step-up refusal.

See WALKTHROUGH.md for the step-by-step sequence (regenerate it with just readme).


Walkthrough

MCP agents: discovery, scoping, per-caller filtering and step-up

Walks through the agent discovery extension (agents-wg#22, implemented in experimental/ext/agents). A server advertises agents, each a named group of its own tools with instructions. A host lists them with agents/list, opens one with agents/get, and runs it locally, calling the scoped tools through the ordinary tools/call. Three callers with different scopes show what each one is allowed to see and open.

What you’ll learn

  • Connect as Alice and check the extension - A host must not assume agent discovery unless the server advertises it. listChanged: true says the server will announce roster changes.
  • List the agents Alice can see - The roster carries routing fields only, never instructions or tool schemas, so a large roster stays cheap. billing-agent is missing because Alice lacks billing:read. cacheScope is private because the answer depends on who asks.
  • Open pipeline-agent - agents/get returns the instructions, an advisory model hint and the full schemas of the agent’s tools. Every tool it returns also appears in tools/list, field for field. The host maps the hint onto its own models with agentsclient.RoleTable.
  • Run it, through tools/call - There is no agent-invocation method. The host runs the agent locally with its own model and calls the scoped tools like any other tool. This step stands in for the model and calls each one once.
  • Per-caller filtering: Bob sees a different roster - Bob holds billing:read, so his roster includes billing-agent. When Alice asks for it by name she gets agent_not_found, exactly what a name that was never registered returns, so she cannot probe for agents she is not meant to know about.
  • Step-up: Alice may see deploy-agent but not open it yet - deploy-agent is in Alice’s roster, so she is allowed to know it exists, but agents/get asks for ops:deploy. The server answers with the standard step-up challenge, agentsclient.StepUpScopes reads the scopes out of it, and the host retries with a broader token for the same user. The refusal is never cached, so the retry reaches the server.
  • The roster changes, and the host hears about it - The roster was cached for its 60 s ttlMs, so only the notification can make the host see release-agent sooner. On the session wire it arrives on the GET stream; on the stateless wire agentsclient.WatchListChanged opens a subscriptions/listen stream for it. Either way the handler calls Cache.Invalidate.

Flow

sequenceDiagram
    participant Host as MCP Host (this walkthrough)
    participant Server as MCP Server (just serve)

    Note over Host,Server: Step 1: Connect as Alice and check the extension
    Host->>Server: initialize (Bearer alice-read)
    Server-->>Host: capabilities.extensions["io.modelcontextprotocol/agents"] = {listChanged: true}

    Note over Host,Server: Step 2: List the agents Alice can see
    Host->>Server: agents/list
    Server-->>Host: {agents: [cards], ttlMs: 60000, cacheScope: "private"}

    Note over Host,Server: Step 3: Open pipeline-agent
    Host->>Server: agents/get {name: "pipeline-agent"}
    Server-->>Host: {agent, instructions, modelRoutingHint, tools: [...]}

    Note over Host,Server: Step 4: Run it, through tools/call
    Host->>Server: tools/call list_failed_pipelines
    Host->>Server: tools/call get_pipeline_log {id}

    Note over Host,Server: Step 5: Per-caller filtering: Bob sees a different roster
    Host->>Server: agents/list (Bearer bob-billing)
    Host->>Server: agents/get billing-agent (Bearer alice-read)
    Server-->>Host: -32602 {reason: "agent_not_found"}

    Note over Host,Server: Step 6: Step-up: Alice may see deploy-agent but not open it yet
    Host->>Server: agents/get deploy-agent (Bearer alice-read)
    Server-->>Host: HTTP 403, WWW-Authenticate: Bearer error="insufficient_scope", scope="ops:deploy"
    Host->>Server: agents/get deploy-agent (Bearer alice-deploy)
    Server-->>Host: {agent, instructions, tools}

    Note over Host,Server: Step 7: The roster changes, and the host hears about it
    Host->>Server: tools/call publish_release_agent
    Server-->>Host: notifications/agents/list_changed
    Host->>Server: agents/list

Steps

Setup

Start the server in a separate terminal first:

Terminal 1:  just serve         # agents server on :8080
Terminal 2:  just demo          # this walkthrough (--tui for the interactive TUI)

The server accepts three demo bearer tokens in place of an authorization server: alice-read (ops:read), alice-deploy (ops:read ops:deploy) and bob-billing (ops:read billing:read). examples/fine-grained-auth shows the same step-up flow against a real one.

The roster

Agent Tools Who sees it Who can open it
pipeline-agent list_failed_pipelines, get_pipeline_log everyone everyone
billing-agent list_invoices billing:read only billing:read only
deploy-agent list_failed_pipelines, deploy_service everyone ops:deploy only (step-up)

Step 1: Connect as Alice and check the extension

A host must not assume agent discovery unless the server advertises it. listChanged: true says the server will announce roster changes.

Step 2: List the agents Alice can see

The roster carries routing fields only, never instructions or tool schemas, so a large roster stays cheap. billing-agent is missing because Alice lacks billing:read. cacheScope is private because the answer depends on who asks.

Step 3: Open pipeline-agent

agents/get returns the instructions, an advisory model hint and the full schemas of the agent’s tools. Every tool it returns also appears in tools/list, field for field. The host maps the hint onto its own models with agentsclient.RoleTable.

Step 4: Run it, through tools/call

There is no agent-invocation method. The host runs the agent locally with its own model and calls the scoped tools like any other tool. This step stands in for the model and calls each one once.

Step 5: Per-caller filtering: Bob sees a different roster

Bob holds billing:read, so his roster includes billing-agent. When Alice asks for it by name she gets agent_not_found, exactly what a name that was never registered returns, so she cannot probe for agents she is not meant to know about.

Step 6: Step-up: Alice may see deploy-agent but not open it yet

deploy-agent is in Alice’s roster, so she is allowed to know it exists, but agents/get asks for ops:deploy. The server answers with the standard step-up challenge, agentsclient.StepUpScopes reads the scopes out of it, and the host retries with a broader token for the same user. The refusal is never cached, so the retry reaches the server.

Step 7: The roster changes, and the host hears about it

The roster was cached for its 60 s ttlMs, so only the notification can make the host see release-agent sooner. On the session wire it arrives on the GET stream; on the stateless wire agentsclient.WatchListChanged opens a subscriptions/listen stream for it. Either way the handler calls Cache.Invalidate.

Where to look in the code

  • Server: agents.Register, AgentDef (including ScopeChallenge) and Config.Visible, in experimental/ext/agents/
  • Host helpers: agentsclient.Cache, WatchListChanged, RoleTable, StepUpScopes, in experimental/ext/agents/clients/go/
  • This example’s host: host.go. Its server: buildServer in main.go
  • Spec: https://github.com/modelcontextprotocol/agents-wg/pull/22

Run it

go run ./examples/agents/discovery/

Pass --non-interactive to skip pauses:

go run ./examples/agents/discovery/ --non-interactive