DEV Community

GEX.live
GEX.live

Posted on

Your MCP server is invisible to ChatGPT unless it has these two tools

My MCP server had nine working tools. Claude used all of them. ChatGPT could list the server and could not use a single one.

The reason is short: ChatGPT reaches an MCP server through exactly two tool names, search and fetch, and ignores the rest of the table. In deep research that pair is not a convenience layer over your tools — it is the interface. A server without them is listable and useless.

Here is what I learned building them for a server that was already in production with a different shape.

The contract

Both tools answer with plain text. Not structuredContent, not an outputSchema — the connector reads content[0].text and expects a documented JSON envelope inside it.

search(query) returns:

{"results": [{"id": "...", "title": "...", "url": "https://...", "text": "snippet"}]}
Enter fullscreen mode Exit fullscreen mode

fetch(id) returns one document, not wrapped in a list:

{"id": "...", "title": "...", "text": "the full text", "url": "https://...", "metadata": {}}
Enter fullscreen mode Exit fullscreen mode

If your existing tools declare outputSchema and answer with structuredContent — mine did, and it is the better shape for every other client — leave those alone and make these two the exception. They are the only tools in my table that are text-only, and there is a comment in the source saying why so nobody "fixes" it later.

Rule one: every id must round-trip

search hands out ids. fetch receives them. If a model can be handed an id it cannot resolve, you have built a dead end that costs a round trip to discover.

The failure mode I avoided by accident and then almost re-introduced: using titles as ids. A model will hand you back something close to the title, and now you are doing fuzzy matching in your resolver.

Namespace them instead:

`session:${day}`     // a dated record
`page:${path}`       // a document on the site
Enter fullscreen mode Exit fullscreen mode

Two prefixes, both trivially parseable, both impossible to produce by paraphrase.

Rule two: tolerant on input, strict on output

Models do not reliably hand back the id they were given. Mine gets handed citation URLs, bare dates, and paths with and without a leading slash — all meaning something perfectly clear. Refusing them is technically correct and practically a wasted turn.

function resolveRef(raw) {
  let s = raw.trim();
  if (s.startsWith("session:")) s = s.slice(8);
  else if (s.startsWith("page:")) s = s.slice(5);

  // a URL from a citation, with or without the scheme
  const url = s.match(/^(?:https?:\/\/)?(?:www\.)?example\.com(\/[^\s]*)?$/i);
  if (url) s = url[1] || "/";

  if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { kind: "record", day: s };
  const rec = s.match(/^\/(?:record)\/(\d{4}-\d{2}-\d{2})\/?$/);
  if (rec) return { kind: "record", day: rec[1] };

  const path = ("/" + s.replace(/^\/+/, "")).split(/[?#]/)[0].replace(/\/+$/, "") || "/";
  return { kind: "page", path };
}
Enter fullscreen mode Exit fullscreen mode

Strict on the way out, though. fetch on a site is an HTTP client you are handing to a language model, and the thing it must never become is a way to reach the routes that execute work. My denylist is my own robots.txt Disallow list restated in code — not because robots.txt is a security boundary, but because the two lists answer the same question and having one derive from the other means they cannot drift.

Dates as people type them

If your corpus is time-series, half the queries carry a date, and none of them are ISO.

2026-08-24
August 24, 2026
24 Aug 2026
August 2026        <- means every record in that month
Enter fullscreen mode Exit fullscreen mode

The one non-obvious rule: a named day must win over the month containing it. Parse "24 August 2026" with a month-and-year pattern and you will also match "August 2026" inside it and drag in the whole month. Compute exact days first; only if there are none, fall back to month expansion.

And return only dates you actually hold. A weekend is silently not a trading day, and the honest answer to "what happened on Saturday" is an empty result, not an error.

Read your catalogue from your own site

The first version of my search had a hardcoded list of pages. That was wrong within a day: I published a new article and it was unfindable until I redeployed the server.

Now the catalogue is fetched from the site itself, from two sources, because neither is enough alone:

  • llms.txt is maintained as one line per page — - [Title](url): description — so it carries good titles and real blurbs. But it is a curated file. Mine named five of eleven pages in one section.
  • sitemap.xml names every public URL and tells you nothing about any of them.

So: parse llms.txt first, then let the sitemap fill the gaps with a title derived from the slug. Edge-cache both. Now anything I publish is searchable without touching the server.

Ranking: "right now" is not "the latest"

This one shipped broken and I only caught it because I tested with a real question.

The query was "where is the SPX gamma flip right now". My scorer put the page that answers exactly that in fifth place, under three dated records. The words that separate the two intents — "right now" versus "latest" — are precisely the ones a keyword scorer treats as noise.

Keyword scoring cannot fix this. Split by intent instead:

const wantsNow    = /\b(now|currently|at the moment|live)\b/i.test(q) || /\btoday\b/i.test(q);
const wantsLatest = /\b(latest|newest|recent|yesterday|last)\b/i.test(q);
Enter fullscreen mode Exit fullscreen mode

Present tense hoists the live page. Most-recent hoists the newest finished records. yesterday belongs with the second group deliberately: it is a finished day, and "now" is a moment.

If your server has one URL that answers the present tense, make sure a present-tense query returns it first. Everything else is a rounding error next to getting that wrong.

Do not put a long cache on the page whose value is freshness

I gave fetch a fifteen-minute edge cache for the pages it retrieves. Sensible for documentation that changes on deploy. Wrong for the one page that updates through the day: its own content is already delayed, and a fifteen-minute cache on top of that would hand an assistant a state half an hour old.

Cache by path, not globally:

cf: { cacheTtl: path === "/live" ? 60 : 900, cacheEverything: true }
Enter fullscreen mode Exit fullscreen mode

Keep your real tools

search and fetch are a document interface. They return prose. My other tools return typed numbers with an outputSchema, which is strictly better for any client that can call them.

So both exist, and the descriptions say so — the generic pair carries a line telling a capable client to prefer the specific tools, and the server instructions repeat it. ChatGPT gets an interface it can use. Everything else gets the good one.

Worth knowing before you start

Adding a custom MCP connector in ChatGPT lives under Settings → Plugins → Developer mode, and that toggle is flagged "elevated risk" because it permits unverified connectors. That is a real decision for whoever owns the account, not a checkbox to breeze past — and it is worth telling your users where it is, because "Connectors" is not where it used to be.

None of this is hard. It is about a hundred and fifty lines. But no amount of good tool design substitutes for the two names, and I spent a while assuming a well-built server would be discovered on its merits.


I build gex.live — SPX dealer positioning rebuilt from the options tape, with a public research section where most of the posts are null results. The MCP server is at gex.live/mcp if you want to see the shape described above in something that is actually running.

Top comments (0)