CodeGym /Courses /ChatGPT Apps /MCP message format: requests, replies, notifications, too...

MCP message format: requests, replies, notifications, tools/resources/prompts

ChatGPT Apps
Level 6 , Lesson 1
Available

1. MCP and JSON‑RPC: the “boring” foundation you need to understand once

In the previous lecture, we talked about why MCP exists at all and how it fits into the Apps SDK stack. In this lecture, we narrow the focus to the “boring” layer — the MCP message format — so you can confidently read raw JSON logs and understand what exactly ChatGPT sends to your server and what the server returns.

MCP uses JSON‑RPC 2.0 as the data transport: all requests, responses, and notifications are regular JSON objects with a predictable schema.

So, instead of “every service invents its own format,” there’s a basic contract:

  • a request has a required jsonrpc field (usually "2.0"), a unique id, a string method name method, and a params object with parameters;
  • a reply is correlated to the request via id and contains either result or error;
  • notifications look like requests but without id, and there is no reply to them.

It looks roughly like this:


{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/list",
  "params": {
    "cursor": null
  }
}

This is a request. And the success reply:

{
  "jsonrpc": "2.0",
  "id": 42,
  "result": {
    "tools": [],
    "nextCursor": null
  }
}

If you thought “this is just regular RPC,” you’re right. MCP simply fixes which methods exist (tools/list, tools/call, resources/list, prompts/list, …) and in what format they expect parameters and return data.

The key feeling to develop: JSON‑RPC is the skeleton — “request–reply–notification.” MCP is “which specific requests exist and what’s inside them.”

2. Request: how MCP asks to do something

Let’s start with requests. They always head toward “someone wants to do something.” Usually it’s client → server (ChatGPT → your MCP server), but MCP also allows reverse requests, where the server asks the client to do sampling or elicitation. In this lecture, we mostly care about the classic variant: the client asks the server.

Any MCP request has three key fields:

  1. jsonrpc — the JSON‑RPC protocol version, usually "2.0".
  2. id — the request identifier; any JSON type, but in practice most often a number or a string. The main thing is that id values are unique for active requests.
  3. method — a string like "tools/list" or "tools/call". MCP specifies the set of allowed methods.

And there is a params object that contains the parameters for the specific method.

Example: requesting the list of tools

Imagine ChatGPT has just connected to your MCP server and wants to learn which tools it can call. It will send a request roughly like this:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "cursor": null
  }
}

The cursor field is for pagination — if there are many tools, the server can return them in chunks.

For our tutorial app (gift picker), this will still be a bit boring: one or two tools, but the protocol remains the same. For now, treat it as an intuitive example; we’ll go through the formal structures later in the tools section.

Example: invoking a tool (tools/call)

Now something a bit more interesting. Suppose we already have an MCP tool suggest_gifts, which you plan to implement in the lecture about the MCP server. It expects parameters:

  • occasion — the occasion (Birthday, Wedding, …),
  • budget — a number in dollars,
  • recipient — a string describing who the gift is for.

When ChatGPT decides to use this tool, it will form an MCP request:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "suggest_gifts",
    "arguments": {
      "occasion": "birthday",
      "budget": 100,
      "recipient": "friend who loves board games"
    }
  }
}

Note a few details.

First, the tool name comes from what you registered on the server side (server.registerTool("suggest_gifts", …)). Second, the arguments object must conform to the JSON Schema you attach in the tool description.

If GPT tries to send arguments that don’t match the schema (for example, budget: "a hundred bucks"), the server has the right to return an error at the protocol level or at the business‑logic level, depending on the implementation. For now, the main thing is to grasp the general shape of such a request; in the tools section below we’ll look at these same messages more systematically.

Requests for resources and prompts

Requests to resources and prompts look similar. The MCP specification defines the methods:

  • resources/list — list available resources;
  • resources/read (or resources/get) — read a specific resource by URI;
  • prompts/list — get the list of available prompts;
  • prompts/get — get the text of a specific prompt.

Example of a read request for a gift catalog resource:

{
  "jsonrpc": "2.0",
  "id": 15,
  "method": "resources/read",
  "params": {
    "uri": "mcp://gift-server/resources/gift_catalog"
  }
}

