Agent Development with S2SP Servers

This guide shows how to build an agent that uses two S2SP servers: a resource server (@s2sp_resource_tool()) that provides weather alerts, and a consumer server (@s2sp_consumer_tool()) that generates charts from the data.

The example uses real NWS (National Weather Service) data with ~30 columns per alert. It demonstrates both async and sync modes, and works with Claude Desktop, OpenAI, and MCP Inspector.

Architecture

  Weather Server             Agent (LLM)                Stats Server
  @s2sp_resource_tool()                                           @s2sp_consumer_tool()
  get_alerts()  ──abstract──▶  filters by             draw_chart()
  get_forecast()                event, severity   ◀── receives abstract
                                selects _row_ids       + resource_url
                                      │
                                      │ async mode:
                                      ▼
  Weather Server  ◀──POST /s2sp/data/{id}──  Stats Server
                     body domains fetched directly (data plane)
                     agent never sees this data
            

Weather Server (Resource)

A standard MCP server with @server.s2sp_resource_tool(). Returns all ~30 NWS alert fields. The decorator adds abstract_domains and mode parameters automatically.

from mcp_s2sp import S2SPServer

server = S2SPServer("weather-server")

@server.s2sp_resource_tool()
async def get_alerts(area: str) -> list[dict]:
    """Get weather alerts — ~30 columns per alert."""
    data = await make_nws_request(
        f"https://api.weather.gov/alerts/active?area={area}"
    )
    return [f["properties"] for f in data["features"]]

server.run()  # mcp dev demos/weather_agent/weather_server.py

Stats Server (Consumer)

Uses @server.s2sp_consumer_tool(). The decorator handles all S2SP plumbing — parsing, fetching body, remapping columns, merging by _row_id. Your function just receives merged rows.

from mcp_s2sp import S2SPServer

server = S2SPServer("stats-server")

@server.s2sp_consumer_tool()
async def draw_chart(rows: list[dict]) -> str:
    """Receives merged abstract + body rows."""
    return generate_chart(rows)

server.run()

# The decorator adds: abstract_data, resource_url, body_data, column_mapping
# Agent calls: draw_chart(abstract_data='[...]', resource_url='http://...')

Async Mode Demo

Body data stays cached on the Weather Server. The Stats Server fetches it directly via the data plane. The agent never sees the body.

# Step 1: Agent calls Weather Server with abstract_domains
result = get_alerts(area="CA",
    abstract_domains="event,severity,urgency,status",
    mode="async")

# Agent sees only:
# { "abstract": [{"_row_id": 0, "event": "Wind Advisory", ...}, ...],
#   "resource_url": "http://..." }
# No body data. No resource_url means body is inline.

# Step 2: Agent filters on abstract (control plane)
wind_rows = [r for r in result["abstract"]
             if "Wind" in r["event"]]

# Step 3: Agent passes abstract rows + resource ref to Stats Server
draw_chart(
    abstract_data=json.dumps(wind_rows),
    
    resource_url=result["resource_url"],
)
# Stats Server fetches body via POST /s2sp/data/dK7x_...
# Merges abstract + body by _row_id, generates chart
$ python demos/weather_agent/run_async.py --area CA --event Wind

Sync Mode Demo

Body data is returned inline alongside the abstract. No resource_url — no server-to-server fetch needed. The agent's SDK layer has the body but should not send it to the LLM.

# Step 1: Agent calls Weather Server in sync mode
result = get_alerts(area="CA",
    abstract_domains="event,severity,urgency,status",
    mode="sync")

# Agent sees abstract AND body inline:
# { "abstract": [{"_row_id": 0, "event": "Wind Advisory", ...}, ...],
#   "body": [{"_row_id": 0, "description": "...", ...}, ...] }
# No resource_url — body is right here.

# Step 2: Agent filters on abstract (control plane)
wind_rows = [r for r in result["abstract"]
             if "Wind" in r["event"]]

# Step 3: Filter body to match, pass both to Stats Server
wind_ids = {r["_row_id"] for r in wind_rows}
wind_body = [b for b in result["body"]
             if b["_row_id"] in wind_ids]

draw_chart(
    abstract_data=json.dumps(wind_rows),
    body_data=json.dumps(wind_body),
)
# No data-plane fetch. Stats Server merges and generates chart.
$ python demos/weather_agent/run_sync.py --area CA --event Wind

Running the Servers

To use these servers with Claude Desktop or an interactive agent, see Use S2SP Servers in Claude.

MCP Inspector

Debug each server independently with the MCP Inspector:

# Weather Server (source) — tools: get_alerts, get_forecast
$ mcp dev demos/weather_agent/weather_server.py

# Stats Server (consumer) — tool: draw_chart
$ CLIENT_PORT=6280 SERVER_PORT=6281 mcp dev demos/weather_agent/stats_server.py

Measured Results

Metric Traditional MCP S2SP Async S2SP Sync
Tokens in agent context ~10,000 (all 30 columns) ~600 (abstract only) ~600 (abstract only*)
Body data through agent Yes (all in LLM context) No (server-to-server) Through SDK, not LLM
Server-to-server fetch N/A Yes (POST /s2sp/data/) No (inline)
Token savings 85-96% 85-96%

* In sync mode, body is in the tool response but a well-designed agent SDK feeds only the abstract to the LLM. The token count reflects what the LLM processes.

File Structure

demos/weather_agent/
├── weather_server.py    # Resource: @s2sp_resource_tool() — get_alerts, get_forecast
├── stats_server.py      # Consumer: @s2sp_consumer_tool() — draw_chart
├── run_async.py         # Scripted async demo
├── run_sync.py          # Scripted sync demo
├── agent_async.py       # Interactive agent (async mode)
└── agent_sync.py        # Interactive agent (sync mode)