How I Built an MCP Server: From Chat Message to Tool Result
A practical, end-to-end guide to the Model Context Protocol, built on a real server for a supply-chain ERP.

Why I built an MCP server
Our vendors and buyers live inside our supply-chain ERP. Every day they ask the same questions: “How many POs are pending?”, “Was PO 4512 approved?”, “What did we process last week?” Each answer meant logging in, opening a screen, setting filters, and paging through results.
I wanted them to ask an AI assistant instead. The Model Context Protocol (MCP) is the open standard that makes this possible. It gives any AI application (Claude, ChatGPT or your own chatbot) one common way to discover and use the actions and data your system exposes.
Before MCP, every AI app needed its own custom integration for every system. With MCP, you build one server, and any MCP-compatible app can plug into it. Think of it as “USB-C for AI”: one standard connector instead of a drawer full of adapters.
In this post, I walk through everything, from first principles to the exact HTTP calls our server accepts. By the end, you will understand:
- The roles in MCP: host, client, server, and the LLM
- The core building blocks: tools, resources and prompts, plus the client-side features sampling, elicitation and roots
- What really happens between a user typing a message and seeing an answer
- How you gain extra control when you build your own host, such as loading resources at startup
- How the handshake, the transport (Streamable HTTP and SSE), and the responses work on the wire
- How we handle login today, and our plan to add OAuth for public chatbots later
The server is written in TypeScript with fastmcp, but the concepts apply to any language or SDK.
Part 1: MCP essentials
MCP has four players. Most confusion disappears once you separate them clearly.
Host. The application the user actually opens and types into. Examples: Claude Desktop, Claude.ai, Claude Code, ChatGPT, or a chatbot you build yourself. The host owns the chat UI and the conversation with the LLM. It decides which MCP servers to connect to, and it is responsible for asking the user for consent before sensitive actions.
Client. A connector that lives inside the host. A host creates one client per server. The client speaks the protocol: it performs the handshake, lists tools, sends tool calls, and keeps track of session IDs. If your host connects to three servers, it holds three clients.
Server. Your program. It exposes capabilities (tools, resources, and prompts) and runs them against your real systems. In our case, this is mcp-server, which talks to the ERP APIs.
LLM. The model inside the host, such as Claude or GPT. It reads the conversation, decides which tool to call and with which arguments, and writes the final answer for the user.
The single most important point: the LLM never talks to your server directly. It only asks for things. The host and client do the actual talking.
A simple analogy:
- The host is an office.
- The LLM is the employee at the desk who decides what needs to be done.
- The client is a dedicated phone line from the office to one supplier.
- The server is the supplier’s help desk, answering from its own records.
The employee decides what to ask. The phone line carries the call. The help desk answers. The employee then explains the answer to the customer in plain words.
┌──────────────────────── HOST (Claude Desktop / your chatbot) ────────────────────────┐
│ │
│ User ⇄ Chat UI ⇄ LLM │
│ │ "I want to call list_purchase_orders" │
│ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ MCP Client #1 │ │ MCP Client #2 │ │ MCP Client #3 │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
└───────────────────────┼──────────────────────┼──────────────────────┼─────────────────┘
│ JSON-RPC over HTTP │ │
▼ ▼ ▼
mcp-server GitHub server Calendar server
│
▼
ERP APIs
The three server building blocks
An MCP server can offer three kinds of things. The easiest way to remember them is by asking who decides when they are used.
1. Tools: actions the model decides to call (model-controlled)
A tool is a function the LLM can call. It has a name, a description, an input schema (the arguments), and optionally an output schema. The LLM reads the description and decides when the tool is useful.
Think of tools as verbs: “list purchase orders”, “approve a PO”, “create a shipment”. They can read data or change it.
A simplified fastmcp definition looks like this:
The description is not decoration. It is the only thing the LLM reads when deciding whether and how to use the tool. Write it as if you are briefing a new colleague.
2. Resources: data the application decides to load (application-controlled)
A resource is a read-only context identified by a URI, like a file or a document. The server exposes it; the host (not the LLM) decides when to read it and how to show it to the model.
Think of resources as nouns: “the PO status reference”, “the vendor’s profile”, “today’s warehouse report”.
Examples we could expose:
- supplymint://reference/po-statuses: a short Markdown note explaining what each PO status means.
- supplymint://vendor/{vendorId}/profile: a resource template, where the client fills in vendorId.
When a user asks, “What’s the difference between APPROVED and PROCESSED?”, a host that has already loaded the status reference can answer immediately, without a tool call at all.
Protocol methods: resources/list, resources/templates/list, resources/read, and optionally resources/subscribe to get notified when a resource changes.
server.addResource({
uri: "supplymint://reference/po-statuses",
name: "PO status reference",
mimeType: "text/markdown",
async load() {
return {
text: [
"# PO statuses",
"- AWAITING_PO: created, waiting for approval (pending)",
"- APPROVED: approved by the buyer",
"- PROCESSED: fulfilled and closed",
"- CANCELLED: cancelled, no further action",
].join("\n"),
};
},
});3. Prompts: templates the user chooses (user-controlled)
A prompt is a reusable, parameterised message template. The user picks it explicitly, usually from a menu or a slash command in the host. The server returns ready-made messages that start the conversation in a well-defined way.
Think of prompts as saved workflows: “Weekly PO review”, “Draft a delay notice to a buyer”.
Example:
- The user opens the prompt menu and picks weekly_po_review with status = AWAITING_PO.
- The server returns a message such as: “Review all my purchase orders with status AWAITING_PO. Group them by buyer, flag anything older than 7 days, and suggest next steps.”
- The host inserts that message into the chat, and the LLM takes it from there, calling tools as needed.
Protocol methods: prompts/list and prompts/get.
server.addPrompt({
name: "weekly_po_review",
description: "Review purchase orders for the week and suggest next steps",
arguments: [
{ name: "status", description: "PO status to review", required: false },
],
load: async ({ status = "AWAITING_PO" }) =>
`Review all my purchase orders with status ${status}. ` +
`Group them by buyer, flag anything older than 7 days, and suggest next steps.`,
});Quick memory aid
- Tools = the model decides. Verbs. “Do something.”
- Resources = the application decides. Nouns. “Know something.”
- Prompts = the user decides. Templates. “Start a known workflow.”
Client-side features: when the server needs something back
MCP is two-way. A server can also ask the client for help if the client says it supports it during the handshake.
Sampling. The server asks the host’s LLM to generate text. Example: a tool fetches 40 PO line items and asks the model, “Summarise these in two sentences.” The server never needs its own LLM key; the host stays in control and can ask the user to approve.
Elicitation. The server asks the user a structured question in the middle of a tool call. Example: cancel_purchase_order is called, and before cancelling, the server asks, "Are you sure you want to cancel PO 4512? Reason?" The host shows a small form and returns the answer.
Utilities
A few smaller features round out the protocol:
- Logging (notifications/message): the server sends log lines to the client.
- Progress (notifications/progress): the server reports progress during long operations, such as "fetched page 1 of 3".
- Completions: the server suggests values for prompt or resource arguments, like autocomplete for a vendor ID.
- List-changed notifications: the server tells the client “my tool list changed, fetch it again”.
Part 2: From the user’s chat message to the final answer
Here is the full journey for one question, step by step. Assume the user types:
“How many pending POs do I have?”
Step 0: At startup (before the user types anything). The host creates an MCP client and connects to mcp-server. The client sendsinitialize, receives the server's capabilities and instructions, sendsnotifications/initialized, and then calls tools/list. The host now knows that list_purchase_orders exists, along with its description and input schema.
Step 1: The user sends a message. The host takes the user’s text and sends it to the LLM, together with the list of available tools (name, description, input schema) and the server’s instructions.
Step 2: The LLM decides to use a tool. The LLM reasons, “Pending POs means AWAITING_PO, and there’s a tool for that.” Instead of answering, it returns a tool-use request:
{
"type": "tool_use",
"name": "list_purchase_orders",
"input": { "poStatus": "AWAITING_PO"}
}Step 3: The host checks permissions. Depending on its policy, the host may ask the user, “Allow Supplymint to list your purchase orders?” Read-only tools are often auto-approved; write actions should usually ask.
Step 4: The client sends tools/call. The MCP client turns the request into JSON-RPC and POST it to the server:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "list_purchase_orders",
"arguments": { "poStatus": "AWAITING_PO"}
}
}Step 5: The server does the real work. It authenticates the request (via x-auth-id), loads the user's token from Redis, calls the ERP API, and returns:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"content": [{ "type": "text", "text": "{\"purchaseOrders\":[...],\"currPage\":1,\"maxPage\":3}" }],
"structuredContent": { "purchaseOrders": [ ... ], "currPage": 1, "maxPage": 3 }
}
}Step 6: The host feeds the result back to the LLM. The result goes into the conversation as a tool result.
Step 7: The LLM may call again. It sees currPage: 1 and maxPage: 3, so it may call the tool again withpageNo: 2, then pageNo: 3. Steps 2 to 6 repeat.
Step 8: The LLM writes the answer. Once it has what it needs, the LLM writes plain text:
“You have 12 pending purchase orders. The oldest is PO 4497 from 18 September…”
That final sentence is the only thing the LLM itself produces. Everything else came from the host, the client, and the server.
Part 3: Building your own host, and preloading resources at startup
Claude Desktop and ChatGPT are hosts somebody else built. If you build your own host (for example, a chatbot inside the Supplymint web app), you control every step of the journey above. That gives you options the off-the-shelf apps don’t.
Load resources once, at startup
Because resources are application-controlled, your host decides when to read them. Stable reference data (status codes, business rules, the vendor’s own profile) can be read once when the session starts and placed directly into the system prompt.
Benefits:
- The LLM answers “What does PROCESSED mean?” instantly, with no tool call.
- Fewer round trips and fewer tokens spent on repeated lookups.
- Consistent behaviour: every conversation starts with the same, known context.
You can also subscribe to a resource (resources/subscribe) so the server notifies you when it changes, and refresh your cached copy only then.
Other things your own host can control
- Cache the tool list after tools/list and refresh it only on a list-changed notification.
- Filter tools per user role. A vendor only sees vendor tools; a buyer sees buyer tools. Fewer tools also means the LLM picks the right one more reliably.
- Write your own system prompt that combines the server’s instructions, preloaded resources and your product's tone.
- Decide consent rules. Auto-approve read-only tools; ask for confirmation on anything that approves, cancels or changes data.
- Expose prompts as buttons in your UI, such as “Weekly PO review”.
- Handle authentication however your product needs, such as passing a custom header.
Part 4: How our Supplymint server works
Now let’s get concrete.
Login: getting a session ID (POST /session)
This route is not part of MCP. It’s our own endpoint that logs a user in and returns a sessionId (a UUID). That UUID goes in the x-auth-id header on every MCP call.
curl -X POST https://mcp-test.supplymint.com/session \
-H "Content-Type: application/json" \
-H "x-auth-token: <INTEGRATION_TOKEN>" \
-d '{"mobile_no":"<MOBILE_NUMBER>"}'
Response:
{ "sessionId": "e278f66f-xxxx-xxxx-xxxx-xxxxxxxxxxxx" }What happens behind the scenes:
- The server logs in to the Supplymint auth service.
- It stores the user’s token in Redis under sessionTokenKey(sessionId).
- It returns the sessionId to the caller.
- The session expires after SESSION_TTL_SECONDS. After that, call POST /session again.
- A request to /mcp with nox-auth-id, or an unknown one, gets a 401. There is no fallback.
Why this design? This login was built for our current client’s needs: an integration that already has a trusted integration token and knows which user it is acting for. It is simple, fast, and works with any client that can send a custom header. Support for public chatbots like Claude.ai and ChatGPT, which require OAuth, comes later.
Calling a tool: the three steps
Every request is a POST /mcp with exactly one JSON-RPC message in the body.
Step 1: initialize. Copy the mcp-session-id response header; you'll need it for every later call.
curl -i -X POST https://mcp-test.supplymint.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "x-auth-id: <SESSION_ID>" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Step 2: notifications/initialized.
curl -X POST https://mcp-test.supplymint.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "x-auth-id: <SESSION_ID>" \
-H "mcp-session-id: <MCP_SESSION_ID>" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
The server replies 202 Accepted with an empty body.
Step 3: tools/call. Example: list pending purchase orders.
curl -X POST https://mcp-test.supplymint.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "x-auth-id: <SESSION_ID>" \
-H "mcp-session-id: <MCP_SESSION_ID>" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_purchase_orders","arguments":{"poStatus":"AWAITING_PO"}}}'
Listing all tools and their arguments:
curl -X POST https://mcp-test.supplymint.com/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "x-auth-id: <SESSION_ID>" \
-H "mcp-session-id: <MCP_SESSION_ID>" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/list"}'
Why initialize is needed
initialize is the handshake. Client and server agree on the rules before any real work happens. Four things are exchanged:
- protocolVersion: both sides agree which version of the MCP specification to follow.
- capabilities: the server says what it offers (tools, logging, completions and so on); the client says what it supports (for example, elicitation).
- serverInfo and instructions: the client learns the server's name and receives the text that tells the LLM which tool to use when.
- The mcp-session-id header: over HTTP, this is the moment the server creates the session. Every later request sends that ID. In our server, this is also when the MCP session gets bound to your x-auth-id.
Without initialize, the server doesn't know your protocol version or capabilities, and there is no session to attach your requests to.
One security detail: an MCP session is tied to the first x-auth-id it is used with. Using the same MCP session with a different x-auth-id returns 401 ("This MCP session belongs to a different login"). This stops one user's session from being reused by another.
Why notifications/initialized is needed after initialize
It works like the final ACK in a TCP handshake:
- Client sends initialize: "Here's who I am."
- Server replies: “Here’s who I am. Your session ID is X.”
- Client sends notifications/initialized: "Got it, I'm ready."
Step 2 only proves the server replied. It doesn’t tell the server that the client received the reply and finished its own setup. The specification says the server must not send its own requests to the client (such as elicitation) until step 3 arrives, and the client should only call tools after sending it.
It is a notification, meaning it has noid, so there is no JSON-RPC response, only 202 Accepted. Some servers let you skip it, but don't rely on that. Real clients always send it.
Transport: Streamable HTTP and SSE
Client to server: always a plain HTTP POST /mcp with one JSON-RPC message in the body.
Server to client: the server chooses one of two reply formats for each request.
- application/json: one JSON-RPC response, then done, just like a normal REST call.
- text/event-stream (SSE): the server keeps the reply open and can send several events before the final result.
That is why the client must send Accept: application/json, text/event-stream. It promises to handle either format.
Why SSE is useful: during a single tool call, the server can push extra messages before the final result.
POST /mcp tools/call list_purchase_orders
← event: message {"method":"notifications/progress", ...} (e.g. "fetched page 1 of 3")
← event: message {"method":"notifications/message", ...} (a log line)
← event: message {"method":"elicitation/create", ...} (ask the user a question)
← event: message {"result":{...}, "id":2} (final answer)
[stream closes]
With plain JSON, the server could only send that last line.
In our server: fastmcp’s httpStream transport replies with SSE by default, even when there is only one event. That's why an API client such as Postman shows "Connected → message → Connection closed". The "stream" is just one event, and closing it afterwards is normal.
Part 5: Which clients can connect today
Our current login is a custom x-auth-id header, not OAuth. That was a deliberate choice for our current client's integration. Here's what that means in practice:
- Your own app built on the MCP SDK, curl or Postman: works. They can send any header.
- Claude.ai, Claude Desktop and mobile connectors: not yet. They require OAuth.
- ChatGPT connectors: not yet. They require OAuth.
Part 6: What’s next - OAuth for chatbots
The current auth serves our client well. To let users connect from Claude.ai or ChatGPT, we will add OAuth next to x-auth-id, not instead of it, so existing integrations keep working. Follow to learn how we do this.
Security first
Today’s mobile login needs only the integration token and a mobile number, with no password or OTP. That’s acceptable for a trusted server-to-server integration, but not for a public login page. The authorization page must add a real check, such as an OTP or a password. Otherwise, anyone who knows a vendor’s number could log in as them.
Closing thoughts
MCP looks complex at first because several parties are involved. Once you separate them, it’s straightforward: the host runs the conversation, the LLM decides what to do, the client carries the request, and your server does the real work. Tools let the model act, resources give the application context, and prompts give users ready-made workflows.
If you’re building your own MCP server and have questions, drop them in the comments. I’m happy to help.