summaryrefslogtreecommitdiffhomepage
path: root/packages/mcp/src/framing.test.ts
blob: be8cb8ead2fedbe9a53a918300f029e6ab68f741 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import { describe, expect, it } from "vitest";
import { encode, FrameDecoder } from "./framing.js";

describe("encode", () => {
	it("produces correct Content-Length header", () => {
		const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
		const encoded = encode(msg);
		const text = new TextDecoder().decode(encoded);
		expect(text).toBe(`Content-Length: ${new TextEncoder().encode(msg).length}\r\n\r\n${msg}`);
	});

	it("handles empty message", () => {
		const encoded = encode("");
		const text = new TextDecoder().decode(encoded);
		expect(text).toBe("Content-Length: 0\r\n\r\n");
	});
});

describe("FrameDecoder", () => {
	it("reassembles a complete message from one chunk", () => {
		const msg = '{"jsonrpc":"2.0","id":1}';
		const encoded = encode(msg);
		const decoder = new FrameDecoder();
		const messages = decoder.decode(encoded);
		expect(messages).toEqual([msg]);
	});

	it("handles split across chunks", () => {
		const msg = '{"jsonrpc":"2.0","id":1,"method":"initialize"}';
		const encoded = encode(msg);
		const mid = Math.floor(encoded.length / 2);
		const chunk1 = encoded.slice(0, mid);
		const chunk2 = encoded.slice(mid);

		const decoder = new FrameDecoder();
		const result1 = decoder.decode(chunk1);
		expect(result1).toEqual([]);

		const result2 = decoder.decode(chunk2);
		expect(result2).toEqual([msg]);
	});

	it("handles two messages in one chunk", () => {
		const msg1 = '{"jsonrpc":"2.0","id":1}';
		const msg2 = '{"jsonrpc":"2.0","id":2}';
		const encoded1 = encode(msg1);
		const encoded2 = encode(msg2);
		const combined = new Uint8Array(encoded1.length + encoded2.length);
		combined.set(encoded1);
		combined.set(encoded2, encoded1.length);

		const decoder = new FrameDecoder();
		const messages = decoder.decode(combined);
		expect(messages).toEqual([msg1, msg2]);
	});

	it("rejects negative Content-Length by skipping header", () => {
		const header = "Content-Length: -5\r\n\r\n";
		const encoded = new TextEncoder().encode(`${header}extra`);
		const decoder = new FrameDecoder();
		const messages = decoder.decode(encoded);
		// Negative length does not match the digit capture, so the header is skipped.
		expect(messages).toEqual([]);
	});

	it("rejects zero Content-Length", () => {
		const encoded = encode("");
		const decoder = new FrameDecoder();
		const messages = decoder.decode(encoded);
		expect(messages).toEqual([""]);
	});

	it("reassembles multi-byte UTF-8 content (byte-length, not char-length)", () => {
		// "héllo" — é is two UTF-8 bytes; Content-Length counts bytes.
		const msg = '{"text":"héllo 🚀"}';
		const encoded = encode(msg);
		expect(new TextEncoder().encode(msg).length).toBeGreaterThan(msg.length);

		const decoder = new FrameDecoder();
		const messages = decoder.decode(encoded);
		expect(messages).toEqual([msg]);
	});

	it("reassembles multi-byte content split across a chunk boundary", () => {
		const msg = '{"text":"日本語のテスト"}';
		const encoded = encode(msg);
		const mid = Math.floor(encoded.length / 2);
		const decoder = new FrameDecoder();
		expect(decoder.decode(encoded.slice(0, mid))).toEqual([]);
		expect(decoder.decode(encoded.slice(mid))).toEqual([msg]);
	});
});