CodeGym /Courses /ChatGPT Apps /Integrating ChatGPT App into an existing product and SDK/...

Integrating ChatGPT App into an existing product and SDK/MCP migrations

ChatGPT Apps
Level 20 , Lesson 2
Available

1. Why talk about integration and migrations at all

Up to now we mostly designed APIs and tools the way it was convenient. In real life it’s almost always the opposite: you already have:

  • a monolith or a bunch of microservices;
  • REST/GraphQL APIs;
  • business logic that has been running in prod for years.

And suddenly a task appears: “Connect our product to ChatGPT via the Apps SDK and MCP.”

Rewriting everything for an “ideal MCP server” is not an option. You need to carefully “put on” a thin layer over the existing world that translates your backend’s language into ChatGPT’s language: tools, resources, and schemas.

The second problem: the product is alive. Schemas and APIs change. In a regular frontend you at least immediately get a TypeScript error when a field changes. In the world of LLM apps, it’s trickier: the model will keep confidently sending the old format, the tool will crash, and instead of a clean compile-time failure you’ll get:

  • runtime errors on the MCP server;
  • hallucinations like “I kind of guessed what you wanted from this field”;
  • frustrating quality incidents.

Therefore in this lecture we look at the MCP+Apps layer as:

  • an adapter to your existing backend;
  • a contract you need to maintain for years;
  • a migration target: versions, annotations, scopes, and SDK.

2. Integration architecture: MCP as an adapter on top of the existing backend

The basic picture

Let’s recap the stack, but now through the lens of production:

flowchart LR
  U[User in ChatGPT] --> G[ChatGPT model]
  G -->|calls App| W["Widget (Apps SDK, Next.js)"]
  G -->|tools.call| MCP[MCP server / Gateway]
  MCP --> S1["Gift Service (your existing service)"]
  MCP --> S2["Commerce Service (orders, ACP)"]

ChatGPT communicates with your world not directly but via the MCP protocol: a list of tools/resources, calls tools/call, and event streaming.

In this scheme, the MCP server is exactly that adapter: it knows about ChatGPT (JSON‑RPC, tools) and about your services (REST/DB/queues), and it translates between them.

MCP as Gateway/Adapter

A classic setup: you already have a Gift Service with REST endpoints:

// Example of an existing REST API
GET  /api/gifts/recommendations?budget=100&occasion=birthday
POST /api/orders

Instead of writing new business logic, the MCP layer simply wraps this as a tool:

// mcp/tools/recommendGifts.ts
import { z } from "zod";
import { server } from "./mcpServer"; // a hypothetical SDK instance

const recommendGiftsInput = z.object({
  occasion: z.string(),
  budgetUsd: z.number().int().positive(),
});

server.registerTool({
  name: "recommend_gifts",
  description: "Finds gift ideas within the budget",
  inputSchema: recommendGiftsInput,
  async execute(args) {
    const { occasion, budgetUsd } = recommendGiftsInput.parse(args);
    const res = await fetch(
      `https://api.myapp.com/gifts/recommendations?budget=${budgetUsd}&occasion=${occasion}`,
    );
    return res.json(); // important: return JSON that is convenient for both the model and the widget
  },
});

All the gift selection logic remains inside your existing service. The MCP layer is a “thin translator” from ChatGPT’s language to your APIs.

Sometimes the MCP layer also routes requests to multiple backend services. In this case it turns into a full-fledged MCP Gateway — you will dive deeper into this role in the production and networking module.

Monolith-integrated MCP vs Sidecar MCP

There are two basic options for where to “attach” this MCP layer.

In text form, it looks like this:

Option Description Where MCP code lives
Monolith-integrated All in one Next.js/Node service In Next.js API routes or Express
Sidecar MCP A separate container/service that talks to the API A separate Node/Go application

In small projects, the first option is often enough: a Next.js app deployed to Vercel, with a route like /mcp or /api/mcp, and the MCP server lives next to the rest of your APIs.

