mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-06-19 09:01:33 +00:00
Compare commits
5 commits
a9d75554d7
...
5294277704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5294277704 | ||
|
|
3f10a1235e | ||
|
|
11347c23f0 | ||
|
|
dc5b818400 | ||
|
|
d453c48c55 |
13 changed files with 2677 additions and 1598 deletions
|
|
@ -15,7 +15,9 @@
|
|||
"amphp/http-client": "^5.1",
|
||||
"api-platform/doctrine-orm": "^4.1",
|
||||
"api-platform/json-api": "^4.0.0",
|
||||
"api-platform/mcp": "4.3.x-dev",
|
||||
"api-platform/symfony": "^4.0.0",
|
||||
"api-platform/metadata": "4.3.x-dev",
|
||||
"beberlei/doctrineextensions": "^1.2",
|
||||
"brick/math": "^0.14.8",
|
||||
"brick/schema": "^0.2.0",
|
||||
|
|
@ -40,6 +42,7 @@
|
|||
"league/html-to-markdown": "^5.0.1",
|
||||
"liip/imagine-bundle": "^2.2",
|
||||
"maennchen/zipstream-php": "2.1",
|
||||
"mcp/sdk": "v0.5.0 as 0.4.0",
|
||||
"nbgrp/onelogin-saml-bundle": "^v2.0.2",
|
||||
"nelexa/zip": "^4.0",
|
||||
"nelmio/cors-bundle": "^2.3",
|
||||
|
|
@ -73,6 +76,7 @@
|
|||
"symfony/http-client": "7.4.*",
|
||||
"symfony/http-kernel": "7.4.*",
|
||||
"symfony/mailer": "7.4.*",
|
||||
"symfony/mcp-bundle": "^0.8.0",
|
||||
"symfony/monolog-bundle": "^4.0",
|
||||
"symfony/process": "7.4.*",
|
||||
"symfony/property-access": "7.4.*",
|
||||
|
|
|
|||
963
composer.lock
generated
963
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -34,4 +34,5 @@ return [
|
|||
Jbtronics\TranslationEditorBundle\JbtronicsTranslationEditorBundle::class => ['dev' => true],
|
||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
||||
Symfony\AI\AiBundle\AiBundle::class => ['all' => true],
|
||||
Symfony\AI\McpBundle\McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -38,3 +38,7 @@ api_platform:
|
|||
serializer:
|
||||
# Change this to false later, to remove the hydra prefix on the API
|
||||
hydra_prefix: true
|
||||
|
||||
mcp:
|
||||
enabled: true # default: true
|
||||
format: jsonld # default: 'jsonld'
|
||||
|
|
|
|||
10
config/packages/mcp.yaml
Normal file
10
config/packages/mcp.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
mcp:
|
||||
client_transports:
|
||||
http: true
|
||||
stdio: false
|
||||
http:
|
||||
path: "/mcp"
|
||||
session:
|
||||
store: "file"
|
||||
directory: "%kernel.cache_dir%/mcp"
|
||||
ttl: 3600
|
||||
2918
config/reference.php
2918
config/reference.php
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,11 @@ csp_report:
|
|||
methods: [POST]
|
||||
defaults: { _controller: nelmio_security.csp_reporter_controller::indexAction }
|
||||
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
|
||||
|
||||
# Must be last as it matches everything
|
||||
redirector:
|
||||
path: /{url}
|
||||
|
|
@ -26,3 +31,4 @@ redirector:
|
|||
controller: App\Controller\RedirectController::addLocalePart
|
||||
# Dont match localized routes (no redirection loop, if no root with that name exists) or API prefixed routes
|
||||
condition: "not (request.getPathInfo() matches '/^\\\\/([a-z]{2}(_[A-Z]{2})?|api)\\\\//')"
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ use ApiPlatform\Metadata\ApiResource;
|
|||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\McpTool;
|
||||
use ApiPlatform\Metadata\McpToolCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Serializer\Filter\PropertyFilter;
|
||||
|
|
@ -39,6 +41,7 @@ use App\ApiPlatform\Filter\EntityFilter;
|
|||
use App\ApiPlatform\Filter\LikeFilter;
|
||||
use App\ApiPlatform\Filter\PartStoragelocationFilter;
|
||||
use App\ApiPlatform\Filter\TagFilter;
|
||||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\Entity\Attachments\Attachment;
|
||||
use App\Entity\Attachments\AttachmentContainingDBElement;
|
||||
use App\Entity\Attachments\PartAttachment;
|
||||
|
|
@ -55,7 +58,10 @@ use App\Entity\Parts\PartTraits\ManufacturerTrait;
|
|||
use App\Entity\Parts\PartTraits\OrderTrait;
|
||||
use App\Entity\Parts\PartTraits\ProjectTrait;
|
||||
use App\EntityListeners\TreeCacheInvalidationListener;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use App\Repository\PartRepository;
|
||||
use App\State\Mcp\GetPartByIdProcessor;
|
||||
use App\State\Mcp\SearchPartsProcessor;
|
||||
use App\Validator\Constraints\UniqueObjectCollection;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
|
@ -80,7 +86,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
#[ORM\Index(columns: ['datetime_added', 'name', 'last_modified', 'id', 'needs_review'], name: 'parts_idx_datet_name_last_id_needs')]
|
||||
#[ORM\Index(columns: ['name'], name: 'parts_idx_name')]
|
||||
#[ORM\Index(columns: ['ipn'], name: 'parts_idx_ipn')]
|
||||
#[ORM\Index(columns: ['gtin'], name: 'parts_idx_gtin')]
|
||||
#[ORM\Index(name: 'parts_idx_gtin', columns: ['gtin'])]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(normalizationContext: [
|
||||
|
|
@ -104,6 +110,27 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||
],
|
||||
normalizationContext: ['groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read'], 'openapi_definition_name' => 'Read'],
|
||||
denormalizationContext: ['groups' => ['part:write', 'api:basic:write', 'eda_info:write', 'attachment:write', 'parameter:write'], 'openapi_definition_name' => 'Write'],
|
||||
mcp: [
|
||||
'search_parts' => new McpToolCollection(
|
||||
title: "Search parts by keyword",
|
||||
description: 'Search for parts',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
input: PartSearchFilter::class,
|
||||
processor: SearchPartsProcessor::class,
|
||||
),
|
||||
'get_part_details' => new McpTool(
|
||||
title: 'Get part details by ID',
|
||||
description: 'Get detailed information about a specific part by its database ID',
|
||||
annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true, 'openWorldHint' => false],
|
||||
normalizationContext: [
|
||||
'groups' => ['part:read', 'provider_reference:read', 'api:basic:read', 'part_lot:read', 'orderdetail:read', 'pricedetail:read', 'parameter:read', 'attachment:read', 'eda_info:read'],
|
||||
'item_uri_template' => '/api/parts/{id}',
|
||||
],
|
||||
input: ElementByIdInput::class,
|
||||
validate: true,
|
||||
processor: GetPartByIdProcessor::class
|
||||
),
|
||||
],
|
||||
)]
|
||||
#[ApiFilter(PropertyFilter::class)]
|
||||
#[ApiFilter(EntityFilter::class, properties: ["category", "footprint", "manufacturer", "partUnit", "partCustomState"])]
|
||||
|
|
|
|||
35
src/Mcp/DTO/ElementByIdInput.php
Normal file
35
src/Mcp/DTO/ElementByIdInput.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\DTO;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
readonly class ElementByIdInput
|
||||
{
|
||||
public function __construct(
|
||||
#[Assert\NotNull]
|
||||
#[Assert\Positive]
|
||||
public int $id,
|
||||
) {
|
||||
}
|
||||
}
|
||||
162
src/Mcp/JsonSchema/FixedSchemaFactory.php
Normal file
162
src/Mcp/JsonSchema/FixedSchemaFactory.php
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\JsonSchema;
|
||||
|
||||
use ApiPlatform\JsonSchema\Schema;
|
||||
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||
|
||||
/**
|
||||
* Overwrite the default JSON Schema factory to resolve $ref and allOf into a flat schema.
|
||||
* This is a workaround until https://github.com/api-platform/core/pull/7962 is merged
|
||||
*/
|
||||
#[AsAlias('api_platform.mcp.json_schema.schema_factory')]
|
||||
readonly class FixedSchemaFactory implements SchemaFactoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SchemaFactoryInterface $decorated,
|
||||
) {
|
||||
}
|
||||
|
||||
public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema
|
||||
{
|
||||
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
|
||||
|
||||
$definitions = [];
|
||||
foreach ($schema->getDefinitions() as $key => $definition) {
|
||||
$definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition;
|
||||
}
|
||||
|
||||
$rootKey = $schema->getRootDefinitionKey();
|
||||
if (null !== $rootKey) {
|
||||
$root = $definitions[$rootKey] ?? [];
|
||||
} else {
|
||||
// Collection schemas (and others) put allOf/type directly on the root
|
||||
$root = $schema->getArrayCopy(false);
|
||||
}
|
||||
|
||||
$flat = self::resolveNode($root, $definitions);
|
||||
|
||||
$flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA);
|
||||
unset($flatSchema['$schema']);
|
||||
foreach ($flat as $key => $value) {
|
||||
$flatSchema[$key] = $value;
|
||||
}
|
||||
|
||||
return $flatSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve $ref, allOf, and nested structures into a flat schema node.
|
||||
*
|
||||
* @param array $resolving Tracks the current $ref resolution chain to detect circular references
|
||||
*/
|
||||
public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array
|
||||
{
|
||||
if ($node instanceof \ArrayObject) {
|
||||
$node = $node->getArrayCopy();
|
||||
}
|
||||
|
||||
if (isset($node['$ref'])) {
|
||||
$refKey = str_replace('#/definitions/', '', $node['$ref']);
|
||||
if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) {
|
||||
return ['type' => 'object'];
|
||||
}
|
||||
$resolving[$refKey] = true;
|
||||
$resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving);
|
||||
unset($resolving[$refKey]);
|
||||
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
if (isset($node['allOf'])) {
|
||||
$merged = ['type' => 'object', 'properties' => []];
|
||||
$requiredSets = [];
|
||||
foreach ($node['allOf'] as $entry) {
|
||||
$resolved = self::resolveNode($entry, $definitions, $resolving);
|
||||
if (isset($resolved['properties'])) {
|
||||
foreach ($resolved['properties'] as $k => $v) {
|
||||
$merged['properties'][$k] = $v;
|
||||
}
|
||||
}
|
||||
if (isset($resolved['required'])) {
|
||||
$requiredSets[] = $resolved['required'];
|
||||
}
|
||||
}
|
||||
|
||||
if ($requiredSets) {
|
||||
$merged['required'] = array_merge(...$requiredSets);
|
||||
}
|
||||
if ([] === $merged['properties']) {
|
||||
unset($merged['properties']);
|
||||
}
|
||||
if (isset($node['description'])) {
|
||||
$merged['description'] = $node['description'];
|
||||
}
|
||||
|
||||
return self::resolveDeep($merged, $definitions, $resolving);
|
||||
}
|
||||
|
||||
// oneOf/anyOf nodes must not receive a type fallback — their type is expressed
|
||||
// through the sub-schemas. Adding 'type: object' here would break schemas like
|
||||
// HydraItemBaseSchema's @context, which is oneOf: [string, object].
|
||||
if (isset($node['oneOf']) || isset($node['anyOf'])) {
|
||||
return self::resolveDeep($node, $definitions, $resolving);
|
||||
}
|
||||
|
||||
if (!isset($node['type'])) {
|
||||
$node['type'] = 'object';
|
||||
}
|
||||
|
||||
return self::resolveDeep($node, $definitions, $resolving);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve nested properties and array items.
|
||||
*/
|
||||
private static function resolveDeep(array $node, array $definitions, array &$resolving): array
|
||||
{
|
||||
if (isset($node['items'])) {
|
||||
$node['items'] = self::resolveNode(
|
||||
$node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'],
|
||||
$definitions,
|
||||
$resolving,
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($node['properties']) && \is_array($node['properties'])) {
|
||||
foreach ($node['properties'] as $propName => $propSchema) {
|
||||
$node['properties'][$propName] = self::resolveNode(
|
||||
$propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema,
|
||||
$definitions,
|
||||
$resolving,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
60
src/State/Mcp/GetPartByIdProcessor.php
Normal file
60
src/State/Mcp/GetPartByIdProcessor.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State\Mcp;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\Parts\Part;
|
||||
use App\Mcp\DTO\ElementByIdInput;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
class GetPartByIdProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly AuthorizationCheckerInterface $authorizationChecker,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof ElementByIdInput) {
|
||||
throw new \InvalidArgumentException('Expected PartByIdInput');
|
||||
}
|
||||
|
||||
$part = $this->entityManager->find(Part::class, $data->id);
|
||||
|
||||
if (!$part instanceof Part) {
|
||||
throw new NotFoundHttpException(sprintf('Part with id %d not found', $data->id));
|
||||
}
|
||||
|
||||
if (!$this->authorizationChecker->isGranted('read', $part)) {
|
||||
throw new AccessDeniedException(sprintf('Access denied to part with id %d', $data->id));
|
||||
}
|
||||
|
||||
return $part;
|
||||
}
|
||||
}
|
||||
80
src/State/Mcp/SearchPartsProcessor.php
Normal file
80
src/State/Mcp/SearchPartsProcessor.php
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
/*
|
||||
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||
*
|
||||
* Copyright (C) 2019 - 2026 Jan Böhmer (https://github.com/jbtronics)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
|
||||
namespace App\State\Mcp;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\DataTables\Filters\PartSearchFilter;
|
||||
use App\Entity\Parts\Part;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
class SearchPartsProcessor implements ProcessorInterface
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
|
||||
{
|
||||
if (!$data instanceof PartSearchFilter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$qb = $this->entityManager->getRepository(Part::class)->createQueryBuilder('part');
|
||||
|
||||
$data->apply($qb);
|
||||
$this->addJoins($qb);
|
||||
|
||||
$qb->addGroupBy('part');
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
private function addJoins(QueryBuilder $qb): void
|
||||
{
|
||||
$dql = $qb->getDQL();
|
||||
|
||||
if (str_contains($dql, '_category')) {
|
||||
$qb->leftJoin('part.category', '_category');
|
||||
}
|
||||
if (str_contains($dql, '_storelocations')) {
|
||||
$qb->leftJoin('part.partLots', '_partLots');
|
||||
$qb->leftJoin('_partLots.storage_location', '_storelocations');
|
||||
}
|
||||
if (str_contains($dql, '_orderdetails') || str_contains($dql, '_suppliers')) {
|
||||
$qb->leftJoin('part.orderdetails', '_orderdetails');
|
||||
$qb->leftJoin('_orderdetails.supplier', '_suppliers');
|
||||
}
|
||||
if (str_contains($dql, '_manufacturer')) {
|
||||
$qb->leftJoin('part.manufacturer', '_manufacturer');
|
||||
}
|
||||
if (str_contains($dql, '_footprint')) {
|
||||
$qb->leftJoin('part.footprint', '_footprint');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -585,6 +585,9 @@
|
|||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.8.0"
|
||||
},
|
||||
"symfony/mime": {
|
||||
"version": "v4.3.1"
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue