Expose Versioned API Documentation Through MCP Resources

Your agent is trying to call a billing API. The MCP tool is connected to v2. The model, however, has just read an old authentication guide, a deprecated endpoint list, or a generic OpenAPI dump with no clear version. It confidently calls POST /v1/invoices with the wrong auth header. The tool fails. The model apologizes. The user wonders why the “AI integration” feels flaky.

This is a documentation problem, but not in the usual sense.

In MCP-based systems, documentation is not only for humans browsing a website. It is runtime context. Models, agents, and developer tools may need to read it through the same protocol they use to discover capabilities. If your API documentation is not versioned, addressable, and machine-readable, your MCP server is leaving a critical part of the developer experience outside the protocol.

MCP resources are a natural fit for this. They let a server expose read-only context through stable URIs. Clients can list them, read them, and sometimes subscribe to changes. Instead of stuffing documentation into system prompts or tool descriptions, you can expose it as first-class MCP resources:

docs://billing/versions.json
docs://billing/v2/index.json
docs://billing/v2/openapi.json
docs://billing/v2/guides/authentication.md
docs://billing/v2/operations/create-invoice

That gives clients a clean way to ask:

  • Which API versions exist?
  • Which version is stable?
  • Which version is deprecated?
  • What changed between v1 and v2?
  • How does createInvoice work in v2?
  • What authentication does v3-beta require?

This article looks at how to design that properly.

TL;DR

  • Expose API docs as MCP resources, not only as prompt text.
  • Publish a version manifest before exposing deep documentation.
  • Use immutable versioned URIs such as docs://api/v2/openapi.json.
  • Treat latest as a pointer, not as the primary contract.
  • Split large docs into small, addressable resources.
  • Use resource templates for endpoint-level lookup.
  • Generate both raw OpenAPI and model-friendly summaries.
  • Include deprecation, migration, and lifecycle metadata.
  • Notify clients when docs change, if your client supports it.
  • Secure and test documentation resources like production API surface.

📋 Table of Contents


Why Versioned Documentation Belongs in MCP Resources

MCP resources are intended for read-only context that a server wants to make available to clients. They are identified by URIs, listed through resources/list, and read through resources/read. Servers can also expose resource templates and, where supported, change notifications.

API documentation fits that shape well.

A good documentation resource surface might include:

docs://billing/versions.json
docs://billing/v1/index.json
docs://billing/v1/openapi.json
docs://billing/v1/changelog.md
docs://billing/v2/index.json
docs://billing/v2/openapi.json
docs://billing/v2/guides/authentication.md
docs://billing/v2/operations/create-invoice

The important part is not the exact URI scheme. It is the structure.

You want documentation to be:

  • Discoverable.
  • Versioned.
  • Stable.
  • Readable.
  • Machine-parseable where useful.
  • Chunked appropriately.
  • Explicit about lifecycle state.

That is harder than it sounds because documentation is often written for humans browsing a website, not for clients asking targeted questions through a protocol.

The goal is not to dump your entire documentation site into MCP. The goal is to expose a curated, versioned documentation surface that clients can navigate safely.


1. Stop Hiding API Documentation in Prompts and Tool Descriptions

Scenario

Your MCP tool description says:

Creates an invoice. Requires customer_id and currency.

That works until the model needs to know:

  • Which fields are optional?
  • Which error codes can be returned?
  • Which API version is being used?
  • What changed from the previous version?
  • Whether the endpoint supports idempotency keys?
  • Whether tax objects are required in the EU region?

You cannot fit all of that into a tool description. If you try, the description becomes bloated and fragile.

Why it matters

Tool descriptions are for capability discovery, not for full API documentation. Prompts are even worse because they are usually static, hard to version, and invisible as protocol artifacts.

If documentation lives only in a prompt, you lose:

  • Discoverability.
  • Versioning.
  • Reusability across clients.
  • Testability.
  • Auditability.
  • Client-side caching and navigation.

Solution

Expose documentation through MCP resources.

A minimal MCP server that exposes documentation resources can look like this:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

const server = new Server(
  {
    name: "api-docs-server",
    version: "0.1.0",
  },
  {
    capabilities: {
      resources: {},
    },
  }
);

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "docs://billing/versions.json",
        name: "Billing API versions",
        description: "List of available Billing API versions and lifecycle status.",
        mimeType: "application/json",
      },
      {
        uri: "docs://billing/v2/openapi.json",
        name: "Billing API v2 OpenAPI",
        description: "OpenAPI document for Billing API version 2.",
        mimeType: "application/vnd.oai.openapi+json",
      },
      {
        uri: "docs://billing/v2/guides/authentication.md",
        name: "Billing API v2 authentication guide",
        description: "How to authenticate against Billing API v2.",
        mimeType: "text/markdown",
      },
    ],
  };
});

server.setRequestHandler(ReadResourceRequestSchema, async request => {
  const uri = request.params.uri;

  if (uri === "docs://billing/versions.json") {
    return {
      contents: [
        {
          uri,
          mimeType: "application/json",
          text: JSON.stringify(
            {
              versions: [
                { version: "v1", status: "deprecated" },
                { version: "v2", status: "stable" },
                { version: "v3", status: "beta" },
              ],
            },
            null,
            2
          ),
        },
      ],
    };
  }

  throw new Error(`Resource not found: ${uri}`);
});

const transport = new StdioServerTransport();
await server.connect(transport);

This is intentionally simple. The point is that documentation becomes part of the MCP surface.

Why this works

Clients can now discover documentation without guessing. They do not need a hidden prompt or a hardcoded URL. The documentation becomes addressable.

That matters especially when multiple clients use the same MCP server:

  • IDE assistants.
  • Internal developer platforms.
  • Agent frameworks.
  • Support copilots.
  • API migration tools.
  • Evaluation harnesses.

All of them can use the same resource URIs.

💡 Practical note: do not expose every documentation page by default. Start with the resources that answer high-value questions: versions, authentication, errors, and operation references.


2. Publish a Version Manifest as the Front Door

Scenario

A client asks for API documentation. Your server exposes dozens of resources:

docs://billing/v1/openapi.json
docs://billing/v2/openapi.json
docs://billing/v3-beta/openapi.json
docs://billing/2024-08/openapi.json
docs://billing/2025-02/openapi.json

The client has no idea which one is stable, which one is deprecated, or which one matches the connected tool.

Why it matters

Versioned documentation without a manifest creates a new problem: choice overload. Models and automated clients are bad at guessing which version they should use unless the metadata is explicit.

Solution

Expose a version manifest as the primary entry point.

Example:

{
  "api": "billing",
  "defaultVersion": "v2",
  "versions": [
    {
      "version": "v1",
      "status": "deprecated",
      "sunsetDate": "2026-08-01",
      "index": "docs://billing/v1/index.json",
      "openapi": "docs://billing/v1/openapi.json",
      "changelog": "docs://billing/v1/changelog.md"
    },
    {
      "version": "v2",
      "status": "stable",
      "index": "docs://billing/v2/index.json",
      "openapi": "docs://billing/v2/openapi.json",
      "changelog": "docs://billing/v2/changelog.md"
    },
    {
      "version": "v3",
      "status": "beta",
      "index": "docs://billing/v3/index.json",
      "openapi": "docs://billing/v3/openapi.json",
      "changelog": "docs://billing/v3/changelog.md"
    }
  ]
}

Serve it at a stable URI:

docs://billing/versions.json

Then each version can have its own index:

{
  "version": "v2",
  "status": "stable",
  "baseUrl": "https://api.example.com/v2",
  "authentication": "docs://billing/v2/guides/authentication.md",
  "openapi": "docs://billing/v2/openapi.json",
  "changelog": "docs://billing/v2/changelog.md",
  "errorReference": "docs://billing/v2/errors.json",
  "operations": [
    {
      "operationId": "createInvoice",
      "method": "POST",
      "path": "/invoices",
      "documentation": "docs://billing/v2/operations/create-invoice"
    },
    {
      "operationId": "getInvoice",
      "method": "GET",
      "path": "/invoices/{invoiceId}",
      "documentation": "docs://billing/v2/operations/get-invoice"
    }
  ]
}

Why this works

The manifest gives the client a safe starting point. Instead of guessing, the client can:

  1. Read docs://billing/versions.json.
  2. Choose the stable version or the version matching the tool.
  3. Read that version’s index.
  4. Navigate to deeper resources only when needed.

This also makes lifecycle state explicit. A client can avoid deprecated versions unless it is specifically doing migration work.

Keep the manifest small

The manifest should be a navigation layer, not a documentation dump.

Good manifest contents:

  • API name.
  • Available versions.
  • Lifecycle status.
  • Default version.
  • Links to index, OpenAPI, changelog.
  • Sunset dates.

Bad manifest contents:

  • Full endpoint schemas.
  • Long markdown guides.
  • Huge examples.
  • Internal implementation notes.

🔍 Why this matters: a manifest turns version selection from a guessing game into a navigable protocol interaction.


3. Use Immutable Versioned URIs, and Treat latest as a Pointer

Scenario

You expose this resource:

docs://billing/openapi.json

At first, it contains v1. Later you update it to v2. Existing conversations, cached prompts, recorded agent traces, and tests now refer to a document whose meaning changed.

Why it matters

Documentation URIs should behave like versioned API contracts. If a URI can silently change meaning, reproducibility breaks.

This is especially painful in agent systems. A recorded conversation may contain a reference to a resource that meant one thing during debugging and something else two weeks later.

Solution

Use immutable versioned URIs:

docs://billing/v1/openapi.json
docs://billing/v2/openapi.json
docs://billing/v3/openapi.json

If you want a convenience alias, make latest explicit:

docs://billing/latest.json

where latest.json contains:

{
  "latestVersion": "v2",
  "openapi": "docs://billing/v2/openapi.json",
  "index": "docs://billing/v2/index.json",
  "resolvedAt": "2026-02-01T00:00:00Z"
}

Do not make the primary documentation URI mutable.

Implementation sketch

function buildVersionedResources(api: string, versions: ApiVersion[]) {
  const resources = [];

  for (const version of versions) {
    resources.push(
      {
        uri: `docs://${api}/${version.version}/index.json`,
        name: `${api} ${version.version} index`,
        description: `Documentation index for ${api} ${version.version}.`,
        mimeType: "application/json",
      },
      {
        uri: `docs://${api}/${version.version}/openapi.json`,
        name: `${api} ${version.version} OpenAPI`,
        description: `OpenAPI document for ${api} ${version.version}.`,
        mimeType: "application/vnd.oai.openapi+json",
      },
      {
        uri: `docs://${api}/${version.version}/changelog.md`,
        name: `${api} ${version.version} changelog`,
        description: `Changelog for ${api} ${version.version}.`,
        mimeType: "text/markdown",
      }
    );
  }

  return resources;
}

Why this works

Immutable URIs make documentation cacheable, referenceable, and testable. If an evaluation says, “The agent used docs://billing/v2/operations/create-invoice,” you can reproduce that later.

Be careful with latest

latest is useful for humans, but it can be harmful for reproducibility.

If a client must produce deterministic behavior, prefer explicit versions:

docs://billing/v2/openapi.json

If latest is necessary, make it a pointer resource, not a mutable document.

⚠️ Gotcha: if your MCP tools are bound to a specific API version, their descriptions should point to the matching documentation version. Do not let a v2 tool rely on latest docs that may drift to v3.


4. Split Large Specs Into Addressable Reading Units

Scenario

Your OpenAPI document is 3 MB. It contains 180 endpoints, dozens of schemas, and many examples. A client reads the whole thing because it does not know what else to do. The model receives far more context than it needs, and the relevant section gets buried.

Why it matters

Large resources are not automatically useful. Clients may truncate them, ignore them, or use them inefficiently. Even when they do not truncate, the model may struggle to find the relevant operation in a huge document.

Solution

Expose documentation at multiple levels:

docs://billing/v2/index.json
docs://billing/v2/openapi.json
docs://billing/v2/summary.json
docs://billing/v2/operations/create-invoice
docs://billing/v2/guides/authentication.md
docs://billing/v2/guides/errors.md

The raw OpenAPI remains available for completeness. The smaller resources are optimized for reading.

A good hierarchy:

Manifest
  Version index
    Summary
      Operation docs
      Guides
      Error reference
    Full OpenAPI

Example resource list

const resources = [
  {
    uri: "docs://billing/v2/index.json",
    name: "Billing API v2 index",
    mimeType: "application/json",
  },
  {
    uri: "docs://billing/v2/summary.json",
    name: "Billing API v2 summary",
    mimeType: "application/json",
  },
  {
    uri: "docs://billing/v2/openapi.json",
    name: "Billing API v2 OpenAPI",
    mimeType: "application/vnd.oai.openapi+json",
  },
  {
    uri: "docs://billing/v2/guides/authentication.md",
    name: "Billing API v2 authentication",
    mimeType: "text/markdown",
  },
  {
    uri: "docs://billing/v2/operations/create-invoice",
    name: "Create invoice operation",
    mimeType: "text/markdown",
  },
];

Why this works

Clients can choose the smallest useful resource.

A model trying to understand what the API can do may read:

docs://billing/v2/summary.json

A model trying to call one endpoint may read:

docs://billing/v2/operations/create-invoice

A code generator or migration tool may read:

docs://billing/v2/openapi.json

That is much better than forcing every consumer to read the largest document.

Keep chunks self-contained

Each operation resource should include enough context to be useful on its own:

  • Operation ID.
  • HTTP method and path.
  • Authentication requirements.
  • Request schema.
  • Response schema.
  • Error codes.
  • Idempotency behavior.
  • Version-specific notes.
  • Example request and response.

Do not split so aggressively that every resource requires five other resources to make sense.


5. Use Resource Templates for Operation-Level Lookup

Scenario

A client wants documentation for a specific operation:

createInvoice

It could list all resources and search for the right one. That works for small APIs, but it becomes awkward when there are hundreds of operations.

Why it matters

Clients should not need to scan the whole documentation surface to answer a narrow question. MCP resource templates can make documentation lookup more direct.

Solution

Expose a resource template such as:

docs://billing/{version}/operations/{operationId}

At the protocol level, the server can expose templates so clients understand the available parameterized resources.

import { ListResourceTemplatesRequestSchema } from "@modelcontextprotocol/sdk/types.js";

server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
  return {
    resourceTemplates: [
      {
        uriTemplate: "docs://billing/{version}/operations/{operationId}",
        name: "Billing API operation documentation",
        description:
          "Documentation for a specific Billing API operation in a specific version.",
        mimeType: "text/markdown",
      },
    ],
  };
});

Then the read handler resolves expanded URIs:

server.setRequestHandler(ReadResourceRequestSchema, async request => {
  const uri = request.params.uri;

  const operationMatch = uri.match(
    /^docs:\/\/billing\/(v\d+)\/operations\/([A-Za-z0-9_-]+)$/
  );

  if (operationMatch) {
    const version = operationMatch[1];
    const operationId = operationMatch[2];

    const doc = await loadOperationDoc(version, operationId);

    if (!doc) {
      throw new Error(`Operation documentation not found: ${uri}`);
    }

    return {
      contents: [
        {
          uri,
          mimeType: "text/markdown",
          text: doc,
        },
      ],
    };
  }

  return readStaticResource(uri);
});

Why this works

The client can ask for exactly what it needs:

docs://billing/v2/operations/create-invoice

or:

docs://billing/v1/operations/refund-charge

The server resolves the request without exposing hundreds of individual static resources in resources/list.

Keep templates simple

Prefer simple templates with obvious variables:

{version}
{operationId}
{guideSlug}

Avoid complicated templates that clients may struggle to expand or that require unusual encoding rules.

💡 Practical note: resource templates are especially useful when documentation is generated dynamically from an OpenAPI document or internal API registry.


6. Generate Model-Friendly Summaries Alongside Raw OpenAPI

Scenario

You expose the raw OpenAPI document. It is technically correct. But it contains:

  • Shared schema components.
  • Generic response wrappers.
  • Many optional fields.
  • Internal metadata.
  • Large enum lists.
  • Nested allOf structures.

The model can parse some of it, but it often misses the practical intent.

Why it matters

OpenAPI is excellent for machines, but it is not always optimized for reading. Models benefit from concise, structured summaries that emphasize the parts needed to make a call correctly.

Solution

Expose both:

docs://billing/v2/openapi.json
docs://billing/v2/summary.json

The summary can be generated from the OpenAPI document.

Example generator:

type OpenApiOperation = {
  operationId?: string;
  summary?: string;
  tags?: string[];
  deprecated?: boolean;
};

type OpenApiSpec = {
  info: {
    title: string;
    version: string;
  };
  paths: Record<string, Record<string, OpenApiOperation>>;
};

function summarizeOpenApi(spec: OpenApiSpec) {
  const operations = [];

  for (const [path, methods] of Object.entries(spec.paths)) {
    for (const [method, operation] of Object.entries(methods)) {
      operations.push({
        operationId: operation.operationId ?? `${method.toUpperCase()} ${path}`,
        method: method.toUpperCase(),
        path,
        summary: operation.summary ?? "",
        tags: operation.tags ?? [],
        deprecated: operation.deprecated ?? false,
      });
    }
  }

  return {
    api: spec.info.title,
    version: spec.info.version,
    operations,
  };
}

The resulting summary might look like:

{
  "api": "Billing API",
  "version": "2.3.0",
  "operations": [
    {
      "operationId": "createInvoice",
      "method": "POST",
      "path": "/invoices",
      "summary": "Create an invoice for a customer.",
      "tags": ["invoices"],
      "deprecated": false
    },
    {
      "operationId": "getInvoice",
      "method": "GET",
      "path": "/invoices/{invoiceId}",
      "summary": "Retrieve an invoice by ID.",
      "tags": ["invoices"],
      "deprecated": false
    }
  ]
}

Why this works

The summary gives the client a cheap way to understand the API surface. If the model needs more detail, it can read the operation-specific resource or the full OpenAPI document.

This layered approach avoids two common mistakes:

  • Giving the model too little information.
  • Giving the model too much raw specification.

Include operation cards for high-risk tools

For important or dangerous operations, generate richer documentation:

# createInvoice

`POST /v2/invoices`

Creates an invoice for a customer.

## Authentication

Requires bearer token with `billing:write` scope.

## Required fields

- `customer_id`
- `currency`
- `lines`

## Idempotency

Supports `Idempotency-Key` header. Recommended for retries.

## Common errors

- `customer_not_found`
- `invalid_currency`
- `tax_calculation_failed`

## Example

```json
{
  "customer_id": "cus_123",
  "currency": "USD",
  "lines": [
    {
      "amount": 1000,
      "description": "Support plan"
    }
  ]
}

This kind of resource is often more useful than asking a model to infer behavior from a giant OpenAPI file.

---

## 7. Make Deprecation and Migration Part of the Doc Surface

### Scenario

Your API has three versions:

```text
v1: deprecated
v2: stable
v3: beta

The documentation for v1 still looks current. The model reads it and uses deprecated endpoints. Later, it tries to migrate a customer and does not know which fields changed.

Why it matters

Versioning is not only about keeping old docs around. It is about communicating lifecycle state and migration intent.

If deprecation status lives only on a human-facing banner, MCP clients will miss it.

Solution

Expose lifecycle metadata as resources.

Version manifest fragment:

{
  "version": "v1",
  "status": "deprecated",
  "sunsetDate": "2026-08-01",
  "migrationGuide": "docs://billing/migrations/v1-to-v2.md",
  "changelog": "docs://billing/v1/changelog.md"
}

Migration guide resource:

docs://billing/migrations/v1-to-v2.md

Example content:

# Migrating from Billing API v1 to v2

## Authentication

v1 uses `X-Api-Token`.
v2 uses standard bearer tokens.

## Breaking changes

### Invoice creation

v1:

```json
{
  "customer": "cus_123",
  "amount": 1000
}

v2:

{
  "customer_id": "cus_123",
  "currency": "USD",
  "lines": [
    {
      "amount": 1000,
      "description": "Service charge"
    }
  ]
}

Removed fields

  • invoice.due_on replaced by invoice.due_date
  • charge.source replaced by payment_method_id

Deprecated endpoints

  • POST /v1/charges
  • POST /v1/refunds

You can also expose machine-readable compatibility notes:

```json
{
  "from": "v1",
  "to": "v2",
  "breakingChanges": [
    {
      "operationId": "createInvoice",
      "type": "request_schema_changed",
      "description": "Invoice creation now requires line items instead of a single amount."
    },
    {
      "operationId": "createCharge",
      "type": "removed",
      "replacement": "createPaymentIntent"
    }
  ]
}

Serve it at:

docs://billing/migrations/v1-to-v2.json

Why this works

Deprecation becomes part of the contract surface. Clients can avoid deprecated versions, suggest migrations, or warn users before calling old endpoints.

This is especially useful when MCP tools wrap multiple API versions. A tool can say:

This tool uses Billing API v2. For v1 migration details, read docs://billing/migrations/v1-to-v2.md.

That gives the model a precise next step instead of a vague warning.

🚨 Production warning: documentation can say an API is deprecated, but it cannot stop misuse by itself. Pair deprecated docs with runtime guardrails, tool annotations, or policy checks where appropriate.


8. Notify Clients When Documentation Changes

Scenario

You update the v2 documentation to clarify a required field. Some clients continue using cached context. Others do not know the resource changed until someone manually rereads it.

Why it matters

Documentation is not always static. Even immutable versioned docs receive fixes:

  • Clarified examples.
  • Corrected error codes.
  • Updated rate-limit guidance.
  • Added migration notes.
  • Fixed schema mistakes.

Clients need a way to know when the documentation surface changed.

Solution

Use MCP resource change notifications where supported.

Declare the capability:

const server = new Server(
  {
    name: "api-docs-server",
    version: "0.2.0",
  },
  {
    capabilities: {
      resources: {
        listChanged: true,
      },
    },
  }
);

After publishing a new set of resources, send:

await server.notification({
  method: "notifications/resources/list_changed",
});

At the protocol level, the notification is:

{
  "jsonrpc": "2.0",
  "method": "notifications/resources/list_changed"
}

If you support subscriptions, clients may also subscribe to individual resources and receive updates when those resources change. The exact level of client support can vary, so design defensively.

Build documentation atomically

If your docs are generated during CI, publish them as a complete snapshot.

A good pipeline:

1. Build OpenAPI artifacts.
2. Generate summaries and operation docs.
3. Validate schemas and links.
4. Scan for secrets.
5. Publish docs snapshot.
6. Update resource registry.
7. Emit list_changed notification.

Do not update half the documentation surface while clients are reading it.

Keep URIs stable during updates

Even when documentation changes, the URI structure should remain stable.

Good:

docs://billing/v2/guides/authentication.md

Bad:

docs://billing/v2/guides/authentication-final-v3-updated.md

If a resource is replaced by a new version, keep the old URI readable if possible or expose a redirect-like metadata resource. MCP resources do not need to mimic HTTP redirects, but clients still benefit from predictable naming.

💡 Practical note: not every MCP client will react immediately to resource notifications. Stable, versioned URIs remain your primary safeguard.


9. Secure and Test Documentation Like Production API Surface

Scenario

Your MCP server exposes internal documentation resources. One of them includes a staging API key in an example. Another exposes an internal admin endpoint that should not be visible to general clients. A third contains a path traversal bug because it maps URIs directly to filesystem paths.

Why it matters

Documentation is often treated as harmless. It is not.

Documentation resources can leak:

  • Internal endpoints.
  • Environment URLs.
  • Sample credentials.
  • Customer data in examples.
  • Feature flags.
  • Security assumptions.
  • Unreleased API surface.

They can also introduce injection risk if untrusted documentation content is fed into model context without care.

Solution

Treat documentation resources as production API surface.

Use an allowlist

Do not read arbitrary files based on URI fragments.

Bad idea:

const filePath = `./docs/${uri.replace("docs://", "")}`;
const text = await readFile(filePath, "utf8");

Better:

type DocResource = {
  uri: string;
  filePath: string;
  mimeType: string;
};

const resourceMap = new Map<string, DocResource>();

function registerResource(resource: DocResource) {
  resourceMap.set(resource.uri, resource);
}

async function readResource(uri: string) {
  const resource = resourceMap.get(uri);

  if (!resource) {
    throw new Error(`Resource not found: ${uri}`);
  }

  const text = await readFile(resource.filePath, "utf8");

  return {
    contents: [
      {
        uri,
        mimeType: resource.mimeType,
        text,
      },
    ],
  };
}

The URI maps to a known, registered file. It is not interpreted as a path.

Redact examples

Examples should use synthetic data:

{
  "customer_id": "cus_example_123",
  "email": "user@example.com",
  "api_key": "sk_test_REPLACE_WITH_KEY"
}

Never expose real tokens, even if they are revoked.

Validate generated docs

Add tests for:

  • Missing version manifests.
  • Broken resource URIs.
  • OpenAPI parsing failures.
  • Operation docs without examples.
  • Deprecated versions missing migration guides.
  • Resources with unsupported MIME types.
  • Secret-like strings in documentation.

A simple contract test:

import assert from "node:assert";

function validateDocumentationResources(resources: DocResource[]) {
  const uris = new Set<string>();

  for (const resource of resources) {
    assert.ok(resource.uri.startsWith("docs://"), "Resource URI must use docs scheme");
    assert.ok(!uris.has(resource.uri), `Duplicate resource URI: ${resource.uri}`);
    assert.ok(resource.mimeType, `Missing mimeType for ${resource.uri}`);
    assert.ok(resource.filePath, `Missing filePath for ${resource.uri}`);

    uris.add(resource.uri);
  }
}

Scope documentation by client

Not every client should see every document.

Examples:

  • Public clients see stable public API docs.
  • Internal tools see internal API docs.
  • Partner clients see partner-scoped docs.
  • Beta clients see beta docs.

MCP itself does not replace your authorization model. If documentation is sensitive, enforce access at the server or transport layer.

🚨 Production warning: if documentation resources are fed into model context, maliciously edited docs can become a prompt-injection vector. Keep doc generation trusted and reviewable.


Choosing the Right Documentation Exposure Strategy

There are several ways to make API documentation available to MCP clients. Resources are not the only option, but they are often the best default.

ApproachBest forStrengthWeakness
Prompt snippetsVery small toolsSimpleHard to version and maintain
Tool descriptionsCapability hintsClose to tool surfaceToo limited for full docs
MCP resourcesVersioned, addressable docsDiscoverable and stableRequires design and curation
MCP tools for doc searchDynamic doc queryingFlexibleMore complex, less read-only
External documentation URLsHuman referenceEasy to hostNot protocol-native or machine-friendly
Vector search/RAGLarge doc corporaSemantic lookupLess deterministic, harder to version

When MCP resources are the right choice

Use MCP resources when documentation is:

  • Stable enough to publish.
  • Versioned.
  • Read-only.
  • Useful across multiple clients.
  • Needed for debugging or agent reasoning.
  • Related directly to MCP tools.
  • Small enough to chunk meaningfully.

They are especially good for:

  • API manifests.
  • Version indexes.
  • OpenAPI documents.
  • Authentication guides.
  • Error references.
  • Operation cards.
  • Migration guides.
  • Deprecation notices.

When a documentation tool may be better

A tool may be better if the documentation source is highly dynamic and requires complex queries, such as:

  • “Find endpoints that support idempotency.”
  • “Show breaking changes affecting my current token scopes.”
  • “Search internal runbooks for this error code.”

Even then, the tool can return resource URIs for canonical documents.

What I would choose

For most API-facing MCP servers, I would use this design:

docs://api/versions.json
docs://api/{version}/index.json
docs://api/{version}/summary.json
docs://api/{version}/openapi.json
docs://api/{version}/operations/{operationId}
docs://api/{version}/guides/authentication.md
docs://api/{version}/errors.json
docs://api/migrations/{from}-to-{to}.md

Then I would apply these rules:

  1. Version manifest first.
    Clients should never guess which versions exist.

  2. Immutable version URIs.
    v2 docs should not silently become v3 docs.

  3. Small summaries before large specs.
    Let clients choose how much context to read.

  4. Operation docs as first-class resources.
    Endpoint-level questions are common; make them cheap to answer.

  5. Deprecation as data.
    Lifecycle state should be machine-readable.

  6. Generated docs from the same source as the API.
    Do not let OpenAPI, tool implementation, and documentation drift apart.

  7. Security review for docs.
    Documentation can leak as much as an API response.


A Practical Checklist Before You Ship

Before exposing versioned API documentation through MCP resources, verify these:

  • There is a stable version manifest resource.
  • Each API version has an index resource.
  • Documentation URIs are versioned and immutable.
  • latest, if present, is a pointer resource.
  • Large documents are split into smaller resources.
  • Operation-level documentation is addressable.
  • Resource templates are simple and predictable.
  • Raw OpenAPI is available where relevant.
  • Model-friendly summaries are generated.
  • Deprecated versions are clearly marked.
  • Migration guides are exposed for breaking changes.
  • Documentation updates are published atomically.
  • Resource change notifications are used where supported.
  • Documentation resources are access-controlled.
  • Examples contain no real secrets or customer data.
  • Resource URIs are resolved through an allowlist.
  • Generated docs are validated in CI.
  • Tool descriptions reference matching documentation versions.
  • Clients can navigate from manifest to operation docs without guessing.
  • The documentation surface is tested like an API contract.

The deeper point is that MCP resources turn documentation into part of the system contract. It is no longer a website you hope the model saw once during training. It becomes discoverable, versioned, and addressable context that clients can use deliberately.

That is exactly what you want when tools start touching real APIs.

Post a Comment

Previous Post Next Post