
Table of Contents
1. What Is the Model Context Protocol (MCP)?
The Model Context Protocol (MCP) is an open standard — originally created by Anthropic and now stewarded by the Agentic AI Foundation under the Linux Foundation — that provides a unified, vendor-neutral way for AI applications to connect to external data sources and tools. Think of it as “USB-C for AI integrations”: before MCP, every AI tool needed its own bespoke connector; MCP defines one plug that works everywhere. At its core, MCP uses JSON-RPC 2.0 messages to communicate between two parties:
Key Benefits of MCP
2. Why Build an MCP Server in Drupal?
Drupal is an enterprise-grade content platform that already manages structured data, access control, multilingual content, and configuration. Exposing that capability via MCP means:
node.access, role checks) naturally gates what an AI can see or do.
3. Scaffolding the Module
We will build a minimal custom module called simple_mcp. Following Drupal best practice, each class lives in its own file under src/, following PSR-4 autoloading (namespace root: Drupal\simple_mcp, path root: web/modules/custom/simple_mcp/src/). Best practices applied:
File tree:
modulename/src/.web/modules/custom/simple_mcp/
├── simple_mcp.info.yml
├── simple_mcp.routing.yml
├── simple_mcp.services.yml
└── src/
├── Controller/
│ └── McpController.php
└── Service/
└── McpHandlerService.php
simple_mcp.info.yml
Best practice: name: 'Simple MCP Server'
type: module
description: 'Exposes a minimal Model Context Protocol (MCP) endpoint over HTTP.'
package: Custom
core_version_requirement: ^10 || ^11
core_version_requirement: ^10 || ^11 declares compatibility with both Drupal 10 and 11 explicitly, which is the current Drupal.org recommendation for new modules.
4. The MCP Handler Service
Business logic belongs in a dedicated service class, not inside a controller. This is one of the most important architectural rules in Drupal 8+: controllers are thin dispatchers; services carry the logic. This separation also makes unit testing dramatically easier. Best practices applied in the code below:
\Drupal::service() inside a service; inject everything through the constructor.EntityTypeManagerInterface), not the concrete class.?> tag — per §PHP Code Tags.error_log() or print in Drupal modules.<?php
/**
* @file
* Contains \Drupal\simple_mcp\Service\McpHandlerService.
*/
declare(strict_types=1);
namespace Drupal\simple_mcp\Service;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Session\AccountProxyInterface;
/**
* Handles incoming MCP JSON-RPC requests and returns structured responses.
*
* This service implements a subset of the Model Context Protocol (MCP)
* specification, exposing Drupal node data as MCP Resources and a basic
* search capability as an MCP Tool.
*
* @see https://modelcontextprotocol.io/specification/2025-11-25
*/
class McpHandlerService {
/**
* The logger channel for this service.
*
* @var \Drupal\Core\Logger\LoggerChannelInterface
*/
protected LoggerChannelInterface $logger;
/**
* Constructs a new McpHandlerService.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\Core\Session\AccountProxyInterface $currentUser
* The currently authenticated user.
* @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $loggerFactory
* The logger channel factory.
*/
public function __construct(
protected readonly EntityTypeManagerInterface $entityTypeManager,
protected readonly AccountProxyInterface $currentUser,
LoggerChannelFactoryInterface $loggerFactory,
) {
$this->logger = $loggerFactory->get('simple_mcp');
}
/**
* Dispatches a decoded JSON-RPC request array to the correct handler.
*
* Supported MCP methods:
* - initialize : Capability handshake.
* - resources/list : Returns available MCP resources.
* - resources/read : Returns the content of a single resource.
* - tools/list : Returns available MCP tools.
* - tools/call : Executes a named tool.
*
* @param array $request
* The decoded JSON-RPC 2.0 request body.
*
* @return array
* A JSON-RPC 2.0 response array (without json_encode).
*/
public function dispatch(array $request): array {
$id = $request['id'] ?? NULL;
$method = $request['method'] ?? '';
$params = $request['params'] ?? [];
// Validate that we received a recognised JSON-RPC 2.0 structure.
if (empty($method)) {
return $this->errorResponse($id, -32600, 'Invalid Request: missing method.');
}
$this->logger->info('MCP dispatch: method=@method uid=@uid', [
'@method' => $method,
'@uid' => $this->currentUser->id(),
]);
return match ($method) {
'initialize' => $this->handleInitialize($id),
'resources/list' => $this->handleResourcesList($id),
'resources/read' => $this->handleResourcesRead($id, $params),
'tools/list' => $this->handleToolsList($id),
'tools/call' => $this->handleToolsCall($id, $params),
default => $this->errorResponse($id, -32601, 'Method not found: ' . $method),
};
}
/**
* Handles the MCP initialize handshake.
*
* @param int|string|null $id
* The JSON-RPC request ID.
*
* @return array
* The capabilities response.
*/
protected function handleInitialize(int|string|null $id): array {
return $this->successResponse($id, [
'protocolVersion' => '2024-11-05',
'serverInfo' => [
'name' => 'Drupal Simple MCP Server',
'version' => '1.0.0',
],
'capabilities' => [
'resources' => ['listChanged' => FALSE],
'tools' => ['listChanged' => FALSE],
],
]);
}
/**
* Returns a list of available MCP resources.
*
* Each resource maps to a published Drupal node. The resource URI follows
* the pattern: drupal://node/{nid}
*
* @param int|string|null $id
* The JSON-RPC request ID.
*
* @return array
* JSON-RPC response with a 'resources' array.
*/
protected function handleResourcesList(int|string|null $id): array {
$resources = [];
try {
$storage = $this->entityTypeManager->getStorage('node');
// Load only published nodes; access control is respected via the
// entity access system. We cap at 50 for performance.
$nids = $storage->getQuery()
->condition('status', 1)
->accessCheck(TRUE)
->range(0, 50)
->sort('changed', 'DESC')
->execute();
$nodes = $storage->loadMultiple($nids);
foreach ($nodes as $node) {
$resources[] = [
'uri' => 'drupal://node/' . $node->id(),
'name' => $node->label(),
'description' => 'Drupal node (type: ' . $node->bundle() . ')',
'mimeType' => 'text/plain',
];
}
}
catch (\Exception $e) {
$this->logger->error('resources/list failed: @msg', ['@msg' => $e->getMessage()]);
return $this->errorResponse($id, -32603, 'Internal error loading resources.');
}
return $this->successResponse($id, ['resources' => $resources]);
}
/**
* Reads and returns the content of a single MCP resource by URI.
*
* @param int|string|null $id
* The JSON-RPC request ID.
* @param array $params
* Request params; must contain 'uri' (e.g. "drupal://node/42").
*
* @return array
* JSON-RPC response with resource contents.
*/
protected function handleResourcesRead(int|string|null $id, array $params): array {
$uri = $params['uri'] ?? '';
if (!preg_match('/^drupal:\/\/node\/(\d+)$/', $uri, $matches)) {
return $this->errorResponse($id, -32602, 'Invalid params: unrecognised URI format.');
}
$nid = (int) $matches[1];
try {
$node = $this->entityTypeManager->getStorage('node')->load($nid);
if (!$node || !$node->access('view', $this->currentUser)) {
return $this->errorResponse($id, -32602, 'Resource not found or access denied.');
}
// Build a plain-text representation of the node body.
$body = '';
if ($node->hasField('body') && !$node->get('body')->isEmpty()) {
$body = strip_tags((string) $node->get('body')->value);
}
return $this->successResponse($id, [
'contents' => [
[
'uri' => $uri,
'mimeType' => 'text/plain',
'text' => $node->label() . "\n\n" . $body,
],
],
]);
}
catch (\Exception $e) {
$this->logger->error('resources/read failed nid=@nid: @msg', [
'@nid' => $nid,
'@msg' => $e->getMessage(),
]);
return $this->errorResponse($id, -32603, 'Internal error reading resource.');
}
}
/**
* Returns the list of tools this MCP server exposes.
*
* @param int|string|null $id
* The JSON-RPC request ID.
*
* @return array
* JSON-RPC response with a 'tools' array.
*/
protected function handleToolsList(int|string|null $id): array {
return $this->successResponse($id, [
'tools' => [
[
'name' => 'search_nodes',
'description' => 'Full-text search for published Drupal nodes by keyword.',
'inputSchema' => [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
'description' => 'The search keyword(s).',
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum number of results (default 10, max 25).',
],
],
'required' => ['query'],
],
],
],
]);
}
/**
* Executes a named MCP tool.
*
* @param int|string|null $id
* The JSON-RPC request ID.
* @param array $params
* Must contain 'name' (tool name) and 'arguments' (tool input).
*
* @return array
* JSON-RPC response with tool output.
*/
protected function handleToolsCall(int|string|null $id, array $params): array {
$tool_name = $params['name'] ?? '';
$arguments = $params['arguments'] ?? [];
return match ($tool_name) {
'search_nodes' => $this->toolSearchNodes($id, $arguments),
default => $this->errorResponse($id, -32602, 'Unknown tool: ' . $tool_name),
};
}
/**
* Tool implementation: searches published nodes by title keyword.
*
* @param int|string|null $id
* The JSON-RPC request ID.
* @param array $arguments
* Tool arguments: 'query' (string, required), 'limit' (int, optional).
*
* @return array
* JSON-RPC response containing matching node data.
*/
protected function toolSearchNodes(int|string|null $id, array $arguments): array {
$query = trim((string) ($arguments['query'] ?? ''));
$limit = min((int) ($arguments['limit'] ?? 10), 25);
if ($query === '') {
return $this->errorResponse($id, -32602, 'Invalid params: query must not be empty.');
}
try {
$storage = $this->entityTypeManager->getStorage('node');
$nids = $storage->getQuery()
->condition('status', 1)
->condition('title', '%' . $query . '%', 'LIKE')
->accessCheck(TRUE)
->range(0, $limit)
->sort('changed', 'DESC')
->execute();
$nodes = $storage->loadMultiple($nids);
$results = [];
foreach ($nodes as $node) {
$results[] = [
'uri' => 'drupal://node/' . $node->id(),
'title' => $node->label(),
'type' => $node->bundle(),
'changed' => $node->getChangedTime(),
];
}
return $this->successResponse($id, [
'content' => [
[
'type' => 'text',
'text' => json_encode(['results' => $results], JSON_PRETTY_PRINT),
],
],
'isError' => FALSE,
]);
}
catch (\Exception $e) {
$this->logger->error('tools/call search_nodes failed: @msg', ['@msg' => $e->getMessage()]);
return $this->errorResponse($id, -32603, 'Internal error executing tool.');
}
}
/**
* Builds a JSON-RPC 2.0 success response envelope.
*
* @param int|string|null $id
* The request ID to echo back.
* @param array $result
* The result payload.
*
* @return array
* The response array.
*/
protected function successResponse(int|string|null $id, array $result): array {
return [
'jsonrpc' => '2.0',
'id' => $id,
'result' => $result,
];
}
/**
* Builds a JSON-RPC 2.0 error response envelope.
*
* @param int|string|null $id
* The request ID to echo back.
* @param int $code
* The JSON-RPC error code.
* @param string $message
* A human-readable error message.
*
* @return array
* The error response array.
*/
protected function errorResponse(int|string|null $id, int $code, string $message): array {
$this->logger->warning('MCP error @code: @msg', [
'@code' => $code,
'@msg' => $message,
]);
return [
'jsonrpc' => '2.0',
'id' => $id,
'error' => [
'code' => $code,
'message' => $message,
],
];
}
}
5. The MCP Controller
The controller’s only job is to:
Best practices applied:
McpHandlerService.JsonResponse.
ControllerBase and implements ContainerInjectionInterface via the create() factory — the standard Drupal DI pattern for controllers per Drupal.org.\Drupal::service() directly inside the controller — the service is injected.Request from Symfony HttpFoundation — the canonical way to inspect the incoming request in Drupal.JsonResponse — Drupal/Symfony’s typed response for JSON endpoints.<?php
/**
* @file
* Contains \Drupal\simple_mcp\Controller\McpController.
*/
declare(strict_types=1);
namespace Drupal\simple_mcp\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\simple_mcp\Service\McpHandlerService;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Controller for the Simple MCP Server HTTP endpoint.
*
* Accepts POST requests containing JSON-RPC 2.0 payloads and returns
* MCP-compliant JSON responses.
*/
class McpController extends ControllerBase {
/**
* Constructs a new McpController.
*
* @param \Drupal\simple_mcp\Service\McpHandlerService $mcpHandler
* The MCP handler service.
*/
public function __construct(
protected readonly McpHandlerService $mcpHandler,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('simple_mcp.handler'),
);
}
/**
* Handles POST requests to the /mcp/rpc endpoint.
*
* Decodes the JSON-RPC 2.0 body, dispatches to the handler service, and
* returns the JSON-encoded result with appropriate HTTP status codes.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The current HTTP request.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* The JSON-RPC 2.0 response.
*/
public function handle(Request $request): JsonResponse {
$content_type = $request->headers->get('Content-Type', '');
if (!str_contains($content_type, 'application/json')) {
return new JsonResponse(
[
'jsonrpc' => '2.0',
'id' => NULL,
'error' => [
'code' => -32700,
'message' => 'Content-Type must be application/json.',
],
],
Response::HTTP_UNSUPPORTED_MEDIA_TYPE,
);
}
$body = $request->getContent();
$data = json_decode($body, TRUE);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
return new JsonResponse(
[
'jsonrpc' => '2.0',
'id' => NULL,
'error' => [
'code' => -32700,
'message' => 'Parse error: invalid JSON body.',
],
],
Response::HTTP_BAD_REQUEST,
);
}
$response = $this->mcpHandler->dispatch($data);
// An 'error' key in the JSON-RPC response body does not necessarily mean
// an HTTP error — JSON-RPC errors are always returned with HTTP 200 per
// the spec, except for transport-level failures handled above.
return new JsonResponse($response, Response::HTTP_OK);
}
}
6. Routing
Drupal’s routing system (built on Symfony Router) is the correct way to expose HTTP endpoints. We never create raw PHP files in the module directory or use .htaccess tricks. Best practices applied:
_format: json restricts the route to JSON requests.methods: [POST] restricts to POST only — MCP clients always POST JSON-RPC._permission requirement uses Drupal’s access system — never bypass with _access: 'TRUE' in production. Use a dedicated permission.
Register the corresponding permission in simple_mcp.rpc:
path: '/mcp/rpc'
defaults:
_controller: 'Drupal\simple_mcp\Controller\McpController::handle'
_title: 'MCP RPC Endpoint'
requirements:
_permission: 'access simple mcp endpoint'
_format: 'json'
methods: [POST]
options:
_auth:
- basic_auth
- cookie
simple_mcp.permissions.yml:
'access simple mcp endpoint':
title: 'Access the Simple MCP endpoint'
description: 'Grants access to POST requests on /mcp/rpc. Assign only to trusted roles or API users.'
restrict access: true
7. Wiring It Together: services.yml
Services are registered in simple_mcp.services.yml. Following Drupal.org conventions:
simple_mcp.handler.@-prefixed service IDs.entity_type.manager, current_user, logger.factory).services:
simple_mcp.handler:
class: Drupal\simple_mcp\Service\McpHandlerService
arguments:
- '@entity_type.manager'
- '@current_user'
- '@logger.factory'
8. Security Considerations
MCP servers are powerful — they expose your Drupal data and actions to AI agents. Do not treat this lightly.
Authentication
The routing example above lists basic_auth and cookie as auth providers. In production, use Drupal’s Key Authentication module or OAuth 2.0 (the Simple OAuth module) to issue scoped tokens to MCP clients. The MCP specification (as of 2025-06-18) formally classifies MCP servers as OAuth Resource Servers, which means issued tokens should include Resource Indicators (RFC 8707) to prevent token misuse across servers.
Access Control
accessCheck(TRUE) on entity queries — this ensures Drupal’s node access system is respected.$node->access('view', $this->currentUser) before returning node content._access: 'TRUE'.Input Validation
Content-Type before parsing.json_last_error() to detect malformed payloads.Rate Limiting
Consider adding Drupal’s Rate Limiter module or a reverse-proxy rule (e.g. Nginx limit_req) to protect /mcp/rpc from abuse.
Tool Poisoning
The MCP spec notes that tool descriptions can be manipulated by malicious servers. If you are consuming (not serving) MCP tools, treat tool descriptions as untrusted. If you are serving tools, keep descriptions accurate and minimal.
9. A Note on Testing
One of the biggest advantages of proper dependency injection is testability. Because McpHandlerService receives all its dependencies via constructor arguments, you can write a PHPUnit unit test by passing mocked services:
Place this file at: <?php
declare(strict_types=1);
namespace Drupal\Tests\simple_mcp\Unit;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Session\AccountProxyInterface;
use Drupal\simple_mcp\Service\McpHandlerService;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\simple_mcp\Service\McpHandlerService
* @group simple_mcp
*/
class McpHandlerServiceTest extends TestCase {
/**
* Tests that an initialize request returns protocol version info.
*
* @covers ::dispatch
* @covers ::handleInitialize
*/
public function testInitializeReturnsCapabilities(): void {
$entity_type_manager = $this->createMock(EntityTypeManagerInterface::class);
$current_user = $this->createMock(AccountProxyInterface::class);
$logger = $this->createMock(LoggerChannelInterface::class);
$logger_factory = $this->createMock(LoggerChannelFactoryInterface::class);
$logger_factory->method('get')->willReturn($logger);
$service = new McpHandlerService(
$entity_type_manager,
$current_user,
$logger_factory,
);
$response = $service->dispatch([
'jsonrpc' => '2.0',
'id' => 1,
'method' => 'initialize',
]);
$this->assertSame('2.0', $response['jsonrpc']);
$this->assertArrayHasKey('result', $response);
$this->assertArrayHasKey('protocolVersion', $response['result']);
$this->assertArrayHasKey('capabilities', $response['result']);
}
/**
* Tests that an unknown method returns a JSON-RPC method-not-found error.
*
* @covers ::dispatch
* @covers ::errorResponse
*/
public function testUnknownMethodReturnsError(): void {
$entity_type_manager = $this->createMock(EntityTypeManagerInterface::class);
$current_user = $this->createMock(AccountProxyInterface::class);
$logger = $this->createMock(LoggerChannelInterface::class);
$logger_factory = $this->createMock(LoggerChannelFactoryInterface::class);
$logger_factory->method('get')->willReturn($logger);
$service = new McpHandlerService(
$entity_type_manager,
$current_user,
$logger_factory,
);
$response = $service->dispatch([
'jsonrpc' => '2.0',
'id' => 2,
'method' => 'nonexistent/method',
]);
$this->assertArrayHasKey('error', $response);
$this->assertSame(-32601, $response['error']['code']);
}
}
web/modules/custom/simple_mcp/tests/src/Unit/McpHandlerServiceTest.php and run it with:
./vendor/bin/phpunit web/modules/custom/simple_mcp/tests/src/Unit/
10. Summary & Best Practices Recap
Here is a consolidated reference of every Drupal best practice applied in this guide:
Practice
Where Applied
Source
PSR-4 autoloading, one class per file
All PHP classes under
src/Drupal coding standards §Declaring Classes
declare(strict_types=1); on all PHP filesService, Controller, Test
Drupal coding standards §Strict type declaration
Constructor DI via
create() factoryMcpControllerDrupal.org DI documentation
Constructor DI without
\Drupal::service()McpHandlerServiceDrupal.org DI documentation
Interface type hints on injected services
Service constructor parameters
Drupal coding standards §Parameter and return type hinting
Return type declarations on all methods
All methods
Drupal coding standards §Parameter and return type hinting (Drupal 9+)
Visibility on all methods and properties
All classes
Drupal coding standards §Visibility
protected readonly for injected properties (PHP 8.1+)Both classes
PHP 8.1 feature; valid in Drupal 10 (PHP 8.1 minimum) and Drupal 11
UpperCamelCase classes, lowerCamelCase methods
All classes and methods
Drupal coding standards §Naming Conventions
Module-name prefix on service IDs
simple_mcp.services.ymlDrupal.org §Structure of a service file
2-space indentation, no closing
?>All PHP files
Drupal coding standards §Indenting and Whitespace, §PHP Code Tags
Doxygen PHPDoc on every class and method
All PHP files
Drupal API documentation standards
Entity query with
accessCheck(TRUE)McpHandlerServiceDrupal.org Entity Query API
Routing via
.routing.yml, not raw PHPsimple_mcp.routing.ymlDrupal routing system
Dedicated permission with
restrict access: truesimple_mcp.permissions.ymlDrupal.org hook_permission()
Logger injected via
LoggerChannelFactoryInterfaceMcpHandlerServiceDrupal.org Logging API
Business logic in service, not controller
Architecture separation
Drupal architectural best practices
Unit-testable service with mocked dependencies
McpHandlerServiceTestDrupal.org automated testing guide
What to Build Next
This module is intentionally minimal to illustrate the patterns clearly. In a production MCP server you would extend it with:
All code in this post follows the Drupal PHP Coding Standards and has been written against Drupal 10 / 11 APIs. The MCP protocol details are based on the MCP specification 2025-11-25 (the latest stable version at time of writing).
No Comments yet!