A client connected to https://YOUR-DOMAIN/mcp sees nine tools. Every one of them is read-only, and every one is scoped to your published documentation and API reference. There is no write path, no draft path and no route to any other content: a reader’s AI cannot reach an unpublished page, a blog post or a marketing page through this surface, even by asking for it by id.
The server identifies itself as leed-docs-mcp and ships an instruction string telling clients where to begin — “Start with get_site_overview to learn what documentation sets this site publishes.” Well-behaved clients follow it, and the tools are designed around that opening move: orient, browse or search, then fetch only the pages you actually need.
| Tool | Parameters | Returns | Read-only |
|---|---|---|---|
get_site_overview | None | Site name, description, and every set with its id, name, type and basePath | Yes |
list_documentation_sets | None | The same set list, without the site identity | Yes |
describe_schema | None | JSON Schema for every shape the other tools return | Yes |
get_documentation_set | setId | The set’s hierarchical table of contents | Yes |
search_docs | query, mode, pageTypeIds, limit | Ranked hits over documentation pages — references, not content | Yes |
search_api | query, mode, pageTypeIds, limit | Ranked hits over API reference pages | Yes |
get_pages_by_id | pageIds | { resources: [DocResource] }, in input order | Yes |
get_pages_by_path | paths | { resources: [DocResource] }, in input order | Yes |
get_openapi_spec | setId | The set’s complete openapi.yaml | Yes |
Orientation
Three tools take no arguments at all and exist to let a client work out what your site holds before it spends a call on anything expensive.
get_site_overview is the cheap opener. It returns your site title, your site description, and one entry per documentation set carrying the set’s id, its display name, its type (documentation or api) and its basePath. That id is the handle everything else takes — pass it to get_documentation_set or get_openapi_spec.
list_documentation_sets returns the same set list on its own, for a client that already knows the site and just wants the current inventory.
describe_schema returns JSON Schema for the response shapes the other tools emit: DocResource, DocumentationSet, ContentItem, the search-result item and the pages envelope. It touches no live data.
Browsing a set
get_documentation_set returns the set’s table of contents as a tree — the same left-hand navigation your readers see in a browser, in structured form.
| Parameter | Type | Required | Default | Range/limit |
|---|---|---|---|---|
setId | string | Yes | — | A set id from list_documentation_sets or get_site_overview |
The result is { setId, name, items }, where items is a tree of nodes. A node takes one of three shapes:
- A folder.
name,titleandchildren. Folders are never links — they exist to group, and any href on one is cleared before the tree is built. - An internal page.
pageId,pathand the canonical absoluteurl, resolved from the page’s current path at the moment you asked. If the page it points at no longer resolves, the node still carries thepageIdso a client can see what was referenced. - An external link. The
hrefis passed through untouched.
An API-reference leaf additionally carries a method — "GET", "POST" and so on — parsed from the navigation icon Leed puts on endpoint items, with the redundant verb stripped from the front of the title. "GET /users" in the menu becomes { title: "/users", method: "GET" } on the wire, so a model gets the verb as data rather than as a word it has to re-parse out of a string.
The TOC these tools return is the menu you built. There is no separate structure to maintain for AI clients, and reordering the menu reorders what they see.
Searching
search_docs and search_api share one engine, one argument schema and one result shape. Each is hard-scoped to its own content kind: a documentation search can never return an API page, and an API search can never return a documentation page. Neither can reach posts.
| Parameter | Type | Required | Default | Range/limit |
|---|---|---|---|---|
query | string | Yes | — | At least one character |
mode | enum | No | hybrid | freetext, vector or hybrid |
pageTypeIds | string array | No | Every set of this kind | Set ids from list_documentation_sets |
limit | integer | No | 10 | 1–25 |
search_api takes exactly the same four parameters, with the same defaults and the same ranges.
| Mode | How it matches | When to use it |
|---|---|---|
freetext | Full-text keyword search over your published content | An exact identifier, an error string, a flag name |
vector | The query is embedded and matched by meaning | A described problem with none of your vocabulary in it |
hybrid | Both lists, merged by reciprocal rank fusion and de-duplicated by page | Everything else — which is why it is the default |
A response carries the mode that actually ran plus the ranked hits:
{
"mode": "hybrid",
"results": [
{
"pageId": "8f0f3f2a-4a1e-4c0e-9c1d-7b2f0a6a5e11",
"path": "/docs/getting-started/authentication/",
"url": "https://docs.example.com/docs/getting-started/authentication/",
"title": "Authentication",
"snippet": "Every request carries a bearer token in the Authorization header…",
"score": 0.81
}
]
}The same engine powers the search box on your published documentation site, so a reader and their AI are ranking against the same index.
The pageTypeIds filter is the versioning axis
pageTypeIds narrows a search to a subset of the sets of that kind. Its most useful application is a versioned API reference: pass the set id for v2 and the search never surfaces a v1 operation, which is what lets a client pin an entire session to one API version.
It can only ever narrow. Passing a set of the wrong kind does not widen the search into that kind — the tool’s own scope is applied first and is not negotiable from the outside.
Reading pages
Two tools fetch pages. They differ only in how you address them: get_pages_by_id takes page ids (from a search hit or a TOC node), get_pages_by_path takes site paths (from a TOC node or an OpenAPI operation reference). Both return the same envelope, and both preserve input order.
| Parameter | Type | Required | Default | Range/limit |
|---|---|---|---|---|
pageIds | string array | Yes | — | 1–25 entries |
| Parameter | Type | Required | Default | Range/limit |
|---|---|---|---|---|
paths | string array | Yes | — | 1–25 entries |
Both scope-check every item independently. A page id that resolves to something outside your documentation and API sets — a blog post, a landing page — comes back as a per-item error rather than as content, and it does not fail the rest of the batch.
The DocResource shape
Every fetch, and every search hit, is a DocResource:
{
"pageId": "8f0f3f2a-4a1e-4c0e-9c1d-7b2f0a6a5e11",
"path": "/docs/getting-started/authentication/",
"url": "https://docs.example.com/docs/getting-started/authentication/",
"title": "Authentication",
"setId": "u05nxr",
"content": "…",
"error": "…"
}path and url are always present. content and error are mutually exclusive in practice, and the way content arrives is worth understanding if you ever read a raw response.
The MCP server itself never emits content. It emits the envelope — path present, content absent — and that pairing is the instruction to your published site’s edge worker to fetch the page from your deployed assets and fill it in on the way out. Your reader’s client receives a complete resource; the two halves are assembled at the edge because the content lives with the published site, not in the API. If the edge cannot find the file, it sets a per-item error on that one resource and the rest of the response is unaffected.
{
"resources": [
{
"pageId": "8f0f3f2a-4a1e-4c0e-9c1d-7b2f0a6a5e11",
"path": "/docs/getting-started/authentication/",
"url": "https://docs.example.com/docs/getting-started/authentication/",
"setId": "u05nxr",
"content": "Every request carries a bearer token…"
},
{
"path": "/blog/why-we-rebuilt-search/",
"url": "",
"error": "path is not within a documentation set: /blog/why-we-rebuilt-search/"
}
]
}Path safety
get_pages_by_path accepts strings from an untrusted client, so it validates before it decodes. A path containing a .. segment, a percent-encoded separator (%2e, %2f, %5c), a backslash or a NUL byte is rejected outright. What survives is normalized and then re-resolved against a fixed root to confirm that collapsing . and // did not walk out of it.
A path that fails any of those checks comes back as a per-item invalid path error. This sits in front of an asset store that is already per-company and already scoped, so it is defense in depth rather than the only line — but it means a client fuzzing your paths gets errors, not surprises.
The OpenAPI spec
get_openapi_spec returns the whole openapi.yaml for one API set.
| Parameter | Type | Required | Default | Range/limit |
|---|---|---|---|---|
setId | string | Yes | — | Must be an api-type set; a documentation set is refused |
The tool’s own description warns clients that the result is large and steers them at get_pages_by_path for a single operation. It is the right tool when a model needs the schema of the whole API — generating a client, diffing two versions — and the wrong tool for answering a question about one endpoint.
Like a page fetch, the spec is returned as a DocResource envelope and hydrated at the edge from the file your build published.
Limits
| Limit | Value | Why |
|---|---|---|
| Pages per fetch call | 25 ids or paths | Matches the search ceiling, so “fetch everything I found” is exactly one call. It also bounds the per-call fan-out on an authenticated surface. |
| Search results | 1–25, default 10 | Ten is enough to choose from; twenty-five is the most a single follow-up fetch can consume. |
| Requests per POST | 1 — batching is refused | One request or one notification per POST, in every protocol revision this server speaks. |
| Sign-in and code limits | See the reader flow | Per-address and per-IP ceilings on the authentication side are documented in Reader Sign-In Flow. |
The 25-item ceiling is advertised to clients as maxItems on the argument schema, so a well-built client will not send 40 and find out the hard way. One that does gets a validation error naming the limit.
What is logged
Every tool call writes exactly one request row, and it is written after the tool runs so the row knows how many records came back. Every denied authorization attempt writes one too.
A row carries the tool name, the inputs — including the search text and the ids or paths requested — the record count, and which verified reader made the call. A denial row instead carries the attempted email address and its domain, and no reader identity, because there is not one yet.
Two signals in that log repay attention:
- Searches that return nothing. These are your documentation gaps, phrased in your users’ own words rather than yours. A recurring zero-result query is a page you have not written.
- Denied attempts. Demand from outside whoever your access rules currently admit. Under capture-first they arrive attached to a captured lead; either way they tell you who is trying. Who is allowed to call any of this at all is decided in Configuring the Docs MCP.
Blog posts, articles and other public content are not served here at all. They belong to a separate, unauthenticated surface — the Site AI Agent — which is where a client that asks this server for one is politely redirected.