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 revision2025-11-25, with the official Go SDK atv1.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.
§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.