Give a Specialized Agent Ticket Access over MCP: Resources, Controlled Tools and Failure Recovery

Give a Specialized Agent Ticket Access over MCP: Resources, Controlled Tools and Failure Recovery

A coordination agent hands a ticket-analysis job to a specialized agent. This page builds the MCP half of that handoff: a ticket server that exposes data as a resource and actions as narrowly scoped tools, and the client calls the specialized agent runs. The A2A half — how the coordination agent discovers the specialist, tracks the task, and receives the result — is a separate concern covered by the A2A guides on a2acn.com; this page links to it only where the boundary matters.

The server and client code below was checked against protocol revision 2026-07-28 and run end to end over stdio with Python SDK mcp==2.3.0 on 2026-10-28. The remote HTTP authorization guidance follows the cited specification; this run did not exercise controlled 401/403 or token-audience cases.

What you will have at the end

CapabilityHow it is exposedWho may call it
Read one ticket (subject, severity, customer report)Resource tickets://{ticket_id}Any caller that can reach the server
Append an internal noteTool add_internal_noteCallers whose token carries tickets:notes (remote) or the local process owner (stdio)
Reassign or close a ticketNot exposed. High-risk actions stay in the ticketing UI.—

The last row is the point of this tutorial: a specialized agent gets exactly the access its job needs, and nothing wider.

Decide what becomes a resource and what becomes a tool

Before writing code, classify each capability. The reader question differs:

Reader questionPrimitiveWhy
“What does ticket 481 say?”ResourceReading structured data; the client fetches it on demand, and subscriptions can push updates when it changes
“Add this finding to the ticket”ToolA side effect the model must not trigger accidentally; it needs a schema, a description, and a permission decision
“What should I do next?”Neither — the agent’s own reasoningNot the server’s job

Use this table as a gate: if you cannot name the reader question and the permission, the capability does not belong on the MCP server.

Server: one resource, one controlled tool

Install the server the same way the official tutorials do:

uv add "mcp==2.3.0"

ticket_server.py — the resource returns anticipated failures as ResourceError, and the tool validates its input before any effect:

import json

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceError

server = MCPServer("ticket-bridge")

# Stands in for your ticketing system's API. The server never edits
# TICKETS except through add_internal_note.
TICKETS = {
    "481": {"subject": "Checkout fails on Safari", "severity": 2,
            "report": "Customer reports the pay button does nothing on Safari 17.",
            "notes": []},
}


@server.resource("tickets://{ticket_id}", name="ticket",
                 description="One support ticket: subject, severity, report, notes")
def ticket(ticket_id: str) -> str:
    record = TICKETS.get(ticket_id)
    if record is None:
        # An anticipated failure: the client sees this message as-is.
        raise ResourceError(f"no ticket {ticket_id}")
    return json.dumps(record, ensure_ascii=False)


@server.tool()
def add_internal_note(ticket_id: str, note: str) -> str:
    """Append an internal note to a ticket. Staff-visible only.

    Args:
        ticket_id: Ticket identifier, e.g. "481".
        note: One or two sentences the next reader can act on.
    """
    record = TICKETS.get(ticket_id)
    if record is None:
        raise ResourceError(f"no ticket {ticket_id}")
    if len(note) > 500:
        raise ResourceError("note too long; keep it under 500 characters")
    record["notes"].append(note)
    return f"note added to {ticket_id}; {len(record['notes'])} total"


if __name__ == "__main__":
    server.run("stdio")

Two deliberate choices:

  • add_internal_note never reassigns or closes a ticket. Scope is the cheapest security control there is: a capability that is not on the server cannot be abused.
  • The tool checks its input (ticket exists, note length) before mutating state, so a rejected call leaves the ticket untouched and can be retried with corrected input.

Client: connect, list, call — and what you should see

The specialized agent embeds an MCP client. The connection lifecycle is one async with block: entering it launches the server subprocess and agrees a protocol version; leaving it shuts the server down.

import asyncio
import os

from mcp import Client, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp_types import TextContent


