Skip to main content

Building a Simple MCP Server in Drupal 10: A Full-Stack Developer’s Guide

By a Drupal Full-Stack Developer & Solution Architect  |  Drupal 10 / 11  |  March 2026

Table of Contents

  1. What Is the Model Context Protocol (MCP)?
  2. Why Build an MCP Server in Drupal?
  3. Scaffolding the Module
  4. The MCP Handler Service
  5. The MCP Controller
  6. Routing
  7. Wiring It Together: services.yml
  8. Security Considerations
  9. A Note on Testing
  10. Summary & Best Practices Recap

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:
  • MCP Clients — AI hosts like Claude Desktop, Cursor, VS Code Copilot, or any custom LLM application.
  • MCP Servers — services that expose three kinds of primitives to those clients:
    • Resources — read-only data (think: a Drupal node, a user profile, a config value).
    • Tools — callable actions that can produce side effects (think: creating content, running a query).
    • Prompts — reusable prompt templates and workflows.

Key Benefits of MCP

  • Standardisation — one integration protocol works with Claude, ChatGPT, Gemini, Copilot, and thousands of open-source agents.
  • Composability — multiple MCP servers can be chained to build sophisticated agent workflows.
  • Security — the spec defines explicit consent, authorisation flows, and OAuth 2.1 resource-server semantics.
  • Ecosystem velocity — as of early 2026 there are over 10,000 active MCP servers and 97 million monthly SDK downloads across Python and TypeScript alone.
  • Drupal relevance — Drupal’s rich entity, content, and configuration APIs become instantly consumable by any AI agent, without custom per-client integrations.

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:
  • An AI agent can read, search, and create Drupal nodes without a human at the keyboard.
  • Editorial workflows become AI-assisted without bespoke integrations for each AI tool.
  • Drupal’s existing permission system (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:
  • One class per file, named identically to the class — per Drupal coding standards §Declaring Classes.
  • PSR-4 directory layout: module classes live under modulename/src/.
  • Module machine name prefixes all service IDs and hook function names to avoid collisions.
File tree:
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

name: 'Simple MCP Server'
type: module
description: 'Exposes a minimal Model Context Protocol (MCP) endpoint over HTTP.'
package: Custom
core_version_requirement: ^10 || ^11
Best practice: 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:
  • Dependency Injection via constructor — per Drupal.org DI documentation, never call \Drupal::service() inside a service; inject everything through the constructor.
  • Interface type hints for injected services — per coding standards §Parameter and return type hinting, use the interface (e.g. EntityTypeManagerInterface), not the concrete class.
  • Return type declarations — required for all new methods in Drupal 9+.
  • Strict types declaration — placed after the file DocBlock and before namespace, per Drupal coding standards §Strict type declaration.
  • Visibility on all methods and properties — per §Visibility.
  • UpperCamelCase class names, lowerCamelCase method names — per §Naming Conventions.
  • 2-space indentation, no tabs — per §Indenting and Whitespace.
  • No closing ?> tag — per §PHP Code Tags.
  • Doxygen / PHPDoc on every class and method — per Drupal API documentation standards.
  • LoggerChannelFactoryInterface for logging — never use 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:
  1. Validate and decode the incoming HTTP request body.
  2. Delegate to the McpHandlerService.
  3. Return a JsonResponse.
Best practices applied:
  • Extends ControllerBase and implements ContainerInjectionInterface via the create() factory — the standard Drupal DI pattern for controllers per Drupal.org.
  • Never calls \Drupal::service() directly inside the controller — the service is injected.
  • Uses Request from Symfony HttpFoundation — the canonical way to inspect the incoming request in Drupal.
  • Returns JsonResponse — Drupal/Symfony’s typed response for JSON endpoints.
  • Interface type hint on injected service parameter — best practice in Drupal 10/11.
<?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.
  • The permission name is prefixed with the module name to avoid collisions.
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
Register the corresponding permission in 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:
  • Service IDs are prefixed with the module machine name: simple_mcp.handler.
  • Dependencies are declared with @-prefixed service IDs.
  • We reference Drupal core services by their canonical 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

  • Always pass accessCheck(TRUE) on entity queries — this ensures Drupal’s node access system is respected.
  • Always call $node->access('view', $this->currentUser) before returning node content.
  • Never expose the MCP endpoint with _access: 'TRUE'.

Input Validation

  • Validate Content-Type before parsing.
  • Use json_last_error() to detect malformed payloads.
  • Sanitise free-text query strings — the LIKE query in the tool example uses Drupal’s database abstraction layer (no raw SQL), which safely parameterises the query.

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:
<?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']);
  }

}
Place this file at: 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 files Service, Controller, Test Drupal coding standards §Strict type declaration
Constructor DI via create() factory McpController Drupal.org DI documentation
Constructor DI without \Drupal::service() McpHandlerService Drupal.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.yml Drupal.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) McpHandlerService Drupal.org Entity Query API
Routing via .routing.yml, not raw PHP simple_mcp.routing.yml Drupal routing system
Dedicated permission with restrict access: true simple_mcp.permissions.yml Drupal.org hook_permission()
Logger injected via LoggerChannelFactoryInterface McpHandlerService Drupal.org Logging API
Business logic in service, not controller Architecture separation Drupal architectural best practices
Unit-testable service with mocked dependencies McpHandlerServiceTest Drupal.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:
  • OAuth 2.1 / Simple OAuth integration for proper MCP Resource Server authentication.
  • Streamable HTTP transport (Server-Sent Events) for real-time MCP notifications — supported in the MCP 2025-11-25 spec.
  • More tool types: create node, update field value, trigger workflow transitions.
  • MCP Prompts: expose reusable Drupal-aware prompt templates to AI clients.
  • Rate limiting and audit logging per access by AI agent identity.
  • Kernel or Functional tests in addition to unit tests, to validate the full HTTP stack.

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!

Your Email address will not be published.