Compare commits

..

20 Commits

Author SHA1 Message Date
gitea-actions 8f75e2e310 chore: bump version to v0.4.17
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 22s
2026-05-27 08:53:52 +00:00
Matthieu 75fd737a4c fix(mcp) : décoder les arguments tableaux/objets sérialisés en string JSON
Auto Tag Develop / tag (push) Successful in 9s
Complément du fix scalaire : certains proxies MCP sérialisent aussi les
arguments tableaux/objets en string JSON (ex: tagIds arrive en "[3]" au
lieu de [3]). Le schéma array les rejetait en 422, et castToArray du SDK
ne décode pas les strings JSON.

CoerceJsonEncodedArgumentsListener écoute le RequestEvent du SDK (dispatché
avant tout handler) et, piloté par le schéma du tool, décode les arguments
string dont le type cible est array/object. Les params string ne sont
jamais touchés (sûr pour les titres/descriptions ressemblant à du JSON).

Corrige le 422 'Expected array|null, but received string' sur tagIds /
collaboratorIds lors des appels depuis Claude.
2026-05-27 10:53:42 +02:00
gitea-actions 77e1017d09 chore: bump version to v0.4.16
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-27 08:36:22 +00:00
Matthieu c528067c79 fix(mcp) : accepter les arguments scalaires stringifiés (coercition string->int/bool)
Auto Tag Develop / tag (push) Successful in 11s
Certains clients MCP sérialisent tous les arguments JSON-RPC en string
(ex: "22" au lieu de 22). Le SDK valide les arguments contre le schéma
JSON AVANT de les caster (CallToolHandler), donc un schéma integer strict
rejetait "22" en 422 alors que ReferenceHandler::castArgumentType sait
le coercer ensuite.

CoercingSchemaGenerator enveloppe le SchemaGenerator du SDK et ajoute
"string" aux types scalaires integer/number/boolean (et aux items de
tableaux), de sorte que opis accepte la valeur stringifiée ; le type PHP
réel du paramètre pilote toujours la coercition. Branché sur le builder
MCP via McpSchemaGeneratorPass (enregistrée dans Kernel::build).

Corrige le rejet 422 sur groupId/effortId/priorityId/statusId/etc. lors
de l'appel des tools depuis Claude.
2026-05-27 10:36:06 +02:00
gitea-actions 433032701e chore: bump version to v0.4.15
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 28s
2026-05-27 08:11:38 +00:00
Matthieu 4334420625 fix(mcp) : typer les éléments des params tableaux d'IDs (items: integer)
Auto Tag Develop / tag (push) Successful in 11s
Les params tableaux (tagIds, collaboratorIds) des tools create-task,
update-task, list-tasks, create-time-entry et update-time-entry
généraient un schéma { type: [array, null] } sans clé items : aucune
contrainte sur le type des éléments, d'où des IDs pouvant transiter en
string. Ajout d'un docblock @param int[] sur chaque __invoke pour que le
SchemaGenerator du SDK MCP produise items: { type: integer }, ce qui
force la validation à n'accepter que des entiers.
2026-05-27 10:11:24 +02:00
gitea-actions 7e32e4c013 chore: bump version to v0.4.14
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 52s
2026-05-26 09:36:14 +00:00
Matthieu 8fb5b80d8d fix(absences) : afficher le solde de CP avec décimales (8,75) sans arrondir
Auto Tag Develop / tag (push) Successful in 8s
Le solde était arrondi à la demi-journée (Math.round(n*2)/2), affichant
9 au lieu de 8,75 : un salarié pouvait croire à un droit supérieur au
réel. Formatage via Intl.NumberFormat fr-FR (virgule, max 2 décimales,
zéros superflus retirés) dans formatDays et les cartes de solde.
2026-05-26 11:36:04 +02:00
gitea-actions 96e25c2390 chore: bump version to v0.4.13
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 48s
2026-05-26 09:33:27 +00:00
Matthieu 02ac151ac0 feat(users) : ajout prénom et nom sur l'utilisateur
Auto Tag Develop / tag (push) Successful in 7s
Deux colonnes nullable firstName/lastName sur User (groupes me:read,
user:list, user:write), éditables dans le drawer utilisateur (admin).
L'affichage reste basé sur le username pour l'instant. Migration +
valeurs de démo dans les fixtures.
2026-05-26 11:33:18 +02:00
gitea-actions 1991c43f8c chore: bump version to v0.4.12
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 57s
2026-05-26 09:08:30 +00:00
Matthieu e9ca00aeb2 fix(ui) : espace vertical dans le corps des drawers (champ focus rogné)
Auto Tag Develop / tag (push) Successful in 10s
Le body scrollable de MalioDrawer (overflow-y-auto) n'avait aucun
padding vertical : le label flottant du premier champ, qui remonte au
focus, dépassait le bord haut et se faisait rogner sous l'entête. Idem
pour le dernier champ en bas.

Ajoute une feuille app.css (hors thème sombre) qui donne 1rem de padding
haut/bas au corps de tous les drawers, via l'API data-test stable de
@malio/layer-ui (sans modifier la lib ni chaque drawer).
2026-05-26 11:08:19 +02:00
gitea-actions b13ba41674 chore: bump version to v0.4.11
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-05-26 09:02:57 +00:00
Matthieu ac615875f3 fix(absences) : dates du drawer employé en pleine largeur (1 par ligne)
Auto Tag Develop / tag (push) Successful in 7s
Dans le drawer « Informations employé », les champs Date d'embauche et
Date de sortie étaient sur une grille 2 colonnes : le popover du
calendrier MalioDate, coincé en demi-largeur, chevauchait le champ
voisin. Passage en pleine largeur (sm:col-span-2) pour laisser le
calendrier s'afficher correctement.
2026-05-26 11:02:50 +02:00
gitea-actions f8322f8b1e chore: bump version to v0.4.10
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-26 08:49:58 +00:00
Matthieu cd474d5089 feat(absences) : réserve « Mes absences » aux employés
Auto Tag Develop / tag (push) Successful in 6s
Un utilisateur non coché « Employé » (isEmployee = false) ne voit plus
l'entrée « Mes absences » dans le menu et ne peut plus accéder à la page
/absences (nouveau middleware employee qui redirige vers l'accueil).
L'entête de section « Absences » est masquée si l'utilisateur n'est ni
employé ni admin.
2026-05-26 10:49:52 +02:00
gitea-actions 2feba57cb6 chore: bump version to v0.4.9
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 20s
2026-05-26 08:42:59 +00:00
Matthieu f74f0e1ad3 fix(absences) : seed des politiques par défaut en prod via migration
Auto Tag Develop / tag (push) Successful in 7s
Les 6 AbsencePolicy par défaut (barème Syntec / IDCC 1486) n'étaient
créées que par les fixtures, jamais en prod : l'écran « Politiques
d'absence » était vide en production.

Ajoute une migration de données idempotente (INSERT ... ON CONFLICT
(type) DO NOTHING sur l'index unique uniq_absence_policy_type) qui sème
les mêmes valeurs que les fixtures. Sans effet en dev (lignes déjà
présentes), peuple la prod au déploiement.
2026-05-26 10:42:27 +02:00
gitea-actions 6db6d13e76 chore: bump version to v0.4.8
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m21s
2026-05-26 08:08:06 +00:00
malio 26fae393cf Merge pull request 'feat(absences) : module de gestion des absences + MCP + conformité RGPD/légale' (#7) from feat/absence-management into develop
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #7
2026-05-26 08:07:54 +00:00
26 changed files with 636 additions and 20 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.7'
app.version: '0.4.17'
+21
View File
@@ -0,0 +1,21 @@
/*
* App-level layout fixes (not theme-related).
*/
/*
* MalioDrawer : donne au corps scrollable un peu d'espace vertical.
*
* Le body du drawer est `overflow-y-auto` sans padding vertical. Or le label
* flottant d'un champ Malio remonte (-1.25rem) au focus/remplissage : pour le
* PREMIER champ, collé en haut du body, ce label dépasse le bord supérieur et
* se fait rogner (il « grossit et passe sous l'entête »). Le dernier champ
* (popover de date, hint) souffre du même rognage en bas.
*
* On ajoute donc un padding vertical au body de TOUS les drawers via l'API de
* test stable de la lib (@malio/layer-ui), sans la modifier ni toucher chaque
* drawer un par un. Le sélecteur reste limité au panneau du drawer.
*/
[data-test="panel"] > [data-test="body"] {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -102,7 +102,8 @@ const others = computed<AbsenceBalance[]>(() =>
)
function formatNumber(n: number): string {
return (Math.round(n * 2) / 2).toString()
// Valeur réelle avec décimales (ex. 8,75) : pas d'arrondi qui gonflerait le solde.
return new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }).format(n)
}
// Total entitlement = acquired (N-1) + in-progress (N); falls back to the
+16 -10
View File
@@ -7,16 +7,22 @@
</div>
</template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
<!-- Dates en pleine largeur (1 par ligne) : le popover du calendrier
a besoin de toute la largeur pour s'afficher correctement. -->
<div class="sm:col-span-2">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
</div>
<div class="sm:col-span-2">
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
</div>
<MalioSelect
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
+18
View File
@@ -11,6 +11,16 @@
:error="touched.username && !form.username.trim() ? 'Le nom est requis' : ''"
@blur="touched.username = true"
/>
<MalioInputText
v-model="form.firstName"
label="Prénom"
input-class="w-full"
/>
<MalioInputText
v-model="form.lastName"
label="Nom"
input-class="w-full"
/>
<MalioInputPassword
v-model="form.password"
label="Mot de passe"
@@ -84,6 +94,8 @@ const isSubmitting = ref(false)
const form = reactive({
username: '',
firstName: '',
lastName: '',
password: '',
roles: [] as string[],
isEmployee: false,
@@ -98,11 +110,15 @@ watch(() => props.modelValue, (open) => {
if (open) {
if (props.item) {
form.username = props.item.username ?? ''
form.firstName = props.item.firstName ?? ''
form.lastName = props.item.lastName ?? ''
form.password = ''
form.roles = [...props.item.roles]
form.isEmployee = props.item.isEmployee ?? false
} else {
form.username = ''
form.firstName = ''
form.lastName = ''
form.password = ''
form.roles = ['ROLE_USER']
form.isEmployee = false
@@ -124,6 +140,8 @@ async function handleSubmit() {
try {
const payload: UserWrite = {
username: form.username.trim(),
firstName: form.firstName.trim() || null,
lastName: form.lastName.trim() || null,
roles: form.roles,
isEmployee: form.isEmployee,
}
+5 -3
View File
@@ -75,9 +75,11 @@ export function useAbsenceHelpers() {
}
function formatDays(days: number): string {
const rounded = Math.round(days * 2) / 2
const unit = rounded > 1 ? t('absences.daysPlural') : t('absences.daySingular')
return `${rounded} ${unit}`
// Affiche la valeur réelle avec décimales (ex. 8,75) : un solde de CP se
// gère en demi/quart de journée, arrondir masquerait des droits réels.
const value = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 2 }).format(days)
const unit = days >= 2 ? t('absences.daysPlural') : t('absences.daySingular')
return `${value} ${unit}`
}
return {
+9 -4
View File
@@ -119,11 +119,14 @@
</div>
<!-- Section : Absences -->
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Absences
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
<template v-if="isAbsenceSectionVisible">
<p v-if="!sidebarIsCollapsed" class="px-4 pt-5 pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
Absences
</p>
<div v-else class="mx-2 my-3 border-t border-secondary-500" />
</template>
<SidebarLink
v-if="isEmployee"
to="/absences"
icon="mdi:umbrella-beach-outline"
label="Mes absences"
@@ -211,6 +214,8 @@ const {version} = useAppVersion()
const route = useRoute()
const isAdmin = computed(() => (auth.user?.roles ?? []).includes('ROLE_ADMIN'))
const isEmployee = computed(() => Boolean(auth.user?.isEmployee))
const isAbsenceSectionVisible = computed(() => isEmployee.value || isAdmin.value)
const isMailVisible = computed(() => {
const roles: string[] = auth.user?.roles ?? []
+9
View File
@@ -0,0 +1,9 @@
export default defineNuxtRouteMiddleware(() => {
const auth = useAuthStore()
// "Mes absences" is reserved for users flagged as employees (subject to the
// absence management). Non-employees are redirected to the home page.
if (!auth.isAuthenticated || !auth.user?.isEmployee) {
return navigateTo('/')
}
})
+1 -1
View File
@@ -2,7 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
css: ['~/assets/css/dark.css'],
css: ['~/assets/css/app.css', '~/assets/css/dark.css'],
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
+2
View File
@@ -75,6 +75,8 @@ import { useAbsenceHelpers } from '~/composables/useAbsenceHelpers'
type Row = AbsenceRequest & { typeLabelText: string; periodText: string; daysText: string; createdAtText: string }
definePageMeta({ middleware: ['employee'] })
const { t } = useI18n()
const service = useAbsenceService()
const { statusLabel, statusVariant, statusIcon, formatRange, formatDays, formatDate } = useAbsenceHelpers()
+4
View File
@@ -4,6 +4,8 @@ export type UserData = {
id: number
'@id'?: string
username: string
firstName?: string | null
lastName?: string | null
roles: string[]
avatarUrl?: string | null
apiToken?: string | null
@@ -20,6 +22,8 @@ export type UserData = {
export type UserWrite = {
username: string
firstName?: string | null
lastName?: string | null
plainPassword?: string
roles: string[]
// HR / absence management
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Seed the default absence policies (Syntec / IDCC 1486 legal baseline).
*
* These rows were previously created only by AppFixtures, so they existed in
* dev but never in production: the "Politiques d'absence" admin screen was
* empty in prod. This data migration seeds the same 6 policies idempotently
* (ON CONFLICT on the unique `type` index), so prod gets populated and dev,
* where fixtures already created them, is left untouched.
*
* Values mirror AppFixtures and the legal compliance design
* (docs/superpowers/specs/2026-05-22-absence-legal-compliance-fixes-design.md):
* - CP: 25 jours ouvrés/an, préavis 30 j, pas de justificatif.
* - Mariage/PACS: 4 j/événement ; Naissance: 3 j/événement.
* - Décès / Congé parental / Maladie: pas de forfait codé en dur
* (montant selon lien de parenté ou suspension), justificatif requis.
*/
final class Version20260526100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Seed default absence policies (Syntec IDCC 1486) idempotently for prod';
}
public function up(Schema $schema): void
{
// [type, days_per_year, days_per_event, justification_required, notice_days, count_working_days_only]
$policies = [
['cp', '25', null, 'false', 30, 'true'],
['mariage_pacs', null, '4', 'true', 0, 'true'],
['naissance', null, '3', 'true', 0, 'true'],
['conge_parental', null, null, 'true', 30, 'true'],
['deces', null, null, 'true', 0, 'true'],
['maladie', null, null, 'true', 0, 'true'],
];
foreach ($policies as [$type, $daysPerYear, $daysPerEvent, $justif, $notice, $workingOnly]) {
$daysPerYear = null === $daysPerYear ? 'NULL' : $daysPerYear;
$daysPerEvent = null === $daysPerEvent ? 'NULL' : $daysPerEvent;
$this->addSql(<<<SQL
INSERT INTO absence_policy
(type, days_per_year, days_per_event, justification_required, notice_days, count_working_days_only, active)
VALUES
('{$type}', {$daysPerYear}, {$daysPerEvent}, {$justif}, {$notice}, {$workingOnly}, true)
ON CONFLICT (type) DO NOTHING
SQL);
}
}
public function down(Schema $schema): void
{
$this->addSql("DELETE FROM absence_policy WHERE type IN ('cp', 'mariage_pacs', 'naissance', 'conge_parental', 'deces', 'maladie')");
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Add optional first name / last name to users.
*/
final class Version20260526120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add user.first_name and user.last_name (nullable)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD first_name VARCHAR(100) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD last_name VARCHAR(100) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS first_name');
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS last_name');
}
}
+8
View File
@@ -43,6 +43,8 @@ class AppFixtures extends Fixture
// Users
$admin = new User();
$admin->setUsername('admin');
$admin->setFirstName('Alex');
$admin->setLastName('Martin');
$admin->setRoles(['ROLE_ADMIN']);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->setApiToken('dev-mcp-token-for-testing-only-do-not-use-in-production');
@@ -50,18 +52,24 @@ class AppFixtures extends Fixture
$userAlice = new User();
$userAlice->setUsername('alice');
$userAlice->setFirstName('Alice');
$userAlice->setLastName('Dupont');
$userAlice->setRoles(['ROLE_USER']);
$userAlice->setPassword($this->passwordHasher->hashPassword($userAlice, 'alice'));
$manager->persist($userAlice);
$userBob = new User();
$userBob->setUsername('bob');
$userBob->setFirstName('Bob');
$userBob->setLastName('Leroy');
$userBob->setRoles(['ROLE_USER']);
$userBob->setPassword($this->passwordHasher->hashPassword($userBob, 'bob'));
$manager->persist($userBob);
$userCharlie = new User();
$userCharlie->setUsername('charlie');
$userCharlie->setFirstName('Charlie');
$userCharlie->setLastName('Moreau');
$userCharlie->setRoles(['ROLE_USER']);
$userCharlie->setPassword($this->passwordHasher->hashPassword($userCharlie, 'charlie'));
$manager->persist($userCharlie);
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\DependencyInjection\Compiler;
use App\Mcp\Schema\CoercingSchemaGenerator;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Wires the CoercingSchemaGenerator into the MCP server builder so that
* generated tool input schemas accept stringified scalar arguments.
*/
final class McpSchemaGeneratorPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('mcp.server.builder')) {
return;
}
$container->getDefinition('mcp.server.builder')
->addMethodCall('setSchemaGenerator', [new Reference(CoercingSchemaGenerator::class)])
;
}
}
+32
View File
@@ -55,6 +55,14 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'absence_request:read', 'absence_balance:read'])]
private ?string $username = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $firstName = null;
#[ORM\Column(length: 100, nullable: true)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?string $lastName = null;
/** @var list<string> */
#[ORM\Column]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
@@ -147,6 +155,30 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): static
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): static
{
$this->lastName = $lastName;
return $this;
}
public function getUserIdentifier(): string
{
return (string) $this->username;
+7
View File
@@ -4,10 +4,17 @@ declare(strict_types=1);
namespace App;
use App\DependencyInjection\Compiler\McpSchemaGeneratorPass;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
class Kernel extends BaseKernel
{
use MicroKernelTrait;
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new McpSchemaGeneratorPass());
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Mcp\EventListener;
use App\Mcp\Schema\CoercingSchemaGenerator;
use Mcp\Capability\RegistryInterface;
use Mcp\Event\RequestEvent;
use Mcp\Schema\Request\CallToolRequest;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Throwable;
use function is_array;
use function is_string;
/**
* Decodes JSON-encoded structured arguments before tool calls are validated.
*
* Some MCP clients/proxies serialize array and object arguments as JSON strings
* (e.g. `tagIds` arrives as the string `"[3]"` instead of the array `[3]`). The
* SDK validates arguments against the JSON Schema BEFORE casting, so an `array`
* schema rejects the string with a 422, and ReferenceHandler::castToArray does
* not decode JSON strings either.
*
* This listener runs on the SDK RequestEvent (dispatched before any handler) and,
* driven by the tool's input schema, decodes string arguments whose target type
* is `array` or `object`. Scalar stringification is handled separately by
* {@see CoercingSchemaGenerator}.
*/
#[AsEventListener(event: RequestEvent::class)]
final class CoerceJsonEncodedArgumentsListener
{
public function __construct(
#[Autowire(service: 'mcp.registry')]
private readonly RegistryInterface $registry,
) {}
public function __invoke(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$request instanceof CallToolRequest) {
return;
}
$arguments = $request->arguments;
if ([] === $arguments) {
return;
}
$properties = $this->toolProperties($request->name);
if (null === $properties) {
return;
}
$changed = false;
foreach ($arguments as $name => $value) {
if (!is_string($value) || !is_array($properties[$name] ?? null)) {
continue;
}
$types = (array) ($properties[$name]['type'] ?? []);
if ([] === array_intersect(['array', 'object'], $types)) {
continue;
}
$decoded = json_decode($value, true);
if (is_array($decoded)) {
$arguments[$name] = $decoded;
$changed = true;
}
}
if ($changed) {
$event->setRequest(
new CallToolRequest($request->name, $arguments)
->withId($request->getId())
->withMeta($request->getMeta()),
);
}
}
/**
* @return null|array<string, mixed>
*/
private function toolProperties(string $toolName): ?array
{
try {
$schema = $this->registry->getTool($toolName)->tool->inputSchema;
} catch (Throwable) {
return null;
}
$properties = $schema['properties'] ?? null;
return is_array($properties) ? $properties : null;
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Schema;
use Mcp\Capability\Discovery\DocBlockParser;
use Mcp\Capability\Discovery\SchemaGenerator;
use Mcp\Capability\Discovery\SchemaGeneratorInterface;
use Reflector;
use function count;
use function in_array;
use function is_array;
/**
* Wraps the SDK SchemaGenerator and relaxes scalar parameter schemas so that
* numeric/boolean parameters also accept their string representation.
*
* Rationale: some MCP clients serialize every JSON-RPC argument as a string
* (e.g. `"22"` instead of `22`). The SDK validates arguments against the
* generated JSON Schema BEFORE casting them (see CallToolHandler), so a strict
* `integer` schema rejects `"22"` with a 422 even though the SDK's
* ReferenceHandler::castArgumentType would happily coerce it afterwards.
*
* By advertising `["integer", "string"]` (resp. number/boolean) we let opis
* accept the stringified value; the reflected PHP type hint (`int`, `bool`, ...)
* still drives the actual coercion in ReferenceHandler. Non-numeric strings are
* rejected later with a clear "cannot cast" error.
*/
final class CoercingSchemaGenerator implements SchemaGeneratorInterface
{
public function __construct(
private readonly SchemaGeneratorInterface $inner = new SchemaGenerator(new DocBlockParser()),
) {}
public function generate(Reflector $reflection): array
{
$schema = $this->inner->generate($reflection);
if (isset($schema['properties']) && is_array($schema['properties'])) {
foreach ($schema['properties'] as $name => $property) {
if (is_array($property)) {
$schema['properties'][$name] = $this->relaxNode($property);
}
}
}
return $schema;
}
public function generateOutputSchema(Reflector $reflection): ?array
{
return $this->inner->generateOutputSchema($reflection);
}
/**
* @param array<string, mixed> $node
*
* @return array<string, mixed>
*/
private function relaxNode(array $node): array
{
if (isset($node['type'])) {
$node['type'] = $this->relaxType($node['type']);
}
// Relax array element types too (stringified IDs inside tagIds, etc.).
if (isset($node['items']) && is_array($node['items'])) {
$node['items'] = $this->relaxNode($node['items']);
}
return $node;
}
/**
* Adds "string" to a type definition that allows integer/number/boolean.
*
* @param string|string[] $type
*
* @return string|string[]
*/
private function relaxType(array|string $type): array|string
{
$types = (array) $type;
$isNumericOrBool = in_array('integer', $types, true)
|| in_array('number', $types, true)
|| in_array('boolean', $types, true);
if ($isNumericOrBool && !in_array('string', $types, true)) {
$types[] = 'string';
}
return 1 === count($types) ? $types[0] : array_values($types);
}
}
+4
View File
@@ -41,6 +41,10 @@ class CreateTaskTool
private readonly CalDavService $calDavService,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
* @param int[] $collaboratorIds IDs of the collaborators to attach
*/
public function __invoke(
int $projectId,
string $title,
+3
View File
@@ -18,6 +18,9 @@ class ListTasksTool
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to filter by
*/
public function __invoke(
?int $projectId = null,
?int $statusId = null,
+4
View File
@@ -38,6 +38,10 @@ class UpdateTaskTool
private readonly CalDavService $calDavService,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
* @param int[] $collaboratorIds IDs of the collaborators to attach
*/
public function __invoke(
int $id,
?string $title = null,
@@ -33,6 +33,9 @@ class CreateTimeEntryTool
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
*/
public function __invoke(
int $userId,
string $startedAt,
@@ -30,6 +30,9 @@ class UpdateTimeEntryTool
private readonly Security $security,
) {}
/**
* @param int[] $tagIds IDs of the tags to attach
*/
public function __invoke(
int $id,
?string $title = null,
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Mcp;
use App\Mcp\EventListener\CoerceJsonEncodedArgumentsListener;
use Mcp\Capability\Registry\ToolReference;
use Mcp\Capability\RegistryInterface;
use Mcp\Event\RequestEvent;
use Mcp\Schema\Request\CallToolRequest;
use Mcp\Schema\Tool;
use Mcp\Server\Session\SessionInterface;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
class CoerceJsonEncodedArgumentsListenerTest extends TestCase
{
private const SCHEMA = [
'type' => 'object',
'properties' => [
'id' => ['type' => ['integer', 'string']],
'title' => ['type' => 'string'],
'tagIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]],
'collaboratorIds' => ['type' => ['array', 'null'], 'items' => ['type' => ['integer', 'string']]],
],
];
public function testDecodesJsonStringArrayForArrayTypedParam(): void
{
$result = $this->handle(['tagIds' => '[3]', 'collaboratorIds' => '[5,6]']);
self::assertSame([3], $result->arguments['tagIds']);
self::assertSame([5, 6], $result->arguments['collaboratorIds']);
}
public function testLeavesRealArrayUntouched(): void
{
$result = $this->handle(['tagIds' => [3]]);
self::assertSame([3], $result->arguments['tagIds']);
}
public function testDoesNotTouchStringTypedParamEvenIfItLooksLikeJson(): void
{
$result = $this->handle(['title' => '[1,2]']);
// title is schema-typed string -> must stay the literal string.
self::assertSame('[1,2]', $result->arguments['title']);
}
public function testLeavesScalarTypedParamUntouched(): void
{
// id is integer/string typed -> not an array/object, handled by the schema
// relaxation + SDK cast, not by this listener.
$result = $this->handle(['id' => '463']);
self::assertSame('463', $result->arguments['id']);
}
public function testPreservesRequestId(): void
{
$result = $this->handle(['tagIds' => '[3]']);
self::assertSame(1, $result->getId());
}
/**
* @param array<string, mixed> $arguments
*/
private function handle(array $arguments): CallToolRequest
{
$tool = new Tool('update-task', self::SCHEMA, null, null);
$reference = new ToolReference($tool, static fn () => null);
$registry = $this->createMock(RegistryInterface::class);
$registry->method('getTool')->willReturn($reference);
$request = new CallToolRequest('update-task', $arguments)->withId(1);
$event = new RequestEvent($request, $this->createMock(SessionInterface::class));
(new CoerceJsonEncodedArgumentsListener($registry))($event);
$result = $event->getRequest();
self::assertInstanceOf(CallToolRequest::class, $result);
return $result;
}
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Mcp;
use App\Mcp\Schema\CoercingSchemaGenerator;
use App\Mcp\Tool\Task\CreateTaskTool;
use App\Mcp\Tool\Task\ListTasksTool;
use PHPUnit\Framework\TestCase;
use ReflectionMethod;
/**
* @internal
*/
class CoercingSchemaGeneratorTest extends TestCase
{
private CoercingSchemaGenerator $generator;
protected function setUp(): void
{
$this->generator = new CoercingSchemaGenerator();
}
public function testNullableIntegerScalarAlsoAcceptsString(): void
{
$schema = $this->generator->generate(new ReflectionMethod(ListTasksTool::class, '__invoke'));
// ?int $projectId -> ["null","integer"] relaxed with "string".
self::assertSame(['null', 'integer', 'string'], $schema['properties']['projectId']['type']);
}
public function testRequiredIntegerScalarAlsoAcceptsString(): void
{
$schema = $this->generator->generate(new ReflectionMethod(ListTasksTool::class, '__invoke'));
// int $limit = 100 -> "integer" relaxed to ["integer","string"].
self::assertSame(['integer', 'string'], $schema['properties']['limit']['type']);
}
public function testBooleanScalarAlsoAcceptsString(): void
{
$schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke'));
// ?bool $syncToCalendar -> ["boolean","null"] relaxed with "string".
$type = $schema['properties']['syncToCalendar']['type'];
self::assertContains('boolean', $type);
self::assertContains('string', $type);
self::assertContains('null', $type);
}
public function testArrayItemTypeAlsoAcceptsString(): void
{
$schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke'));
// int[] $tagIds -> items {type: integer} relaxed to {type: [integer, string]}.
self::assertSame(['integer', 'string'], $schema['properties']['tagIds']['items']['type']);
}
public function testStringScalarIsLeftUntouched(): void
{
$schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke'));
// string $title stays a plain string (no spurious relaxation).
self::assertSame('string', $schema['properties']['title']['type']);
}
public function testArrayContainerTypeIsNotRelaxed(): void
{
$schema = $this->generator->generate(new ReflectionMethod(CreateTaskTool::class, '__invoke'));
// The array container itself must not gain "string".
self::assertSame(['array', 'null'], $schema['properties']['tagIds']['type']);
}
}