Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# The MCP adapter tests skip without the mcp extra, which needs Python 3.10+
pip install "mcp >=2.3.0, <3.0.0; python_version >= '3.10'"

- name: Run tests
run: pytest --cov=kinde_sdk --cov-branch --cov-report=xml
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,8 @@ docs/_build/
# Local environment files (secrets, never commit)
.env
.env.*
*.env
!.env.example
!.env.sample
!.env.sample

.DS_Store
145 changes: 145 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,151 @@ For both FastAPI and Flask integrations:
5. Implement proper session management
6. Consider implementing CSRF protection

# AI agents and MCP (CIMD and DCR)

`kinde_sdk.agents` lets you protect your own MCP server (or any API) with Kinde, and build agents that sign users in to such servers. It is separate from the login flow above and changes nothing about it.

Agents identify themselves in one of two ways:

- **Client ID Metadata Document (CIMD), preferred.** The agent publishes a small JSON document on its own domain, and the document's URL is its client ID. There's no registration step. Claude and ChatGPT already work this way.
- **Dynamic Client Registration (DCR), the fallback.** The agent registers with Kinde and gets back a client ID and secret. It's for agents that can't host a document.

## Set up Kinde

1. Create an application of type **AI agents and MCP** and set its **Resource URL** to your server's URL, for example `https://api.example.com/mcp`. The application has no callback URLs, on purpose: agents bring their own.
2. If you run your own agent, add the domain that serves its metadata document to **Trusted agent hosts** (bare domains, up to 10). Vendor agents such as Claude and ChatGPT are already trusted.
3. Set the environment variables. These belong to the AI agents and MCP application, not your login application:

```bash
KINDE_HOST=https://yourbiz.kinde.com
KINDE_AGENTS_CLIENT_ID=<agents application client id>
KINDE_AGENTS_CLIENT_SECRET=<agents application client secret>
KINDE_AGENTS_RESOURCE_URL=https://api.example.com/mcp
```

`KINDE_AGENTS_RESOURCE_URL` must match the Resource URL in Kinde exactly, character for character.

## Protect an MCP server

Install the extra (needs Python 3.10 or newer):

```bash
pip install "kinde-python-sdk[mcp]"
```

```python
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

from kinde_sdk.agents import KindeAgentAuth, has_permission
from kinde_sdk.agents.mcp import KindeTokenVerifier, get_agent_auth_context, kinde_auth_settings

auth = KindeAgentAuth() # reads the environment variables above

server = MCPServer(
"Docs server",
token_verifier=KindeTokenVerifier(auth),
auth=kinde_auth_settings(auth),
)

@server.tool()
async def read_docs() -> str:
if not has_permission(get_agent_auth_context(), "read:docs"):
raise ToolError("You don't have permission to read docs.")
return "..."
```

