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 Rezha.”
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. Make your first call
# Replace TOKEN, PHONE, BASE_URL with your values. 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": 1 }'
3. 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 required on every call
| 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 any mutating call. 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 permission the tool requires (
card.edit,card.move,list.create, etc.).
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'.
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).
Read no permissions required beyond membership
| Tool | Arguments | Permission |
|---|---|---|
| list-boards-tool | (none) | membership |
| get-board-tool | board_id |
membership |
| list-cards-tool | board_id, list_id?, assigned_to_user_id?, include_archived?, limit? |
membership |
| get-card-tool | card_id |
membership |
| search-cards-tool | board_id, query, limit? |
membership |
Card mutations requires card.* permission
| 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.edit |
| 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.edit |
| archive-card-tool | card_id |
card.edit |
| restore-card-tool | card_id |
card.edit |
| comment-on-card-tool | card_id, body |
card.comment |
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 |
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
}
}
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 422 — mutating request without
X-Acting-User-Phone. JSON body:{"message":"X-Acting-User-Phone header required for mutating requests.","error":{"code":"acting_user_required"}} - JSON-RPC
isError: true— every other failure (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. |