PromptisePromptise
Why PromptiseUse casesRoadmapDocs
GitHubGet started
Use cases/Agent-ready SaaS API
Use case · Product and platform

Secure MCP Server for Your SaaS API in Python

Your REST API as an MCP server that customers' AI agents can use, with sign-in, roles, approvals and an audit log.

MCP server SDKSign-in and rolesApprovals on deletesAudit log
Get the codeRun it in one command
customer's agentmcpkeyrolelimittaskboardwebsite relaunchq4 hiring2024 trade showapproverdelete it? yesaudit log
Note

These are reference applications. They show how each use case can look when you build it with Promptise. They are not finished products. The outside systems they talk to, such as billing, ticketing or logs, are local stand-ins with sample data, not real connectors. Swap in your own systems before you use real data.

[n.01/02]> run it

Run it with your own model.

Set your model key and start it. The project works with OpenAI, Anthropic and any provider Promptise supports.

terminalshell
git clone https://github.com/promptise-com/promptise-templates
cd promptise-templates/agent-ready-saas-api
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
promptise run setup
export OPENAI_API_KEY=...

# each in its own terminal
promptise run api
promptise run mcp
promptise run approvals
promptise run agent
  1. 01Overview
  2. 02How it works
  3. 03Build it
  4. 04Run and test
  5. 05Adapt it
  6. 06FAQ
01Overview
  1. The business problem
  2. What a real run looks like

By the end of this guide, your customers' AI agents can work in your product through an MCP server you control. Each agent signs in with its own key. Each tool says which keys may call it. Deleting data waits for a person. Every call lands in a log that shows any later edit. You can revoke a key while the server runs.

Reference application. This shows how this use case can look with Promptise. It is not a finished product. Taskboard is a small sample API with sample data, standing in for your real product. Point the MCP server at your own API before you use it with real data.

The complete code is in agent-ready-saas-api.

The business problem

Customers now ask whether your product works with their AI assistant. Some use Claude or Cursor. Some build their own agents. They all speak MCP, the Model Context Protocol.

You already have a REST API. Opening it to agents raises questions your API never had to answer:

  • An agent's key leaks. Can you stop it now, without a deploy?
  • A reporting bot should read. Can it also delete?
  • An agent misreads a request and deletes a project. Who said yes?
  • A customer asks what their agent did last Tuesday. Can you prove the log is complete?
  • One agent loops. Does everyone else slow down?

The MCP server in this guide answers each of them in code.

What a real run looks like

Start the sample API and the MCP server. Then run the customer's agent with a real model:

terminalbash
promptise run setup
export OPENAI_API_KEY=...
promptise run api
promptise run mcp
promptise run approvals
promptise run agent

Each run command after setup gets its own terminal. Here is openai:gpt-5-mini doing the weekly overdue sweep, with lines shortened:

outputtext
  -> find_tasks({'project_id': 'prj_101', 'overdue_only': True})
  <- find_tasks: [{"id": "tsk_1001", "project_id": "prj_101", "title": "Write homepage copy", "assignee": "maya", ...
  -> add_comment({'task_id': 'tsk_1001', 'body': 'Hi Maya, this task is overdue. Could you please update the due date so we can plan next steps? Thanks.'})
  <- add_comment: {"id": "cmt_5000", "author": "agent:acme-ops-agent", ...
  -> create_task({'project_id': 'prj_101', 'title': 'Review overdue task responses', 'assignee': 'maya', 'due_date': '2026-10-23', ...})
[7 model calls, 6 tool calls, 16018 tokens. Trace: .data/agent-trace.jsonl]

Then ask it to delete a project. The call waits until the reviewer answers in the approvals console:

outputtext
acme-ops-agent wants to run delete_project with {"project_id": "prj_103"}
Approve? [y/N] y
approved: {"request_id": "dae73b79...", "approved": true, "resolved": true}

The rest of this guide shows how each part is built.

02How it works

How it works

The customer's agent connects over MCP with an x-api-key header. The MCP server runs every call through five checks, in order:

  1. 01Audit. The call is written to an HMAC-chained log, whatever the outcome.
  2. 02Auth. The key becomes a client id, a workspace and a set of roles.
  3. 03Rate limit. Each key has its own token bucket.
  4. 04Approval gate. A deletion parks until a reviewer decides.
  5. 05Guard. The tool's own roles=[...] check.

Then the tool calls the REST API with the token of the caller's workspace. The agent never names the workspace. So an Acme key cannot reach Globex data, whatever arguments the agent sends.

03Build it
  1. Step 1: Sign agents in with API keys
  2. Step 2: Declare who may call each tool
  3. Step 3: Make deletions wait for a person
  4. Step 4: Log every call, limit every key, report health
  5. Step 5: Revoke a key while the server runs
  6. Step 6: Build the customer's agent
  7. Connect any MCP client

Step 1: Sign agents in with API keys

A customer mints one key per agent. The key carries the workspace and an access level. The server stores only a SHA-256 hash of the secret.

KeyStore.lookup() is the hook the auth provider calls on every request:

mcp_server/keys.pypython
def lookup(self, presented: str) -> dict[str, Any] | None:    """APIKeyAuth(lookup=...) hook: the key's identity, or None to refuse it."""    key_id, _, secret = presented.removeprefix(PREFIX).partition(".")    if not presented.startswith(PREFIX) or not secret:        return None    record = self._load().get(key_id)    if record is None or record.get("revoked_at"):        return None    if not secrets.compare_digest(_hash(secret), record["secret_sha256"]):        return None    return {        "client_id": record["client_id"],        "tenant_id": record["workspace"],        "roles": record["roles"],    }

The returned tenant_id becomes ctx.client.tenant_id. The roles feed the guards. The server is built with require_tenant=True, so no tool runs without a key that names a workspace. It also sets hide_unauthorized_tools=True, so a reader key never sees the write tools.

Step 2: Declare who may call each tool

Each tool states its role in the decorator. Reads need reader. Writes need editor. Deleting a project needs admin plus a person's approval:

mcp_server/server.pypython
@server.tool(roles=["admin"], requires_approval=True, destructive_hint=True, rate_limit="5/min")async def delete_project(ctx: RequestContext, project_id: ProjectId) -> dict[str, Any]:    """Permanently delete a project with all its tasks and comments.    A person in the workspace must approve it first; the call waits for that decision.    Use archive_project unless the user asked to delete."""    project = await api.call(ws(ctx), "GET", f"/projects/{project_id}")    await api.call(ws(ctx), "DELETE", f"/projects/{project_id}")    return {"deleted": project_id, "name": project["name"]}

The docstring is the description the model reads. It says when to use the tool. It also names the safer tool to prefer.

Fields that identify a person come from the key. They never come from the agent:

mcp_server/server.pypython
# The author comes from the verified key, never from the agent.comment = await api.call(    ws(ctx), "POST", f"/tasks/{task_id}/comments", json={"body": body, "author": f"agent:{ctx.client_id}"})

Step 3: Make deletions wait for a person

requires_approval=True only works with an ApprovalGateMiddleware installed. The server refuses to build without one. Here the handler is a PendingApprover. A gated call waits in its store until a reviewer decides. No decision before the timeout means the call is denied.

The SDK's stock reviewer tools see every pending call on the server. On a multi-tenant server, that would show Acme's pending deletions to Globex's reviewers. So this project registers its own two tools, scoped to the reviewer's workspace:

mcp_server/approvals.pypython
entry = next((p for p in approver.pending() if p["request_id"] == request_id), None)# Another workspace's request looks exactly like an unknown one.if entry is None or entry["tenant_id"] != ctx.client.tenant_id:    raise ToolError(f"No pending request {request_id!r} in your workspace.", code="NOT_FOUND")if approve and entry["client_id"] == ctx.client_id:    raise ToolError("You cannot approve a call you made yourself.", code="ACCESS_DENIED")resolved = approver.decide(request_id, approve, reviewer_id=ctx.client_id, reason=reason or None)

In the real run, the reviewer took 45 seconds to answer. The agent's call stayed open and finished once it was approved.

Step 4: Log every call, limit every key, report health

The middleware order matters. Audit goes first, so refused calls are logged too:

mcp_server/server.pypython
# Audit goes first so refused calls (bad key, wrong role, rate limited,# approval denied) are in the log too.server.add_middleware(audit)server.add_middleware(AuthMiddleware(APIKeyAuth(lookup=keys.lookup)))server.add_middleware(    RateLimitMiddleware(TokenBucketLimiter(rate_per_minute=settings.rate_per_minute, burst=settings.rate_burst)))server.add_middleware(ApprovalGateMiddleware(approver, timeout=settings.approval_timeout))register_approval_tools(server, approver)

Each audit entry has the tool, the client id, the workspace, the roles, the arguments and the outcome. Each entry's HMAC covers the one before it. promptise run audit checks the chain. Edit one field and the check fails at that line.

RateLimitMiddleware runs after AuthMiddleware, so each key gets its own bucket.

A readiness check tells your load balancer when the API behind the server is down:

mcp_server/server.pypython
health = HealthCheck()health.add_check("taskboard_api", api.healthy, required_for_ready=True)health.register_resources(server)

Step 5: Revoke a key while the server runs

APIKeyAuth(lookup=...) calls lookup() on every request. The key store re-reads its file whenever the file changes. So a revocation from another process applies to the next request:

outputtext
$ promptise run keys revoke 2794e3c1 && promptise run agent-reader "List the active projects."
revoked 2794e3c1
The Taskboard MCP server refused this agent key (HTTP 401). It is unknown or was revoked.

In production, point lookup() at the table your admin UI writes.

Step 6: Build the customer's agent

This is the code a customer writes. It needs the server URL and an agent key:

customer_agent/agent.pypython
return await build_agent(    model=model,    # A Prompt object, rendered again on every run (see prompts.py).    instructions=ops_assistant,    # The key travels as the x-api-key header on every MCP request.    servers={"taskboard": HTTPServerSpec(url=url, api_key=api_key)},    # A plain tool loop: look up, act, answer. The task needs nothing more.    agent_pattern="react",    max_agent_iterations=15,    # agent.chat(..., session_id=...) keeps the conversation across runs.    conversation_store=SQLiteConversationStore(str(DATA / "conversations.db")) if remember else None,    observe=observe if observe is not None else observability(),)

The instructions are a Prompt built from blocks in customer_agent/prompts.py. One block states today's date. Without it, the model cannot work out "due this Friday". The conversation store lets a follow-up question pick up where the sweep left off. The observability config writes an HTML report and a JSON trace of every model call and tool call.

Connect any MCP client

The same key works in any MCP client. In Cursor, add this to .cursor/mcp.json:

jsonjson
{
  "mcpServers": {
    "taskboard": {
      "url": "http://127.0.0.1:8080/mcp",
      "headers": { "x-api-key": "<agent key>" }
    }
  }
}

In Claude Code:

terminalbash
claude mcp add taskboard --transport http http://127.0.0.1:8080/mcp --header "x-api-key: <agent key>"

The README also shows Claude Desktop, through the mcp-remote bridge. The rules stay the same in every client, because they live in the server.

04Run and test

Test it

terminalbash
promptise run test
promptise run test-live

The first command runs 31 offline tests through the server's TestClient. The second runs one real end-to-end flow and needs the model key. No key yet? promptise run demo-offline runs the same server with a scripted model from promptise.testing.

05Adapt it
  1. Adapt it to your product
  2. Pitfalls

Adapt it to your product

  • Your API. Change mcp_server/upstream.py and the tools in mcp_server/server.py.
  • Your key store. Make KeyStore.lookup() query your database. Return None for revoked keys.
  • Your identity provider. Swap APIKeyAuth for JwksAuth. Roles then come from the token.
  • Your review screen. Call approvals_list and approvals_decide from your admin UI.

Pitfalls

Taking the tenant from a tool argument. If the agent can pass a workspace id, it can pass someone else's. Take it from the verified key.

Gating everything. Approval on every comment trains reviewers to click yes. Gate what cannot be undone. Offer a reversible path, such as archive_project.

Trusting identity fields in arguments. An author argument lets an agent sign as anyone. Set those fields on the server.

Leaving out the date. A model does not know today's date. Put it in the instructions.

Adding audit after auth. Then refused calls never reach the log. Add it first.

Running several replicas. Pending approvals and rate-limit buckets live in one process. Use one replica, or an approval handler backed by a shared store.

06FAQ

FAQ

Can I use JWTs instead of API keys?

Yes. Swap the provider for JWTAuth, AsymmetricJWTAuth or JwksAuth. They also support revocation by token id or subject.

What does the agent see while a deletion waits?

The tool call stays open until the decision. Approved, it gets the result. Denied, it gets APPROVAL_DENIED with the reviewer's reason.

What happens when a read-only agent is asked to write?

It never sees the write tools. In the real run it said its key does not allow updates. A client that calls a write tool anyway gets ACCESS_DENIED.

How much does a run cost?

The weekly sweep used about 16,000 tokens with openai:gpt-5-mini. Check your provider's current prices.

Does this work with clients that are not Promptise agents?

Yes. The rules live in the server. Claude, Cursor and any other MCP client get the same keys, roles, approvals and logs.

Related guides
  • Build a production MCP server in Python→
  • MCP server authentication with JWT and OAuth→
  • Tamper-evident audit logs for AI agents→
  • What is human-in-the-loop AI?→
More use cases
  • FinanceInvoice reconciliationAn agent that reconciles invoices every night and follows up on failed payments, with refunds capped in code.
  • Customer supportMulti-tenant support agentOne support agent sold to many customers, where each customer's data, memory and limits stay their own.
  • EngineeringOn-call incident agentAn alert wakes the agent, it investigates and follows the runbook, and risky fixes wait for a person.
All templates on GitHubWhy Promptise
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python