Example (heavily simplified):

// app/api/mcp/route.ts (Next.js 16)
import { NextRequest } from "next/server";
import { mcpHandler } from "@/mcp/server";

export async function POST(req: NextRequest) {
  const body = await req.json();
  const response = await mcpHandler.handle(body); // JSON-RPC request
  return new Response(JSON.stringify(response), {
    headers: { "content-type": "application/json" },
  });
}

In a more mature architecture where there are multiple domain services (Gift, Commerce, Analytics), it’s convenient to move the MCP layer into a separate Gateway service. It will accept MCP traffic from ChatGPT and route calls to different backends by tool name.

Important to remember: from the point of view of ChatGPT and the Apps SDK, it’s still a single MCP server. Where exactly it runs — inside the monolith or as a separate microservice — is your architectural concern.

We’ve sorted out the MCP layer architecture: it can live inside the monolith or as a separate Gateway. Next comes the question of what exactly this layer accepts and returns — and this is where schemas and contracts come in.

3. Single source of truth: schemas, types, and contract tests

If you have internal DTOs, external REST contracts, and also MCP schemas for tools, the temptation to “sketch the schemas by eye” is huge. The result is predictable:

  • you change a field in the backend and forget to update the tool’s schema;
  • the model keeps sending the old format;
  • you get a fun runtime zoo.

The sane path: create a single source of truth for data structures and use it everywhere. In the TypeScript world this is very convenient to do with Zod or similar libraries, which the MCP SDK can convert into JSON Schema.

A shared Zod schema for GiftGenius

Suppose your Gift service in our GiftGenius training project already uses Zod to validate inputs:

// domain/gifts.ts
import { z } from "zod";

export const giftRecommendationInputSchema = z.object({
  occasion: z.string().describe("Occasion: birthday, wedding, etc."),
  budgetUsd: z.number().int().positive(),
  recipientProfile: z.string().describe("A brief description of the person"),
});

export type GiftRecommendationInput = z.infer<
  typeof giftRecommendationInputSchema
>;

The same schema is used:

  • in the REST endpoint (to validate the request body);
  • in the MCP tool (as the inputSchema);
  • in tests (as the basis for fixtures).

Wire the schema into an MCP tool

// mcp/tools/recommendGifts.ts
import { giftRecommendationInputSchema } from "@/domain/gifts";
import { server } from "../mcpServer";

server.registerTool({
  name: "recommend_gifts",
  description: "Gift selection by profile and budget",
  inputSchema: giftRecommendationInputSchema,
  async execute(args) {
    const input = giftRecommendationInputSchema.parse(args);

    const res = await fetch("https://api.myapp.com/gifts/recommendations", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(input),
    });

    return res.json();
  },
});

The SDK will automatically convert the Zod schema into a JSON Schema that ChatGPT will see in tools/list. This solves two problems at once:

  • tool argument types and code are tightly coupled;
  • when you change the schema, the TypeScript compiler will force you to update the handler as well.

Contract tests for MCP ↔ backend

Contract tests here are not a scary term, but a few very grounded checks.

The simplest unit/contract test can look like this:

// tests/mcp/recommendGifts.contract.test.ts
import { giftRecommendationInputSchema } from "@/domain/gifts";

test("sample request conforms to the tool schema", () => {
  const sample = {
    occasion: "birthday",
    budgetUsd: 150,
    recipientProfile: "colleague, likes gadgets",
  };

  expect(() => giftRecommendationInputSchema.parse(sample)).not.toThrow();
});

This test does not guarantee that the whole world is perfect, but it at least catches the desynchronization between the backend’s expectations and the MCP layer if you changed the schema and forgot to update fixtures.

From here, it’s easy to extend this approach to:

  • mocked responses from external APIs (Stripe, CMS);
  • running an MCP client against a real MCP server in a test environment.

4. Versioning strategies for tools and resources

