errogaht/doctrine-mcp-bundle

Secure, attribute-driven Doctrine ORM CRUD over Model Context Protocol for Symfony applications.

Maintainers

Package info

github.com/errogaht/doctrine-mcp-bundle

Type:symfony-bundle

pkg:composer/errogaht/doctrine-mcp-bundle

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 1

Stars: 0

Open Issues: 0

v0.1.2 2026-07-20 14:39 UTC

This package is auto-updated.

Last update: 2026-07-20 14:41:08 UTC


README

Doctrine MCP Bundle exposes an explicitly allow-listed Doctrine ORM model and application-defined capabilities through the Model Context Protocol. It is an independent Symfony bundle built on the official mcp/sdk Streamable HTTP transport and does not require symfony/mcp-bundle.

The bundle is intended for authenticated, multi-user Symfony applications. Symfony Security identifies the caller, mandatory query scopes constrain every Doctrine query, and PHP attributes define the public contract. Doctrine mapping by itself never exposes an entity or field.

Features

  • Expose selected Doctrine entities and fields with PHP attributes;
  • provide generic search, read, create, update, and delete MCP tools;
  • keep writes disabled unless explicitly enabled per entity and field;
  • preview every mutation with dryRun: true by default;
  • enforce tenant, ownership, workflow, and other row-level restrictions with Doctrine QueryBuilder scopes;
  • combine row scopes with Symfony voters;
  • resolve writable associations through the same scoped query pipeline;
  • publish actor-aware reference values for enums, selectors, and dictionaries;
  • add custom MCP tools, resources, resource templates, and prompts as ordinary Symfony services;
  • bind MCP sessions to the authenticated Symfony user;
  • emit traceable, sanitized logs and mutation audit records;
  • use Streamable HTTP with the official PHP MCP SDK.

Requirements

  • PHP 8.1 or later;
  • Symfony 6.4, 7.4, or 8.x;
  • Doctrine ORM 2.20 or later, or Doctrine ORM 3.3 or later;
  • an existing Symfony firewall and an authenticated UserInterface.

Installation

composer require errogaht/doctrine-mcp-bundle:^0.1

If Symfony Flex does not register the bundle automatically, enable it manually:

<?php

// config/bundles.php

return [
    // ...
    Errogaht\DoctrineMcpBundle\DoctrineMcpBundle::class => ['all' => true],
];

Load the bundle route:

# config/routes/doctrine_mcp.yaml
doctrine_mcp:
  resource: .
  type: doctrine_mcp

The default MCP endpoint is /_mcp. Protect it with the host application's firewall and access rules:

# config/packages/security.yaml
security:
  # Keep the application's existing provider and authentication mechanism.
  firewalls:
    main:
      # ...

  access_control:
    - { path: ^/_mcp$, roles: ROLE_USER }

The bundle does not issue access tokens and does not implement OAuth. Session cookies, JWT, OAuth access tokens, service credentials, or another authentication mechanism may be used as long as the Symfony firewall resolves an authenticated UserInterface for the request.

Quick start

1. Configure the endpoint

Create the package configuration:

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  server:
    name: "Acme application"
    version: "1.0.0"
    description: "Authenticated access to selected Acme application data."

  http:
    path: /_mcp
    allowed_hosts: ["app.example.com"]
    session:
      cache_pool: cache.app
      ttl: 3600

  query:
    default_limit: 25
    max_limit: 100

[!IMPORTANT] Configure every public hostname in allowed_hosts. Setting allowed_hosts: false disables DNS-rebinding protection and should only be used behind a trusted proxy that performs an equivalent check.

2. Expose a Doctrine entity

Apply McpEntity to the class and McpField only to properties that belong to the public MCP contract:

<?php

namespace App\Entity;

use App\Mcp\Scope\OrderScope;
use Doctrine\ORM\Mapping as ORM;
use Errogaht\DoctrineMcpBundle\Attribute\FilterOperator;
use Errogaht\DoctrineMcpBundle\Attribute\McpEntity;
use Errogaht\DoctrineMcpBundle\Attribute\McpField;
use Errogaht\DoctrineMcpBundle\Attribute\McpOperation;

#[ORM\Entity]
#[McpEntity(
    name: 'Order',
    description: 'An order owned by the current business.',
    operations: [McpOperation::Read, McpOperation::Update],
    scopes: [OrderScope::class],
    permissions: ['update' => 'ORDER_EDIT'],
)]
final class Order
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    #[McpField(description: 'The order identifier.')]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[McpField(
        description: 'The delivery address while the order is editable.',
        updatable: true,
        operators: [FilterOperator::Eq, FilterOperator::Contains],
        sortable: true,
    )]
    private string $deliveryAddress = '';

    #[ORM\Column(length: 32)]
    #[McpField(description: 'The order workflow status.')]
    private string $status = 'new';

    #[ORM\Column]
    #[McpField(readable: false, serverControlled: true, sensitive: true)]
    private int $businessId;

    // Getters and setters are omitted from this documentation example.
}

Read is the only operation enabled by default. Create, update, and delete must be enabled on the entity, and individual fields must separately opt in to creatable or updatable.

3. Add a mandatory row scope

Every exposed entity must have at least one scope. A scope adds actor-specific predicates to Doctrine queries and owns server-controlled creation data:

<?php

namespace App\Mcp\Scope;

use App\Entity\Order;
use Doctrine\ORM\QueryBuilder;
use Errogaht\DoctrineMcpBundle\Attribute\McpOperation;
use Errogaht\DoctrineMcpBundle\Metadata\EntityDefinition;
use Errogaht\DoctrineMcpBundle\Scope\EntityScopeInterface;
use Errogaht\DoctrineMcpBundle\Security\ActorContext;

/** Restricts orders to the actor's business and enforces editable workflow states. */
final class OrderScope implements EntityScopeInterface
{
    public function apply(
        QueryBuilder $query,
        string $alias,
        EntityDefinition $entity,
        McpOperation $operation,
        ActorContext $actor,
    ): void {
        $businessId = $actor->user->getBusiness()->getId();

        $query
            ->andWhere($alias.'.businessId = :mcp_business_id')
            ->setParameter('mcp_business_id', $businessId);

        if (McpOperation::Update === $operation) {
            $query
                ->andWhere($alias.'.status = :mcp_editable_status')
                ->setParameter('mcp_editable_status', 'new');
        }
    }

    public function initialize(object $entity, EntityDefinition $definition, ActorContext $actor): void
    {
        if ($entity instanceof Order) {
            // Tenant ownership is controlled by the server, never by MCP input.
            $entity->setBusinessId($actor->user->getBusiness()->getId());
        }
    }

    public function assert(
        object $entity,
        EntityDefinition $definition,
        McpOperation $operation,
        ActorContext $actor,
    ): void {
        if ($entity instanceof Order && $entity->getBusinessId() !== $actor->user->getBusiness()->getId()) {
            throw new \DomainException('Cross-business mutation rejected.');
        }
    }
}

With standard Symfony service defaults, the scope is autowired, autoconfigured, and tagged automatically:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true

  App\:
    resource: "../src/"

4. Validate the public contract

Run the validation command before exposing the endpoint:

php bin/console doctrine-mcp:validate
php bin/console doctrine-mcp:debug Order

Connect an MCP client to https://app.example.com/_mcp and provide the authentication headers or cookies required by the application's firewall. MCP session negotiation and the Mcp-Session-Id header are handled by compatible clients automatically.

How entity exposure works

The public contract is an allow-list assembled from both Doctrine metadata and MCP attributes:

  1. Doctrine must manage the class and property;
  2. the class must have #[McpEntity];
  3. every visible property must have #[McpField];
  4. the requested operation must be enabled on the entity;
  5. a field must be separately writable for create or update;
  6. every addressed row must pass all configured query scopes;
  7. configured Symfony voters and Validator constraints must pass.

Unannotated Doctrine entities and properties are invisible. The bundle never infers exposure from ORM mapping, serializer groups, forms, or API Platform metadata.

Entity configuration

McpEntity

McpEntity defines the public entity name and its operation-level policy:

Option Type Default Description
name ?string Doctrine class short name Stable public name used by all generic tools.
description ?string null Description shown to MCP clients and agents.
operations list<McpOperation> [Read] Allowed CRUD operations.
scopes list<class-string> [] Entity-specific EntityScopeInterface services. At least one entity or global scope is required.
permissions array<string, string> [] Symfony voter attributes keyed by read, create, update, or delete.
handler ?string null Service ID of a custom EntityMutationHandlerInterface.

