---
title: "Deep Dive: Empowering MCP Apps with Zuplo MCP Server Handler"
description: "MCP Apps are a new standardized way for users to interface with services and agentic capabilities in tools they already use every day, like ChatGPT. Zuplo MCP Server Handler is a perfect companion for building robust apps with delightful user experiences and agentic capabilities!"
canonicalUrl: "https://zuplo.com/blog/2026/01/08/mcp-openai-apps-sdk"
pageType: "blog"
date: "2026-01-08"
authors: "john"
tags: "Model Context Protocol"
image: "https://zuplo.com/og?text=MCP%20Apps%20Deep%20Dive"
---
The [OpenAI Apps SDK](https://developers.openai.com/apps-sdk) and
[the new MCP Apps extension](https://blog.modelcontextprotocol.io/posts/2025-11-21-mcp-apps/)
are powerful ways for users to interface with AI systems and capabilities within
applications they are already using (like ChatGPT and Claude). The OpenAI Apps
SDK extends MCP by taking advantage of `resources` and `tools` in order to serve
UI components and agentic capabilities directly within a chat interface.

[Zuplo's MCP Server Handler](https://zuplo.com/docs/handlers/mcp-server) is a
perfect fit for integrating with the OpenAI Apps SDK as it allows you to utilize
your existing APIs and policies with a robust and powerful MCP server.

Let's build a simple app using OpenAI's Apps SDK using the Zuplo MCP Server
Handler and take a deep look at how this works for MCP under the hood.

:::info

This is an advanced guide on building cutting edge MCP features: some of these
semantics and interfaces are likely to change in the future. Be sure to read our
[getting started guide on MCP with Zuplo](https://zuplo.com/docs/articles/mcp-quickstart)
and
[the docs for building powerful MCP servers on Zuplo](https://zuplo.com/docs/handlers/mcp-server).

:::

## Sample API

First, let's start with a simple demo API. This is a stub weather API that takes
latitude and longitude query parameters, returning the weather conditions for
that location.

In `config/routes.oas.json`, add the following route inside the `paths` object:

```json
"/weather": {
  "get": {
    "operationId": "getWeather",
    "summary": "Get weather",
    "description": "Retrieves weather conditions for a specified latitude and longitude",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "export": "urlForwardHandler",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "baseUrl": "https://weather.zuplo.io"
        }
      }
    },
    "parameters": [
      {
        "name": "lat",
        "in": "query",
        "required": true,
        "description": "Latitude of the location (-90 to 90)",
        "schema": {
          "type": "number",
          "minimum": -90,
          "maximum": 90
        }
      },
      {
        "name": "lon",
        "in": "query",
        "required": true,
        "description": "Longitude of the location (-180 to 180)",
        "schema": {
          "type": "number",
          "minimum": -180,
          "maximum": 180
        }
      }
    ],
    "responses": {
      "200": {
        "description": "Weather data for the location",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "temperature": { "type": "number" },
                "condition": { "type": "string" },
                "humidity": { "type": "number" }
              }
            }
          }
        }
      }
    }
  }
}
```

Once deployed on Zuplo, we can hit this API using `curl` and it'll return a
payload of weather data:

```bash
curl "https://openai-apps-sdk-weather.d2.zuplo.dev/weather?lat=30&lon=30"
```

Notice that we provide the `lat` and `lon` query parameters. These will be used
later as arguments to the MCP tool that is mapped to this endpoint.

```json
{
  "temperature": 72,
  "condition": "sunny",
  "humidity": 45
}
```

:::note

For the purposes of this demo, the `/weather` API endpoint uses the
`urlForwardHandler` to forward requests to the demo `https://weather.zuplo.io`
backend that returns the same weather data every time. In a real production
setup, you would set the `baseUrl` to your actual weather API backend.
[Learn more about the URL Forward Handler in our docs](https://zuplo.com/docs/handlers/url-forward).

:::

## MCP Server

Next, we need to setup the MCP server to be able to utilize our `/weather`
endpoint and expose it as a tool. On a new `/mcp` endpoint, we can build a
`POST` route that uses the `mcpServerHandler`. This automatically bootstraps a
full MCP server with JSON schema verifiable tools.

In `config/routes.oas.json`, add the following route inside the `paths` object:

```json
"/mcp": {
    "post": {
      "operationId": "mcpServer",
      "summary": "MCP Server",
      "description": "Model Context Protocol <> OpenAI Apps SDK endpoint",
      "x-zuplo-route": {
        "corsPolicy": "none",
        "handler": {
          "export": "mcpServerHandler",
          "module": "$import(@zuplo/runtime)",
          "options": {
            "name": "weather-mcp",
            "version": "0.0.0",
            "includeStructuredContent": true,
            "operations": [
              {
                "file": "./config/routes.oas.json",
                "id": "getWeather"
              }
            ]
          }
        }
      }
    }
  }
```

Let's look more closely at the options we provided the MCP server handler:

- `name` is the name of the MCP server. This is shown to MCP clients when they
  initialize with the server.
- `version` is the version of the MCP server itself. This is also shown to MCP
  clients during initialization.
- `includeStructuredContent` adds a `structuredContent` object during tool
  calls. This is required by the OpenAI Apps SDK in order to populate
  `window.openai` data with tool call results for the UI widgets to utilize.
- `operations` is a list of objects with file and operation ID association.
  These mapped OpenAPI operations are then transformed into MCP entities (like
  `tools` and `resources`) as defined by the operation's `x-zuplo-route.mcp`
  configuration. By default, operations are assumed to be tools and get several
  sane defaults. Since we are adding the `getWeather` operation, we can utilize
  it as a tool immediately.

:::info

Learn more about the configuration options for the `mcpServerHandler`
[in the Zuplo docs.](https://zuplo.com/docs/handlers/mcp-server#configuration)

:::

We can list the tools on this server by calling the `tools/list` MCP method:

```sh
curl https://openai-apps-sdk-weather.d2.zuplo.dev/mcp \
      -X POST \
      -H 'accept: application/json, text/event-stream' \
      -d '{
    "jsonrpc": "2.0",
    "id": "1",
    "method": "tools/list"
  }'
```

Note that we are making the `POST` request to the `/mcp` endpoint with the hand
crafted JSON RPC 2.0 payload: since Zuplo builds a stateless, HTTP streamable
MCP server, every request to the server by a client will be a `POST`. The
results show us the tools on the server by name and include the `inputSchema`
derived for each tool based on the input parameters of the OpenAPI route for
that mapped tool:

```json
{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "tools": [
      {
        "name": "getWeather",
        "description": "Get the weather conditions for a location specified by latitude and longitude coordinates",
        "inputSchema": {
          "type": "object",
          "properties": {
            "queryParams": {
              "type": "object",
              "properties": {
                "lat": {
                  "description": "Latitude of the location (-90 to 90)",
                  "type": "number",
                  "minimum": -90,
                  "maximum": 90
                },
                "lon": {
                  "description": "Longitude of the location (-180 to 180)",
                  "type": "number",
                  "minimum": -180,
                  "maximum": 180
                }
              },
              "required": ["lat", "lon"],
              "additionalProperties": false
            }
          },
          "required": ["queryParams"],
          "additionalProperties": false,
          "$schema": "https://json-schema.org/draft/2020-12/schema"
        }
      }
    ]
  }
}
```

In this case, we have a mapping on `lat` and `lon` properties that are required
by the input schema. We can call this tool by providing the `name` of the tool
alongside the required `arguments` (again, as defined by the input schema):

```sh
curl https://openai-apps-sdk-weather.d2.zuplo.dev/mcp \
      -X POST \
      -H 'accept: application/json, text/event-stream' \
      -d '{
    "jsonrpc": "2.0",
    "id": "1",
    "method": "tools/call",
    "params": {
      "name": "getWeather",
      "arguments": {
        "queryParams": {
          "lat": 30,
          "lon": 0
        }
      }
    }
  }'
```

```json
{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"temperature\":72,\"condition\":\"sunny\",\"humidity\":45}"
      }
    ],
    "structuredContent": {
      "temperature": 72,
      "condition": "sunny",
      "humidity": 45
    },
    "isError": false,
    "_meta": {}
  }
}
```

:::note

Notice that we provide the query params as arguments in a `queryParams` object
in the JSON RPC message. Argument serialization and verification is all taken
care of by the MCP server handler as it automatically transforms the OpenAPI
spec into an MCP callable tool with the appropriate `inputSchema`. Also notice
that we get back the `structuredContent` object: again, this is required by the
OpenAI Apps SDK in order to populate the global `window.openai` object with the
tool call results. It is also worth noting that all of this is more or less
handled by the inner workings of MCP client and server communication: it's
useful to understand what's going on within the protocol itself as this informs
how MCP apps are crafted and how they work.

:::

## Build a UI template `resource`

When calling tools in apps, the OpenAI Apps SDK expects some sort of UI template
that it can display in its HTML sandbox. These bundles of tools and widgets make
up the full, end to end user experience within the chat. Think of it as dynamic
UI generation: the MCP resource renders HTML based on the tool's output, which
the chat client then displays in its sandbox.

Let's stub out another simple API endpoint that we can use as a `resource` in
our MCP server that will render the HTML we need.

In `config/routes.oas.json`, add the following route inside the `paths` object:

```json
"/weather/widget": {
  "get": {
    "operationId": "weather_widget",
    "description": "Serves the Apps SDK widget HTML for the weather tool",
    "x-zuplo-route": {
      "corsPolicy": "none",
      "handler": {
        "export": "default",
        "module": "$import(./modules/widget)"
      },
      "mcp": {
        "type": "resource",
        "name": "widget_get_weather",
        "description": "The widget HTML for the Apps SDK applet",
        "uri": "ui://widget/weather.html",
        "mimeType": "text/html+skybridge",
        "_meta": {}
      }
    }
  }
}
```

You'll also need to add this operation to your MCP server's `operations` array
in the `/mcp` route configuration:

```json
"operations": [
  {
    "file": "./config/routes.oas.json",
    "id": "getWeather"
  },
  {
    "file": "./config/routes.oas.json",
    "id": "weather_widget"
  }
]
```

`x-zuplo-route.mcp` is a _very_ important chunk of OpenAPI JSON, so let's take a
bit of time to understand _exactly_ what's going on:

- The `x-zuplo-route.mcp` object on the route defines MCP _specific_ metadata
  for the entities (resources, tools, etc.). In MCP, resources are typically
  read-only objects like files on a filesystem or blobs in a bucket. In this
  case, we're providing some HTML that will accompany the tool in order to
  provide a UI for the end user. While we provide some sane defaults (like
  utilizing the OpenAPI `operationId` and `description`), it's important to use
  AI-specific names and descriptions, especially if your OpenAPI descriptions
  are generic or intended for non-agentic audiences. You can think of this
  object as the place where you can perform "prompt engineering" or "context
  engineering" in order to fine tune the metadata and get the most value out of
  your MCP tools and resources. In the case of the OpenAI Apps SDK, this is the
  name and description of the `resource` the chat agent will use when it
  executes a specific tool from the MCP server.
- `type` is the type of MCP entity this route will be: by default, this is a
  tool. In this case, we're setting this as a `resource`.
- `name` is the name of the `resource` that the AI agent or system will see when
  it performs a `resources/list`: this name is very important as it is one of
  the main ways to "communicate" to the downstream agent exactly _what_ this
  entity is. In our case, we want to communicate that this is the widget for the
  weather tool.
- `description` is the long-form description of the `resource`. Along with the
  `name` field, this is the main way the AI agent "understands" exactly what
  this `resource` is.
- `uri` is the "location" of the resource. Historically, this would have been a
  literal filepath or link to a bucket but for the OpenAI Apps SDK, we set this
  URI to a non-existent, "virtual" `ui://` path which can then be referenced
  later using this exact path. This field will be linked to a tool in metadata,
  so note the `uri` you give it carefully!
- `mimeType` is an optional field that defines what "type" the `resource` is.
  Importantly, for the OpenAI Apps SDK, this _must_ be set to
  `text/html+skybridge` in order to display it in the HTML sandbox within the
  chat.
- `_meta` is the free object field that is used during publishing to set the
  domain and other important metadata for the resource.

Now, let's briefly look at the TypeScript module that is used as a `resource` in
order to render the widget.

Create the file `modules/widget.ts` with the following content:

```ts
import { ZuploContext, ZuploRequest } from "@zuplo/runtime";

export default async function (request: ZuploRequest, context: ZuploContext) {
  return new Response(
    `<div id="root"></div>
<style>
  body { font-family: sans-serif; padding: 16px; margin: 0; }
  .card { border: 1px solid #ccc; padding: 16px; border-radius: 8px; display: inline-block; }
  .temp { font-size: 32px; font-weight: bold; }
</style>
<script>
  // simple render function for pulling out `toolOutput` from OpenAI's global
  // state after tool call completed
  function render() {
    var data = window.openai && window.openai.toolOutput ? window.openai.toolOutput : null;
    if (!data) {
      document.getElementById("root").innerHTML = '<div class="card">Loading...</div>';
      return;
    }
    document.getElementById("root").innerHTML = '<div class="card"><div class="temp">' + data.temperature + '°F</div><div>' + data.condition + '</div><div>Humidity: ' + data.humidity + '%</div></div>';
  }

  // Initial render
  render();

  // Listen for updates from ChatGPT host on global state being set
  window.addEventListener("openai:set_globals", function(event) {
    render();
  });
</script>`,
    {
      headers: { "Content-Type": "text/html" },
    }
  );
}
```

This simply returns the literal `text/html` of our widget including the HTML
components, the styling, and the scripts. In a real production setting, you may
choose to build this on the gateway like in this example, pull the documents
from a bucket, or do more robust rendering or template in your backends.

In this example, you'll notice a special, globally exposed `window.openai`
object alongside a `openai:set_globals` event listener. While designing robust
UIs for the OpenAI Apps SDK is beyond the scope of this post, understanding
these pieces and how they relate to your user's experience is important. The
data in `window.openai.toolOutput` will carry the structured content of the tool
call associated with this widget and can be used to populate the UI. This is the
data that is presented to our sandboxed widget from the tool in order to display
the weather (i.e., the `temperature`, `condition`, and `humidity`).

:::info

Read more about building robust UI components and the global `window.openai`
object available to UI components on
[the OpenAI docs site.](https://developers.openai.com/apps-sdk/build/chatgpt-ui)

:::

Next, let's wire up the tool to integrate with our widget UI!

## Connect with the tool

In order to take advantage of the OpenAI Apps SDK displaying widgets, we need to
populate some `_meta` into our tool: let's go back to the `/weather` route and
do this directly in the `x-zuplo-route.mcp` property.

In `config/routes.oas.json`, update the `/weather` route's `x-zuplo-route`
object to include the `mcp` property:

```json
"x-zuplo-route": {
  "corsPolicy": "none",
  "handler": {
    "export": "default",
    "module": "$import(./modules/weather)"
  },
  "mcp": {
    "type": "tool",
    "name": "get_weather",
    "description": "Retrieve and render application weather component",
    "annotations": {
      "readOnlyHint": true
    },
    "_meta": {
      "openai/toolInvocation/invoking": "Getting weather ...",
      "openai/toolInvocation/invoked": "Weather ready!",
      "openai/outputTemplate": "ui://widget/weather.html"
    }
  }
}
```

Like the data on the widget's `resource`, the `x-zuplo-route.mcp` data on the
`tool` for this route is just as important:

- `type` is the type of MCP entity this route will be: by default, this is a
  tool.
- `name` is the tool name that the AI agent or system will see: again, this is
  very important to help the agent "understand" what invoking this tool and its
  associated widget will produce. Here, we give the tool the name `get_weather`
  in order to differentiate it from the `getWeather` operation ID in our OpenAPI
  doc. From my personal experience, extremely clear and structured tool names
  with underscores like `some_tool_name` are better for the agents understanding
  and success rate. This often does not align with what organizations have
  defined as an `operationId` in their OpenAPI doc so I encourage you to take
  advantage of this field and provide a unique, agentic focused tool name.
  Again, I cannot stress how important a clear tool name is!
- `description` is the long-form description of the MCP tool: again, this is
  used by the LLM to "understand" exactly _what_ this tool is and what it's for.
  This will drastically inform _when_ the AI chat agent calls this tool and
  displays this tool's widget.
- `annotations` are optional MCP specific annotations. In this case, we provide
  a `readOnlyHint` in order to inform the MCP client and agent that this tool
  does not mutate anything and is "read only" by nature.
  [There are lots of supported annotations](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
  that you may choose to use.
- `_meta` is an optional "free" object where we can provide any metadata. In
  this case, we must use it to provide the OpenAI Apps SDK specific metadata:
  - `openai/toolInvocation/invoking` is the message the chat agent will display
    when invoking the tool.
  - `openai/toolInvocation/invoked` is the message the chat agent will display
    when the tool has been invoked.
  - `openai/outputTemplate` is the URI of the `resource` that will be displayed
    to the end user. This is what connects this `tool` with the `resource` that
    holds our widget as a sort of "bundle" to be utilized in the chat interface.
    Again, this is what links the HTML widget we built as a `resource` with the
    actual `tool`!

## Testing

Once we deploy all of this to a Zuplo gateway, we have a working MCP server
behind `https` that can execute our tool (which calls the `/weather` API) and
serves a small UI component to display in the chat interface's sandbox.

In order to test this:

1. Go to `https://chatgpt.com` > Settings > Apps > Advanced Settings
2. Ensure that you have "Developer mode" enabled
3. Select the "Create app" button
4. Enter the details of your App including the `/mcp` endpoint for the server

Test the app in the chat interface by selecting it and enabling it from the
"More" menu in the chat interface.

![OpenAI Apps SDK integration](/media/posts/2026-01-08-mcp-openai-apps-sdk/integrated.png)

Here we can see my "Test Apps SDK" App is being used, it's displaying my widget,
it's called the `get_weather` tool which returned our stub weather data. The
widget with its hook then displays this data!

## Advanced

In some cases, you may need to intercept the call to your API with a "wrapper"
module. This may be necessary to set specific headers, invoke other ancillary
APIs, or intercept the raw tool call request.

You can accomplish this through the `ZuploMcpSdk` in the `@zuplo/runtime`
package in a separate module. Then, set this new module as the tool that
interfaces with the Apps SDK client.

```
MCP client --> Zuplo MCP server --> Wrapper module --> API backend
```

In the following wrapper example, we use the Zuplo MCP SDK to access the
incoming MCP request `_meta` manually in order to get a special field and use it
in a `POST` body to _another_ route on the gateway using the
`context.invokeRoute` method.

Create a wrapper module in `modules/weather-wrapper.ts`:

```ts
import { ZuploContext, ZuploMcpSdk, ZuploRequest } from "@zuplo/runtime";

export default async function handler(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const sdk = new ZuploMcpSdk(context);

  // Access the incoming MCP request metadata
  const mcpRequest = sdk.getRawCallToolRequest();

  // Invoke another route on the gateway and get data for the application
  // using the "specialField" in the MCP metadata
  const response = await context.invokeRoute("/internal/route", {
    method: "POST",
    body: JSON.stringify({
      specialField: mcpRequest?.params._meta.specialField,
    }),
    headers: {
      "Content-Type": "application/json",
    },
  });
  const data = await response.json();

  // Set metadata on the response for the ChatGPT widget
  sdk.setRawCallToolResult({
    content: [{ type: "text", text: "Data retrieved" }],
    _meta: {
      specialApplicationState: data,
      timestamp: new Date().toISOString(),
    },
  });

  return data;
}
```

This is especially useful when using `_meta` for data exclusively for the
widget: i.e., data that exists _out of band_ of the actual LLM context window or
LLM generated tool arguments. Using `ZuploMcpSdk` gives you full unbridled
access to the original MCP request.

:::info

[Learn more about using the `ZuploMcpSdk` on the docs site](https://zuplo.com/docs/mcp-server/openai-apps-sdk#zuplomcpsdk)
including `getRawCallToolRequest` and `setRawCallToolResult`.

:::

## Next Steps

When actually submitting your OpenAI App for review, there are a few additional
`_meta` fields you'll need to configure on your widget resource including
[`openai/widgetCSP`](https://developers.openai.com/apps-sdk/build/mcp-server/#build-your-mcp-server)
and
[`openai/widgetDomain`](https://developers.openai.com/apps-sdk/build/mcp-server/#build-your-mcp-server).

Further, there are
[several advanced capabilities in the `window.openai` object](https://developers.openai.com/apps-sdk/build/chatgpt-ui)
that make building robust UIs easier with more powerful user interactions.

[Soon, Model Context Protocol will adopt "MCP Apps"](https://blog.modelcontextprotocol.io/posts/2025-11-21-mcp-apps/)
as an official MCP extension. This official extension will be informed largely
on the basis of how OpenAI built the Apps SDK. While it will look very similar
to the OpenAI Apps SDK, keep an eye out for an official standard to be available
in order to make your apps available in more chat clients!

Good luck and happy MCP-ing!