Schemas will change sooner or later. The main thing is not to do it in the spirit of “I’ll just rename a field, what could possibly go wrong.” In the LLM world this can break not only the build, but also the model’s behavior: old prompts, saved conversations, and golden cases will keep expecting the old contract.

Additive vs breaking changes

Roughly, changes fall into two categories.

Additive changes — you add something without breaking existing users:

  • a new optional field in the response;
  • a new optional argument with a default value;
  • additional enum values that the UI and the model can safely ignore.

For example, you added a deliveryEstimateDays field to the tool’s response, but the old widget just ignores it. That’s safe: the schema can expand, but nobody is required to use it.

Breaking changes — you break existing expectations:

  • you make a field required when it didn’t exist before;
  • you change the type (string → object);
  • you change the meaning of arguments (budget in USD → budget in a local currency) while keeping field names the same.

In such cases, the only safe path is to introduce a new version of the tool.

Pattern Tool_v2

A classic pattern: you had recommend_gifts, and you want to seriously change the schema. You don’t touch the old tool; you create a new one — recommend_gifts_v2.

// v1
const recommendGiftsInput_v1 = z.object({
  occasion: z.string(),
  budgetUsd: z.number().int().positive(),
});

// v2: support for currencies and delivery filters
const recommendGiftsInput_v2 = z.object({
  occasion: z.string(),
  maxPrice: z.number().int().positive(),
  currency: z.enum(["USD", "EUR", "GBP"]),
  deliverByDate: z.string().optional(), // ISO string
});

server.registerTool({
  name: "recommend_gifts",
  description: "DEPRECATED: use recommend_gifts_v2",
  inputSchema: recommendGiftsInput_v1,
  async execute(args) { /* old logic */ },
});

server.registerTool({
  name: "recommend_gifts_v2",
  description:
    "Gift selection by budget, currency, and delivery deadline",
  inputSchema: recommendGiftsInput_v2,
  async execute(args) { /* new logic */ },
});

The model and old prompts/agents will keep using recommend_gifts until you update them. New scenarios you write against recommend_gifts_v2.

After the migration period:

  • golden cases and agents are switched to v2;
  • metrics show that v1 is almost never called;

you can start gracefully winding down v1 (for example, first hide it from the tool list in dev/staging, then in prod).

Versioning resources

Tools are not the only things that need versions. If you have resources (resources) — for example, a static gift catalog — it’s best to version them as well.

Popular options:

  • embed the version in the resource name: gift_catalog.v1.json, gift_catalog.v2.json;
  • or pass the version in the URI/parameter: /api/catalog?version=1.

The idea is the same: don’t swap data from under already running scenarios; provide them with an explicitly fixed catalog version.

Zero-downtime migrations

A typical tool migration cycle:

  1. Add the new version of the tool (_v2) alongside the old one.
  2. Update the App/agents/system prompt so they use the new version.
  3. Run golden cases and LLM eval for both variants and make sure quality hasn’t dropped for critical scenarios.
  4. Monitor usage metrics for v1 vs v2 (and errors).
  5. Once traffic to v1 is close to zero, start deactivating it.

This approach works well for schema migrations, for SDK/protocol updates, and for auth changes. We’ve covered how tools and resources evolve — via v1/v2 and careful additive changes. The second big part of the contract is authentication and authorization: OAuth, scopes, and .well-known. They also live for years and require careful migrations.

5. Authentication evolution: .well-known, scopes, and existing OAuth

If your product already lives in the OAuth 2.1/OpenID Connect world, integrating with ChatGPT via MCP is not “just another login,” but a new client that should talk to your Authorization Server under the common rules.

MCP and /.well-known/oauth-protected-resource

We cover full OAuth 2.1/OpenID Connect and Auth Server configuration in a separate module (see the authentication module). Here we only care about the practical side: how an MCP resource tells ChatGPT that it’s protected by OAuth and how to trigger the linking flow.

Standard pattern for protected MCP resources:

  • your MCP server exposes a special endpoint /.well-known/oauth-protected-resource;
  • in its response it states what the resource is and which AS (Authorization Server) protects it;
  • on 401 for an MCP call, the server returns a WWW-Authenticate header with a link to that .well-known, and ChatGPT starts the OAuth flow on its own (“Link account”).

Minimal Express example:

// mcp-auth/.well-known.ts
import express from "express";
const app = express();

app.get("/.well-known/oauth-protected-resource", (_req, res) => {
  res.json({
    resource: "https://mcp.myapp.com",
    authorization_servers: [
      "https://auth.myapp.com/.well-known/openid-configuration",
    ],
  });
});

app.listen(3000);

And a 401 handler with a hint for the client:

res
  .status(401)
  .set(
    "WWW-Authenticate",
    'Bearer resource_metadata="https://mcp.myapp.com/.well-known/oauth-protected-resource"',
  )
  .end();

Seeing this header, ChatGPT understands which AS to talk to and how to start the OAuth flow for your MCP resource.

Scopes and authorization migrations

Scopes are another source of migrations. We already covered this in the Auth module, but in the context of integration/migrations a few points are important.

Imagine that GiftGenius could initially only read the catalog (gifts.read), and later you added gifts.write to create orders. You need to:

  • add the new scope to the client configuration (ChatGPT App);
  • update the MCP server so it requires this scope only for tools that actually change something;
  • describe the changes in .well-known if necessary.

From a UX standpoint, the user may see a request to “expand permissions” for the ChatGPT app the next time they try to use the new functionality. You don’t want this to happen in the middle of an ongoing conversation without warning — therefore such changes need to be:

  • announced (release notes, documentation);
  • tested on staging with a test AS;
  • paired with tool description updates (destructiveHint, etc.) so the model consciously calls “dangerous” tools.

6. Metadata and annotations: a hint layer on top of the contract

The auth layer answers who can do what through your App. But even with correct tokens and scopes, it’s important how the model will call your tools and explain actions to the user. This is where an additional hint layer comes into play: metadata and annotations.

The contract (schema) says what the tool accepts and returns. Metadata and annotations help the model understand how and when to call it. This becomes especially important when you evolve the App: add new destructive actions, change the UI, or introduce integrations with the outside world.

_meta["openai/widgetDescription"] and widgetCSP

In the Apps SDK and MCP descriptions there is a special _meta field where OpenAI adds protocol extensions. For example:

  • _meta["openai/widgetDescription"] — a brief description of what your widget shows; the model can use it so it doesn’t “re-narrate” the UI and can announce the App properly;
  • _meta["openai/widgetCSP"] — a declaration of CSP domains your widget requires (for fetch/images/scripts).

When you change the UI (for example, add a new step to checkout), it’s helpful to update widgetDescription so the model continues to explain to the user what’s happening correctly.

Tool annotations (readOnlyHint, destructiveHint, openWorldHint)

Annotations are simple boolean flags that significantly affect UX and safety:

  • readOnlyHint: true — the tool does not change anything (read-only). The model can call it without extra confirmations.
  • destructiveHint: true — the tool can delete/change something. ChatGPT will ask for explicit confirmation.
  • openWorldHint: true — the tool publishes data externally or can return “a lot of stuff,” which may require summarization.

Example of a tool descriptor with annotations:

server.registerTool({
  name: "delete_saved_gift",
  description: "Deletes a user's saved gift",
  inputSchema: z.object({ giftId: z.string() }),
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
    openWorldHint: false,
  },
  async execute({ giftId }) {
    // ...delete the gift
  },
});

During migrations, when you add new “dangerous” tools, annotations are your friends: they help ChatGPT avoid executing them silently and nudge it toward more cautious behavior.

It’s important to understand that annotations are not “real” protection. They only influence the client and model behavior. Real security is still enforced by your server (Auth, scopes, validation).

7. SDK and MCP specification migrations

MCP and the Apps SDK are evolving rapidly — new fields appear in capabilities, new message types, new _meta/annotations. The documentation honestly warns: “as of 2025” — and we have to live with that.

Therefore, SDK and spec version migrations are a normal part of App life, not a rare “sometime later” event.

A typical upgrade process

A healthy update workflow looks roughly like this:

  1. Read the changelog of the new Apps SDK/MCP SDK version. Mark all potentially breaking changes.
  2. Update dependencies in a dev/staging environment without touching prod.
  3. Run MCP Inspector / Jam or another client:
    • check the handshake;
    • tools/list / resources/list;
    • a few test tools/calls.
  4. Update tool descriptions and _meta according to new capabilities:
    • for example, add new annotations or widgetDescription.
  5. Run golden cases and LLM eval (as discussed in previous lectures) to ensure App behavior has not degraded in terms of quality.
  6. Only then deploy updates to prod, ideally using canary/feature flags for a subset of traffic.

Example: adding openWorldHint in a new SDK version

Suppose a new Apps SDK version added support for openWorldHint, and you decided to mark the search_public_reviews tool with it — it crawls external reviews and can return a lot of noise.

The steps look like this:

  • update the SDK and types;
  • add annotations.openWorldHint = true to the tool descriptor;
  • update the system prompt so the agent explicitly explains to the user that it’s about to request data from the outside world;
  • run safety golden cases (especially around privacy/PII) to ensure the model hasn’t become excessively talkative.

We discussed the overall process of updating the SDK and annotations. Let’s now see it all in one concrete scenario — the evolution of the recommend_gifts tool.

8. Mini case: evolution of recommend_gifts in GiftGenius

Let’s put everything together on a specific scenario.

Initial version

The basic tool looked like this:

const recommendGiftsInput_v1 = z.object({
  occasion: z.string(),
  budgetUsd: z.number().int().positive(),
  recipientProfile: z.string(),
});

server.registerTool({
  name: "recommend_gifts",
  description: "Picks gift ideas in USD",
  inputSchema: recommendGiftsInput_v1,
  async execute(args) {
    const input = recommendGiftsInput_v1.parse(args);
    return giftService.recommend(input); // internal function
  },
});

Everything is fine as long as you only have users in the US and a single currency.

New business requirements: multi-currency and deadline

The product team brings new requirements:

  • support EUR/GBP;
  • take the delivery deadline into account (don’t show gifts that will arrive in a month if the birthday is in three days);
  • ideally add a delivery time estimate to the response.

A naive approach: just change the fields:

  • rename budgetUsd to maxPrice;
  • add currency;
  • add deliveryEstimateDays to the response.

What will go wrong?

Old prompts (including golden cases and the system prompt description) and saved conversations continue to send budgetUsd. The model doesn’t know it no longer exists. The MCP layer will start failing when trying to parse. The ChatGPT App’s behavior suddenly breaks for real users.

The right way:

  1. Add a new schema and a new tool _v2.
const recommendGiftsInput_v2 = z.object({
  occasion: z.string(),
  maxPrice: z.number().int().positive(),
  currency: z.enum(["USD", "EUR", "GBP"]),
  recipientProfile: z.string(),
  deliverByDate: z.string().optional(),
});

server.registerTool({
  name: "recommend_gifts_v2",
  description:
    "Gift selection taking currency and desired delivery date into account",
  inputSchema: recommendGiftsInput_v2,
  async execute(args) {
    const input = recommendGiftsInput_v2.parse(args);
    return giftService.recommendV2(input); // new logic
  },
});
  1. Leave recommend_gifts as is, adding a description note of DEPRECATED.
  2. Update the system prompt and App descriptions so the model prefers recommend_gifts_v2 (you can explicitly state this in instructions).
  3. Update the GiftGenius widget so it understands the new response format: the deliveryEstimateDays field, etc.
  4. Run golden cases for typical scenarios (gift selection by a certain date) through LLM eval.

Tests and observability

A couple of tests you want to have:

Contract test for the new input:

test("v2 accepts a scenario with EUR and a deadline", () => {
  const sample = {
    occasion: "birthday",
    maxPrice: 100,
    currency: "EUR",
    recipientProfile: "colleague",
    deliverByDate: "2025-12-24",
  };

  expect(() => recommendGiftsInput_v2.parse(sample)).not.toThrow();
});

Observability in prod:

  • the share of calls to recommend_gifts_v2 vs recommend_gifts;
  • error rate for v1 (we expect it not to grow);
  • LLM eval score on golden cases before/after migration (from previous lectures you already know how to do this).

When v2 “wins” both in quality and usage metrics, you can carefully plan deactivation of v1.

If we simplify down to three thoughts: (1) MCP is a thin adapter, not a new monolith; (2) schemas, auth, and annotations are a long-lived contract between ChatGPT and your backend that must be versioned and tested as carefully as regular APIs; (3) any SDK/spec migrations are a normal engineering process with staging, golden cases, and observability — not “we updated a package on Friday night.” If you look at a ChatGPT App through this lens, integrations with an existing product will stop feeling like chaos.

9. Common mistakes in MCP/SDK integration and migrations

Mistake #1: MCP as “a new backend” instead of a thin adapter.
It’s tempting to pull all business logic into the MCP layer: DB calls, domain rules, calculations. This turns the MCP server into yet another monolith that is hard to keep in sync with the rest of the backend. It’s much healthier to keep MCP as a Gateway/Adapter over existing services: all domain logic lives where it did before ChatGPT, and MCP only translates JSON back and forth.

Mistake #2: Different schemas for the same object.
A common antipattern is to have three definitions of “gift”: one in the DB, one in the REST API, and one in the MCP tool — all slightly different. As a result, static typing, contracts, tests, and common sense break down. Using a single schema (Zod/TypeBox, etc.) as the single source of truth and generating JSON Schema for MCP significantly reduces this risk.

Mistake #3: Bad schema migrations — a “silent” breaking change.
Renaming a field or changing its meaning without changing the tool name is a path to a hidden regression. The model will keep sending the old format, and the incident will only surface for some users and not immediately. For serious changes, introduce *_v2, keep the old version running in parallel, use deprecation notes and monitoring.

Mistake #4: Ignoring auth changes and scopes.
You added a new tool with side effects but forgot to update scopes and .well-known? The user may either get a 401 in the middle of a scenario, or worse, your MCP will start performing destructive operations without adequate authorization. Plan auth-layer migrations as carefully as schema migrations: through staging, tests, and gradual permission expansion.

Mistake #5: Not using annotations (destructiveHint, readOnlyHint, openWorldHint).
If you don’t hint to the model which tools are safe and which are potentially dangerous, it may behave unexpectedly: asking for confirmation for a harmless get_catalog while silently performing data deletion. Proper annotations make behavior predictable for the user and reduce the risk of quality and security incidents.

Mistake #6: Updating the SDK “in prod” without running golden cases.
A new SDK/spec version can add fields, change handshake behavior, or message structure. If you just “update dependencies and deploy,” you risk hitting a quality regression (the model stopped calling the right tool, error wording changed, etc.). First — dev/staging, MCP Inspector, then golden cases and LLM eval, and only after that — prod.

Mistake #7: Hard-wiring business logic to a single tool version.
When internal Gift Service logic directly depends on a specific recommend_gifts, it’s painful to migrate to recommend_gifts_v2. A best practice is to have an internal service that evolves by its own rules, while *_v1 and *_v2 tools are just thin adapters mapping old and new external contracts to common domain structures.

Mistake #8: No observability by tool versions.
If in logs and metrics you don’t distinguish which tool and version were called, debugging migrations turns into guesswork. Log the tool name, schema/SDK version, and key parameters — then any regressions are easier to tie to a specific change.

1
Task
ChatGPT Apps, level 20, lesson 2
Locked
Single Source of Truth for tool input (Zod → MCP + REST)
Single Source of Truth for tool input (Zod → MCP + REST)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION