Skip to content
Chapter 4 of 6 · ResourcesTemplate expansion and read authorization

14 min read

Template expansion and read authorization

On this page

A template like docs://team/{team}/policy/{name} is published by the server and expanded by the client, which is the reverse of how most parameterised requests work. The server states the pattern once, on resources/templates/list; a host draws a form from it; a person types values in; and the client turns pattern plus values into the single string it sends on resources/read.

Two distinct bugs live around that step and they are constantly conflated, because both produce a read that returns the wrong thing. One is an encoding bug, where a value escapes its segment and the server matches a URI nobody meant to send. The other is a missing authorization check, where the URI is exactly what the client meant to send and the server had no business answering it. They have different traces, different owners and different fixes.

Expansion is a defined operation, not string substitution

Someone from the Platform & Ops team opens an attachment picker, types their team into the form the host drew from the template, picks the escalation policy, and gets an error. The values were Platform & Ops and on-call/escalation, and the client built the URI by substitution:

ts
const uri = template
  .replace("{team}", team)
  .replace("{name}", name);
// docs://team/Platform & Ops/policy/on-call/escalation

The string that reached the server has a space in an authority-ish position, an ampersand that a query parser will read as a separator, and — the one that actually decides the outcome — a slash inside on-call/escalation that the server's route matcher counts as a path separator. The server matched the pattern against four segments where it expects three, failed to match, and returned -32002. In a different server, one that matches greedily or joins the trailing segments, the same string resolves to a document nobody asked for.

The reason the slash outranks the space and the ampersand is a matter of what happens first. A route matcher does not compare the whole string against the whole pattern; it splits both on the separator and compares them piece by piece, and the split runs before any comparison does. So the number of segments in the incoming URI is decided by the raw characters, before the matcher has had any opportunity to notice that one of those slashes was meant to be part of a value. A pattern with three segments has no arm for a four-segment input, and the match fails on arity rather than on content. The space and the ampersand are ugly but inert by comparison: they sit inside a segment, the segment still compares as one piece, and a server that looks the value up in a store keyed on strings simply finds nothing under a key with a space in it — a lookup miss rather than a routing failure, and indistinguishable from the outside.

The uriTemplate field from chapter 2 holds an RFC 6570 template, and RFC 6570 specifies what expansion does to each value rather than leaving it to whoever writes the client. Simple string expansion — the bare {team} form — percent-encodes everything outside the unreserved set, which is letters, digits, and the four characters -, ., _, ~. Every other character in the value comes out as %XX, which is what keeps a slash inside a value from becoming a separator in the URI.

Applying that rule by hand to the two values is worth doing once, because it is the only way to be sure the rule as written and the code below agree. Letters are unreserved, so P, l, a, t and the rest of Platform come through untouched; the space is not, so it becomes %20; the ampersand is not, so it becomes %26; the second space becomes %20 again; Ops comes through. The second value is the more instructive one, because the hyphen in on-call is one of the four punctuation marks the unreserved set admits, so it survives, while the slash does not and becomes %2F. Encoding is therefore not "escape everything", and the difference has consequences later: a team slug written in the usual house style of lowercase words joined by hyphens passes through expansion completely unchanged, which is why the slug check at the end of this section can be written against the plain value.

ts
function expand(template: string, vars: Record<string, string>): string {
  return template.replace(/\{(\+?)(\w+)\}/g, (_, plus, key) => {
    const value = vars[key];
    if (value === undefined) throw new Error(`missing template variable ${key}`);
    return plus
      ? encodeURI(value)          // reserved expansion: separators pass through
      : encodeURIComponent(value); // simple expansion: separators are escaped
  });
}

expand("docs://team/{team}/policy/{name}", {
  team: "Platform & Ops",
  name: "on-call/escalation",
});
// docs://team/Platform%20%26%20Ops/policy/on-call%2Fescalation

That output is one team segment and one policy segment, and the server's matcher sees three segments as the pattern requires. Note that encodeURIComponent is close to RFC 6570's simple expansion and not identical: it leaves !, ', (, ) and * unescaped where the RFC escapes them. For a route matcher that only cares about /, ? and # the difference is invisible; for a server that round-trips the value into a filesystem path or a shell it is the kind of gap worth closing with a real template library rather than a regex.

The {+name} form exists for the case where you want a value to contribute separators. A file template like file:///repo/{+path} is meant to accept src/server/read.ts as one variable spanning three segments, and encoding the slashes there would break it. Choosing between the two forms is a decision about what the variable means: a team slug is one segment and takes {team}, a repository path is a path and takes {+path}. A server that publishes {+path} is also publishing the obligation to reject ../ before it touches the disk, because reserved expansion passes ..%2F and ../ alike straight through.

RFC 6570 has a third form worth knowing because it removes a category of hand-rolled string building. Form-style query expansion, written {?from,to}, emits the ?, the parameter names, the = signs and the & separators for you, and omits a parameter entirely when its value is undefined. A template of report://acme/usage{?from,to} expanded with only from produces report://acme/usage?from=2025-10-01, with no dangling separator and no empty parameter for the server to interpret. Clients that build query strings by concatenation get this subtly wrong in the undefined case, usually by emitting ?from=2025-10-01&to=, and servers then have to decide whether an empty to means "no upper bound" or "the empty string", which is a decision nobody wants to make twice. Declaring the query variables in the template moves that decision into a specification that both sides already implement.

The counterpart mistake lives on the server. A handler that decodes the matched variable, and sits behind a proxy or a framework that already decoded it once, decodes twice — so a client that correctly sent on-call%2Fescalation yields on-call/escalation at the matcher and then on-call/escalation again as a path, and a client that sent ..%252F..%252Fetc yields ../../etc after two passes. Decode exactly once, at a known layer, and treat the decoded value as data rather than as a path fragment.

The traversal case is worth stepping through slowly, because the interesting property is that the attacker never has to produce a slash. Percent-decoding turns %25 back into a literal %, and that is what the doubled sequence exploits: ..%252F decodes once to ..%2F, which still contains no separator and passes any check looking for one. The second decode then turns that %2F into /, and the value the storage layer receives is ../. Neither pass did anything wrong on its own — each decoded exactly one layer of encoding, correctly — and the defect is that two layers were applied to a value that had one. This is why "validate the input, then decode" and "decode, then validate the input" are not two orderings of the same program: a validator placed between the two passes is looking at a string that is neither what the client sent nor what the storage layer will see.

python
@mcp.resource("docs://team/{team}/policy/{name}", mime_type="text/markdown")
def team_policy(team: str, name: str) -> str:
    # team and name arrive already decoded, exactly once
    if not SLUG.fullmatch(team) or not SLUG.fullmatch(name):
        raise ValueError(f"unexpected characters in {team}/{name}")
    return storage.get_policy(team_id=team, policy=name)

The fullmatch against a slug pattern is the cheap belt beside the braces. It costs nothing, it turns an encoding surprise into a loud failure at the boundary instead of a quiet one three layers down, and it documents in one line what the variable is allowed to be.

The read that was spelled correctly

A second server publishes report://{tenant}/{month}, with both values coming out of the conversation — the tenant because the model read it from an earlier turn, the month because the user asked for October. A customer reports seeing another tenant's report. Pulled from the session log, the entire relevant trace is two messages:

json
{ "jsonrpc": "2.0", "id": 41, "method": "resources/read",
  "params": { "uri": "report://northwind/2025-10" } }

{ "jsonrpc": "2.0", "id": 41,
  "result": { "contents": [ { "uri": "report://northwind/2025-10",
    "mimeType": "application/json", "text": "{\"tenant\":\"northwind\",..." } ] } }

Both encoding explanations are ruled out by that trace. Under-encoding would show a URI with extra separators in it, a value that had escaped its segment, and the request here has exactly two segments with no percent sequences and no punctuation to escape. Double-decoding would show a request whose URI does not correspond to the document that came back, and here the response echoes the URI it was sent and the document's own tenant field agrees with the segment. The client asked for Northwind's October report, correctly spelled, and the server returned Northwind's October report. What is left is that the session belonged to a customer who is not Northwind, and nothing in the request path ever asked whether it should.

The two classes are worth separating explicitly, because the fix for one does nothing for the other.

Under-encoded valueDouble-decoded valueMissing authorization
What the trace showsa URI with stray /, &, #request URI and served document disagreeone well-formed URI, one success
Who is at faultthe client's expansionthe server's decode layerthe server's read handler
Typical symptom-32002, or the wrong documentpath traversal, wrong documentcorrect document, wrong audience
Where the fix goesencode each variable before substitutiondecode exactly once, validate the shapecheck session against tenant on every read
What it costsnothingnothingone lookup per read, ~2 ms
Fixing this one fixes the others?nonono

The top row makes that table a procedure rather than a taxonomy, because two observations off a captured trace separate all three columns. Ask first whether the request URI contains separators or punctuation the pattern did not put there; if it does, the value escaped its segment and the fault is on the client's side of the wire. Ask second whether the document that came back is the document the request named, which the echoed uri and the document's own contents let you check without trusting either alone; if they disagree, something between the matcher and the storage layer transformed the value. A trace that survives both questions — well-formed URI, matching document, successful response — has no remaining transport explanation, and that is the only way a missing check makes itself visible, since it produces no anomaly at all. The last row is the reason to run all three questions rather than stopping at the first yes: a server can have one of these bugs, or two, or three, and repairing an encoding fault leaves an unauthorized read exactly as reachable as it was.

Expansion was never going to be the authorization step, for the same reason chapter 2 gave about listings: a template is advertised by the server and executed by the client, so it constrains a cooperating client and constrains nothing else. A URI is a string. Any client that has the string can send it, including a scripted client written in ten lines, including a second host that never fetched your templates, including the same host after a bug puts the wrong value in a variable. The server sees one resources/read and cannot tell those cases apart from the outside.

The check goes in the read handler, on every read

The identity that decides the answer comes from the transport session — the authenticated principal, meaning the user or service account the connection was opened on behalf of and proved itself as during setup — and never from the URI. The URI says which document is being requested; the session says who is asking. An authorization check is the comparison of the two, and it belongs in the read handler because that is the only place both are in scope.

python
@mcp.resource("report://{tenant}/{month}")
def tenant_report(tenant: str, month: str, ctx: Context) -> str:
    principal = ctx.session.principal          # from the verified access token
    if tenant not in principal.allowed_tenants:
        # deliberately the same error as a document that does not exist
        raise ResourceNotFound(f"report://{tenant}/{month}")
    return reports.render(tenant=tenant, month=month)

Returning "not found" rather than "forbidden" for a tenant the session may not see keeps the server from confirming which tenants exist, which matters when the variable is being filled by something that can try values. The cost of the check on this server is one membership lookup against a cached claim in the access token, about 2 ms at the median including the deserialisation, against a report render that takes 180 ms. There is no version of this that is too expensive to run on every read.

That last sentence is an arithmetic claim and it is worth seeing the ratio behind it. Two milliseconds against a hundred and eighty is a bit more than one hundredth of the work the handler is about to do anyway, so the check disappears into the variance of the render it guards; there is no hot path for a reviewer to point at, because the work being protected costs the better part of a hundred times what the protection does. The 2 ms figure carries a condition, though, and the condition is what makes it cheap: the membership test runs against a claim already carried in the verified access token, so it costs a deserialisation and a set lookup in memory. A deployment where the same check means a database round trip per read is a different number and a fair thing to argue about — and the argument to have then is how to get the claim into the token, rather than whether to run the check.

Host-supplied values narrow the input, they do not check it

The natural counter-proposal after an incident like that is to stop letting the model choose the tenant and leave it there: the host knows which customer the session belongs to, so the host fills {tenant} from its own state and only {month} comes from the conversation. This is worth doing. It removes a whole class of incident where a stray string in the transcript — a customer name the user pasted, an instruction embedded in a document the agent read earlier — becomes the value in a URI, and it means the common path stops depending on model behaviour at all. As a design change it is the same move as chapter 1's: take a decision the model did not need to make and give it back to the application.

It is not the check, and treating it as one recreates the bug in a place that is harder to see. The server still accepts resources/read from anything that can open a session, so its correctness would now depend on every client behaving, forever, including the ones you did not write. The host's own state is not beyond reach either, since an agent that can navigate the application can change which account is on screen.

Two adjacent habits do not substitute for the check either. Filtering resources/list per session is good for the user, because a menu of documents they cannot open is a menu of dead ends, and it is not a boundary — chapter 2 covered why. Scoping the token to one tenant at issue time is a strong control when your deployment can do it, since a token that carries a single tenant claim makes the check trivial and makes a cross-tenant URI unanswerable, and it still needs the handler to compare that claim against the segment rather than to assume the claim already applied itself. You want both server-side verification and host-supplied values, and if you can only have one, keep the one that holds when the caller is hostile.

Two obligations meet on one string, then, and they belong to different sides of it. Expansion is the client's, it is specified rather than improvised, and getting it right costs a template library instead of a replace(). Authorization is the server's, it compares a principal the transport authenticated against an identifier the request supplied, and it runs on every read because no earlier point in the path has both facts in scope.

Encoding and authorization both concern a single read at a single instant. A URI expanded correctly and authorized correctly still returns a document as it stood when the server answered, and a session that runs for hours will keep quoting that document long after it stopped being true.

Resources — read the current version at https://daviddominguez.dev/learn/en/resources/template-expansion-and-read-authorization

Back to top