For now, remember two things. First, for each primitive there are */list and */get/*/read methods. Second, the method name always lives in the string field method, and all the content is in the params object.

3. Reply: how MCP responds — result and error

A reply is always tied to a request by the id field. It’s like correlationId in many distributed systems: you look at the logs and see that the request with id=7 received a reply with id=7, so that’s one pair.

JSON‑RPC sets a simple rule: the reply contains either result or error, but not both. On top of this, MCP clarifies the structure of result for different methods (tools/list, tools/call, etc.) and recommends error codes.

Successful reply (result)

Let’s look at an example of a successful reply to tools/call for our suggest_gifts. The server did all the work, found suitable gifts, and returns a list in the result field:

{
  "jsonrpc": "2.0",
  "id": 7,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Here are some gift ideas for your friend..."
      }
    ],
    "structuredContent": {
      "gifts": [
        { "name": "Board game: Catan", "price": 45 },
        { "name": "Dice set", "price": 20 }
      ]
    },
    "isError": false
  }
}

A few important points here.

  • First, content and structuredContent are the parts of MCP tool replies you’ve already seen in the Apps SDK. The model uses text from content, and your widget neatly renders the data from structuredContent.
  • Second, the isError flag relates to the business result. From the protocol’s point of view, everything went fine: JSON is valid, the method exists, arguments were parsed. But business logic might say, “I didn’t find any gift ideas; from a UX standpoint we consider this an error.” Then you set isError: true and describe the problem in content.
  • Third, the MCP specification for different methods (tools/list, tools/call, */list, */get) details which fields must be present in result. For example, for tools/list the server returns an array of tool descriptions with names, titles, descriptions, and the JSON Schema for the input arguments.

Error reply (error)

If something goes wrong at the protocol or server level, instead of result, an error object is returned. It usually has:

  • code — a numeric error code;
  • message — a human‑readable description;
  • data — optional additional data (stack trace, details, …).

Example: the model called a non‑existent method:

{
  "jsonrpc": "2.0",
  "id": 99,
  "error": {
    "code": -32601,
    "message": "Method not found: tools/col"
  }
}

The code -32601 is the classic JSON‑RPC “method not found.”

There’s a subtle but important line between two types of errors.

A protocol error is when MCP/JSON‑RPC rules are violated: unknown method, wrong type in params, invalid JSON. Then it’s appropriate to return an error at the top level.

A business error is when the protocol is followed, but the operation itself fails for a domain reason: empty catalog, no permission to a specific resource, invalid business identifier. Then MCP usually recommends returning a valid result but marking it as isError: true and describing the problem in the content.

This separation greatly helps ChatGPT and debugging tools: by looking at the logs, you can immediately see whether it was a technical breakdown or a deliberate business‑logic refusal.

4. Notifications: one‑way messages

A notification is “a message with no reply expected.” In JSON‑RPC, notifications look like regular requests without the id field. The client must not send a reply to them.

In MCP, notifications are used for events: changes to the tool/resource/prompt lists, progress of long operations, log messages, etc.

The simplest example you’ll definitely encounter is a notification that the tool list has changed. The MCP spec for tools describes a listChanged capability and a tools/list_changed notification that the server sends if the set of available tools has changed.

A notification might look like this:

{
  "jsonrpc": "2.0",
  "method": "tools/list_changed",
  "params": {
    "reason": "New tool 'suggest_gift_cards' was added"
  }
}

No reply is required. Upon receiving such a notification, the client may decide: “oh, I should call tools/list again and refresh the tool cache.”

Other typical MCP notifications (we’ll talk about them in detail in the module on streams and events):

  • progress events (notifications/progress) for long‑running operations;
  • server logs (notifications/logging/message);
  • resource changes (resources/list_changed) and prompt changes (prompts/list_changed).

For now, remember one thing: a notification = request without id and without a reply expected. If you see JSON without id in the logs, it’s most likely a notification.

Insight

It has been established experimentally that ChatGPT App ignores messages (MCP notifications) sent to it. However, given that ChatGPT Apps are only at the beginning of their development, the likelihood of full support for all parts of the MCP protocol in the near future is very high. So I still recommend learning this side of the MCP protocol.

5. What tools/resources/prompts look like in messages

Now the fun part: how exactly those tools, resources, and prompts we keep talking about are represented inside MCP messages.

Tools: description and invocation

At the protocol level, tools have two main processes:

  1. discovery — the client learns which tools exist;
  2. invocation — the client calls a specific tool.

We already briefly saw tools/list and tools/call above. Now let’s look at them more systematically: which processes they cover and what exactly is returned in result.

5.1.1. Tool list — tools/list

We’ve already seen the request for tools/list. Let’s look at the structure of the reply. The MCP specification says: in result.tools you must return an array of objects, each describing one tool. A tool must have:

  • name — a unique name to be used when calling tools/call later;
  • title — a short title (visible to both humans and the model);
  • description — a more detailed description of what the tool does, as if explaining it to a colleague;
  • inputSchema — the JSON Schema for the tool’s arguments.

For our suggest_gifts, a tools/list reply might look like this (greatly simplified):

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "suggest_gifts",
        "title": "Gift ideas generator",
        "description": "Suggests gift ideas for a given occasion and budget.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "occasion": { "type": "string" },
            "budget": { "type": "number" },
            "recipient": { "type": "string" }
          },
          "required": ["occasion", "budget"]
        }
      }
    ],
    "nextCursor": null
  }
}

If you have already written inputSchema in the Apps SDK when registering the tool, you’ve practically seen this object already, just “from above” — as a TypeScript object. MCP simply passes it to the client over the protocol.

5.1.2. Tool invocation — tools/call

We already touched on the call format. The MCP specification states that params must contain:

  • name — the tool’s name;
  • arguments — an object that conforms to inputSchema.

For example:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "tools/call",
  "params": {
    "name": "suggest_gifts",
    "arguments": {
      "occasion": "wedding",
      "budget": 150,
      "recipient": "coworker from marketing"
    }
  }
}

And in response, the server returns a result with content, structuredContent and, optionally, _meta (for example, with openai/outputTemplate if you want to tie this tool to a specific widget).

This pairing — tools/listtools/call — is the basic cycle of MCP tools: first discovery, then usage.

Resources: data with an address

Resources in MCP are any pieces of data that the client can access via a URI: files, database records, configs, catalogs, etc.

They have a standard set of operations:

  • resources/list — to learn which resources exist;
  • resources/read — to read a specific resource (or a part of it).

Imagine a resource gift_catalog that describes a basic gift catalog: categories, brands, minimum and maximum prices. The server can declare it with the URI "mcp://gift-server/resources/gift_catalog".

A reply to resources/list might look like this (simplified):

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resources": [
      {
        "uri": "mcp://gift-server/resources/gift_catalog",	// just a unique string. mcp is not a protocol.
        "name": "gift_catalog",
        "description": "Base catalog of gifts with categories and prices",
        "mimeType": "application/json"
      }
    ],
    "nextCursor": null
  }
}

And reading a resource — resources/read:

{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "resources/read",
  "params": {
    "uri": "mcp://gift-server/resources/gift_catalog"
  }
}

The reply can contain the content itself and metadata:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "contents": [
      {
        "uri": "mcp://gift-server/resources/gift_catalog",
        "mimeType": "application/json",
        "text": "{\"categories\":[\"boardgames\",\"books\"]}"
      }
    ]
  }
}

The main idea: a resource is addressable data, while tools are operations. MCP makes both explicit in the protocol.

Prompts: reusable templates

Prompts are “prepared cues” or templates that the server can provide to the client. MCP treats them as a primitive with:

  • a name;
  • a human‑readable title/description;
  • content (often a system‑prompt template or a set of few‑shot examples).

And, predictably, there are two methods:

  • prompts/list — learn which prompts exist;
  • prompts/get — get the content of a specific prompt.

For example, you want to set a special style for generating congratulatory messages along with a gift. Then in the MCP server you can declare a prompt gift_congrats_style.

A reply to prompts/list might look like this:

{
  "jsonrpc": "2.0",
  "id": 10,
  "result": {
    "prompts": [
      {
        "name": "gift_congrats_style",
        "description": "Style guide for birthday congratulations in a friendly tone"
      }
    ]
  }
}

