OpenSearch MCP server is the textbook stateless design. Some MCP servers cannot be that, and the contrast is the most useful thing you can learn about MCP.
OpenSearch already gives every document a stable _id and accepts arbitrary search bodies over REST. So opensearch-mcp-server-py can be a thin facade: each tool call is self-contained, hits one endpoint, returns hits keyed by id, and the agent carries those ids forward.
That is what people mean when they say MCP servers are simple. It assumes the data store underneath has already done the hard work of making every record addressable. When that assumption breaks, the shape of the MCP server breaks with it. WhatsApp MCP for macOS is the cleanest case I know of an MCP server that had to be designed stateful on purpose. I'll walk both, with code, then come back to what to copy and what not to.
What the OpenSearch MCP server actually exposes
The repository at github.com/opensearch-project/opensearch-mcp-server-py is a Python project published by the OpenSearch project itself. You install it from PyPI, point it at a cluster, and it speaks MCP over stdio or over a streaming transport (SSE or Streamable HTTP). The default-on tool surface is small and shaped like the OpenSearch REST API.
Every one of those tools is independent of the others. Calling SearchIndexTool does not put the server into a mode that subsequent calls have to respect. The hits come back keyed by document _id, and the agent passes those ids forward into ExplainTool, GetShardsTool, another search, or whatever else makes sense. Pagination is another self-contained call: same query body, different from and size, or a search_after cursor.
What stateless looks like in code
Here is the shape of the search tool, paraphrased to the part that matters. There is no instance variable on the server that survives past the return statement. The query body is fully self-describing, and the response is just a projection of the upstream result.
That shape is what most readers expect when they hear "MCP server." Tools as named functions. Each tool maps to one upstream call. Identifiers in the response that survive across calls. Order of calls is irrelevant beyond what the agent decides. For OpenSearch the shape is correct, and the project ships exactly it.
The shape on the wire is the same. The shape of the data is not.
JSON-RPC framing is identical for any MCP server. Stateless or stateful, the bytes between the host and the server look the same. What differs is what the server does inside the call, and what the return value lets the next call do.
OpenSearch MCP / stateless
One round trip per tool call. The reply carries _ids. The agent picks one and uses it later. Nothing on the server persists. Now compare against an MCP that has no stable id to return, because there is no database underneath, just a UI.
WhatsApp MCP / stateful
Two round trips, deliberately. The first one types into the sidebar, parses the visible result buttons out of the accessibility tree, and returns them with 0-based indices. It does not close the search field. The second tool call, whatsapp_open_chat, takes one of those indices and clicks the button at that ordinal position in the still-open UI. The state lives in WhatsApp, not in the MCP server, but the MCP server is responsible for not disturbing it between calls.
The two-tool handshake, in source
The relevant code is in Sources/WhatsAppMCP/main.swift. The first half is the search tool. The comment at the bottom of the function is load-bearing: it is the contract the next call depends on.
The follow-up tool is right below it. The argument it accepts is a single integer. The tool description, declared further down in the file at line 1050, instructs the agent to call whatsapp_search first and then pass through the index. The implementation re-walks the AX tree, collects the same result buttons, and clicks the one at that ordinal.
That ordinal is the entire identifier. There is no document id, no phone number used as a key, no stable handle the way OpenSearch hands you a _id. The agent has only the position of the result in the live UI, and the position only stays valid as long as the search is open and nobody else moves around in WhatsApp. That is the cost of not having a queryable data store underneath.
The full tool surfaces, side by side
OpenSearch's default-on tools cover index management, search, and a few diagnostic primitives. WhatsApp MCP's eleven tools cover a much narrower domain (one app on one OS) but two of them are deliberately stateful in a way no OpenSearch tool is.
Three tools form the search-and-open-a-chat handshake: whatsapp_search opens the search UI and returns indexed results, whatsapp_scroll_search extends the same open UI with more results, and whatsapp_open_chat consumes one of the indices to actually navigate. Neither OpenSearch's SearchIndexTool nor MsearchTool need a partner tool to land a result. They return enough by themselves.
Per-feature breakdown
What changes between the two designs, and why each change is forced by the data source rather than by taste.
| Feature | OpenSearch MCP server | WhatsApp MCP for macOS |
|---|---|---|
| What the data lives in | An OpenSearch cluster, indexed by document _id. | The visible WhatsApp Catalyst window, addressed by AX tree position. |
| How a tool call resolves | Server makes one HTTPS call to a REST endpoint and returns the response. | Server walks AXUIElement, types or clicks, re-walks the tree, returns a parsed slice. |
| Identifier the agent uses next | Document _id from the previous hit. Stable across calls. | 0-based ordinal in the result list. Only meaningful while the UI is still open. |
| Pagination | Stateless: the agent re-sends the query with from/size or search_after. | Stateful: whatsapp_scroll_search extends the same open search UI. |
| Per-call independence | Each tool call is self-contained. Tools/list order does not matter. | whatsapp_open_chat assumes whatsapp_search ran most recently. Order matters. |
| Where the server runs | Anywhere with HTTPS to the OpenSearch endpoint. Hosted variants exist. | On the same Mac as the WhatsApp Catalyst app. Local stdio only. |
| Failure mode the agent has to handle | 401 (auth), 404 (no such index), 5xx (cluster unhealthy). | Search field not found, no results visible, index out of range, UI raced. |
| Why the design is what it is | OpenSearch already exposes a stable, queryable, REST surface. Pass it through. | WhatsApp on macOS does not expose any single-user API. Drive the UI. |
Which shape should you reach for
The decision is almost entirely a function of what is on the other side of your MCP server, not of what the protocol allows.
- A queryable store with stable record ids. OpenSearch, Elasticsearch, Postgres-with-an-API, and most modern SaaS (Stripe, Linear, Notion, Slack, GitHub) sit here. Every record has an id you can hand back to the agent, and the upstream API accepts arbitrary filters. Build the OpenSearch shape: small tools, one HTTP call each, identifiers in the return that the agent carries forward. There is no reason to keep state on the server, and adding state will only create bugs.
- A surface with no public id and no API for individual users. WhatsApp consumer accounts, iMessage, Apple Notes, Things, Bear, and most native macOS apps sit here. The only handle on a record is its position in the UI right now. Build the WhatsApp shape: tools that explicitly say what UI state they leave open, return objects with positional indices, follow-up tools that consume those indices, and verification tools the agent can call to re-anchor before any write. State is unavoidable. The honest move is to admit it in the tool descriptions instead of pretending the server is stateless.
- The mixed case, where the data is queryable but writes go through a UI. You can read messages over an API but only send via the desktop app, for example. Split the surface into two namespaces: stateless read tools that pass through the API, stateful write tools that drive the UI. Do not pretend the writes are stateless. The agent will assume any write tool is safe to call concurrently, and that assumption breaks the moment your write path is a click.
What WhatsApp MCP would have to look like if WhatsApp shipped an OpenSearch
For the sake of the comparison, imagine WhatsApp consumer accounts exposed a per-user REST API that let you query messages and contacts by stable id, the way OpenSearch lets you query indices. The MCP server for it would collapse. The eleven tools in main.swift would shrink to about four: search_chats, read_messages, send_message, and a generic API passthrough. The two-call search-and-open handshake would disappear, because every chat would have a stable phone-number-keyed handle and send_message could just take that handle as an argument. Pagination would move to the API and become stateless. The whole accessibility-tree traversal would become dead code.
That is exactly what the OpenSearch MCP server gets to be. The data store does the work of giving every record a name. The MCP server's job is just to expose those names to the agent in a shape MCP hosts know how to consume. Anyone writing an MCP server against a real database (OpenSearch, Postgres, MongoDB, SingleStore, ClickHouse) is downstream of that gift. Anyone writing an MCP server against a desktop app is not.
How both servers handle pagination
Pagination is the cleanest place to see the design split. In OpenSearch MCP server, the agent decides how to paginate by choosing what to put in the request body. The classic pair from and size works for shallow pages. For deep pages, search_after gives the agent a sort cursor it can carry forward. Either way, the next page is a fresh, fully-formed call. The server has no opinion on what page you are on.
In WhatsApp MCP, the equivalent is whatsapp_scroll_search (defined at line 1059 in the same file). Because there is no offset parameter exposed by the WhatsApp app, the tool literally sends scroll-wheel events at the midpoint of the visible result buttons, sleeps briefly so the app can lazy-load, and then re-walks the AX tree to return the now-larger set. Calling it after the search has been closed does nothing useful. Calling it before the search has been opened returns an error. There is no stateless variant of this in the WhatsApp MCP. There cannot be.
Building an MCP server and not sure which shape it wants?
If your data source is queryable, copy OpenSearch. If it is a UI, you have a different problem. Happy to walk through your tool surface on a call.
Frequently asked questions
What does OpenSearch MCP server actually do?
It is a Python MCP server, published by the OpenSearch project at github.com/opensearch-project/opensearch-mcp-server-py, that exposes OpenSearch cluster operations as MCP tools. The default-on surface includes ListIndexTool, IndexMappingTool, SearchIndexTool, GetShardsTool, ClusterHealthTool, CountTool, ExplainTool, MsearchTool, and GenericOpenSearchApiTool. It supports stdio and streaming transports (SSE and Streamable HTTP), runs as its own process, and can be installed from PyPI. Functionally, every tool is a thin wrapper over a REST endpoint on the OpenSearch cluster the server is configured to talk to. There is no per-server long-lived state between calls, because the data already lives in the cluster and is addressable by index name and document id.
Is the OpenSearch MCP server stateless on every call?
Yes, in the sense that matters here. The server itself does not have to remember the result of the previous tool call to make sense of the current one. SearchIndexTool returns hits keyed by document _id, and the agent's follow-up call (a get-by-id, an explain, another search) carries the _id forward. There is no UI to keep open and no positional ordinal that would go stale if you waited a minute. That is what makes OpenSearch MCP server such a clean reference design: the data store already does the work of giving every entity a stable handle, and the MCP layer can just pass it through.
Why does this page contrast OpenSearch MCP server with WhatsApp MCP?
Because the two are at opposite ends of the design space, and seeing them next to each other clarifies what is actually a choice and what is forced by the data source. OpenSearch MCP server can be stateless because OpenSearch gives every document a stable id and accepts arbitrary search bodies over REST. WhatsApp MCP for macOS cannot be stateless. The data is the contents of a running Catalyst window, and the only handle on a particular search result is its position in the live AX tree. So the server's whatsapp_search tool deliberately leaves the search UI open, returns results with 0-based indices, and tells the next tool call (whatsapp_open_chat) to use one of those indices. The contrast is the most useful frame I know for thinking about MCP server design.
Where exactly is the stateful tool surface in WhatsApp MCP defined?
Sources/WhatsAppMCP/main.swift in the m13v/whatsapp-mcp-macos repository. The whatsapp_search tool is declared on line 1032 onward, and its description string contains the literal sentence "Leaves search OPEN, call whatsapp_open_chat(index) to select a result." The implementation behind it (handleSearch, around line 716) ends with a comment that says "search is left OPEN so caller can use whatsapp_open_chat with an index." The whatsapp_open_chat tool sits right next to it (line 1048 onward, with handleOpenChat at line 746), and its only meaningful argument is a 0-based index that addresses the search results from the previous call by position. There are eleven tools in total, and that two-tool handshake is the part that breaks the stateless mold.
How does each pattern handle pagination?
OpenSearch MCP server delegates pagination to the OpenSearch query body. The agent sets from and size in a SearchIndexTool call, or uses search_after with a sort cursor for deep pages. Each follow-up call is self-contained: same query body shape, different range. The server does not have to remember the previous page. WhatsApp MCP cannot do that. The 'corpus' is whatever the WhatsApp sidebar is currently showing, and there is no offset parameter exposed by the app. So the server adds a third tool, whatsapp_scroll_search, that scrolls within the still-open results list to load more and then re-traverses the tree. The next whatsapp_open_chat call then references the now-larger list. Pagination is done by extending the open UI rather than by sending a new query.
When is each pattern the right one to reach for?
Reach for the OpenSearch shape when the data source already has three properties: a stable per-record identifier, a queryable interface (REST or RPC) that accepts arbitrary filters, and a permission story you can express in tokens. That covers OpenSearch, Elasticsearch, Postgres-with-an-API, Stripe, Linear, Notion, GitHub, Slack, and most modern SaaS. Reach for the WhatsApp shape when the data source is a desktop or Catalyst app, the records have no public id, and the only stable handle is 'whatever is on screen right now.' That covers WhatsApp on macOS, iMessage, Apple Notes, Things, Bear, and most native macOS apps. The mistake is to write the wrong one: a stateful UI walker on top of OpenSearch is over-engineering, and a stateless REST passthrough on top of WhatsApp Web is undefined behavior the moment the DOM changes.
Can these two MCP servers run side by side in the same agent host?
Yes. MCP hosts (Claude Desktop, Claude Code, Cursor, Windsurf) read a JSON config that lists servers and how to launch each. OpenSearch MCP server typically runs as a stdio child started by the host (or as an HTTP entry pointing at an SSE endpoint). WhatsApp MCP runs as a stdio child too, but it has to be launched on the same Mac as the WhatsApp Catalyst app. The host forks both, presents the union of tools to the agent, and a single agent turn can call SearchIndexTool against your log cluster and then whatsapp_send_message to ping the on-call engineer. The agent does not see any boundary between the two servers.
Does the stateful design in WhatsApp MCP create reliability problems?
It creates problems the stateless design does not have, and the server's tool descriptions push those problems back onto the calling agent rather than hiding them. whatsapp_open_chat returns the active_chat name and tells the agent to verify it matches the intended contact, because the index from the previous whatsapp_search call may have shifted if the user did anything in WhatsApp in between. whatsapp_scroll_search returns the freshly parsed full result list rather than a delta, so the agent can re-anchor on names. whatsapp_send_message refuses to run unless a chat is currently open, because the only place a sent message can land is the active chat. None of these are necessary for OpenSearch MCP server, and that is exactly the point: a stateless surface lets the server hide more of the world from the agent, while a stateful surface forces the agent to participate in keeping the world consistent.
What should I take away if I am writing my own MCP server?
Look at the data source first. If it gives you stable ids and a query API, copy the OpenSearch shape: small tools, each call self-contained, identifiers in returns that the agent passes back later. If it gives you a UI and nothing else, copy the WhatsApp shape: tool descriptions that explicitly say what is left open, return values that include positional indices, follow-up tools that consume those indices, and an explicit verification tool the agent can use to confirm reality before any irreversible action. The MCP protocol does not pick for you. The shape your tool surface ends up taking is mostly a function of how addressable the underlying data already is.
Where can I read both servers' source?
OpenSearch MCP server is at github.com/opensearch-project/opensearch-mcp-server-py. Its README lists the default-on tool surface and the optional tool groups, plus instructions for stdio and streaming transports. WhatsApp MCP for macOS is at github.com/m13v/whatsapp-mcp-macos. The Sources/WhatsAppMCP/main.swift file contains the eleven tool definitions on line 1110 onward, the stateful search/open-chat handshake at lines 716 to 792, and the eleven-tool dispatch table immediately after.