Ultra TechArabic edition

SoftwareGuide 3 of 4

What MCP Is

Last verified against its sources on .

You wrote a tool for your own application — a function that looks up an order, say — and your model calls it. Then you want the same tool inside a second AI application, and a third, and each one asks for it in its own format, wired in its own way. You end up writing the same tool several times, once per application, and keeping all the copies in step. MCP exists so that you write it once.

MCP (Model Context Protocol) is an open-source standard for connecting AI applications to external systems. Its own documentation likens it to a USB-C port for AI applications : one shape of plug, so that a tool built to the standard fits any application that speaks it. In practice that means a small program — an MCPserver — that offers your tool, and a fixed set of messages any application can send it to learn what the tool is and to call it.

This guide builds exactly that, with nothing but Node. You will write a server that offers one tool — the same order lookup as this site's guide What Tool Use Is — talk to it by hand, then write the client side that an AI application would contain, and call the tool through it. Every message format on this page is quoted from the MCP specification, and every output is what the files printed when they ran.

One warning before you start, because it will save you an afternoon. The current MCP protocol version is 2026-07-28 , and it changed the way a conversation with a server begins. Earlier protocol revisions established a connection-scoped session with an initialize handshake ; this revision has no handshake at all. Much of what you will find written about MCP describes the old way. This guide follows the current one, and the pitfalls section shows what happens when the two meet.

By the end of this guide you will

  • Write an MCP server that offers one tool, and send it a message by hand to see exactly what it answers.
  • Write the client side that launches the server, asks what it offers, and reads the tool's definition.
  • Call the tool through MCP, and tell the two kinds of error MCP defines apart — the one a model can fix and the one it cannot.
  • Say where MCP stops and the model starts: which part decides to call a tool, which part sends the call, and which part runs it.

Before you start

You need two things, and neither costs anything.

  • Node.js 22 or newer. Run node --version. It prints a version such as v24.14.1, which is what the output on this page came from. If it prints an error, install Node.js from its official site.
  • A terminal in an empty folder. Run mkdir mcp-order-desk, then cd mcp-order-desk. You will create two files there, server.mjs and client.mjs.

There is no key in this guide, on Windows or on a Mac, because nothing here calls a model or leaves your machine: the server and the client talk to each other through the terminal's own input and output. Nothing is installed either — no MCP library, no package — because the point is to see the messages themselves.

Step 1 — Write a server and talk to it by hand

Create a file named server.mjs and put this in it:

Exampleserver
import { createInterface } from 'node:readline';

// The protocol revision this server speaks: MCP's current one.
const VERSION = '2026-07-28';

// The shop's order system, and the one tool that reads it — the same as in "What Tool Use Is".
const ORDERS = {
  '1042': { status: 'shipped', carrier: 'DHL', expected_delivery: '2026-09-14' },
  '1043': { status: 'packed', carrier: null, expected_delivery: '2026-09-16' },
};

const getOrderStatus = {
  name: 'get_order_status',
  description:
    'Look up one order in the shop\'s order system by its order number. Returns the order\'s current ' +
    'status (packed, shipped or delivered), the carrier once it has shipped, and the expected delivery ' +
    'date. Returns an error for an order number that does not exist.',
  inputSchema: {
    type: 'object',
    properties: {
      order_id: { type: 'string', description: 'The order number as the customer sees it, digits only, for example "1042".' },
    },
    required: ['order_id'],
  },
};

// One request in, one reply out. Every request names its protocol version in _meta.
function reply(request) {
  const result = (fields) => ({ jsonrpc: '2.0', id: request.id, result: { resultType: 'complete', ...fields } });
  const error = (code, message, data) => ({ jsonrpc: '2.0', id: request.id, error: { code, message, ...(data && { data }) } });

  const requested = request.params?._meta?.['io.modelcontextprotocol/protocolVersion'];
  if (requested !== VERSION) {
    return error(-32022, 'Unsupported protocol version', { supported: [VERSION], requested: requested ?? null });
  }
  if (request.method === 'server/discover') {
    return result({
      supportedVersions: [VERSION],
      capabilities: { tools: {} },
      _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'order-status', version: '1.0.0' } },
    });
  }
  if (request.method === 'tools/list') return result({ tools: [getOrderStatus] });
  if (request.method === 'tools/call') {
    if (request.params.name !== getOrderStatus.name) return error(-32602, `Unknown tool: ${request.params.name}`);
    const order = ORDERS[request.params.arguments?.order_id];
    const text = JSON.stringify(order ?? { error: `no order numbered ${request.params.arguments?.order_id}` });
    return result({ content: [{ type: 'text', text }], isError: order === undefined });
  }
  return error(-32601, `Method not found: ${request.method}`);
}

// The stdio transport: read one JSON-RPC message per line on stdin, write one per line on stdout,
// and write nothing else there. When the client closes stdin, the conversation is over.
const lines = createInterface({ input: process.stdin });
lines.on('line', (line) => process.stdout.write(JSON.stringify(reply(JSON.parse(line))) + '\n'));
lines.on('close', () => process.exit(0));

Output

{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"order-status","version":"1.0.0"}}}}

Read it from the bottom up, because the bottom is what makes it an MCP server. MCP has two standard ways to carry its messages. One is stdio: newline-delimited messages over the standard streams of a subprocess the client launches. The other is Streamable HTTP, where each message is an HTTP POST to a single MCP endpoint. This server uses stdio. In the stdio transport, the client launches the MCP server as a subprocess , and the last three lines are the whole of the server's side of that: read a line, answer with a line.

The messages are JSON-RPC, a small standard for requests and replies written as JSON objects. The base protocol uses the JSON-RPC message format, with stateless, self-contained requests and per-request capability negotiation. "Self-contained" is the change from the old revisions, and you can see it inreply: the first thing it does is read the protocol version out of the request itself, from _meta, because every request carries its protocol version and client capabilities in _meta.io.modelcontextprotocol/*fields. There is no earlier message it could have remembered it from.

Now run it with node server.mjs. It prints nothing and waits: it is listening on its input. Paste this one line and press Enter:

{"id":1,"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}

The server answers with one line. Here is what it printed:

{"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"order-status","version":"1.0.0"}}}}

Read it field by field:

  • "jsonrpc":"2.0" and "id":1 — a JSON-RPC reply, and its id matches the "id":1 you sent, which is how a client pairs a reply with its request when several are in flight.
  • "resultType":"complete" — the request finished in this one reply.
  • "supportedVersions":["2026-07-28"] — the protocol versions this server speaks. There is one.
  • "capabilities":{"tools":{}} — what it offers: tools, and nothing else.
  • "_meta":{"io.modelcontextprotocol/serverInfo":{"name":"order-status","version":"1.0.0"}} — its name and version, as it describes itself.

The message you sent was server/discover. It lets a client query a server's supported protocol versions, capabilities and identity before sending any other request, and every server must implement it. It is a way to ask "who are you and what can you do" in one round, though calling it is optional for clients : a client that already knows the server can go straight to its tools.

To stop the server, press Ctrl+C. In Step 2 the client does something gentler: it closes the server's input when it has nothing more to ask. The last line of the server answers that, and the specification asks for it — servers should exit promptly when their standard input is closed.

Two rules the server follows are easy to miss because they are about what it does notdo. Messages are delimited by newlines and must not contain embedded newlines , which is why the server writesJSON.stringify(...) with no indentation and one \nafter it. And the server must not write anything to its stdout that is not a valid MCP message — not a greeting, not a debug line. The pitfalls section comes back to that one.

Step 2 — Write the client that launches the server

An AI application does not paste lines into a terminal. It runs the server itself and talks to it from code. That code is an MCP client. Create a file named client.mjs next to server.mjs and put this in it:

Exampleclient
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { setImmediate } from 'node:timers';

// Launch the server as a child process — MCP's stdio transport. Requests go in on its stdin and
// replies come back on its stdout, one JSON object per line.
const server = spawn(process.execPath, ['server.mjs'], { stdio: ['pipe', 'pipe', 'inherit'] });
const waiting = new Map();
createInterface({ input: server.stdout }).on('line', (line) => {
  const message = JSON.parse(line);
  waiting.get(message.id)(message);
  waiting.delete(message.id);
  // Nothing left to ask: close the server's input, which is how a client says it is done.
  setImmediate(() => {
    if (waiting.size === 0 && !server.stdin.writableEnded) server.stdin.end();
  });
});

// Every request carries the protocol version, the client's capabilities and its name in _meta.
let nextId = 1;
function request(method, params = {}) {
  const id = nextId++;
  const _meta = {
    'io.modelcontextprotocol/protocolVersion': '2026-07-28',
    'io.modelcontextprotocol/clientInfo': { name: 'order-desk', version: '1.0.0' },
    'io.modelcontextprotocol/clientCapabilities': {},
  };
  server.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params: { ...params, _meta } }) + '\n');
  return new Promise((resolve) => waiting.set(id, resolve));
}

console.log('→ server/discover');
const discovered = (await request('server/discover')).result;
const info = discovered._meta['io.modelcontextprotocol/serverInfo'];
console.log(`server: ${info.name} ${info.version}, speaks ${discovered.supportedVersions.join(', ')}`);
console.log(`offers: ${Object.keys(discovered.capabilities).join(', ')}`);

console.log('→ tools/list');
for (const tool of (await request('tools/list')).result.tools) {
  console.log(`tool: ${tool.name}`);
  console.log(`  needs: ${JSON.stringify(tool.inputSchema.properties)} (required: ${tool.inputSchema.required.join(', ')})`);
}

Output

→ server/discover
server: order-status 1.0.0, speaks 2026-07-28
offers: tools
→ tools/list
tool: get_order_status
  needs: {"order_id":{"type":"string","description":"The order number as the customer sees it, digits only, for example \"1042\"."}} (required: order_id)

Run it with node client.mjs. The client starts the server, asks it two things, closes it and exits. Here is what it printed:

  • → server/discover — the client sends the same message you typed in Step 1, now built by request.
  • server: order-status 1.0.0, speaks 2026-07-28 — the name, the version and the one protocol version from the reply, read out of the same fields you saw raw.
  • offers: tools — the keys of capabilities.
  • → tools/list— the second request. To discover available tools, clients send a tools/list request.
  • tool: get_order_status — the one tool the server offers, by name.
  • needs: {"order_id":{"type":"string","description":"The order number as the customer sees it, digits only, for example \"1042\"."}} (required: order_id) — what the tool takes: one string, order_id, which it cannot do without.

Look at request. It builds the _metablock for every message — the protocol version, the client's capabilities and its name — and that repetition is the current revision's design, not an oversight: each request stands on its own. The rest of the client is plumbing:spawn launches node server.mjs with pipes on its input and output, and the line handler reads the server's replies one line at a time and hands each to the request that is waiting for its id. When nothing is left waiting, it calls server.stdin.end(), the server sees its input close and exits, and the client exits after it.

Now compare the tool the client just read with the tool in What Tool Use Is. There it was written into the application as a function tool with a name, a description and parameters. Here the server sends a name, a description and an inputSchema — the same three things, the same JSON Schema describing order_id. That is the point of the whole protocol: an application that speaks MCP does not need the tool written into it. It asks the server with tools/list and hands what comes back to its model as the tool list.

Step 3 — Call the tool, and meet both kinds of error

Add this at the end of client.mjs:

Examplecall-tool
async function call(name, args) {
  console.log(`→ tools/call ${name} ${JSON.stringify(args)}`);
  const answer = await request('tools/call', { name, arguments: args });
  if (answer.error) console.log(`protocol error ${answer.error.code}: ${answer.error.message}`);
  else console.log(`${answer.result.isError ? 'tool error' : 'result'}: ${answer.result.content[0].text}`);
}

await call('get_order_status', { order_id: '1042' });
await call('get_order_status', { order_id: '9999' });
await call('cancel_order', { order_id: '1042' });

Output

→ tools/call get_order_status {"order_id":"1042"}
result: {"status":"shipped","carrier":"DHL","expected_delivery":"2026-09-14"}
→ tools/call get_order_status {"order_id":"9999"}
tool error: {"error":"no order numbered 9999"}
→ tools/call cancel_order {"order_id":"1042"}
protocol error -32602: Unknown tool: cancel_order

Run node client.mjs again. Step 2's lines print first; then six more. Here is what they said:

  • → tools/call get_order_status {"order_id":"1042"}— to invoke a tool, clients send a tools/call request , with the tool's name and its arguments.
  • result: {"status":"shipped","carrier":"DHL","expected_delivery":"2026-09-14"} — the tool ran on the server and its answer came back as text content. This is the text the application would hand to its model.
  • → tools/call get_order_status {"order_id":"9999"} — the right tool, with an order that does not exist.
  • tool error: {"error":"no order numbered 9999"} — the call succeeded as a message, and the tool reported a failure inside its result.
  • → tools/call cancel_order {"order_id":"1042"} — a tool the server does not have.
  • protocol error -32602: Unknown tool: cancel_order — the request itself was refused.

Those last two are different on purpose. Tools use two error reporting mechanisms. A tool execution error — order 9999 — is reported in the tool result withisError: true, and it contains actionable feedback that language models can use to self-correct and retry with adjusted parameters : a model told "no order numbered 9999" can ask the user to check the number. A protocol error —cancel_order— indicates an issue with the request structure itself that models are less likely to be able to fix, such as an unknown tool. It comes back as a JSON-RPC error instead of a result, and the specification's own example of an unknown tool uses the same code this server used, -32602. What the application does with each follows from that: clients may pass protocol errors to the model, though these are less likely to lead to recovery, and should pass tool execution errors to it so it can correct itself.

The one rule

MCP carries tools; it does not call them. The model still decides when a tool is needed, exactly as in What Tool Use Is; MCP is how the tool reached the application, and how the application's call reaches the tool.

The specification names three roles. Hosts are LLM applications that initiate connections; clients are connectors within the host application; servers are services that provide context and capabilities. Tools are model-controlled: functions exposed to the LLM to take actions. Put together, one tool call crosses all three:

Who What it does In this guide
The model Decides a tool is needed and asks for it by name, with arguments not here — the tool-use guide's Step 1
The host, through its client Lists the server's tools, gives them to the model, sends tools/call when the model asks client.mjs
The server Runs the tool and returns its result or its error server.mjs

Tools are one of three things a server can offer. It can also offer resources, context and data, and prompts, templated messages and workflows for users; tools are the functions for the AI model to execute. This guide's server offers tools only, which is why itscapabilities held tools alone.

A hosted model can play the host's part for you. In OpenAI's Responses API you use the mcp tool type, with server_url for a remote MCP server or tunnel_idfor a local one through Secure MCP Tunnel. Remote MCP servers can be any server on the public Internet that implements a remote MCP server — which is why the server in this guide, a local process that speaks stdio, cannot simply be handed to it by address.

When to use it, and when not

Situation Decision Reason
One tool, used by your own application and nothing else Don't — use function calling The tool-use guide's way is simpler, and MCP adds a process and a protocol for nothing
The same tool should work in several AI applications Write an MCP server One server, listed and called the same way by every host that speaks MCP
A service you use already publishes an MCP server Connect it Its tools arrive with tools/list; you write none of them
A server from a source you cannot vouch for, with tools that act Don't, or only with approval on every call Its descriptions are untrusted and what reaches the model can leak through it
Your model runs on OpenAI's Responses API and the server is public Use the mcp tool type with server_url The API acts as the host and calls the server for the model
The server is a local program, like this guide's Launch it from your own client over stdio, or reach it through Secure MCP Tunnel A hosted API cannot reach a process on your machine by address

Terms that came up

  • MCP (Model Context Protocol) — an open standard for connecting AI applications to external systems: tools, data and prompts.
  • Server — the program that offers tools (and possibly resources and prompts); here, server.mjs.
  • Client — the code inside an AI application that talks to one server; here, client.mjs.
  • Host — the AI application itself, which runs clients and gives the model what they find.
  • Tool — a function a server offers for the model to call, described by a name, a description and an input schema.
  • Resource and prompt — the other two things a server can offer: context and data, and ready-made messages for users.
  • JSON-RPC — the message format MCP uses: requests with an id and a method, replies with the same id and a result or an error.
  • stdio transport — carrying those messages over a subprocess's standard input and output, one per line.
  • Streamable HTTP — the other standard transport, where each message is an HTTP POST to one endpoint.
  • _meta — the part of every request that carries the protocol version and the client's capabilities in the current revision.
  • Protocol version — a date, YYYY-MM-DD; the current one is 2026-07-28.
  • server/discover, tools/list, tools/call — the three requests in this guide: who are you, what tools do you have, run this one.
  • Tool execution error — a failure inside a tool's result, marked isError: true, which a model can often fix.
  • Protocol error — a JSON-RPC error for a request that could not be handled at all, such as an unknown tool.

In short

MCP is one standard way for an AI application to find a tool and call it: a server lists what it offers, and a client asks for it with tools/list and runs it with tools/call. In the current revision, 2026-07-28, every request carries its own protocol version, and there is no handshake. The model still decides when to call a tool; MCP only makes the same tool reachable from every application that speaks it.

What changed recently

No source of this guide has changed since the last check.