Skip to main content

AI Agent + MCP in 5 Minutes

Build an AI agent with its own identity that calls an MCP server with a scoped, audited bearer token. Five steps. Real working code. No service accounts.

Try it live — no signup

Want to see the whole flow (agent token → MCP metadata → allow/deny → JIT approval) before you write a line of code? Open the interactive demo → — a simulated walkthrough for a fictional organization, no account, nothing provisioned.

Want the mental model first? How LumoAuth secures an agent → diagrams this lifecycle and maps every credential/token.

What you'll have at the end
  • A registered MCP server publishing protected resource metadata (the document that tells clients which authorization server protects it)
  • An agent registered with capabilities and an audit trail
  • A short-lived MCP-scoped bearer minted by the admin API
  • An audit-logged tool call attributed to that specific agent
  • A JIT escalation flow for sensitive actions, with a human approver in the loop
6 / 6
  1. Agent to LumoAuth: POST /admin/agents/{id}/token
  2. LumoAuth to Agent: MCP-scoped access token (TTL ≤ 1h)
  3. Agent to MCP Server: POST /mcp + Authorization: Bearer
  4. MCP Server to LumoAuth: protected resource metadata / JWKS verify
  5. LumoAuth to MCP Server: allow + scopes (audit logged)
  6. MCP Server to Agent: tool result

Two paths are documented below. Path A (service-account agent) is the 5-minute path: admin mints a bearer for the agent, agent calls the MCP server with Authorization: Bearer …. Path B (AAuth, cryptographic identity) is a production upgrade with Ed25519 keys and signed HTTP requests (every request carries a signature made with the agent's private key). Use Path A first; harden to Path B when you're ready.


Prerequisites​

  • A LumoAuth organization — sign up, copy your org slug + an admin Bearer token (run lumo login and grab it from ~/.lumoauth/credentials.yaml, or create an admin API key in the portal under API keys).
  • Python 3.10+ or Node 20+. Both are shown.
  • An MCP server you control. (LumoAuth doesn't currently host a public sample MCP server; spin up npx @modelcontextprotocol/server-everything locally if you need one.)
export LUMO_BASE_URL="https://app.lumoauth.dev"
export LUMO_ORG="your-org-slug"
export LUMO_ADMIN_TOKEN="..." # Bearer access token from `lumo login` or admin API key

The admin API is mounted under /orgs/$LUMO_ORG/api/v1/admin/.... Every URL below uses that prefix.


Step 1 — Register the MCP server (30 seconds, one-time admin)​

curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/mcp/servers" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoices MCP",
"description": "Internal invoices MCP server",
"resource_uri": "https://invoices.example.com/mcp",
"endpoint_url": "https://invoices.example.com/mcp",
"scopes_supported": ["invoices.read", "invoices.refund"],
"transport": "http_streamable",
"auth_mode": "oauth"
}'

Response:

{
"data": {
"server_id": "mcp_01HF...",
"name": "Invoices MCP",
"resource_uri": "https://invoices.example.com/mcp",
"scopes_supported": ["invoices.read", "invoices.refund"]
}
}

Point your MCP server at the protected resource metadata URL for token verification:

$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/.well-known/oauth-protected-resource/mcp/<server_id>

That's the entire MCP-side change.


Step 2 — Register the agent (30 seconds)​

curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/agents" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice Analyzer",
"description": "Reads invoices on behalf of finance team",
"capabilities": ["invoices.read"]
}'

Response:

{
"data": {
"agentId": "agt_01HF...",
"name": "Invoice Analyzer",
"capabilities": ["invoices.read"]
}
}

Save agentId. The agent is now a first-class principal — searchable in the portal under AI Agents → Agents, revocable in one click, audited everywhere.


Step 3 — Mint an MCP-scoped bearer for the agent (Path A)​

curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/agents/agt_01HF.../token" \
-H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"expiresIn": 600,
"scopes": ["invoices.read"]
}'

expiresIn is the lifetime in seconds (default 3600, at most 30 days). scopes is optional and can only narrow the agent's registered capabilities — leave it out to get all of them; asking for one the agent does not have is a 400.

Response:

{
"data": {
"accessToken": "eyJ...",
"tokenType": "Bearer",
"expiresIn": 600,
"scopes": ["invoices.read"]
}
}

This is the same code path the in-portal tour and lumo agents token use.

Which token-mint route?

Two routes mint an agent token and are not interchangeable by audience:

  • POST /orgs/{orgId}/api/v1/admin/agents/{id}/token — the canonical admin API route (shown here). Requires an admin bearer / API key. Use it for server-side provisioning.
  • POST /orgs/{orgId}/api/v1/agents/{id}/token — the route the lumo CLI uses (lumo agents token), authenticated as the agent itself.

Pick the admin path for admin-authenticated automation; use the CLI (or the agent-authenticated path) from the agent side. Don't mix the credential type with the wrong route.


Step 4 — Call the MCP server​

import os
import requests # bundled with the lumoauth SDK; no extra install needed

resp = requests.post(
"https://invoices.example.com/mcp",
headers={"Authorization": f"Bearer {os.environ['MCP_TOKEN']}"},
json={"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "list_invoices", "arguments": {}}},
)
resp.raise_for_status()
print(resp.json())

That's it. Open the LumoAuth portal → Audit logs → search for your agent's agentId and you see the call, the resource, the scope, the IP, and the result.

The Audit logs page with its actor, search, event type, status, severity and date range filters


Step 5 — JIT permissions for sensitive tools​

Don't put invoices.refund in the agent's standing capabilities. Request it just-in-time, with a human approver in the loop. JIT works on top of LumoAuthAgent (the standard agent client):

from lumoauth import LumoAuthAgent, JITContext

agent = LumoAuthAgent() # reads LUMOAUTH_URL / LUMOAUTH_ORG_ID / agent creds
agent.authenticate()

with JITContext(agent) as jit:
jit.create_task(name="Process refund for ticket #4429")

result = jit.request_permission({
"type": "mcp_action",
"resource": "https://invoices.example.com/mcp",
"actions": ["invoices.refund"],
"identifier": "invoice_88231",
}, justification="Customer requested refund per ticket #4429")

if result["status"] == "approved":
elevated = jit.get_token(result["request_id"])
# Use elevated token for the refund call

A human approver sees this in the portal under AI Agents → Approvals (filter Permission requests) — risk score, justification, the agent's recent activity. They click Approve or Deny. Your agent resumes (or aborts). Every decision is logged.

The Approvals page with its Waiting, Approved and Denied tabs and, under Waiting, the Permission requests, User consents and Action approvals filters


Path B — AAuth (cryptographic agent identity, Ed25519 keys, signed HTTP requests)​

Use this in production when you don't want to ship admin credentials inside your agent. The agent holds an Ed25519 private key; the public JWK is registered with LumoAuth; every token request is signed.

Python and Node

AAuth ships in both official SDKs: lumoauth[aauth] (Python) and @lumoauth/agent (Node/TypeScript). Pick your language below. For the full language quickstarts see Python (AAuth) and Node.js (AAuth).

from lumoauth import AAuthClient

# 1. Generate a keypair (once; persist private_pem in a secret manager)
private_pem, jwks = AAuthClient.generate_keypair()

# 2. Register the agent with its JWKS URI (instead of the simpler Step 2 above)
# POST /admin/agents with identity_type=certificate and jwks_uri=...

# 3. At runtime, mint an AAuth token
client = AAuthClient(
agent_identifier="https://invoice-analyzer.example.com",
private_key_pem=private_pem,
base_url=os.environ["LUMO_BASE_URL"],
org_id=os.environ["LUMO_ORG"],
)

# `agent_token` is the agent's own `agent+jwt` (presented in Agent-Auth);
# `resource_token` comes from the MCP server's discovery + nonce flow.
# request_type=auth requires both; see /agents/aauth for the full Flow 2.
tokens = client.request_authorization(
resource_token=resource_token,
agent_token=agent_token,
)

# 4. Make signed calls (Bearer + RFC 9421 HTTP Message Signature)
resp = client.signed_request(
"POST",
"https://invoices.example.com/mcp",
auth_token=tokens["auth_token"],
data={"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "list_invoices", "arguments": {}}},
)

Each signed call carries the access token as a Bearer credential plus a standard HTTP message signature: a Signature header (with Signature-Input + Content-Digest). The signature covers @method, @authority, @path, signature-key, content-digest, content-type, and authorization, with a fresh created timestamp and a single-use nonce — so replays and tampering are rejected.

Scope comes from the resource token

For request_type=auth, LumoAuth derives the granted scope from the resource token's scope claim — a scope field in the request body is ignored. Set the scope when the resource token is minted, not on the auth request. See the AAuth Protocol reference.


What you just got vs. a service-account API key​

Service-account API keyLumoAuth Agent + MCP
Identity"service account X used by 47 services"agt_01HF… — one specific agent
ScopeAll-or-nothingPer-token, per-resource, time-bound
Sensitive actionsSame keyJIT + human approval (HITL)
RevocationRotate everywhereRevoke agent → CAEP invalidates in 30s
Audit trail"the API key did it"Filter actor_id=agt_… for every action
Delegation chainUntrackedToken exchange records every hop

For the full lifecycle diagram and a map of every credential/token type an agent uses, see How LumoAuth secures an agent.


Next steps​

Common errors​

ErrorMeaningFix
401 Unauthorized on /admin/agents/.../tokenAdmin token missing or expiredRe-run lumo login or rotate your admin API key
404 Not Found on /admin/mcp/serversAdmin endpoints are /orgs/{slug}/api/v1/admin/..., not /api/v1/...Add the /admin segment
invalid_request: Missing request_type parameterAAuth (Path B) requires request_type ∈ {auth, code, exchange, refresh}Path A doesn't hit /aauth/... — verify you're calling /admin/agents/{id}/token
invalid_resourceMCP server resource_uri typoGET /admin/mcp/servers to list
insufficient_scopeCapability not in agentAdd to agent (admin) or request JIT
agent_revokedCAEP invalidated mid-flightRe-mint; check audit log

Error bodies aren't all the same shape — protocol endpoints (OAuth/AAuth/JIT/MCP token errors) return OAuth-style {error, error_description}, while the admin and validation layer returns the richer {error, message, status, details} envelope. See the Errors reference for which family each endpoint uses.

Standards used on this page​

Plain nameStandard
Protected resource metadataRFC 9728
Signed HTTP requests (HTTP message signatures)RFC 9421
Token exchange (delegation)RFC 8693

See the standards reference for what each one covers and how LumoAuth uses it.