DEV Community

Cover image for Shape a Messy API into Clean MCP Tools with JSONata
MrOops
MrOops

Posted on

Shape a Messy API into Clean MCP Tools with JSONata

A raw OpenAPI operation rarely makes a good MCP tool. It exposes parameters the model should never set, speaks in cryptic API values, and hands back a bloated response envelope, so the model either misuses the call or drowns in the payload. openapi-mcp-gateway fixes this with shaping. From one YAML block next to the operation, you declare the friendly input with params and rewrite the request and response with JSONata, without forking the spec. It does four things.

  • You decide the input. Hide or fix the parameters the model should not choose, like a pinned project scope or pagination, expose only the friendly ones, and restrict values with enum. It is enforced, so anything you did not expose cannot be set, and an out-of-range value is rejected before the call.
  • It speaks the model's language. Friendly names and enums map onto the raw API values (popular becomes popularity.desc), so the model never invents a sort_by string or a filter DSL.
  • You shape the output. Trim the envelope down to the fields that matter (less context), and rename or flatten them (vote_average becomes rating) so the result is easy for the model to use.
  • It stays declarative. No wrapper service to write and deploy, and the model-facing tool holds steady when the upstream spec shifts.

Quickstart

uv add openapi-mcp-gateway
Enter fullscreen mode Exit fullscreen mode

The TMDB example behind everything below ships in the repo. From a checkout, point it at your token and it serves the two shaped tools:

export TMDB_TOKEN="<your v4 read token>"
uv run openapi-mcp-gateway --config examples/movie-shaping.yml
Enter fullscreen mode Exit fullscreen mode

Tool shaping is available in openapi-mcp-gateway 0.6.0+, Python 3.11+.

The Shape of a Tool

Three keys under x-mcp-integration.tool do the work.

  • params and strategy declare what the model sees.
  • request is a JSONata expression that maps the friendly arguments onto the upstream request.
  • response is a JSONata expression that reshapes the upstream body before it reaches the client.

params entries are plain JSON Schema fragments (type, enum, default, description). strategy says how they relate to the spec. replace makes them the whole input schema and drops the spec's parameters, and merge layers onto the spec and keeps the rest.

A Real Example: The Movie Database

TMDB's /discover/movie is a good punching bag. It takes sort_by (values like popularity.desc), include_adult, language, and page, and returns a large envelope. Here is the whole friendly tool.

operations:
  discover_movies:
    tool:
      strategy: replace
      params:
        sort:
          type: string
          enum: [popular, top_rated, newest]
          default: popular
        page:
          type: integer
          default: 1
      request: |
        {
          "sort_by": $lookup(
            {"popular": "popularity.desc", "top_rated": "vote_average.desc", "newest": "primary_release_date.desc"},
            sort
          ),
          "page": page,
          "include_adult": false,
          "language": "en-US"
        }
      response: |
        [results.{
          "title": title,
          "overview": overview,
          "release_date": release_date,
          "rating": vote_average
        }]
Enter fullscreen mode Exit fullscreen mode

The model sees exactly two inputs, sort and page. Everything else is handled for it.

  • $lookup translates the friendly popular into the raw popularity.desc.
  • include_adult and language are injected as constants the model never sees.
  • The response keeps four fields per movie and renames vote_average to rating.

A call with no arguments goes out as sort_by=popularity.desc&page=1&include_adult=false&language=en-US, and comes back as a short list of {title, overview, release_date, rating}.

Why JSONata

The response side looks like a query, and a query language could do it. The request side cannot. Building sort_by from a lookup table, injecting constants, or fanning one friendly query out into the repeated f[] / op[] / v[] parameters of a filter DSL is construction, not selection. JMESPath and JSONPath query, they do not build. JSONata does both, so one engine covers request and response.

Two idioms carry most of the weight.

  • $lookup(table, key) maps a friendly enum onto the raw API value.
  • [ ... ] forces a list. A projection like results.{...} unwraps to a single object when only one item matches, so the array constructor keeps it a list every time.

To pass most arguments straight through and change only a few, merge them with $merge([$, { ... }]), where $ is the whole input.

merge vs replace

replace is for a full reshape. You hide the raw API entirely and declare a clean interface, as above. merge is for a light touch on an API that is already close.

tool:
  strategy: merge
  params:
    internal_flag: { hidden: true }
    per_page: { default: 30 }
Enter fullscreen mode Exit fullscreen mode

Here the spec's other parameters stay visible, internal_flag is hidden, and per_page gains a default. Naming a parameter the spec does not define is a startup error, so a typo fails loudly instead of silently dropping a field.

Loud Failures

Both expressions compile at startup, so a broken JSONata takes the server down on boot with a clear message rather than on the first call. A runtime evaluation failure comes back as an isError result that names the side that broke, request or response.

FAQ

Does shaping work in dynamic exposure mode?

Yes. A dynamically exposed operation gets the same shaped input schema, request, and response as the static path. The one thing dynamic mode drops is per-operation tool annotations.

Wrapping Up

One OpenAPI operation no longer has to mean one bad tool. params and strategy shape what the model sees, and request and response shape the bytes on the wire, all from one YAML block next to the operation. If you try it on a real API, I would love to hear which operations were the worst to tame.

Top comments (0)