Skip to content

Commit b71b570

Browse files
dani-polaniclaude
andcommitted
feat(mcp): add MCP server wrapping the align API
Expose a stateless MCP (Streamable HTTP, JSON-RPC) endpoint at /mcp inside the SvelteKit app. The single tool create_word_alignment reuses parseAlignBody + buildAlignUrl and returns a shareable diagram URL plus an inline PNG preview rendered via the existing OG pipeline. No new dependencies, no separate service. Document connection for Claude and ChatGPT in docs/mcp-server.md and the README, and add MCP sections to the /skill and /api pages. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d51d89a commit b71b570

7 files changed

Lines changed: 591 additions & 2 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ The agent translates, calls the API, and returns a shareable link.
8383

8484
Install instructions and a downloadable archive are also on the site: [aligner.tinygods.dev/skill](https://aligner.tinygods.dev/skill).
8585

86+
### MCP server
87+
88+
There is also an MCP server at `https://aligner.tinygods.dev/mcp` that exposes a single `create_word_alignment` tool. It returns a shareable link plus a preview image, and runs inside the same app over Streamable HTTP with no auth. Connect it in Claude:
89+
90+
```bash
91+
claude mcp add --transport http word-aligner https://aligner.tinygods.dev/mcp
92+
```
93+
94+
See [docs/mcp-server.md](docs/mcp-server.md) for ChatGPT Developer Mode setup and the tool schema.
95+
8696
## Learn more
8797

8898
- **App:** [aligner.tinygods.dev](https://aligner.tinygods.dev)

bitext/src/lib/mcp/server.test.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { handleMcpMessage, MCP_PROTOCOL_VERSION } from './server.js';
3+
import { decodeState } from '$lib/serialization/decode.js';
4+
5+
const ORIGIN = 'https://example.com';
6+
7+
describe('handleMcpMessage — protocol', () => {
8+
it('responds to initialize with protocol version and tool capability', async () => {
9+
const res = await handleMcpMessage(
10+
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
11+
ORIGIN
12+
);
13+
expect(res).toMatchObject({
14+
jsonrpc: '2.0',
15+
id: 1,
16+
result: {
17+
protocolVersion: MCP_PROTOCOL_VERSION,
18+
capabilities: { tools: {} },
19+
serverInfo: { name: 'word-aligner' }
20+
}
21+
});
22+
});
23+
24+
it('answers ping with an empty result', async () => {
25+
const res = await handleMcpMessage({ jsonrpc: '2.0', id: 2, method: 'ping' }, ORIGIN);
26+
expect(res).toMatchObject({ jsonrpc: '2.0', id: 2, result: {} });
27+
});
28+
29+
it('lists exactly one read-only tool', async () => {
30+
const res = (await handleMcpMessage(
31+
{ jsonrpc: '2.0', id: 3, method: 'tools/list' },
32+
ORIGIN
33+
)) as { result: { tools: Array<Record<string, unknown>> } };
34+
expect(res.result.tools).toHaveLength(1);
35+
const tool = res.result.tools[0]!;
36+
expect(tool.name).toBe('create_word_alignment');
37+
expect(tool.annotations).toMatchObject({ readOnlyHint: true, openWorldHint: false });
38+
expect(tool.inputSchema).toMatchObject({ required: ['lines'] });
39+
});
40+
41+
it('returns null (no reply) for a notification', async () => {
42+
const res = await handleMcpMessage(
43+
{ jsonrpc: '2.0', method: 'notifications/initialized' },
44+
ORIGIN
45+
);
46+
expect(res).toBeNull();
47+
});
48+
49+
it('rejects an unknown method with -32601', async () => {
50+
const res = await handleMcpMessage({ jsonrpc: '2.0', id: 4, method: 'does/notExist' }, ORIGIN);
51+
expect(res).toMatchObject({ id: 4, error: { code: -32601 } });
52+
});
53+
54+
it('rejects a non-object message with -32600', async () => {
55+
expect(await handleMcpMessage('nope', ORIGIN)).toMatchObject({ error: { code: -32600 } });
56+
expect(await handleMcpMessage([], ORIGIN)).toMatchObject({ error: { code: -32600 } });
57+
});
58+
});
59+
60+
describe('handleMcpMessage — create_word_alignment', () => {
61+
async function callTool(args: unknown) {
62+
return (await handleMcpMessage(
63+
{
64+
jsonrpc: '2.0',
65+
id: 9,
66+
method: 'tools/call',
67+
params: { name: 'create_word_alignment', arguments: args }
68+
},
69+
ORIGIN
70+
)) as {
71+
result: {
72+
content: Array<{ type: string; text?: string }>;
73+
structuredContent?: { url: string };
74+
isError?: boolean;
75+
};
76+
};
77+
}
78+
79+
it('builds a share URL whose decoded state matches the input', async () => {
80+
const res = await callTool({
81+
lines: ['Hello world', 'Bonjour le monde'],
82+
alignments: [
83+
[0, 0, 1, 0],
84+
[0, 1, 1, 2]
85+
]
86+
});
87+
88+
const url = res.result.structuredContent!.url;
89+
expect(url.startsWith(`${ORIGIN}/?data=`)).toBe(true);
90+
91+
const data = new URL(url).searchParams.get('data');
92+
const state = decodeState(data);
93+
expect(state.project.lines.map((l) => l.rawText)).toEqual(['Hello world', 'Bonjour le monde']);
94+
expect(state.project.connections).toHaveLength(2);
95+
96+
const textBlock = res.result.content.find((c) => c.type === 'text');
97+
expect(textBlock?.text).toContain(url);
98+
});
99+
100+
it('reports validation errors as an isError tool result, not a protocol error', async () => {
101+
const res = await callTool({ lines: [] });
102+
expect(res.result.isError).toBe(true);
103+
expect(res.result.content[0]!.text).toContain('Invalid input');
104+
});
105+
106+
it('reports out-of-range word indices as an isError tool result', async () => {
107+
const res = await callTool({ lines: ['a', 'b'], alignments: [[0, 5, 1, 0]] });
108+
expect(res.result.isError).toBe(true);
109+
expect(res.result.content[0]!.text).toMatch(/Could not build|out of range/);
110+
});
111+
112+
it('rejects a call for an unknown tool name with -32602', async () => {
113+
const res = await handleMcpMessage(
114+
{ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'nope', arguments: {} } },
115+
ORIGIN
116+
);
117+
expect(res).toMatchObject({ id: 10, error: { code: -32602 } });
118+
});
119+
});

0 commit comments

Comments
 (0)