Model Context Protocol (MCP) in PHP: Connecting AI to Your Application
AI models are powerful but isolated. They can't access your database, call your APIs, or read your files — unless you build integrations for each model provider separately. The Model Context Protocol (MCP) standardizes this. Build one MCP server, and any MCP-compatible AI client (Claude, Cursor, VS Code Copilot) can use your tools.
What Is MCP?
MCP is an open protocol (by Anthropic) that defines how AI applications communicate with external tools and data sources.
Before MCP:
Claude ←→ Custom Claude integration
ChatGPT ←→ Custom OpenAI plugin
Cursor ←→ Custom Cursor extension
(N clients × M tools = N×M integrations)
After MCP:
Claude ←→ MCP Client ←→ MCP Server ←→ Your tools
ChatGPT ←→ MCP Client ←↗
Cursor ←→ MCP Client ←↗
(N clients × 1 MCP server = N integrations)
MCP Concepts
| Concept | Description |
|---|---|
| Server | Your application — exposes tools, resources, and prompts |
| Client | AI application (Claude Desktop, Cursor, etc.) |
| Tools | Functions the AI can call (search, create, update) |
| Resources | Data the AI can read (files, database records) |
| Prompts | Pre-defined prompt templates |
Tools vs Resources
Tools = actions (verbs) → "Search posts", "Create draft", "Send email"
Resources = data (nouns) → "Post list", "Config file content", "User profile"
Tools change state or perform computation. Resources provide read-only data.
Transport Types
MCP supports two communication methods:
| Transport | How It Works | When to Use |
|---|---|---|
| stdio | Client spawns process, communicates via stdin/stdout | Local tools, CLI apps |
| HTTP SSE | Client connects via HTTP, server streams responses | Remote servers, web services |
stdio is most common for local development — the client runs php artisan mcp:serve and communicates via stdin/stdout.
Building an MCP Server in PHP
Install the PHP MCP SDK
composer require modelcontextprotocol/sdk
Basic Server Setup
// mcp-server.php (entry point)
use ModelContextProtocol\Server\McpServer;
use ModelContextProtocol\Server\ServerCapabilities;
$server = new McpServer(
name: 'my-blog-mcp',
version: '1.0.0',
capabilities: new ServerCapabilities(
tools: true,
resources: true,
),
);
// Register tools
$server->registerTool(
name: 'search_posts',
description: 'Search blog posts by keyword. Returns title, slug, and excerpt.',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'Search keyword or phrase',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum results (default: 10)',
'default' => 10,
],
],
'required' => ['query'],
],
handler: function (array $args): array {
$query = $args['query'];
$limit = $args['limit'] ?? 10;
$posts = searchPosts($query, $limit);
return [
'content' => [
[
'type' => 'text',
'text' => json_encode($posts, JSON_PRETTY_PRINT),
],
],
];
},
);
$server->registerTool(
name: 'get_post',
description: 'Get the full content of a blog post by its slug.',
inputSchema: [
'type' => 'object',
'properties' => [
'slug' => [
'type' => 'string',
'description' => 'The post slug (URL identifier)',
],
],
'required' => ['slug'],
],
handler: function (array $args): array {
$post = getPostBySlug($args['slug']);
if (!$post) {
return [
'content' => [['type' => 'text', 'text' => 'Post not found']],
'isError' => true,
];
}
return [
'content' => [
[
'type' => 'text',
'text' => json_encode([
'title' => $post['title'],
'date' => $post['date'],
'content' => $post['content'],
'tags' => $post['tags'],
], JSON_PRETTY_PRINT),
],
],
];
},
);
$server->registerTool(
name: 'create_draft',
description: 'Create a new draft blog post with the given title and content.',
inputSchema: [
'type' => 'object',
'properties' => [
'title' => ['type' => 'string', 'description' => 'Post title'],
'content' => ['type' => 'string', 'description' => 'Markdown content'],
'tags' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Tags for the post',
],
],
'required' => ['title', 'content'],
],
handler: function (array $args): array {
$slug = createDraftPost($args);
return [
'content' => [
[
'type' => 'text',
'text' => "Draft created: {$slug}",
],
],
];
},
);
// Start the server (STDIO transport)
$server->run();
Using Laravel Inside MCP Server
// mcp-server.php
require __DIR__ . '/vendor/autoload.php';
// Boot Laravel
$app = require_once __DIR__ . '/bootstrap/app.php';
$kernel = $app->make(\Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
use App\Models\Post;
use App\Services\MarkdownPostService;
$server = new McpServer(
name: 'blog-mcp',
version: '1.0.0',
capabilities: new ServerCapabilities(tools: true, resources: true),
);
$server->registerTool(
name: 'search_posts',
description: 'Search published blog posts',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Search query'],
],
'required' => ['query'],
],
handler: function (array $args): array {
$service = app(MarkdownPostService::class);
$posts = $service->search($args['query']);
$results = $posts->map(fn ($post) => [
'title' => $post['title'],
'slug' => $post['slug'],
'date' => $post['date'],
'description' => $post['description'],
'tags' => $post['tags'],
])->toArray();
return [
'content' => [
['type' => 'text', 'text' => json_encode($results, JSON_PRETTY_PRINT)],
],
];
},
);
// Register resources
$server->registerResource(
uri: 'blog://posts/list',
name: 'All Blog Posts',
description: 'List of all published blog posts',
mimeType: 'application/json',
handler: function (): array {
$service = app(MarkdownPostService::class);
$posts = $service->getAllPosts();
return [
'content' => [
[
'type' => 'text',
'text' => json_encode(
$posts->map(fn ($p) => ['title' => $p['title'], 'slug' => $p['slug']])->toArray(),
JSON_PRETTY_PRINT,
),
],
],
];
},
);
$server->run();
Artisan Command as MCP Server
Integrate MCP directly into Laravel as an Artisan command:
// app/Console/Commands/McpServerCommand.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use ModelContextProtocol\Server\McpServer;
use ModelContextProtocol\Server\ServerCapabilities;
use App\Services\MarkdownPostService;
class McpServerCommand extends Command
{
protected $signature = 'mcp:serve';
protected $description = 'Start the MCP server for AI assistants';
public function handle(MarkdownPostService $postService): int
{
$server = new McpServer(
name: 'laravel-blog-mcp',
version: '1.0.0',
capabilities: new ServerCapabilities(tools: true, resources: true),
);
$this->registerTools($server, $postService);
$this->registerResources($server, $postService);
$server->run();
return Command::SUCCESS;
}
private function registerTools(McpServer $server, MarkdownPostService $postService): void
{
$server->registerTool(
name: 'search_posts',
description: 'Search blog posts by keyword',
inputSchema: [
'type' => 'object',
'properties' => [
'query' => ['type' => 'string', 'description' => 'Search query'],
'tag' => ['type' => 'string', 'description' => 'Filter by tag (optional)'],
'limit' => ['type' => 'integer', 'description' => 'Max results', 'default' => 10],
],
'required' => ['query'],
],
handler: function (array $args) use ($postService): array {
$posts = $postService->search($args['query'], $args['tag'] ?? null);
return [
'content' => [
['type' => 'text', 'text' => json_encode($posts->take($args['limit'] ?? 10)->toArray())],
],
];
},
);
$server->registerTool(
name: 'get_post_content',
description: 'Get full markdown content of a post by slug',
inputSchema: [
'type' => 'object',
'properties' => [
'slug' => ['type' => 'string', 'description' => 'Post slug'],
],
'required' => ['slug'],
],
handler: function (array $args) use ($postService): array {
$post = $postService->getPost($args['slug']);
if (!$post) {
return ['content' => [['type' => 'text', 'text' => 'Post not found']], 'isError' => true];
}
return [
'content' => [['type' => 'text', 'text' => $post['raw_content']]],
];
},
);
$server->registerTool(
name: 'list_tags',
description: 'List all tags used across blog posts with post counts',
inputSchema: ['type' => 'object', 'properties' => []],
handler: function () use ($postService): array {
$tags = $postService->getAllTags();
return [
'content' => [['type' => 'text', 'text' => json_encode($tags)]],
];
},
);
}
private function registerResources(McpServer $server, MarkdownPostService $postService): void
{
$server->registerResource(
uri: 'blog://stats',
name: 'Blog Statistics',
description: 'Overview statistics of the blog',
mimeType: 'application/json',
handler: function () use ($postService): array {
$posts = $postService->getAllPosts();
return [
'content' => [[
'type' => 'text',
'text' => json_encode([
'total_posts' => $posts->count(),
'total_tags' => $posts->pluck('tags')->flatten()->unique()->count(),
'latest_post' => $posts->first()['title'] ?? null,
'oldest_post' => $posts->last()['title'] ?? null,
]),
]],
];
},
);
}
}
Configuring AI Clients
Claude Desktop
// ~/Library/Application Support/Claude/claude_desktop_config.json (macOS)
// ~/.config/Claude/claude_desktop_config.json (Linux)
{
"mcpServers": {
"my-blog": {
"command": "php",
"args": ["/path/to/your/project/artisan", "mcp:serve"]
}
}
}
VS Code (Copilot)
// .vscode/mcp.json
{
"servers": {
"blog": {
"type": "stdio",
"command": "php",
"args": ["artisan", "mcp:serve"]
}
}
}
Cursor
// .cursor/mcp.json
{
"mcpServers": {
"blog": {
"command": "php",
"args": ["artisan", "mcp:serve"]
}
}
}
HTTP Transport (Remote MCP Server)
For remote access, use HTTP+SSE transport:
// routes/api.php
use ModelContextProtocol\Server\McpServer;
Route::post('/mcp', function (Request $request) {
// Authenticate the request
$request->validate(['jsonrpc' => 'required']);
$server = app(McpServer::class);
$response = $server->handleRequest($request->all());
return response()->json($response);
})->middleware('auth:sanctum');
Error Handling
$server->registerTool(
name: 'risky_operation',
// ...
handler: function (array $args): array {
try {
$result = $this->performOperation($args);
return [
'content' => [['type' => 'text', 'text' => json_encode($result)]],
];
} catch (ValidationException $e) {
return [
'content' => [['type' => 'text', 'text' => "Validation error: {$e->getMessage()}"]],
'isError' => true,
];
} catch (\Throwable $e) {
Log::error('MCP tool error', ['tool' => 'risky_operation', 'error' => $e->getMessage()]);
return [
'content' => [['type' => 'text', 'text' => 'An error occurred while performing the operation.']],
'isError' => true,
];
}
},
);
isError: true tells the AI client: "this tool call failed" → the AI will handle it differently (try another approach, inform the user) instead of treating the error message as successful data.
Security Considerations
- Authentication: Always authenticate MCP connections
- Authorization: Check user permissions before executing tools
- Input validation: Sanitize all tool arguments
- Rate limiting: Prevent AI from making excessive tool calls
- Logging: Log all tool executions for audit
// Wrap tool handlers with security
$server->registerTool(
name: 'delete_post',
description: 'Delete a blog post (admin only)',
inputSchema: [...],
handler: function (array $args): array {
// This runs in CLI context — verify authorization differently
$adminToken = getenv('MCP_ADMIN_TOKEN');
if (!$adminToken) {
return ['content' => [['type' => 'text', 'text' => 'Unauthorized']], 'isError' => true];
}
Log::warning('MCP: Deleting post', ['slug' => $args['slug']]);
// Execute deletion...
},
);
Testing MCP Tools
class McpToolsTest extends TestCase
{
public function test_search_posts_tool_returns_results(): void
{
// Create test content
$this->createTestPost('Laravel Caching Tips', 'laravel-caching-tips');
$handler = $this->getToolHandler('search_posts');
$result = $handler(['query' => 'caching']);
$content = json_decode($result['content'][0]['text'], true);
$this->assertNotEmpty($content);
$this->assertStringContainsString('Caching', $content[0]['title']);
}
public function test_get_post_returns_not_found(): void
{
$handler = $this->getToolHandler('get_post_content');
$result = $handler(['slug' => 'nonexistent']);
$this->assertTrue($result['isError']);
}
}
Conclusion
MCP is the USB-C of AI integrations — one standard connector for all AI clients. Building an MCP server for your Laravel app takes minimal effort and gives you:
- Claude Desktop can search your blog, read posts, and create drafts
- VS Code Copilot can access your codebase context
- Cursor can interact with your application data
- Any future MCP client works automatically
Start with 2-3 read-only tools (search, list, get). Add write tools (create, update) once you're comfortable with the security model.