Tool servers and the Model Context Protocol (MCP)

Last updated on 2026-07-16 | Edit this page

Estimated time: 65 minutes

Overview

Questions

  • What is MCP, and what problem does it solve?
  • What does the uproot server expose, and how is it sandboxed?
  • How do you connect and drive a server?

Objectives

  • Bring the servers up, check them, and read a log when one misbehaves (eic-mcp up/status/logs).
  • Generate the connection file for your own client with eic-mcp config.
  • Discover a real DIS dataset by prompting, without hard-coding names or paths.
  • Judge which returned quantities are worth verifying, and against what.

The interoperability problem


Tools are the only way an assistant can act (Episode 1). The Model Context Protocol (MCP) standardizes the interface: implement a tool once as a server, and any MCP-compliant client (the assistant) can use it.

MCP is a client–server protocol over JSON-RPC 2.0. A server offers three object types — tools (callable functions), resources (readable data), and prompts (templated instructions). There are two transports: stdio (the client launches the server as a subprocess and talks to it over standard input/output) and streamable HTTP for servers reached over the network. The lesson’s servers run inside eic-shell and speak streamable HTTP on 127.0.0.1.

%%{init: {'theme':'base', 'themeVariables': {'fontSize':'15px','lineColor':'#94a3b8','edgeLabelBackground':'#e2e8f0','clusterBkg':'#1f293720','clusterBorder':'#94a3b8','titleColor':'#94a3b8'}}}%%
flowchart LR
    accTitle: {EIC MCP data tools}
    accDescr: {EIC MCP data tools}
    A["AI assistant<br/>opencode · Copilot · Cursor"]:::core <-->|"JSON-RPC / HTTP"| S["uproot tool server<br/>(MCP, in eic-shell)"]:::tool
    S <-->|"uproot"| F["EDM4eic ROOT file"]:::data
    classDef core fill:#e7efff,stroke:#4c6ef5,stroke-width:1.5px,color:#10204a;
    classDef tool fill:#e6f7ed,stroke:#2f9e44,stroke-width:1.5px,color:#0b3d1f;
    classDef data fill:#fff4e0,stroke:#f08c00,stroke-width:1.5px,color:#5c3b00;
Callout

Why run the servers inside eic-shell

The servers reuse the container’s own uproot, xrdfs, and rucio, so dependencies are pinned and one environment runs both analysis and tools. eic-mcp up starts them as background HTTP services; eic-mcp down stops them. They hold no state between sessions.

The uproot tool server


The ePIC uproot tool server reads ROOT/EDM4eic files with uproot and returns JSON summaries — edges, counts, statistics, fit inputs — not raw arrays. One caveat: get_file_structure on an EDM4eic file lists all ~6,000 branches (megabytes of JSON); for schema questions get_tree_info is the compact choice. It exposes 15 tools in four groups:

Group Representative tools Purpose
Inspection get_file_structure, get_tree_info, get_branch_statistics, validate_dataset_schema enumerate trees, branches, types, and summary statistics
Single-file compute histogram_branch, execute_kernel histogram a branch; run sandboxed NumPy/awkward over branches
Dataset (multi-file) get_dataset_file_list, histogram_dataset, get_dataset_statistics, execute_kernel_dataset, estimate_dataset_cost enumerate matching files, then accumulate operations across them
Asynchronous jobs submit_kernel_dataset, get_job_status, get_job_result, cancel_job dispatch long dataset jobs and poll them
Callout

The execution sandbox

execute_kernel runs client-supplied Python in a restricted environment: no import, no file or network I/O, only np (NumPy) and ak (awkward) in scope. Limits are enforced at compile time, and the code runs in a subprocess with a 30-second wall-clock limit.

Start the servers


Start the servers from inside eic-shell (the first run bootstraps them automatically — see Setup):

BASH

$ eic-mcp up

This launches the uproot, xrootd, and rucio servers as MCP-over-HTTP endpoints on 127.0.0.1, ports 9101, 9102, 9103. Stop them with eic-mcp down; eic-mcp status shows what is listening, and eic-mcp logs xrootd tails a server’s log when something misbehaves. The assistant connects to those URLs.

Callout

If rucio answers but xrootd/uproot time out

The rucio catalog and the data store are different services. If dataset queries work but every file access hangs, the XRootD store may be temporarily down — check with xrdfs root://epicxrd1.sdcc.bnl.gov:1095 ls /eic/EPIC/RECO (inside eic-shell) and retry later. Your setup is fine; the store isn’t answering.

Current campaigns (25.12.0 onward) are served from BNL disk, which is what eic-mcp points the xrootd server at by default. Older campaigns (up to 25.10.x) live on the JLab store instead — browse those with XROOTD_SERVER=root://dtn-eic.jlab.org XROOTD_BASE_DIR=/volatile/eic/EPIC eic-mcp restart (a plain up skips servers that are already running, so the new setting would never take effect). Either way, rucio replicas always tell you where a file really is.

