Agent API
AI Agents and MCP
Manage agent profiles and call the standards-compliant PrimeCal MCP runtime
PrimeCal separates agent management from runtime tool execution.
Use /api/agents for CRUD, permissions, and keys.
Use MCP JSON-RPC over /api/mcp for tool and resource calls.
Availability Policy
PrimeCal MCP is always available when the backend/MCP process is running with valid configuration.
- No feature flag is used for MCP activation.
- Safety is enforced by agent profile scopes and API keys.
- MCP spec reference: modelcontextprotocol.io/docs/getting-started/intro
Authentication and Permissions
| Surface | Auth model | Notes |
|---|---|---|
/api/agents/* | JWT or user API key | Current user manages their own agents |
/api/mcp | Agent key | MCP runtime requires agent credentials |
Accepted MCP runtime credentials, checked in this order by AgentApiKeyGuard:
x-agent-key: ag_sk_...— static agent API keyx-agent-token: ag_sk_...— static agent API keyAuthorization: Agent ag_sk_...— static agent API keyAuthorization: Bearer ag_sk_...— static agent API key (bearer-prefixed is also accepted)Authorization: Bearer <token>where<token>is notag_sk_-prefixed — treated as an MCP OAuth 2.1 access token and verified as a signed JWT. See MCP OAuth 2.1 and Dynamic Client Registration below.
Static keys and OAuth access tokens resolve to the identical AgentContext
(agent, permissions, owning user) at runtime, so every MCP tool behaves the
same regardless of which credential type authenticated the request.
Unauthenticated calls to /api/mcp receive a 401 with a
WWW-Authenticate: Bearer realm="PrimeCal MCP API", resource_metadata="<origin>/.well-known/oauth-protected-resource"
header, so MCP-spec-compliant clients (RFC 9728) can auto-discover the OAuth
flow instead of failing with no next step.
Endpoint Reference
Agent Management
| Method | Path | Purpose |
|---|---|---|
GET | /api/agents | List current-user agents |
POST | /api/agents | Create an agent |
GET | /api/agents/catalog | Get action catalog and scoping resources |
GET | /api/agents/permission-groups | List built-in MCP permission groups the current user may assign |
GET | /api/agents/:id | Get one agent |
PUT | /api/agents/:id | Update name, description, or status |
DELETE | /api/agents/:id | Disable an agent |
PUT | /api/agents/:id/permissions | Replace permission set |
GET | /api/agents/:id/keys | List keys |
POST | /api/agents/:id/keys | Create key |
DELETE | /api/agents/:id/keys/:keyId | Revoke key |
MCP Runtime (Canonical)
/api/mcp handles MCP JSON-RPC requests over streamable HTTP.
POSTinitialize and method callsGET/DELETEfor MCP transport/session operations- session header:
mcp-session-id
Legacy Compatibility Surface (Deprecated)
Older custom gateway routes may still exist for migration compatibility in some deployments:
/api/mcp/metadata/api/mcp/actions/api/mcp/execute/api/mcp/stream
Treat these as transitional and do not use them for new integrations.
Request Shapes
Agent DTOs
CreateAgentDto:name, optionaldescription, optionalpermissionGroupIdUpdateAgentDto: optionalname,description,statusUpdateAgentPermissionsDto: eitherpermissionGroupIdorpermissions[]UpdateAgentPermissionsDto.permissions[]:actionKey, optionalscopeCreateAgentKeyDto.label: key label
Built-in permissionGroupId values:
minimal- smallest safe planning footprint (profile, context, calendar reads, task reads)all_read_only- read-only access across the full MCP cataloguecalendar_only- calendar list/read/events read/create/update onlytasks_and_routines- full Tasks workspace, labels, checklists, dependencies, and routine templatesreservations_operator- booking-focused: availability, pricing, and reservation createautomation_manager- automation rule reads, audit reads, and trigger, with read-only calendar contexthousehold_manager- household inventory read/write plus task reads for restock follow-upsoperational_read_write- read access everywhere plus operational creates/updates/triggers, no destructive admin actionsfull_mcp_admin- full current MCP runtime action catalogue, requires global admin
PrimeCal treats permission groups as server-defined presets. The runtime still enforces the explicit scoped actions that are materialized for the agent.
MCP Protocol Basics
- Send
initializerequest to/api/mcp. - Read
mcp-session-idresponse header. - Send
notifications/initialized. - Call
tools/list,resources/list,tools/call,resources/readwithmcp-session-id.
People-group action keys
The agent catalog exposes these scoped people-group actions:
| Action key | Risk | Scope notes |
|---|---|---|
calendar.userGroups.list | read | Requires explicit user-group scope. |
calendar.userGroups.details | read | Requires explicit user-group scope. |
calendar.userGroups.createInvite | write | Requires explicit user-group scope and returns privacy-preserving invite output. |
calendar.userGroups.acceptInvite | write | Requires explicit user-group scope for invite acceptance. |
calendar.userGroups.attachCalendars | write | Requires explicit user-group scope and calendar scope. |
calendar.userGroups.detachCalendars | write | Requires explicit user-group scope and calendar scope. |
Scopes are intentionally narrow. An agent cannot operate on groups or calendars outside the selected owner-approved scope, and it cannot exceed the owner user's own rights.
MCP OAuth 2.1 and Dynamic Client Registration
In addition to static agent API keys (above), PrimeCal runs a self-contained
OAuth 2.1 authorization server so any spec-compliant MCP host (Claude,
ChatGPT, or any other RFC 7591-aware client) can add PrimeCal as a connector
with an interactive "sign in and approve" flow, instead of a developer
manually generating and pasting an ag_sk_... key. Static keys are
unaffected and remain the right choice for CLI/scripted tools that can't do
an interactive browser round-trip — OAuth is an additional path, not a
replacement.
Key properties:
- Public clients only. No client secret is ever issued
(
token_endpoint_auth_method: "none"). PKCE withS256is mandatory;plainis rejected. - Authorization codes are single-use with a 60-second TTL.
- Access tokens are signed JWTs, default 1 hour (
OAUTH_ACCESS_TOKEN_TTL, seconds), signed with a secret/issuer/audience (primecal-oauth/primecal-mcp-api) that is entirely separate from PrimeCal's login-session JWT — a leaked MCP access token cannot be replayed against normal/api/*endpoints, and a leaked login token cannot be used against/api/mcp. - Refresh tokens default to 90 days (
OAUTH_REFRESH_TOKEN_TTL, seconds) and rotate on every use — the previous refresh token is immediately revoked. Presenting an already-revoked refresh token is treated as theft: every refresh token for that(client_id, agent)pair is revoked, forcing full re-authorization. - Every approved connection is backed by an ordinary PrimeCal agent
(
GET /api/agentsshows it, withoauthClientIdset), auto-named after the connecting client and provisioned with whichever permission group the user chose on the consent screen. Disabling that agent (DELETE /api/agents/:id) revokes its API key and every outstanding refresh token. Re-approving the same client after a revoke reactivates the same agent instead of creating a duplicate.
See OAuth Authorization Server (MCP) for the full server-side architecture and design rationale, and Connecting via OAuth for the end-user flow.
Discovery Endpoints
Public, unauthenticated, used by MCP hosts to auto-configure the flow:
curl "$PRIMECAL_API/.well-known/oauth-protected-resource"
{
"resource": "https://api.primecal.eu/api/mcp",
"authorization_servers": ["https://api.primecal.eu"],
"bearer_methods_supported": ["header"],
"resource_documentation": "https://api.primecal.eu/api/mcp"
}
curl "$PRIMECAL_API/.well-known/oauth-authorization-server"
{
"issuer": "https://api.primecal.eu",
"authorization_endpoint": "https://api.primecal.eu/api/oauth/authorize",
"token_endpoint": "https://api.primecal.eu/api/oauth/token",
"registration_endpoint": "https://api.primecal.eu/api/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"scopes_supported": ["mcp:read", "mcp:write"]
}
OAuth Endpoint Reference
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | /api/oauth/register | none (public) | RFC 7591 Dynamic Client Registration |
GET | /api/oauth/authorize | none (public) | Spec-mandated entry point; 302s to the SPA's /oauth/consent page |
GET | /api/oauth/consent-details | JWT (login session) | Returns client display info and the permission groups the signed-in user may grant |
POST | /api/oauth/consent | JWT (login session) | Approve or deny; returns a redirectUrl carrying the authorization code (or error=access_denied) |
POST | /api/oauth/token | none (public, PKCE-verified) | Exchanges a code, or rotates a refresh token, for a token pair |
Register a Client (RFC 7591)
curl -X POST "$PRIMECAL_API/api/oauth/register" \
-H "Content-Type: application/json" \
-d '{
"client_name": "My MCP Host",
"client_description": "Daily planning assistant",
"redirect_uris": ["http://127.0.0.1:5173/callback"]
}'
Request fields (RegisterClientDto): client_name (required, max 200
chars), client_description (optional), client_logo_url (optional, must
be an absolute URL), client_homepage_url (optional, must be an absolute
URL), redirect_uris (required, 1-10 entries). Each redirect_uri must be
https://, or http://localhost / http://127.0.0.1 for local development
tools — registered exactly and matched exactly at authorize/token time, no
wildcards.
Response:
{
"client_id": "5b2e2b7a-1f0a-4e9a-9d3a-7a2f7c8e9a10",
"client_name": "My MCP Host",
"client_description": "Daily planning assistant",
"redirect_uris": ["http://127.0.0.1:5173/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"]
}
No client_secret is returned — this is a public client.
Authorize (Browser Step)
GET /api/oauth/authorize
?response_type=code
&client_id=<client_id>
&redirect_uri=<one of the registered redirect_uris, URL-encoded>
&code_challenge=<base64url(sha256(code_verifier))>
&code_challenge_method=S256
&state=<opaque value, echoed back>
&scope=<optional>
This must be opened in a browser (it 302s to PrimeCal's login/consent UI,
OAuthConsentPage.tsx) — it is not a machine-callable JSON endpoint.
code_challenge_method must be S256; plain is rejected by request
validation.
The consent UI itself calls two JWT-authenticated endpoints on behalf of the signed-in user:
curl "$PRIMECAL_API/api/oauth/consent-details?client_id=<client_id>&redirect_uri=<redirect_uri>&code_challenge=<code_challenge>&code_challenge_method=S256&response_type=code" \
-H "Authorization: Bearer $LOGIN_JWT"
{
"clientId": "5b2e2b7a-1f0a-4e9a-9d3a-7a2f7c8e9a10",
"clientName": "My MCP Host",
"clientDescription": "Daily planning assistant",
"clientLogoUrl": null,
"clientHomepageUrl": null,
"redirectUri": "http://127.0.0.1:5173/callback",
"availablePermissionGroups": [
{
"id": "minimal",
"label": "Minimal",
"description": "Smallest safe planning footprint: profile, context, calendars, and task reads.",
"actionKeys": ["user.profile.read", "..."],
"canAssign": true,
"requiresGlobalAdmin": false,
"highlights": ["profile", "context", "calendar reads", "task reads"]
}
]
}
availablePermissionGroups is pre-filtered to canAssign: true — a
non-admin user never sees full_mcp_admin in the list.
curl -X POST "$PRIMECAL_API/api/oauth/consent" \
-H "Authorization: Bearer $LOGIN_JWT" \
-H "Content-Type: application/json" \
-d '{
"clientId": "5b2e2b7a-1f0a-4e9a-9d3a-7a2f7c8e9a10",
"redirectUri": "http://127.0.0.1:5173/callback",
"state": "xyz",
"codeChallenge": "<code_challenge>",
"approve": true,
"permissionGroupId": "all_read_only"
}'
Response: { "redirectUrl": "http://127.0.0.1:5173/callback?code=...&state=xyz" }
(or ?error=access_denied&state=xyz on deny, or on an unassignable
permissionGroupId). The frontend navigates the browser to this URL, which
hands control back to the client.
Exchange the Code for Tokens
curl -X POST "$PRIMECAL_API/api/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"client_id": "5b2e2b7a-1f0a-4e9a-9d3a-7a2f7c8e9a10",
"code": "<code from the redirect>",
"redirect_uri": "http://127.0.0.1:5173/callback",
"code_verifier": "<the original PKCE verifier>"
}'
Response (TokenResponse):
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "3nR0...",
"scope": "all_read_only"
}
Call /api/mcp With the Access Token
curl -X POST "$PRIMECAL_API/api/mcp" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": { "name": "my-mcp-host", "version": "1.0.0" }
}
}'
From here, follow the same tools/list / tools/call flow shown in
MCP Protocol Basics above — OAuth access tokens and
static keys behave identically once authenticated.
Refresh When the Access Token Expires
curl -X POST "$PRIMECAL_API/api/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"client_id": "5b2e2b7a-1f0a-4e9a-9d3a-7a2f7c8e9a10",
"refresh_token": "3nR0..."
}'
Returns a fresh { access_token, refresh_token, ... } pair and revokes the
refresh token that was just used. Store the new refresh_token and discard
the old one — reusing it will fail with invalid_grant and revoke the
entire token family for this client/agent pair.
Token Endpoint Errors
OAuthGrantError responses are RFC 6749 §5.2-shaped (not PrimeCal's normal
{success:false, error:{...}} envelope), HTTP 400:
{ "error": "invalid_grant", "error_description": "Authorization code has already been used." }
error is one of invalid_grant, invalid_request, invalid_client, or
unsupported_grant_type.
Example Calls
Create an agent
curl -X POST "$PRIMECAL_API/api/agents" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "May.B.Late Chrome MCP",
"description": "Daily planning assistant for Chrome MCP",
"permissionGroupId": "all_read_only"
}'
List permission groups
curl "$PRIMECAL_API/api/agents/permission-groups" \
-H "Authorization: Bearer $TOKEN"
Example response excerpt:
[
{
"id": "all_read_only",
"label": "All read-only",
"description": "Read-only access across the full MCP catalogue without writes, deletes, or triggers.",
"canAssign": true
}
]
Apply a built-in permission group
curl -X PUT "$PRIMECAL_API/api/agents/9/permissions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"permissionGroupId": "all_read_only"
}'
Replace permissions with a fully custom set
curl -X PUT "$PRIMECAL_API/api/agents/9/permissions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"permissions": [
{ "actionKey": "calendar.list", "scope": { "calendarIds": [5, 7, 8] } },
{ "actionKey": "calendar.events.read", "scope": { "calendarIds": [5, 7, 8] } },
{ "actionKey": "calendar.userGroups.list", "scope": { "groupIds": [9] } },
{ "actionKey": "calendar.userGroups.attachCalendars", "scope": { "groupIds": [9], "calendarIds": [5] } },
{ "actionKey": "tasks.list" },
{ "actionKey": "tasks.create" },
{ "actionKey": "user.profile.read" }
]
}'
When permissionGroupId is present, do not also send permissions[] in the same request. Use one model or the other.
Initialize MCP session
curl -i -X POST "$PRIMECAL_API/api/mcp" \
-H "Authorization: Agent $AGENT_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-05",
"capabilities": {},
"clientInfo": { "name": "primecal-docs-example", "version": "1.0.0" }
}
}'
List tools (after initialize)
curl -X POST "$PRIMECAL_API/api/mcp" \
-H "Authorization: Agent $AGENT_KEY" \
-H "mcp-session-id: $MCP_SESSION_ID" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}'
Best Practices
- Prefer
minimalorall_read_onlyfor new hosts. - Keep agent scopes narrow and host-specific.
- Rotate keys on host migration or suspected leakage.
- Validate with
primecal_profile_getbefore write operations. - Use MCP
tools/listas the source of truth for available capabilities. - For interactive hosts that support it, prefer OAuth over a manually-pasted static key — users can revoke access from Agent Settings without ever sharing a secret, and access tokens expire on their own.
- Store refresh tokens as sensitively as a static
ag_sk_...key; treat any unexpectedinvalid_grantfrom/api/oauth/tokenas a signal to re-authorize rather than retry.