Add friendly redMCP query options
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']));
|
||||||
|
|||||||
Executable
+142
@@ -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();
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user