Compare commits

..

16 Commits

Author SHA1 Message Date
gitea-actions d48ee8eae5 chore: bump version to v0.4.23
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 21:26:44 +00:00
Matthieu 1dadc31884 style(kanban) : largeur fixe des colonnes de statut + scroll horizontal conditionnel
Auto Tag Develop / tag (push) Successful in 7s
Remplace flex-1/min-w par une largeur fixe (w-72) avec shrink-0 sur les
colonnes du board projet et de Mes Taches. Les colonnes ne sont plus
ecrasees quand un workflow compte beaucoup de statuts ; le scroll
horizontal n'apparait que si elles depassent la largeur du conteneur.
2026-06-01 23:26:35 +02:00
gitea-actions cdd7ca7626 chore: bump version to v0.4.22
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 49s
2026-06-01 20:52:47 +00:00
Matthieu e1bf9ecb22 fix(frontend) : copie presse-papiers fonctionnelle en HTTP via fallback execCommand
Auto Tag Develop / tag (push) Successful in 7s
navigator.clipboard n'est disponible qu'en secure context (HTTPS/localhost),
ce qui cassait la copie en prod HTTP. Ajout d'un utilitaire copyToClipboard
avec fallback textarea + execCommand, appliqué au viewer Markdown, au token
API du profil et au nom de branche Git.
2026-06-01 22:52:32 +02:00
gitea-actions 85897708ec chore: bump version to v0.4.21
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 55s
2026-06-01 20:45:31 +00:00
Matthieu 46c27aab42 feat(documents) : viewer Markdown des documents de ticket avec copie en un clic
Auto Tag Develop / tag (push) Successful in 10s
Aperçu du contenu source pour les fichiers texte/Markdown (.md, .txt, .csv, .json, .xml) avec bouton Copier (presse-papier + toast) et téléchargement. Détection par MIME ou extension, chargement via getContent. Icône Markdown dédiée dans la liste.
2026-06-01 22:45:21 +02:00
gitea-actions 7f79bdf236 chore: bump version to v0.4.20
Auto Tag Develop / tag (push) Successful in 12s
Build & Push Docker Image / build (push) Successful in 1m6s
2026-06-01 20:33:07 +00:00
Matthieu e87c474672 feat(mcp) : ajout du tool add-task-document pour attacher des documents Markdown à un ticket
Auto Tag Develop / tag (push) Successful in 10s
Nouveau tool MCP recevant le contenu texte brut (pas de base64), optimisé pour le Markdown. MIME inféré depuis l'extension du fileName (text/markdown par défaut). Persiste un TaskDocument avec uploadedBy = utilisateur du token MCP.
2026-06-01 22:32:44 +02:00
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
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
21 changed files with 688 additions and 16 deletions
+4
View File
@@ -45,6 +45,10 @@ services:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Mcp\Tool\Task\AddTaskDocumentTool:
arguments:
$uploadDir: '%task_document_upload_dir%'
App\Controller\UserAvatarController:
arguments:
$avatarUploadDir: '%avatar_upload_dir%'
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '0.4.15'
app.version: '0.4.23'
@@ -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<{
@@ -68,6 +68,7 @@ function isImage(mimeType: string): boolean {
}
function getIconForMime(mimeType: string): string {
if (mimeType === 'text/markdown') return 'mdi:language-markdown'
if (mimeType === 'application/pdf') return 'heroicons:document-text'
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
@@ -58,6 +58,46 @@
class="h-[85vh] w-[80vw] rounded-lg bg-white"
/>
<!-- Text / Markdown preview -->
<div
v-else-if="isText"
class="flex max-h-[85vh] w-[85vw] max-w-3xl flex-col overflow-hidden rounded-xl bg-white"
>
<div class="flex items-center justify-between gap-2 border-b border-neutral-200 px-4 py-3">
<p class="truncate text-sm font-medium text-neutral-700">{{ document.originalName }}</p>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-neutral-100 px-3 py-1.5 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-200"
@click="copyContent"
>
<Icon
:name="copied ? 'heroicons:check' : 'mdi:content-copy'"
class="h-4 w-4"
:class="copied ? 'text-green-600' : ''"
/>
{{ copied ? $t('taskDocuments.copied') : $t('taskDocuments.copy') }}
</button>
<a
:href="downloadUrl"
download
class="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-blue-700"
>
{{ $t('taskDocuments.download') }}
</a>
</div>
</div>
<div class="overflow-auto p-4">
<div v-if="loadingText" class="flex justify-center py-10">
<Icon name="heroicons:arrow-path" class="h-6 w-6 animate-spin text-neutral-400" />
</div>
<pre
v-else
class="whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-neutral-800"
>{{ textContent }}</pre>
</div>
</div>
<!-- Generic file -->
<div v-else class="flex flex-col items-center gap-4 rounded-xl bg-white p-10">
<Icon name="heroicons:document" class="h-16 w-16 text-neutral-400" />
@@ -73,7 +113,7 @@
</div>
<!-- File name footer -->
<p class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
<p v-if="!isText" class="mt-3 text-sm text-white/70">{{ document.originalName }}</p>
</div>
</div>
</Transition>
@@ -84,6 +124,7 @@
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
import { formatFileSize } from '~/utils/format'
import { copyToClipboard } from '~/utils/clipboard'
const props = defineProps<{
document: TaskDocument | null
@@ -98,19 +139,53 @@ defineEmits<{
}>()
const overlayRef = ref<HTMLElement | null>(null)
const textContent = ref('')
const loadingText = ref(false)
const copied = ref(false)
const { getDownloadUrl } = useTaskDocumentService()
const { getDownloadUrl, getContent } = useTaskDocumentService()
const { t } = useI18n()
const TEXT_MIME_TYPES = ['text/markdown', 'text/plain', 'text/csv', 'application/json', 'application/xml', 'text/xml']
function isTextDocument(doc: TaskDocument | null): boolean {
if (!doc) return false
if (TEXT_MIME_TYPES.includes(doc.mimeType)) return true
return /\.(md|markdown|txt|csv|json|xml)$/i.test(doc.originalName)
}
const downloadUrl = computed(() => props.document ? getDownloadUrl(props.document.id) : '')
const isImage = computed(() => props.document?.mimeType.startsWith('image/') ?? false)
const isPdf = computed(() => props.document?.mimeType === 'application/pdf')
const isText = computed(() => isTextDocument(props.document))
// Focus overlay for keyboard events
watch(() => props.document, (doc) => {
if (doc) {
nextTick(() => overlayRef.value?.focus())
async function copyContent() {
if (await copyToClipboard(textContent.value)) {
copied.value = true
useToast().success(t('taskDocuments.copied'))
setTimeout(() => { copied.value = false }, 2000)
}
})
}
// Focus overlay for keyboard events, and load text content for text/markdown documents
watch(() => props.document, async (doc) => {
textContent.value = ''
copied.value = false
if (!doc) return
nextTick(() => overlayRef.value?.focus())
if (isTextDocument(doc)) {
loadingText.value = true
try {
textContent.value = await getContent(doc.id)
} catch {
textContent.value = ''
} finally {
loadingText.value = false
}
}
}, { immediate: true })
</script>
<style scoped>
+2 -1
View File
@@ -229,6 +229,7 @@
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'
import { copyToClipboard } from '~/utils/clipboard'
const { t } = useI18n()
const props = defineProps<{
@@ -374,7 +375,7 @@ async function handleCreate() {
async function handleCopy() {
try {
const result = await getBranchName(props.task.id, branchForm.type)
await navigator.clipboard.writeText(result.name)
await copyToClipboard(result.name)
const { success } = useToast()
success(t('gitea.branch.copied'))
} catch {
+2
View File
@@ -126,6 +126,8 @@
"confirmDeleteTitle": "Supprimer le document",
"confirmDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ?",
"download": "Télécharger",
"copy": "Copier",
"copied": "Contenu copié !",
"maxSizeError": "Le fichier dépasse la taille maximale de 50 Mo."
},
"tasks": {
+1 -1
View File
@@ -439,7 +439,7 @@ onMounted(async () => {
<div
v-for="cat in CATEGORIES"
:key="cat"
class="flex min-w-40 flex-1 flex-col rounded-lg bg-neutral-50 transition"
class="flex w-72 shrink-0 flex-col rounded-lg bg-neutral-50 transition"
:class="dragOverCategory === cat ? 'ring-2 ring-primary-400' : ''"
@dragover.prevent="dragOverCategory = cat"
@dragleave="dragOverCategory = null"
+3 -3
View File
@@ -129,6 +129,7 @@
<script setup lang="ts">
import { useAvatarService } from '~/composables/useAvatarService'
import { useApiTokenService } from '~/services/api-token'
import { copyToClipboard } from '~/utils/clipboard'
const auth = useAuthStore()
const toast = useToast()
@@ -181,10 +182,9 @@ async function onRemove() {
async function onCopy() {
if (!auth.user?.apiToken) return
try {
await navigator.clipboard.writeText(auth.user.apiToken)
if (await copyToClipboard(auth.user.apiToken)) {
toast.success({ message: t('profile.apiToken.copied') })
} catch {
} else {
toast.error({ message: t('profile.apiToken.copyFailed') })
}
}
+16 -1
View File
@@ -96,7 +96,7 @@
<div
v-for="status in statuses"
:key="status.id"
class="flex min-w-36 flex-1 flex-col rounded-lg transition-colors"
class="flex w-72 shrink-0 flex-col rounded-lg transition-colors"
:class="dragOverStatusId === status.id ? 'bg-neutral-200' : 'bg-neutral-50'"
@dragover.prevent
@dragenter.prevent="onDragEnter(status.id)"
@@ -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)
}
+8 -1
View File
@@ -41,5 +41,12 @@ export function useTaskDocumentService() {
return `${baseURL}/task_documents/${id}/download`
}
return { getByTask, upload, remove, getDownloadUrl }
async function getContent(id: number): Promise<string> {
return $fetch<string>(`${baseURL}/task_documents/${id}/download`, {
credentials: 'include',
responseType: 'text',
})
}
return { getByTask, upload, remove, getDownloadUrl, getContent }
}
+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)
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Copy text to the clipboard with a fallback for non-secure contexts.
*
* `navigator.clipboard` is only available in secure contexts (HTTPS or
* localhost). On a plain HTTP origin (e.g. an internal/prod server without
* TLS) the API is missing, so we fall back to the legacy
* `document.execCommand('copy')` using a temporary off-screen textarea.
*
* @returns `true` if the copy succeeded, `false` otherwise.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Preferred path: available in secure contexts (HTTPS / localhost).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text)
return true
} catch {
// Fall through to the legacy fallback below.
}
}
// Legacy fallback: works on plain HTTP origins.
try {
const textarea = document.createElement('textarea')
textarea.value = text
// Keep it out of view and prevent layout shift / scrolling.
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
textarea.style.left = '-9999px'
textarea.setAttribute('readonly', '')
document.body.appendChild(textarea)
textarea.select()
textarea.setSelectionRange(0, text.length)
const ok = document.execCommand('copy')
document.body.removeChild(textarea)
return ok
} catch {
return false
}
}
@@ -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)])
;
}
}
+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',
+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);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tool\Task;
use App\Entity\TaskDocument;
use App\Repository\TaskRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException;
use Mcp\Capability\Attribute\McpTool;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Uid\Uuid;
use function sprintf;
use function strlen;
#[McpTool(name: 'add-task-document', description: 'Attach a text document (Markdown by default) to a task by passing its raw content. Optimized for Markdown reports/notes: the content is written verbatim as a UTF-8 file, no base64 needed. The MIME type is inferred from the fileName extension (.md, .txt, .csv, .json, .xml), defaulting to text/markdown.')]
class AddTaskDocumentTool
{
private const MAX_CONTENT_SIZE = 5 * 1024 * 1024; // 5 MB of text
private const EXTENSION_TO_MIME = [
'md' => 'text/markdown',
'markdown' => 'text/markdown',
'txt' => 'text/plain',
'csv' => 'text/csv',
'json' => 'application/json',
'xml' => 'text/xml',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly TaskRepository $taskRepository,
private readonly Security $security,
private readonly string $uploadDir,
) {}
/**
* @param int $taskId ID of the task to attach the document to
* @param string $content Raw text content of the document (e.g. Markdown)
* @param string $fileName Display name of the document, including extension (defaults to "document.md")
*/
public function __invoke(
int $taskId,
string $content,
string $fileName = 'document.md',
): string {
if (!$this->security->isGranted('ROLE_USER')) {
throw new AccessDeniedException('Access denied: ROLE_USER required.');
}
$task = $this->taskRepository->find($taskId);
if (null === $task) {
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
}
if ('' === $content) {
throw new InvalidArgumentException('Document content cannot be empty.');
}
$size = strlen($content);
if ($size > self::MAX_CONTENT_SIZE) {
throw new InvalidArgumentException('Content size exceeds 5 MB limit.');
}
$originalName = '' !== trim($fileName) ? trim($fileName) : 'document.md';
$extension = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
$mimeType = self::EXTENSION_TO_MIME[$extension] ?? 'text/markdown';
if ('' === $extension) {
$originalName .= '.md';
$extension = 'md';
}
$storedName = Uuid::v4()->toRfc4122().'.'.$extension;
if (!is_dir($this->uploadDir) && !mkdir($this->uploadDir, 0o775, true) && !is_dir($this->uploadDir)) {
throw new InvalidArgumentException(sprintf('Upload directory "%s" could not be created.', $this->uploadDir));
}
if (false === file_put_contents($this->uploadDir.'/'.$storedName, $content)) {
throw new InvalidArgumentException('Failed to write document to disk.');
}
$document = new TaskDocument();
$document->setTask($task);
$document->setOriginalName($originalName);
$document->setFileName($storedName);
$document->setMimeType($mimeType);
$document->setSize($size);
$document->setCreatedAt(new DateTimeImmutable());
$document->setUploadedBy($this->security->getUser());
$this->entityManager->persist($document);
$this->entityManager->flush();
return json_encode([
'id' => $document->getId(),
'taskId' => $task->getId(),
'originalName' => $document->getOriginalName(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'createdAt' => $document->getCreatedAt()?->format('c'),
'uploadedBy' => $document->getUploadedBy()?->getUsername(),
]);
}
}
@@ -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']);
}
}