errogaht / doctrine-mcp-bundle
Secure, attribute-driven Doctrine ORM CRUD over Model Context Protocol for Symfony applications.
Package info
github.com/errogaht/doctrine-mcp-bundle
Type:symfony-bundle
pkg:composer/errogaht/doctrine-mcp-bundle
Requires
- php: ^8.1
- doctrine/doctrine-bundle: ^2.18 || ^3.2
- doctrine/orm: ^2.20 || ^3.3
- mcp/sdk: ^0.7
- nyholm/psr7: ^1.8
- psr/log: ^1.1 || ^2.0 || ^3.0
- psr/simple-cache: ^1.0 || ^2.0 || ^3.0
- symfony/cache: ^6.4 || ^7.4 || ^8.0
- symfony/config: ^6.4 || ^7.4 || ^8.0
- symfony/dependency-injection: ^6.4 || ^7.4 || ^8.0
- symfony/event-dispatcher: ^6.4 || ^7.4 || ^8.0
- symfony/framework-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/http-foundation: ^6.4 || ^7.4 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.4 || ^8.0
- symfony/property-access: ^6.4 || ^7.4 || ^8.0
- symfony/psr-http-message-bridge: ^6.4 || ^7.4 || ^8.0
- symfony/routing: ^6.4 || ^7.4 || ^8.0
- symfony/security-bundle: ^6.4 || ^7.4 || ^8.0
- symfony/serializer: ^6.4 || ^7.4 || ^8.0
- symfony/uid: ^6.4 || ^7.4 || ^8.0
- symfony/validator: ^6.4 || ^7.4 || ^8.0
Requires (Dev)
- doctrine/doctrine-fixtures-bundle: ^3.6 || ^4.0
- friendsofphp/php-cs-fixer: ^3.85
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5 || ^11.5 || ^12.0
- symfony/browser-kit: ^6.4 || ^7.4 || ^8.0
- symfony/dotenv: ^6.4 || ^7.4 || ^8.0
- symfony/phpunit-bridge: ^6.4 || ^7.4 || ^8.0
- symfony/yaml: ^6.4 || ^7.4 || ^8.0
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: trueby default; - enforce tenant, ownership, workflow, and other row-level restrictions with Doctrine
QueryBuilderscopes; - 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. Settingallowed_hosts: falsedisables 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:
- Doctrine must manage the class and property;
- the class must have
#[McpEntity]; - every visible property must have
#[McpField]; - the requested operation must be enabled on the entity;
- a field must be separately writable for create or update;
- every addressed row must pass all configured query scopes;
- 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]
formatis 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]
UnrestrictedScopeis 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, orActorProviderInterfaceand 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:
- protect the MCP route with a Symfony firewall and
access_control; - configure all production
allowed_hosts; - use a shared session cache in a multi-instance deployment;
- expose only explicitly reviewed entities and fields;
- add an entity or global scope everywhere and review every use of
UnrestrictedScope; - keep write operations and field write flags disabled unless required;
- add Validator constraints and domain checks for writable values;
- verify custom mutation handlers honor
dryRun; - authorize and audit custom MCP capabilities explicitly;
- run
doctrine-mcp:validateand inspect each entity withdoctrine-mcp:debug; - verify log redaction and configure durable audit storage where required;
- 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.