commit 32c33156a880ab9e03e8842cc77592388b422359 Author: Zsolt Tasnadi Date: Thu Jul 23 19:33:31 2026 +0200 initial commit diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..f96c081 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "ttg-marketplace", + "owner": { + "name": "TTG", + "email": "rastasi@gmail.com" + }, + "description": "TTG belső Claude Code plugin marketplace", + "plugins": [ + { + "name": "redmine-mcp", + "source": "./plugins/redmine-mcp", + "description": "Redmine MCP szerver — issue-k, projektek, időrögzítés, wiki és keresés a Redmine REST API-n keresztül.", + "version": "0.1.0", + "author": { + "name": "TTG" + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0616706 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# TTG Marketplace — Redmine MCP + +Claude Code plugin marketplace, egyetlen pluginnal: **redmine-mcp**, egy függőségmentes +Node.js MCP szerver a [Redmine REST API](https://www.redmine.org/projects/redmine/wiki/rest_api)-hoz. + +## Telepítés + +``` +/plugin marketplace add /Users/tasi/Work/TTG/redmine-mcp +/plugin install redmine-mcp@ttg-marketplace +``` + +(Git repóból: `/plugin marketplace add `.) + +Telepítéskor a plugin két beállítást kér: + +| Beállítás | Leírás | +|---|---| +| `redmine_url` | A Redmine példány alap URL-je, pl. `https://redmine.example.com` | +| `redmine_api_key` | Saját fiók → jobb oldalt „API hozzáférési kulcs” (az adminnak engedélyeznie kell: Adminisztráció → Beállítások → API → REST szolgáltatás engedélyezése) | + +Telepítés után: `/reload-plugins` vagy új session. + +## Eszközök + +**Issue-k:** `redmine_list_issues`, `redmine_get_issue` (journals/attachments/relations include-dal), `redmine_create_issue`, `redmine_update_issue` (mezők + komment a `notes`-szal), `redmine_delete_issue` + +**Projektek:** `redmine_list_projects`, `redmine_get_project`, `redmine_list_versions` + +**Időrögzítés:** `redmine_list_time_entries`, `redmine_create_time_entry`, `redmine_update_time_entry`, `redmine_delete_time_entry` + +**Felhasználók:** `redmine_list_users` (admin), `redmine_get_user` (`id="current"` = saját fiók) + +**Referencia-adatok:** `redmine_list_metadata` — státuszok, trackerek, prioritások, aktivitások, custom mezők, szerepek, mentett lekérdezések, csoportok, projekt-kategóriák, tagságok + +**Wiki és keresés:** `redmine_list_wiki_pages`, `redmine_get_wiki_page`, `redmine_update_wiki_page` (upsert), `redmine_search` + +## Megjegyzések + +- A szervernek nincs npm függősége (Node 18+ beépített `fetch`), build lépés sincs. +- Hitelesítés: `X-Redmine-API-Key` header. +- Lapozás: `offset`/`limit` (max. 100), a válaszban `total_count`. +- Kézi próba plugin nélkül: + ```sh + REDMINE_URL=https://redmine.example.com REDMINE_API_KEY=xxx \ + node plugins/redmine-mcp/server/index.js + ``` diff --git a/plugins/redmine-mcp/.claude-plugin/plugin.json b/plugins/redmine-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..58f993c --- /dev/null +++ b/plugins/redmine-mcp/.claude-plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "redmine-mcp", + "displayName": "Redmine MCP", + "version": "0.1.0", + "description": "Redmine MCP szerver — issue-k, projektek, időrögzítés, felhasználók, wiki és keresés a Redmine REST API-n keresztül.", + "author": { + "name": "TTG", + "email": "rastasi@gmail.com" + }, + "mcpServers": { + "redmine": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/server/index.js"], + "env": { + "REDMINE_URL": "${user_config.redmine_url}", + "REDMINE_API_KEY": "${user_config.redmine_api_key}" + } + } + }, + "userConfig": { + "redmine_url": { + "type": "string", + "title": "Redmine URL", + "description": "A Redmine példány alap URL-je, pl. https://redmine.example.com" + }, + "redmine_api_key": { + "type": "string", + "title": "Redmine API kulcs", + "description": "Saját fiók → API hozzáférési kulcs (az adminnak engedélyeznie kell a REST API-t)", + "sensitive": true + } + } +} diff --git a/plugins/redmine-mcp/server/index.js b/plugins/redmine-mcp/server/index.js new file mode 100644 index 0000000..29287ca --- /dev/null +++ b/plugins/redmine-mcp/server/index.js @@ -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));