Common mistakes when creating an MCP server

Updated:
Common mistakes when creating an MCP server

When I was building an MCP server for my blog, most of the time was spent not on the search itself, but on the little things around it: the client wouldn't connect due to protocol version issues, local tests gave completely different numbers than production, and one careless configuration could expose private analytics data. This article covers all such moments. For each, I explain not only "how to fix it" but also why it happens, how I solved it, and what the alternatives are. What MCP is and how it works is in the main article about MCP servers for websites.

⚡ In Brief

✅ Most problems are not in the search code, but around it: protocol versions, proxies, default framework security, differences between local and production environments.

✅ The most dangerous error is silent: the framework can publish private tools itself – without any warning.

✅ The model is also a user: it needs clear descriptions, statuses instead of exceptions, and responses that don't eat up its context.

✅ I honestly mark: 👤 - I stepped on this myself, 🛡 - I anticipated this in advance.

🎯 You will get: a list of issues to check before launch, explanations of their origins, and a pre-deployment checklist.

👇 Below are 19 errors in five groups: connection, security, limits, search quality, and "the model misunderstands the server."

📚 Table of Contents

📌 Section 1. Connection: Client Can't See Server
📌 Section 2. Security: What Accidentally Leaks Out
📌 Section 3. Limits and Money
📌 Section 4. Search Quality
📌 Section 5. Model Misunderstands Server
✅ Pre-MCP Server Launch Checklist
❓ Frequently Asked Questions (FAQ)
✅ Conclusions

🔌 Section 1. Connection: Client Can't See Server

The most unpleasant type of error: the server is running, curl responds, but the client doesn't connect. Almost always, the reason is a mismatch in expectations between the client and the server.

1.1. 👤 "Unrecognized field "form"": Client and Server on Different Protocol Versions

Symptom. MCP Inspector showed an error upon connection:

Unrecognized field "form" (class io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Elicitation),
not marked as ignorable (0 known properties: ])

Why it happens. MCP is evolving: there are several versions in the specification (2025-06-18, 2025-11-25, and newer). When connecting, the client informs the server about its capabilities, and a fresh client adds the new field capabilities.elicitation.form there. The MCP SDK on my server only knew version 2025-06-18 and parsed JSON strictly: an unfamiliar field meant an error, and the connection was dropped. This isn't just an Inspector problem: the same error occurred in Claude Code when connecting to servers on an old Java SDK. This means some real clients simply wouldn't be able to connect.

How I fixed it. I placed a small filter before the SDK that replaces the client's capabilities with an empty object {} in the initialize request. This is safe for my server specifically: it only provides tools and doesn't use any of the client's capabilities – neither elicitation (requesting user data), nor sampling, nor roots. Other requests pass through unchanged. The filter also logs which client connected and on which protocol version – useful for debugging.

Alternatives.

  • Update the SDK or framework – the correct solution. In my case, it's Spring AI 2.x with 2025-11-25 support, but it requires Spring Boot 4, meaning a migration of the entire site. Therefore, the filter is a temporary measure until migration.
  • An older version of Inspector – this only removes the error in Inspector. Real clients still won't connect.
  • If your server uses client capabilities (e.g., asks the user for confirmation via elicitation), you cannot remove them – you must update the SDK.

How not to repeat it. Test with the latest client versions, not those installed six months ago, and log initialize: the protocol version and client name.

1.2. 👤 MCP Inspector Not Connecting: Server Added as stdio Instead of Streamable HTTP

Symptom. The server addition form in Inspector asks for Command, Arguments, and Environment, but there's no field for URL.

Why it happens. MCP has two fundamentally different connection methods. stdio – the client itself launches the server as a local program and communicates with it via standard input/output (this is how, for example, servers for accessing files on your computer work). Streamable HTTP – the server is already running somewhere on the network, and the client accesses it via a URL. I cloned an example template, and it turned out to be stdio.

How I fixed it. In the Transport field, I selected Streamable HTTP – instead of Command, a URL field appeared.

How not to repeat it. A simple rule: if your server has an address (http://…/mcp), it's always Streamable HTTP. stdio is only for programs that the client launches itself.

1.3. 🛡 localhost Doesn't Work in Claude and ChatGPT

Why it happens. Web versions of Claude and ChatGPT connect to the MCP server from their servers in the cloud, not from your browser. For them, localhost is their own server, not your laptop.

How I do it. I test locally using curl and MCP Inspector, as well as with clients running on my computer (Claude Code, OpenCode, Cursor) – they see localhost. Web clients only after deployment.

Alternative – tunneling (ngrok, Cloudflare Tunnel): it provides a temporary public address for the local server. Convenient, but remember: if the server has no authentication, anyone who knows the address can connect to it during that time. This is not dangerous for a server with public data, but it is dangerous for private data.

1.4. 🛡 HTML Page Instead of JSON: Bot Protection on Proxy

Symptom. Everything works locally, but on production, the client doesn't connect; curl returns an HTML page with a "Are you human?" check instead of JSON.

Why it happens. If the site is behind Cloudflare or another proxy with bot protection, requests from MCP clients look exactly like bot requests: they are servers, not browsers. The proxy shows them a verification page that the program cannot pass. A second possible problem is caching: the proxy might return a cached response instead of the actual one.

How to fix it. For the /mcp address – an exception rule: no caching and no bot checks. Abuse protection remains at the application level (limits are in Section 3).

How not to repeat it. After every deployment – curl the production address from the outside, not from the server itself.

1.5. 🛡 403 or redirect to login: default framework security

Why. Security frameworks (Spring Security, Django, Laravel, etc.) protect POST requests with CSRF tokens by default, redirect unauthorized users to the login page, and create a session for every request. The MCP client will not send a CSRF token, will not log in, and bot sessions only clutter memory.

How I did it. In my application, the admin panel is protected not by the security framework, but by a custom request interceptor for /admin/**. Therefore, I checked the main thing: that the interceptor is registered specifically for /admin/**, and not for all addresses with exceptions. Otherwise, /mcp would receive a redirect to login.

If you have Spring Security (or an analog) — for /mcp, there's a separate chain of rules: access for everyone, CSRF disabled, sessions not created. You can also add /mcp to the main site configuration, but then you'll be mixing site and API rules, and sessions will still be created.

⚠️ What not to do: do not add a security framework to the project "for the sake of MCP" if it wasn't there before. Spring Security by default will close the entire site with a login form, including the blog.

How to check: curl with initialize returns 200 without the Set-Cookie header, while the admin panel remains closed.

🔐 Section 2. Security: what accidentally gets exposed

These errors don't show any symptoms. The server works, clients are satisfied — but meanwhile, things that shouldn't be exposed are being sent out.

2.1. 🛡 The framework itself published private tools

Why. Framework MCP starters are convenient: mark a method with an annotation — and it's already a tool. But they also conveniently collect all tools found in the application. My application, besides the blog, runs an SEO agent with tools for Search Console and Google Analytics data. A single configuration line is enough for these tools to enter the general registry — and anyone from the internet could ask Claude about my clicks, positions, and traffic. Without errors or warnings.

How I protected myself.

  • MCP tools — only through a separate annotation and only in a separate package. The SEO agent's tools connect directly to their agent and are never registered as general.
  • I performed the first check of tools/list before I wrote any tools. The response was empty — meaning the starter didn't pick up anything extra.
  • After each deploy — the same tools/list: the list should contain exactly three blog tools.

Alternatives. The most reliable way is to move MCP to a separate service with read-only access to the necessary tables: private tools are physically absent there. A compromise is an explicit whitelist of tool names in the configuration.

2.2. 🛡 Drafts and deleted pages in the vector database

Why. The vector database lives its own life: an article might have been indexed as a draft, unpublished, or deleted, and its chunks remained. Vector search knows nothing about the publication status.

How I do it. Each search query connects the found chunks with the articles table and only allows those where the status is "published" and the publication date has passed. Even if there's leftover garbage in the vector database, it won't be exposed.

Alternative — delete chunks from the vector database when an article is unpublished. This is correct, but relies on the indexer never making mistakes. A filter at the query level is a safeguard that always works.

2.3. 🛡 Tools without a "read-only" flag

Why. MCP allows marking a tool with hints: whether it's read-only (readOnlyHint) or can delete something (destructiveHint). Clients react differently to the absence of flags — ChatGPT, for example, considers a tool without a "read-only" flag as one that can modify data and behaves more cautiously with it.

How I do it. All three tools are marked: readOnlyHint = true, destructiveHint = false, idempotentHint = true.

💡 Important: a hint is a promise to the client, not a protection. If a tool actually changes something, the "read-only" flag is a deception, and the responsibility is yours.

💸 Section 3. Limits and money

An open server where every search call costs money (embedding the query), without limits, is an account without a ceiling. Therefore, there are three lines of defense here: a limit per IP, a general daily limit, and a spending limit with the provider. And one difficult decision — what to do when the limit mechanism itself fails.

3.1. 🛡 Limit counts proxy IP instead of the real client

Why. When a site is behind Cloudflare, all requests come to the server from Cloudflare IP addresses. If you count the limit by the connection address, "one client" is the entire Cloudflare. Result: with the first active user, the limit is exhausted for everyone at once.

How I do it. I get the IP through a chain: the CF-Connecting-IP header (set by Cloudflare) → the first IP from X-Forwarded-For → the connection address. The limit is 30 requests per minute from one IP; upon exceeding, the server responds with HTTP 429 and a Retry-After header.

Alternative — limits at the proxy level itself (rate limiting rules in Cloudflare). This offloads the application, but free plans have limited capabilities, and the logic is spread across two places. I kept the limits in the application, where they are easy to change and log.

3.2. 🛡 Spoofed CF-Connecting-IP when accessing the server directly

Why. The CF-Connecting-IP header can only be trusted when the request has actually passed through Cloudflare. But hosting usually has its own server address (in my case, the Railway technical domain), accessible directly. An attacker can access it and provide a new "IP" in each request — the IP limit stops working.

How I have it now. Honestly: I haven't closed direct server access yet. Protection against costs in this scenario is provided by the general daily limit (next point): it counts all requests together, so spoofed IPs don't bypass it. And the last line of defense is the spending limit with the embedding provider.

How to do it correctly: accept requests only from Cloudflare IP addresses, use request authenticity verification from proxies (authenticated origin pulls), or do not publish the server's technical address at all.

3.3. 🛡 No daily safeguard

Why. The per-IP limit protects against one persistent client, but not against thousands of different ones: a botnet or simply server popularity will generate as many calls as there are IP addresses.

How I do it. A separate counter for the entire server — 5000 calls per day in UTC. When it's exceeded, the server responds with "daily limit exceeded" and the search is not performed, meaning embeddings are not paid for.

What if Redis with counters crashes?

Both counters — per IP and daily — reside in Redis. If it's unavailable, you have to choose between two strategies, and both have a cost:

Fail-open (allow)Fail-closed (deny)
What happensrequests are processed without limitsserver responds "temporarily unavailable"
What it savesservice availabilitybudget
Riskwhile Redis is down, there are no limits at all — neither per IP nor dailycache problem crashes the entire MCP, even when there's no abuse
When it's suitablepublic data, cheap operations, short outagespaid API, expensive AI operations, generation via LLM at your expense

I chose fail-open — and honestly about the cost: while Redis is unavailable, no application limits work. For my blog, this is acceptable: one call is just embedding the query (fractions of a cent), Redis rarely crashes and not for long, and an unavailable MCP for theoretical abuse is a worse scenario. If the tool generated responses via LLM, I would choose fail-closed without hesitation.

What makes fail-open safer:

  • Spending limit with the embedding provider — a mandatory insurance that doesn't depend on Redis or your code. Even if all application limits fail, the bill won't exceed the set amount. Best of all is a separate API key specifically for MCP with its own limit: then abuse through MCP won't affect other parts of the site that use the same provider.
  • Budget notifications — to find out about a problem before the bill tells you about it.
  • Notifications about the Redis failure itself — so that "working without limits" lasts minutes, not days.
  • In-memory counter as a fallback. When Redis is unavailable, the application switches to a simple counter in its own memory. It doesn't synchronize between instances and resets on restart, but it provides at least some ceiling instead of none. This is a compromise between the two strategies: the service is available, but not with a completely open tap.
💡 General rule: choose a strategy based on the cost of a single call. Cheap call and public data — fail-open plus provider limit. Expensive call (LLM, paid APIs, data actions) — fail-closed: better a few minutes of unavailability than an unexpected bill.

🔍 Section 4. Search quality

4.1. 👤 Connection refused :11434 — local embedding model not running

Symptom. Local test search failed with a 500 error:

I/O error on POST request for "http://localhost:11434/api/embed": Connection refused

Why. Search first converts the query into a vector, and this requires an embedding model. Locally, I use Ollama (port 11434), in production — a cloud model. Ollama was simply not running. It's trivial, but illustrative: search is not just a database, but also an external service that can be unavailable.

How I fixed it. Started Ollama. More importantly, in the MCP tool, such an error is caught and converted into an unavailable status with the message "search is temporarily unavailable," rather than an incomprehensible failure for the model (more details in section 5.1).

⚠️ Related pitfall: the embedding model for search must be the same one used to index the database. A different model means a different vector dimension (768 locally for me and 1536 in production) and a "different vector dimensions" error, or worse, silently incorrect results.

4.2. 👤 Local and production embeddings give different scores — threshold doesn't work

Why. To avoid returning irrelevant results, search filters out results below a similarity threshold. But "similarity 0.5" is not a universal measure: each embedding model has its own scale. The local model gave similarity of 0.7+ for the same queries, while on the production model, relevant results were around 0.6. A threshold tuned on one model will either let everything through or cut everything off on another.

How I do it. The threshold is moved to configuration: locally — 0 (I only look at logic), in production — I calibrate. Calibration is simple: a few queries of three types and their numbers in the logs. On a previous project with the same production model, it turned out like this: the article is definitely there — 0.60–0.63, a related topic — 0.43–0.49, completely unrelated to the site — 0.33–0.34. I set the threshold between "completely unrelated to the site" and "related topic." The value is set by an environment variable, so it can be changed without a new deploy.

How not to repeat: never transfer the similarity threshold between models and environments — calibrate where the actual model is running.

4.3. 🛡 Five fragments of one article instead of five different articles

Why so. A long article is divided into dozens of chunks, and the closest ones to the query often belong to the same article. A naive "top 5 closest chunks" gives five pieces of the same text — the model sees one source instead of five.

How I do it. I take the 50 closest chunks (the vector index works here), number them within each article, keep no more than two per article, and group them by article. The order "first 50 closest, then grouping" is important: if you group immediately, the database will iterate through the entire table.

Alternative — re-ranking for diversity (MMR): the algorithm penalizes results similar to those already selected. More flexible, but more complex; for a blog, "no more than two per article" achieves the same effect more simply.

4.4. 👤 Fragments start with a cut word

Symptom. Fragments like "ma on weak hardware..." or "tti Top 10 models...".

Why so. When slicing text into chunks, adjacent pieces overlap to avoid losing context at the boundary — and the boundary often passes through the middle of a word.

How I fixed it. If a fragment starts with a lowercase letter, I cut everything up to the first space (but no more than 30 characters, to avoid cutting too much). I trim the end at the word boundary and add "...".

Alternative — more correct, but more expensive: cut the text into chunks at sentence boundaries during indexing. Then there will be no cuts at all, but the entire database will need to be re-indexed.

4.5. 🛡 Full article in response eats client context

Why so. An article is 5–10 thousand tokens. One such call — and a significant part of the model's context window is occupied by text from which it needs one paragraph. And the client model still needs to remember the conversation with the user.

How I do it. Search returns fragments up to ~900 characters, no more than two per article. A separate `get_article` tool returns not the full text, but an "article map": title, short summary, and list of sections. The model understands that there is more in the article, and for the full text, the person follows the link.

Compromise: full text is more convenient for the user, fragments are for the model's context and for clicks to the site. For documentation, where a person rarely goes to the site, the full text of a section might be a better choice.

🤖 Section 5. The model does not understand the server correctly

The user of the MCP server is the model. Like a person, it needs clear messages.

5.1. 🛡 Exceptions instead of statuses: "no data" and "server is broken" look the same

Why so. If a tool throws an exception, the client receives a general call error. The model doesn't know what happened: there's no article on this topic, or the server is temporarily down? This determines what it will tell the person: "this site doesn't write about this" or "try again later".

How I do it. Each response has a `status` field:

{"status": "ok",          "articles": [...]}
{"status": "not_found",   "message": "Nothing found on webscraft.org for this query."}
{"status": "unavailable", "message": "Search is temporarily unavailable. Please try again later."}
{"status": "error",       "message": "Query is empty."}

All exceptions are caught within the tool and written to the log, and the model receives a clear status.

5.2. 🛡 Vague descriptions: the model does not call the tool

Why so. The model decides whether to call a tool based solely on its name, description, and parameters. A description like "search articles" says nothing about which articles and when they are useful. The model will simply answer from its own knowledge.

How I do it. The description includes four things: what the data is about (Ukrainian technical blog: AI, local LLMs, Spring Boot, RAG, SEO), when to call (questions "how to do" in these topics or requests to provide a source), how to formulate the query (in Ukrainian — so the search is more accurate), and what to do with the result (these are fragments — always provide a link).

How to check: look at the description through the eyes of the client in MCP Inspector, then ask a real client a question without mentioning the tool, and check the server logs to see if the call was made. [CHECK ON PROD: does Claude call search without direct prompting]

5.3. 👤 Cyrillic in configuration breaks instructions

Why so. The MCP server, upon connection, sends an instruction to the client — a short text about what this server is. I set it in the configuration file `.properties`, and this format is historically not read in UTF-8. Cyrillic there easily turns into "gibberish" — I've encountered this before with other settings.

How I do it. I write the instruction and tool descriptions in English: models understand both languages equally well, and the encoding problem disappears.

Alternatives: YAML configuration (read in UTF-8), environment variables, or text directly in code.

✅ Checklist before launching MCP server

  • ☐ `tools/list` returns only the tools you consciously publish.
  • ☐ `initialize` to the production address from the outside returns JSON, not an HTML proxy page.
  • ☐ Response without `Set-Cookie`; admin and other closed sections remain closed.
  • ☐ Connection verified with the latest versions of clients (Inspector, Claude Code, web clients).
  • ☐ Limit counts the real client IP, not the proxy IP; upon exceeding, returns 429 with `Retry-After`.
  • ☐ There is a general daily spending limit.
  • ☐ Only published data is exposed externally (filter by status in the request itself).
  • ☐ Read-only tools are marked with `readOnlyHint`.
  • ☐ Relevance threshold calibrated on the production embedding model.
  • ☐ Fragments are short, without word cuts, no more than 1–2 per source.
  • ☐ Tools return statuses (`ok` / `not_found` / `unavailable`), not exceptions.
  • ☐ Tool descriptions answer the questions "what about," "when to call," and "what to do with the result."
  • ☐ Logs show who connected, what query the model formulated, and how long the call lasted.

❓ Frequently Asked Questions (FAQ)

Why doesn't the client see my MCP server's tools?
Most often, one of three reasons: the client and server are on different protocol versions (error in `initialize`), the request is intercepted by a proxy or security framework (HTML or redirect instead of JSON), or the tools simply haven't registered. Check `tools/list` via curl — this will narrow down the search immediately.

How do I know which protocol version the client is using?
The client sends it in the `initialize` request in the `protocolVersion` field, along with the client name and version in `clientInfo`. Log these fields — and you will always know who and how is connecting.

Is Redis required for limits?
No. For a single server instance, an in-memory counter is sufficient. Redis is needed when there are multiple instances or when limits need to survive restarts.

How to check that nothing extra is being exposed externally?
Call `tools/list` after each deployment and compare it with the list you are consciously publishing. It's best to automate this in a post-deployment check.

Can I test the server in ChatGPT or Claude without deploying?
Only through a tunnel (ngrok, Cloudflare Tunnel), which gives the local server a temporary public address. For local tests, Inspector and clients on your computer are easier — Claude Code, Cursor, OpenCode.

✅ Conclusions

The search itself in the MCP server is the easiest part. Most errors lie at the boundaries: between protocol versions, between the server and the proxy, between the local environment and production, between your code and the "convenient" automation of the framework. Therefore, the main rule is to check not the code, but what the client sees: `initialize`, `tools/list`, and actual external calls, after each deployment.

How MCP is structured and what architectural decisions should be made before writing code — in the main article about MCP server for a website. And if you need MCP for your website or company documents — contact us.