@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.
@@ -0,0 +1,169 @@
1
+ import { describe, it, beforeEach } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { registry } from '../tools/registry.js';
4
+ import { McpTool } from '../tools/mcpTool.js';
5
+ import { z } from 'zod';
6
+
7
+ // Mock tool classes for testing
8
+ class TestTool1 extends McpTool {
9
+ static metadata = {
10
+ name: 'test_tool_1',
11
+ description: 'Test tool 1',
12
+ inputSchema: z.object({})
13
+ };
14
+
15
+ static async execute() {
16
+ return { result: 'test1' };
17
+ }
18
+
19
+ static handler() {
20
+ return async () => this.formatResult({ result: 'test1' });
21
+ }
22
+ }
23
+
24
+ class TestTool2 extends McpTool {
25
+ static metadata = {
26
+ name: 'test_tool_2',
27
+ description: 'Test tool 2',
28
+ inputSchema: z.object({})
29
+ };
30
+
31
+ static async execute() {
32
+ return { result: 'test2' };
33
+ }
34
+
35
+ static handler() {
36
+ return async () => this.formatResult({ result: 'test2' });
37
+ }
38
+ }
39
+
40
+ class OverrideTool extends McpTool {
41
+ static metadata = {
42
+ name: 'test_tool_1', // Same name as TestTool1
43
+ description: 'Override tool',
44
+ inputSchema: z.object({})
45
+ };
46
+
47
+ static async execute() {
48
+ return { result: 'override' };
49
+ }
50
+
51
+ static handler() {
52
+ return async () => this.formatResult({ result: 'override' });
53
+ }
54
+ }
55
+
56
+ describe('MCP Registry', () => {
57
+ beforeEach(() => {
58
+ // Clear registry before each test
59
+ registry.clear();
60
+ });
61
+
62
+ it('should register a single tool', () => {
63
+ registry.registerTool(TestTool1);
64
+
65
+ assert.strictEqual(registry.hasTool('test_tool_1'), true);
66
+ assert.strictEqual(registry.getTool('test_tool_1'), TestTool1);
67
+ });
68
+
69
+ it('should register multiple tools', () => {
70
+ registry.registerTools([TestTool1, TestTool2]);
71
+
72
+ assert.strictEqual(registry.hasTool('test_tool_1'), true);
73
+ assert.strictEqual(registry.hasTool('test_tool_2'), true);
74
+
75
+ const allTools = registry.getAllTools();
76
+ assert.strictEqual(allTools.length, 2);
77
+ });
78
+
79
+ it('should override existing tool with same name', () => {
80
+ registry.registerTool(TestTool1);
81
+ registry.registerTool(OverrideTool);
82
+
83
+ const tool = registry.getTool('test_tool_1');
84
+ assert.strictEqual(tool, OverrideTool);
85
+ assert.notStrictEqual(tool, TestTool1);
86
+ });
87
+
88
+ it('should return all registered tools', () => {
89
+ registry.registerTools([TestTool1, TestTool2]);
90
+
91
+ const allTools = registry.getAllTools();
92
+ assert.strictEqual(allTools.length, 2);
93
+ assert.ok(allTools.includes(TestTool1));
94
+ assert.ok(allTools.includes(TestTool2));
95
+ });
96
+
97
+ it('should return undefined for non-existent tool', () => {
98
+ const tool = registry.getTool('non_existent');
99
+ assert.strictEqual(tool, undefined);
100
+ });
101
+
102
+ it('should return false for non-existent tool check', () => {
103
+ assert.strictEqual(registry.hasTool('non_existent'), false);
104
+ });
105
+
106
+ it('should clear all registered tools', () => {
107
+ registry.registerTools([TestTool1, TestTool2]);
108
+ assert.strictEqual(registry.getAllTools().length, 2);
109
+
110
+ registry.clear();
111
+ assert.strictEqual(registry.getAllTools().length, 0);
112
+ });
113
+
114
+ it('should throw error for invalid tool class', () => {
115
+ assert.throws(
116
+ () => registry.registerTool(null),
117
+ /ToolClass must be a class\/function/
118
+ );
119
+
120
+ assert.throws(
121
+ () => registry.registerTool('not a class'),
122
+ /ToolClass must be a class\/function/
123
+ );
124
+ });
125
+
126
+ it('should throw error for tool without metadata', () => {
127
+ class InvalidTool {}
128
+
129
+ assert.throws(
130
+ () => registry.registerTool(InvalidTool),
131
+ /must have static metadata property/
132
+ );
133
+ });
134
+
135
+ it('should throw error for tool without name in metadata', () => {
136
+ class InvalidTool {
137
+ static metadata = {
138
+ description: 'No name'
139
+ };
140
+ }
141
+
142
+ assert.throws(
143
+ () => registry.registerTool(InvalidTool),
144
+ /metadata must have a string 'name' property/
145
+ );
146
+ });
147
+
148
+ it('should throw error for tool without handler method', () => {
149
+ class InvalidTool {
150
+ static metadata = {
151
+ name: 'invalid',
152
+ description: 'No handler'
153
+ };
154
+ }
155
+
156
+ assert.throws(
157
+ () => registry.registerTool(InvalidTool),
158
+ /must have static handler method/
159
+ );
160
+ });
161
+
162
+ it('should throw error when registerTools receives non-array', () => {
163
+ assert.throws(
164
+ () => registry.registerTools('not an array'),
165
+ /toolClasses must be an array/
166
+ );
167
+ });
168
+ });
169
+
package/tools/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { SourceQueryTool } from './sourceQuery.js';
2
+ export { TranslateTool } from './translate.js';
3
+ export { StatusTool as TranslationStatusTool } from './status.js';
@@ -0,0 +1,214 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Base error type for MCP tools with structured metadata.
5
+ */
6
+ export class McpToolError extends Error {
7
+
8
+ /**
9
+ * @param {string} message Error message
10
+ * @param {Object} [options]
11
+ * @param {string} [options.code] Stable machine readable error code
12
+ * @param {boolean} [options.retryable=false] Whether the caller can retry safely
13
+ * @param {string[]} [options.hints] Recovery hints for the caller
14
+ * @param {unknown} [options.details] Arbitrary structured payload with extra context
15
+ * @param {Error} [options.cause] Underlying error
16
+ */
17
+ constructor(message, { code, retryable = false, hints, details, cause } = {}) {
18
+ super(message);
19
+ this.name = this.constructor.name;
20
+ this.code = code;
21
+ this.retryable = retryable;
22
+ this.hints = hints;
23
+ this.details = details;
24
+ if (cause) {
25
+ this.cause = cause;
26
+ }
27
+ }
28
+
29
+ static wrap(error, defaults = {}) {
30
+ if (error instanceof McpToolError) {
31
+ return error;
32
+ }
33
+ return new McpToolError(error?.message ?? 'Unexpected error', {
34
+ cause: error,
35
+ ...defaults
36
+ });
37
+ }
38
+ }
39
+
40
+ export class McpInputError extends McpToolError {
41
+ constructor(message, options = {}) {
42
+ super(message, { code: 'INVALID_INPUT', ...options });
43
+ }
44
+ }
45
+
46
+ export class McpNotFoundError extends McpToolError {
47
+ constructor(message, options = {}) {
48
+ super(message, { code: 'NOT_FOUND', ...options });
49
+ }
50
+ }
51
+
52
+ export class McpProviderError extends McpToolError {
53
+ constructor(message, options = {}) {
54
+ super(message, { code: 'PROVIDER_ERROR', ...options });
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Base class for MCP tools that call underlying MonsterManager functions directly.
60
+ *
61
+ * MCP tools are separate from CLI actions and:
62
+ * - Return structured data (not console output)
63
+ * - Have MCP-optimized schemas (not CLI-optimized)
64
+ * - Call underlying MonsterManager methods directly
65
+ *
66
+ */
67
+
68
+ export class McpTool {
69
+
70
+ /**
71
+ * Static metadata object containing:
72
+ * - name: Tool identifier
73
+ * - description: Tool description for MCP
74
+ * - inputSchema: Zod schema for input validation
75
+ *
76
+ * Subclasses must define this static property.
77
+ */
78
+ static metadata = {
79
+ name: '',
80
+ description: '',
81
+ inputSchema: z.object({})
82
+ };
83
+
84
+ /**
85
+ * Execute the tool with validated arguments
86
+ * @param {Object} mm - MonsterManager instance
87
+ * @param {Object} args - Validated arguments from Zod schema
88
+ * @returns {Promise<*>} Result from tool execution (will be formatted by handler)
89
+ */
90
+ // eslint-disable-next-line no-unused-vars
91
+ static async execute(mm, args) {
92
+ throw new Error('Subclasses must implement execute()');
93
+ }
94
+
95
+ /**
96
+ * Returns an async handler function for MCP tool registration
97
+ * @param {Object} mm - MonsterManager instance
98
+ * @returns {Function} Async handler function
99
+ */
100
+ static handler(mm) {
101
+ const schema = this.metadata.inputSchema;
102
+
103
+ return async (args) => {
104
+ try {
105
+ const validatedArgs = schema.parse(args);
106
+ const result = await this.execute(mm, validatedArgs);
107
+ return this.formatResult(result);
108
+ } catch (error) {
109
+ return this.formatError(error);
110
+ }
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Format a result for MCP response
116
+ * @param {*} result - Result from execute()
117
+ * @returns {Object} MCP-formatted response
118
+ */
119
+ static formatResult(result) {
120
+ if (result && typeof result === 'object') {
121
+ // Pass through responses that already conform to MCP response shape
122
+ if (Array.isArray(result.content)) {
123
+ return result;
124
+ }
125
+ return {
126
+ content: [{
127
+ type: 'text',
128
+ text: JSON.stringify(result, null, 2)
129
+ }]
130
+ };
131
+ }
132
+
133
+ const text = typeof result === 'string' ? result : String(result);
134
+
135
+ return {
136
+ content: [{
137
+ type: 'text',
138
+ text
139
+ }]
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Format an error for MCP response
145
+ * @param {Error} error - Error that occurred
146
+ * @returns {Object} MCP-formatted error response
147
+ */
148
+ static formatError(error) {
149
+ const normalized = McpToolError.wrap(error, { code: 'UNKNOWN_ERROR' });
150
+ const summary = `Error executing ${this.metadata.name}: ${normalized.message}`;
151
+
152
+ const payload = {
153
+ name: normalized.name,
154
+ message: normalized.message,
155
+ code: normalized.code ?? 'UNKNOWN_ERROR',
156
+ retryable: Boolean(normalized.retryable),
157
+ hints: normalized.hints ?? [],
158
+ details: normalized.details
159
+ };
160
+
161
+ const cause = normalized.cause;
162
+ if (cause && typeof cause === 'object') {
163
+ let causeName;
164
+ if ('name' in cause && typeof cause.name === 'string') {
165
+ causeName = cause.name;
166
+ } else if (cause.constructor && typeof cause.constructor.name === 'string') {
167
+ causeName = cause.constructor.name;
168
+ }
169
+
170
+ let causeMessage;
171
+ if ('message' in cause && typeof cause.message === 'string') {
172
+ causeMessage = cause.message;
173
+ }
174
+
175
+ payload.cause = {
176
+ name: causeName,
177
+ message: causeMessage
178
+ };
179
+ }
180
+
181
+ if (error instanceof z.ZodError) {
182
+ payload.code = 'INVALID_INPUT';
183
+ payload.details = {
184
+ issues: error.issues.map(issue => ({
185
+ path: issue.path.join('.') || '(root)',
186
+ message: issue.message,
187
+ code: issue.code
188
+ }))
189
+ };
190
+ if (!payload.hints || payload.hints.length === 0) {
191
+ payload.hints = [
192
+ 'Inspect the schema and ensure all required fields are provided.',
193
+ 'Check enum values and optional defaults.'
194
+ ];
195
+ }
196
+ }
197
+
198
+ if (typeof normalized.stack === 'string') {
199
+ payload.stack = normalized.stack;
200
+ }
201
+
202
+ return {
203
+ content: [{
204
+ type: 'text',
205
+ text: summary
206
+ }, {
207
+ type: 'text',
208
+ text: JSON.stringify(payload, null, 2)
209
+ }],
210
+ isError: true
211
+ };
212
+ }
213
+ }
214
+
@@ -0,0 +1,69 @@
1
+ /**
2
+ * MCP Tool Registry
3
+ *
4
+ * Provides a registration mechanism for extending MCP with custom tools.
5
+ * External packages can register their own McpTool subclasses which will be
6
+ * automatically discovered and registered when the MCP server starts.
7
+ */
8
+
9
+ class Registry {
10
+ constructor() {
11
+ this.registeredTools = new Map(); // toolName -> ToolClass
12
+ }
13
+
14
+ registerTool(ToolClass) {
15
+ if (!ToolClass || typeof ToolClass !== 'function') {
16
+ throw new Error('registerTool: ToolClass must be a class/function');
17
+ }
18
+
19
+ if (!ToolClass.metadata || typeof ToolClass.metadata !== 'object') {
20
+ throw new Error(`registerTool: ${ToolClass.name} must have static metadata property`);
21
+ }
22
+
23
+ if (!ToolClass.metadata.name || typeof ToolClass.metadata.name !== 'string') {
24
+ throw new Error(`registerTool: ${ToolClass.name} metadata must have a string 'name' property`);
25
+ }
26
+
27
+ if (typeof ToolClass.handler !== 'function') {
28
+ throw new Error(`registerTool: ${ToolClass.name} must have static handler method`);
29
+ }
30
+
31
+ const toolName = ToolClass.metadata.name;
32
+
33
+ if (this.registeredTools.has(toolName)) {
34
+ console.warn(`MCP tool "${toolName}" is already registered. Overwriting with new implementation.`);
35
+ }
36
+
37
+ this.registeredTools.set(toolName, ToolClass);
38
+ console.info(`Registered MCP tool: ${toolName}`);
39
+ }
40
+
41
+ registerTools(toolClasses) {
42
+ if (!Array.isArray(toolClasses)) {
43
+ throw new Error('registerTools: toolClasses must be an array');
44
+ }
45
+
46
+ for (const ToolClass of toolClasses) {
47
+ this.registerTool(ToolClass);
48
+ }
49
+ }
50
+
51
+ getAllTools() {
52
+ return Array.from(this.registeredTools.values());
53
+ }
54
+
55
+ getTool(toolName) {
56
+ return this.registeredTools.get(toolName);
57
+ }
58
+
59
+ hasTool(toolName) {
60
+ return this.registeredTools.has(toolName);
61
+ }
62
+
63
+ clear() {
64
+ this.registeredTools.clear();
65
+ }
66
+ }
67
+
68
+ export const registry = new Registry();
69
+
@@ -0,0 +1,88 @@
1
+ import { z } from 'zod';
2
+ import { McpTool, McpInputError, McpNotFoundError, McpToolError } from './mcpTool.js';
3
+
4
+ /**
5
+ * MCP tool for querying source content and translation memory.
6
+ *
7
+ * This tool calls the underlying MonsterManager methods directly,
8
+ * avoiding CLI-specific concerns like console logging and file I/O.
9
+ */
10
+ export class SourceQueryTool extends McpTool {
11
+ static metadata = {
12
+ name: 'source_query',
13
+ description: `Query sources in the source snapshot.
14
+ You can write your own where conditions using SQL syntaxt against the following columns:
15
+ - channel: Channel id
16
+ - prj: Project id
17
+ - rid: Resource id
18
+ - sid: Segment id
19
+ - guid: Segment guid
20
+ - nsrc: Normalized source
21
+ - minQ: Desired minimum quality
22
+ - ntgt: Normalized translation (if available)
23
+ - q: Quality score (if translation is available)
24
+ - notes: Notes object (if any)
25
+ - mf: Message format id
26
+ - segProps: Non-standard segment properties object (if any)
27
+ - words: Word count
28
+ - chars: Character count`,
29
+ inputSchema: z.object({
30
+ sourceLang: z.string()
31
+ .describe('Source language code (e.g., "en-US")'),
32
+ targetLang: z.string()
33
+ .describe('Target language code (e.g., "es-419")'),
34
+ channel: z.string()
35
+ .describe('Channel ID to query sources from'),
36
+ whereCondition: z.string()
37
+ .optional()
38
+ .describe('SQL WHERE condition against sources (default: "true" to match all)'),
39
+ })
40
+ };
41
+
42
+ static async execute(mm, args) {
43
+ const { sourceLang, targetLang, channel: channelId } = args;
44
+ const whereCondition = args.whereCondition ?? 'true';
45
+
46
+ const availableChannels = mm.rm.channelIds ?? [];
47
+ if (availableChannels.length > 0 && !availableChannels.includes(channelId)) {
48
+ throw new McpNotFoundError(`Channel "${channelId}" not found`, {
49
+ hints: [`Available channels: ${availableChannels.join(', ')}`]
50
+ });
51
+ }
52
+
53
+ // Get translation memory and query sources
54
+ let tm;
55
+ try {
56
+ tm = mm.tmm.getTM(sourceLang, targetLang);
57
+ } catch (error) {
58
+ throw new McpInputError(`Language pair ${sourceLang}→${targetLang} is not available`, {
59
+ hints: ['Call translation_status with include=["coverage"] to inspect available language pairs.'],
60
+ cause: error
61
+ });
62
+ }
63
+
64
+ let tus;
65
+ try {
66
+ tus = await tm.querySource(channelId, whereCondition);
67
+ } catch (error) {
68
+ throw new McpToolError('Failed to execute query against source snapshot', {
69
+ code: 'QUERY_FAILED',
70
+ hints: [
71
+ 'Verify that your SQL WHERE clause only references supported columns.',
72
+ 'Escaping: wrap string literals in single quotes.'
73
+ ],
74
+ details: { channelId, whereCondition },
75
+ cause: error
76
+ });
77
+ }
78
+
79
+ return {
80
+ message: tus.length === 0 ? 'No content returned for the specified query' : `Found ${tus.length} translation units`,
81
+ sourceLang,
82
+ targetLang,
83
+ translationUnits: tus,
84
+ };
85
+
86
+ }
87
+ }
88
+