The first MCP server I wrote was about forty lines. It worked on the first try. That should have worried me.
At MCP Dev Summit Toronto I spent 25 minutes on what happened after that first try: the servers that came next, the bugs that only showed up once real people and real agents used them, and the things I now build into every server on day one.
This post is the written version, with one thing the talk didn’t have time for: how the same server plugs into watsonx Orchestrate, which is where most of my MCP work actually ends up.
Part 1 · Building one from scratch
The first version is deceptively simple
Here is the shape almost everyone starts with, me included:
client → MCP server → your API
|
+-- tool: request(path, method, query, body)
One generic tool that forwards whatever the model asks for. It’s the least code for me, and it hands every decision to the model: which path, which verb, which fields. With five tools the model guesses right. With fifty, the name and the description are all it has.
So the first rewrite is always the same: name the tool after what the human wants, not after the HTTP call underneath. search_tickets(query, status, requester_email, limit) is easier to use and safer than request(). The intent is in the name; the choices are in the brackets.
The stack I use today is boring on purpose: the official MCP TypeScript SDK (stdio, Streamable HTTP and SSE transports), Zod for input and output schemas so a bad call is refused before my code runs, and Express for a few extra HTTP pages on the same port. In Python, FastMCP in the official SDK is the same shape. Start with the official SDK for your language; add a framework once you know what you’re missing.
The running example is a small support-ticket server. Tickets are only the stand-in — swap them for orders, assets or containers and every lesson below holds. The three seed tickets in the demo are three mistakes from this post:
TCK-1001 open 0 tools discovered after I registered the server TCK-1002 pending Customer never got the reply — ticket looks fine in the API TCK-1003 solved mcp.json worked yesterday, 404 today
Part 2 · The lessons, in the order they hit me
1. Prove it’s up — and that it works
The IDE said 0 tools discovered. No error. No warning. I couldn’t tell broken from empty.
My case: I’d handed a cloud runner a path from my laptop. A missing file reads as an empty tool list, silently.
The fix is two plain HTTP endpoints next to /mcp:
$ curl -s localhost:8787/health?format=json
{ "ok": true, "version": "3.1.0", "tools": 19 }
$ curl -s localhost:8787/test?format=json
{ "ok": true, "steps": [ { "name": "run_query tickets", "ok": true }, … ] }
/health reports the tool count from the server’s side, so when the client shows zero and the server says 19, you know it’s the connection. /test actually calls the tools. MCP’s ping only proves the process answered. Alive is not works.
Neither endpoint is in the spec. They’re my convention, and they’re about ten lines of code.
2. Easy to ship and use
Before I fixed this, curl was the debugger: curl /health, curl /tools, hand-edit mcp.json, restart the IDE, still zero tools, repeat. Every IDE had its own config file, and “is it my config or your code?” became a meeting.
The answer was distribution, not documentation. I bundled the server inside a VS Code extension (LF MCP Demo): one install ships the server, one click writes mcp.json for VS Code, Cursor, Bob, Windsurf and Cline, and Diagnose runs the same checks I used to do by hand, in order, and points at the first one that fails.
Same server, different wiring: stdio for one developer, Streamable HTTP for a team, SSE for older clients. On a laptop with npx, in a Podman container, or on IBM Cloud Code Engine. The tools and schemas don’t change.
3. Which server answers?
Once the same server ran in several places at once, the client happily listed the tools of whatever answered. Each IDE’s stdio config spawned its own copy with its own memory. An npx …@latest in one IDE disagreed with the bundled copy in the extension. Once, a completely different MCP server was on my port and its tools showed up as mine.
Two fixes. HTTP became the default, so the IDE, the UI and the extension all share one process. And every copy now says which copy it is, on /health, /test, /tools and in describe_server:
{ "service": "mcp-ticket-demo",
"instance": { "label": "local", "id": "my-laptop.local", "url": "http://127.0.0.1:8787" } }
4. When OK wasn’t OK
This one still stings. create_ticket returned 201 Created every single time. But when no email was passed, the code fell back to the API’s service account — so the bot owned the ticket, every reply went to the bot, and the customer never heard back. The package went down the storm drain; technically it left the truck.
The other failure was a bare 403. The model read it like a typo, tweaked a field, retried, retried again, and finally told the user “the system is unavailable.”
What fixed both:
- Before the call, in the description: “ALWAYS pass requester_email — the real customer’s email. If you leave it out, the bot owns the ticket.”
- After the call, in the result: a
warningand anextfield with the exact call to make instead. - Errors that teach: “key has scopes [read] but create_ticket requires the write scope. Issue a key with that scope on /admin, then retry once.” The model stops looping and asks the human for a key.
A tool isn’t done when the API call succeeds. It’s done when the next thing that happens is right.
5. Past a hundred tools
On my Maximo work I ended up with query_records, query_with_filter, query_with_fields, query_work_orders, query_assets… Every name correct. The model still picked wrong. Near-identical names don’t add choice; they dilute it.
The fix is to think of the tools like a database. Reads are queries. Writes stay verbs.
| What you want | Tools |
|---|---|
| What exists? | describe_server, get_schema |
| Read it | run_query with a schema: tickets, customers, trace |
| Change it | create_ticket, add_comment, close_ticket |
| Operate it | update_settings, issue_api_key |
My Code Engine MCP server went from about 112 tools to 28 this way. The ticket demo went from 31 to 19. The win is clearer choices, not fewer bytes.
6–8. Control plane, remote configuration, and a server that talks back
The last three boxes are where a server stops being “an integration” and starts being a platform:
- A control plane. The server grew its own UI —
/adminfor auth mode, keys, scopes and tool gates;/logfor the audit trail and call trace. Because the person rotating a key at 2 a.m. is often ops or security, not a developer with the IDE open, and a hosted copy has no editor next to it. - Remote configuration. Every
/adminbutton became a tool too (update_settings,issue_api_key, …). One script makes the same change on every copy. An ops agent can rotate keys without a browser. Same scopes, same audit trace. - The server talks back. Instead of the agent asking “anything new?” on a loop, the server pushes
ticket.*events over a standard MCP logging stream. The third “checkout is down” ticket in ten minutes can wake an agent, which comments “known incident” on all three, and pages a human with one question.
Part 3 · Taking it to watsonx Orchestrate
Everything above I learned in an IDE. Most of it matters even more once the client is an enterprise agent platform. watsonx Orchestrate (WXO) speaks MCP natively: you add an MCP server as a toolkit with the Agent Developer Kit (ADK), and its tools become tools your agents can use.
There are three ways in: two with the ADK CLI, and one with no CLI at all.
Option A · Local toolkit: Orchestrate runs your server
Orchestrate starts your server inside its own runtime and talks to it over stdio. You can point it at an npm or PyPI package, or upload a local folder.
From npm, pinned to v3.1.0:
orchestrate toolkits add --kind mcp \ --name ticket-demo \ --description "Support tickets: describe_server first, then get_schema and run_query" \ --command "npx -y mcp-ticket-demo@3.1.0" \ --tools "*"
From a local folder:
orchestrate toolkits add --kind mcp \ --name ticket-demo \ --description "Support tickets: describe_server first, then get_schema and run_query" \ --package_root ./mcp-ticket-demo/server \ --command "node src/index.js" \ --tools "*"
ADK 2.0 note: the old orchestrate toolkits import command is now orchestrate toolkits add. import now means importing from a YAML file. Older tutorials, including some of mine, still say import.
This is where TCK-1001 came from. --package_root gets zipped and uploaded; the command runs relative to it. Give it an absolute path from your laptop and Orchestrate is looking at /Users/me/… on a machine that has never heard of you. The import doesn’t crash. You just get zero tools. Ship the package, run from a relative path.
Two more things from Part 2 that matter here:
- stdout is the protocol. A stray
console.login a stdio server corrupts the JSON-RPC stream. Log to stderr (console.error). - Secrets come in as environment variables. Attach a connection with
--app-id, and each key and value in it is exposed to your server as an env var. No keys in the command line, no keys in the package.
Option B · Remote toolkit: your server, your URL
If the server already runs somewhere — Render, Code Engine, Kubernetes, OpenShift — point Orchestrate at the URL and pick the transport explicitly:
orchestrate toolkits add --kind mcp \ --name ticket-demo-remote \ --description "Support tickets (hosted copy)" \ --url "https://<your-app>.<region>.codeengine.appdomain.cloud/mcp" \ --transport "streamable_http" \ --tools "*" \ --app-id "ticket-demo-key"
Supported transports are sse and streamable_http. There’s no automatic fallback between them; choose one. For a team or CI, this is the one I use: one URL, secrets server-side, and the server’s own /health, /test and /admin pages right next to it.
Option C · No CLI: add the hosted server in the Orchestrate UI
You don’t need the ADK at all for a server that’s already hosted. If it has a public URL, you can add it straight from the browser:
- Build → Agent Builder, open your agent, and go to Toolset.
- Add tool → MCP server → Add MCP server, and pick Remote MCP.
- Give it a name and a description, paste the MCP server URL, and choose the transport: Streamable HTTP for the ticket demo, SSE for older servers.
- If the server needs a key, attach a connection (created under Manage → Connections).
- Connect. Orchestrate calls
tools/listand shows every tool the server offers. Select the ones this agent needs and Add to agent.
Want the same server in several agents? Add it once at the system level (All tools → Add tool → MCP server → Manage MCP servers) and import the tools from there.
Both of my hosted copies work this way:
https://mcp-ticket-demo.onrender.com/mcp https://mcp-ticket-demo.29m5mrru3s3n.ca-tor.codeengine.appdomain.cloud/mcp
They run in write mode: discovery and reads are open, so Orchestrate can list the tools with no key, and the write tools answer with a refusal that names the scope until you attach a key.
Two things I learned doing it this way:
- Wake the server first. Orchestrate waits about 30 seconds for the tool list. A free Render service sleeps when idle and can take longer than that to wake up. Open
/healthin another tab first, wait for the JSON, then click Connect. - Re-read the descriptions after import. The agent picks tools from the description field. If the server’s own descriptions are good —
create_ticketwarning aboutrequester_email,describe_serversaying “call me first” — you shouldn’t have to edit them. That’s the whole argument for putting the manual in the metadata.
Why the lessons matter more in Orchestrate
Reading the ADK docs after the talk, I kept nodding. Several of the scars line up one-to-one.
Import validates schemas, not behaviour. During import, the ADK connects, waits up to 30 seconds for tools/list, and checks the schemas for structural correctness. It does not run your tools. A clean import tells you exactly as much as ping does: something answered. That’s why /test exists. Run it before you import, and again after every deploy.
Discovery has to stay open. A 4xx during import fails the import. In the ticket demo, tools/list and describe_server stay open in every auth mode, and locked tools still show up, locked. Hide a tool and the agent assumes it doesn’t exist and invents a workaround. Show it locked and the refusal explains what’s needed.
The description is the manual. An Orchestrate agent picks tools the same way an IDE model does: name, description, input schema. The requester_email warning, the next hints and the refusals that name the fix aren’t IDE niceties. They’re how an agent with no human watching the trace avoids opening tickets for the bot.
Know which caller you’re serving. Orchestrate always sends trace context (traceparent, tracestate) through its MCP gateway, and you can opt in to tenant_id and agent_id headers when you add the toolkit. Put those in your audit trail.
Hostnames move. TCK-1003, “mcp.json worked yesterday, 404 today”, was a redeploy that gave the service a new hostname. A remote toolkit bakes in a URL the same way. Read the endpoint after each deploy; don’t paste it by hand. And keep the YAML version of the toolkit in git so orchestrate toolkits import -f can rebuild it in CI.
Fewer, clearer tools travel better. Every tool you import is one more choice for the agent. describe_server → get_schema → run_query imports cleanly, and --tools lets you import only what a given agent needs.
Part 4 · Where to go next in WXO Labs
The WXO Labs blog series is 16 hands-on parts from your first Python tool to SSO and enterprise integrations. These four are the MCP path:
| Part | Post | Picks up from the talk |
|---|---|---|
| 7 | Getting Started with MCP in watsonx Orchestrate | Prove it’s up — stdio transport and the “0 tools found” bug |
| 8 | Passing User Context and Bearer Tokens to Sidecars | When OK wasn’t OK — whose identity is the call made with? |
| 15 | Remote Containerized MCP Servers on IBM Cloud Code Engine | Easy to ship and use — from the laptop to a shared URL |
| 16 | IBM Planning Analytics (TM1) MCP Integration | Past a hundred tools — a real enterprise API |
If you’re new to Orchestrate, start at Part 1 and come back to Part 7. If you already have an MCP server, go straight to 7 and 15.
What carries to your next server
Three things. They work the same in an IDE and in Orchestrate.
- Prove it.
/healthwith a tool count, and a/testthat calls the tools. - Make it easy to ship and use. Bundle the server in an IDE extension, give operators a UI, and give Orchestrate a package with a relative path or a URL that doesn’t move.
- Make it discoverable.
describe_serverfirst,get_schemafor the shape, tool names that say the intent, and errors that name the fix.
None of this needed a spec change. Some of it probably should become standard.
Try it tonight
git clone https://github.com/markusvankempen/mcp-ticket-demo cd mcp-ticket-demo/server npm install npm run http # then open http://127.0.0.1:8787/health, /test and /admin (demo / demo)
Then add it to Orchestrate — Option A with the ADK, or Option C in the UI with one of the hosted URLs if you’d rather not install anything — ask your agent “Show me all tickets and their status”, and read the three subjects that come back. Each one is a mistake I made so you don’t have to.
Then break it on purpose: call create_ticket without requester_email and see who owns the ticket. Flip /admin to write mode and read the refusal. That’s the whole talk in two calls.
What will your next server teach the agent? Leave a comment, open an issue on the repo, or find me on LinkedIn.
No bug 🐛 too small, no syntax too weird 🔍 🤔 🔨 🦾
Cheers,
Markus
The MCP servers, extensions and labs mentioned here are my personal open-source projects. They are not IBM products and are not officially supported IBM software. ADK commands are from the remote and local MCP toolkit docs; check them against the version you run.