Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO XCVII 2026-05-25 · 5 MIN · SHORT-FORM

Roots: Telling the Server Where It May Look

The client hands the server a list of directories it is allowed to use, a boundary the server asks for and the client defines and enforces

Diagram · folio xcvii
sequenceDiagram
  autonumber
  participant S as Server
  participant C as Client (host)
  S->>C: roots/list
  C-->>S: file:// roots (the boundary)
  Note over C: user adds a directory
  C--)S: notifications/roots/list_changed
  S->>C: roots/list
  C-->>S: the wider set

Roots are the smallest of the server-to-client requests, and the easiest to dismiss as a convenience. They are not. A root is a directory the client tells the server it may use, and roots/list is the request the server sends to ask for the list. It runs the same inverted direction as sampling: the server asks, the client answers. What the client answers with is the boundary the server is meant to stay inside.

This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision 2025-11-25, with the official Go SDK at v1.6.1.

§A Boundary the Server Asks For

Roots are a client capability. A client that exposes them declares it at initialization, and the declaration includes whether it will announce changes:

"capabilities": { "roots": { "listChanged": true } }

With that declared, a server can ask what it is allowed to touch. A where_can_i_look tool that calls roots/list produces this exchange, captured from the wire, with the request flowing server to client:

read:  { "id": 1, "method": "roots/list" }
write: { "id": 1, "result": { "roots": [
         { "name": "project", "uri": "file:///home/craig/project" } ] } }

The server sent roots/list with no parameters, and the client answered with the directories it permits. That list is the server’s filesystem view. The server is meant to operate inside it and nowhere else, and because it asked, it knows where the fence is.

§file:// Only, For Now

A root is two fields. The uri identifies the directory and, in the current specification, must be a file:// URI. The restriction is explicit and may be relaxed later, but for now roots are filesystem locations and nothing else. The optional name is a human-readable label for a workspace picker, project above. A client commonly exposes more than one, a frontend repo and a backend repo, a project and a scratch directory, and the server treats the union as its working set.

Declared usable, the roots
file:///home/craig/project project
file:///tmp/scratch scratch
Outside the boundary, not exposed
/
/etc/shadow
/home/craig/.ssh
The roots list is what the server is told it may use. It is the client, not the list, that keeps a server out of everything else.

§When the Workspace Changes

A workspace is not static. The user opens another folder, closes one, switches projects. Because the client declared listChanged, it must tell the server when its roots change, and it does so with a notification. Adding a second directory in the demo pushes one, and the server’s next roots/list sees the wider set:

write: notifications/roots/list_changed
read:  { "id": 2, "method": "roots/list" }
write: { "id": 2, "result": { "roots": [
         { "name": "project", "uri": "file:///home/craig/project" },
         { "name": "scratch", "uri": "file:///tmp/scratch" } ] } }

The notification carries no data, the same nudge-not-delivery pattern as a resource update: it tells the server the boundary moved, and the server re-reads it. A server that caches the root list and never listens for the notification will keep operating against a stale boundary, which is the bug this notification exists to prevent.

§Why It Is a Security Primitive, Not a Convenience

It is tempting to read roots as the server simply being told where the project is. The spec frames them harder than that: roots “define the boundaries of where servers can operate.” But the boundary is declared, not enforced by the list itself, and that distinction is the whole point.

The duty is split. The client must validate root URIs against path traversal, apply access controls, and prompt the user for consent before exposing a directory. The server should respect the boundary, validate every path it touches against the roots, and handle a root going away. So roots are not a sandbox. A well-behaved server stays inside because it asked where the fence is and honors it. A hostile server is stopped by the client’s access controls and the operating system, not by the politeness of a list it could ignore. Reading roots as a convenience misses that they are the consent-and-scope mechanism for a server’s filesystem access, the declared half of a boundary whose enforced half lives on the client. The security post later in the series returns to what happens when a server does not respect them.

§The Go Side

The two sides are a few calls. The client declares its roots with client.AddRoots, and adding or removing one with AddRoots or RemoveRoots is what pushes the list_changed notification to connected servers. A server asks with req.Session.ListRoots from inside a handler. If the client never declared the capability, the request comes back as a -32601, the protocol saying roots are not supported here, and a careful server checks for the capability before relying on them.

Roots are a filesystem boundary, so they do not apply to every server. txn2/mcp-data-platform reaches Trino, DataHub, and object storage, not a local filesystem, and scopes access through personas and tokens instead. Roots are for the servers that do touch files.

§What’s Next

Roots and sampling are two of the three requests that run server to client. The third is the one where the server needs something only a person can give. Elicitation: When the Server Needs to Ask the User covers elicitation/create, how a server asks the user for structured input mid-task, the flat schema it is restricted to, the rule against requesting secrets, and the URL mode added in 2025-11-25 for the flows that must bypass the client entirely.


The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.

← back to all notes