Skip to main content
Was this helpful?

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.

JWT for managementAgent key for MCP runtimeScope-aware permissionsMCP standard surface

Availability Policy

PrimeCal MCP is always available when the backend/MCP process is running with valid configuration.

Authentication and Permissions

SurfaceAuth modelNotes
/api/agents/*JWT or user API keyCurrent user manages their own agents
/api/mcpAgent keyMCP runtime requires agent credentials

Accepted MCP runtime credentials, checked in this order by AgentApiKeyGuard:

  • x-agent-key: ag_sk_... — static agent API key
  • x-agent-token: ag_sk_... — static agent API key
  • Authorization: Agent ag_sk_... — static agent API key
  • Authorization: Bearer ag_sk_... — static agent API key (bearer-prefixed is also accepted)
  • Authorization: Bearer <token> where <token> is not ag_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

MethodPathPurpose
GET/api/agentsList current-user agents
POST/api/agentsCreate an agent
GET/api/agents/catalogGet action catalog and scoping resources
GET/api/agents/permission-groupsList built-in MCP permission groups the current user may assign
GET/api/agents/:idGet one agent
PUT/api/agents/:idUpdate name, description, or status
DELETE/api/agents/:idDisable an agent
PUT/api/agents/:id/permissionsReplace permission set
GET/api/agents/:id/keysList keys
POST/api/agents/:id/keysCreate key
DELETE/api/agents/:id/keys/:keyIdRevoke key

MCP Runtime (Canonical)

/api/mcp handles MCP JSON-RPC requests over streamable HTTP.

  • POST initialize and method calls
  • GET/DELETE for 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, optional description, optional permissionGroupId
  • UpdateAgentDto: optional name, description, status
  • UpdateAgentPermissionsDto: either permissionGroupId or permissions[]
  • UpdateAgentPermissionsDto.permissions[]: actionKey, optional scope
  • CreateAgentKeyDto.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 catalogue
  • calendar_only - calendar list/read/events read/create/update only
  • tasks_and_routines - full Tasks workspace, labels, checklists, dependencies, and routine templates
  • reservations_operator - booking-focused: availability, pricing, and reservation create
  • automation_manager - automation rule reads, audit reads, and trigger, with read-only calendar context
  • household_manager - household inventory read/write plus task reads for restock follow-ups
  • operational_read_write - read access everywhere plus operational creates/updates/triggers, no destructive admin actions
  • full_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

  1. Send initialize request to /api/mcp.
  2. Read mcp-session-id response header.
  3. Send notifications/initialized.
  4. Call tools/list, resources/list, tools/call, resources/read with mcp-session-id.

People-group action keys

The agent catalog exposes these scoped people-group actions:

Action keyRiskScope notes
calendar.userGroups.listreadRequires explicit user-group scope.
calendar.userGroups.detailsreadRequires explicit user-group scope.
calendar.userGroups.createInvitewriteRequires explicit user-group scope and returns privacy-preserving invite output.
calendar.userGroups.acceptInvitewriteRequires explicit user-group scope for invite acceptance.
calendar.userGroups.attachCalendarswriteRequires explicit user-group scope and calendar scope.
calendar.userGroups.detachCalendarswriteRequires 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 with S256 is mandatory; plain is 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/agents shows it, with oauthClientId set), 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

MethodPathAuthPurpose
POST/api/oauth/registernone (public)RFC 7591 Dynamic Client Registration
GET/api/oauth/authorizenone (public)Spec-mandated entry point; 302s to the SPA's /oauth/consent page
GET/api/oauth/consent-detailsJWT (login session)Returns client display info and the permission groups the signed-in user may grant
POST/api/oauth/consentJWT (login session)Approve or deny; returns a redirectUrl carrying the authorization code (or error=access_denied)
POST/api/oauth/tokennone (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 minimal or all_read_only for new hosts.
  • Keep agent scopes narrow and host-specific.
  • Rotate keys on host migration or suspected leakage.
  • Validate with primecal_profile_get before write operations.
  • Use MCP tools/list as 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 unexpected invalid_grant from /api/oauth/token as a signal to re-authorize rather than retry.