For agents
This page is addressed to a model reading this raw file, unrendered — the machine path, with no human detour. Every example below was replayed against a real PalmaPlan server (not copied from a specification); the identifiers and tokens are those of that trial, to be replaced with your own.
1. Discovering the server
A call to /mcp with no token says where to go:
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://<host>/.well-known/oauth-protected-resource"
{"type":"https://palmaplan.dev/errors/UNAUTHORIZED","title":"/mcp only accepts a Bearer: a named token from Settings › Connections and agents, missing, invalid or revoked","status":401,"code":"UNAUTHORIZED"}
GET the address carried by resource_metadata gives the resource and its issuer:
{
"resource": "http://<host>/mcp",
"authorization_servers": ["http://<host>"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"]
}
GET /.well-known/oauth-authorization-server on that issuer gives the OAuth endpoints (authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint) — useful if you follow path 2b below.
2. Getting a Bearer
Two paths, depending on whether a human is in the loop.
2a. A token already given (the shortest path). A human creates a named token from Settings › Connections and agents › Advanced access (scope read or write) and hands it to you — as an environment variable, for example. No OAuth round trip is needed: this token is used directly as Authorization: Bearer pp_… on /mcp. This is the path stdio takes (Claude Desktop), and it remains available over HTTP.
2b. Dynamic registration + OAuth 2.1 (the autonomous path). You register yourself (POST /oauth/register, RFC 7591: client_name, redirect_uris), then build an authorization address with mandatory PKCE S256 and resource equal to <host>/mcp (RFC 8707). A human must open this address and click "Authorize" — this step doesn't automate, by design: it's the consent of the person who owns the data. See Connecting Claude Code for the full, exactly-replayed sequence. The code obtained is exchanged for a named token (POST /oauth/token).
In both cases, the result is the same: a pp_… token, with read and/or write scopes (detail).
3. Opening an MCP session
initialize, with the Bearer:
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer pp_…
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}
The response is in SSE format (Content-Type: text/event-stream), even for a single message — expect event: message\ndata: {…}\n\n, not raw JSON. It also carries the Mcp-Session-Id header: keep it and send it back on every following call.
200 OK
Mcp-Session-Id: 01a0dd94-a733-790d-bb2c-0cc7dad419b3
Content-Type: text/event-stream
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"palmaplan","version":"0.27.0"}},"jsonrpc":"2.0","id":1}
serverInfo.version is the version of the instance answering you: a different number is not an error. This capture was taken against a server running 0.15.0; that field is the only one a release changes, and it is retouched at every closure rather than leaving the page announce a stale version — at every closure since 0.20.0; it reads 0.27.0 here today. The rest of the capture is untouched. Since 0.21.0 a test enforces it (scripts/site-versions.test.ts): the numbers guard sweeps docs/ too.
4. Building a schedule
tools/call, with the same Bearer and the Mcp-Session-Id received in step 3:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_schedule","arguments":{"name":"Agent showcase"}}}
{"result":{"content":[{"type":"text","text":"Schedule \"Agent showcase\" created.\nid: 01a0dd9c-…"}],"structuredContent":{"schedule":"01a0dd9c-…","seq":0}},"jsonrpc":"2.0","id":2}
A fresh schedule is born with a "Phase 1" group and three empty rows — usable directly by its label, with no need to re-read the schedule to find its identifier: anywhere PalmaPlan expects an identifier, a name works. To place a row and a block:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_rows","arguments":{"schedule":"Agent showcase","group":"Phase 1","rows":[{"periods":[{"start":"2026-10-05","duration":5,"label":"Scoping"}]}]}}}
{"result":{"content":[{"type":"text","text":"1 row added, 1 block.\nids: 01a0dd9c-…"}],"structuredContent":{"dry_run":false,"seq":2,"applied":[…],"effects":[…],"warnings":[]}},"jsonrpc":"2.0","id":3}
For a whole schedule in a single call rather than row by row, see build_schedule in the generated reference — the tool the plan_project prompt recommends.
5. Reading a schedule
By a tool — the full view, in readable Markdown:
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_schedule","arguments":{"schedule":"Agent showcase"}}}
Or by a resource — the same data, addressable with no tool call:
{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"palmaplan://schedule/01a0dd9c-…"}}
palmaplan://schedule/{id}/json renders the same thing as normalized JSON, for exact processing rather than reading. palmaplan://schedules (with no identifier) lists every schedule accessible to this token.
6. Absences and risks (S22)
An absence belongs to a person, not a schedule: posted once, it holds across every schedule that person is a member of, and it never moves a block — it flags a risk. The reason is one of conges, temps_partiel, formation, autre (congés, part-time, training, other): never a medical reason, never a free-text field — the schema refuses both (health data is a special category under GDPR article 9, and a scheduling tool only needs to know that you're out, never why).
First, link a row to the person who works it — set_row_member accepts "me", a name, an email, or an id (list_members gives those):
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"set_row_member","arguments":{"schedule":"Refonte","row":"2.1","member":"anne@x.test"}}}
{"result":{"content":[{"type":"text","text":"ligne 2.1 reliée à Anne.\nrow: 01a0f9e5-…"}],"structuredContent":{"dry_run":false,"seq":4,"applied":[…],"row":"01a0f9e5-…","member_id":"01a0f9e5-…"}},"jsonrpc":"2.0","id":6}
A schedule's members' absences are read with list_absences — the same visibility as the screen's Agenda, through the same route (GET /schedules/:id/absences): a stranger or a former member sees nothing there, even one a row or an action still names:
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_absences","arguments":{"schedule":"Refonte"}}}
{"result":{"content":[{"type":"text","text":"Absences des membres de ce planning :\nAnne :\n - Congés — le 2026-08-19\n id: 01a0f9e5-…"}],"structuredContent":{"schedule_id":"01a0f9e5-…","members":[{"user_id":"01a0f9e5-…","name":"Xavier","role":"owner"},{"user_id":"01a0f9e5-…","name":"Anne","role":"editor"}],"absences":[{"id":"01a0f9e5-…","user_id":"01a0f9e5-…","motif":"conges","forme":{"kind":"periode","date_from":"2026-08-19","date_to":"2026-08-19","debut":"matin","fin":"apres_midi"},"created_at":"2026-07-01T00:00:00.000Z","updated_at":"2026-07-01T00:00:00.000Z"}],"linked":[{"user_id":"01a0f9e5-…","current":true,"rows":["01a0f9e5-…"],"actions":[]}]}},"jsonrpc":"2.0","id":7}
Without schedule, list_absences returns your own (/me/absences); add_absence posts one — a period (a day, a half-day, or a long stretch) or recurring days (every Friday, every other Friday…) — and remove_absence takes one off, by its id.
Finally, get_schedule_risks says which blocks are at risk — the same calculation, the same list as the Risks screen (risquesDAbsence, in the core, which knows neither accounts nor the database):
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"get_schedule_risks","arguments":{"schedule":"Refonte"}}}
{"result":{"content":[{"type":"text","text":"1 risque :\n- « Tests » — Anne — 1 j ouvré sur 5 (partiel), du 2026-08-19 au 2026-08-19 (ligne reliée)\n period: 01a0f9e5-…"}],"structuredContent":{"schedule":"01a0f9e5-…","risks":[{"period_id":"01a0f9e5-…","user_id":"01a0f9e5-…","source":"ligne","jours_ouvres_absents":1,"jours_ouvres_total":5,"partiel":true,"premier_jour":"2026-08-19","dernier_jour":"2026-08-19"}],"lignes_membre_retire":[]}},"jsonrpc":"2.0","id":8}
A block is at risk as soon as at least one working day of its span falls on the absence of someone who works it — through the linked row, or through an action assigned to them (add_action { assignee }). jours_ouvres_absents counts in half-days (0.5 for an absence covering only one half of a day); partiel is only false when the absence covers the whole block. A row linked to someone who is no longer a member of the schedule shows up separately, in lignes_membre_retire, with no date at all — a stale link never leaks an absence.
7. The kanban: what's waiting for me (S23)
list_my_actions returns what's assigned to you — across every schedule —, grouped by schedule: no need to know a schedule id to find out what's waiting for you on a Monday morning.
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"list_my_actions","arguments":{}}}
{"result":{"content":[{"type":"text","text":"# Mes actions\n\n1 action sur 1 planning :\n\n## Suivi agents\n- ○ Rediger le plan — Cadrage (01a0fb2a-…)\n\nas_of: 2026-10-02T05:51:20.153Z"}],"structuredContent":{"as_of":"2026-10-02T05:51:20.153Z","schedules":[{"id":"01a0fb2a-…","name":"Suivi agents","actions":[{"action":{"id":"01a0fb2a-…","period_id":"01a0fb2a-…","title":"Rediger le plan","status":"todo","assignee":{"kind":"user","user_id":"01a0fb2a-…"}},"period_label":"Cadrage","change":null}]}],"removed":[],"since_unknown":[]}},"jsonrpc":"2.0","id":9}
as_of is the "since my last visit" loop: pass it back as since on the next call. With since, the same response also carries, per action, change ("new", "updated", or null), and a removed array — every action that used to be yours and no longer is, with its reason spelled out (reassigned, unassigned, deleted, access_lost), never a silent disappearance.
An action's history — who, when, and time spent in each column, read from the existing journal, no new storage:
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"get_action_history","arguments":{"schedule":"Suivi agents","action":"Rediger le plan"}}}
{"result":{"content":[{"type":"text","text":"# Histoire de « Rediger le plan »\n\n- 2026-10-02 05:51 · Xavier · créée\n- 2026-10-02 05:51 · Xavier · passée de à faire à en cours\n\nTemps passé : < 1 h à faire, < 1 h en cours, 0 h fait.\nActuellement en cours depuis le 2026-10-02 05:51."}],"structuredContent":{"action_id":"01a0fb2a-…","events":[{"kind":"created","who":{"name":"Xavier"}},{"kind":"status","from":"todo","to":"doing","who":{"name":"Xavier"}}],"columns":{"todo_ms":20,"doing_ms":11,"done_ms":0,"current":"doing","since":"2026-10-02T05:51:20.160Z"}}},"jsonrpc":"2.0","id":10}
update_action moves an action to another block with period — the progress of both blocks (the one it leaves, the one it joins) follows, derived from their actions:
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"update_action","arguments":{"schedule":"Suivi agents","action":"Rediger le plan","period":"Tests"}}}
{"result":{"content":[{"type":"text","text":"Action « Rediger le plan » modifiée — responsable : vous : « Tests » à 0 % (0/1) ; « Cadrage » n a plus d action : son avancement (0 %) se règle à nouveau à la main.\nid: 01a0fb2a-…"}],"structuredContent":{"applied":[{"type":"action.move","payload":{"id":"01a0fb2a-…","period_id":"01a0fb2a-…"}}],"progress":0}},"jsonrpc":"2.0","id":11}
get_action_alerts says what's stuck: an unfinished action whose block is over (en_retard), an "in progress" action that hasn't moved in idle_days days (immobile, seven by default), an action with no owner in a block that has already started (sans_responsable), or one assigned to someone who's out during its block (absent) — an action can carry more than one alert at once. Without schedule, all of yours across every schedule you can read; with one, a single schedule's:
{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"get_action_alerts","arguments":{"schedule":"Suivi agents"}}}
{"result":{"content":[{"type":"text","text":"# Ce qui coince\n\n2 alertes :\n- ⏰ « Sans responsable » (Cadrage) — en retard de 4 j ouvrés (bloc terminé le 2026-09-28)\n- ❓ « Sans responsable » (Cadrage) — personne n’en est responsable"}],"structuredContent":{"alerts":[{"sorte":"en_retard","depuis":"2026-09-28","jours":4,"title":"Sans responsable","period_label":"Cadrage"},{"sorte":"sans_responsable","title":"Sans responsable","period_label":"Cadrage"}],"idle_unknown":[]}},"jsonrpc":"2.0","id":12}
get_kanban_status returns the columns, what moved this week (who, from what to what, when), and the same alerts — one call instead of piecing it together yourself:
{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"get_kanban_status","arguments":{"schedule":"Suivi agents"}}}
{"result":{"content":[{"type":"text","text":"# État du kanban\n\n○ à faire : 1 · ◐ en cours : 1 · ✓ fait : 0\n\n## Cette semaine\n- 2026-10-02 05:51 · Xavier · « Rediger le plan » : à faire → en cours\n\n## Ce qui coince\n- ⏰ « Sans responsable » (Cadrage) — en retard de 4 j ouvrés (bloc terminé le 2026-09-28)\n- ❓ « Sans responsable » (Cadrage) — personne n’en est responsable"}],"structuredContent":{"columns":{"todo":1,"doing":1,"done":0},"moved_this_week":[{"title":"Rediger le plan","from":"todo","to":"doing","who":{"name":"Xavier"}}]}},"jsonrpc":"2.0","id":13}
Finally, palmaplan://me/actions is the same view as a resource — addressable without a tool call, and subscribable (resources/subscribe): on subscribing, the server watches every schedule you can read, and sends resources/updated as soon as one of them sees one of your actions change.
{"jsonrpc":"2.0","id":14,"method":"resources/read","params":{"uri":"palmaplan://me/actions"}}
{"result":{"contents":[{"uri":"palmaplan://me/actions","mimeType":"text/markdown","text":"# Mes actions\n\n1 action sur 1 planning :\n\n## Suivi agents\n- ◐ Rediger le plan — Tests (01a0fb2a-…)"}]},"jsonrpc":"2.0","id":14}
A limit worth knowing: the list of watched schedules is taken at subscription time. A schedule shared with you afterward is only picked up after you resubscribe (resources/unsubscribe then resources/subscribe again) — never sooner, never automatically.
What comes next
- The full reference of tools, resources and prompts, generated from this same server — every tool, its parameters, required or not.
- Tokens and scopes — what
readandwriteallow, and what no token allows. - The embedded view — to show the schedule rather than describe it.