How to add an MCP server to your platform

09 Oct 2026 | Ayoub EZZINI

Why your platform wants an MCP server

An MCP server lets an AI assistant such as Claude Code work in your platform directly, with the same permissions as the person using it. We added one to our internal Laravel task board. Developers can now say "move AUR-12 to review and link my open PR" or "what did I finish this week?" without leaving their editor.

The Model Context Protocol (MCP) is an open standard: your app exposes tools (functions with a JSON schema), and any MCP client can discover and call them. You write the integration once and it works in every assistant that speaks MCP.

Our server ships eight tools:

ToolWhat it does
list-projectsProjects you can access, with task counts per status
list-sprintsSprints, optionally only the active ones
list-tasksTasks filtered by project, sprint, status, owner, mine or identifier
create-taskA new task, numbered like one made in the UI
update-taskChange title, status, owner, sprint, project or archive state
list-pull-requestsSynced GitHub PRs, with the tasks each one is linked to
link-pull-requestAttach a PR to a task by ID or identifier
work-overviewDone work in a period plus open, review and overdue work

The rest of this post walks through how we built it, including the two mistakes we made on the way.

Step 1: Install laravel/mcp and register a server

Laravel has a first-party package that handles the protocol, transports and schema generation. Install it and scaffold a server:

composer require laravel/mcp
php artisan make:mcp-server TaskBoard

The server class is mostly metadata plus a list of tools. The Instructions attribute matters more than it looks: it is the first thing the assistant reads, so tell it who it acts as and which tool to use for what.

#[Name('Task Board')]
#[Version('1.0.0')]
#[Instructions('You act as the user who owns the API token and only see their projects. Use list-tasks to query tasks (identifiers look like "AUR-12"), link-pull-request to connect PRs, and work-overview to summarise done and to-do work.')]
class TaskBoard extends Server
{
protected array $tools = [
ListProjects::class,
ListTasks::class,
CreateTask::class,
UpdateTask::class,
ListPullRequests::class,
LinkPullRequest::class,
WorkOverview::class,
];
}

Then expose it over HTTP in routes/ai.php, which the package loads for you:

Mcp::web('/mcp', TaskBoard::class)
->middleware('api.token');

The middleware line is where we got it wrong the first time; more on that in step 3.

Step 2: Write tools that reuse your domain code

A tool is a class with a description, an input schema and a handle method. Scaffold one with php artisan make:mcp-tool LinkPullRequest. The description and the schema are your prompt: the assistant decides when and how to call the tool from them alone.

#[Description('Link a GitHub pull request to a task. Identify the task by task_id or by its identifier (e.g. "AUR-12").')]
class LinkPullRequest extends Tool
{
public function handle(Request $request): Response
{
$validated = $request->validate([
'task_id' => ['required_without:identifier', 'integer'],
'identifier' => ['required_without:task_id', 'string'],
'url' => ['required', 'string', 'regex:~^https://github\.com/[^/]+/[^/]+/pull/\d+/?$~'],
]);

// find the task, check the repo, update, log activity...

return Response::json([...]);
}

public function schema(JsonSchema $schema): array
{
return [
'task_id' => $schema->integer()->description('The ID of the task (or use identifier)'),
'identifier' => $schema->string()->description('The task identifier, e.g. "AUR-12"'),
'url' => $schema->string()->required()->description('GitHub pull request URL'),
];
}
}

What we learned writing eight of them:

  1. Go through your domain code, not around it. Our first create-task called Task::create() directly and skipped the service that assigns per-project numbers. Tasks made by the AI had no identifier. It now uses the same TaskCreator as the UI.
  2. Log what the AI does. Every write records an activity log entry ending in "via MCP", attributed to the real user.
  3. Accept the identifiers people use. Developers say "AUR-12", not "task 4817". Tools that take a task accept either, through one withIdentifier query scope.
  4. Return errors the model can act on. Response::error("This pull request is not in the project's repository (dennenboom/aurora).") lets the assistant correct itself. Validation failures are turned into tool errors for you.
  5. Give it summaries, not just CRUD. work-overview saves the assistant from paging through tasks and adding them up itself. One call returns done work for a period and open work grouped by status, so the assistant can answer "what did I do this week?" straight away.