`kinde_auth_settings` sets the values the MCP SDK needs: the scoped issuer, `required_scopes=["offline"]` (agents request what's listed here, and without `offline` they get no refresh token and users sign in again every 24 hours), and `validate_token_resource=True`.

Check permissions inside each tool with `has_permission`, and fail with a tool error. Don't answer with an HTTP 403: agents treat that as "sign in again", and signing in again can't grant a permission. Use permissions and roles managed in Kinde rather than email allowlists.

## Protect a FastAPI or Flask API

```python
from fastapi import Depends, FastAPI
from kinde_fastapi.agents import FastAPIAgentAuth
from kinde_sdk.agents import AgentAuthContext

app = FastAPI()
agents = FastAPIAgentAuth() # serves /.well-known/oauth-protected-resource[/path]
agents.install(app)

@app.get("/api/docs")
async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read:docs"))):
return {"user": auth.user_id, "agent": auth.client_id}
```

```python
from flask import Flask
from kinde_flask.agents import FlaskAgentAuth, current_agent_auth

app = Flask(__name__)
agents = FlaskAgentAuth(app=app)

@app.get("/api/docs")
@agents.require_permission("read:docs")
def docs():
return {"user": current_agent_auth().user_id}
```

Requests without a valid token get `401` with a `WWW-Authenticate` challenge pointing at the protected resource metadata, which is how agents discover Kinde. If Kinde can't be reached, the response is `503`, so agents don't restart their sign-in.

### How tokens are verified

Tokens are checked with Kinde's introspection endpoint by default, using the application's client ID and secret, so a revoked token stops working immediately. To verify signatures locally instead (no network call per request, but a revoked token stays valid until it expires, 24 hours by default), pass `verification="jwt"`. `"jwt+introspection"` does both. `introspection_cache_seconds` (up to 60) caches successful introspections to cut traffic. With caching on, `revoke_token` clears only the calling process's cache: other workers keep accepting a revoked token until their cached entry expires, at most `introspection_cache_seconds` later.

To cut off an agent, revoke its token with `auth.revoke_token(token)`. This revokes both the access token and refresh token of the pair.

## Build an agent

`KindeAgentClient` signs a user in to a protected server and calls it. It uses the CIMD document when the server supports it and you set `client_metadata_url`, and registers dynamically otherwise.

```python
from kinde_sdk.agents import FileAgentStorage, KindeAgentClient

async with KindeAgentClient(
"https://api.example.com/mcp",
client_metadata_url="https://agent.example.com/oauth/client-metadata.json",
client_name="Example Agent",
storage=FileAgentStorage("~/.example-agent/tokens.json"),
) as client:
await client.connect() # opens the browser if a sign-in is needed
response = await client.request("POST", json={...})
```

A CLI agent uses a loopback redirect (`http://127.0.0.1/callback`, port picked automatically). A server-hosted agent passes its own `redirect_uri` and uses `begin_authorization()` and `complete_authorization()` in its login and callback routes. For DCR, prefer a fixed port (`http://127.0.0.1:8765/callback`), because a registration is tied to its exact redirect URI. Each account keeps one registration, and signing in on a new redirect URI replaces it. Two processes signing in the same account at the same moment on different ports can replace each other's registration; the loser gets an error and should sign in again.

Agents built on the official MCP client use the adapter instead:

```python
from kinde_sdk.agents.mcp import KindeOAuthClientProvider

provider = KindeOAuthClientProvider(
"https://api.example.com/mcp",
client_metadata_url="https://agent.example.com/oauth/client-metadata.json",
client_name="Example Agent",
redirect_handler=..., callback_handler=...,
)
```

### Publish the client metadata document

```python
from kinde_sdk.agents import build_client_metadata_document
from kinde_fastapi.agents import client_metadata_router # or kinde_flask.agents.client_metadata_blueprint

document = build_client_metadata_document(
"https://agent.example.com/oauth/client-metadata.json", # must be the exact URL it's served at
"Example Agent",
["http://127.0.0.1/callback", "https://agent.example.com/oauth/callback"],
)
app.include_router(client_metadata_router(document))
```

Kinde fetches the document from the public internet, so it can't be served from `localhost`; use a tunnel with a real domain when testing. Kinde honours redirect URIs that are https on the document's own host, or http loopback on any port. It drops custom schemes (`myapp://`) and other hosts, and the builder raises an error for those.

See [examples/agents](examples/agents) for complete examples.

# Kinde Management API Module

This module provides a client for the Kinde Management API, allowing you to manage users, organizations, roles, permissions, and feature flags programmatically.
Expand Down
49 changes: 49 additions & 0 deletions examples/agents/cli_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
A CLI agent that signs a user in to a protected MCP server and calls it.

export RESOURCE_URL=https://<your public host>/mcp
# CIMD (preferred): the URL where client_metadata_host.py serves the document,
# whose host is in the Kinde application's Trusted agent hosts. Leave unset to use DCR.
export CLIENT_METADATA_URL=https://agent.onzs.co.za/oauth/client-metadata.json
python examples/agents/cli_agent.py

The browser opens for sign-in. Tokens are kept in ~/.kinde-example-agent/tokens.json.
"""

import asyncio
import os

from kinde_sdk.agents import FileAgentStorage, KindeAgentClient


async def main() -> None:
async with KindeAgentClient(
os.environ["RESOURCE_URL"],
client_metadata_url=os.getenv("CLIENT_METADATA_URL"),
client_name="Kinde example agent",
# A fixed port, so a DCR registration (tied to its exact redirect URI) is reused between runs
redirect_uri="http://127.0.0.1:8765/callback",
storage=FileAgentStorage("~/.kinde-example-agent/tokens.json"),
) as client:
await client.connect()
print("Signed in:", await client.get_token_info())

response = await client.request(
"POST",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "kinde-example-agent", "version": "1"},
},
},
headers={"Accept": "application/json, text/event-stream"},
)
print(response.status_code, response.text)


if __name__ == "__main__":
asyncio.run(main())
27 changes: 27 additions & 0 deletions examples/agents/client_metadata_host.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Serves an agent's client ID metadata document (CIMD).

Kinde fetches this from the public internet, so run it on (or tunnel it to) the
public host in CLIENT_METADATA_URL, and add that host to the Kinde application's
Trusted agent hosts.

export CLIENT_METADATA_URL=https://agent.onzs.co.za/oauth/client-metadata.json
uvicorn examples.agents.client_metadata_host:app --port 8080
"""

import os

from fastapi import FastAPI

from kinde_fastapi.agents import client_metadata_router
from kinde_sdk.agents import build_client_metadata_document

document = build_client_metadata_document(
os.environ["CLIENT_METADATA_URL"],
"Kinde example agent",
# Loopback works on any port for CIMD; cli_agent.py uses 8765
["http://127.0.0.1/callback"],
)

app = FastAPI()
app.include_router(client_metadata_router(document))
31 changes: 31 additions & 0 deletions examples/agents/fastapi_resource_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
A REST API protected by a Kinde "AI agents and MCP" application, with FastAPI.

pip install "kinde-python-sdk[fastapi]"
export KINDE_HOST=https://yourbiz.kinde.com
export KINDE_AGENTS_CLIENT_ID=... KINDE_AGENTS_CLIENT_SECRET=...
export KINDE_AGENTS_RESOURCE_URL=http://localhost:3001/api
uvicorn examples.agents.fastapi_resource_server:app --port 3001

curl -i http://localhost:3001/api/docs # 401 with a WWW-Authenticate challenge
curl http://localhost:3001/.well-known/oauth-protected-resource/api
"""

from fastapi import Depends, FastAPI

from kinde_fastapi.agents import FastAPIAgentAuth
from kinde_sdk.agents import AgentAuthContext

app = FastAPI()
agents = FastAPIAgentAuth()
agents.install(app)


@app.get("/api/me")
async def me(auth: AgentAuthContext = Depends(agents.require())):
return {"user": auth.user_id, "agent": auth.client_id, "permissions": auth.permissions}


@app.get("/api/docs")
async def docs(auth: AgentAuthContext = Depends(agents.require_permission("read:docs"))):
return {"docs": "...", "user": auth.user_id}
61 changes: 61 additions & 0 deletions examples/agents/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""
An MCP server protected by a Kinde "AI agents and MCP" application.

pip install "kinde-python-sdk[mcp]" uvicorn
export KINDE_HOST=https://yourbiz.kinde.com
export KINDE_AGENTS_CLIENT_ID=... # the AI agents and MCP application
export KINDE_AGENTS_CLIENT_SECRET=...
export KINDE_AGENTS_RESOURCE_URL=https://<your public host>/mcp
python examples/agents/mcp_server.py

Connect with MCP Inspector (npx @modelcontextprotocol/inspector) or Claude Code
(claude mcp add --transport http kinde-test https://<your public host>/mcp).
"""

from urllib.parse import urlsplit

import uvicorn
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.transport_security import TransportSecuritySettings

from kinde_sdk.agents import KindeAgentAuth, has_permission
from kinde_sdk.agents.mcp import (
KindeTokenVerifier,
get_agent_auth_context,
kinde_auth_settings,
mount_authorization_server_metadata,
)

auth = KindeAgentAuth()

server = MCPServer(
"Kinde example server",
token_verifier=KindeTokenVerifier(auth),
auth=kinde_auth_settings(auth),
)
# Optional: some older MCP clients look for this on the server's own origin
mount_authorization_server_metadata(server, auth)


@server.tool()
async def whoami() -> str:
"""Who is signed in, and through which agent."""
context = get_agent_auth_context()
return f"User {context.user_id} via {context.client_id} with permissions {context.permissions}"


@server.tool()
async def read_docs() -> str:
"""Needs the read:docs permission in Kinde."""
if not has_permission(get_agent_auth_context(), "read:docs"):
# A tool error, not an HTTP 403: signing in again can't grant a permission
raise ToolError("You don't have permission to read docs.")
return "The docs."


if __name__ == "__main__":
# Accept the public host from the Resource URL (for example a tunnel) as well as local requests
public_host = urlsplit(auth.config.resource_url).netloc
security = TransportSecuritySettings(allowed_hosts=[public_host, "localhost:3001", "127.0.0.1:3001"])
uvicorn.run(server.streamable_http_app(transport_security=security), host="127.0.0.1", port=3001)
Loading
Loading