Dev.to · 8 min read

One Prompt Template, Three Engines That Disagree

One Prompt Template, Three Engines That Disagree

Written against main at the time of publishing. Every regex and line number is cited so you can re-check with grep -n and there's a runnable reproduction at the end that needs no build. TL;DR A PROMPT_TEMPLATE artifact in Apicurio Registry is rendered by three different pieces of code that don't share an engine and don't agree on what a template means beyond a plain {{name}}. Add a space, a dot, or an {{#if}} block and the same stored template renders three different ways. If you're going to build anything on top of prompt rendering a UI playground,a new client you have to know which of the three you're actually rendering against. Storing a prompt template looks simple You save some text with {{variable}} holes in it, hand it a set of arguments, and get a rendered string back. One artifact, one render. Except the registry doesn't render it in one place. It renders it in three,and those three don't share an engine. A template that renders correctly over the REST API can come back untouched over the MCP server, and be flagged as having novariables at all by the validator that gated it on the way in. A renderer that returns a result is not the same as a renderer that returns a consistent one. This post walks through exactly where the three surfaces diverge, using the real classes and the real regexes. The three-surface model A PROMPT_TEMPLATE artifact is consumed by three independent pieces of code: Surface Class Job REST PromptRenderingService Renders a template to a string for HTTP clients MCP PromptTemplateConverter Renders a template for Model Context Protocol clients, including {{#if}} blocks Validator PromptTemplateContentValidator On write, decides which variables the template "declares" They live in three different modules (app, mcp, schema-util). None of them calls the others. Each one carries its own idea of what a template is encoded in its own regular expression. The objects involved There is exactly one source of truth: the stored artifact content. Everything downstream is a consumer reading the same bytes. ┌──────────────────────────────┐ │ Stored PROMPT_TEMPLATE │ │ content: "Hi {{ name }}" │ ← one source of truth └───────────────┬──────────────┘ │ same bytes, three consumers ┌────────────────────────┼────────────────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌────────────────┐ ┌──────────────────┐ │ REST │ │ MCP │ │ Validator │ │ PromptRender- │ │ PromptTemplate-│ │ PromptTemplate- │ │ ingService │ │ Converter │ │ ContentValidator │ ├───────────────┤ ├────────────────┤ ├──────────────────┤ │ \{\{([^}]+)\}\}│ │ key spliced │ │ \{\{(\w+)\}\} │ │ + .trim() │ │ unescaped │ │ (strict) │ └───────┬───────┘ └───────┬────────┘ └────────┬─────────┘ ▼ ▼ ▼ "Hi Ada" "Hi {{ name }}" declares: (nothing) renders whitespace breaks it name not recognized That single picture is the whole story. The rest of this post is just proving each of those three boxes really behaves that way. A small template example Take the simplest possible template and one argument: template: Hi {{name}} args: { "name": "Ada" } All three surfaces agree here. REST renders Hi Ada, MCP renders Hi Ada, and the validator sees one declared variable, name. Good. This is the case everyone tests, and it's why the divergence hides so well. Now change one thing at a time. Two grammars, not one The REST renderer matches variables with this pattern, and then trims the capture: // PromptRenderingService.java:27 private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{([^}]+)\\}\\}"); // PromptRenderingService.java:361 String varName = matcher.group(1).trim(); [^}]+ means "one or more characters that aren't a closing brace." That is a generous grammar. It matches whitespace. It matches dots. It matches # and /. And because of the .trim(), {{ name }} and {{name}} are treated as the same variable. The validator — the code that decides, on write, which variables a template declares — uses a different, stricter pattern: // PromptTemplateContentValidator.java:30 private static final Pattern TEMPLATE_VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)\\}\\}"); \w+ is "word characters only." No spaces, no dots, no symbols. So the set of variables the server accepts as declared and the set of variables the server will actually substitute are defined by two different regexes in two different modules. The write path and the render path do not speak the same language. What happens with whitespace Add a space: template: Hi {{ name }} args: { "name": "Ada" } REST → Hi Ada. [^}]+ captures name, .trim() makes it name, substituted. MCP → Hi {{ name }}. Unchanged. (Why, in the next section.) Validator → declares nothing. \w+ never matched name. Same template. One surface renders it, one leaves it raw, and the gatekeeper thinks it has no variables at all. Why MCP leaves it raw — the substitution nobody escapes MCP declares a VARIABLE_PATTERN at the top of the file, exactly like the others: // PromptTemplateConverter.java:25 — declared but never referenced private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{(\\w+)\\}\\}"); It's never used. The real substitution doesn't compile a pattern for the whole template. It loops over the arguments and builds a regex per key, on the fly: // PromptTemplateConverter.java:441 String placeholder = "\\{\\{" + entry.getKey() + "\\}\\}"; // :443 rendered = rendered.replaceAll(placeholder, Matcher.quoteReplacement(value)); Two things fall out of this: It builds a regex that matches {{name}} exactly — no [^}]+, no .trim(). So {{ name }} with spaces never matches, and the placeholder survives into the output. That's the whitespace result above. The replacement value is escaped with Matcher.quoteReplacement, but the key is spliced straight into the pattern with no Pattern.quote. The key becomes regex source. For an ordinary name that's harmless; for a key with regex metacharacters it is not the same string match REST would do. The triple-brace and the dotted path Two more probes, to show it isn't only whitespace. template: Raw: {{{name}}} args: { "name": "Ada" } REST → Raw: {{{name}}} (the greedy match lands on {name, no such arg → left literal) MCP → Raw: {Ada} ({{name}} is found *inside* the braces and replaced) template: Hi {{user.email}} args: { "user.email": "a@x" } REST → Hi a@x ([^}]+ matches the dot) MCP → Hi a@x (the unescaped '.' happens to match the literal dot) Validator → declares nothing (\w+ rejects the dot) The dotted-path row is the sneaky one: REST and MCP agree on the output, and it's the validator that's the odd one out — it never considered user.email a declared variable in the first place. So "the surfaces disagree" isn't even a clean two-against-one; which surface is the outlier depends on the construct. The full divergence table This is the output of the reproduction script (verify_divergence.py), which re-implements each surface using its exact on-main regex: probe REST MCP validator sees -------------------------------------------------------------------------------- plain Hi Ada Hi Ada name whitespace Hi Ada Hi {{ name }} (none) triple-stache Raw: {{{name}}} Raw: {Ada} name if-block {{#if premium}}VIP Ada{{/if}} VIP Ada name dotted-path Hi a@x Hi a@x (none) Only the first row — plain {{name}} — is unanimous. Every other construct produces at least one disagreement. And {{#if}} blocks only exist on MCP at all: REST has no concept of them, so it emits the raw {{#if}} and {{/if}} tags into the output. So is this a bug? Some of these divergences may be intentional; {{#if}} on MCP but not REST could be a deliberate feature-gap, not an oversight. I'm not calling the code broken. The point that survives either way is narrower and more useful: The same stored template does not have a single, well-defined rendering. What it means depends on which surface serves it. That's a fact about the architecture, and it's the fact anyone building on top of prompt rendering needs to design around. Why I care: building a UI Playground on top I'm proposing a Prompt Template Playground in the Registry UI a panel where you edit a template, fill in variables, and see the rendered result before you save. The table above is the reason this feature is harder than it looks, and the reason it's worth doing carefully. A playground has to render the preview somehow. If it quietly reimplements a fourth regex in TypeScript, it becomes a fourth dialect now there are four answers to "what does this template mean." The correct design is the opposite: the playground must render against the surface the user is actually going to call, and be explicit about which one. Anything else is a preview that lies. That's also why the first useful contributions here are backend-shaped, not just UI polish: before a playground can tell the truth, the surfaces have to agree on what the truth is. What to remember A PROMPT_TEMPLATE is rendered by three independent surfaces (REST, MCP, validator) that do not share an engine. They encode three different grammars: [^}]++trim (REST), exact-key splice (MCP), strict \w+ (validator). Only plain {{name}} renders the same everywhere. Before you build anything on top of prompt rendering, pin down which surface you're rendering against or you'll ship a fourth dialect. Reproduce it yourself No build required the repo re-implements each surface with its exact on-main regex and prints the table above: git clone https://github.com/Faisal77666/prompt-template-render-divergence cd prompt-template-render-divergence python3 verify_divergence.py Source it's checking against: PromptRenderingService.java REST renderer (:27, :361) PromptTemplateConverter.java MCP renderer (:25, :441) PromptTemplateContentValidator.java write-time validator (:30) Line numbers reflect main at the time of writing. If they've drifted, a quick grep -n on the patterns above will find them.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More Programming & Dev News