Step 3: Authenticate as the real user, not a bot

Our first question was whether the AI should get its own bot account, or one per user. We chose neither: each person creates a personal API token, and the assistant acts as them.

  1. Permissions are already defined per user, so there is nothing new to configure.
  2. Activity logs, "my tasks" and assignments stay truthful, because the actions belong to a real person.
  3. The token name ("Claude Code laptop") tells you which client did it, and revoking it cuts that client off.

We already had API tokens for a small REST API: a hashed token_hash column and an api.token middleware that resolves the bearer token to a user and rejects deactivated accounts. So the MCP route uses that middleware too.

The pitfall. Our first version used ->middleware('auth:sanctum'), but Sanctum was never installed, so every request to /mcp failed, and there were no tests to catch it. Use the auth your app already has, and test the endpoint over HTTP (step 5).

One more detail: token management used to be lead-only. We opened the page to every user and added the connection instructions there, so anyone can connect their assistant without asking a lead.

Step 4: Authorize every tool per user

Authentication says who is calling; it does not limit what they see. Our original tools ran Task::query() with no constraints, so any token could read and edit every project. An assistant will happily call list-tasks with a project ID it guessed, so every query has to be scoped.

On our board, leads see all projects and developers only see the ones they are assigned to. We put that rule in one query scope:

public function scopeAccessibleBy(Builder $query, User $user): Builder
{
if ($user->isLead()) {
return $query;
}

return $query->whereIn('id', $user->projects()->select('projects.id'));
}

Every tool then starts from it, for example:

$task = Task::withArchived()
->whereIn('project_id', Project::accessibleBy($request->user())->select('id'))
->find($request->get('task_id'));

if (! $task) {
return Response::error('Task not found.');
}

Three rules we follow:

  1. Scope reads and writes, including filters the model passes in.
  2. Check the destination too: moving a task or creating one requires access to the target project.
  3. Answer "not found" rather than "forbidden", so a token cannot discover what exists outside its projects.

Step 5: Test the tools and the endpoint

laravel/mcp ships testing helpers, so a tool test reads like any Laravel feature test. Call the tool as a user and assert on what comes back:

public function test_dev_cannot_update_a_task_of_an_unassigned_project(): void
{
$dev = User::factory()->dev()->create();
$task = Task::factory()->create(['title' => 'Untouchable']);

TaskBoard::actingAs($dev)
->tool(UpdateTask::class, ['task_id' => $task->id, 'title' => 'Changed'])
->assertHasErrors(['Task not found.']);

$this->assertSame('Untouchable', $task->refresh()->title);
}

actingAs() skips your HTTP middleware, so also test the real endpoint with a JSON-RPC request. That is the test that would have caught our Sanctum mistake:

$this->withToken($issued['plainText'])
->postJson('/mcp', [
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'tools/call',
'params' => ['name' => 'list-projects', 'arguments' => []],
])
->assertOk()
->assertSee('Allowed')
->assertDontSee('Hidden');

Our suite has 27 MCP tests covering:

  1. The endpoint: no token, an invalid token, a deactivated user, a valid token.
  2. Each tool's happy path.
  3. For each project-scoped tool, a developer reaching outside their projects.

Step 6: Connect Claude Code or Cursor

Each user creates a token under API Tokens in the user menu, then registers the server once.

Claude Code:

claude mcp add --transport http task-board https://tasks.example.com/mcp \
--header "Authorization: Bearer <your-token>"

Cursor, VS Code and most other clients take a JSON config:

{
"mcpServers": {
"task-board": {
"url": "https://tasks.example.com/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}

Prompts to try once it is connected:

  1. "What did I finish this week, and what is still open for me?"
  2. "Create a bug in Aurora: the export button times out on large projects."
  3. "I just opened a PR for AUR-12, link it and move the task to review."
  4. "Give me a standup summary for the whole team since Monday."


Ayoub EZZINI

Ayoub EZZINI

I'm a Software Engineer!