Overview ¶
The MCP server lives at POST /mcp/board and speaks JSON-RPC 2.0 over HTTP (with SSE for streamed responses). Every call has two identities: the agent (a per-workspace service user authenticated by bearer token) and the actor (the human the agent is acting on behalf of, identified by phone).
The bearer token says “I am Acme’s JAVIS”. The X-Acting-User-Phone header says “and I’m doing this for User A.”
The server checks (a) the bearer is a service user attached to a workspace as agent, (b) the actor phone resolves to a user who is a member of that workspace, and (c) any board/card referenced lives in that same workspace. Then it executes the tool under the actor’s authorization, marking the resulting record with source = agent.
Why MCP and not REST
Tools self-describe. The agent fetches the tool catalog at runtime — names, descriptions, JSON schemas, semantics — instead of having a contract hard-coded into JAVIS code. The same server is also reachable from Claude Desktop, Cursor, or any other MCP-capable client without further work.
Quickstart ¶
Provision a token for one workspace and call the smoke-test tool.
1. Provision JAVIS for a workspace
From the admin panel: Admin → Manage Workspaces. On the workspace row, click Provision JAVIS. The page creates the workspace’s service user (if missing), assigns the ws.agent role, rotates the Sanctum token, and shows the plaintext token once in a persistent notification. Copy it immediately.
Lost a token? Click Provision JAVIS again — the existing token will be revoked and a fresh one issued. Existing card history and audit rows are preserved.
2. Smoke-test the token
Call whoami-tool first — it’s the cheapest probe and needs only the bearer token. If the token is valid, you’ll get back the agent identity and the workspace it’s bound to.
# Replace TOKEN and BASE_URL with your values. No phone header needed. curl -s -X POST "$BASE_URL/mcp/board" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{ "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "whoami-tool", "arguments": {} }, "id": 1 }'
A successful response contains agent, workspace, and actor: null (since we didn’t send a phone header).
3. Make a real call on behalf of a user
Once whoami-tool works, add X-Acting-User-Phone for any tool that touches workspace data:
# PHONE is the E.164 phone of the human you're acting on behalf of. curl -s -X POST "$BASE_URL/mcp/board" \ -H "Authorization: Bearer $TOKEN" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -H "X-Acting-User-Phone: $PHONE" \ -d '{ "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "list-boards-tool", "arguments": {} }, "id": 2 }'
4. Verify the audit row
Every successful or denied call writes a row to agent_call_audit. Inspect it from the admin agent activity feed or directly via SQL:
SELECT id, agent_user_id, actor_user_id, tool, result, error_code, duration_ms, created_at FROM agent_call_audit ORDER BY id DESC LIMIT 5;
Authentication ¶
Headers
| Header | Purpose |
|---|---|
| Authorization: Bearer … | Sanctum token issued for the workspace JAVIS service user. Has the ability agent. |
| Accept: application/json | Required to opt into JSON 401 responses (otherwise Laravel redirects). |
| Content-Type: application/json | JSON-RPC body. |
| X-Acting-User-Phone | E.164 phone of the human on whose behalf the agent acts. Required for every mutating tool (creates, updates, archives, deletes, comments, assignments). Optional for the read tools and whoami-tool — sending it just records the actor in the audit row. Auto-provisions a new users row if the phone is unknown (since the agent has is_service=true). |
| X-Acting-User-Name | Optional. Display name to use when auto-provisioning a new user from an unknown phone. |
What the server checks, in order
- Sanctum: bearer token resolves to an existing user.
- The token-holding user has
is_service = true. - That user is attached to exactly one workspace via the
workspace_memberspivot withrole = 'agent'. That workspace is the “agent workspace”. - The acting-user phone resolves to a real user (or, if missing, is auto-created with
is_service=false). - The actor is a member of the agent’s workspace.
- Any
board_id/card_id/list_idin the request belongs to that workspace. - The actor’s role grants the specific permission the tool requires — one verb per operation. Examples:
card.create,card.archive,card.complete,card.assign,card.label,card.move,list.create,list.unarchive,label.create,label.delete,custom_field.create,card.custom_field.set,attachment.create,attachment.delete,dependency.add,reminder.cancel,comment.create,comment.edit(own),comment.delete(own),agent.webhook.subscribe,digest.set. The full role × permission matrix lives indatabase/seeders/RolesAndPermissionsSeeder.php.
If any check fails, the tool returns a JSON-RPC isError: true with a human-readable message and an audit row is written with result = 'denied' and error_code = 'forbidden'.
Roles and the permission matrix
Workspace members are assigned one of five tiered roles via the workspace_members.role pivot. Each role grants a different slice of the verb catalog:
owner— everything, includingworkspace.deleteandworkspace.billing.admin— full board/card/list/label/custom-field/webhook management; cannot delete the workspace or touch billing.member— day-to-day operational work (create/edit cards, manage checklists/attachments/dependencies, watch, comment). Cannot manage labels, custom-field schemas, member roles, or agent webhooks; cannot bulk-edit, hard-delete cards, or share boards publicly.guest— comment only, plus edit/delete their own comments.agent— mirrors admin for operational verbs but is further constrained by the per-board agent allowlist (thescope_okcontext flag set byBoardToolonce the board is confirmed in-workspace). Agents cannot invite, change member roles, delete the workspace, or touch billing.
Tools ¶
All tools are listed here grouped by category. Names use the kebab-case convention Laravel/MCP derives from the class name (CreateCardTool → create-card-tool).
Sanity bearer token only
| Tool | Arguments | Permission |
|---|---|---|
| whoami-tool | (none) | bearer only |
Returns agent, workspace, and server_time using just the Sanctum token. actor is included only if X-Acting-User-Phone is sent; otherwise it’s null. Use this at boot to verify the token resolves to the correct workspace before issuing any acting-user calls.
Read bearer token only
Read tools are scoped to the agent’s workspace and accept the bearer token alone — X-Acting-User-Phone is optional. If you send it, the audit row records the actor; otherwise the agent itself is recorded.
| Tool | Arguments | Permission |
|---|---|---|
| list-boards-tool | (none) | bearer only |
| get-board-tool | board_id |
bearer only |
| list-cards-tool | board_id, list_id?, assigned_to_user_id?, include_archived?, limit? |
bearer only |
| get-card-tool | card_id |
bearer only |
| search-cards-tool | board_id, query, limit? |
bearer only |
| list-card-reminders-tool | card_id, status? |
bearer only |
Board mutations requires board.create / board.update / board.archive
| Tool | Arguments | Permission |
|---|---|---|
| create-board-tool | name, external_ref?, color?, visibility?, initial_lists? |
board.create |
| update-board-tool | board_id + any of name, color, visibility, external_ref |
board.update (or board.external_ref when external_ref is in the patch) |
| archive-board-tool | board_id, restore? |
board.archive (board.unarchive when restore=true) |
| find-or-create-board-by-external-ref-tool | external_ref, name, color?, visibility?, initial_lists? |
board.create (only on create) |
Card mutations requires specific card.* / comment.* verb
| Tool | Arguments | Permission |
|---|---|---|
| create-card-tool | board_id, list_id, title, description?, due_at?, due_reminder_minutes?, assignee_user_ids?, assignee_phones?, label_ids? |
card.create |
| update-card-tool | card_id + any of title, description, due_at, due_reminder_minutes, cover_color, cover_label |
card.edit |
| move-card-tool | card_id, target_list_id, target_board_id?, position? |
card.move |
| assign-member-tool | card_id, (user_id | user_phone), action = add | remove |
card.assign |
| archive-card-tool | card_id |
card.archive |
| restore-card-tool | card_id |
card.unarchive |
| comment-on-card-tool | card_id, body |
comment.create |
| complete-card-tool | card_id, completed? |
card.complete |
Checklists requires checklist.* / checklist_item.*
| Tool | Arguments | Permission |
|---|---|---|
| create-checklist-tool | card_id, title |
checklist.create |
| add-checklist-item-tool | checklist_id, body |
checklist_item.create |
| complete-checklist-item-tool | item_id, completed |
checklist_item.update |
Reminders requires reminder.cancel
| Tool | Arguments | Permission |
|---|---|---|
| cancel-card-reminder-tool | dispatch_id?, card_id?, type? |
reminder.cancel |
List structural requires list.* permission
| Tool | Arguments | Permission |
|---|---|---|
| create-list-tool | board_id, name, position? |
list.create |
| rename-list-tool | list_id, name |
list.edit |
| archive-list-tool | list_id, restore? |
list.archive |
| reorder-lists-tool | board_id, ordered_list_ids[] |
list.reorder |
Labels requires label.create / .update / .delete
| Tool | Arguments | Permission |
|---|---|---|
| list-labels-tool | board_id |
bearer only |
| create-label-tool | board_id, name, color |
label.create |
| update-label-tool | label_id, name?, color? |
label.update |
| delete-label-tool | label_id |
label.delete |
Attachments requires attachment.delete
| Tool | Arguments | Permission |
|---|---|---|
| list-card-attachments-tool | card_id |
bearer only |
| delete-attachment-tool | attachment_id |
attachment.delete |
Card dependencies requires dependency.add / .remove
| Tool | Arguments | Permission |
|---|---|---|
| add-card-dependency-tool | blocker_card_id, blocked_card_id |
dependency.add |
| remove-card-dependency-tool | dependency_id? | (blocker_card_id, blocked_card_id) |
dependency.remove |
| list-card-dependencies-tool | card_id |
bearer only |
Custom fields requires custom_field.* / card.custom_field.set
| Tool | Arguments | Permission |
|---|---|---|
| list-board-custom-fields-tool | board_id |
bearer only |
| create-board-custom-field-tool | board_id, name, type, options? |
custom_field.create |
| delete-board-custom-field-tool | field_id |
custom_field.delete |
| set-card-custom-field-tool | card_id, field_id, value? |
card.custom_field.set |
Agent webhooks requires agent.webhook.*
| Tool | Arguments | Permission |
|---|---|---|
| subscribe-agent-event-tool | event, target_url |
agent.webhook.subscribe |
| unsubscribe-agent-event-tool | subscription_id? | (event, target_url) |
agent.webhook.unsubscribe |
| list-agent-event-subscriptions-tool | (none) | agent.webhook.list |
Personal digest requires digest.set
| Tool | Arguments | Permission |
|---|---|---|
| set-digest-schedule-tool | frequency, hour_of_day?, day_of_week?, timezone?, active? |
digest.set |
Smart ops utility / card.bulk_edit
| Tool | Arguments | Permission |
|---|---|---|
| parse-due-date-tool | phrase, timezone? |
bearer only |
| bulk-update-cards-tool | filter, patch |
card.bulk_edit |
Analytics read-only
| Tool | Arguments | Permission |
|---|---|---|
| get-board-analytics-tool | board_id, window_days? |
bearer only |
Workspace membership requires workspace.invite
| Tool | Arguments | Permission |
|---|---|---|
| invite-workspace-member-tool | phone, name?, role? |
workspace.invite |
Worked example: create_card
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "create-card-tool",
"arguments": {
"board_id": 5,
"list_id": 10,
"title": "Reschedule pricing review",
"due_at": "2026-05-15T10:00:00Z",
"assignee_phones": ["+62812..."]
}
},
"id": 42
}
{
"jsonrpc": "2.0",
"id": 42,
"result": {
"content": [{
"type": "text",
"text": "{\"card_id\":11,\"title\":\"Reschedule pricing review\",\"list_id\":10,\"position\":\"459769.0000000000\",\"source\":\"agent\",\"created_by_user_id\":42,\"created_via_agent_user_id\":4}"
}],
"isError": false
}
}
Try it ¶
Call any tool live against this server. Provide a workspace JAVIS bearer token and the phone of the human you’re acting on behalf of, fill in the parameters, and send. Each request is real — mutating tools will actually create or change records and write an audit row.
Requests post to POST https://board.ssf.studio/mcp/board. Use a non-production workspace token while exploring; cards, lists, comments, and invitations created here are real. The bearer token and phone you enter are kept only in your browser’s localStorage and never leave this device unless you press Send request.
Saved in this browser only.
Resources ¶
Resources are read-only data the agent can fetch by URI. Useful for seeding conversation context cheaply, without invoking a tool.
| URI template | Returns |
|---|---|
| board://{board_id}/snapshot | Full board state: lists with their non-archived cards (id, title, due_at, source). |
| workspace://{workspace_id}/my-assignments | Cards assigned to the acting user across all boards in the workspace, ordered by due date. |
| workspace://{workspace_id}/members | Workspace member directory (id, name, phone, role) — useful for resolving @mentions and assignment targets. |
Listing and reading
{ "jsonrpc": "2.0", "method": "resources/templates/list", "params": {}, "id": 1 }
{
"jsonrpc": "2.0",
"method": "resources/read",
"params": { "uri": "board://5/snapshot" },
"id": 2
}
Errors & audit ¶
Error shapes
- HTTP 401 — bearer missing/invalid. JSON body:
{"message":"Unauthenticated."} - HTTP 404 —
X-Acting-User-Phonesent but the phone is not registered and the bearer is not a service token. JSON body:{"message":"Acting user not registered.","error":{"code":"phone_not_linked"}} - JSON-RPC
isError: true— every other failure: missing acting-user (no_actor), bad arguments, no permission, missing record, cross-workspace boundary. The text content carries a human-readable reason.
Audit codes
| error_code | Meaning |
|---|---|
| no_actor | Bearer authenticated but no actor could be resolved. |
| forbidden | Actor is not a workspace member, board is in another workspace, or actor lacks the required permission. |
| not_found | Board / list / card / user referenced does not exist. |
| validation | Argument schema validation failed. |
| <ExceptionClass> | Uncaught exception class basename (truncated at 64 chars). Look in Laravel logs. |
Audit retention
Rows older than 180 days are pruned daily at 03:30 by php artisan agent:prune-audit. Override via --days=N or test with --dry-run.
Client integration ¶
Any MCP-compatible client works. Below: the official TypeScript SDK pattern, since most agent stacks (including the JAVIS production agent) run in Node.
import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; const transport = new StreamableHTTPClientTransport( new URL(`${BASE_URL}/mcp/board`), { requestInit: { headers: { "Authorization": `Bearer ${TOKEN}`, "X-Acting-User-Phone": phoneOfTheUserSpeakingNow, } } } ); const client = new Client({ name: "javis-agent", version: "1.0.0" }); await client.connect(transport); // One round-trip — server returns full tool list with JSON schemas. const { tools } = await client.listTools(); const result = await client.callTool({ name: "create-card-tool", arguments: { board_id: 5, list_id: 10, title: "From Javis" } });
The acting-user header is per request, not per session. If your agent is in a multi-user channel (a WhatsApp group), update the header before each call to identify the human currently speaking. The bearer token never changes.
Data model side-effects ¶
What changes in the database when JAVIS acts.
Cards created via the agent
cards.source = 'agent'cards.created_by_user_id= the human (the actor)cards.created_via_agent_user_id= the workspace JAVIS service user- UI surfaces this as the “via JAVIS” badge on the card.
Events fired
Tools dispatch the same events the web UI does — CardCreated, CardUpdated, CardMoved, CardArchived, CommentAdded, ListCreated, ListMoved, ListArchived, ListUpdated. Broadcast subscribers (Reverb) and notification dispatchers see no difference between web-originated and agent-originated changes, beyond the source field.
Notifications
Assignments and due-date changes schedule the same NotificationDispatch rows that the REST API path writes. The actor is recorded on the card.assignees pivot and as the actor_id in the assignment notification payload.
Ops & troubleshooting ¶
Rotating a token
Admin → Manage Workspaces → Provision JAVIS on the affected workspace. The previous token is deleted; the new plaintext is shown once. Live agent processes need to be restarted with the new token.
Disabling JAVIS for a workspace
Delete the JAVIS user’s tokens ($javis->tokens()->delete()) — every subsequent MCP call returns 401, but no kanban data is affected. The user row and audit history remain.
Pagination on tools/list
Default page size is 10 tools. The response includes nextCursor when there are more. JAVIS’s production client should walk the cursor; ad-hoc curl callers can pass ?per_page=50 to get them all in one shot.
Local debugging
Run php artisan mcp:inspector to open the Laravel/MCP web inspector against your local server. Useful for poking at tool schemas without writing a client.
Appendix · file map ¶
Where to look in the codebase, in execution order.
| File | Role |
|---|---|
| routes/ai.php | Registers Mcp::web('/mcp/board', BoardServer::class) with auth:sanctum + acting-user middleware. |
| app/Http/Middleware/ResolveActingUser.php | Reads X-Acting-User-Phone, swaps the auth user to the actor, stashes the original service caller as service_caller on the request. |
| app/Mcp/Servers/BoardServer.php | Registers all 16 tools and 3 resources. |
| app/Mcp/Tools/BoardTool.php | Base class. Wraps tool execution in workspace-boundary checks, validation, audit, and structured error mapping. |
| app/Mcp/Support/McpContext.php | Pure helpers: actor(), agent(), agentWorkspaceId(), assertActorInAgentWorkspace(), assertBoardInWorkspace(), audit(). |
| app/Mcp/Tools/*.php | One file per tool. Each is a thin adapter that validates input and delegates to an App\Actions\* class. |
| app/Actions/Cards/*, app/Actions/Lists/* | Business logic. The same actions are intended to back Livewire and (eventually) the REST controllers, so MCP and the UI never drift. |
| app/Mcp/Resources/*.php | URI-templated resources for read-only context. |
| app/Console/Commands/Mcp/PruneAgentAudit.php | Daily prune of agent_call_audit rows older than 180 days. |
| database/migrations/…_add_created_via_agent_user_id_to_cards_table.php | Adds the via-agent FK column on cards. |
| database/migrations/…_create_agent_call_audit_table.php | Creates the audit table. |