Reference documentation for ChatGPT. Part 2: Authentication, OAuth, Protocols.
Pierre supports multiple authentication methods for different use cases.
| method | use case | header | endpoints |
|---|---|---|---|
| jwt tokens | mcp clients, web apps | Authorization: Bearer <token> |
all authenticated endpoints |
| api keys | a2a systems | X-API-Key: <key> |
a2a endpoints |
| oauth2 | provider integration | varies | fitness provider apis |
curl -X POST http://localhost:8081/api/auth/register \
-H "Authorization: Bearer <admin_jwt_token>" \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePass123!",
"display_name": "User Name"
}'Response:
{
"user_id": "uuid",
"email": "user@example.com",
"token": "jwt_token",
"expires_at": "2024-01-01T00:00:00Z"
}Uses OAuth2 Resource Owner Password Credentials (ROPC) flow:
curl -X POST http://localhost:8081/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=user@example.com&password=SecurePass123!"Response includes jwt_token. Store securely.
Include in authorization header:
curl -H "Authorization: Bearer <jwt_token>" \
http://localhost:8081/mcpDefault: 24 hours (configurable via JWT_EXPIRY_HOURS)
Refresh before expiry:
curl -X POST http://localhost:8081/api/auth/refresh \
-H "Authorization: Bearer <current_token>"For a2a systems and service-to-service communication.
Requires admin or user jwt:
curl -X POST http://localhost:8081/api/keys \
-H "Authorization: Bearer <jwt_token>" \
-H "Content-Type: application/json" \
-d '{
"name": "My A2A System",
"tier": "professional"
}'Response:
{
"api_key": "generated_key",
"name": "My A2A System",
"tier": "professional",
"created_at": "2024-01-01T00:00:00Z"
}Save api key - cannot be retrieved later.
curl -H "X-API-Key: <api_key>" \
http://localhost:8081/a2a/toolstrial: 1,000 requests/month (auto-expires after 14 days)starter: 10,000 requests/monthprofessional: 100,000 requests/monthenterprise: unlimited (no fixed monthly cap)
Rate limits are enforced per API key over a rolling 30-day window.
Pierre acts as oauth2 authorization server for mcp clients.
Pierre implements two oauth systems:
-
oauth2_server module (
src/oauth2_server/): pierre AS oauth2 server- mcp clients authenticate TO pierre
- rfc 7591 dynamic client registration
- issues jwt access tokens
-
oauth2_client module (
src/oauth2_client/): pierre AS oauth2 client- pierre authenticates TO fitness providers (strava, garmin, fitbit, whoop)
- manages provider tokens
- handles token refresh
Sdk handles automatically. Manual flow:
- register client:
curl -X POST http://localhost:8081/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["http://localhost:35535/oauth/callback"],
"client_name": "My MCP Client",
"grant_types": ["authorization_code"]
}'- authorization (browser):
http://localhost:8081/oauth2/authorize?
client_id=<client_id>&
redirect_uri=<redirect_uri>&
response_type=code&
code_challenge=<sha256_base64url(verifier)>&
code_challenge_method=S256
- token exchange:
curl -X POST http://localhost:8081/oauth2/token \
-d "grant_type=authorization_code&\
code=<code>&\
client_id=<client_id>&\
client_secret=<client_secret>&\
code_verifier=<verifier>"Receives jwt access token.
Pierre requires pkce (rfc 7636) for security:
- code verifier: 43-128 random characters
- code challenge: base64url(sha256(verifier))
- challenge method: S256 only
No plain text challenge methods allowed.
mcp clients (claude code, vs code with cline/continue, cursor, etc.) connect to pierre via http-based mcp protocol.
- user registration and login:
# create user account
curl -X POST http://localhost:8081/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "SecurePass123!"
}'
# login to get jwt token (OAuth2 ROPC flow)
curl -X POST http://localhost:8081/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=user@example.com&password=SecurePass123!"response includes jwt token:
{
"jwt_token": "eyJ0eXAiOiJKV1Qi...",
"expires_at": "2025-11-05T18:00:00Z",
"user": {
"id": "75059e8b-1f56-4fcf-a14e-860966783c93",
"email": "user@example.com"
}
}- configure mcp client:
option a: claude code - using /mcp command (interactive):
# in claude code session
/mcp add pierre-production \
--url http://localhost:8081/mcp \
--transport http \
--header "Authorization: Bearer eyJ0eXAiOiJKV1Qi..."manual configuration (~/.config/claude-code/mcp_config.json):
{
"mcpServers": {
"pierre-production": {
"url": "http://localhost:8081/mcp",
"transport": "http",
"headers": {
"Authorization": "Bearer eyJ0eXAiOiJKV1Qi..."
}
}
}
}option b: vs code (cline, continue, cursor) - edit settings:
for cline extension (~/.vscode/settings.json or workspace settings):
{
"cline.mcpServers": {
"pierre-production": {
"url": "http://localhost:8081/mcp",
"transport": "http",
"headers": {
"Authorization": "Bearer eyJ0eXAiOiJKV1Qi..."
}
}
}
}for continue extension:
{
"continue.mcpServers": [{
"url": "http://localhost:8081/mcp",
"headers": {
"Authorization": "Bearer eyJ0eXAiOiJKV1Qi..."
}
}]
}- automatic authentication:
mcp clients include jwt token in all mcp requests:
POST /mcp HTTP/1.1
Host: localhost:8081
Authorization: Bearer eyJ0eXAiOiJKV1Qi...
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "connect_provider",
"arguments": {"provider": "strava"}
}
}pierre's mcp server validates jwt on every request:
- extracts user_id from token
- validates signature using jwks
- checks expiration
- enforces rate limits per tenant
| endpoint | auth required | notes |
|---|---|---|
POST /mcp (initialize) |
no | discovery only |
POST /mcp (tools/list) |
no | unauthenticated tool listing |
POST /mcp (tools/call) |
yes | requires valid jwt |
POST /mcp (prompts/list) |
no | discovery only |
POST /mcp (resources/list) |
no | discovery only |
implementation: src/mcp/multitenant.rs:1726
jwt tokens expire after 24 hours (default, configurable via JWT_EXPIRY_HOURS).
when token expires, user must:
- login again to get new jwt token
- update claude code configuration with new token
automatic refresh not implemented in most mcp clients (requires manual re-login).
once authenticated to pierre, connect to fitness providers:
- using mcp tool (recommended):
user: "connect to strava"
mcp client calls connect_provider tool with jwt authentication:
- pierre validates jwt, extracts user_id
- generates oauth authorization url for that user_id
- opens browser for strava authorization
- callback stores strava token for user_id
- no pierre login required - user already authenticated via jwt!
- via rest api:
curl -H "Authorization: Bearer <jwt>" \
http://localhost:8081/api/oauth/auth/strava/<user_id>common question: "why don't i need to log into pierre when connecting to strava?"
answer: you're already authenticated!
sequence:
- you logged into pierre (got jwt token)
- configured your mcp client (claude code, vs code, cursor, etc.) with jwt token
- mcp client includes jwt in every mcp request
- when you say "connect to strava":
- mcp client sends
tools/callwith jwt - pierre extracts user_id from jwt (e.g.,
75059e8b-1f56-4fcf-a14e-860966783c93) - generates oauth url:
http://localhost:8081/api/oauth/auth/strava/75059e8b-1f56-4fcf-a14e-860966783c93 - state parameter includes user_id:
75059e8b-1f56-4fcf-a14e-860966783c93:random_nonce
- mcp client sends
- browser opens strava authorization (you prove you own the strava account)
- strava redirects to callback with code
- pierre validates state, exchanges code for token
- stores strava token for your user_id (from jwt)
key insight: jwt token proves your identity to pierre. strava oauth proves you own the fitness account. no duplicate login needed.
jwt token storage: mcp clients store jwt tokens in configuration files:
- claude code:
~/.config/claude-code/mcp_config.json - vs code extensions:
.vscode/settings.jsonor user settings
these files should have restricted permissions (chmod 600 for config files).
token exposure: jwt tokens in config files are sensitive. treat like passwords:
- don't commit to version control
- don't share tokens
- rotate regularly (re-login to get new token)
- revoke if compromised
oauth state validation: pierre validates oauth state parameters to prevent:
- csrf attacks (random nonce verified)
- user_id spoofing (state must match authenticated user)
- replay attacks (state used once)
implementation: src/routes/auth.rs, src/mcp/multitenant.rs
"authentication required" error:
- check jwt token in your mcp client's configuration file
- claude code:
~/.config/claude-code/mcp_config.json - vs code:
.vscode/settings.json
- claude code:
- verify token not expired (24h default)
- confirm token format:
Bearer eyJ0eXAi...
"invalid token" error:
- token may be expired - login again
- token signature invalid - check
PIERRE_MASTER_ENCRYPTION_KEY - user account may be disabled - check user status
fitness provider connection fails:
- check oauth credentials (client_id, client_secret) at server startup
- verify redirect_uri matches provider registration
- see oauth credential validation logs for fingerprint debugging
oauth credential debugging:
pierre validates oauth credentials at startup and logs fingerprints:
OAuth provider strava: enabled=true, client_id=163846,
secret_length=40, secret_fingerprint=f3c0d77f
use fingerprints to compare secrets without exposing actual values:
# check correct secret
echo -n "0f2b184c076e60a35e8ced43db9c3c20c5fcf4f3" | \
sha256sum | cut -c1-8
# output: f3c0d77f ← correct
# check wrong secret
echo -n "1dfc45ad0a1f6983b835e4495aa9473d111d03bc" | \
sha256sum | cut -c1-8
# output: 79092abb ← wrong!if fingerprints don't match, you're using wrong credentials.
Pierre acts as oauth client to fitness providers.
- strava (oauth2)
- garmin (oauth1 + oauth2)
- fitbit (oauth2)
Set environment variables:
# strava (local development)
export STRAVA_CLIENT_ID=your_id
export STRAVA_CLIENT_SECRET=your_secret
export STRAVA_REDIRECT_URI=http://localhost:8081/api/oauth/callback/strava # local dev only
# strava (production)
export STRAVA_REDIRECT_URI=https://api.example.com/api/oauth/callback/strava # required
# garmin (local development)
export GARMIN_CLIENT_ID=your_key
export GARMIN_CLIENT_SECRET=your_secret
export GARMIN_REDIRECT_URI=http://localhost:8081/api/oauth/callback/garmin # local dev only
# garmin (production)
export GARMIN_REDIRECT_URI=https://api.example.com/api/oauth/callback/garmin # requiredcallback url security requirements:
- http urls: local development only (localhost/127.0.0.1)
- https urls: required for production deployments
- failure to use https in production:
- authorization codes transmitted unencrypted
- vulnerable to token interception
- most providers reject http callbacks in production
Via mcp tool:
user: "connect to strava"
Or via rest api:
curl -H "Authorization: Bearer <jwt>" \
http://localhost:8081/oauth/connect/stravaOpens browser for provider authentication. After approval, redirected to callback:
# local development
http://localhost:8081/api/oauth/callback/strava?code=<auth_code>
# production (https required)
https://api.example.com/api/oauth/callback/strava?code=<auth_code>Pierre exchanges code for access/refresh tokens, stores encrypted.
security: authorization codes in callback urls must be protected with tls in production. Http callbacks leak codes to network observers.
Provider tokens stored encrypted in database:
- encryption key: tenant-specific key (derived from master key)
- algorithm: aes-256-gcm
- rotation: automatic refresh before expiry
curl -H "Authorization: Bearer <jwt>" \
http://localhost:8081/oauth/statusResponse:
{
"connected_providers": ["strava"],
"strava": {
"connected": true,
"expires_at": "2024-01-01T00:00:00Z"
},
"garmin": {
"connected": false
}
}Pierre implements secure cookie-based authentication for web applications using httpOnly cookies with CSRF protection.
httpOnly cookies prevent JavaScript access to JWT tokens, eliminating XSS-based token theft:
Set-Cookie: auth_token=<jwt>; HttpOnly; Secure; SameSite=Strict; Max-Age=86400
CSRF protection uses double-submit cookie pattern with cryptographic tokens:
Set-Cookie: csrf_token=<token>; Secure; SameSite=Strict; Max-Age=1800
X-CSRF-Token: <token> (sent in request header)
| flag | value | purpose |
|---|---|---|
| HttpOnly | true | prevents JavaScript access (XSS protection) |
| Secure | true | requires HTTPS (prevents sniffing) |
| SameSite | Strict | prevents cross-origin requests (CSRF mitigation) |
| Max-Age | 86400 (auth), 1800 (csrf) | automatic expiration |
login (POST /oauth/token - OAuth2 ROPC flow):
curl -X POST http://localhost:8081/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=user@example.com&password=SecurePass123!"response sets two cookies and returns csrf token:
{
"jwt_token": "eyJ0eXAiOiJKV1Qi...", // deprecated, for backward compatibility
"csrf_token": "cryptographic_random_32bytes",
"user": {"id": "uuid", "email": "user@example.com"},
"expires_at": "2025-01-20T18:00:00Z"
}cookies set automatically:
Set-Cookie: auth_token=eyJ0eXAiOiJKV1Qi...; HttpOnly; Secure; SameSite=Strict; Max-Age=86400
Set-Cookie: csrf_token=cryptographic_random_32bytes; Secure; SameSite=Strict; Max-Age=1800
authenticated requests:
browsers automatically include cookies. web apps must include csrf token header:
curl -X POST http://localhost:8081/api/something \
-H "X-CSRF-Token: cryptographic_random_32bytes" \
-H "Cookie: auth_token=...; csrf_token=..." \
-d '{"data": "value"}'server validates:
- jwt token from
auth_tokencookie - csrf token from
csrf_tokencookie matchesX-CSRF-Tokenheader - csrf token is valid for authenticated user
- csrf token not expired (30 minute lifetime)
logout (POST /api/auth/logout):
curl -X POST http://localhost:8081/api/auth/logout \
-H "Cookie: auth_token=..."server clears cookies:
Set-Cookie: auth_token=; Max-Age=0
Set-Cookie: csrf_token=; Max-Age=0
token generation:
- 256-bit (32 byte) cryptographic randomness
- user-scoped validation (token tied to specific user_id)
- 30-minute expiration
- stored in-memory (HashMap with automatic cleanup)
validation requirements:
- csrf validation required for: POST, PUT, DELETE, PATCH
- csrf validation skipped for: GET, HEAD, OPTIONS
- validation extracts:
- user_id from jwt token (auth_token cookie)
- csrf token from X-CSRF-Token header
- verifies token valid for that user_id
- verifies token not expired
double-submit cookie pattern:
1. server generates csrf token
2. server sets csrf_token cookie (JavaScript readable)
3. server returns csrf_token in JSON response
4. client stores csrf_token in memory
5. client includes X-CSRF-Token header in state-changing requests
6. server validates:
- csrf_token cookie matches X-CSRF-Token header
- token is valid for authenticated user_id
- token not expired
security benefits:
- attacker cannot read csrf token (cross-origin restriction)
- attacker cannot forge valid csrf token (cryptographic randomness)
- attacker cannot reuse old token (user-scoped validation)
- attacker cannot use expired token (30-minute lifetime)
axios configuration:
// enable automatic cookie handling
axios.defaults.withCredentials = true;
// request interceptor for csrf token
axios.interceptors.request.use((config) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(config.method?.toUpperCase() || '')) {
const csrfToken = apiService.getCsrfToken();
if (csrfToken && config.headers) {
config.headers['X-CSRF-Token'] = csrfToken;
}
}
return config;
});
// response interceptor for 401 errors
axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// clear csrf token and redirect to login
apiService.clearCsrfToken();
window.location.href = '/login';
}
return Promise.reject(error);
}
);login flow (OAuth2 ROPC):
async function login(email: string, password: string) {
const params = new URLSearchParams({
grant_type: 'password',
username: email,
password: password
});
const response = await axios.post('/oauth/token', params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
// store csrf token in memory (cookies set automatically)
apiService.setCsrfToken(response.data.csrf_token);
// store user info in localStorage (not sensitive)
localStorage.setItem('user', JSON.stringify(response.data.user));
return response.data;
}logout flow:
async function logout() {
try {
// call backend to clear httpOnly cookies
await axios.post('/api/auth/logout');
} catch (error) {
console.error('Logout failed:', error);
} finally {
// clear client-side state
apiService.clearCsrfToken();
localStorage.removeItem('user');
}
}web apps can proactively refresh tokens using the refresh endpoint:
async function refreshToken() {
const response = await axios.post('/api/auth/refresh');
// server sets new auth_token and csrf_token cookies
apiService.setCsrfToken(response.data.csrf_token);
return response.data;
}refresh generates:
- new jwt token (24 hour expiry)
- new csrf token (30 minute expiry)
- both cookies updated automatically
when to refresh:
- proactively before jwt expires (24h default)
- after csrf token expires (30min default)
- after receiving 401 response with expired token
backend:
- csrf token manager:
src/security/csrf.rs - secure cookie utilities:
src/security/cookies.rs - csrf middleware:
src/middleware/csrf.rs - authentication middleware:
src/middleware/auth.rs(cookie-aware) - auth handlers:
src/routes/auth.rs(login, refresh, logout)
frontend:
- api service:
frontend/src/services/api.ts - auth context:
frontend/src/contexts/AuthContext.tsx
pierre supports both cookie-based and bearer token authentication simultaneously:
- cookie-based (web apps): jwt from httpOnly cookie
- bearer token (api clients):
Authorization: Bearer <token>header
middleware tries cookies first, falls back to authorization header.
for a2a systems and service-to-service communication, api keys provide simpler authentication without cookies or csrf.
- algorithm: argon2id (default) or bcrypt
- configurable work factor
- per-user salt
- jwt signing: rs256 asymmetric (rsa) or hs256 symmetric
- rs256: 4096-bit rsa keys (production), 2048-bit (tests)
- hs256: 64-byte secret (legacy)
- provider tokens: aes-256-gcm
- encryption keys: two-tier system
- master key (env:
PIERRE_MASTER_ENCRYPTION_KEY) - tenant keys (derived from master key)
- master key (env:
Asymmetric signing for distributed token verification.
Public keys available at /admin/jwks (legacy) and /oauth2/jwks (oauth2 clients):
curl http://localhost:8081/oauth2/jwksResponse (rfc 7517 compliant):
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key_2024_01_01_123456",
"n": "modulus_base64url",
"e": "exponent_base64url"
}
]
}cache-control headers: jwks endpoint returns Cache-Control: public, max-age=3600 allowing browsers to cache public keys for 1 hour.
Clients verify tokens using public key. Pierre signs with private key.
Benefits:
- private key never leaves server
- clients verify without shared secret
- supports key rotation with grace period
- browser caching reduces jwks endpoint load
key rotation: when keys are rotated, old keys are retained during grace period to allow existing tokens to validate. New tokens are signed with the current key.
Token bucket algorithm per authentication method:
- jwt tokens: per-tenant limits
- api keys: per-tier limits (free: 100/day, professional: 10,000/day, enterprise: unlimited)
- oauth2 endpoints: per-ip limits
/oauth2/authorize: 60 requests/minute/oauth2/token: 30 requests/minute/oauth2/register: 10 requests/minute
Oauth2 rate limit responses include:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1704067200
Retry-After: 42
Implementation: src/rate_limiting.rs, src/oauth2/rate_limiting.rs
pierre implements comprehensive csrf protection for web applications:
web application requests:
- double-submit cookie pattern (see "Web Application Security" section above)
- 256-bit cryptographic csrf tokens
- user-scoped validation
- 30-minute token expiration
- automatic header validation for POST/PUT/DELETE/PATCH
oauth flows:
- state parameter validation in oauth flows (prevents csrf in oauth redirects)
- pkce for oauth2 authorization (code challenge verification)
- origin validation for web requests
see "Web Application Security" section above for detailed csrf implementation.
Pierre prevents toctou (time-of-check to time-of-use) race conditions in token operations.
problem: token reuse attacks
Standard token validation flow vulnerable to race conditions:
thread 1: check token valid → ✓ valid
thread 2: check token valid → ✓ valid
thread 1: revoke token → success
thread 2: revoke token → success (token used twice!)
solution: atomic check-and-revoke
Pierre uses database-level atomic operations:
-- single atomic transaction
UPDATE oauth2_refresh_tokens
SET revoked_at = NOW()
WHERE token = ? AND revoked_at IS NULL
RETURNING *Benefits:
- race condition elimination: only one thread can consume token
- database-level garantees: transaction isolation prevents concurrent access
- zero-trust security: every token exchange verified atomically
vulnerable endpoints protected:
POST /oauth2/token(refresh token grant)- token refresh operations
- authorization code exchange
implementation details:
Atomic operations in database plugins (src/database_plugins/):
/// atomically consume oauth2 refresh token (check-and-revoke in single operation)
async fn consume_refresh_token(&self, token: &str) -> Result<RefreshToken, DatabaseError>Sqlite implementation uses RETURNING clause:
UPDATE oauth2_refresh_tokens
SET revoked_at = datetime('now')
WHERE token = ? AND revoked_at IS NULL
RETURNING *Postgresql implementation uses same pattern with RETURNING:
UPDATE oauth2_refresh_tokens
SET revoked_at = NOW()
WHERE token = $1 AND revoked_at IS NULL
RETURNING *If query returns no rows, token either:
- doesn't exist
- already revoked (race condition detected)
- expired
All three cases result in authentication failure, preventing token reuse.
Security guarantees:
- serializability: database transactions prevent concurrent modifications
- atomicity: check and revoke happen in single operation
- consistency: no partial state changes possible
- isolation: concurrent requests see consistent view
Implementation: src/database_plugins/sqlite.rs, src/database_plugins/postgres.rs, src/oauth2/endpoints.rs
- check token expiry: jwt tokens expire after 24h (default)
- verify token format: must be
Bearer <token> - ensure token not revoked: check
/oauth/status
- verify redirect uri exactly matches registration
- check pkce challenge/verifier match
- ensure code not expired (10 min lifetime)
- verify provider credentials (client_id, client_secret)
- check redirect uri accessible from browser
- ensure callback endpoint reachable
- verify api key active: not deleted or expired
- check rate limits: may be throttled
- ensure correct header:
X-API-Key(case-sensitive)
- jwt authentication:
src/auth.rs - api key management:
src/api_keys.rs - oauth2 server:
src/oauth2_server/ - provider oauth:
src/oauth2_client/ - encryption:
src/crypto/,src/key_management.rs - rate limiting:
src/rate_limiting.rs
Pierre includes a standards-compliant oauth2 authorization server for secure mcp client authentication.
- authorization code flow with pkce (s256 only)
- dynamic client registration (rfc 7591)
- server-side state validation for csrf protection
- argon2id client secret hashing
- multi-tenant isolation
- refresh token rotation
- jwt-based access tokens
curl -X POST http://localhost:8081/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://example.com/callback"],
"client_name": "My MCP Client",
"grant_types": ["authorization_code"],
"response_types": ["code"]
}'Response:
{
"client_id": "mcp_client_abc123",
"client_secret": "secret_xyz789",
"client_id_issued_at": 1640000000,
"redirect_uris": ["https://example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"]
}important: save client_secret immediately. Cannot be retrieved later.
import secrets
import hashlib
import base64
# generate code verifier (43-128 characters)
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
# generate code challenge (s256)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')
# generate state (csrf protection)
state = secrets.token_urlsafe(32)
# store code_verifier and state in session
session['code_verifier'] = code_verifier
session['oauth_state'] = stateRedirect user to authorization endpoint:
https://pierre.example.com/oauth2/authorize?
response_type=code&
client_id=mcp_client_abc123&
redirect_uri=https://example.com/callback&
state=<random_state>&
code_challenge=<pkce_challenge>&
code_challenge_method=S256&
scope=read:activities write:goals
User will authenticate and authorize. Pierre redirects to callback with authorization code:
https://example.com/callback?
code=auth_code_xyz&
state=<same_random_state>
# validate state parameter (csrf protection)
received_state = request.args.get('state')
stored_state = session.pop('oauth_state', None)
if not received_state or received_state != stored_state:
return "csrf attack detected", 400
# exchange authorization code for tokens
code = request.args.get('code')
code_verifier = session.pop('code_verifier')curl -X POST http://localhost:8081/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=auth_code_xyz" \
-d "redirect_uri=https://example.com/callback" \
-d "client_id=mcp_client_abc123" \
-d "client_secret=secret_xyz789" \
-d "code_verifier=<stored_code_verifier>"Response:
{
"access_token": "jwt_access_token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "refresh_token_abc",
"scope": "read:activities write:goals"
}curl -H "Authorization: Bearer jwt_access_token" \
http://localhost:8081/mcpEndpoint: POST /oauth2/register
Required fields:
redirect_uris- array of callback urls (https required except localhost)
Optional fields:
client_name- display nameclient_uri- client homepage urlgrant_types- defaults to["authorization_code"]response_types- defaults to["code"]scope- space-separated scope list
Pierre enforces strict redirect uri validation:
allowed:
https://urls (production)http://localhost:*(development)http://127.0.0.1:*(development)urn:ietf:wg:oauth:2.0:oob(out-of-band for native apps)
rejected:
http://non-localhost urls- urls with fragments (
#) - wildcard domains (
*.example.com) - malformed urls
web application:
curl -X POST http://localhost:8081/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://app.example.com/auth/callback"],
"client_name": "Example Web App",
"client_uri": "https://app.example.com",
"scope": "read:activities read:athlete"
}'native application:
curl -X POST http://localhost:8081/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["http://localhost:8080/callback"],
"client_name": "Example Desktop App",
"scope": "read:activities write:goals"
}'Build authorization url with required parameters:
from urllib.parse import urlencode
params = {
'response_type': 'code',
'client_id': client_id,
'redirect_uri': redirect_uri,
'state': state, # required for csrf protection
'code_challenge': code_challenge, # required for pkce
'code_challenge_method': 'S256', # only s256 supported
'scope': 'read:activities write:goals' # optional
}
auth_url = f"https://pierre.example.com/oauth2/authorize?{urlencode(params)}"Redirect user to auth_url.
If user not logged in, pierre displays login form. After successful login, shows authorization consent screen.
Pierre redirects to your redirect_uri with authorization code:
https://example.com/callback?code=<auth_code>&state=<state>
Error response (if user denies):
https://example.com/callback?error=access_denied&error_description=User+denied+authorization
Exchange authorization code for access token:
curl -X POST http://localhost:8081/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<auth_code>" \
-d "redirect_uri=<same_redirect_uri>" \
-d "client_id=<client_id>" \
-d "client_secret=<client_secret>" \
-d "code_verifier=<pkce_verifier>"important: authorization codes expire in 10 minutes and are single-use.
Jwt-based tokens with 1-hour expiration (configurable).
Claims include:
sub- user idemail- user emailtenant_id- tenant identifierscope- granted scopesexp- expiration timestamp
Use refresh token to obtain new access token without re-authentication:
curl -X POST http://localhost:8081/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=<refresh_token>" \
-d "client_id=<client_id>" \
-d "client_secret=<client_secret>"Response:
{
"access_token": "new_jwt_access_token",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "new_refresh_token",
"scope": "read:activities write:goals"
}refresh token rotation: pierre issues new refresh token with each refresh request. Old refresh token is revoked.
Validate access token and optionally refresh if expired:
curl -X POST http://localhost:8081/oauth2/validate \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"refresh_token": "optional_refresh_token"
}'Responses:
valid token:
{
"status": "valid",
"expires_in": 1800
}token refreshed:
{
"status": "refreshed",
"access_token": "new_jwt_token",
"refresh_token": "new_refresh_token",
"token_type": "Bearer"
}invalid token:
{
"status": "invalid",
"reason": "token expired",
"requires_full_reauth": true
}Pierre requires pkce for all authorization code flows.
supported methods:
S256(sha256) - required
rejected methods:
plain- insecure, not supported
implementation:
- Generate random
code_verifier(43-128 characters) - Compute
code_challenge = base64url(sha256(code_verifier)) - Send
code_challengein authorization request - Send
code_verifierin token exchange - Pierre validates
sha256(code_verifier) == code_challenge
Prevents authorization code interception attacks.
Pierre implements defense-in-depth csrf protection with server-side state validation.
client requirements:
- Generate cryptographically random state (≥128 bits entropy)
- Store state in session before authorization request
- Include state in authorization request
- Validate state matches in callback
server behavior:
- Stores state with 10-minute expiration
- Binds state to client_id and user
- Validates state on callback
- Marks state as used (single-use)
- Rejects expired, used, or mismatched states
example implementation:
import secrets
# before authorization
state = secrets.token_urlsafe(32)
session['oauth_state'] = state
# in callback
received_state = request.args.get('state')
stored_state = session.pop('oauth_state', None)
if not received_state or received_state != stored_state:
abort(400, "invalid state - possible csrf attack")Client secrets hashed with argon2id (memory-hard algorithm resistant to gpu attacks).
verification:
# validate client credentials
curl -X POST http://localhost:8081/oauth2/token \
-d "client_id=<id>" \
-d "client_secret=<secret>" \
...Pierre verifies secret using constant-time comparison to prevent timing attacks.
All oauth artifacts (codes, tokens, states) bound to tenant_id. Cross-tenant access prevented at database layer.
Pierre supports fine-grained permission control via oauth scopes.
fitness data:
read:activities- read activity datawrite:activities- create/update activitiesread:athlete- read athlete profilewrite:athlete- update athlete profile
goals and analytics:
read:goals- read fitness goalswrite:goals- create/update goalsread:analytics- access analytics data
administrative:
admin:users- manage usersadmin:system- system administration
Include in authorization request:
/oauth2/authorize?
...
scope=read:activities read:athlete write:goals
Pierre validates requested scopes against client's registered scopes. Access tokens include granted scopes in jwt claims.
Returned as query parameters in redirect:
https://example.com/callback?
error=invalid_request&
error_description=missing+code_challenge&
state=<state>
common errors:
invalid_request- missing or invalid parametersunauthorized_client- client not authorized for this flowaccess_denied- user denied authorizationunsupported_response_type- response_type not supportedinvalid_scope- requested scope invalid or not allowedserver_error- internal server error
Returned as json in response body:
{
"error": "invalid_grant",
"error_description": "authorization code expired",
"error_uri": "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"
}common errors:
invalid_request- malformed requestinvalid_client- client authentication failedinvalid_grant- code expired, used, or invalidunauthorized_client- client not authorizedunsupported_grant_type- grant type not supported
- User clicks "connect with pierre"
- App redirects to pierre authorization endpoint
- User logs in (if needed) and approves
- Pierre redirects back with authorization code
- App exchanges code for tokens (server-side)
- App stores tokens securely (encrypted database)
- App uses access token for api requests
- App refreshes token before expiration
- App opens system browser to authorization url
- User authenticates and approves
- Browser redirects to
http://localhost:port/callback - App's local server receives callback
- App exchanges code for tokens
- App stores tokens securely (os keychain)
recommended: use authorization code flow with pkce:
- Spa redirects to pierre authorization endpoint
- Pierre redirects back with authorization code
- Spa exchanges code for tokens via backend proxy
- Backend stores refresh token
- Backend returns short-lived access token to spa
- Spa uses access token for api requests
- Spa requests new access token via backend when expired
not recommended: implicit flow (deprecated)
symptom: invalid_grant error when exchanging code
solution: authorization codes expire in 10 minutes. Restart authorization flow.
symptom: invalid_grant: pkce verification failed
solutions:
- ensure
code_verifiersent in token request matches original - verify code_challenge computed as
base64url(sha256(code_verifier)) - check no extra padding (
=) in base64url encoding
symptom: invalid_grant: invalid state parameter
solutions:
- ensure state sent in callback matches original request
- check state not expired (10-minute ttl)
- verify state not reused (single-use)
- confirm state stored in user session before authorization
symptom: invalid_request: redirect_uri mismatch
solutions:
- redirect_uri in authorization request must exactly match registration
- redirect_uri in token request must match authorization request
- https required for non-localhost urls
symptom: invalid_client
solutions:
- verify client_id correct
- verify client_secret correct (case-sensitive)
- ensure client_secret not expired
- check client not deleted
symptom: invalid_grant: refresh token revoked or expired
solutions:
- refresh tokens expire after 30 days of inactivity
- old refresh tokens revoked after successful refresh (rotation)
- restart authorization flow to obtain new tokens
Pierre currently uses fixed lifetimes for OAuth2 artifacts (configured in code, not via environment variables):
- Authorization codes: 10 minutes (single-use)
- Access tokens: 1 hour
- Refresh tokens: 30 days
- State parameters: 10 minutes
Changing these values requires a code change in the OAuth2 server configuration (see src/oauth2_server/ and src/constants/).
- authentication - jwt and api key authentication
- protocols - fitness provider integrations
- configuration - server configuration
Pierre acts as an oauth 2.0 client to connect to fitness providers (strava, fitbit, garmin) on behalf of users.
oauth2_client module (src/oauth2_client/):
- pierre connects TO fitness providers as oauth client
- handles user authorization and token management
- supports pkce for enhanced security
- multi-tenant credential isolation
separate from oauth2_server:
- oauth2_server: mcp clients connect TO pierre
- oauth2_client: pierre connects TO fitness providers
| provider | oauth version | pkce | status | scopes | implementation |
|---|---|---|---|---|---|
| strava | oauth 2.0 | required | active | activity:read_all |
src/providers/strava.rs |
| fitbit | oauth 2.0 | required | active | activity,heartrate,location,nutrition,profile,settings,sleep,social,weight |
src/providers/fitbit.rs |
| garmin | oauth 2.0 | required | active | wellness:read,activities:read |
src/providers/garmin_provider.rs |
| whoop | oauth 2.0 | required | active | read:profile,read:body_measurement,read:workout,read:sleep,read:recovery,read:cycles |
src/providers/whoop_provider.rs |
| terra | oauth 2.0 | required | active | device-dependent (150+ wearables) | src/providers/terra_provider.rs |
note: providers require compile-time feature flags (provider-strava, provider-fitbit, provider-whoop, provider-terra, etc.).
Implementation: src/oauth2_client/mod.rs
strava:
export STRAVA_CLIENT_ID=your_client_id
export STRAVA_CLIENT_SECRET=your_client_secret
export STRAVA_REDIRECT_URI=http://localhost:8081/api/oauth/callback/strava # devfitbit:
export FITBIT_CLIENT_ID=your_client_id
export FITBIT_CLIENT_SECRET=your_client_secret
export FITBIT_REDIRECT_URI=http://localhost:8081/api/oauth/callback/fitbit # devgarmin:
export GARMIN_CLIENT_ID=your_consumer_key
export GARMIN_CLIENT_SECRET=your_consumer_secret
export GARMIN_REDIRECT_URI=http://localhost:8081/api/oauth/callback/garmin # devwhoop:
export WHOOP_CLIENT_ID=your_client_id
export WHOOP_CLIENT_SECRET=your_client_secret
export WHOOP_REDIRECT_URI=http://localhost:8081/api/oauth/callback/whoop # devproduction: use https redirect urls:
export STRAVA_REDIRECT_URI=https://api.example.com/api/oauth/callback/strava
export FITBIT_REDIRECT_URI=https://api.example.com/api/oauth/callback/fitbit
export GARMIN_REDIRECT_URI=https://api.example.com/api/oauth/callback/garmin
export WHOOP_REDIRECT_URI=https://api.example.com/api/oauth/callback/whoopConstants: src/constants/oauth/providers.rs
Credentials resolved in priority order:
- tenant-specific credentials (database, encrypted)
- server-level credentials (environment variables)
Implementation: src/oauth2_client/tenant_client.rs
TenantOAuthClient (src/oauth2_client/tenant_client.rs:36-49):
pub struct TenantOAuthClient {
pub oauth_manager: Arc<Mutex<TenantOAuthManager>>,
}features:
- tenant-specific credential isolation
- rate limiting per tenant per provider
- automatic credential fallback to server config
via authorization request headers:
curl -X GET "http://localhost:8081/api/oauth/auth/strava/uuid" \
-H "x-strava-client-id: tenant_client_id" \
-H "x-strava-client_secret: tenant_client_secret"Credentials stored encrypted in database, bound to tenant.
via api:
tenant_oauth_client.store_credentials(
tenant_id,
"strava",
StoreCredentialsRequest {
client_id: "tenant_client_id".to_string(),
client_secret: "tenant_client_secret".to_string(),
redirect_uri: "https://tenant.example.com/callback/strava".to_string(),
scopes: vec!["activity:read_all".to_string()],
configured_by: user_id,
}
).await?;Implementation: src/oauth2_client/tenant_client.rs:21-34
default limits (src/tenant/oauth_manager.rs):
- strava: 1000 requests/day per tenant
- fitbit: 150 requests/day per tenant
- garmin: 1000 requests/day per tenant
- whoop: 1000 requests/day per tenant
rate limit enforcement:
let (current_usage, daily_limit) = manager
.check_rate_limit(tenant_id, provider)?;
if current_usage >= daily_limit {
return Err(AppError::invalid_input(format!(
"Tenant {} exceeded daily rate limit for {}: {}/{}",
tenant_id, provider, current_usage, daily_limit
)));
}Implementation: src/oauth2_client/tenant_client.rs:64-75
via mcp tool:
user: "connect to strava"
via rest api:
curl -H "Authorization: Bearer <jwt>" \
"http://localhost:8081/api/oauth/auth/strava/<user_id>"flow manager (src/oauth2_client/flow_manager.rs:29-105):
- Validates user_id and tenant_id
- Processes optional tenant credentials from headers
- Generates authorization redirect url
- Returns http 302 redirect to provider
Pierre generates authorization url with:
- pkce s256 challenge (128-character verifier)
- state parameter for csrf protection (
{user_id}:{random_uuid}) - provider scopes (activity read, heartrate, etc.)
pkce generation (src/oauth2_client/client.rs:35-58):
pub fn generate() -> PkceParams {
// 128-character random verifier (43-128 allowed by RFC)
let code_verifier: String = (0..128)
.map(|_| CHARS[rng.gen_range(0..CHARS.len())] as char)
.collect();
// S256 challenge: base64url(sha256(code_verifier))
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let hash = hasher.finalize();
let code_challenge = URL_SAFE_NO_PAD.encode(hash);
PkceParams {
code_verifier,
code_challenge,
code_challenge_method: "S256".into(),
}
}User authenticates with provider and grants permissions.
Provider redirects to pierre callback:
http://localhost:8081/api/oauth/callback/strava?
code=authorization_code&
state=user_id:random_uuid
callback handling (src/routes/auth.rs):
- Validates state parameter (csrf protection)
- Extracts user_id from state
- Exchanges authorization code for access token
- Encrypts tokens with aes-256-gcm
- Stores in database (tenant-isolated)
- Renders success page
User sees branded html page:
- provider name and connection status
- user identifier
- pierre logo
- instructions to return to mcp client
Template: templates/oauth_success.html
Renderer: src/oauth2_client/flow_manager.rs:350-393
OAuth2Token (src/oauth2_client/client.rs:61-82):
pub struct OAuth2Token {
pub access_token: String,
pub token_type: String,
pub expires_at: Option<DateTime<Utc>>,
pub refresh_token: Option<String>,
pub scope: Option<String>,
}
impl OAuth2Token {
pub fn is_expired(&self) -> bool {
self.expires_at
.is_some_and(|expires_at| expires_at <= Utc::now())
}
pub fn will_expire_soon(&self) -> bool {
self.expires_at
.is_some_and(|expires_at| expires_at <= Utc::now() + Duration::minutes(5))
}
}Tokens stored in users table with provider-specific columns:
-- strava example
strava_access_token TEXT -- encrypted
strava_refresh_token TEXT -- encrypted
strava_expires_at TIMESTAMP
strava_scope TEXT -- comma-separatedencryption:
- algorithm: aes-256-gcm
- key: tenant-specific (derived from
PIERRE_MASTER_ENCRYPTION_KEY) - unique key per tenant ensures isolation
Implementation: src/database/tokens.rs, src/crypto/, src/key_management.rs
Pierre refreshes expired tokens before api requests:
refresh criteria:
- access token expired or expiring within 5 minutes
- refresh token available and valid
refresh flow (src/oauth2_client/client.rs:272-302):
pub async fn refresh_token(&self, refresh_token: &str) -> AppResult<OAuth2Token> {
let params = [
("client_id", self.config.client_id.as_str()),
("client_secret", self.config.client_secret.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
];
let response: TokenResponse = self
.client
.post(&self.config.token_url)
.form(¶ms)
.send()
.await?
.json()
.await?;
Ok(Self::token_from_response(response))
}Note: PKCE (code_verifier) is only used during authorization code exchange, not token refresh per RFC 7636.
get token:
let token = database.get_oauth_token(user_id, "strava").await?;update token:
database.update_oauth_token(
user_id,
"strava",
OAuthToken {
access_token: "new_token".to_string(),
refresh_token: Some("new_refresh".to_string()),
expires_at: Utc::now() + Duration::hours(6),
scope: "activity:read_all".to_string(),
}
).await?;clear token (disconnect):
database.clear_oauth_token(user_id, "strava").await?;Implementation: src/database/tokens.rs
check connection:
curl -H "Authorization: Bearer <jwt>" \
http://localhost:8081/api/oauth/statusResponse:
{
"connected_providers": ["strava", "fitbit"],
"providers": {
"strava": {
"connected": true,
"expires_at": "2024-01-01T12:00:00Z",
"scope": "activity:read_all",
"auto_refresh": true
},
"fitbit": {
"connected": true,
"expires_at": "2024-01-01T14:00:00Z",
"scope": "activity heartrate location",
"auto_refresh": true
},
"garmin": {
"connected": false
}
}
}disconnect provider:
Use the disconnect_provider MCP tool to revoke a provider connection; there is no standalone REST DELETE /api/oauth/disconnect/{provider} endpoint.
Implementation: src/routes/auth.rs
implementation (src/oauth2_client/client.rs:27-59):
All provider oauth flows use pkce (rfc 7636):
code verifier:
- 128 characters
- cryptographically random
- allowed characters:
A-Z a-z 0-9 - . _ ~
code challenge:
- sha256 hash of code verifier
- base64url encoded (no padding)
- method: s256 only
Prevents authorization code interception attacks.
state format: {user_id}:{random_uuid}
validation (src/oauth2_client/flow_manager.rs:162-215):
- Extract user_id from state
- Verify user exists and belongs to tenant
- Ensure state not reused (single-use)
Invalid state results in authorization rejection.
encryption (src/crypto/, src/key_management.rs):
- algorithm: aes-256-gcm
- key derivation:
- master key:
PIERRE_MASTER_ENCRYPTION_KEY(base64, 32 bytes) - tenant keys: derived from master key using tenant_id
- unique key per tenant ensures isolation
- master key:
encrypted fields:
- access_token
- refresh_token
- client_secret (for tenant credentials)
Decryption requires:
- Correct master key
- Correct tenant_id
- Valid encryption nonce
Oauth artifacts never shared between tenants:
- credentials stored per tenant_id
- tokens bound to user and tenant
- rate limits enforced per tenant
- database queries include tenant_id filter
Cross-tenant access prevented at database layer.
Implementation: src/tenant/oauth_manager.rs
auth url: https://www.strava.com/oauth/authorize
token url: https://www.strava.com/oauth/token
api base: https://www.strava.com/api/v3
default scopes: activity:read_all
available scopes:
read- read public profileactivity:read- read non-private activitiesactivity:read_all- read all activities (public and private)activity:write- create and update activities
rate limits:
- 100 requests per 15 minutes per access token
- 1000 requests per day per application
token lifetime:
- access token: 6 hours
- refresh token: permanent (until revoked)
Implementation: src/providers/strava.rs, src/providers/strava_provider.rs
auth url: https://www.fitbit.com/oauth2/authorize
token url: https://api.fitbit.com/oauth2/token
api base: https://api.fitbit.com/1
default scopes: activity heartrate location nutrition profile settings sleep social weight
scope details:
activity- steps, distance, calories, floorsheartrate- heart rate datalocation- gps datanutrition- food and water logsprofile- personal informationsettings- user preferencessleep- sleep logssocial- friends and leaderboardsweight- weight and body measurements
rate limits:
- 150 requests per hour per user
token lifetime:
- access token: 8 hours
- refresh token: 1 year
Implementation: src/providers/fitbit.rs
auth url: https://connect.garmin.com/oauthConfirm
token url: https://connectapi.garmin.com/oauth-service/oauth/access_token
api base: https://apis.garmin.com
default scopes: wellness:read activities:read
scope details:
wellness:read- health metrics (sleep, stress, hrv)activities:read- workout and activity datawellness:write- update health dataactivities:write- create activities
rate limits:
- varies by api endpoint
- typically 1000 requests per day
token lifetime:
- access token: 1 year
- refresh token: not provided (long-lived access token)
Implementation: src/providers/garmin_provider.rs
auth url: https://api.prod.whoop.com/oauth/oauth2/auth
token url: https://api.prod.whoop.com/oauth/oauth2/token
api base: https://api.prod.whoop.com/developer/v1
default scopes: offline read:profile read:body_measurement read:workout read:sleep read:recovery read:cycles
scope details:
offline- offline access for token refreshread:profile- user profile informationread:body_measurement- body measurements (weight, height)read:workout- workout/activity data with strain scoresread:sleep- sleep sessions and metricsread:recovery- daily recovery scoresread:cycles- physiological cycle data
rate limits:
- varies by endpoint
- standard api rate limiting applies
token lifetime:
- access token: 1 hour
- refresh token: long-lived (requires
offlinescope)
Implementation: src/providers/whoop_provider.rs
Displayed on html error page (templates/oauth_error.html):
common errors:
access_denied- user denied authorizationinvalid_request- missing or invalid parametersinvalid_scope- requested scope not availableserver_error- provider api error
Renderer: src/oauth2_client/flow_manager.rs:329-347
Returned as query parameters:
http://localhost:8081/api/oauth/callback/strava?
error=access_denied&
error_description=User+declined+authorization
expired token:
- automatically refreshed before api request
- no user action required
invalid refresh token:
- user must re-authorize
- connection status shows disconnected
rate limit exceeded:
{
"error": "rate_limit_exceeded",
"provider": "strava",
"retry_after_secs": 3600,
"limit_type": "daily quota"
}Implementation: src/providers/errors.rs
symptom: redirect to provider fails or returns error
solutions:
- verify provider credentials (client_id, client_secret)
- check redirect_uri matches provider configuration exactly
- ensure redirect_uri uses https in production
- confirm provider api credentials active and approved
symptom: invalid state parameter error on callback
solutions:
- ensure user_id in authorization request matches authenticated user
- check user exists in database
- verify tenant association correct
- confirm no url encoding issues in state parameter
symptom: api requests fail with authentication error
solutions:
- check refresh token not expired or revoked
- verify provider credentials still valid
- ensure network connectivity to provider api
- re-authorize user to obtain new tokens
symptom: api requests rejected with rate limit error
solutions:
- check current usage via tenant_oauth_manager
- wait for daily reset (midnight utc)
- request rate limit increase from provider
- optimize api call patterns to reduce requests
symptom: cannot decrypt stored tokens
solutions:
- verify
PIERRE_MASTER_ENCRYPTION_KEYunchanged - check key is valid base64 (32 bytes decoded)
- ensure key not rotated without token re-encryption
- re-authorize users if key changed
- oauth2 client:
src/oauth2_client/client.rs - oauth flow manager:
src/oauth2_client/flow_manager.rs - tenant client:
src/oauth2_client/tenant_client.rs - tenant oauth manager:
src/tenant/oauth_manager.rs - provider implementations:
src/providers/ - token storage:
src/database/tokens.rs - route handlers:
src/routes/auth.rs - templates:
templates/oauth_success.html,templates/oauth_error.html
- oauth2 server - mcp client authentication
- authentication - authentication methods and jwt tokens
- configuration - environment variables
Pierre implements three protocols on a single http port (8081).
Json-rpc 2.0 protocol for ai assistant integration.
POST /mcp- main mcp endpointGET /mcp/sse/{session_id}- sse transport for streaming (session-scoped)
Pierre supports both http and sse transports:
- http: traditional request-response
- sse: server-sent events for streaming responses
Sdk handles transport negotiation automatically.
Mcp requests require jwt bearer token in authorization header:
Authorization: Bearer <jwt_token>
Obtained via oauth2 flow (sdk handles automatically).
{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "get_activities",
"arguments": {
"limit": 5
}
}
}{
"jsonrpc": "2.0",
"id": "1",
"result": {
"content": [
{
"type": "text",
"text": "[activity data...]"
}
]
}
}Most data-returning tools support an optional format parameter for output serialization:
| Format | Description | Use Case |
|---|---|---|
json |
Standard JSON (default) | Universal compatibility |
toon |
Token-Oriented Object Notation | ~40% fewer LLM tokens |
Example with TOON format:
{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "get_activities",
"arguments": {
"provider": "strava",
"limit": 100,
"format": "toon"
}
}
}TOON format responses include format: "toon" and content_type: "application/vnd.toon" in the result. Use TOON for large datasets (year summaries, batch analysis) to reduce LLM context usage.
See TOON specification for format details.
initialize- start sessiontools/list- list available toolstools/call- execute toolresources/list- list resourcesprompts/list- list prompts
Implementation: src/mcp/protocol.rs, src/protocols/universal/
Rfc 7591 (dynamic client registration) + rfc 7636 (pkce) compliant oauth2 server for mcp client authentication.
GET /.well-known/oauth-authorization-server- server metadata (rfc 8414)POST /oauth2/register- dynamic client registrationGET /oauth2/authorize- authorization endpointPOST /oauth2/token- token endpointGET /oauth2/jwks- json web key setGET /.well-known/jwks.json- jwks at standard oidc locationPOST /oauth2/validate-and-refresh- validate and refresh jwt tokensPOST /oauth2/token-validate- validate jwt token
- client registration (rfc 7591):
# local development (http allowed for localhost)
curl -X POST http://localhost:8081/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["http://localhost:35535/oauth/callback"],
"client_name": "My MCP Client (Dev)",
"grant_types": ["authorization_code"]
}'
# production (https required)
curl -X POST https://api.example.com/oauth2/register \
-H "Content-Type: application/json" \
-d '{
"redirect_uris": ["https://client.example.com/oauth/callback"],
"client_name": "My MCP Client",
"grant_types": ["authorization_code"]
}'Response:
{
"client_id": "generated_client_id",
"client_secret": "generated_secret",
"redirect_uris": ["http://localhost:35535/oauth/callback"],
"grant_types": ["authorization_code"]
}callback url security: redirect_uris using http only permitted for localhost/127.0.0.1 in development. Production clients must use https to protect authorization codes from interception.
- authorization request:
GET /oauth2/authorize?
client_id=<client_id>&
redirect_uri=<redirect_uri>&
response_type=code&
code_challenge=<pkce_challenge>&
code_challenge_method=S256
User authenticates in browser, redirected to:
<redirect_uri>?code=<authorization_code>
- token exchange:
curl -X POST http://localhost:8081/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&\
code=<authorization_code>&\
client_id=<client_id>&\
client_secret=<client_secret>&\
redirect_uri=<redirect_uri>&\
code_verifier=<pkce_verifier>"Response:
{
"access_token": "jwt_token",
"token_type": "Bearer",
"expires_in": 86400
}Jwt access token used for all mcp requests.
Pierre enforces pkce (rfc 7636) for all authorization code flows. Clients must:
- generate code verifier (43-128 characters)
- create code challenge:
base64url(sha256(verifier)) - include challenge in authorization request
- include verifier in token request
Pierre provides oauth2 server metadata for automatic configuration:
curl http://localhost:8081/.well-known/oauth-authorization-serverResponse includes:
{
"issuer": "http://localhost:8081",
"authorization_endpoint": "http://localhost:8081/oauth2/authorize",
"token_endpoint": "http://localhost:8081/oauth2/token",
"jwks_uri": "http://localhost:8081/oauth2/jwks",
"registration_endpoint": "http://localhost:8081/oauth2/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"]
}Issuer url configurable via OAUTH2_ISSUER_URL environment variable.
Public keys for jwt token verification available at /oauth2/jwks:
curl http://localhost:8081/oauth2/jwksResponse (rfc 7517 compliant):
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key_2024_01_01",
"n": "modulus_base64url",
"e": "exponent_base64url"
}
]
}cache-control headers: jwks endpoint returns Cache-Control: public, max-age=3600 allowing browsers to cache public keys for 1 hour.
Pierre supports rs256 key rotation with grace period:
- new keys generated with timestamp-based kid (e.g.,
key_2024_01_01_123456) - old keys retained during grace period for existing token validation
- tokens issued with old keys remain valid until expiration
- new tokens signed with current key
Clients should:
- Fetch jwks on startup
- Cache public keys for 1 hour (respects cache-control header)
- Refresh jwks if unknown kid encountered
- Verify token signature using matching kid
Oauth2 endpoints protected by per-ip token bucket rate limiting:
| endpoint | requests per minute |
|---|---|
/oauth2/authorize |
60 (1/second) |
/oauth2/token |
30 (1/2 seconds) |
/oauth2/register |
10 (1/6 seconds) |
Rate limit headers included in all responses:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1704067200
429 response when limit exceeded:
{
"error": "rate_limit_exceeded",
"error_description": "Rate limit exceeded. Retry after 42 seconds."
}Headers:
Retry-After: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704067200
Implementation: src/oauth2_server/, src/oauth2_server/rate_limiting.rs
Protocol for autonomous ai systems to communicate.
GET /a2a/status- protocol statusGET /a2a/tools- available toolsPOST /a2a/execute- execute toolGET /a2a/monitoring- monitoring info
A2a uses api keys:
X-API-Key: <api_key>
Create api key via admin endpoint:
curl -X POST http://localhost:8081/api/keys \
-H "Authorization: Bearer <admin_jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "My A2A System", "tier": "professional"}'Agents advertise capabilities via agent cards:
{
"agent_id": "fitness-analyzer",
"name": "Fitness Analyzer Agent",
"version": "1.0.0",
"capabilities": [
"activity_analysis",
"performance_prediction",
"goal_tracking"
],
"endpoints": [
{
"path": "/a2a/execute",
"method": "POST",
"description": "Execute fitness analysis"
}
]
}{
"tool": "analyze_activity",
"parameters": {
"activity_id": "12345",
"analysis_type": "comprehensive"
}
}{
"success": true,
"result": {
"analysis": {...},
"recommendations": [...]
}
}Implementation: src/a2a/, src/protocols/universal/
Traditional rest endpoints for web applications.
POST /api/auth/register- user registration (admin-provisioned)POST /api/auth/login- user loginPOST /api/auth/logout- logoutPOST /api/auth/refresh- refresh jwt token
GET /api/oauth/auth/{provider}/{user_id}- initiate oauth (strava, garmin, fitbit, whoop)GET /api/oauth/callback/{provider}- oauth callbackGET /api/oauth/status- connection status
POST /admin/setup- create admin userPOST /admin/users- manage usersGET /admin/analytics- usage analytics
GET /api/configuration/catalog- config catalogGET /api/configuration/profiles- available profilesGET /api/configuration/user- user configPUT /api/configuration/user- update config
Implementation: src/routes.rs, src/admin_routes.rs, src/configuration_routes.rs
Real-time notifications for oauth completions and system events.
GET /notifications/sse?user_id=<user_id>
oauth_complete- oauth flow completedoauth_error- oauth flow failedsystem_status- system status update
const eventSource = new EventSource('/notifications/sse?user_id=user-123');
eventSource.onmessage = function(event) {
const notification = JSON.parse(event.data);
if (notification.type === 'oauth_complete') {
console.log('OAuth completed for provider:', notification.provider);
}
};Implementation: src/notifications/sse.rs, src/sse.rs
| feature | mcp | oauth2 | a2a | rest |
|---|---|---|---|---|
| primary use | ai assistants | client auth | agent comms | web apps |
| auth method | jwt bearer | - | api key | jwt bearer |
| transport | http + sse | http | http | http |
| format | json-rpc 2.0 | oauth2 | json | json |
| implementation | src/mcp/ |
src/oauth2_server/ |
src/a2a/ |
src/routes/ |
- ai assistant integration: use mcp (claude, chatgpt)
- web application: use rest api
- autonomous agents: use a2a
- client authentication: use oauth2 (for mcp clients)
All protocols share the same business logic via src/protocols/universal/.
This guide shows how pierre's pluggable provider architecture supports 1 to x providers simultaneously and how new providers are registered.
┌──────────────────────────────────────────────────────┐
│ Step 1: Application Startup │
│ ProviderRegistry::new() called │
└────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Step 2: Factory Registration (1 to x providers) │
│ │
│ registry.register_factory("strava", StravaFactory) │
│ registry.register_factory("garmin", GarminFactory) │
│ registry.register_factory("fitbit", FitbitFactory) │
│ registry.register_factory("synthetic", SynthFactory)│
│ registry.register_factory("whoop", WhoopFactory) │ <- built-in
│ registry.register_factory("terra", TerraFactory) │ <- built-in
│ registry.register_factory("polar", PolarFactory) │ <- custom example
│ ... unlimited providers ... │
└────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Step 3: Environment Configuration Loading │
│ │
│ For each registered provider: │
│ config = load_provider_env_config( │
│ provider_name, │
│ default_auth_url, │
│ default_token_url, │
│ default_api_base_url, │
│ default_revoke_url, │
│ default_scopes │
│ ) │
│ registry.set_default_config(provider, config) │
└────────────┬─────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────┐
│ Step 4: Runtime Usage │
│ │
│ // Check if provider is available │
│ if registry.is_supported("strava") { ... } │
│ │
│ // List all available providers │
│ let providers = registry.supported_providers(); │
│ // ["strava", "garmin", "fitbit", "synthetic", │
│ // "whoop", "polar", ...] │
│ │
│ // Create provider instance │
│ let provider = registry.create_provider("strava"); │
│ │
│ // Use provider through FitnessProvider trait │
│ let activities = provider.get_activities(...).await;│
└──────────────────────────────────────────────────────┘
Location: src/providers/registry.rs:71-94
impl ProviderRegistry {
pub fn new() -> Self {
let mut registry = Self {
factories: HashMap::new(),
default_configs: HashMap::new(),
};
// 1. Register factory
registry.register_factory(
oauth_providers::STRAVA, // "strava"
Box::new(StravaProviderFactory),
);
// 2. Load environment configuration
let (_client_id, _client_secret, auth_url, token_url,
api_base_url, revoke_url, scopes) =
crate::config::environment::load_provider_env_config(
oauth_providers::STRAVA,
"https://www.strava.com/oauth/authorize",
"https://www.strava.com/oauth/token",
"https://www.strava.com/api/v3",
Some("https://www.strava.com/oauth/deauthorize"),
&[oauth_providers::STRAVA_DEFAULT_SCOPES.to_owned()],
);
// 3. Set default configuration
registry.set_default_config(
oauth_providers::STRAVA,
ProviderConfig {
name: oauth_providers::STRAVA.to_owned(),
auth_url,
token_url,
api_base_url,
revoke_url,
default_scopes: scopes,
},
);
// Repeat for Garmin, Fitbit, Synthetic, etc.
// ...
registry
}
}Location: src/providers/registry.rs (add to new() method)
// Register Whoop provider
registry.register_factory(
"whoop",
Box::new(WhoopProviderFactory),
);
let (_, _, auth_url, token_url, api_base_url, revoke_url, scopes) =
crate::config::environment::load_provider_env_config(
"whoop",
"https://api.prod.whoop.com/oauth/authorize",
"https://api.prod.whoop.com/oauth/token",
"https://api.prod.whoop.com/developer/v1",
Some("https://api.prod.whoop.com/oauth/revoke"),
&["read:workout".to_owned(), "read:profile".to_owned()],
);
registry.set_default_config(
"whoop",
ProviderConfig {
name: "whoop".to_owned(),
auth_url,
token_url,
api_base_url,
revoke_url,
default_scopes: scopes,
},
);That's it! Whoop is now registered and available alongside Strava, Garmin, and others.
Pierre supports unlimited providers simultaneously. Just set environment variables for each:
# Default provider (required)
export PIERRE_DEFAULT_PROVIDER=strava
# Provider 1: Strava
export PIERRE_STRAVA_CLIENT_ID=abc123
export PIERRE_STRAVA_CLIENT_SECRET=secret123
# Provider 2: Garmin
export PIERRE_GARMIN_CLIENT_ID=xyz789
export PIERRE_GARMIN_CLIENT_SECRET=secret789
# Provider 3: Fitbit
export PIERRE_FITBIT_CLIENT_ID=fitbit123
export PIERRE_FITBIT_CLIENT_SECRET=fitbit_secret
# Provider 4: Synthetic (no credentials needed!)
# Automatically available - no env vars required
# Provider 5: Custom Whoop
export PIERRE_WHOOP_CLIENT_ID=whoop_client
export PIERRE_WHOOP_CLIENT_SECRET=whoop_secret
# Provider 6: Custom Polar
export PIERRE_POLAR_CLIENT_ID=polar_client
export PIERRE_POLAR_CLIENT_SECRET=polar_secret
# ... unlimited providers ...Tools automatically discover all registered providers:
Request:
{
"method": "tools/call",
"params": {
"name": "get_connection_status"
}
}Response (discovers all 1 to x providers):
{
"success": true,
"result": {
"providers": {
"strava": { "connected": true, "status": "connected" },
"garmin": { "connected": true, "status": "connected" },
"fitbit": { "connected": false, "status": "disconnected" },
"synthetic": { "connected": true, "status": "connected" },
"whoop": { "connected": true, "status": "connected" },
"polar": { "connected": false, "status": "disconnected" }
}
}
}Implementation (src/protocols/universal/handlers/connections.rs:84-110):
// Multi-provider mode - check all supported providers from registry
let providers_to_check = executor.resources.provider_registry.supported_providers();
let mut providers_status = serde_json::Map::new();
for provider in providers_to_check {
let is_connected = matches!(
executor
.auth_service
.get_valid_token(user_uuid, provider, request.tenant_id.as_deref())
.await,
Ok(Some(_))
);
providers_status.insert(
provider.to_owned(),
serde_json::json!({
"connected": is_connected,
"status": if is_connected { "connected" } else { "disconnected" }
}),
);
}Key benefit: No hardcoded provider lists! Add/remove providers without changing tool code.
Request (invalid provider):
{
"method": "tools/call",
"params": {
"name": "connect_provider",
"arguments": {
"provider": "unknown_provider"
}
}
}Response (automatically lists all registered providers):
{
"success": false,
"error": "Provider 'unknown_provider' is not supported. Supported providers: strava, garmin, fitbit, synthetic, whoop, polar"
}Implementation (src/protocols/universal/handlers/connections.rs:332-340):
if !is_provider_supported(provider, &executor.resources.provider_registry) {
let supported_providers = executor
.resources
.provider_registry
.supported_providers()
.join(", ");
return Ok(connection_error(format!(
"Provider '{provider}' is not supported. Supported providers: {supported_providers}"
)));
}Each provider implements ProviderFactory:
struct StravaProviderFactory;
impl ProviderFactory for StravaProviderFactory {
fn create(&self, config: ProviderConfig) -> Box<dyn FitnessProvider> {
Box::new(StravaProvider::new(config))
}
fn supported_providers(&self) -> &'static [&'static str] {
&["strava"]
}
}struct SyntheticProviderFactory;
impl ProviderFactory for SyntheticProviderFactory {
fn create(&self, _config: ProviderConfig) -> Box<dyn FitnessProvider> {
// Ignores config - generates synthetic data
Box::new(SyntheticProvider::default())
}
fn supported_providers(&self) -> &'static [&'static str] {
&["synthetic"]
}
}pub struct WhoopProviderFactory;
impl ProviderFactory for WhoopProviderFactory {
fn create(&self, config: ProviderConfig) -> Box<dyn FitnessProvider> {
Box::new(WhoopProvider::new(config))
}
fn supported_providers(&self) -> &'static [&'static str] {
&["whoop"]
}
}Users can connect to all providers simultaneously and aggregate data:
pub async fn get_all_activities_from_all_providers(
user_id: Uuid,
tenant_id: Uuid,
registry: &ProviderRegistry,
auth_service: &AuthService,
) -> Vec<Activity> {
let mut all_activities = Vec::new();
// Iterate through all registered providers
for provider_name in registry.supported_providers() {
// Check if user is connected to this provider
if let Ok(Some(credentials)) = auth_service
.get_valid_token(user_id, &provider_name, Some(&tenant_id.to_string()))
.await
{
// Create provider instance
if let Some(provider) = registry.create_provider(&provider_name) {
// Set credentials
if provider.set_credentials(credentials).await.is_ok() {
// Fetch activities
if let Ok(activities) = provider.get_activities(Some(50), None).await {
all_activities.extend(activities);
}
}
}
}
}
// Sort by date (most recent first)
all_activities.sort_by(|a, b| b.start_date.cmp(&a.start_date));
// Deduplicate if needed (same activity synced to multiple providers)
all_activities
}Result: Activities from Strava, Garmin, Fitbit, Whoop, Polar all in one unified list!
# Use synthetic provider - no OAuth needed
export PIERRE_DEFAULT_PROVIDER=synthetic# Default to strava
export PIERRE_DEFAULT_PROVIDER=strava
# Configure all active providers
export PIERRE_STRAVA_CLIENT_ID=${STRAVA_CLIENT_ID_SECRET}
export PIERRE_STRAVA_CLIENT_SECRET=${STRAVA_SECRET}
export PIERRE_GARMIN_CLIENT_ID=${GARMIN_KEY}
export PIERRE_GARMIN_CLIENT_SECRET=${GARMIN_SECRET}
export PIERRE_FITBIT_CLIENT_ID=${FITBIT_KEY}
export PIERRE_FITBIT_CLIENT_SECRET=${FITBIT_SECRET}# Test with both synthetic and real provider
export PIERRE_DEFAULT_PROVIDER=synthetic
export PIERRE_STRAVA_CLIENT_ID=test_id
export PIERRE_STRAVA_CLIENT_SECRET=test_secret1 to x providers simultaneously:
- ✅ Register unlimited providers via factory pattern
- ✅ Each provider independently configured via environment variables
- ✅ Runtime discovery via
supported_providers()andis_supported() - ✅ Zero code changes to add/remove providers
- ✅ Tools automatically adapt to available providers
- ✅ Users can connect to all providers at once
- ✅ Data aggregation across multiple providers
- ✅ Synthetic provider for OAuth-free development
Key files:
src/providers/registry.rs- Central registry managing all providerssrc/providers/core.rs-FitnessProvidertrait andProviderFactorytraitsrc/config/environment.rs- Environment-based configuration loadingsrc/protocols/universal/handlers/connections.rs- Dynamic provider discovery
For detailed implementation guide, see Chapter 17.5: Pluggable Provider Architecture.
This document describes Pierre's LLM (Large Language Model) provider abstraction layer, which enables pluggable AI model integration with streaming support for the chat functionality.
The LLM module provides a trait-based abstraction that allows Pierre to integrate with multiple AI providers (Gemini, OpenAI, Ollama, etc.) through a unified interface. The design mirrors the fitness provider SPI pattern for consistency.
┌─────────────────────────────────────────────────────────────────┐
│ LlmProviderRegistry │
│ Manages multiple LLM providers │
└────────────────────────────┬────────────────────────────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Gemini │ │ OpenAI │ │ Ollama │
│ Provider │ │ Provider │ │ Provider │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└──────────────────┴───────────────────┘
│
▼
┌───────────────────────┐
│ LlmProvider Trait │
│ (shared interface) │
└───────────────────────┘
| Variable | Description | Required |
|---|---|---|
GEMINI_API_KEY |
Google Gemini API key | Yes (for Gemini) |
| Model | Description | Default |
|---|---|---|
gemini-2.0-flash-exp |
Latest experimental flash model | ✓ |
gemini-1.5-pro |
Production-ready pro model | |
gemini-1.5-flash |
Fast, efficient model | |
gemini-1.0-pro |
Legacy pro model |
use pierre_mcp_server::llm::{
GeminiProvider, LlmProvider, ChatMessage, ChatRequest,
};
// Create provider from environment variable
let provider = GeminiProvider::from_env()?;
// Build a chat request
let request = ChatRequest::new(vec![
ChatMessage::system("You are a helpful fitness assistant."),
ChatMessage::user("What's a good warm-up routine?"),
])
.with_temperature(0.7)
.with_max_tokens(1000);
// Get a response
let response = provider.complete(&request).await?;
println!("{}", response.content);use futures_util::StreamExt;
let request = ChatRequest::new(vec![
ChatMessage::user("Explain the benefits of interval training"),
])
.with_streaming();
let mut stream = provider.complete_stream(&request).await?;
while let Some(chunk) = stream.next().await {
match chunk {
Ok(chunk) => {
print!("{}", chunk.delta);
if chunk.is_final {
println!("\n[Done]");
}
}
Err(e) => eprintln!("Error: {e}"),
}
}Bitflags indicating provider features:
| Flag | Description |
|---|---|
STREAMING |
Supports streaming responses |
FUNCTION_CALLING |
Supports function/tool calling |
VISION |
Supports image input |
JSON_MODE |
Supports structured JSON output |
SYSTEM_MESSAGES |
Supports system role messages |
// Check capabilities
let caps = provider.capabilities();
if caps.supports_streaming() {
// Use streaming API
}Message structure for conversations:
// Constructor methods
let system = ChatMessage::system("You are helpful");
let user = ChatMessage::user("Hello!");
let assistant = ChatMessage::assistant("Hi there!");Request configuration with builder pattern:
let request = ChatRequest::new(messages)
.with_model("gemini-1.5-pro") // Override default model
.with_temperature(0.7) // 0.0 to 1.0
.with_max_tokens(2000) // Max output tokens
.with_streaming(); // Enable streamingResponse structure:
| Field | Type | Description |
|---|---|---|
content |
String |
Generated text |
model |
String |
Model used |
usage |
Option<TokenUsage> |
Token counts |
finish_reason |
Option<String> |
Why generation stopped |
Streaming chunk structure:
| Field | Type | Description |
|---|---|---|
delta |
String |
Incremental text |
is_final |
bool |
Whether this is the last chunk |
finish_reason |
Option<String> |
Reason if final |
The LlmProviderRegistry manages multiple providers:
use pierre_mcp_server::llm::LlmProviderRegistry;
let mut registry = LlmProviderRegistry::new();
// Register providers
registry.register(Box::new(GeminiProvider::from_env()?));
// registry.register(Box::new(OpenAIProvider::from_env()?));
// Set default
registry.set_default("gemini")?;
// Get provider by name
let provider = registry.get("gemini");
// List all registered
let names: Vec<&str> = registry.list();To implement a new LLM provider:
- Implement the trait:
use async_trait::async_trait;
use pierre_mcp_server::llm::{
LlmProvider, LlmCapabilities, ChatRequest, ChatResponse,
ChatStream, AppError,
};
pub struct MyProvider {
api_key: String,
// ...
}
#[async_trait]
impl LlmProvider for MyProvider {
fn name(&self) -> &'static str {
"myprovider"
}
fn display_name(&self) -> &'static str {
"My Custom Provider"
}
fn capabilities(&self) -> LlmCapabilities {
LlmCapabilities::STREAMING | LlmCapabilities::SYSTEM_MESSAGES
}
fn default_model(&self) -> &'static str {
"my-model-v1"
}
fn available_models(&self) -> &'static [&'static str] {
&["my-model-v1", "my-model-v2"]
}
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, AppError> {
// Implementation
}
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, AppError> {
// Implementation
}
async fn health_check(&self) -> Result<bool, AppError> {
// Implementation
}
}- Register the provider:
registry.register(Box::new(MyProvider::new(api_key)));All provider methods return Result<T, AppError>:
match provider.complete(&request).await {
Ok(response) => println!("{}", response.content),
Err(AppError { code, message, .. }) => {
match code {
ErrorCode::RateLimitExceeded => // Handle rate limit
ErrorCode::AuthenticationFailed => // Handle auth error
_ => // Handle other errors
}
}
}Run LLM-specific tests:
# Unit tests
cargo test --test llm_test
# With output
cargo test --test llm_test -- --nocapture- Chapter 26: LLM Provider Architecture
- Configuration Guide
- Error Reference