Skip to main content
Glama
naansari
by naansari

API-First SaaS + MCP: A Pattern Guide

How to expose any REST API to an LLM — without duplicating schemas, hardcoding rules, or rebuilding your backend.

This repo is a working reference implementation of the pattern (Python + Flask + FastMCP), but the pattern itself is language-agnostic. The concepts in the first half apply equally to a Node.js Express API, a Rails app, a Go service, or any HTTP backend.

User (natural language)
  → LLM
    → MCP Server       (thin adapter — fetches everything from the API at runtime)
      → Your REST API  (unchanged business logic)
        → Your database

The Problem

Most approaches to "LLM + API" land in one of two failure modes:

Approach

Problem

Hardcode everything in the system prompt

Schemas drift, rules go stale, every API change needs an LLM update

Give the LLM raw OpenAPI spec

All schemas in context every turn — most irrelevant, tokens wasted

One MCP tool per endpoint

25 endpoints = 25 tool schemas in context always

The root issue: knowledge duplication. The API already knows its own shape. The goal is to make the LLM ask the API what it can do, rather than telling the LLM upfront.


Related MCP server: OpenAPI MCP Server

Why MCP Over Direct Function Calling?

You could skip MCP entirely and pass your OpenAPI spec to the LLM as a tool list. Many integrations do this. Here's why that diverges from this pattern at scale:

Direct function calling

MCP + discovery

Schema in context

All tools loaded every turn

Fetched on demand — only what's needed

Client portability

Tied to one LLM client

Any MCP-compatible client (Claude Desktop, Cursor, custom agents)

API change propagation

Regenerate and redeploy the tool list

Discovery routes return the latest state automatically

Business rules

In the system prompt or tool descriptions

In the rules registry, fetched at runtime

MCP server updates

N/A

Never needed — server has no API knowledge

MCP also gives you a transport-agnostic boundary: the same MCP server works over stdio (subprocess), HTTP/SSE, or WebSocket, and works with any LLM that supports tool use — not just the one you started with.

The short version: direct function calling works for simple APIs and single clients. MCP with discovery works for APIs that change and systems that need to evolve.


The Pattern

Three layers, each with a single job.

Running example: this guide uses a recruiting CRM throughout — an API with four entities: accounts (client companies), contacts (hiring managers), candidates (job seekers), and placements (candidate ↔ account relationships at a given stage). It's a good fit because the entities have real dependencies: you can't create a placement without both a candidate and an account. Wherever you see "recruiting CRM" or "placement", substitute your own domain.

Layer 1 — Your API (add a registry + discovery routes)

Your existing business logic stays untouched. You add two things:

A metadata registry — a structured description of every endpoint, stored in one place. Not used for routing. Used only to answer the question "what can you do, and how?"

Each entry in the registry holds:

  • path and method

  • description — plain English, searchable by the LLM

  • body_schema — what the endpoint accepts

  • response_schema — what the endpoint returns

  • tags — domain grouping (accounts, candidates, placements…)

Four discovery endpoints — meta-routes that expose the registry over HTTP:

Route

Returns

GET /api/meta/endpoints?tag=

All endpoints, optionally filtered by domain

GET /api/meta/endpoints/search?q=

Endpoints matching a keyword

GET /api/meta/endpoints/detail?path=&method=

Full schema for one endpoint

GET /api/meta/rules?entity=&action=

Dependency rules and validations

These four routes are the only contract between your API and the MCP layer. They don't change your existing routes.

Generating the registry. If your framework already produces an OpenAPI spec — FastAPI, NestJS, Spring Boot, Django REST Framework, Gin, Fastify — write a one-time converter that reads /openapi.json and writes your registry file. You get paths, methods, parameters, and response schemas for free. The one thing no tool can generate is the rules registry: business rules like "a placement requires a candidate" are domain knowledge that must be authored by hand.

Registry as a team contract. When the API team and the AI/LLM team are different people, the registry + discovery routes become a formal handoff point. The AI team can build and test the entire MCP layer against the four discovery routes — without knowing anything about how the backend is implemented, what database it uses, or how routes are structured internally. Any breaking change to the registry is immediately visible to the LLM layer without any coordination overhead.


Layer 2 — The MCP Server (a thin discovery adapter)

The MCP server owns zero API knowledge. No schemas, no hardcoded paths, no business rules. It exposes six tools that the LLM calls on demand:

Tool

What it does

list_domains()

(optional) Domain map with endpoint counts — use for 50+ endpoint APIs or when search returns too many unrelated results

list_api_endpoints(tag?)

Browse available endpoints by domain

search_api_endpoints(keyword, tag?)

Find the right endpoint — returns scored results ranked by relevance

get_api_endpoint_details(path, method)

Read the full input + output schema

get_workflow_rules(entity, action)

Fetch dependency rules before any write

call_api(path, method, params?, body?)

Execute the actual call

The LLM discovers what it needs, when it needs it. Adding an endpoint to your API makes it immediately discoverable — no changes to the MCP server.

Why 5 tools instead of one per endpoint:

With 25 endpoints, registering each as a separate MCP tool loads all 25 schemas into context on every turn — most irrelevant. With 5 meta-tools, the LLM pays schema cost only for the endpoints relevant to the current task.

User: "show me all active candidates"

LLM → search_api_endpoints("active candidates")
   ← [{path: "/api/candidates", method: "GET", description: "List all candidates..."}]

LLM → get_api_endpoint_details("/api/candidates", "GET")
   ← {query_params: {status: "active|passive|placed|inactive"}, response_schema: {...}}

LLM → call_api("/api/candidates", "GET", {status: "active"})
   ← {candidates: [...], total: 3}

3 small tool calls. Zero schemas for unrelated endpoints loaded into context.


Layer 3 — Rules and Restrictions

This is the most commonly skipped part of the pattern, and the one that matters most for complex APIs.

Every API has implicit business rules. In a recruiting CRM:

  • You can't create a placement without a candidate and an account

  • Deleting an account cascades to all its contacts

  • A placement stage must follow a valid progression

If you put these rules in the LLM's system prompt, you now have two sources of truth. The system prompt drifts. Rules go stale when the API changes. The LLM "remembers" rules it was told, not rules the API actually enforces.

The solution: a rules registry, owned by the API.

A rules registry maps every entity + action to:

  • dependencies — what must exist before this action, and how to verify it

  • validations — field constraints the LLM should enforce

  • cascade — what else gets affected

  • order_hint — the correct creation order for multi-step operations

What one entry looks like (language-agnostic JSON structure):

{
  "placements": {
    "create": {
      "dependencies": [
        {
          "entity":      "candidates",
          "field":       "candidate_id",
          "required":    true,
          "verify_with": "GET /api/candidates/{candidate_id}",
          "reason":      "Placement must reference an existing candidate",
          "if_missing":  "Create candidate first via POST /api/candidates"
        },
        {
          "entity":      "accounts",
          "field":       "account_id",
          "required":    true,
          "verify_with": "GET /api/accounts/{account_id}",
          "reason":      "Placement must reference an existing account",
          "if_missing":  "Create account first via POST /api/accounts"
        }
      ],
      "validations": [
        "stage must be one of: sourced, screening, interviewing, offered, placed, rejected"
      ],
      "order_hint": "accounts → candidates → (contacts optional) → placements"
    },
    "delete": {
      "dependencies": [],
      "cascade": [],
      "order_hint": "safe to delete — no child records depend on placements"
    }
  }
}

The structure is just data — store it as JSON, YAML, a module-level dict, a database table, whatever fits your stack. The only requirement is that it's readable by the /api/meta/rules route.

The LLM calls get_workflow_rules before any write. It gets back the exact checks to run. If a dependency is missing, it returns missing_dependency and stops — it never proceeds.

User: "place Alice at Acme Corp"

LLM → get_workflow_rules("placements", "create")
   ← dependencies: [candidate_id required, account_id required]
   ← order_hint: "accounts → candidates → placements"

LLM → search for "Alice" in candidates  ✓  found: id=1
LLM → search for "Acme Corp" in accounts  ✗  not found

LLM returns:
{
  "status": "missing_dependency",
  "summary": "Acme Corp does not exist as an account. Create it before the placement.",
  "suggestions": ["Create account 'Acme Corp', then retry the placement"]
}

Key principle: rules live in one place, fetched at runtime. Updating a rule requires changing only the registry — no LLM infrastructure changes.

Approach

Rule update requires

Hardcoded in system prompt

Edit the CLI / agent config, redeploy

Dynamic via rules registry

Edit the registry file — nothing else


Full Write-Path Sequence

This is what the complete flow looks like for any create, update, or delete operation — combining Layer 2, Layer 3, and Layer 4:

User: "create a placement for Alice at Acme Corp"
│
├─ 1. DISCOVER
│     LLM → search_api_endpoints("create placement")
│          ← {path: "/api/placements", method: "POST", description: "..."}
│
│     LLM → get_api_endpoint_details("/api/placements", "POST")
│          ← {body_schema: {candidate_id, account_id, stage, ...}}
│
├─ 2. CHECK RULES
│     LLM → get_workflow_rules("placements", "create")
│          ← {dependencies: [candidate_id required, account_id required],
│              order_hint: "accounts → candidates → placements"}
│
├─ 3. VERIFY DEPENDENCIES
│     LLM → call_api("/api/candidates/search", "GET", {q: "Alice"})
│          ← found: {id: 1, name: "Alice Johnson"}  ✓
│
│     LLM → call_api("/api/accounts", "GET", {q: "Acme Corp"})
│          ← not found  ✗
│          → return missing_dependency, stop here
│
│   (if all dependencies exist, continue ↓)
│
├─ 4. HUMAN APPROVAL
│     Agent intercepts the call before it reaches the API
│     ╭─ Approval Required ─────────────────────────╮
│     │  POST /api/placements                        │
│     │  {"candidate_id": 1, "account_id": 5, ...}  │
│     ╰─────────────────────────────────────────────╯
│     n → inject rejection into LLM context, stop
│     y → continue ↓
│
└─ 5. EXECUTE + RESPOND
      LLM → call_api("/api/placements", "POST", body)
           ← {placement: {id: 12, stage: "sourced", ...}}

      LLM returns structured envelope:
      {"status": "success", "summary": "Placement created...", "data": {...}}

Each step has a clear exit condition. The LLM never guesses — it checks rules, verifies existence, then acts.


Layer 4 — The Agent (human-in-the-loop + structured output)

Two more patterns at the LLM layer that complete the system:

Human-in-the-loop at the infrastructure layer.

Write operations are intercepted by the agent framework before they hit the API. The LLM calls the tool directly — the framework shows the approval prompt. The LLM never asks "shall I proceed?" in conversation (that causes a duplicate prompt). The gate is in the infrastructure, not the conversation.

╭─ Approval Required ──────────────────────────────────╮
│  Method  : POST                                       │
│  Path    : /api/accounts                              │
│  Payload : {"name": "Acme Corp"}                      │
╰───────────────────────────────────────────────────────╯
  Proceed? y/n:

Read-only calls and discovery tools pass through without prompting.

Structured output envelope.

All responses follow a consistent shape, regardless of success or failure:

{
  "status":      "success | error | out_of_scope | missing_dependency",
  "action":      "short description of what was done",
  "data":        {},
  "summary":     "one-sentence plain English summary",
  "suggestions": ["follow-up action 1", "follow-up action 2"]
}

out_of_scope is returned when the LLM searches and finds no matching endpoints — no fabricated tool calls, no hallucinated routes.


How to Apply This to Your Own API

Step 1 — Build a metadata registry. In whatever language your API uses, create a data structure (file, module, table) that holds a description of every endpoint: path, method, input schema, output schema, tags. This is metadata only — it doesn't affect routing.

Step 2 — Add four discovery routes. Add the four meta-routes to your API. They read from the registry at runtime. No changes to existing business logic.

Step 3 — Define a rules registry. For each entity and each action (create, update, delete), define what must exist first and what validations apply. Store this alongside the endpoint registry, in the same file or module.

Step 4 — Write a thin MCP server. Five tools: list, search, detail, rules, call. The MCP server has no API knowledge — it proxies the discovery routes and executes calls. It can be written in any language that has an MCP SDK (Python, TypeScript, Go, Kotlin…). It doesn't change when your API changes.

Step 5 — Configure the LLM agent. The system prompt is short because rules aren't in it. Instruct the LLM to: search → inspect schema → check rules → get approval → call. The workflow is mechanical, not conversational.


What This Pattern Doesn't Solve

Being clear about the limits helps you decide whether this pattern fits your situation:

Registry maintenance overhead. The registry is only as good as what you put in it. If descriptions are vague or response_schema is missing, the LLM will make poor tool calls. Keeping the registry accurate as the API evolves requires discipline — it won't self-update from your route handlers.

Latency per turn. Each LLM turn involves 2–4 HTTP calls to the discovery routes before the actual API call. For high-frequency or latency-sensitive use cases, the MCP server should cache the registry in memory at session start rather than fetching it fresh each call.

The rules registry doesn't enforce — it informs. The rules registry tells the LLM what to check. It doesn't prevent a bad API call if the LLM ignores it. Real enforcement still lives in the API itself (foreign key constraints, validation logic). The registry is a guide layer, not a hard gate.

This pattern doesn't replace API authentication or authorization. The MCP server calls the API with whatever credentials it has. If your API has per-user permissions, those must be enforced at the API layer — the MCP server is transparent to them.


This Implementation (Python)

The recruiter CRM is a reference implementation of the pattern. Four entities:

accounts  ──< contacts      (one account → many contacts)
accounts  ──< placements    (one account → many placements)
candidates──< placements    (one candidate → many placements)
contacts  ──< placements    (one contact manages many placements, optional)

Stack: Flask (API), FastMCP (MCP server), OpenRouter (LLM), SQLite (database), Rich (terminal UI)

File Structure

MCP-from-API/
├── src/
│   ├── registry.py         ← ENDPOINT_REGISTRY + RULES_REGISTRY (single source of truth)
│   ├── recruiter_api.py    ← Flask routes + DB logic (imports registry, no metadata here)
│   ├── mcp_server.py       ← 6 MCP tools, zero hardcoded API knowledge
│   └── chat.py             ← CLI: OpenRouter LLM, human-in-the-loop, JSON output
├── docs/
│   ├── api.md              ← endpoint reference + schema tables
│   ├── mcp.md              ← MCP design + tool details + improvement ideas
│   └── cli.md              ← CLI usage guide + example queries
├── requirements.txt        ← flask, fastmcp, httpx, python-dotenv, rich
├── .env.example            ← template — copy to .env and fill in your key
└── recruiter.db            ← SQLite (auto-created, seeded on first run only)

Quick Start

# 1. Install dependencies
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt

# 2. Configure
cp .env.example .env
# Edit .env — set OPENROUTER_API_KEY=sk-or-...

# 3. Start the API  (terminal 1)
.venv/bin/python src/recruiter_api.py

# 4. Start the CLI  (terminal 2)
.venv/bin/python src/chat.py

Configuration

OPENROUTER_API_KEY=sk-or-...
OPENROUTER_MODEL=openai/gpt-4o-mini
MCP_SCRIPT=mcp_server.py

Adding a New Endpoint

  1. Add the route to recruiter_api.py with @app.route

  2. Call register(...) in registry.py with body_schema and response_schema

  3. If the entity has dependencies, add an entry to RULES_REGISTRY in registry.py

No changes needed to mcp_server.py or chat.py.


Further Reading

  • docs/api.md — endpoint reference, schema tables, data model

  • docs/mcp.md — MCP design decisions, tool details, improvement ideas

  • docs/cli.md — CLI usage guide, example queries, troubleshooting

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    C
    maintenance
    A generic MCP server that dynamically converts OpenAPI-defined REST APIs into tools for LLMs like Claude. It supports multiple authentication methods and transport protocols, enabling seamless interaction with any OpenAPI-compliant API.
    Last updated
    17
    MIT
  • A
    license
    A
    quality
    D
    maintenance
    A generic MCP server that dynamically exposes any OpenAPI-documented REST API to LLMs by auto-discovering endpoints. It provides tools for exploring API capabilities and making authenticated requests directly through natural language interfaces.
    Last updated
    2
    17
    MIT
  • A
    license
    -
    quality
    D
    maintenance
    A universal MCP server that connects any REST API to AI assistants via OpenAPI or Postman specifications. It enables dynamic tool creation with GraphQL-style field selection and automatic schema inference for efficient data retrieval.
    Last updated
    9
    5
    Inno Setup
  • A
    license
    -
    quality
    B
    maintenance
    Enables LLMs to interact with any REST API that has an OpenAPI specification by providing a lightweight MCP server that translates between natural language and API calls.
    Last updated
    MIT

View all related MCP servers

Related MCP Connectors

  • Self-hosted MCP gateway: turn any API, database or MCP server into AI connectors — no code.

  • MCP server providing access to the Scorecard API to evaluate and optimize LLM systems.

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/naansari/mcp-from-api'

If you have feedback or need assistance with the MCP directory API, please join our Discord server