Chapter 3 of 7 · 9 min read
A schema encodes which fields exist, never what a good one contains
On this page
The platform team's migration slide had two rows on it and the same tick in the same column for both. The first row was search_tickets, a function that three separate agent services each implemented against the Jira API. The second row was change requests, currently handled by a skill that documented the internal change-management system plus an agent that drove a browser through the form. Both were listed as "convert to MCP server, Q3", both were sized at two weeks, and the argument for both was the same sentence about reuse and governance. One of those conversions was a straightforward win. The other shipped, passed every check, and produced a quality regression that took eleven weeks to surface, by which point nobody connected it to the migration.
What the ticket search conversion actually bought
Before the migration, search_tickets existed three times. The support agent had a version that hit /rest/api/3/search with JQL — Jira's own query language, in which a search is written as a string like project = SUP AND text ~ "billing" — assembled from keyword arguments. The engineering-triage agent had a version written four months later that used the newer /rest/api/3/search/jql endpoint with pagination by token rather than by offset. The release agent had a version that had been copied from support and then modified so that it returned only the fields the release notes needed. Three implementations meant three sets of behaviour under the same name, and when Atlassian deprecated offset pagination for result sets over 5,000 issues, one of the three broke and two did not, which is a genuinely awful thing to debug because the report arrives as "the agent can't find tickets" from one team and silence from the other two.
Three implementations also meant three copies of the Jira API token. Each agent service had it in its own secret mount, each was a separate grant in Jira's admin panel, and rotating it was a coordination exercise across three deploy pipelines. Two of the three tokens were scoped to the same service account with browse permission on every project in the instance, because that is what you get when the person setting it up wants the thing to work and does not know which projects will matter.
The conversion collapsed both of those. After it, search_tickets was one implementation, deployed once by the platform team, and the Jira token lived in the platform team's secret store with the three agents presenting workload identities. That is the honest answer to what improved: one implementation maintained and deployed once instead of three times, and a credential that lives with its operator instead of sitting inside three separate agent processes. Everything else people say about this kind of migration is either a consequence of those two facts or is not true.
The rest of the ledger, which the slide did not have
A conversion is a trade and the wins above are the credit side. The debit side has entries that are easy to leave off a slide because none of them is a failure, and each of them is a real change in what the system can do.
| Three in-process tools | One operated server | |
|---|---|---|
| Maintenance | three implementations, drifting | one, deployed once |
| Credential | Jira token in three agent processes | operator's secret store; agents present identity |
| Latency, p50 | 240 ms (Jira round trip) | 291 ms (+51 ms for the extra hop) |
| Latency, p99 | 810 ms | 1,340 ms (server pool saturation adds a queue) |
| Portability | rewrite per agent, but each agent can run standalone | any MCP client gets it; every agent now needs the server reachable |
| Authorization | whatever the agent process's token allows | per-caller identity, enforceable per tool by the operator |
| Observability | the JQL, the response, the timing, all in your trace | your trace shows a call and a result; the JQL the server built and the Jira response it got are in their logs |
| Failure surface | Jira is down | Jira is down, or the server is down, or your token failed to refresh |
The latency numbers are from the support agent's own tracing after the cutover, measured over a week of production traffic. Fifty-one milliseconds at p50 is nothing for a capability invoked once or twice per conversation, and it is not nothing for a loop that calls the tool eleven times while narrowing a search, which is what the triage agent does. The p99 change was the more interesting one, because it was not caused by the network: the server ran a fixed pool of 16 upstream connections shared across three agents, so the release agent's Monday-morning burst put the support agent's calls in a queue that had never existed when each process held its own client.
The observability row is the one that costs teams the most and appears on the fewest slides. Before the conversion, an engineer debugging a bad search read the assembled JQL directly out of the agent's trace. After it, the trace records that search_tickets was called with {"project": "SUP", "text": "billing 402"} and returned six issues, and the JQL that produced those six is a line in the platform team's structured logs, retained for 14 days and queryable by anyone who asks in their channel. What the capability did is now visible in the operator's logs rather than in your own traces, which turns a two-minute investigation into a message and a wait. If you want to feel this difference before committing to it rather than after, the Playground to test Skills and MCP from MCPJam is built for exactly that comparison — running one task both ways and reading the two traces side by side is more convincing than any table, including this one.
Part of that split is recoverable and it has to be negotiated at conversion time rather than during the first incident. The cheap version is a correlation identifier: your agent sends one with every call, the operator writes it into their structured logs, and joining the two halves becomes a query instead of a conversation. The support team asked for this three weeks after the cutover and got it in the following sprint, which was fine, and would have been a one-line addition if it had been in the original request. The expensive version is the operator returning enough of what they did for your trace to stand alone — the assembled JQL, the upstream status code, the number of results before truncation — which some operators will do and many will not, because it exposes internals they would rather keep free to change. Deciding which of the two you are getting belongs in the conversion review, alongside retention: logs kept for 14 days on their side are a hard limit on how far back any joint investigation can reach, whatever your own retention is.
The row that was not the same shape
The change-request capability looked structurally identical on the slide and was not, because most of what made it work was the skill rather than the browser automation. The skill was about a page and a half of text, and its useful part was not a list of fields. Half of it concerned the CAB, the change advisory board that meets to review changes considered risky enough to need a human decision before they ship.
## Filing a change request
`justification` is what the CAB reads. Two sentences that state the
user-visible consequence of *not* doing the change, and the blast radius
if the change goes wrong. "Upgrade library to 4.2" is not a
justification; "TLS 1.1 is disabled at the edge on 1 March and the
current client cannot negotiate 1.2, so checkout fails for ~8% of
traffic" is.
`risk_level` is about what breaks, not about how big the diff is. A
one-line change to a payment path is `high`. A 4,000-line refactor of an
internal admin tool is `low`.
Require a CAB review when any of these hold, regardless of `risk_level`:
- the change touches authentication, payments, or the customer data store
- the rollback is not a redeploy of the previous artifact
- it lands inside a declared freeze window
- a previous attempt at the same change was rolled back
If none hold, file as standard change and name the automated rollback
in `rollback_plan`.The proposed server exposed this instead:
{
"name": "create_change_request",
"inputSchema": {
"type": "object",
"properties": {
"title": {"type": "string", "maxLength": 120},
"system_id": {"type": "string"},
"risk_level": {"enum": ["low", "medium", "high"]},
"justification": {"type": "string", "minLength": 40},
"rollback_plan": {"type": "string", "minLength": 20},
"requires_cab": {"type": "boolean"},
"window_start": {"type": "string", "format": "date-time"}
},
"required": ["title", "system_id", "risk_level",
"justification", "rollback_plan", "requires_cab"]
}
}The mechanical half of the skill converts cleanly and should. Finding the right system_id in the CMDB — the configuration management database, the inventory that lists every system the company runs along with its owner — knowing that the form rejects a window_start less than 48 hours out, retrying the submission when the change-management system returns its intermittent 503, and reading back the assigned ticket number are all execution, and they are better as a typed call than as a browser being driven through a form by a model. The browser automation was also the most fragile thing the team owned: it broke twice in six months on front-end releases, both times at 9 a.m. on a Tuesday.
What does not convert is everything in the skill that decides what to put in the fields. The schema says justification is a string of at least 40 characters. It cannot say that a justification is strong when it names the user-visible consequence of inaction and the blast radius of failure, because that is a property of the string's meaning and the schema only constrains its type and length. The schema says requires_cab is a boolean and is required, which forces the model to produce one of two values and offers nothing about which. Every one of the four CAB conditions in the skill — auth, payments and customer data; non-redeploy rollbacks; freeze windows; previously rolled back — is a judgement made before the call, from context the schema has no way to carry.
Why deleting the skill produced no signal at all
The team that shipped this deleted the skill, on the reasonable-sounding grounds that the fields were now documented by the schema and two descriptions of the same thing drift apart. The agent kept filing change requests. Every call validated, because a model asked for a boolean produces a boolean and a model asked for 40 characters of justification produces 40 characters of justification. The server's error rate stayed at zero. The change-management system's dashboards showed the same volume as the month before.
What changed was inside the strings. requires_cab was true on 22% of requests in the two months before the migration and on 4% in the two months after, because with the conditions gone the model set it from risk_level, which is the only nearby signal. Median justification length went from 310 characters to 96, and the shorter ones described the change rather than the consequence of not making it — "Bump auth-sdk from 3.9 to 4.2" where the old ones said what would break. Nobody was watching either number, since neither is an error.
The signal arrived eleven weeks later as a change failure: a payments-path library upgrade went out as a standard change, took card authorisation down for 34 minutes, and the post-incident review asked why it had not gone to CAB. It had not gone to CAB because nothing in the system knew it should. That is the shape of this regression in general — it does not appear in error rates, latency, or validation, because the calls are all well-formed, and it appears eventually in the outcomes the capability existed to protect.
When the operator will run it but will not change it
There is a version of this decision where the change-management team says they will operate the server and will not add fields or endpoints on request, which is a normal and honest thing for a team with their own roadmap to say. It does not reverse the recommendation, and it sharpens it in two directions. It makes the skill more necessary rather than less, because anything the interface does not express now has to reach the model as procedural context, and there is no path where you get it added to the schema next quarter. It also means the conversion should be scoped to the parts of the capability that are genuinely stable — submission, lookup, status — and should not include anything you expect to need to change, because the cost of a change is now a negotiation with another team's backlog rather than a pull request.
That last point is the general one, and it is bigger than one server and one team. Once a capability is operated by someone else, its interface moves at their pace and in their direction, and that includes directions you did not ask for. The next chapter is about what happens when the change comes from their side.
Skill vs tool vs MCP server — read the current version at https://daviddominguez.dev/learn/en/skill-vs-tool-vs-mcp-server/a-schema-encodes-which-fields-exist-never-what-a-good-one-contains