For agent developers
Agent integration: passport and calling MCP
This guide shows how a deployed agent keeps its Agent Token, mints and refreshes an MCP passport, and calls a service's tools. We cover minting the passport, calling tools, and refreshing it before expiry. For AI agents: /docs/all.en.md.
At a glance#
| Fact | Value |
|---|---|
| Effort | ~15 minutes |
| Type | Agent-side SDK, recurring loop |
| Delivery | Agent runtime → MCP service |
How it works#
- The agent keeps its Agent Token in the environment and never sends it to a service.
- The agent mints a short-lived
aud=mcppassport per service domain. - The agent calls tools with
Authorization: Bearerand re-mints before expiry.
What you need#
Create an agent in the LIME portal and keep its Agent Token in a .env file.
Step 1. Mint the MCP passport#
The service should not have to call LIME to learn who the agent is, so the agent gets a passport in advance. The SDK sends the Agent Token in the X-Agent-Token header and the service domain in the JSON body.
# .env — Agent Token lives here, not in the code
LIME_AGENT_TOKEN="<AGENT_TOKEN>"import asyncio
from lime_agents import LimeAgent
agent = LimeAgent(agent_token="<AGENT_TOKEN>") # replace with your Agent Token
async def main() -> None:
passport = await agent.get_mcp_access_token("<SERVICE_DOMAIN>")
print(passport.access_token)
await agent.aclose()
if __name__ == "__main__":
asyncio.run(main())Result: 200 with a passport issued for 300 seconds, carrying aud=mcp and sub=<AGENT_ID>.
Step 2. Call the tools over MCP#
Send the passport in the Authorization: Bearer header. The service verifies the signature locally via JWKS and reads the agent_id from the sub claim.
import asyncio
from lime_agents import LimeAgent
agent = LimeAgent(agent_token="<AGENT_TOKEN>") # replace with your Agent Token
async def main() -> None:
print(await agent.list_tools("https://<SERVICE_DOMAIN>/mcp"))
await agent.aclose()
if __name__ == "__main__":
asyncio.run(main())Result: the service accepts the call and logs the agent_id; a call without Authorization: Bearer returns 401.
Step 3. Let the SDK refresh the passport#
The SDK handles refresh for you: it caches the passport per domain, lazily re-mints it ~30 s before expiry (mcp_token_refresh_skew), and re-mints once on a 401 from a service.
import asyncio
from lime_agents import LimeAgent
agent = LimeAgent(agent_token="<AGENT_TOKEN>") # replace with your Agent Token
async def main() -> None:
passport = await agent.get_mcp_access_token("<SERVICE_DOMAIN>")
print(passport.access_token)
await agent.aclose()
if __name__ == "__main__":
asyncio.run(main())Result: the client reuses one passport per domain and re-mints it before expiry — no cache class to maintain.
Example response (JSON)#
The mint call returns the passport and its lifetime in seconds.
{
"access_token": "<PASSPORT>",
"token_type": "Bearer",
"expires_in": 300
}MCP protocol#
LIME does not host MCP servers — the service does, and it picks the transport. The SDK speaks it for you; a hand-rolled client talks MCP over Streamable HTTP with JSON-RPC 2.0 at the server endpoint (the demo server uses https://demo.lime.pics/mcp). The demo RS is stateless: call tools/list and tools/call directly — no initialize handshake and no session.
tools = await agent.list_tools("https://demo.lime.pics/mcp")
result = await agent.call_tool("https://demo.lime.pics/mcp", "who_am_i", {})The transport is stateless: the server issues no mcp-session-id and needs no initialize handshake — call tools/list / tools/call directly, carrying only Authorization: Bearer. (A stateful MCP server does require an initialize first and may return a session id to echo.) The tools/list result carries a tools array that names each tool:
{
"jsonrpc": "2.0",
"id": 2,
"result": { "tools": [ { "name": "<TOOL>", "description": "<TEXT>", "inputSchema": { "type": "object" } } ] }
}tools/call returns the tool's own result; the passport rides in Authorization: Bearer on every call, and the server verifies it locally without calling LIME. A call returns the tool's content plus structured data:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [ { "type": "text", "text": "{\"verified\":true,\"agent_id\":\"<AGENT_ID>\"}" } ],
"structuredContent": { "verified": true, "agent_id": "<AGENT_ID>" },
"isError": false
}
}A protocol failure is HTTP 200 with an error object; a missing or invalid passport is HTTP 401:
{ "jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "invalid params", "data": null } }Verify it worked#
| Check | Expected | Negative |
|---|---|---|
| First mint | aud=mcp, sub=<AGENT_ID>, lifetime 300 s | a wrong X-Agent-Token returns invalid_client (401) |
list_tools with the passport | the service returns tools and logs the agent_id | a call without Authorization: Bearer returns 401 from the service |
| Loop longer than the TTL | the cache re-mints before expiry; no gap | minting on every call returns RATE_LIMIT_EXCEEDED (429) |
| Reuse the passport after expiry | the service rejects it and the SDK re-mints | an expired passport returns 401 |
Codes and errors#
The mint returns the same errors as the quickstart; the full public catalogue is in the value reference. The OAuth token mint reports RFC 6749 errors as a raw body {error,error_description} (no ok envelope) — for example invalid_client on a bad X-Agent-Token; LIME's other routes use the envelope {ok:false,error:{code,message,detail}}. The refresh loop also meets these two:
| Code | HTTP | Cause | Action |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | A mint loop hits the limit | Cache the passport per domain and back off with jitter; the OAuth mint 429 does not carry Retry-After |
AGENT_INACTIVE | 403 | The agent is inactive and cannot mint passports | Enable the agent in the LIME portal |