If instead every uproot call starts timing out after one big one, the server is busy, not broken: it handles one request at a time, and a call your client gave up on is still running. Wait a minute, or clear it with EIC_MCP_SERVERS=uproot eic-mcp restart. Do not let the assistant “work around” it by installing packages or reading the file itself — AGENTS.md (Episode 4) forbids exactly that.

Connect the assistant


opencode reads its server list from a JSON config. In the directory where you will launch opencode, one command writes it:

BASH

$ eic-mcp config opencode

The file it writes, opencode.jsonc, is just the three server URLs — print it with eic-mcp config opencode - (committed as the example files/mcp-config/opencode.jsonc):

JSON

{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "uproot": { "type": "remote", "url": "http://127.0.0.1:9101/mcp", "enabled": true },
    "xrootd": { "type": "remote", "url": "http://127.0.0.1:9102/mcp", "enabled": true },
    "rucio":  { "type": "remote", "url": "http://127.0.0.1:9103/mcp", "enabled": true }
  }
}

Within a session, /mcp lists the connected servers and their tools.

Callout

Other clients point at the same URLs

The HTTP endpoints work with any MCP client, and eic-mcp config <client> writes the file where that client reads it:

BASH

$ eic-mcp config claude    # Claude Code (.mcp.json) — likewise copilot, vscode, cursor, gemini, codex
Callout

Which side am I on?

Only the servers need eic-shell — they use the container’s uproot, xrdfs, and signed-in rucio. The client just talks to http://127.0.0.1:910x/mcp, so run it where you normally work, and put its config in the directory you launch it from.

  • Linux and Windows/WSL: the same URLs work inside and outside the container.
  • macOS: you published the ports in Setup; on the Mac itself, copy files/mcp-config/opencode.jsonc rather than running eic-mcp config (it needs the container).
  • Can’t install a client? eic-shell ships claude and copilot — run one inside it.

Finding the data with MCP


You never download a dataset. The other two MCP servers let the assistant find and verify the real files — replacing the manual rucio + xrdfs recipe — and uproot-mcp then reads them straight from the store.

%%{init: {'theme':'base', 'themeVariables': {'fontSize':'15px','lineColor':'#94a3b8','edgeLabelBackground':'#e2e8f0','clusterBkg':'#1f293720','clusterBorder':'#94a3b8','titleColor':'#94a3b8'}}}%%
flowchart LR
    accTitle: {EIC MCP data tools}
    accDescr: {EIC MCP data tools}
    R["rucio-mcp<br/>list_dids · list_files · list_file_replicas"]:::tool -->|"DID + root:// replica URLs"| X["xrootd-mcp<br/>list_datasets · check_file_exists · get_dataset_event_statistics"]:::tool
    X -->|"verified root:// paths"| U["uproot-mcp<br/>analyze in place"]:::core
    classDef tool fill:#e6f7ed,stroke:#2f9e44,stroke-width:1.5px,color:#0b3d1f;
    classDef core fill:#e7efff,stroke:#4c6ef5,stroke-width:1.5px,color:#10204a;
  • rucio-mcp queries the data-management catalog: list_dids finds the dataset identifier (DID) by name, get_did_metadata and list_files describe its contents, list_file_replicas returns the physical root:// locations.
  • xrootd-mcp works directly on the store: list_campaigns / list_datasets browse it, list_directory and check_file_exists enumerate and verify files, and get_dataset_event_statistics reports total events across a dataset.

rucio tells you what the dataset is and where its replicas live, xrootd confirms the files are there, and uproot-mcp reads a root:// URL in place.

Callout

rucio works automatically — no key

Inside eic-shell, rucio-mcp signs in to the authenticated catalog with the shared, read-only eicread account. No password or grid proxy. The xrootd path is public, so to only browse the store you can use xrootd-mcp alone.

List the available campaigns


ePIC data is organized by production campaign — a version such as 26.06.0 — together with the beam/target and physics, all encoded in the rucio DID (e.g. epic:/RECO/26.06.0/epic_craterlake/DIS/pythia8.316-1.0/NC/noRad/ep/18x275/...). Before locating a specific dataset, check which campaigns exist so you use a current one:

Using the rucio tools, find which production campaigns are available (the version field in the DIDs, e.g. 26.06.0) and show the most recent few.

The assistant calls list_dids on scope epic and groups the DIDs by their campaign component. Watch how it does this: the catalog holds thousands of DIDs and the pages are not sorted newest-first, so sampling one page can miss the current campaigns entirely. Narrowing with a version wildcard (/RECO/26.*) — or one call to the xrootd server’s list_campaigns — gives the honest answer.

Challenge

Exercise: locate a dataset (≈ 10 min)

With rucio and xrootd connected (no credentials — see the callout), ask your assistant:

Use the rucio tools to find the ePIC reconstructed-DIS dataset for the BeAGLE eCu ep 10x115 GeV sample in campaign 26.04.1, list its files, then use the xrootd tools to confirm those files exist on the store and report the total number of events.

The assistant calls list_scopes/list_dids (scope epic, narrowing by a name glob on the campaign and beam/target) to find the DID, list_files to enumerate it (374 files), and list_file_replicas for the root:// URLs. It then switches to xrootd-mcp (list_directory_filtered, check_file_exists) to verify the files. For the event total: rucio does not store event counts, and scanning all 374 files would take an hour — so a sensible assistant checks a few files (≈ 1,220 events each) and extrapolates. The DID is discovered with list_dids, not hard-coded — what you want when campaign names change.

Inspect the dataset


You say what you want in plain language and the assistant makes the matching tool calls. Take one of the root:// URLs from the previous exercise — written below as root://epicxrd1.sdcc.bnl.gov:1095//… — and analyze it in place.

Challenge

Exercise: enumerate the schema (≈ 10 min)

Issue the request:

Using the uproot tools, report the structure of the events tree in root://epicxrd1.sdcc.bnl.gov:1095//<your-discovered-file>.root and list the members of the ReconstructedChargedParticles collection.

The assistant calls get_tree_info on the events tree (not the full get_file_structure dump, which runs to megabytes on an EDM4eic file) and reports something like:

OUTPUT

File:  root://epicxrd1.sdcc.bnl.gov:1095//…/<dataset-file>.root
Tree:  events   — branches grouped by collection

ReconstructedChargedParticles collection:
  ReconstructedChargedParticles.PDG          int32[]   PDG particle-ID code
  ReconstructedChargedParticles.momentum.x   float[]   p_x [GeV]
  ReconstructedChargedParticles.momentum.y   float[]   p_y [GeV]
  ReconstructedChargedParticles.momentum.z   float[]   p_z [GeV]
  … energy, charge, mass, type, referencePoint.*, covMatrix.*

The names are read from the file, not inferred — eliminating the schema-hallucination failure mode from Episode 1. These are the branches you’re looking for.

Challenge

Exercise: identify the species present (≈ 10 min)

Issue the request:

Histogram ReconstructedChargedParticles.PDG with one bin per integer code, so I can see the reconstructed particle species in the file.

The assistant calls histogram_branch, setting the bins and range so each integer code gets its own bin (the default auto-binning would merge neighbouring codes, e.g. 0 and 11). The distribution is discrete — spikes at the PDG codes present. Counting over a reconstructed-DIS file gives, for example:

OUTPUT

   PDG  species   count
  -211   pi-      11447
   211   pi+       9885
    11   e-        4489
     0   unID      2971      <- tracks with no PID hypothesis
  -321   K-        1662
   321   K+        1588
   -11   e+         967
 -2212   pbar       693
  2212   p          684      <- protons are rare
Bar histogram of reconstructed charged-particle PDG codes in the file, with pions dominating and protons rare
Reconstructed charged-particle species in the file

Pions dominate; protons are rare (≈ 2%), so the Λ⁰ signal will be small. A sizeable fraction of tracks carry no PID (code 0) or a wrong one — misidentification that feeds the combinatorial background and is why we fit the peak rather than count it.

Callout

Verify the returned quantities

Inspect the returned numbers — bin edges, counts, statistics: do the PDG peaks fall at physical codes, and are the proton and pion yields plausible? Episode 4 turns this into explicit success criteria.

One data model, several access paths


ePIC data follow the PODIO model (EDM4eic): an events tree whose branches are per-event collections such as ReconstructedChargedParticles and MCParticles. We read it with uproot because that needs only eic-shell — no compiled framework. uproot is one of several equivalent access paths.

Callout

Equivalent implementations

The same Λ⁰ peak comes out of:

  • ROOT RDataFrame — declarative, columnar, parallel;
  • ROOT TTreeReader — an explicit event loop;
  • bare uproot — Python with no tool server; and
  • the PODIO Frame API — the native interface.

Worked implementations of each are in Alternative analysis approaches.

The assistant can now query the data through a verifiable interface. The next episode captures this procedure as a reusable, versioned skill.

Key Points
  • MCP is a JSON-RPC client–server protocol; a server exposes tools, resources, and prompts to any compliant client.
  • The uproot server returns compact, JSON-serializable summaries rather than raw arrays, which keeps results inspectable.
  • execute_kernel runs client-supplied Python in a restricted sandbox: no imports or I/O, only NumPy/awkward, with a timeout.
  • The servers run inside eic-shell (eic-mcp up) and speak streamable HTTP; opencode and other clients connect to the same 127.0.0.1 URLs (eic-mcp config <client>).
  • PODIO/uproot is one access path; RDataFrame, TTreeReader, and bare uproot give the same result (see the extras).