@l10nmonster/mcp 3.0.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,261 @@
1
+ # L10n Monster MCP Server
2
+
3
+ Model Context Protocol (MCP) server for L10n Monster, exposing translation management functionality to Claude Code and other MCP clients.
4
+ The server exposed an standards-compliant Streamable HTTP transport with per-request isolation.
5
+
6
+ ## Usage
7
+
8
+ Register MCP as an extension to be served and then start an l10n monster server as usual.
9
+
10
+
11
+ ### Example
12
+
13
+ `l10nmonster.config.mjs`:
14
+ ```javascript
15
+ import config from '@l10nmonster/core';
16
+ import serve from '@l10nmonster/server';
17
+ import { createMcpRoutes } from '@l10nmonster/mcp';
18
+ serve.registerExtension('mcp', createMcpRoutes);
19
+
20
+ export default config.l10nMonster(import.meta.dirname).action(serve)
21
+ ```
22
+
23
+ With above config, you can start the l10n server via:
24
+ ```shell
25
+ $ npx l10n serve
26
+ ```
27
+
28
+ The server then exposes an mcp at `http://localhost:9000/api/ext/mcp` which can be leveraged by any LLM agent.
29
+
30
+
31
+ ## Available Tools
32
+
33
+ - `status` - Get status of various l10nmonster subsystems including channels, projects, providers, language pairs, jobs, translation memory etc. The caller agent controls which sub-systems to include and the level of details to allow for more efficient use of the context.
34
+ - `source_query` - Query source content and translation memory.
35
+ - `translate` - Translate segments using configured providers.
36
+
37
+ ## Extending MCP with Custom Tools
38
+
39
+ The MCP server supports a registration-based extensibility pattern, allowing external packages to add custom tools without modifying the core MCP package. This mirrors the `ServeAction.registerExtension` pattern used by the L10n Monster server.
40
+
41
+ ### Creating a Custom Tool
42
+
43
+ Custom tools extend the `McpTool` base class and follow the same conventions as built-in tools:
44
+
45
+ ```javascript
46
+ import { McpTool, McpInputError } from '@l10nmonster/mcp';
47
+ import { z } from 'zod';
48
+
49
+ export class MyCustomTool extends McpTool {
50
+ static metadata = {
51
+ name: 'my_custom_tool',
52
+ description: 'Does something custom with translation data',
53
+ inputSchema: z.object({
54
+ channelId: z.string().describe('Channel ID to process'),
55
+ option: z.string().optional().default('default').describe('Optional processing option')
56
+ })
57
+ };
58
+
59
+ static async execute(mm, args) {
60
+ // Access MonsterManager methods directly
61
+ const channel = mm.rm.getChannel(args.channelId);
62
+
63
+ if (!channel) {
64
+ throw new McpInputError(`Channel "${args.channelId}" not found`, {
65
+ hints: [`Available channels: ${mm.rm.channelIds.join(', ')}`]
66
+ });
67
+ }
68
+
69
+ // Return structured data
70
+ return {
71
+ channelId: args.channelId,
72
+ result: 'processed',
73
+ option: args.option
74
+ };
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### Registering Custom Tools
80
+
81
+ Register your custom tools in your `l10nmonster.config.mjs` before calling `serve.registerExtension`:
82
+
83
+ ```javascript
84
+ import config from '@l10nmonster/core';
85
+ import serve from '@l10nmonster/server';
86
+ import { createMcpRoutes, registerTool } from '@l10nmonster/mcp';
87
+ import { MyCustomTool } from './my-custom-tool.js';
88
+
89
+ // Register custom MCP tools
90
+ registerTool(MyCustomTool);
91
+
92
+ // Register MCP routes with the server
93
+ serve.registerExtension('mcp', createMcpRoutes);
94
+
95
+ export default config.l10nMonster(import.meta.dirname)
96
+ .action(serve);
97
+ ```
98
+
99
+ ### Tool Registration in Helper Packages
100
+
101
+ Helper packages can export their MCP tools for registration by consumers:
102
+
103
+ ```javascript
104
+ // In @l10nmonster/helpers-custom/index.js
105
+ export { MyCustomTool } from './mcpTools/MyCustomTool.js';
106
+
107
+ // In l10nmonster.config.mjs
108
+ import { registerTool } from '@l10nmonster/mcp';
109
+ import { MyCustomTool } from '@l10nmonster/helpers-custom';
110
+
111
+ registerTool(MyCustomTool);
112
+ ```
113
+
114
+ ### Tool Override
115
+
116
+ Registered tools can override built-in tools by using the same tool name. This allows customization of default behavior:
117
+
118
+ ```javascript
119
+ import { McpTool } from '@l10nmonster/mcp';
120
+ import { z } from 'zod';
121
+
122
+ // Override the built-in 'status' tool with custom implementation
123
+ export class CustomStatusTool extends McpTool {
124
+ static metadata = {
125
+ name: 'status', // Same name as built-in tool
126
+ description: 'Custom status implementation',
127
+ inputSchema: z.object({
128
+ // Custom schema
129
+ })
130
+ };
131
+
132
+ static async execute(mm, args) {
133
+ // Custom implementation
134
+ return { custom: true };
135
+ }
136
+ }
137
+
138
+ registerTool(CustomStatusTool);
139
+ ```
140
+
141
+ ### Exported API
142
+
143
+ The MCP package exports the following for extensibility:
144
+
145
+ - **`registerTool(ToolClass)`** - Register a custom MCP tool
146
+ - **`McpTool`** - Base class for creating tools
147
+ - **Error types** for structured error handling:
148
+ - `McpToolError` - Base error with structured metadata
149
+ - `McpInputError` - Invalid input errors
150
+ - `McpNotFoundError` - Resource not found errors
151
+ - `McpProviderError` - Translation provider errors
152
+
153
+
154
+ ## Examples
155
+
156
+ The `examples/` directory contains complete examples of custom MCP tools:
157
+
158
+ - **`custom-tool-example.js`** - Demonstrates creating custom tools including:
159
+ - `ProjectStatsTool` - Get detailed statistics for a specific project
160
+ - `QualityInsightsTool` - Analyze translation quality distribution
161
+
162
+ These examples show best practices for:
163
+ - Extending the `McpTool` base class
164
+ - Defining input schemas with Zod
165
+ - Accessing MonsterManager APIs
166
+ - Handling errors with structured error types
167
+ - Returning well-formatted results
168
+
169
+ ## Development
170
+
171
+ When developing, the best way to test the MCP server is by running it and using the inspector.
172
+
173
+ ```
174
+ npx @modelcontextprotocol/inspector
175
+ ```
176
+
177
+
178
+ ## Creating New Tools
179
+
180
+ All tools live under `/tools` directory. Each tool interface directly with MonsterManager, providing structured access to L10n Monster functionality through the Model Context Protocol. Unlike CLI actions, these tools return structured data optimized for programmatic consumption rather than console output.
181
+
182
+
183
+ All MCP tools inherit from the `McpTool` base class, which handles schema validation with Zod, automatic error formatting, and MCP response serialization. Tools are automatically discovered and registered at server startup by scanning exports from `tools/index.js`.
184
+
185
+ New tools should focus on a single responsibility while remaining composable with existing tools. Design them to be idempotent where possible, especially for query operations that shouldn't modify state.
186
+
187
+ Use consistent naming conventions: prefer verb-noun patterns like `source_query` or `translate_segments`, and standardize parameter names such as `channelId`, `sourceLang`, and `targetLang`. Write clear descriptions for both the tool and its parameters, as these directly influence how AI agents discover and use your tools. Include example values in parameter descriptions when they help clarify expected formats.
188
+
189
+ Remember that tool description and schema descriptions serve different purposes and audiences. Descriptions help with tool selection and understanding, while schema descriptions guide proper usage. So
190
+ - Aim to have good sensible default for variables to make it easy out-of-the-box usage. Users should be able to get started without extensive configuration.
191
+ - In case of error emit a helpful message to the caller with information to potentially recover. For example if a provided parameter is not valid in the error return a list of valid values so LLM can recover.
192
+ - Optional paramter should have their default explained.
193
+
194
+
195
+
196
+ Additional reading:
197
+ - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1382
198
+ - https://steipete.me/posts/2025/mcp-best-practices#tool--parameter-descriptions
199
+
200
+
201
+
202
+ ### Schema Design
203
+
204
+ Define input schemas using Zod with descriptive field documentation. Use `.describe()` to explain each parameter's purpose and format, and leverage Zod's validation features like `.min()`, `.max()`, and `.optional()` to enforce constraints and provide sensible defaults.
205
+
206
+ ```javascript
207
+ inputSchema: z.object({
208
+ channelId: z.string().describe('Channel ID to fetch source TUs from'),
209
+ guids: z.array(z.string()).min(1).describe('Array of TU GUIDs to translate'),
210
+ whereCondition: z.string().optional().default('true').describe('SQL WHERE condition against sources')
211
+ })
212
+ ```
213
+
214
+ ### Implementation
215
+
216
+ Create a tool class that extends `McpTool` with static `metadata` and `execute` method:
217
+
218
+ ```javascript
219
+ export class MyNewTool extends McpTool {
220
+ static metadata = {
221
+ name: 'my_new_tool',
222
+ description: 'Tool description for MCP discovery',
223
+ inputSchema: z.object({...})
224
+ };
225
+
226
+ static async execute(mm, args) {
227
+ // Call MonsterManager methods directly
228
+ // Return structured data - the base class handles MCP formatting
229
+ return { ... };
230
+ }
231
+ }
232
+ ```
233
+
234
+ Export the tool from `tools/index.js` to make it available for automatic registration:
235
+
236
+ ```javascript
237
+ export { MyNewTool } from './MyNewTool.js';
238
+ ```
239
+
240
+ ### Output Guidelines
241
+
242
+ Return structured objects rather than formatted strings, letting MCP clients handle presentation. Use arrays of objects for lists instead of concatenated strings, and include contextual metadata with results to help consumers understand what they're receiving.
243
+
244
+ The base class automatically formats results for MCP compatibility, so focus on returning clean, structured data that represents your tool's output naturally.
245
+
246
+ ### Error Handling
247
+
248
+ The base class provides structured error handling with specific error types (`McpInputError`, `McpNotFoundError`, `McpProviderError`) that include machine-readable codes, retry hints, and detailed context. Let errors bubble up naturally unless you need to add domain-specific context.
249
+
250
+ When catching errors, use the structured error types to provide actionable information:
251
+
252
+ ```javascript
253
+ try {
254
+ return await someOperation();
255
+ } catch (error) {
256
+ throw new McpInputError(`Invalid channel: ${channelId}`, {
257
+ hints: ['Call translation_status to see available channels'],
258
+ cause: error
259
+ });
260
+ }
261
+ ```
package/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // Main MCP server integration
2
+ export { createMcpRoutes } from './server.js';
3
+
4
+ // Tool registration for extensibility
5
+ import { registry } from './tools/registry.js';
6
+ export const registerTool = registry.registerTool.bind(registry);
7
+
8
+ // Base classes and utilities for creating custom tools
9
+ export {
10
+ McpTool,
11
+ McpToolError,
12
+ McpInputError,
13
+ McpNotFoundError,
14
+ McpProviderError
15
+ } from './tools/mcpTool.js';
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@l10nmonster/mcp",
3
+ "version": "3.0.0-alpha.16",
4
+ "description": "L10n Monster Model Context Protocol (MCP) Server",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "scripts": {
8
+ "test": "node --test tests/*.test.js"
9
+ },
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.18.1",
12
+ "zod": "^3"
13
+ },
14
+ "peerDependencies": {
15
+ "@l10nmonster/core": "^3.0.0-alpha.0"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.11.0"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "localization",
26
+ "translation",
27
+ "l10n"
28
+ ],
29
+ "author": "L10n Monster",
30
+ "license": "MIT"
31
+ }
package/server.js ADDED
@@ -0,0 +1,235 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
5
+ import * as mcpTools from './tools/index.js';
6
+ import { registry } from './tools/registry.js';
7
+ import { readFile } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ // Session management for HTTP transport
11
+ const sessions = new Map(); // sessionId -> { transport, lastActivity }
12
+ const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
13
+
14
+ // Shared MCP server instance per MonsterManager
15
+ // Use a WeakMap to avoid memory leaks. Once all sessions to a server are closed the server will be garbage collected.
16
+ const serverInstances = new WeakMap(); // monsterManager -> McpServer
17
+
18
+
19
+ async function getMcpPackageVersion() {
20
+ try {
21
+ const packageJsonContent = await readFile(path.join(import.meta.dirname, 'package.json'), 'utf-8');
22
+ const packageJson = JSON.parse(packageJsonContent.toString());
23
+ return packageJson.version;
24
+ } catch (error) {
25
+ console.error('Error parsing MCP package version:', error);
26
+ return '0.0.1-unknown';
27
+ }
28
+ }
29
+
30
+ // Set server version to be the package version
31
+ const serverVersion = await getMcpPackageVersion();
32
+
33
+ /**
34
+ * Setup tools on an MCP server instance
35
+ *
36
+ * Registers all MCP tools from the registry, including:
37
+ * 1. Built-in tools (auto-registered from ./tools/index.js)
38
+ * 2. External tools registered via registerTool()
39
+ */
40
+ async function setupToolsOnServer(server, monsterManager) {
41
+ // Register built-in tools if not already registered
42
+ const builtInTools = Object.values(mcpTools).filter(ToolClass => (
43
+ ToolClass &&
44
+ typeof ToolClass === 'function' &&
45
+ typeof ToolClass.handler === 'function' &&
46
+ ToolClass.metadata));
47
+
48
+ for (const ToolClass of builtInTools) {
49
+ const toolName = ToolClass.metadata.name;
50
+ if (!registry.hasTool(toolName)) {
51
+ registry.registerTool(ToolClass);
52
+ }
53
+ }
54
+
55
+ // Register all tools from the registry with the MCP server
56
+ for (const ToolClass of registry.getAllTools()) {
57
+ const { name, description, inputSchema } = ToolClass.metadata;
58
+ const handler = ToolClass.handler(monsterManager);
59
+
60
+ console.info(`Registering MCP tool: ${name}`);
61
+ await server.registerTool(
62
+ name,
63
+ {
64
+ title: name,
65
+ description,
66
+ inputSchema: inputSchema.shape,
67
+ },
68
+ handler,
69
+ );
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Get or create a shared MCP server instance for a MonsterManager
75
+ */
76
+ async function getOrCreateSharedServer(monsterManager) {
77
+ let server = serverInstances.get(monsterManager);
78
+
79
+ if (!server) {
80
+ server = new McpServer({
81
+ name: 'l10nmonster-mcp',
82
+ version: serverVersion,
83
+ });
84
+
85
+ await setupToolsOnServer(server, monsterManager);
86
+ serverInstances.set(monsterManager, server);
87
+ console.info('Created shared MCP server instance');
88
+ }
89
+
90
+ return server;
91
+ }
92
+
93
+ /**
94
+ * Clean up expired sessions on-demand
95
+ */
96
+ async function cleanupExpiredSessions() {
97
+ let cleaned = 0;
98
+ const now = Date.now();
99
+ for (const [sessionId, session] of sessions.entries()) {
100
+ if (now - session.lastActivity > SESSION_TIMEOUT_MS) {
101
+ try {
102
+ console.info(`Cleaning up expired session: ${sessionId}`);
103
+ await session.transport.close();
104
+ } catch (error) {
105
+ // Log error per session but continue cleaning other expired sessions
106
+ console.error(`Error cleaning up expired session ${sessionId}:`, error);
107
+ } finally {
108
+ sessions.delete(sessionId);
109
+ cleaned++;
110
+ }
111
+ }
112
+ }
113
+ return cleaned;
114
+ }
115
+
116
+ /**
117
+ * Creates MCP route handlers for use with the serve action extension mechanism.
118
+ * Returns route definitions that can be registered via ServeAction.registerExtension.
119
+ *
120
+ * @param {import('@l10nmonster/core').MonsterManager} mm - MonsterManager instance
121
+ * @returns {Array<[string, string, Function]>} Array of [method, path, handler] route definitions
122
+ */
123
+ export function createMcpRoutes(mm) {
124
+ // Handle POST requests for client-to-server communication
125
+ const handlePost = async (req, res) => {
126
+ // Clean up expired sessions on each request
127
+ await cleanupExpiredSessions();
128
+
129
+ try {
130
+ const sessionId = req.headers['mcp-session-id'];
131
+ let session;
132
+
133
+ if (sessionId && sessions.has(sessionId)) {
134
+ // Existing session - update activity timestamp
135
+ session = sessions.get(sessionId);
136
+ session.lastActivity = Date.now();
137
+ } else if (!sessionId && isInitializeRequest(req.body)) {
138
+ // New initialization request - create transport and connect shared server
139
+ const transport = new StreamableHTTPServerTransport({
140
+ sessionIdGenerator: () => randomUUID(),
141
+ onsessioninitialized: (newSessionId) => {
142
+ sessions.set(newSessionId, {
143
+ transport,
144
+ lastActivity: Date.now(),
145
+ });
146
+ },
147
+ // DNS rebinding protection is disabled by default for backwards compatibility.
148
+ // For production use, consider enabling:
149
+ // enableDnsRebindingProtection: true,
150
+ // allowedHosts: ['127.0.0.1'],
151
+ });
152
+
153
+ transport.onclose = () => {
154
+ if (transport.sessionId) {
155
+ console.info(`Session closed: ${transport.sessionId}`);
156
+ sessions.delete(transport.sessionId);
157
+ }
158
+ };
159
+
160
+ // Get or create shared MCP server and connect to new transport
161
+ const server = await getOrCreateSharedServer(mm);
162
+ await server.connect(transport);
163
+ console.info(`New MCP session initialized: ${transport.sessionId}`);
164
+
165
+ session = { transport, lastActivity: Date.now() };
166
+ } else {
167
+ // Invalid request - no session ID and not an initialize request
168
+ return res.status(400).json({
169
+ jsonrpc: '2.0',
170
+ error: {
171
+ code: -32000,
172
+ message: 'Bad Request: No valid session ID provided and not an initialize request',
173
+ },
174
+ id: null,
175
+ });
176
+ }
177
+
178
+ // Handle the request through the transport
179
+ await session.transport.handleRequest(req, res, req.body);
180
+ } catch (error) {
181
+ console.error('Error handling MCP POST request:', error);
182
+ res.status(500).json({
183
+ jsonrpc: '2.0',
184
+ error: {
185
+ code: -32603,
186
+ message: 'Internal server error',
187
+ data: { detail: error.message },
188
+ },
189
+ id: null,
190
+ });
191
+ }
192
+ };
193
+
194
+ // Handle GET and DELETE requests for existing sessions
195
+ const handleSessionRequest = async (req, res) => {
196
+ // Clean up expired sessions on each request
197
+ await cleanupExpiredSessions();
198
+
199
+ try {
200
+ const sessionId = req.headers['mcp-session-id'];
201
+
202
+ if (!sessionId || !sessions.has(sessionId)) {
203
+ return res.status(400).json({
204
+ jsonrpc: '2.0',
205
+ error: {
206
+ code: -32000,
207
+ message: 'Invalid or missing session ID',
208
+ },
209
+ id: null,
210
+ });
211
+ }
212
+
213
+ const session = sessions.get(sessionId);
214
+ session.lastActivity = Date.now();
215
+ await session.transport.handleRequest(req, res);
216
+ } catch (error) {
217
+ console.error('Error handling MCP session request:', error);
218
+ res.status(500).json({
219
+ jsonrpc: '2.0',
220
+ error: {
221
+ code: -32603,
222
+ message: 'Internal server error',
223
+ data: { detail: error.message },
224
+ },
225
+ id: null,
226
+ });
227
+ }
228
+ };
229
+
230
+ return [
231
+ ['post', '/', handlePost],
232
+ ['get', '/', handleSessionRequest],
233
+ ['delete', '/', handleSessionRequest],
234
+ ];
235
+ }