mcp-1.x-dev/src/Plugin/Mcp/Content.php
src/Plugin/Mcp/Content.php
<?php
namespace Drupal\mcp\Plugin\Mcp;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\mcp\Attribute\Mcp;
use Drupal\mcp\Plugin\McpPluginBase;
use Drupal\mcp\ServerFeatures\Resource;
use Drupal\mcp\ServerFeatures\ResourceTemplate;
use Drupal\mcp\ServerFeatures\Tool;
use Drupal\node\NodeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Plugin implementation of the mcp.
*/
#[Mcp(
id: 'content',
name: new TranslatableMarkup('Content'),
description: new TranslatableMarkup(
'Provides MCP integration with Drupal content and fields.'
),
)]
final class Content extends McpPluginBase implements ContainerFactoryPluginInterface {
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
private $moduleHandler;
/**
* The entity type manager.
*
* @var ?\Drupal\Core\Entity\EntityTypeManagerInterface
*/
private $entityTypeManager;
/**
* The entity field manager.
*
* @var ?\Drupal\Core\Entity\EntityFieldManagerInterface
*/
private $entityFieldManager;
/**
* {@inheritDoc}
*/
public static function create(
ContainerInterface $container,
array $configuration,
$plugin_id,
$plugin_definition,
) {
$instance = parent::create(
$container,
$configuration,
$plugin_id,
$plugin_definition,
);
$instance->moduleHandler = $container->get('module_handler');
$instance->entityTypeManager = $container->get(
'entity_type.manager', ContainerInterface::NULL_ON_INVALID_REFERENCE
);
$instance->entityFieldManager = $container->get(
'entity_field.manager', ContainerInterface::NULL_ON_INVALID_REFERENCE
);
return $instance;
}
/**
* {@inheritDoc}
*/
public function checkRequirements(): bool {
return $this->moduleHandler->moduleExists('node');
}
/**
* {@inheritDoc}
*/
public function getRequirementsDescription(): string {
if (!$this->checkRequirements()) {
return $this->t('The Node module must be enabled to use this plugin.');
}
return '';
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
$config = parent::defaultConfiguration();
$config['config']['content_types'] = [];
return $config;
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(
array $form,
FormStateInterface $form_state,
): array {
$config = $this->getConfiguration();
$nodeTypes = $this->entityTypeManager->getStorage('node_type')
->loadMultiple();
$form['content_types'] = [
'#type' => 'container',
'#tree' => TRUE,
'#description' => $this->t(
'Select which content types should be available through MCP. By default, no content types are exposed.'
),
];
foreach ($nodeTypes as $nodeType) {
$typeId = $nodeType->id();
$form['content_types'][$typeId] = [
'#type' => 'checkbox',
'#title' => $nodeType->label(),
'#description' => $nodeType->getDescription(),
'#default_value' => $config['config']['content_types'][$typeId] ??
FALSE,
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function getResources(): array {
$nodeTypes = $this->entityTypeManager->getStorage('node_type')
->loadMultiple();
$resources = [];
foreach ($nodeTypes as $nodeType) {
if (!$this->isContentTypeEnabled($nodeType->id())) {
continue;
}
$resources[] = new Resource(
uri: "node/{$nodeType->id()}",
name: $nodeType->label(),
description: $nodeType->getDescription(),
mimeType: 'application/json',
text: NULL,
);
}
return $resources;
}
/**
* {@inheritdoc}
*/
public function getResourceTemplates(): array {
$nodeTypes = $this->entityTypeManager->getStorage('node_type')
->loadMultiple();
$resourceTemplates = [];
foreach ($nodeTypes as $nodeType) {
if (!$this->isContentTypeEnabled($nodeType->id())) {
continue;
}
$resourceTemplates[] = new ResourceTemplate(
uriTemplate: "node/{$nodeType->id()}/{id}",
name: $nodeType->label(),
description: $nodeType->getDescription(),
mimeType: 'application/json',
);
}
return $resourceTemplates;
}
/**
* {@inheritdoc}
*/
public function readResource(string $resourceId): array {
$parts = explode('/', $resourceId);
if (count($parts) < 2 || $parts[0] !== 'node') {
throw new \InvalidArgumentException(
"Invalid resource ID format: $resourceId"
);
}
if (!$this->isContentTypeEnabled($parts[1])) {
throw new \InvalidArgumentException("Unknown resource Id: $resourceId");
}
if (count($parts) === 2) {
return $this->readContentTypeInfo($parts[1]);
}
if (count($parts) === 3) {
return $this->readNodeContent($parts[1], $parts[2]);
}
throw new \InvalidArgumentException("Unknown resource Id: $resourceId");
}
/**
* Read and return node content.
*/
private function readNodeContent(string $contentType, string $nodeId): array {
$nodes = $this->entityTypeManager->getStorage('node')
->loadByProperties([
'nid' => $nodeId,
'type' => $contentType,
]);
$node = reset($nodes);
if (!$node instanceof NodeInterface) {
throw new \InvalidArgumentException("Node not found: $nodeId");
}
$fieldDefinitions = $this->entityFieldManager->getFieldDefinitions(
'node', $contentType
);
$nodeData = [];
foreach ($fieldDefinitions as $fieldName => $definition) {
if ($node->hasField($fieldName) && !$node->get($fieldName)->isEmpty()
&& $this->isSupportedField($definition)
) {
$field = $node->get($fieldName);
$nodeData[$fieldName] = $definition->getFieldStorageDefinition()
->isMultiple() ?
array_map(
fn($item) => $item['value'], $field->getValue()
) : $field->getString();
}
}
return [
new Resource(
uri: "node/$contentType/$nodeId",
name: $node->getTitle(),
description: NULL,
mimeType: 'application/json',
text: json_encode(
$nodeData, JSON_UNESCAPED_UNICODE
),
),
];
}
/**
* Read and return content type information.
*/
private function readContentTypeInfo(string $contentType): array {
$nodeType = $this->entityTypeManager->getStorage('node_type')
->load($contentType);
if (!$nodeType) {
throw new \InvalidArgumentException(
"Content type not found: $contentType"
);
}
$fieldDefinitions = $this->entityFieldManager->getFieldDefinitions(
'node', $contentType
);
$fields = [];
foreach ($fieldDefinitions as $fieldName => $definition) {
// @todo This is temporary solution and need to be refactored.
// Expose only title, body and user defined fields that are supported.
if (!$this->isSupportedField($definition)) {
continue;
}
$fields[$fieldName] = [
'name' => $definition->getLabel(),
'type' => $definition->getType(),
'description' => $definition->getDescription(),
'required' => $definition->isRequired(),
'multiple' => $definition->getFieldStorageDefinition()->isMultiple(),
];
}
return [
new Resource(
uri: "node/$contentType",
name: 'Content type info for ' . $nodeType->label(),
description: "Fields and properties for content type $contentType. This is only available and supported fields.",
mimeType: 'application/json',
text: json_encode([
'name' => $nodeType->label(),
'id' => $nodeType->id(),
'description' => $nodeType->getDescription(),
'fields' => $fields,
], JSON_UNESCAPED_UNICODE),
),
];
}
/**
* {@inheritdoc}
*/
public function getTools(): array {
$enabledContentTypes = array_keys(
array_filter(
$this->getConfiguration()['config']['content_types'] ?? [],
static fn($enabled) => $enabled === 1,
)
);
// If no content types are enabled, return an empty tools array
// to avoid JSON Schema validation errors with empty enums.
if (empty($enabledContentTypes)) {
return [];
}
return [
new Tool(
name: "search-content",
description: 'Search content using filters. Multiple filters are combined with AND logic.',
inputSchema: [
'type' => 'object',
'properties' => [
'contentType' => [
'type' => 'string',
'description' => 'Content type machine name',
'enum' => $enabledContentTypes,
],
'filters' => [
'type' => 'array',
'description' => 'Array of filter conditions',
'items' => [
'type' => 'object',
'properties' => [
'field' => [
'type' => 'string',
'description' => 'Field name to filter on',
],
'value' => [
'description' => 'Value to filter by',
'oneOf' => [
['type' => 'string'],
['type' => 'number'],
[
'type' => 'array',
'items' => [
'oneOf' => [
['type' => 'string'],
['type' => 'number'],
],
],
],
],
],
'operator' => [
'type' => 'string',
'description' => 'Comparison operator',
'enum' => [
'=',
'<>',
'>',
'>=',
'<',
'<=',
'CONTAINS',
'STARTS_WITH',
'ENDS_WITH',
'IN',
'NOT IN',
'BETWEEN',
'IS NULL',
'IS NOT NULL',
],
'default' => '=',
],
],
'required' => ['field', 'value'],
],
],
'limit' => [
'type' => 'integer',
'description' => 'Maximum number of results',
'default' => 10,
],
'offset' => [
'type' => 'integer',
'description' => 'Starting point for pagination',
'default' => 0,
],
'sort' => [
'type' => 'object',
'description' => 'Sort options',
'properties' => [
'field' => [
'type' => 'string',
'description' => 'Field name to sort by',
],
'order' => [
'type' => 'string',
'description' => 'Sort order',
'enum' => ['ASC', 'DESC'],
'default' => 'ASC',
],
],
'required' => ['field'],
],
],
'required' => ['contentType', 'filters'],
],
),
];
}
/**
* {@inheritdoc}
*/
public function executeTool(string $toolId, mixed $arguments): array {
$sanitizedName = 'search_content';
if ($toolId === $sanitizedName || $toolId === md5('search-content')) {
return $this->searchContent($arguments);
}
return [];
}
/**
* Execute content search based on provided filters.
*/
public function searchContent(array $arguments): array {
$contentType = $arguments['contentType'];
if (!is_string($contentType)) {
throw new \InvalidArgumentException('Content type must be a string');
}
$filters = $arguments['filters'];
if (!is_array($filters)) {
throw new \InvalidArgumentException('Filters must be an array');
}
if (!$this->isContentTypeEnabled($contentType)) {
throw new \InvalidArgumentException("Unknown content type: $contentType");
}
$limit = $arguments['limit'] ?? 10;
$offset = $arguments['offset'] ?? 0;
$query = $this->entityTypeManager->getStorage('node')->getQuery()
->accessCheck(TRUE)
->condition('type', $contentType)
->condition('status', 1)
->range($offset, $limit)
->sort('created', 'DESC');
$fieldDefinitions = $this->entityFieldManager->getFieldDefinitions(
'node', $contentType
);
foreach ($filters as $filter) {
$field = $filter['field'];
if (!isset($fieldDefinitions[$field])
|| !$this->isSupportedField(
$fieldDefinitions[$field]
)
) {
throw new \InvalidArgumentException("Unknown field: $field");
}
$value = $filter['value'];
$operator = $filter['operator'] ?? '=';
switch ($operator) {
case 'CONTAINS':
$query->condition($field, '%' . $value . '%', 'LIKE');
break;
case 'STARTS_WITH':
$query->condition($field, $value . '%', 'LIKE');
break;
case 'ENDS_WITH':
$query->condition($field, '%' . $value, 'LIKE');
break;
case 'IN':
$query->condition($field, (array) $value, 'IN');
break;
case 'NOT IN':
$query->condition($field, (array) $value, 'NOT IN');
break;
case 'BETWEEN':
if (!is_array($value) || count($value) !== 2) {
throw new \InvalidArgumentException(
'BETWEEN operator requires an array with exactly 2 values'
);
}
$query->condition($field, $value, 'BETWEEN');
break;
case 'IS NULL':
$query->notExists($field);
break;
case 'IS NOT NULL':
$query->exists($field);
break;
default:
$query->condition($field, $value, $operator);
}
}
if (isset($arguments['sort'])) {
$sort = $arguments['sort'];
if (!isset($fieldDefinitions[$sort['field']])
|| !$this->isSupportedField(
$fieldDefinitions[$sort['field']]
)
) {
throw new \InvalidArgumentException("Unknown field: $sort[field]");
}
$query->sort($sort['field'], $sort['order'] === 'ASC' ? 'ASC' : 'DESC');
}
$nids = $query->execute();
$nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($nids);
$resources = [];
foreach ($nodes as $node) {
$resources[] = [
"type" => "resource",
'resource' => $this->readNodeContent($contentType, $node->id())[0],
];
}
return $resources;
}
/**
* Check if content type is enabled in configuration.
*
* @param string $contentType
* The content type machine name.
*/
private function isContentTypeEnabled(string $contentType): bool {
return $this->getConfiguration()['config']['content_types'][$contentType] ??
FALSE;
}
/**
* Check if field type is supported.
*
* @param \Drupal\Core\Field\FieldDefinitionInterface $fieldDefinition
* The field type.
*
* @return bool
* TRUE if field type is supported, FALSE otherwise.
*/
private function isSupportedField(
FieldDefinitionInterface $fieldDefinition,
): bool {
$supportedTypes = [
'string',
'string_long',
'list_string',
'datetime',
'boolean',
'text_long',
];
$fieldName = $fieldDefinition->getName();
if (in_array($fieldName, ['title', 'body'])) {
return TRUE;
}
if (str_starts_with($fieldName, 'field_')
&& in_array(
$fieldDefinition->getType(), $supportedTypes
)
) {
return TRUE;
}
return FALSE;
}
}