async def main() -> None:
    params = StdioServerParameters(
        command="python",
        args=["ticket_server.py"],
        # A read-only staff token from the environment, never hard-coded.
        env={"TICKET_API_TOKEN": os.environ["TICKET_API_TOKEN"]},
    )
    async with Client(stdio_client(params)) as client:
        tools = await client.list_tools()
        print([t.name for t in tools.tools])  # ['add_internal_note']

        result = await client.call_tool(
            "add_internal_note",
            {"ticket_id": "481", "note": "Reproduced on Safari 17; console shows a TypeError."},
        )
        texts = [b.text for b in result.content if isinstance(b, TextContent)]
        print(result.is_error, texts)  # False ['note added to 481; 1 total']


asyncio.run(main())

Expected observations, in order: the tool list contains exactly one tool; the successful call returns is_error=False with one text block; and the ticket’s notes now holds your sentence (read the resource to confirm). If list_tools shows a tool you did not expect, stop and re-check the server — the client is about to have more authority than you designed.

When the server runs remotely: scopes and the token audience

Over stdio on one machine, the boundary above is the process owner. Over Streamable HTTP, the same server is a resource server under OAuth 2.1, and the boundary becomes the token:

  • On the first unauthorized request the server answers 401 with a WWW-Authenticate header pointing at its Protected Resource Metadata, and the client runs the standard discovery and PKCE flow (see Authorization with OAuth 2.1).
  • Give the note tool its own scope and check it, so a token meant for reading cannot write:
Token scopetickets://{id} resourceadd_internal_note
tickets:readyesno
tickets:read tickets:notesyesyes
  • Validate the audience, not just the signature. A token minted for a different API that happens to validate here is the token-passthrough problem; the authorization tutorial shows how the audience is embedded in the token and checked server-side.

Local stdio deployment keeps the simpler rule from the official security pages: read credentials from the environment, bind local HTTP servers to 127.0.0.1, and do not run the browser-based OAuth flow for a single-user local tool.

Failures: classify, then recover

The three failure families behave differently, and mixing them up is the most common bug in agent loops:

What you seeFamilyMeaningRecovery
is_error=True in a normal CallToolResultTool failureYour tool ran and rejected the request (ResourceError and friends); the session stays upRead the message, correct the input, call again — this is the intended path
JSON-RPC error, e.g. -32601 method not foundProtocol errorThe call never reached your tool: wrong name, or server and client disagree on revisionFix the name or the negotiated version; retrying the same call unchanged cannot succeed
Connection-level error (session dropped)Transport failureThe server process or the stream diedReconnect with a fresh async with; treat the last call’s effect as unknown — check the ticket before retrying, or make the tool idempotent
401 after working beforeAuthorizationToken expired, or was issued for the wrong audienceRefresh per the RFC 9728 metadata; a wrong-audience token is a configuration error, not something to retry
Empty message on a crashing toolSDK containmentAn uncaught exception inside a tool; the SDK withholds internal details (stack, secrets) from the clientFix the server; the withheld text never reaches the model

The last row is worth internalizing: raise ResourceError for failures the model can act on, and let anything else crash — the SDK’s containment decides what the client learns. The resources concept page demonstrates both behaviors on purpose.

Verify your own deployment

The checks below run the server and client above end to end; each asserts one observation from this page:

  1. list_tools returns exactly add_internal_note.
  2. Calling it with a valid ticket returns is_error=False and the note count.
  3. Calling it with an unknown ticket returns is_error=True with the no ticket message, and the session accepts a subsequent valid call.
  4. A note over 500 characters is rejected and the ticket is unchanged.

The scenario scripts and the page assertions live in the site’s check suite under a2a-handoff-check/; run them against your own server by swapping in your command and parameters.

The recorded run on 2026-10-28 passed all four stdio scenarios with mcp==2.3.0. It does not validate the remote HTTP authorization behavior described above; use a controlled resource server and both accepted and rejected tokens for that boundary.

Scope of this page and the A2A half

This page owns the MCP side only: resources, controlled tools, scopes, audience, and the failure families above. The A2A half of the handoff — advertising the specialized agent, sending it the task, tracking task state, and exchanging messages and artifacts — uses a different protocol and has its own failure modes; see the A2A guides on a2acn.com.

Sources, checked 2026-10-27 against the pinned upstream revision dae694f6: