initial commit
This commit is contained in:
@@ -0,0 +1,581 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Redmine MCP server — zero-dependency stdio JSON-RPC implementation.
|
||||
*
|
||||
* Env:
|
||||
* REDMINE_URL Base URL of the Redmine instance (e.g. https://redmine.example.com)
|
||||
* REDMINE_API_KEY API key (Account → API access key; REST API must be enabled by the admin)
|
||||
*
|
||||
* API docs: https://www.redmine.org/projects/redmine/wiki/rest_api
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const SERVER_INFO = { name: 'redmine-mcp', version: '0.1.0' };
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
|
||||
const REDMINE_URL = (process.env.REDMINE_URL || '').replace(/\/+$/, '');
|
||||
const REDMINE_API_KEY = process.env.REDMINE_API_KEY || '';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redmine REST client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function requireConfig() {
|
||||
if (!REDMINE_URL) {
|
||||
throw new Error(
|
||||
'REDMINE_URL is not configured. Set it to your Redmine base URL (e.g. https://redmine.example.com).'
|
||||
);
|
||||
}
|
||||
if (!REDMINE_API_KEY) {
|
||||
throw new Error(
|
||||
'REDMINE_API_KEY is not configured. Find your key under "My account" → "API access key" in Redmine ' +
|
||||
'(the administrator must enable the REST API in Administration → Settings → API).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params || {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
qs.set(key, String(value));
|
||||
}
|
||||
const s = qs.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
async function redmine(method, path, { query, body } = {}) {
|
||||
requireConfig();
|
||||
const url = `${REDMINE_URL}${path}${buildQuery(query)}`;
|
||||
const headers = { 'X-Redmine-API-Key': REDMINE_API_KEY };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = text;
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed.errors) detail = parsed.errors.join('; ');
|
||||
} catch { /* keep raw text */ }
|
||||
const hints = {
|
||||
401: 'Authentication failed — check REDMINE_API_KEY.',
|
||||
403: 'Forbidden — your Redmine account lacks permission for this action.',
|
||||
404: 'Not found — check the id/identifier (or the REST API may be disabled).',
|
||||
422: 'Validation failed.',
|
||||
};
|
||||
const hint = hints[res.status] ? ` ${hints[res.status]}` : '';
|
||||
throw new Error(`Redmine API ${res.status} ${res.statusText} for ${method} ${path}.${hint}${detail ? ` Details: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
if (!text) return { success: true, status: res.status };
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { success: true, status: res.status, raw: text.slice(0, 2000) };
|
||||
}
|
||||
}
|
||||
|
||||
// Strip undefined values so we send clean payloads.
|
||||
function compact(obj) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined && v !== null) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGINATION = {
|
||||
offset: { type: 'integer', description: 'Skip this many results (pagination).' },
|
||||
limit: { type: 'integer', description: 'Number of results per page (default 25, max 100).' },
|
||||
};
|
||||
|
||||
const CUSTOM_FIELDS = {
|
||||
type: 'array',
|
||||
description: 'Custom field values, e.g. [{"id": 1, "value": "foo"}]. Multiselect fields take an array value.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Custom field id.' },
|
||||
value: { description: 'Field value (string, or array of strings for multiselect).' },
|
||||
},
|
||||
required: ['id', 'value'],
|
||||
},
|
||||
};
|
||||
|
||||
const ISSUE_FIELDS = {
|
||||
subject: { type: 'string', description: 'Issue subject/title.' },
|
||||
description: { type: 'string', description: 'Issue description (Textile/Markdown per instance settings).' },
|
||||
tracker_id: { type: 'integer', description: 'Tracker id (see redmine_list_metadata type=trackers).' },
|
||||
status_id: { type: 'integer', description: 'Status id (see redmine_list_metadata type=issue_statuses).' },
|
||||
priority_id: { type: 'integer', description: 'Priority id (see redmine_list_metadata type=issue_priorities).' },
|
||||
assigned_to_id: { type: 'integer', description: 'User id to assign the issue to.' },
|
||||
category_id: { type: 'integer', description: 'Issue category id.' },
|
||||
fixed_version_id: { type: 'integer', description: 'Target version id (see redmine_list_versions).' },
|
||||
parent_issue_id: { type: 'integer', description: 'Parent issue id (for subtasks).' },
|
||||
start_date: { type: 'string', description: 'Start date, YYYY-MM-DD.' },
|
||||
due_date: { type: 'string', description: 'Due date, YYYY-MM-DD.' },
|
||||
estimated_hours: { type: 'number', description: 'Estimated hours.' },
|
||||
done_ratio: { type: 'integer', description: 'Percent done, 0-100.' },
|
||||
is_private: { type: 'boolean', description: 'Whether the issue is private.' },
|
||||
custom_fields: CUSTOM_FIELDS,
|
||||
};
|
||||
|
||||
const TOOLS = [
|
||||
{
|
||||
name: 'redmine_list_projects',
|
||||
description: 'List projects visible to the authenticated user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
include: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated extras: trackers, issue_categories, enabled_modules, time_entry_activities.',
|
||||
},
|
||||
...PAGINATION,
|
||||
},
|
||||
},
|
||||
handler: (a) => redmine('GET', '/projects.json', { query: { include: a.include, offset: a.offset, limit: a.limit } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_get_project',
|
||||
description: 'Get a single project by numeric id or string identifier.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Project id or identifier (e.g. "42" or "my-project").' },
|
||||
include: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated extras: trackers, issue_categories, enabled_modules, time_entry_activities.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.id)}.json`, { query: { include: a.include } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_issues',
|
||||
description:
|
||||
'List/search issues with filters. Returns open issues by default; use status_id="*" for all, "closed" for closed. ' +
|
||||
'Date filters accept exact dates or operators like ">=2026-01-01" and "><2026-01-01|2026-01-31".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
issue_id: { type: 'string', description: 'Single issue id or comma-separated list of ids.' },
|
||||
status_id: { type: 'string', description: '"open" (default), "closed", "*", or a status id.' },
|
||||
assigned_to_id: { type: 'string', description: 'User id, or "me" for the authenticated user.' },
|
||||
author_id: { type: 'string', description: 'User id of the author, or "me".' },
|
||||
tracker_id: { type: 'integer', description: 'Filter by tracker id.' },
|
||||
priority_id: { type: 'integer', description: 'Filter by priority id.' },
|
||||
fixed_version_id: { type: 'integer', description: 'Filter by target version id.' },
|
||||
parent_id: { type: 'integer', description: 'Filter by parent issue id.' },
|
||||
query_id: { type: 'integer', description: 'Use a saved query id (see redmine_list_metadata type=queries).' },
|
||||
subject: { type: 'string', description: 'Subject filter, e.g. "~login bug" for contains.' },
|
||||
created_on: { type: 'string', description: 'e.g. ">=2026-07-01" or "><2026-07-01|2026-07-31".' },
|
||||
updated_on: { type: 'string', description: 'e.g. ">=2026-07-01".' },
|
||||
sort: { type: 'string', description: 'Sort column(s), ":desc" suffix for descending, e.g. "updated_on:desc".' },
|
||||
include: { type: 'string', description: 'Comma-separated extras: attachments, relations.' },
|
||||
extra_filters: {
|
||||
type: 'object',
|
||||
description: 'Any additional raw query params, e.g. {"cf_12": "value"} for custom field filters.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
...PAGINATION,
|
||||
},
|
||||
},
|
||||
handler: (a) => {
|
||||
const { extra_filters, ...rest } = a;
|
||||
return redmine('GET', '/issues.json', { query: { ...compact(rest), ...(extra_filters || {}) } });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_get_issue',
|
||||
description: 'Get a single issue by id, optionally with journals (comments/history), attachments, relations, children, watchers.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Issue id.' },
|
||||
include: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated extras: journals, children, attachments, relations, changesets, watchers, allowed_statuses.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => redmine('GET', `/issues/${a.id}.json`, { query: { include: a.include } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_create_issue',
|
||||
description: 'Create a new issue in a project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
watcher_user_ids: { type: 'array', items: { type: 'integer' }, description: 'User ids to add as watchers.' },
|
||||
...ISSUE_FIELDS,
|
||||
},
|
||||
required: ['project_id', 'subject'],
|
||||
},
|
||||
handler: (a) => redmine('POST', '/issues.json', { body: { issue: compact(a) } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_update_issue',
|
||||
description: 'Update an issue: change any field and/or add a comment via "notes".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Issue id.' },
|
||||
project_id: { type: 'string', description: 'Move to another project (id or identifier).' },
|
||||
notes: { type: 'string', description: 'Comment to add to the issue journal.' },
|
||||
private_notes: { type: 'boolean', description: 'Make the added note private.' },
|
||||
...ISSUE_FIELDS,
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => {
|
||||
const { id, ...fields } = a;
|
||||
return redmine('PUT', `/issues/${id}.json`, { body: { issue: compact(fields) } });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_delete_issue',
|
||||
description: 'Permanently delete an issue. Irreversible — prefer closing via redmine_update_issue unless deletion is explicitly wanted.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'integer', description: 'Issue id.' } },
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => redmine('DELETE', `/issues/${a.id}.json`),
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_time_entries',
|
||||
description: 'List time entries, filterable by user, project, issue and date range.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User id, or "me".' },
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
issue_id: { type: 'integer', description: 'Issue id.' },
|
||||
spent_on: { type: 'string', description: 'Exact date, YYYY-MM-DD.' },
|
||||
from: { type: 'string', description: 'Range start, YYYY-MM-DD.' },
|
||||
to: { type: 'string', description: 'Range end, YYYY-MM-DD.' },
|
||||
...PAGINATION,
|
||||
},
|
||||
},
|
||||
handler: (a) => redmine('GET', '/time_entries.json', { query: compact(a) }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_create_time_entry',
|
||||
description: 'Log time on an issue or a project (exactly one of issue_id/project_id is required).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
issue_id: { type: 'integer', description: 'Issue to log time on.' },
|
||||
project_id: { type: 'string', description: 'Project to log time on (if not issue-bound).' },
|
||||
hours: { type: 'number', description: 'Hours spent.' },
|
||||
spent_on: { type: 'string', description: 'Date, YYYY-MM-DD (defaults to today).' },
|
||||
activity_id: { type: 'integer', description: 'Activity id (see redmine_list_metadata type=time_entry_activities).' },
|
||||
comments: { type: 'string', description: 'Short description (max 255 chars).' },
|
||||
user_id: { type: 'integer', description: 'Log on behalf of this user (admin only).' },
|
||||
},
|
||||
required: ['hours'],
|
||||
},
|
||||
handler: (a) => redmine('POST', '/time_entries.json', { body: { time_entry: compact(a) } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_update_time_entry',
|
||||
description: 'Update an existing time entry.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'integer', description: 'Time entry id.' },
|
||||
issue_id: { type: 'integer' },
|
||||
project_id: { type: 'string' },
|
||||
hours: { type: 'number' },
|
||||
spent_on: { type: 'string', description: 'YYYY-MM-DD.' },
|
||||
activity_id: { type: 'integer' },
|
||||
comments: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => {
|
||||
const { id, ...fields } = a;
|
||||
return redmine('PUT', `/time_entries/${id}.json`, { body: { time_entry: compact(fields) } });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_delete_time_entry',
|
||||
description: 'Delete a time entry.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'integer', description: 'Time entry id.' } },
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => redmine('DELETE', `/time_entries/${a.id}.json`),
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_users',
|
||||
description: 'List users (requires admin rights). Filter by status, name or group.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', description: '1=active (default), 2=registered, 3=locked, or "" for all.' },
|
||||
name: { type: 'string', description: 'Matches login, firstname, lastname or email.' },
|
||||
group_id: { type: 'integer', description: 'Only users in this group.' },
|
||||
...PAGINATION,
|
||||
},
|
||||
},
|
||||
handler: (a) => redmine('GET', '/users.json', { query: compact(a) }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_get_user',
|
||||
description: 'Get a user by id, or the authenticated user with id="current".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'User id, or "current" for the API key owner.' },
|
||||
include: { type: 'string', description: 'Comma-separated extras: memberships, groups.' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: (a) => redmine('GET', `/users/${encodeURIComponent(a.id)}.json`, { query: { include: a.include } }),
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_versions',
|
||||
description: 'List versions (milestones/target versions) of a project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
},
|
||||
required: ['project_id'],
|
||||
},
|
||||
handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/versions.json`),
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_metadata',
|
||||
description:
|
||||
'List reference data needed for other calls: issue statuses, trackers, priorities, time entry activities, ' +
|
||||
'custom fields, roles, saved queries, groups, or per-project issue categories and memberships.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'issue_statuses', 'trackers', 'issue_priorities', 'time_entry_activities',
|
||||
'document_categories', 'custom_fields', 'roles', 'queries', 'groups',
|
||||
'issue_categories', 'memberships',
|
||||
],
|
||||
description: 'Which reference list to fetch.',
|
||||
},
|
||||
project_id: {
|
||||
type: 'string',
|
||||
description: 'Project id or identifier — required for issue_categories and memberships.',
|
||||
},
|
||||
...PAGINATION,
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
handler: (a) => {
|
||||
const paths = {
|
||||
issue_statuses: '/issue_statuses.json',
|
||||
trackers: '/trackers.json',
|
||||
issue_priorities: '/enumerations/issue_priorities.json',
|
||||
time_entry_activities: '/enumerations/time_entry_activities.json',
|
||||
document_categories: '/enumerations/document_categories.json',
|
||||
custom_fields: '/custom_fields.json',
|
||||
roles: '/roles.json',
|
||||
queries: '/queries.json',
|
||||
groups: '/groups.json',
|
||||
};
|
||||
if (a.type === 'issue_categories' || a.type === 'memberships') {
|
||||
if (!a.project_id) throw new Error(`project_id is required for type=${a.type}.`);
|
||||
return redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/${a.type}.json`, {
|
||||
query: { offset: a.offset, limit: a.limit },
|
||||
});
|
||||
}
|
||||
return redmine('GET', paths[a.type], { query: { offset: a.offset, limit: a.limit } });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_search',
|
||||
description: 'Full-text search across issues, wiki pages, documents, etc.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Search query. Multiple space-separated tokens are ANDed.' },
|
||||
scope: {
|
||||
type: 'string',
|
||||
description: 'Optional comma-separated result types to enable, e.g. "issues,wiki_pages". Default: all.',
|
||||
},
|
||||
titles_only: { type: 'boolean', description: 'Match in titles only.' },
|
||||
open_issues: { type: 'boolean', description: 'Restrict issue results to open issues.' },
|
||||
...PAGINATION,
|
||||
},
|
||||
required: ['q'],
|
||||
},
|
||||
handler: (a) => {
|
||||
const query = { q: a.q, offset: a.offset, limit: a.limit };
|
||||
if (a.titles_only) query.titles_only = 1;
|
||||
if (a.open_issues) query.open_issues = 1;
|
||||
for (const s of (a.scope || '').split(',').map((x) => x.trim()).filter(Boolean)) {
|
||||
query[s] = 1;
|
||||
}
|
||||
return redmine('GET', '/search.json', { query });
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_list_wiki_pages',
|
||||
description: 'List all wiki pages of a project.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { project_id: { type: 'string', description: 'Project id or identifier.' } },
|
||||
required: ['project_id'],
|
||||
},
|
||||
handler: (a) => redmine('GET', `/projects/${encodeURIComponent(a.project_id)}/wiki/index.json`),
|
||||
},
|
||||
{
|
||||
name: 'redmine_get_wiki_page',
|
||||
description: 'Get a wiki page (optionally a specific old version) with its text.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
title: { type: 'string', description: 'Wiki page title.' },
|
||||
version: { type: 'integer', description: 'Specific page version to fetch.' },
|
||||
},
|
||||
required: ['project_id', 'title'],
|
||||
},
|
||||
handler: (a) => {
|
||||
const base = `/projects/${encodeURIComponent(a.project_id)}/wiki/${encodeURIComponent(a.title)}`;
|
||||
const path = a.version ? `${base}/${a.version}.json` : `${base}.json`;
|
||||
return redmine('GET', path);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'redmine_update_wiki_page',
|
||||
description: 'Create or update a wiki page (PUT is upsert in Redmine).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
project_id: { type: 'string', description: 'Project id or identifier.' },
|
||||
title: { type: 'string', description: 'Wiki page title.' },
|
||||
text: { type: 'string', description: 'Full page content (replaces existing content).' },
|
||||
comments: { type: 'string', description: 'Edit comment for the page history.' },
|
||||
version: { type: 'integer', description: 'Base version for optimistic locking (409 on stale edit).' },
|
||||
},
|
||||
required: ['project_id', 'title', 'text'],
|
||||
},
|
||||
handler: (a) =>
|
||||
redmine('PUT', `/projects/${encodeURIComponent(a.project_id)}/wiki/${encodeURIComponent(a.title)}.json`, {
|
||||
body: { wiki_page: compact({ text: a.text, comments: a.comments, version: a.version }) },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP stdio transport (newline-delimited JSON-RPC 2.0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message) + '\n');
|
||||
}
|
||||
|
||||
function sendResult(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result });
|
||||
}
|
||||
|
||||
function sendError(id, code, message) {
|
||||
send({ jsonrpc: '2.0', id, error: { code, message } });
|
||||
}
|
||||
|
||||
async function handleRequest(msg) {
|
||||
const { id, method, params } = msg;
|
||||
|
||||
switch (method) {
|
||||
case 'initialize':
|
||||
sendResult(id, {
|
||||
protocolVersion: params?.protocolVersion || PROTOCOL_VERSION,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: SERVER_INFO,
|
||||
});
|
||||
return;
|
||||
|
||||
case 'ping':
|
||||
sendResult(id, {});
|
||||
return;
|
||||
|
||||
case 'tools/list':
|
||||
sendResult(id, {
|
||||
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
||||
});
|
||||
return;
|
||||
|
||||
case 'tools/call': {
|
||||
const tool = TOOL_MAP.get(params?.name);
|
||||
if (!tool) {
|
||||
sendError(id, -32602, `Unknown tool: ${params?.name}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await tool.handler(params.arguments || {});
|
||||
sendResult(id, {
|
||||
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
||||
isError: false,
|
||||
});
|
||||
} catch (err) {
|
||||
sendResult(id, {
|
||||
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
default:
|
||||
if (id !== undefined && id !== null) {
|
||||
sendError(id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
// Notifications (notifications/initialized, notifications/cancelled, ...) are ignored.
|
||||
}
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
buffer += chunk;
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
sendError(null, -32700, 'Parse error');
|
||||
continue;
|
||||
}
|
||||
handleRequest(msg).catch((err) => {
|
||||
if (msg.id !== undefined && msg.id !== null) {
|
||||
sendError(msg.id, -32603, `Internal error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
Reference in New Issue
Block a user