582 lines
22 KiB
JavaScript
582 lines
22 KiB
JavaScript
#!/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));
|