Chapter 4 of 6 · 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:
const uri = template
.replace("{team}", team)
.replace("{name}", name);
// docs://team/Platform & Ops/policy/on-call/escalationThe 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.
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%2FescalationThat 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.
@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:
{ "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 value | Double-decoded value | Missing authorization | |
|---|---|---|---|
| What the trace shows | a URI with stray /, &, # | request URI and served document disagree | one well-formed URI, one success |
| Who is at fault | the client's expansion | the server's decode layer | the server's read handler |
| Typical symptom | -32002, or the wrong document | path traversal, wrong document | correct document, wrong audience |
| Where the fix goes | encode each variable before substitution | decode exactly once, validate the shape | check session against tenant on every read |
| What it costs | nothing | nothing | one lookup per read, ~2 ms |
| Fixing this one fixes the others? | no | no | no |
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.
@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