From d8f17ff7e749697325871b9a96333866635c0f24 Mon Sep 17 00:00:00 2001 From: Jason Thistlethwaite Date: Sat, 25 Apr 2026 04:12:01 +0000 Subject: [PATCH] Add friendly redMCP query options --- README.md | 2 + docs/redmineup_local_fork_changelog.md | 11 + redMCP/README.md | 31 +++ redMCP/app/ListQueryNormalizer.php | 369 +++++++++++++++++++++++++ redMCP/app/McpDispatcher.php | 73 ++++- redMCP/bin/test-query-normalizer.php | 142 ++++++++++ redMCP/composer.json | 3 +- 7 files changed, 618 insertions(+), 13 deletions(-) create mode 100644 redMCP/app/ListQueryNormalizer.php create mode 100755 redMCP/bin/test-query-normalizer.php diff --git a/README.md b/README.md index 563f13f..ca3f5f3 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ The redMCP wrapper now makes Helpdesk behavior explicit: issue filters. - `search()` and `searchIssues()` expose Redmine's built-in `/search.json` text search. +- MCP list tools accept friendly `limit`, `page`, `offset`, `sort`, status, and + date options while still allowing raw Redmine `filters`/`params` overrides. - `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message metadata. - `updateIssue()` is safe by default and does not send customer email. diff --git a/docs/redmineup_local_fork_changelog.md b/docs/redmineup_local_fork_changelog.md index 85adb38..d9c0354 100644 --- a/docs/redmineup_local_fork_changelog.md +++ b/docs/redmineup_local_fork_changelog.md @@ -50,6 +50,11 @@ environment. Before risky edits, archive the current plugin directories in `/projects.json` APIs. - Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for Redmine's user and membership APIs. + - Added `ListQueryNormalizer` so MCP list tools accept friendly paging, + sorting, status, and date options while preserving raw Redmine + `filters`/`params` overrides. + - Added `redMCP/bin/test-query-normalizer.php` for no-network checks of + Redmine query parameter normalization. - Added a shared MCP dispatcher and transport-specific server wrappers. - Added `redMCP/bin/redmcp-server.php` for stdio MCP clients. - Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected @@ -85,6 +90,12 @@ environment. Before risky edits, archive the current plugin directories in - `redmine_list_project_memberships` returned direct and inherited memberships for `customer-service`; `fud-helpdesk` returned a valid empty membership list. + - `php redMCP/bin/test-query-normalizer.php` passed with coverage for paging, + sort shortcuts, status aliases, date presets/ranges, free-text dates, and + raw override precedence. + - Live Streamable HTTP tests passed for friendly `redmine_list_issues`, + `redmine_search_issues`, `redmine_list_users`, `redmine_list_projects`, and + `redmine_list_project_memberships` arguments. - Debug logging wrote JSONL records with full project-tool arguments and did not include the bearer token, `Authorization`, or Redmine API key. - Token generation passed default, `--bytes 48`, and `--env-line` modes. diff --git a/redMCP/README.md b/redMCP/README.md index 941a61a..628cb94 100644 --- a/redMCP/README.md +++ b/redMCP/README.md @@ -65,6 +65,31 @@ $issueResults = $client->searchIssues('power supply', [ ]); ``` +MCP list tools also accept friendly top-level query options so callers do not +need to know Redmine's raw parameter syntax. These are normalized into Redmine +params before the request is sent: + +```json +{ + "name": "redmine_list_issues", + "arguments": { + "project_id": "customer-service", + "status": "open", + "updated": "last 7 days", + "sort": "newest", + "limit": 25 + } +} +``` + +Friendly paging uses `limit`, `page`, and `offset`; the default limit is 25 and +the maximum is 100. Sort shortcuts include `newest`, `recent`, `oldest`, +`created_newest`, `created_oldest`, and `priority`. Issue date filters accept +exact dates, ranges such as `2026-04-01..2026-04-25`, objects with `from`/`to`, +phrases such as `since 2026-04-01`, and common presets such as `today`, +`yesterday`, `last 7 days`, `this_week`, and `last_month`. Raw `filters` or +`params` remain available and override friendly fields on conflict. + Project and user discovery is read-only: ```php @@ -175,6 +200,12 @@ and explicit Helpdesk email responses. Tools that can send customer-visible mail require an explicit tool call such as `redmine_send_helpdesk_response` or `redmine_update_issue` with `send_helpdesk_email=true`. +Run the local no-network query normalizer checks with: + +```sh +php redMCP/bin/test-query-normalizer.php +``` + ## Test instance A working test copy of Redmine is available on the LAN at `192.168.50.170`. diff --git a/redMCP/app/ListQueryNormalizer.php b/redMCP/app/ListQueryNormalizer.php new file mode 100644 index 0000000..44e9879 --- /dev/null +++ b/redMCP/app/ListQueryNormalizer.php @@ -0,0 +1,369 @@ + $arguments + * + * @return array + */ + public static function listParams(array $arguments): array + { + $params = self::pagingParams($arguments); + self::addSort($params, $arguments['sort'] ?? null); + + return array_merge($params, self::objectValue($arguments, 'params')); + } + + /** + * @param array $arguments + * + * @return array + */ + public static function issueFilters(array $arguments, ?DateTimeImmutable $clock = null): array + { + $filters = self::pagingParams($arguments); + + foreach ([ + 'project_id', + 'tracker_id', + 'assigned_to_id', + 'author_id', + 'priority_id', + 'category_id', + 'query_id', + ] as $key) { + if (array_key_exists($key, $arguments)) { + $filters[$key] = $arguments[$key]; + } + } + + if (array_key_exists('status', $arguments)) { + $filters['status_id'] = self::statusValue($arguments['status']); + } elseif (array_key_exists('status_id', $arguments)) { + $filters['status_id'] = self::statusValue($arguments['status_id']); + } + + self::addDateFilter($filters, 'created_on', $arguments['created'] ?? null, $clock); + self::addDateFilter($filters, 'updated_on', $arguments['updated'] ?? null, $clock); + self::addDateFilter($filters, 'due_date', $arguments['due'] ?? null, $clock); + self::addSort($filters, $arguments['sort'] ?? null); + + return array_merge($filters, self::objectValue($arguments, 'filters')); + } + + /** + * @param array $arguments + * + * @return array + */ + public static function userParams(array $arguments): array + { + $params = self::pagingParams($arguments); + + foreach (['name', 'group_id'] as $key) { + if (array_key_exists($key, $arguments)) { + $params[$key] = $arguments[$key]; + } + } + + if (array_key_exists('status', $arguments)) { + $params['status'] = self::userStatusValue($arguments['status']); + } + + self::addSort($params, $arguments['sort'] ?? null); + + return array_merge($params, self::objectValue($arguments, 'params')); + } + + /** + * @param array $arguments + * + * @return array + */ + public static function searchParams(array $arguments): array + { + $params = self::pagingParams($arguments); + + foreach (['project_id', 'scope'] as $key) { + if (array_key_exists($key, $arguments)) { + $params[$key] = $arguments[$key]; + } + } + + foreach (['all_words', 'titles_only', 'open_issues'] as $key) { + if (array_key_exists($key, $arguments)) { + $params[$key] = self::booleanString($arguments[$key]); + } + } + + self::addSort($params, $arguments['sort'] ?? null); + + return array_merge($params, self::objectValue($arguments, 'params')); + } + + /** + * @param array $arguments + * + * @return array + */ + private static function pagingParams(array $arguments): array + { + $limit = self::positiveInt($arguments['limit'] ?? self::DEFAULT_LIMIT, self::DEFAULT_LIMIT); + $limit = min($limit, self::MAX_LIMIT); + + $params = ['limit' => $limit]; + if (array_key_exists('offset', $arguments)) { + $params['offset'] = max(0, (int) $arguments['offset']); + return $params; + } + + if (array_key_exists('page', $arguments)) { + $page = max(1, (int) $arguments['page']); + $params['offset'] = ($page - 1) * $limit; + } + + return $params; + } + + private static function positiveInt(mixed $value, int $default): int + { + $value = (int) $value; + + return $value > 0 ? $value : $default; + } + + /** + * @param array $params + * @param mixed $sort + */ + private static function addSort(array &$params, $sort): void + { + $normalized = self::sortValue($sort); + if ($normalized !== null) { + $params['sort'] = $normalized; + } + } + + /** + * @param mixed $sort + */ + private static function sortValue($sort): ?string + { + if ($sort === null || $sort === '') { + return null; + } + + if (is_array($sort)) { + $parts = []; + foreach ($sort as $item) { + if (!is_array($item)) { + continue; + } + $field = trim((string) ($item['field'] ?? '')); + if ($field === '') { + continue; + } + $direction = strtolower((string) ($item['direction'] ?? 'asc')) === 'desc' ? 'desc' : 'asc'; + $parts[] = $field . ':' . $direction; + } + + return $parts === [] ? null : implode(',', $parts); + } + + $sort = trim((string) $sort); + $shortcut = strtolower(str_replace([' ', '-'], '_', $sort)); + + return [ + 'newest' => 'updated_on:desc', + 'recent' => 'updated_on:desc', + 'oldest' => 'created_on:asc', + 'created_newest' => 'created_on:desc', + 'created_oldest' => 'created_on:asc', + 'priority' => 'priority:desc,updated_on:desc', + ][$shortcut] ?? $sort; + } + + /** + * @param mixed $value + */ + private static function statusValue($value): mixed + { + $status = strtolower(trim((string) $value)); + + return [ + 'open' => 'open', + 'opened' => 'open', + 'active' => 'open', + 'closed' => 'closed', + 'all' => '*', + 'any' => '*', + ][$status] ?? $value; + } + + /** + * @param mixed $value + */ + private static function userStatusValue($value): mixed + { + $status = strtolower(trim((string) $value)); + + return [ + 'active' => 1, + 'registered' => 2, + 'locked' => 3, + 'all' => '*', + 'any' => '*', + ][$status] ?? $value; + } + + /** + * @param array $params + * @param mixed $value + */ + private static function addDateFilter(array &$params, string $redmineField, $value, ?DateTimeImmutable $clock): void + { + if ($value === null || $value === '') { + return; + } + + $params[$redmineField] = self::dateValue($value, $clock); + } + + /** + * @param mixed $value + */ + private static function dateValue($value, ?DateTimeImmutable $clock): string + { + $clock = $clock ?? new DateTimeImmutable('now', new DateTimeZone('UTC')); + $clock = $clock->setTimezone(new DateTimeZone('UTC')); + + if (is_array($value)) { + $from = self::datePart($value['from'] ?? null, $clock); + $to = self::datePart($value['to'] ?? null, $clock); + + return self::rangeValue($from, $to); + } + + $text = trim((string) $value); + if ($text === '') { + throw new RuntimeException('Date filter cannot be empty.'); + } + + if (preg_match('/^(.+)\.\.(.+)$/', $text, $matches)) { + return self::rangeValue(self::datePart($matches[1], $clock), self::datePart($matches[2], $clock)); + } + + if (preg_match('/^(since|after)\s+(.+)$/i', $text, $matches)) { + return '>=' . self::datePart($matches[2], $clock); + } + + if (preg_match('/^(before|until)\s+(.+)$/i', $text, $matches)) { + return '<=' . self::datePart($matches[2], $clock); + } + + $preset = strtolower(str_replace(['-', ' '], '_', $text)); + if ($preset === 'today') { + return $clock->format('Y-m-d'); + } + if ($preset === 'yesterday') { + return $clock->modify('-1 day')->format('Y-m-d'); + } + if (preg_match('/^last_(\d+)_days$/', $preset, $matches)) { + $days = max(1, (int) $matches[1]); + return self::rangeValue($clock->modify('-' . ($days - 1) . ' days')->format('Y-m-d'), $clock->format('Y-m-d')); + } + if ($preset === 'this_week') { + return self::rangeValue($clock->modify('monday this week')->format('Y-m-d'), $clock->format('Y-m-d')); + } + if ($preset === 'last_week') { + return self::rangeValue( + $clock->modify('monday last week')->format('Y-m-d'), + $clock->modify('sunday last week')->format('Y-m-d') + ); + } + if ($preset === 'this_month') { + return self::rangeValue($clock->modify('first day of this month')->format('Y-m-d'), $clock->format('Y-m-d')); + } + if ($preset === 'last_month') { + return self::rangeValue( + $clock->modify('first day of last month')->format('Y-m-d'), + $clock->modify('last day of last month')->format('Y-m-d') + ); + } + + return self::datePart($text, $clock); + } + + private static function rangeValue(?string $from, ?string $to): string + { + if ($from !== null && $to !== null) { + return '><' . $from . '|' . $to; + } + if ($from !== null) { + return '>=' . $from; + } + if ($to !== null) { + return '<=' . $to; + } + + throw new RuntimeException('Date range requires from, to, or both.'); + } + + /** + * @param mixed $value + */ + private static function datePart($value, DateTimeImmutable $clock): ?string + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + $text = trim((string) $value); + $timestamp = strtotime($text, $clock->getTimestamp()); + if ($timestamp === false) { + throw new RuntimeException('Could not parse date filter: ' . $text); + } + + return gmdate('Y-m-d', $timestamp); + } + + /** + * @param mixed $value + */ + private static function booleanString($value): string + { + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) { + return '1'; + } + if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) { + return '0'; + } + } + + return $value ? '1' : '0'; + } + + /** + * @param array $arguments + * + * @return array + */ + private static function objectValue(array $arguments, string $key): array + { + return is_array($arguments[$key] ?? null) ? $arguments[$key] : []; + } +} diff --git a/redMCP/app/McpDispatcher.php b/redMCP/app/McpDispatcher.php index 8f2f3c4..b7c5ea8 100644 --- a/redMCP/app/McpDispatcher.php +++ b/redMCP/app/McpDispatcher.php @@ -92,7 +92,11 @@ final class McpDispatcher { return [ $this->tool('redmine_list_projects', 'List Redmine projects using native /projects.json.', [ - 'params' => ['type' => 'object', 'description' => 'Redmine project list params such as include, offset, and limit.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], + 'params' => ['type' => 'object', 'description' => 'Raw Redmine project list params; overrides friendly fields on conflict.'], ]), $this->tool('redmine_get_project', 'Fetch one Redmine project by id or identifier.', [ 'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project id or identifier.'], @@ -100,25 +104,70 @@ final class McpDispatcher ], ['project_id']), $this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [ 'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project id or identifier.'], - 'params' => ['type' => 'object', 'description' => 'Redmine membership list params such as offset and limit.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], + 'params' => ['type' => 'object', 'description' => 'Raw Redmine membership list params; overrides friendly fields on conflict.'], ], ['project_id']), $this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [ - 'params' => ['type' => 'object', 'description' => 'Redmine user list params such as status, name, group_id, offset, and limit.'], + 'status' => ['description' => 'User status such as active, registered, locked, all, or a Redmine status id.'], + 'name' => ['type' => 'string', 'description' => 'Filter users by name.'], + 'group_id' => ['type' => ['string', 'integer'], 'description' => 'Filter users by group id.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], + 'params' => ['type' => 'object', 'description' => 'Raw Redmine user list params; overrides friendly fields on conflict.'], ]), $this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [ 'user_id' => ['type' => 'integer'], 'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'], ], ['user_id']), $this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [ - 'filters' => ['type' => 'object', 'description' => 'Redmine issue list filters such as project_id, status_id, query_id, sort, offset, and limit.'], + 'project_id' => ['type' => ['string', 'integer']], + 'status' => ['description' => 'Issue status such as open, closed, all, or a Redmine status id.'], + 'status_id' => ['description' => 'Raw Redmine status id or status token.'], + 'tracker_id' => ['type' => ['string', 'integer']], + 'assigned_to_id' => ['type' => ['string', 'integer']], + 'author_id' => ['type' => ['string', 'integer']], + 'priority_id' => ['type' => ['string', 'integer']], + 'category_id' => ['type' => ['string', 'integer']], + 'query_id' => ['type' => ['string', 'integer']], + 'created' => ['description' => 'Friendly created_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'], + 'updated' => ['description' => 'Friendly updated_on date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'], + 'due' => ['description' => 'Friendly due_date filter such as today, last 7 days, since 2026-04-01, or {from,to}.'], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, priority, or a Redmine sort string.'], + 'filters' => ['type' => 'object', 'description' => 'Raw Redmine issue list filters; overrides friendly fields on conflict.'], ]), $this->tool('redmine_search', 'Search Redmine using native /search.json.', [ 'query' => ['type' => 'string'], - 'params' => ['type' => 'object', 'description' => 'Redmine search params such as project_id, all_words, titles_only, offset, and limit.'], + 'project_id' => ['type' => ['string', 'integer']], + 'scope' => ['type' => 'string'], + 'all_words' => ['type' => ['boolean', 'string', 'integer']], + 'titles_only' => ['type' => ['boolean', 'string', 'integer']], + 'open_issues' => ['type' => ['boolean', 'string', 'integer']], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], + 'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'], ], ['query']), $this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [ 'query' => ['type' => 'string'], - 'params' => ['type' => 'object', 'description' => 'Additional Redmine search params.'], + 'project_id' => ['type' => ['string', 'integer']], + 'scope' => ['type' => 'string'], + 'all_words' => ['type' => ['boolean', 'string', 'integer']], + 'titles_only' => ['type' => ['boolean', 'string', 'integer']], + 'open_issues' => ['type' => ['boolean', 'string', 'integer']], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], + 'page' => ['type' => 'integer', 'minimum' => 1], + 'offset' => ['type' => 'integer', 'minimum' => 0], + 'sort' => ['description' => 'Sort shortcut such as newest, oldest, created_newest, or a Redmine sort string.'], + 'params' => ['type' => 'object', 'description' => 'Raw Redmine search params; overrides friendly fields on conflict.'], ], ['query']), $this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [ 'issue_id' => ['type' => 'integer'], @@ -180,28 +229,28 @@ final class McpDispatcher switch ($name) { case 'redmine_list_projects': - $result = $this->redmine->listProjects($this->objectArg($arguments, 'params')); + $result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments)); break; case 'redmine_get_project': $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); break; case 'redmine_list_project_memberships': - $result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); + $result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), ListQueryNormalizer::listParams($arguments)); break; case 'redmine_list_users': - $result = $this->redmine->listUsers($this->objectArg($arguments, 'params')); + $result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments)); break; case 'redmine_get_user': $result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params')); break; case 'redmine_list_issues': - $result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters')); + $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments)); break; case 'redmine_search': - $result = $this->redmine->search($this->stringArg($arguments, 'query'), $this->objectArg($arguments, 'params')); + $result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); break; case 'redmine_search_issues': - $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), $this->objectArg($arguments, 'params')); + $result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments)); break; case 'redmine_get_issue': $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments'])); diff --git a/redMCP/bin/test-query-normalizer.php b/redMCP/bin/test-query-normalizer.php new file mode 100755 index 0000000..3aca061 --- /dev/null +++ b/redMCP/bin/test-query-normalizer.php @@ -0,0 +1,142 @@ +#!/usr/bin/env php +testDefaultPagingAndRawOverride(); + $this->testIssueFriendlyFilters(); + $this->testSortShortcutsAndStructuredSort(); + $this->testDatePresetsAndRanges(); + $this->testUserParams(); + $this->testSearchParams(); + + fwrite(STDOUT, "OK {$this->assertions} assertions\n"); + } + + private function testDefaultPagingAndRawOverride(): void + { + $params = ListQueryNormalizer::listParams([ + 'limit' => 500, + 'page' => 3, + 'params' => ['limit' => 75], + ]); + + $this->assertSame(75, $params['limit'], 'raw params override normalized limit'); + $this->assertSame(200, $params['offset'], 'page offset uses clamped normalized limit before raw override'); + } + + private function testIssueFriendlyFilters(): void + { + $filters = ListQueryNormalizer::issueFilters([ + 'project_id' => 'customer-service', + 'status' => 'open', + 'assigned_to_id' => 25, + 'updated' => 'since 2026-04-01', + 'filters' => ['assigned_to_id' => 'me'], + ]); + + $this->assertSame('customer-service', $filters['project_id'], 'project_id is copied'); + $this->assertSame('open', $filters['status_id'], 'open status alias maps to Redmine open status'); + $this->assertSame('me', $filters['assigned_to_id'], 'raw filters override normalized filters'); + $this->assertSame('>=2026-04-01', $filters['updated_on'], 'since date maps to Redmine lower bound'); + } + + private function testSortShortcutsAndStructuredSort(): void + { + $newest = ListQueryNormalizer::listParams(['sort' => 'newest']); + $this->assertSame('updated_on:desc', $newest['sort'], 'newest shortcut sorts by updated_on descending'); + + $priority = ListQueryNormalizer::listParams(['sort' => 'priority']); + $this->assertSame('priority:desc,updated_on:desc', $priority['sort'], 'priority shortcut includes updated_on tie-breaker'); + + $structured = ListQueryNormalizer::listParams([ + 'sort' => [ + ['field' => 'created_on', 'direction' => 'desc'], + ['field' => 'id', 'direction' => 'asc'], + ], + ]); + $this->assertSame('created_on:desc,id:asc', $structured['sort'], 'structured sort converts to Redmine sort string'); + } + + private function testDatePresetsAndRanges(): void + { + $clock = new DateTimeImmutable('2026-04-25 12:00:00', new DateTimeZone('UTC')); + + $today = ListQueryNormalizer::issueFilters(['created' => 'today'], $clock); + $this->assertSame('2026-04-25', $today['created_on'], 'today maps to exact date'); + + $range = ListQueryNormalizer::issueFilters(['created' => '2026-04-01..2026-04-25'], $clock); + $this->assertSame('><2026-04-01|2026-04-25', $range['created_on'], 'range string maps to Redmine between syntax'); + + $objectRange = ListQueryNormalizer::issueFilters(['due' => ['from' => '2026-05-01', 'to' => '2026-05-31']], $clock); + $this->assertSame('><2026-05-01|2026-05-31', $objectRange['due_date'], 'object range maps to due_date'); + + $lastSeven = ListQueryNormalizer::issueFilters(['updated' => 'last 7 days'], $clock); + $this->assertSame('><2026-04-19|2026-04-25', $lastSeven['updated_on'], 'last N days includes today'); + + $freeText = ListQueryNormalizer::issueFilters(['created' => 'April 2 2026'], $clock); + $this->assertSame('2026-04-02', $freeText['created_on'], 'simple free text date parses'); + } + + private function testSearchParams(): void + { + $params = ListQueryNormalizer::searchParams([ + 'project_id' => 'customer-service', + 'all_words' => true, + 'titles_only' => false, + 'open_issues' => true, + 'sort' => 'oldest', + 'page' => 2, + 'params' => ['offset' => 5], + ]); + + $this->assertSame('customer-service', $params['project_id'], 'search project_id is copied'); + $this->assertSame('1', $params['all_words'], 'true search flags map to Redmine 1'); + $this->assertSame('0', $params['titles_only'], 'false search flags map to Redmine 0'); + $this->assertSame('1', $params['open_issues'], 'open_issues flag maps to Redmine 1'); + $this->assertSame('created_on:asc', $params['sort'], 'oldest shortcut maps to created_on ascending'); + $this->assertSame(5, $params['offset'], 'raw search params override normalized paging'); + } + + private function testUserParams(): void + { + $params = ListQueryNormalizer::userParams([ + 'status' => 'active', + 'name' => 'danny', + 'group_id' => 4, + 'sort' => 'oldest', + ]); + + $this->assertSame(1, $params['status'], 'active user status maps to Redmine active status id'); + $this->assertSame('danny', $params['name'], 'user name filter is copied'); + $this->assertSame(4, $params['group_id'], 'user group filter is copied'); + $this->assertSame('created_on:asc', $params['sort'], 'user list accepts sort shortcuts'); + } + + /** + * @param mixed $expected + * @param mixed $actual + */ + private function assertSame($expected, $actual, string $message): void + { + $this->assertions++; + if ($expected === $actual) { + return; + } + + fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n"); + exit(1); + } +} + +(new QueryNormalizerTest())->run(); diff --git a/redMCP/composer.json b/redMCP/composer.json index a9b0430..22d7fa2 100644 --- a/redMCP/composer.json +++ b/redMCP/composer.json @@ -10,7 +10,8 @@ "bin": [ "bin/redmcp-server.php", "bin/redmcp-http-server.php", - "bin/generate-bearer-token.php" + "bin/generate-bearer-token.php", + "bin/test-query-normalizer.php" ], "require": { "kbsali/redmine-api": "^2.9"