Entity names must be unique across all Doctrine entity managers.

McpField

McpField defines a property's public representation and field-level policy:

Option Type Default Description
name ?string Property name Public field name.
description ?string null Human-readable meaning and usage constraints.
readable bool true Include the field in MCP output.
creatable bool false Accept the field during create.
updatable bool false Accept the field during update.
filterable bool true Allow filtering for scalar fields.
operators list<FilterOperator> [Eq] Explicitly allowed filter operators.
sortable bool false Allow ordering by the field.
sensitive bool false Replace serialized and audited values with [REDACTED].
serverControlled bool false Reject the field in create and update input even if a write flag was set accidentally.
format ?string null Format hint returned by doctrine_describe_entity.
reference ?string null Name of a ReferenceProviderInterface available through doctrine_reference_values.

[!NOTE] format is descriptive metadata for the agent. Use PHP types, Doctrine types, Symfony Validator constraints, or a custom mutation handler for server-side validation.

Supported values

The built-in codec safely converts common Doctrine and PHP values:

Value MCP representation
Strings, integers, floats, booleans, null Native JSON value
Doctrine integer types JSON integer
Doctrine float and decimal types JSON number
Doctrine boolean JSON boolean
Date and datetime types ISO-8601 string
Symfony Uuid RFC 4122 string
Backed enum Backing value
Unit enum Case name
Doctrine JSON JSON object or array
Association { "entity": "EntityName", "id": { ... } }

Unknown object graphs are rejected instead of being serialized recursively.

Built-in MCP tools

The bundle exposes a fixed tool surface. Adding more entities does not create one tool per entity:

Tool Purpose
doctrine_list_entities List entities and operations available to the current actor.
doctrine_describe_entity Describe identifiers, fields, formats, filters, write flags, and references.
doctrine_search Search scoped records with allow-listed filters, sorting, limits, and cursor pagination.
doctrine_get Read one scoped record by its complete identifier object.
doctrine_create Preview or create an entity.
doctrine_update Preview or update writable fields on a scoped entity.
doctrine_delete Preview or delete a scoped entity.
doctrine_reference_values Read actor-aware allowed-value suggestions from a reference provider.

All successful results contain ok, operation, and traceId.

Identifiers

Identifiers are always objects, including single-column identifiers:

{
  "entity": "Order",
  "id": { "id": 42 }
}

Composite identifiers must contain every identifier field and no additional fields.

Search and filtering

Equality can use the short form:

{
  "entity": "Order",
  "filters": {
    "status": "new"
  }
}

Other operators use an explicit specification:

{
  "entity": "Order",
  "filters": {
    "deliveryAddress": { "op": "contains", "value": "Main Street" },
    "createdAt": { "op": "gte", "value": "2026-01-01T00:00:00+00:00" },
    "status": { "op": "in", "value": ["new", "confirmed"] }
  },
  "sort": { "field": "createdAt", "direction": "DESC" },
  "limit": 25,
  "cursor": null
}

Supported operators are eq, in, contains, gt, gte, lt, and lte. An operator works only when it is listed in that field's McpField.operators. Associations cannot be filtered through the generic tool.

Search returns items and an opaque nextCursor. It deliberately does not execute a separate total-count query. Without an explicit sort, results are ordered by Doctrine identifier fields.

Mutation preview

Create, update, and delete default to dryRun: true:

{
  "entity": "Order",
  "id": { "id": 42 },
  "changes": {
    "deliveryAddress": "10 Main Street"
  },
  "dryRun": true
}

A successful preview returns sanitized before and after values, but does not flush the standard Doctrine mutation. The caller must review the preview and explicitly send dryRun: false to apply it.

Symfony Validator constraints run for both previews and applied create/update operations. Mutation attempts also produce audit records.

Associations

Associations are represented as shallow references; the bundle never serializes an unbounded Doctrine object graph:

{
  "entity": "Customer",
  "id": { "id": 7 }
}

For a writable to-one association, send either an identifier object or a scalar for a single-column identifier:

{
  "customer": { "id": 7 }
}

The target entity must also be exposed with McpEntity. It is resolved using the target entity's read scopes and voter, so a caller cannot attach an inaccessible record.

Writable collections use explicit add/remove change sets:

{
  "tags": {
    "add": [{ "id": 3 }, { "id": 5 }],
    "remove": [{ "id": 2 }]
  }
}

The entity must provide conventional addTag() and removeTag() methods, or use a custom mutation handler. The number of association changes is bounded by query.max_association_items.

Row-level scopes

EntityScopeInterface is the primary data-isolation boundary:

  • apply() adds bound predicates to search and identifier queries;
  • initialize() fills tenant ownership and other server-controlled values during create;
  • assert() verifies state and ownership before a mutation is accepted.

Scopes are applied to search, get, update, delete, and writable association resolution. The bundle does not use EntityManager::find() for actor-addressable records.

Use bound parameters in every scope. Scope logic may add joins and predicates for ownership, organization membership, soft deletion, geographic access, or workflow state.

Global scopes apply to every exposed entity:

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  global_scopes:
    - App\Mcp\Scope\ApplicationStatusScope

If an application intentionally exposes unscoped records, opt in explicitly:

use Errogaht\DoctrineMcpBundle\Scope\UnrestrictedScope;

#[McpEntity(scopes: [UnrestrictedScope::class])]
final class PublicCountry
{
    // ...
}

[!WARNING] UnrestrictedScope is a security-review marker, not a convenience default. Never use it for tenant-owned or user-owned data.

If Symfony autoconfiguration is disabled, tag scope services manually with doctrine_mcp.scope.

Symfony voters

Scopes decide which rows are addressable. Voters decide whether the current actor may perform an operation:

#[McpEntity(
    operations: [McpOperation::Read, McpOperation::Update, McpOperation::Delete],
    scopes: [OrderScope::class],
    permissions: [
        'read' => 'ORDER_VIEW',
        'update' => 'ORDER_EDIT',
        'delete' => 'ORDER_DELETE',
    ],
)]
final class Order
{
    // ...
}

Discovery and collection checks authorize against the entity class name. Record-specific get, update, and delete checks authorize against the resolved entity object. Create is authorized against the initialized entity object.

Scopes remain mandatory when voters are configured. A voter is not a replacement for a query predicate because filtering unauthorized rows after loading them can leak data and identifiers.

Actor providers

The default actor provider reads the authenticated user from Symfony Security and creates an ActorContext containing the user identifier, original UserInterface, and roles.

Implement ActorProviderInterface to add tenant identifiers or other scalar security attributes:

<?php

namespace App\Mcp;

use Errogaht\DoctrineMcpBundle\Security\ActorContext;
use Errogaht\DoctrineMcpBundle\Security\ActorProviderInterface;
use Errogaht\DoctrineMcpBundle\Security\UnauthenticatedActorException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\User\UserInterface;

/** Maps the authenticated application user into the stable MCP security context. */
final class ActorProvider implements ActorProviderInterface
{
    public function __construct(private readonly Security $security)
    {
    }

    public function current(): ActorContext
    {
        $user = $this->security->getUser();
        if (!$user instanceof UserInterface) {
            throw new UnauthenticatedActorException('Authentication is required.');
        }

        return new ActorContext(
            id: $user->getUserIdentifier(),
            user: $user,
            roles: array_values($user->getRoles()),
            attributes: ['business_id' => $user->getBusiness()->getId()],
        );
    }
}

Configure its service ID:

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  actor_provider: App\Mcp\ActorProvider

Actor attributes must contain only scalar values or null. Scopes and custom handlers may inspect both ActorContext.attributes and the original ActorContext.user.

Reference values

Reference providers publish bounded, searchable, actor-aware value lists. They are useful for time zones, statuses, countries, warehouses, or project-specific dictionaries:

<?php

namespace App\Mcp\Reference;

use Errogaht\DoctrineMcpBundle\Reference\ReferencePage;
use Errogaht\DoctrineMcpBundle\Reference\ReferenceProviderInterface;
use Errogaht\DoctrineMcpBundle\Security\ActorContext;

/** Provides agent-readable time-zone choices without exposing an internal table. */
final class TimezoneReferenceProvider implements ReferenceProviderInterface
{
    public function name(): string
    {
        return 'timezones';
    }

    public function values(ActorContext $actor, ?string $query, int $limit, ?string $cursor): ReferencePage
    {
        $values = array_filter(
            \DateTimeZone::listIdentifiers(),
            static fn (string $timezone): bool => null === $query || str_contains(strtolower($timezone), strtolower($query)),
        );
        $items = array_map(
            static fn (string $timezone): array => ['value' => $timezone, 'label' => $timezone],
            array_slice(array_values($values), 0, $limit),
        );

        return new ReferencePage($items);
    }
}

Attach the provider name to a field:

#[McpField(
    description: 'IANA time-zone identifier.',
    updatable: true,
    format: 'iana-timezone',
    reference: 'timezones',
)]
private string $timezone = 'UTC';

The agent calls doctrine_reference_values with reference, optional query, limit, and cursor. Providers are tagged automatically; without autoconfiguration, use doctrine_mcp.reference_provider.

[!NOTE] A reference provider documents and suggests values. Enforce membership with Symfony Validator constraints or domain logic when the field is written.

Custom mutation handlers

The default mutation pipeline uses a no-argument constructor, Symfony PropertyAccess setters, Doctrine persistence, and conventional collection methods. Use EntityMutationHandlerInterface for aggregate factories, value objects, workflow transitions, or domain services:

<?php

namespace App\Mcp\Mutation;

use App\Entity\Order;
use App\Service\OrderManager;
use Errogaht\DoctrineMcpBundle\Metadata\EntityDefinition;
use Errogaht\DoctrineMcpBundle\Mutation\EntityMutationHandlerInterface;
use Errogaht\DoctrineMcpBundle\Security\ActorContext;

/** Routes MCP mutations through the same domain service used by the application. */
final class OrderMutationHandler implements EntityMutationHandlerInterface
{
    public function __construct(private readonly OrderManager $orders)
    {
    }

    public function create(EntityDefinition $definition, array $values, ActorContext $actor, bool $dryRun): object
    {
        return $this->orders->createForMcp($values, $actor->user, $dryRun);
    }

    public function update(object $entity, EntityDefinition $definition, array $changes, ActorContext $actor, bool $dryRun): object
    {
        if (!$entity instanceof Order) {
            throw new \LogicException('Unexpected MCP entity.');
        }

        return $this->orders->updateForMcp($entity, $changes, $actor->user, $dryRun);
    }

    public function delete(object $entity, EntityDefinition $definition, ActorContext $actor, bool $dryRun): void
    {
        $this->orders->deleteForMcp($entity, $actor->user, $dryRun);
    }
}

Reference the handler by service ID:

#[McpEntity(
    operations: [McpOperation::Read, McpOperation::Create, McpOperation::Update],
    scopes: [OrderScope::class],
    handler: OrderMutationHandler::class,
)]
final class Order
{
    // ...
}

Custom handlers receive the dryRun flag and own their persistence and transaction behavior. They must not persist during a preview. Entity scopes, voters, and Symfony validation still run around handler results. Without autoconfiguration, tag handlers with doctrine_mcp.mutation_handler.

Custom MCP capabilities

The same endpoint can expose application-specific tools, resources, resource templates, and prompts. Put autowired services in src/Mcp and use attributes from the official PHP MCP SDK.

Custom tools

<?php

namespace App\Mcp;

use App\Service\OrderReportService;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\ToolAnnotations;

/** Provides an aggregate report that does not map to generic entity CRUD. */
final class OrderReportTools
{
    public function __construct(private readonly OrderReportService $reports)
    {
    }

    /** @return array{orders: int, revenue: float} */
    #[McpTool(
        name: 'orders_daily_summary',
        description: 'Return the current actor\'s order summary for an ISO-8601 date.',
        annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true),
    )]
    public function dailySummary(string $date): array
    {
        return $this->reports->dailySummary(new \DateTimeImmutable($date));
    }
}

The SDK derives the tool input schema from PHP parameter types and documentation. Constructor dependencies are resolved by the Symfony container.

Resources and prompts

<?php

namespace App\Mcp;

use Mcp\Capability\Attribute\McpPrompt;
use Mcp\Capability\Attribute\McpResource;
use Mcp\Capability\Attribute\McpResourceTemplate;

/** Publishes application guidance through standard MCP capabilities. */
final class ApplicationCapabilities
{
    #[McpResource(
        uri: 'docs://orders/policy',
        name: 'order_policy',
        description: 'Current order editing policy.',
        mimeType: 'text/markdown',
    )]
    public function orderPolicy(): string
    {
        return 'Orders can be edited before they enter delivery.';
    }

    #[McpResourceTemplate(
        uriTemplate: 'docs://orders/{locale}/policy',
        name: 'localized_order_policy',
        description: 'Localized order editing policy.',
        mimeType: 'text/markdown',
    )]
    public function localizedOrderPolicy(string $locale): string
    {
        return sprintf('Order policy for locale %s.', $locale);
    }

    #[McpPrompt(
        name: 'prepare_order_summary',
        description: 'Prepare a concise customer-facing order summary.',
    )]
    public function prepareOrderSummary(string $orderId): string
    {
        return sprintf('Summarize order %s using only data available to the current actor.', $orderId);
    }
}

Change discovery directories when capabilities live elsewhere:

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  discovery:
    scan_dirs:
      - src/Mcp
      - src/Integration/Mcp
    exclude_dirs:
      - src/Mcp/Internal

Paths are relative to kernel.project_dir. The classes must also be registered as Symfony services. With non-standard service loading, register the namespace explicitly:

# config/services.yaml
services:
  App\Mcp\:
    resource: "../src/Mcp/"
    autowire: true
    autoconfigure: true

[!IMPORTANT] Custom capabilities share the authenticated transport and actor-bound MCP session, but they do not automatically pass through Doctrine CRUD scopes, dryRun, field permissions, voters, or mutation auditing. Inject application services, Symfony Security, or ActorProviderInterface and enforce the capability's own authorization and domain rules.

Authentication and session security

The controller resolves the actor before MCP payload processing. Unauthenticated requests receive HTTP 401.

Streamable HTTP sessions are stored in the configured cache pool. Every session ID is bound to a SHA-256 fingerprint of the actor identifier; replaying another user's session ID returns HTTP 403. Session IDs are hashed in logs.

For multi-instance deployments, use a shared cache pool such as Redis:

# config/packages/cache.yaml
framework:
  cache:
    pools:
      cache.doctrine_mcp_sessions:
        adapter: cache.adapter.redis

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  http:
    session:
      cache_pool: cache.doctrine_mcp_sessions
      ttl: 3600

The actor identifier returned by ActorProviderInterface must be stable and unique across all users or service identities that can access the endpoint.

Audit and observability

Every request has a traceId. The endpoint accepts X-Request-Id; otherwise it generates one and returns it in the response header.

The doctrine_mcp log channel emits structured events including:

  • request received, completed, and failed;
  • authentication and session ownership failures;
  • scoped query result counts;
  • mutation operation, actor, entity, dryRun, and trace identifier;
  • bounded and sanitized mutation values at debug level.

Secret-like keys such as passwords, tokens, authorization headers, API keys, and cookies are redacted. Fields marked sensitive: true are redacted during entity serialization and mutation auditing.

The default audit sink writes sanitized mutation records to the logger. Implement AuditSinkInterface for an immutable database table or external audit service:

<?php

namespace App\Mcp\Audit;

use Errogaht\DoctrineMcpBundle\Audit\AuditSinkInterface;

/** Persists sanitized MCP mutation records in the application's audit store. */
final class DoctrineAuditSink implements AuditSinkInterface
{
    public function record(array $record): void
    {
        // Persist the already-sanitized record in an append-only store.
    }
}
# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  audit_sink: App\Mcp\Audit\DoctrineAuditSink

Configuration reference

# config/packages/doctrine_mcp.yaml
doctrine_mcp:
  server:
    name: "Doctrine MCP"
    version: "0.1.1"
    description: "Secure Doctrine ORM capabilities exposed over MCP."
    instructions: >-
      Discover entity capabilities before reading or mutating data. All results are constrained to
      the authenticated actor.

  http:
    path: /_mcp
    allowed_hosts: null
    max_body_bytes: 4194304
    session:
      cache_pool: cache.app
      prefix: "doctrine-mcp-"
      ttl: 3600

  discovery:
    scan_dirs: ["src/Mcp"]
    exclude_dirs: []

  query:
    default_limit: 25
    max_limit: 100
    max_association_items: 100

  actor_provider: doctrine_mcp.actor_provider.security
  audit_sink: Errogaht\DoctrineMcpBundle\Audit\LoggerAuditSink
  global_scopes: []
Option Default Description
server.name Doctrine MCP MCP server display name.
server.version 0.1.1 Application-facing MCP server version.
server.description See example Server description returned during discovery.
server.instructions See example Instructions provided to MCP clients.
http.path /_mcp Streamable HTTP endpoint path.
http.allowed_hosts null SDK localhost defaults; use a hostname list in production or false to disable the middleware.
http.max_body_bytes 4194304 Maximum MCP request body size; minimum 1024 bytes.
http.session.cache_pool cache.app Symfony PSR-6 cache pool used for MCP state and actor binding.
http.session.prefix doctrine-mcp- Cache key prefix.
http.session.ttl 3600 Session lifetime in seconds; minimum 60.
discovery.scan_dirs ['src/Mcp'] Capability discovery paths relative to the project directory.
discovery.exclude_dirs [] Paths excluded from discovery.
query.default_limit 25 Default search page size.
query.max_limit 100 Maximum search and SDK pagination size.
query.max_association_items 100 Maximum add/remove items in one collection mutation.
actor_provider Symfony Security provider Service implementing ActorProviderInterface.
audit_sink Logger sink Service implementing AuditSinkInterface.
global_scopes [] Scope classes applied to every exposed entity.

Console commands

Validate all exposed entities, mapped fields, scope registrations, server-controlled write flags, and reference configuration:

php bin/console doctrine-mcp:validate

Inspect the normalized public contract of one entity:

php bin/console doctrine-mcp:debug Order

Run validation during deployment and after changing Doctrine metadata, MCP attributes, scope services, or reference providers. Clear the Symfony cache when a production deployment changes discovered attributes or container configuration.

Production checklist

Before connecting an agent:

  1. protect the MCP route with a Symfony firewall and access_control;
  2. configure all production allowed_hosts;
  3. use a shared session cache in a multi-instance deployment;
  4. expose only explicitly reviewed entities and fields;
  5. add an entity or global scope everywhere and review every use of UnrestrictedScope;
  6. keep write operations and field write flags disabled unless required;
  7. add Validator constraints and domain checks for writable values;
  8. verify custom mutation handlers honor dryRun;
  9. authorize and audit custom MCP capabilities explicitly;
  10. run doctrine-mcp:validate and inspect each entity with doctrine-mcp:debug;
  11. verify log redaction and configure durable audit storage where required;
  12. test with two users from different tenants before production rollout.

Troubleshooting

Authentication is always required

The default actor provider requires an authenticated Symfony UserInterface. Check the firewall pattern, authenticator, access token or cookie, and access_control rules for the MCP path.

The entity is unknown

Confirm that Doctrine manages the class, the class has McpEntity, its public name is unique, and the application cache was cleared after deployment.

The entity has no query scope

Add an EntityScopeInterface class to McpEntity.scopes, configure a global scope, or deliberately use UnrestrictedScope for reviewed public data.

A scope or provider is not registered

Ensure Symfony autoconfiguration covers the class. Otherwise add doctrine_mcp.scope, doctrine_mcp.reference_provider, or doctrine_mcp.mutation_handler manually.

A custom MCP tool is not discovered

Check that its file is inside discovery.scan_dirs, outside exclude_dirs, has an SDK MCP attribute, and is registered as an autoconfigured Symfony service. Clear the Symfony cache afterward.

A field cannot be written

The entity operation and field write flag must both be enabled. Identifiers, unannotated fields, read-only fields, and serverControlled fields are rejected by design.

An association cannot be resolved

Expose the target entity, provide its exact identifier shape, and ensure the current actor can read it through the target's scopes and voter.

Development

Install dependencies and run the complete package checks:

composer install
composer check

Doctrine integration tests require pdo_sqlite. The CI matrix covers PHP 8.1 with the lowest supported Symfony and Doctrine versions and stable dependencies on newer PHP versions.

Maintainer releases

Every successful CI run for a push to master publishes the tested commit automatically. The release workflow increments the patch version, creates an annotated Git tag and GitHub Release, and updates Packagist immediately. The Packagist webhook remains enabled as a second synchronization path.

Use the manual dispatcher only for an intentional minor or major release, or to retry automation:

scripts/release.sh minor
scripts/release.sh major

To ask Packagist to recrawl the existing release without creating a version, run:

scripts/update-packagist.sh

Release credentials are stored as GitHub Actions Secrets. Local recovery uses a machine-bound encrypted systemd-creds credential; secrets are never committed to the repository.

License

This project is proprietary. See LICENSE. Adapted MIT-licensed transport code is documented in THIRD_PARTY_NOTICES.md.