And prompts/get will return the actual text (or structured content) that the client can then pass to the LLM as part of the system prompt. An example of such a request and reply:

{
  "jsonrpc": "2.0",
  "id": 11,
  "method": "prompts/get",
  "params": {
    "name": "gift_congrats_style"
  }
}
{
  "jsonrpc": "2.0",
  "id": 11,
  "result": {
    "prompt": {
      "name": "gift_congrats_style",
      "messages": [
        {
          "role": "system",
          "content": [
            {
              "type": "text",
              "text": "You are a friendly assistant that writes short, warm birthday congratulations..."
            }
          ]
        }
      ]
    }
  }
}

6. How this ties into the Apps SDK and our widget

Right now, MCP JSON might still look a bit noisy. Let’s connect it to what you’ve already done via the Apps SDK.

Recall that in your widget frontend you might have code like:

// inside a React component in the ChatGPT sandbox
async function fetchGifts() {
  const result = await window.openai.callTool("suggest_gifts", {
    occasion: "birthday",
    budget: 50,
    recipient: "friend who loves sci-fi"
  });

  console.log(result);
}

At the Apps SDK level, this is a convenient function that:

  1. knows the MCP server URL (from the app configuration);
  2. can find the tool description by the name suggest_gifts;
  3. packs your call into an MCP request tools/call;
  4. sends it over the chosen transport (HTTP/SSE);
  5. waits for the MCP reply, unpacks the result, and returns it to you as result in JavaScript.

If we draw this as a diagram, it will look roughly like this:

sequenceDiagram
    participant Widget
    participant AppsSDK as Apps SDK
    participant MCP as MCP server

    Widget->>AppsSDK: window.openai.callTool("suggest_gifts", {...})
    AppsSDK->>MCP: JSON { id:7, method:"tools/call", params:{...} }
    MCP-->>AppsSDK: JSON { id:7, result:{ content, structuredContent } }
    AppsSDK-->>Widget: result (ToolOutput)
    Widget->>Widget: setState(toolOutput)

Understanding the MCP format gives you two great skills.

First, you can competently look at raw MCP logs (for example, in MCP Inspector, which will have a separate lecture) and see which exact tools/call was sent, what arguments it had, and what came back in result or error.

Second, when designing tools and resources you can think not only in terms of TypeScript types, but also in terms of MCP schemas: how it will look in JSON and how convenient it will be for other clients (for example, agents that can also connect to your MCP server).

7. Mini practice: reading and “fixing” MCP JSON

To make the MCP format your own, it’s best to manually break down a couple of messages at least once. Let’s take an example of a complete dialogue tools/listtools/call → result.

The client wants the list of tools

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

What we see:

  • this is a request (there is an id);
  • method tools/list, so this is tool discovery;
  • empty parameters, no pagination.

The server replies:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "suggest_gifts",
        "title": "Gift ideas generator",
        "description": "Suggests gift ideas",
        "inputSchema": { "type": "object", "properties": { "occasion": { "type": "string" } } }
      }
    ]
  }
}

It’s immediately clear this is the reply to that very request (same id: 1), the protocol succeeded (result is present, error is absent), and now the client knows there’s a tool suggest_gifts.

The client calls a tool

Next the client does tools/call:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "suggest_gifts",
    "arguments": {
      "occasion": "anniversary"
    }
  }
}

If the server also expects a budget but the model didn’t provide it, the server can:

  • either return a protocol error (for example, an error with an “invalid params” code);
  • or make a default decision (for example, use an average budget) and return a normal result.

In the terms we introduced earlier, the first option is a protocol error (a top‑level error), the second is already a matter of business logic: you still return a valid result and decide whether to treat the situation as a business error (isError: true) or normal behavior.

A reply in case of argument errors might look like this:

{
  "jsonrpc": "2.0",
  "id": 2,
  "error": {
    "code": -32602,
    "message": "Missing required property 'budget' in arguments"
  }
}

Again, we distinguish this from a business error: the protocol is violated (arguments don’t match the schema), so an error is appropriate here.

Broken example: find the bug

Here’s JSON you sometimes see from beginners:

{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "tool": "suggest_gifts",
    "args": {
      "occasion": "birthday",
      "budget": 100
    }
  }
}

At first glance it looks plausible, but if you compare it to the MCP specification, you’ll notice that the fields tool and args do not match the expected name and arguments.

An MCP client/server SDK will most likely never generate such JSON, but if you’re integrating manually without knowing the spec, this kind of bug is quite realistic. That’s exactly why in this course we go through the protocol “in the raw,” not just SDK wrappers.

8. Common mistakes when working with MCP messages

Mistake #1: mixing protocol and business errors.
Developers often habitually wrap everything that “went wrong” in a top‑level error — missing resource, wrong arguments, database crashes. In MCP, it’s helpful to distinguish: if the JSON structure and call schema are violated (wrong method, wrong fields, incorrect types), that’s a reason to return an error. If the tool simply couldn’t perform the domain operation (no gifts for that budget, user not found), it’s better to return a valid result with isError: true and a clear message in content. Then both the ChatGPT model and debuggers can correctly distinguish “the transport broke” from “the server deliberately refused.”

Mistake #2: ignoring the id field and request correlation.
Sometimes in MCP server logs you can see manual output without an id or with duplicate id values for different active requests. In a single‑threaded hello‑world, you might get away with it, but once you have parallel calls or retries, it becomes hard to tell which reply belongs to which request. JSON‑RPC explicitly requires a unique id for the lifetime of a request, and MCP relies on that rule. If you use official SDKs, you don’t have to think about id, but as soon as you write the transport or logging yourself, don’t forget to preserve and print the id — it’s the first thing you’ll use to debug odd bugs.

Mistake #3: unstable result structures for the same method.
It can be tempting to “slightly” change the reply format depending on the situation: sometimes return an array of gifts, sometimes an object with one string, sometimes just text without structuredContent. The model might survive such tricks, but your widgets and any other MCP clients most likely won’t. The MCP spec describes a predictable result structure for each method; try to stick to it. If you need a different format, it’s better to declare a separate tool or version rather than change the schema on the fly.

Mistake #4: extra or missing fields in params.
A typical problem in custom implementations is adding something to params that MCP doesn’t expect, or forgetting a required field. For example, sending toolName instead of name in tools/call, or resourceId instead of uri in resources/read. MCP SDKs usually validate such things and throw a clear exception, but if you’re working closer to the protocol, you can spend a long time wondering why “the server doesn’t understand me.” A good technique is to keep a sample of a correct JSON request from the spec or from logs of a working client next to your handler and compare it with what you’re sending.

Mistake #5: trying to use notifications as a “second response channel.”
Sometimes developers, after seeing notifications, start sending operation results via notifications instead of regular replies: “we’re already in MCP and we have SSE, let’s push everything through notifications.” The problem is that JSON‑RPC notifications by definition are not tied to a specific id and are not perceived by the client as a response to a request. As a result, it’s harder to debug and impossible to tell which tool call a particular message belongs to. Notifications are great for events (tool/resource/prompt lists changed, new progress arrived, a log message came in), but not for regular replies to tools/call and similar methods.

Mistake #6: not looking at MCP logs and inspectors.
The most human mistake is trying to debug the integration only through the ChatGPT UI: “I clicked a button, something didn’t arrive, I’ll deal with it someday.” Until you see the raw MCP messages (requests, replies, notifications), it’s hard to understand at which level the problem lies: the model didn’t call the tool, the Apps SDK didn’t reach the MCP server, the server returned the wrong JSON, or everything broke at the widget rendering stage. MCP Inspector / Jam and structured logging of MCP messages are your best friends. After you see a live tools/call and tools/list in the logs once, the MCP message format will finally stop being “magic” and become ordinary engineering routine.

1
Task
ChatGPT Apps, level 6, lesson 1
Locked
MCP Message Builder (request vs notification)
MCP Message Builder (request vs notification)
1
Task
ChatGPT Apps, level 6, lesson 1
Locked
Mini JSON-RPC handler for MCP methods (result vs error)
Mini JSON-RPC handler for MCP methods (result vs error)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION