Research · October 2026 · 20 min read

From Scratch to watsonx Orchestrate

What building MCP servers taught me — and how the same server plugs into watsonx Orchestrate.

Resources:  Slides  · mcp-ticket-demo on GitHub  · WXO Labs blog series

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:

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 wantTools
What exists?describe_server, get_schema
Read itrun_query with a schema: tickets, customers, trace
Change itcreate_ticket, add_comment, close_ticket
Operate itupdate_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:


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:

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:

  1. Build → Agent Builder, open your agent, and go to Toolset.
  2. Add tool → MCP server → Add MCP server, and pick Remote MCP.
  3. 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.
  4. If the server needs a key, attach a connection (created under Manage → Connections).
  5. Connect. Orchestrate calls tools/list and 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:

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:

PartPostPicks up from the talk
7Getting Started with MCP in watsonx OrchestrateProve it’s up — stdio transport and the “0 tools found” bug
8Passing User Context and Bearer Tokens to SidecarsWhen OK wasn’t OK — whose identity is the call made with?
15Remote Containerized MCP Servers on IBM Cloud Code EngineEasy to ship and use — from the laptop to a shared URL
16IBM Planning Analytics (TM1) MCP IntegrationPast 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.

  1. Prove it. /health with a tool count, and a /test that calls the tools.
  2. 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.
  3. Make it discoverable. describe_server first, get_schema for 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.