Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions 8cfa048e5a chore: bump version to v0.4.19
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 51s
2026-05-29 14:46:18 +00:00
Matthieu c692e4cf43 fix(time-tracking) : afficher toutes les time entries sans filtre projet
Auto Tag Develop / tag (push) Successful in 13s
La vue suivi de temps tapait la GetCollection paginée de /time_entries
(30 items/page) et ne lisait que la première page : sur une semaine
chargée, les entrées les plus anciennes (triées startedAt DESC) étaient
tronquées tant qu'aucun filtre projet ne réduisait le total sous 30.

Ajout d'une GetCollection dédiée /time_entries/range non paginée, bornée
par date, vers laquelle pointe désormais getByDateRange.
2026-05-29 16:46:04 +02:00
gitea-actions 81d905257a chore: bump version to v0.4.18
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 1m0s
2026-05-28 08:51:21 +00:00
Matthieu a3c0696023 feat(projects) : archivage en masse des tickets sur statut final
Auto Tag Develop / tag (push) Successful in 9s
- TaskBulkActions : prop canArchive + bouton archive conditionnel
- pages/projects/[id] : computed canArchiveSelection (true quand le filtre statut courant pointe vers un statut isFinal)
- purge la sélection des ids hors filtre courant pour garder le compteur cohérent en vue liste
2026-05-28 10:50:48 +02:00
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
7 changed files with 227 additions and 2 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.16'
app.version: '0.4.19'
@@ -79,6 +79,17 @@
@update:model-value="(v: number | null) => v && emit('bulk-update', 'group', v)"
/>
<!-- Archive (only when current filter targets a final status) -->
<MalioButtonIcon
v-if="canArchive"
icon="mdi:archive-outline"
aria-label="Archiver"
variant="ghost"
icon-size="22"
button-class="self-end text-neutral-500 hover:bg-primary-50 hover:text-primary-500"
@click="emit('bulk-archive')"
/>
<!-- Delete -->
<MalioButtonIcon
icon="mdi:delete-outline"
@@ -113,9 +124,11 @@ const props = withDefaults(defineProps<{
groups: TaskGroup[]
selectedTasks?: Task[]
projects?: Project[]
canArchive?: boolean
}>(), {
selectedTasks: () => [],
projects: () => [],
canArchive: false,
})
const emit = defineEmits<{
+15
View File
@@ -161,6 +161,7 @@
:priorities="priorities"
:efforts="efforts"
:groups="groups"
:can-archive="canArchiveSelection"
@toggle-all="toggleSelectAll(filteredTasks)"
@bulk-update="onBulkUpdate"
@bulk-archive="onBulkArchive"
@@ -297,6 +298,12 @@ const effortFilterOptions = computed(() =>
efforts.value.map(e => ({ label: e.label, value: e.id }))
)
const canArchiveSelection = computed(() => {
if (selectedStatusId.value === null) return false
const status = statuses.value.find(s => s.id === selectedStatusId.value)
return status?.isFinal === true
})
const filteredTasks = computed(() => {
let result = tasks.value.filter(t => !t.archived)
if (selectedGroupId.value) {
@@ -323,6 +330,14 @@ const filteredTasks = computed(() => {
return result
})
watch(filteredTasks, (list) => {
if (selectedTaskIds.size === 0) return
const visibleIds = new Set(list.map(t => t.id))
for (const id of selectedTaskIds) {
if (!visibleIds.has(id)) selectedTaskIds.delete(id)
}
})
function tasksByStatus(statusId: number): Task[] {
return filteredTasks.value.filter(t => t.status?.id === statusId)
}
+1 -1
View File
@@ -25,7 +25,7 @@ export function useTimeEntryService() {
if (params.tag) {
query['tags[]'] = `/api/task_tags/${params.tag}`
}
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries', query)
const data = await api.get<HydraCollection<TimeEntry>>('/time_entries/range', query)
return extractHydraMembers(data)
}
+7
View File
@@ -25,6 +25,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(security: "is_granted('ROLE_USER')"),
new GetCollection(
name: 'time_entries_range',
uriTemplate: '/time_entries/range',
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
),
new GetCollection(
name: 'active_time_entry',
uriTemplate: '/time_entries/active',
@@ -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,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;
}
}