mcp-1.x-dev/src/Plugin/Mcp/JsonApi.php

src/Plugin/Mcp/JsonApi.php
<?php

declare(strict_types=1);

namespace Drupal\mcp\Plugin\Mcp;

use Drupal\Core\Access\AccessResult;
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\Tool;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * Plugin implementation of the JSON:API MCP provider.
 */
#[Mcp(
  id: 'jsonapi',
  name: new TranslatableMarkup('JSON:API'),
  description: new TranslatableMarkup(
    'Search and retrieve content using Drupal JSON:API'
  )
)]
class JsonApi extends McpPluginBase implements ContainerFactoryPluginInterface {

  /**
   * The HTTP kernel.
   *
   * @var \Symfony\Component\HttpKernel\HttpKernelInterface
   */
  protected $httpKernel;

  /**
   * The JSON:API resource type repository.
   *
   * @var \Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface|null
   */
  protected $resourceTypeRepository;

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The JSON:API base path.
   *
   * @var string
   */
  protected $jsonApiBasePath;

  /**
   * The module handler.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ) {
    $instance = parent::create(
      $container,
      $configuration,
      $plugin_id,
      $plugin_definition,
    );

    $instance->httpKernel = $container->get('http_kernel');
    $instance->resourceTypeRepository = $container->get(
      'jsonapi.resource_type.repository',
      ContainerInterface::NULL_ON_INVALID_REFERENCE
    );
    $instance->requestStack = $container->get('request_stack');
    $instance->currentUser = $container->get('current_user');
    $instance->entityTypeManager = $container->get('entity_type.manager');
    // Get JSON:API base path safely.
    try {
      $instance->jsonApiBasePath = $container->getParameter('jsonapi.base_path');
    }
    catch (\Exception $e) {
      $instance->jsonApiBasePath = '/jsonapi';
    }
    $instance->moduleHandler = $container->get('module_handler');

    return $instance;
  }

  /**
   * {@inheritdoc}
   */
  public function checkRequirements(): bool {
    return $this->resourceTypeRepository !== NULL;
  }

  /**
   * {@inheritdoc}
   */
  public function getRequirementsDescription(): string {
    if (!$this->checkRequirements()) {
      return (string) $this->t('The JSON:API module must be installed and enabled.');
    }
    return '';
  }

  /**
   * Check if jsonapi_schema module is enabled.
   */
  protected function isSchemaModuleEnabled(): bool {
    return $this->moduleHandler->moduleExists('jsonapi_schema');
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration(): array {
    $config = parent::defaultConfiguration();
    $config['enabled'] = FALSE;
    $config['config']['allowed_resource_types'] = [];

    return $config;
  }

  /**
   * Get allowed resource types based on configuration.
   *
   * @return array
   *   Array of allowed resource type names.
   */
  protected function getAllowedResourceTypes(): array {
    $resource_types = [];

    if (!$this->resourceTypeRepository) {
      return $resource_types;
    }

    $config = $this->getConfiguration();
    $allowed_types = $config['config']['allowed_resource_types'] ?? [];

    foreach ($this->resourceTypeRepository->all() as $resource_type) {
      if (!$resource_type->isInternal()) {
        $type_name = $resource_type->getTypeName();
        // Check if type is allowed.
        if (empty($allowed_types) || in_array($type_name, $allowed_types)) {
          $resource_types[] = $type_name;
        }
      }
    }

    return $resource_types;
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(
    array $form,
    FormStateInterface $form_state,
  ): array {
    $config = $this->getConfiguration();

    $form['notice'] = [
      '#type'   => 'markup',
      '#markup' => '<div class="messages messages--warning">' .
      $this->t(
          '<strong>Note:</strong> This plugin provides access to your Drupal content via JSON:API. Only enable if you need MCP clients to access your content.'
      ) .
      '</div>',
    ];

    // Add notice about jsonapi_schema module.
    if (!$this->isSchemaModuleEnabled()) {
      $form['schema_notice'] = [
        '#type'   => 'markup',
        '#markup' => '<div class="messages messages--info">' .
        $this->t(
            '<strong>Tip:</strong> Install and enable the JSON:API Schema module to get access to the schema tool, which helps LLMs understand available fields and relationships.'
        ) .
        '</div>',
      ];
    }

    // Get all available resource types for form options.
    $resource_types = [];
    if ($this->resourceTypeRepository) {
      foreach ($this->resourceTypeRepository->all() as $resource_type) {
        if (!$resource_type->isInternal()) {
          $type_name = $resource_type->getTypeName();
          $resource_types[$type_name] = $type_name;
        }
      }
    }

    $form['allowed_resource_types'] = [
      '#type'          => 'checkboxes',
      '#title'         => $this->t('Allowed Resource Types'),
      '#description'   => $this->t(
        'Select which resource types can be accessed. Leave empty to allow all.'
      ),
      '#options'       => $resource_types,
      '#default_value' => $config['config']['allowed_resource_types'] ?? [],
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitConfigurationForm(
    array &$form,
    FormStateInterface $form_state,
  ): void {
    parent::submitConfigurationForm($form, $form_state);

    if (!$form_state->getErrors()) {
      $this->configuration['config']['allowed_resource_types'] = array_filter(
        $form_state->getValue(['config', 'allowed_resource_types']) ?? []
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getTools(): array {
    $tools = [];

    $tools[] = new Tool(
      name: 'jsonapi_read',
      description: "
        Retrieve entities via JSON:API. Supports filtering, pagination, and including related resources.
        If jsonapi_schema tool is available, use it to understand fields and relationships and only then use this tool.
        Only request necessary fields to optimize performance and reduce token usage.
      ",
      inputSchema: $this->getReadToolSchema(),
    );

    if ($this->isSchemaModuleEnabled()) {
      $tools[] = new Tool(
        name: 'jsonapi_schema',
        description: 'Retrieve JSON Schema for resource types. Helps understand available fields, relationships, and data types.',
        inputSchema: $this->getSchemaToolSchema(),
      );
    }

    return $tools;
  }

  /**
   * Get the schema for the read tool.
   */
  protected function getReadToolSchema(): array {
    $resource_types = $this->getAllowedResourceTypes();

    return [
      'type'       => 'object',
      'properties' => [
        'resource_type' => [
          'type'        => 'string',
          'enum'        => $resource_types,
          'description' => 'Resource type (e.g., node--article, user--user)',
        ],
        'uuid'          => [
          'type'        => 'string',
          'description' => 'UUID of specific entity (optional, omit for collection)',
        ],
        'filters'       => $this->getFilterSchema(),
        'include'       => [
          'type'        => 'string',
          'description' => 'Related resources to include (comma-separated field names)',
        ],
        'page'          => [
          'type'       => 'object',
          'properties' => [
            'limit'  => [
              'type'    => 'integer',
              'default' => 10,
              'minimum' => 1,
              'maximum' => 50,
            ],
            'offset' => [
              'type'    => 'integer',
              'default' => 0,
              'minimum' => 0,
            ],
          ],
        ],
        'sort'          => [
          'type'        => 'string',
          'description' => 'Sort order (e.g., "-created" for newest first, "title" for alphabetical)',
        ],
        'fields'        => [
          'type'                 => 'object',
          'description'          => 'Sparse fieldsets: specify which fields to include for each resource type',
          'additionalProperties' => [
            'type'        => 'string',
            'description' => 'Comma-separated list of fields to include for this resource type',
          ],
          'examples'             => [
            ['node--article' => 'title,body,created,uid'],
            ['user--user' => 'name,mail'],
          ],
        ],
      ],
      'required'   => ['resource_type'],
    ];
  }

  /**
   * Get the filter schema for JSON:API.
   */
  protected function getFilterSchema(): array {
    return [
      'type'                 => 'object',
      'description'          => 'JSON:API filters supporting full specification including operators, groups, and relationships',
      'additionalProperties' => [
        'oneOf' => [
          // Simple filter (shorthand for equality)
          ['type' => 'string'],
          ['type' => 'number'],
          ['type' => 'boolean'],
          ['type' => 'null'],
          // Array for IN/NOT IN operators.
          [
            'type'  => 'array',
            'items' => [
              'oneOf' => [
                ['type' => 'string'],
                ['type' => 'number'],
                ['type' => 'boolean'],
              ],
            ],
          ],
          // Condition object.
          [
            'type'                 => 'object',
            'properties'           => [
              'path'     => [
                'type'        => 'string',
                'description' => 'Field path (e.g., "title", "uid.name", "field_tags.name")',
              ],
              'value'    => [
                'oneOf' => [
                  ['type' => 'string'],
                  ['type' => 'number'],
                  ['type' => 'boolean'],
                  ['type' => 'null'],
                  [
                    'type'  => 'array',
                    'items' => [
                      'oneOf' => [
                        ['type' => 'string'],
                        ['type' => 'number'],
                        ['type' => 'boolean'],
                      ],
                    ],
                  ],
                ],
              ],
              'operator' => [
                'type'    => 'string',
                'enum'    => [
                  '=',
                  '<>',
                  '>',
                  '>=',
                  '<',
                  '<=',
                  'STARTS_WITH',
                  'CONTAINS',
                  'ENDS_WITH',
                  'IN',
                  'NOT IN',
                  'BETWEEN',
                  'NOT BETWEEN',
                  'IS NULL',
                  'IS NOT NULL',
                ],
                'default' => '=',
              ],
              'memberOf' => [
                'type'        => 'string',
                'description' => 'Group ID this condition belongs to',
              ],
            ],
            'additionalProperties' => FALSE,
          ],
          // Group object.
          [
            'type'                 => 'object',
            'properties'           => [
              'group' => [
                'type'       => 'object',
                'properties' => [
                  'conjunction' => [
                    'type'    => 'string',
                    'enum'    => ['AND', 'OR'],
                    'default' => 'AND',
                  ],
                  'memberOf'    => [
                    'type'        => 'string',
                    'description' => 'Parent group ID',
                  ],
                ],
              ],
            ],
            'additionalProperties' => FALSE,
          ],
        ],
      ],
      'examples'             => [
        [
          'title'  => 'Simple filter',
          'filter' => ['status' => '1'],
        ],
        [
          'title'  => 'Filter with operator',
          'filter' => [
            'created' => [
              'value'    => '2023-01-01',
              'operator' => '>',
            ],
          ],
        ],
        [
          'title'  => 'Filter by relationship',
          'filter' => ['uid.name' => 'admin'],
        ],
        [
          'title'  => 'Complex filter with groups',
          'filter' => [
            'or-group'       => [
              'group' => ['conjunction' => 'OR'],
            ],
            'status-filter'  => [
              'path'     => 'status',
              'value'    => '1',
              'memberOf' => 'or-group',
            ],
            'promote-filter' => [
              'path'     => 'promote',
              'value'    => '1',
              'memberOf' => 'or-group',
            ],
          ],
        ],
      ],
    ];
  }

  /**
   * Get the schema for the schema tool.
   */
  protected function getSchemaToolSchema(): array {
    $resource_types = $this->getAllowedResourceTypes();

    return [
      'type'       => 'object',
      'properties' => [
        'resource_type' => [
          'type'        => 'string',
          'enum'        => $resource_types,
          'description' => 'Resource type to get schema for (e.g., node--article, user--user)',
        ],
        'schema_type'   => [
          'type'        => 'string',
          'enum'        => ['resource', 'collection', 'entrypoint'],
          'default'     => 'resource',
          'description' => 'Type of schema to retrieve: resource (object structure), collection (list structure), or entrypoint (all available resources)',
        ],
      ],
      'required'   => ['resource_type'],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function executeTool(string $toolId, mixed $arguments): array {
    $sanitizedReadName = $this->sanitizeToolName('jsonapi_read');
    $sanitizedSchemaName = $this->sanitizeToolName('jsonapi_schema');

    if ($toolId === $sanitizedReadName) {
      return $this->executeReadTool($arguments);
    }

    if ($toolId === $sanitizedSchemaName) {
      return $this->executeSchemaTool($arguments);
    }

    throw new \InvalidArgumentException('Tool not found: ' . $toolId);
  }

  /**
   * Execute the read tool.
   */
  protected function executeReadTool(array $arguments): array {
    if (!isset($arguments['resource_type'])) {
      throw new \InvalidArgumentException('Resource type is required');
    }
    $resource_type = $arguments['resource_type'];
    $uuid = $arguments['uuid'] ?? NULL;

    $config = $this->getConfiguration();
    $allowed_types = $config['config']['allowed_resource_types'] ?? [];
    if (!empty($allowed_types) && !in_array($resource_type, $allowed_types)) {
      throw new \InvalidArgumentException(
        'Resource type not allowed: ' . $resource_type
      );
    }

    $path = '/' . str_replace('--', '/', $resource_type);
    if ($uuid) {
      $path .= '/' . $uuid;
    }

    $query = [];
    if (isset($arguments['filters'])) {
      $query['filter'] = $arguments['filters'];
    }
    if (isset($arguments['include'])) {
      $query['include'] = $arguments['include'];
    }
    if (isset($arguments['page'])) {
      $query['page'] = $arguments['page'];
    }
    if (isset($arguments['sort'])) {
      $query['sort'] = $arguments['sort'];
    }
    if (isset($arguments['fields'])) {
      $query['fields'] = $arguments['fields'];
    }

    try {
      $data = $this->makeInternalRequest($path, $query);

      return [
        [
          'type' => 'text',
          'text' => json_encode(
            $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
          ),
        ],
      ];
    }
    catch (\Exception $e) {
      throw new \Exception('JSON:API request failed: ' . $e->getMessage());
    }
  }

  /**
   * Execute the schema tool.
   */
  protected function executeSchemaTool(array $arguments): array {
    if (!isset($arguments['resource_type'])) {
      throw new \InvalidArgumentException('Resource type is required');
    }
    $resource_type = $arguments['resource_type'];
    $schema_type = $arguments['schema_type'] ?? 'resource';

    $config = $this->getConfiguration();
    $allowed_types = $config['config']['allowed_resource_types'] ?? [];
    if (!empty($allowed_types) && !in_array($resource_type, $allowed_types)) {
      throw new \InvalidArgumentException(
        'Resource type not allowed: ' . $resource_type
      );
    }

    // Determine the appropriate schema path.
    $path = match ($schema_type) {
      'entrypoint' => '/schema',
      'collection' => '/' . str_replace('--', '/', $resource_type)
        . '/collection/schema',
      default => '/' . str_replace('--', '/', $resource_type) . '/resource/schema',
    };

    try {
      $data = $this->makeInternalRequest($path);

      return [
        [
          'type' => 'text',
          'text' => json_encode(
            $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
          ),
        ],
      ];
    }
    catch (\Exception $e) {
      throw new \Exception('JSON:API Schema request failed: ' . $e->getMessage());
    }
  }

  /**
   * Make an internal JSON:API request.
   */
  protected function makeInternalRequest(
    string $path,
    array $query = [],
  ): array {
    $current_request = $this->requestStack->getCurrentRequest();

    $url = $this->jsonApiBasePath . $path;
    if ($query) {
      $url .= '?' . http_build_query($query);
    }

    $request = Request::create(
      $url,
      'GET',
      [],
      $current_request->cookies->all(),
      [],
      $current_request->server->all()
    );

    if ($session = $current_request->getSession()) {
      $request->setSession($session);
    }

    $request->headers->set('Accept', 'application/vnd.api+json');

    $response = $this->httpKernel->handle(
      $request, HttpKernelInterface::SUB_REQUEST
    );

    if ($response->getStatusCode() >= 400) {
      $content = $response->getContent();
      $error_data = json_decode($content, TRUE);
      $error_message = $error_data['errors'][0]['detail'] ?? 'Unknown error';

      throw new \Exception('JSON:API error: ' . $error_message);
    }

    // Parse response.
    $content = $response->getContent();

    return json_decode($content, TRUE);
  }

  /**
   * {@inheritdoc}
   */
  public function hasAccess(): AccessResult {
    $parentAccess = parent::hasAccess();
    if (!$parentAccess->isAllowed()) {
      return $parentAccess;
    }

    return AccessResult::allowedIfHasPermission($this->currentUser, 'access content');
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc