Add friendly redMCP query options

This commit is contained in:
Jason Thistlethwaite
2026-04-25 04:12:01 +00:00
parent a25361f5fc
commit d8f17ff7e7
7 changed files with 618 additions and 13 deletions
+2
View File
@@ -321,6 +321,8 @@ The redMCP wrapper now makes Helpdesk behavior explicit:
issue filters. issue filters.
- `search()` and `searchIssues()` expose Redmine's built-in `/search.json` - `search()` and `searchIssues()` expose Redmine's built-in `/search.json`
text search. 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 - `issueWithHelpdesk()` composes normal issue data with Helpdesk ticket/message
metadata. metadata.
- `updateIssue()` is safe by default and does not send customer email. - `updateIssue()` is safe by default and does not send customer email.
+11
View File
@@ -50,6 +50,11 @@ environment. Before risky edits, archive the current plugin directories in
`/projects.json` APIs. `/projects.json` APIs.
- Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for - Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for
Redmine's user and membership APIs. 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 a shared MCP dispatcher and transport-specific server wrappers.
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients. - Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected - 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 - `redmine_list_project_memberships` returned direct and inherited
memberships for `customer-service`; `fud-helpdesk` returned a valid empty memberships for `customer-service`; `fud-helpdesk` returned a valid empty
membership list. 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 - Debug logging wrote JSONL records with full project-tool arguments and did
not include the bearer token, `Authorization`, or Redmine API key. not include the bearer token, `Authorization`, or Redmine API key.
- Token generation passed default, `--bytes 48`, and `--env-line` modes. - Token generation passed default, `--bytes 48`, and `--env-line` modes.
+31
View File
@@ -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: Project and user discovery is read-only:
```php ```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 require an explicit tool call such as `redmine_send_helpdesk_response` or
`redmine_update_issue` with `send_helpdesk_email=true`. `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 ## Test instance
A working test copy of Redmine is available on the LAN at `192.168.50.170`. A working test copy of Redmine is available on the LAN at `192.168.50.170`.
+369
View File
@@ -0,0 +1,369 @@
<?php
declare(strict_types=1);
namespace RedMCP;
use DateTimeImmutable;
use DateTimeZone;
use RuntimeException;
final class ListQueryNormalizer
{
private const DEFAULT_LIMIT = 25;
private const MAX_LIMIT = 100;
/**
* @param array<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
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<string,mixed> $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<string,mixed> $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<string,mixed> $arguments
*
* @return array<string,mixed>
*/
private static function objectValue(array $arguments, string $key): array
{
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
}
}
+61 -12
View File
@@ -92,7 +92,11 @@ final class McpDispatcher
{ {
return [ return [
$this->tool('redmine_list_projects', 'List Redmine projects using native /projects.json.', [ $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.', [ $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.'], 'project_id' => ['type' => ['string', 'integer'], 'description' => 'Redmine numeric project id or identifier.'],
@@ -100,25 +104,70 @@ final class McpDispatcher
], ['project_id']), ], ['project_id']),
$this->tool('redmine_list_project_memberships', 'List users/groups and roles for a Redmine project.', [ $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.'], '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']), ], ['project_id']),
$this->tool('redmine_list_users', 'List Redmine users using native /users.json.', [ $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.', [ $this->tool('redmine_get_user', 'Fetch one Redmine user by id.', [
'user_id' => ['type' => 'integer'], 'user_id' => ['type' => 'integer'],
'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'], 'params' => ['type' => 'object', 'description' => 'Optional Redmine user params such as include=memberships,groups.'],
], ['user_id']), ], ['user_id']),
$this->tool('redmine_list_issues', 'List Redmine issues using native /issues.json filters.', [ $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.', [ $this->tool('redmine_search', 'Search Redmine using native /search.json.', [
'query' => ['type' => 'string'], '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']), ], ['query']),
$this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [ $this->tool('redmine_search_issues', 'Search only issues using native /search.json.', [
'query' => ['type' => 'string'], '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']), ], ['query']),
$this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [ $this->tool('redmine_get_issue', 'Fetch one Redmine issue.', [
'issue_id' => ['type' => 'integer'], 'issue_id' => ['type' => 'integer'],
@@ -180,28 +229,28 @@ final class McpDispatcher
switch ($name) { switch ($name) {
case 'redmine_list_projects': case 'redmine_list_projects':
$result = $this->redmine->listProjects($this->objectArg($arguments, 'params')); $result = $this->redmine->listProjects(ListQueryNormalizer::listParams($arguments));
break; break;
case 'redmine_get_project': case 'redmine_get_project':
$result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params')); $result = $this->redmine->project($this->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
break; break;
case 'redmine_list_project_memberships': 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; break;
case 'redmine_list_users': case 'redmine_list_users':
$result = $this->redmine->listUsers($this->objectArg($arguments, 'params')); $result = $this->redmine->listUsers(ListQueryNormalizer::userParams($arguments));
break; break;
case 'redmine_get_user': case 'redmine_get_user':
$result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params')); $result = $this->redmine->user($this->intArg($arguments, 'user_id'), $this->objectArg($arguments, 'params'));
break; break;
case 'redmine_list_issues': case 'redmine_list_issues':
$result = $this->redmine->filterIssues($this->objectArg($arguments, 'filters')); $result = $this->redmine->filterIssues(ListQueryNormalizer::issueFilters($arguments));
break; break;
case 'redmine_search': 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; break;
case 'redmine_search_issues': 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; break;
case 'redmine_get_issue': case 'redmine_get_issue':
$result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments'])); $result = $this->redmine->issue($this->intArg($arguments, 'issue_id'), $this->stringListArg($arguments, 'include', ['journals', 'attachments']));
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use RedMCP\ListQueryNormalizer;
require __DIR__ . '/../vendor/autoload.php';
final class QueryNormalizerTest
{
private int $assertions = 0;
public function run(): void
{
$this->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();
+2 -1
View File
@@ -10,7 +10,8 @@
"bin": [ "bin": [
"bin/redmcp-server.php", "bin/redmcp-server.php",
"bin/redmcp-http-server.php", "bin/redmcp-http-server.php",
"bin/generate-bearer-token.php" "bin/generate-bearer-token.php",
"bin/test-query-normalizer.php"
], ],
"require": { "require": {
"kbsali/redmine-api": "^2.9" "kbsali/redmine-api": "^2.9"