@363045841yyt/klinechart-ai-runtime 0.1.0

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.
Files changed (55) hide show
  1. package/README.md +130 -0
  2. package/dist/createWithMcp.d.ts +9 -0
  3. package/dist/createWithMcp.d.ts.map +1 -0
  4. package/dist/createWithMcp.js +15 -0
  5. package/dist/createWithMcp.js.map +1 -0
  6. package/dist/describeControllers.d.ts +34 -0
  7. package/dist/describeControllers.d.ts.map +1 -0
  8. package/dist/describeControllers.js +104 -0
  9. package/dist/describeControllers.js.map +1 -0
  10. package/dist/executeTool.d.ts +12 -0
  11. package/dist/executeTool.d.ts.map +1 -0
  12. package/dist/executeTool.js +63 -0
  13. package/dist/executeTool.js.map +1 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/mcpServer.d.ts +35 -0
  19. package/dist/mcpServer.d.ts.map +1 -0
  20. package/dist/mcpServer.js +189 -0
  21. package/dist/mcpServer.js.map +1 -0
  22. package/dist/serialization.d.ts +28 -0
  23. package/dist/serialization.d.ts.map +1 -0
  24. package/dist/serialization.js +53 -0
  25. package/dist/serialization.js.map +1 -0
  26. package/dist/sessionRegistry.d.ts +19 -0
  27. package/dist/sessionRegistry.d.ts.map +1 -0
  28. package/dist/sessionRegistry.js +41 -0
  29. package/dist/sessionRegistry.js.map +1 -0
  30. package/dist/toolSchemas.d.ts +14 -0
  31. package/dist/toolSchemas.d.ts.map +1 -0
  32. package/dist/toolSchemas.js +216 -0
  33. package/dist/toolSchemas.js.map +1 -0
  34. package/dist/types.d.ts +75 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +2 -0
  37. package/dist/types.js.map +1 -0
  38. package/package.json +63 -0
  39. package/src/__tests__/chartBridge.integration.test.ts +100 -0
  40. package/src/__tests__/describeControllers.test.ts +163 -0
  41. package/src/__tests__/executeTool.test.ts +187 -0
  42. package/src/__tests__/mcpServer.integration.test.ts +155 -0
  43. package/src/__tests__/mcpServer.test.ts +30 -0
  44. package/src/__tests__/serialization.test.ts +116 -0
  45. package/src/__tests__/sessionRegistry.test.ts +139 -0
  46. package/src/__tests__/toolSchemas.test.ts +149 -0
  47. package/src/createWithMcp.ts +28 -0
  48. package/src/describeControllers.ts +166 -0
  49. package/src/executeTool.ts +92 -0
  50. package/src/index.ts +38 -0
  51. package/src/mcpServer.ts +268 -0
  52. package/src/serialization.ts +88 -0
  53. package/src/sessionRegistry.ts +61 -0
  54. package/src/toolSchemas.ts +235 -0
  55. package/src/types.ts +52 -0
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, afterAll, beforeEach } from 'vitest'
2
+ import { WebSocketServer, type WebSocket } from 'ws'
3
+ import { ChartBridge } from '@363045841yyt/klinechart-core'
4
+
5
+ const PORT = 9877
6
+ const wss = new WebSocketServer({ port: PORT, host: '127.0.0.1' })
7
+
8
+ const receivedMessages: unknown[] = []
9
+ let serverWs: WebSocket | null = null
10
+
11
+ wss.on('connection', (ws) => {
12
+ serverWs = ws
13
+ ws.on('message', (raw) => {
14
+ const msg = JSON.parse(raw.toString())
15
+ receivedMessages.push(msg)
16
+
17
+ if (msg.type === 'register') {
18
+ ws.send(JSON.stringify({ type: 'registered', sessionId: msg.sessionId }))
19
+ }
20
+ })
21
+ })
22
+
23
+ afterAll(() => {
24
+ wss.close()
25
+ })
26
+
27
+ describe('ChartBridge integration', { timeout: 10_000 }, () => {
28
+ beforeEach(() => {
29
+ receivedMessages.length = 0
30
+ })
31
+
32
+ it('connects and registers with server', async () => {
33
+ const bridge = new ChartBridge({
34
+ wsUrl: `ws://127.0.0.1:${PORT}`,
35
+ sessionId: 'bridge-test',
36
+ autoReconnect: false,
37
+ })
38
+
39
+ await bridge.connect()
40
+ await new Promise((r) => setTimeout(r, 100))
41
+
42
+ const regMsg = receivedMessages.find(
43
+ (m: unknown) => (m as { type: string }).type === 'register',
44
+ ) as { sessionId: string } | undefined
45
+ expect(regMsg).toBeDefined()
46
+ expect(regMsg!.sessionId).toBe('bridge-test')
47
+
48
+ bridge.disconnect()
49
+ })
50
+
51
+ it('sends state update', async () => {
52
+ const bridge = new ChartBridge({
53
+ wsUrl: `ws://127.0.0.1:${PORT}`,
54
+ sessionId: 'state-push-test',
55
+ autoReconnect: false,
56
+ })
57
+
58
+ await bridge.connect()
59
+ await new Promise((r) => setTimeout(r, 100))
60
+
61
+ bridge.sendStateUpdate({
62
+ testController: {
63
+ controllerId: 'testController',
64
+ summary: 'Test state',
65
+ facts: { value: 42 },
66
+ },
67
+ })
68
+
69
+ await new Promise((r) => setTimeout(r, 100))
70
+ const stateMsg = receivedMessages.find(
71
+ (m: unknown) => (m as { type: string }).type === 'state:update',
72
+ )
73
+ expect(stateMsg).toBeDefined()
74
+ expect(
75
+ (stateMsg as { descriptions: Record<string, unknown> }).descriptions
76
+ .testController,
77
+ ).toBeDefined()
78
+
79
+ bridge.disconnect()
80
+ })
81
+
82
+ it('auto-generates sessionId when not provided', () => {
83
+ const bridge = new ChartBridge({
84
+ wsUrl: `ws://127.0.0.1:${PORT}`,
85
+ autoReconnect: false,
86
+ })
87
+ expect(bridge.sessionId).toBeDefined()
88
+ expect(bridge.sessionId.length).toBeGreaterThan(0)
89
+ bridge.disconnect()
90
+ })
91
+
92
+ it('isSafe to call destroy multiple times', () => {
93
+ const bridge = new ChartBridge({
94
+ wsUrl: `ws://127.0.0.1:${PORT}`,
95
+ autoReconnect: false,
96
+ })
97
+ bridge.destroy()
98
+ bridge.destroy()
99
+ })
100
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ describeVolumeProfileState,
4
+ describeAnchoredVwap,
5
+ describeFootprintLatestBar,
6
+ describeAlerts,
7
+ } from '../describeControllers'
8
+
9
+ describe('describeVolumeProfileState', () => {
10
+ it('handles null state with a clear summary', () => {
11
+ const d = describeVolumeProfileState(null)
12
+ expect(d.controllerId).toBe('volumeProfile')
13
+ expect(d.summary).toMatch(/has not been computed/)
14
+ expect(d.facts.ready).toBe(false)
15
+ })
16
+
17
+ it('produces a summary for populated state', () => {
18
+ const d = describeVolumeProfileState({
19
+ poc: 67_500,
20
+ vah: 68_200,
21
+ val: 66_800,
22
+ totalVolume: 1000,
23
+ vaVolume: 700,
24
+ })
25
+ const wordCount = d.summary.split(/\s+/).length
26
+ expect(wordCount).toBeGreaterThanOrEqual(20)
27
+ expect(wordCount).toBeLessThanOrEqual(100)
28
+ expect(d.facts.poc).toBe(67_500)
29
+ expect(d.facts.vaPercent).toBeCloseTo(70.0, 1)
30
+ })
31
+ })
32
+
33
+ describe('describeAnchoredVwap', () => {
34
+ it('reports zero anchors', () => {
35
+ const d = describeAnchoredVwap([], 100)
36
+ expect(d.facts.count).toBe(0)
37
+ })
38
+
39
+ it('flags overextension when price is above 1sigma upper', () => {
40
+ const d = describeAnchoredVwap(
41
+ [
42
+ {
43
+ label: 'Earnings Q1',
44
+ barIndex: 100,
45
+ vwap: 100,
46
+ upper1: 105,
47
+ lower1: 95,
48
+ upper2: 110,
49
+ lower2: 90,
50
+ },
51
+ ],
52
+ 120,
53
+ )
54
+ expect(d.summary).toMatch(/overextended/)
55
+ })
56
+
57
+ it('flags overextension below 1sigma lower', () => {
58
+ const d = describeAnchoredVwap(
59
+ [
60
+ {
61
+ label: 'Earnings Q1',
62
+ barIndex: 100,
63
+ vwap: 100,
64
+ upper1: 105,
65
+ lower1: 95,
66
+ upper2: 110,
67
+ lower2: 90,
68
+ },
69
+ ],
70
+ 80,
71
+ )
72
+ expect(d.summary).toMatch(/overextended/)
73
+ })
74
+
75
+ it('does NOT flag when price is inside the 1sigma band', () => {
76
+ const d = describeAnchoredVwap(
77
+ [
78
+ {
79
+ label: 'Earnings Q1',
80
+ barIndex: 100,
81
+ vwap: 100,
82
+ upper1: 105,
83
+ lower1: 95,
84
+ upper2: 110,
85
+ lower2: 90,
86
+ },
87
+ ],
88
+ 102,
89
+ )
90
+ expect(d.summary).not.toMatch(/overextended/)
91
+ })
92
+ })
93
+
94
+ describe('describeFootprintLatestBar', () => {
95
+ it('handles null bar', () => {
96
+ const d = describeFootprintLatestBar(null, 0)
97
+ expect(d.facts.ready).toBe(false)
98
+ })
99
+
100
+ it('labels buy-dominated bars', () => {
101
+ const d = describeFootprintLatestBar(
102
+ {
103
+ barIndex: 42,
104
+ delta: 250,
105
+ totalVolume: 1000,
106
+ imbalanceCount: 0,
107
+ maxImbalanceRatio: 0,
108
+ },
109
+ 500,
110
+ )
111
+ expect(d.facts.tone).toBe('buy-dominated')
112
+ })
113
+
114
+ it('labels sell-dominated bars', () => {
115
+ const d = describeFootprintLatestBar(
116
+ {
117
+ barIndex: 42,
118
+ delta: -250,
119
+ totalVolume: 1000,
120
+ imbalanceCount: 0,
121
+ maxImbalanceRatio: 0,
122
+ },
123
+ -500,
124
+ )
125
+ expect(d.facts.tone).toBe('sell-dominated')
126
+ })
127
+
128
+ it('summarises imbalances correctly', () => {
129
+ const d = describeFootprintLatestBar(
130
+ {
131
+ barIndex: 42,
132
+ delta: 100,
133
+ totalVolume: 1000,
134
+ imbalanceCount: 3,
135
+ maxImbalanceRatio: 5.5,
136
+ },
137
+ 100,
138
+ )
139
+ expect(d.summary).toMatch(/3 diagonal imbalance/)
140
+ expect(d.summary).toMatch(/5\.5×/)
141
+ })
142
+ })
143
+
144
+ describe('describeAlerts', () => {
145
+ it('zero rules', () => {
146
+ const d = describeAlerts({
147
+ rulesEnabled: 0,
148
+ rulesTotal: 0,
149
+ recentEventsCount: 0,
150
+ })
151
+ expect(d.summary).toBe('No alert rules configured.')
152
+ })
153
+
154
+ it('counts enabled vs total', () => {
155
+ const d = describeAlerts({
156
+ rulesEnabled: 3,
157
+ rulesTotal: 5,
158
+ recentEventsCount: 2,
159
+ })
160
+ expect(d.summary).toMatch(/3 of 5 alert rules/)
161
+ expect(d.facts.recentEventsCount).toBe(2)
162
+ })
163
+ })
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import type { ChartController } from '@363045841yyt/klinechart-core'
3
+ import { executeTool } from '../executeTool'
4
+
5
+ function createMockChart(
6
+ overrides?: Partial<ChartController>,
7
+ ): ChartController {
8
+ return {
9
+ catalog: [
10
+ { id: 'MA', label: 'MA', role: 'main' as const, params: [] },
11
+ { id: 'RSI', label: 'RSI', role: 'sub' as const, params: [] },
12
+ ],
13
+ zoomToLevel: vi.fn(),
14
+ setTheme: vi.fn(),
15
+ addIndicator: vi.fn(() => 'inst-1'),
16
+ removeIndicator: vi.fn(() => true),
17
+ updateIndicatorParams: vi.fn(() => true),
18
+ getZoomLevelCount: vi.fn(() => 10),
19
+ getData: vi.fn(() => []),
20
+ ...overrides,
21
+ } as unknown as ChartController
22
+ }
23
+
24
+ describe('executeTool', () => {
25
+ it('returns error for unknown tool name', () => {
26
+ const chart = createMockChart()
27
+ const result = executeTool(chart, {
28
+ name: 'chart.nonexistent',
29
+ input: {},
30
+ })
31
+ expect(result.success).toBe(false)
32
+ expect(result.error).toMatch(/Unknown tool/)
33
+ })
34
+
35
+ describe('chart.zoomToLevel', () => {
36
+ it('calls chart.zoomToLevel with level only', () => {
37
+ const chart = createMockChart()
38
+ const result = executeTool(chart, {
39
+ name: 'chart.zoomToLevel',
40
+ input: { level: 5 },
41
+ })
42
+ expect(chart.zoomToLevel).toHaveBeenCalledWith(5, undefined)
43
+ expect(result.success).toBe(true)
44
+ })
45
+
46
+ it('calls chart.zoomToLevel with level and anchorX', () => {
47
+ const chart = createMockChart()
48
+ const result = executeTool(chart, {
49
+ name: 'chart.zoomToLevel',
50
+ input: { level: 3, anchorX: 200 },
51
+ })
52
+ expect(chart.zoomToLevel).toHaveBeenCalledWith(3, 200)
53
+ expect(result.success).toBe(true)
54
+ })
55
+ })
56
+
57
+ describe('chart.setTheme', () => {
58
+ it('calls chart.setTheme with light', () => {
59
+ const chart = createMockChart()
60
+ const result = executeTool(chart, {
61
+ name: 'chart.setTheme',
62
+ input: { theme: 'light' },
63
+ })
64
+ expect(chart.setTheme).toHaveBeenCalledWith('light')
65
+ expect(result.success).toBe(true)
66
+ })
67
+
68
+ it('calls chart.setTheme with dark', () => {
69
+ const chart = createMockChart()
70
+ const result = executeTool(chart, {
71
+ name: 'chart.setTheme',
72
+ input: { theme: 'dark' },
73
+ })
74
+ expect(chart.setTheme).toHaveBeenCalledWith('dark')
75
+ expect(result.success).toBe(true)
76
+ })
77
+ })
78
+
79
+ describe('indicators.add', () => {
80
+ it('looks up role from catalog and delegates', () => {
81
+ const chart = createMockChart()
82
+ const result = executeTool(chart, {
83
+ name: 'indicators.add',
84
+ input: { definitionId: 'MA' },
85
+ })
86
+ expect(chart.addIndicator).toHaveBeenCalledWith('MA', 'main')
87
+ expect(result.success).toBe(true)
88
+ expect(result.data).toEqual({ instanceId: 'inst-1' })
89
+ })
90
+
91
+ it('uses sub role when catalog says sub', () => {
92
+ const chart = createMockChart()
93
+ const result = executeTool(chart, {
94
+ name: 'indicators.add',
95
+ input: { definitionId: 'RSI' },
96
+ })
97
+ expect(chart.addIndicator).toHaveBeenCalledWith('RSI', 'sub')
98
+ expect(result.success).toBe(true)
99
+ })
100
+
101
+ it('falls back to main role for unknown definitionId', () => {
102
+ const chart = createMockChart()
103
+ const result = executeTool(chart, {
104
+ name: 'indicators.add',
105
+ input: { definitionId: 'BOLL' },
106
+ })
107
+ expect(chart.addIndicator).toHaveBeenCalledWith('BOLL', 'main')
108
+ expect(result.success).toBe(true)
109
+ })
110
+ })
111
+
112
+ describe('indicators.remove', () => {
113
+ it('returns success when chart.removeIndicator returns true', () => {
114
+ const chart = createMockChart()
115
+ const result = executeTool(chart, {
116
+ name: 'indicators.remove',
117
+ input: { instanceId: 'inst-1' },
118
+ })
119
+ expect(chart.removeIndicator).toHaveBeenCalledWith('inst-1')
120
+ expect(result.success).toBe(true)
121
+ })
122
+
123
+ it('returns error when chart.removeIndicator returns false', () => {
124
+ const chart = createMockChart({ removeIndicator: vi.fn(() => false) })
125
+ const result = executeTool(chart, {
126
+ name: 'indicators.remove',
127
+ input: { instanceId: 'ghost' },
128
+ })
129
+ expect(result.success).toBe(false)
130
+ expect(result.error).toMatch(/ghost/)
131
+ })
132
+ })
133
+
134
+ describe('indicators.updateParams', () => {
135
+ it('returns success when chart.updateIndicatorParams returns true', () => {
136
+ const chart = createMockChart()
137
+ const result = executeTool(chart, {
138
+ name: 'indicators.updateParams',
139
+ input: { instanceId: 'inst-1', params: { period: 50 } },
140
+ })
141
+ expect(chart.updateIndicatorParams).toHaveBeenCalledWith('inst-1', {
142
+ period: 50,
143
+ })
144
+ expect(result.success).toBe(true)
145
+ })
146
+
147
+ it('returns error when chart.updateIndicatorParams returns false', () => {
148
+ const chart = createMockChart({
149
+ updateIndicatorParams: vi.fn(() => false),
150
+ })
151
+ const result = executeTool(chart, {
152
+ name: 'indicators.updateParams',
153
+ input: { instanceId: 'ghost', params: {} },
154
+ })
155
+ expect(result.success).toBe(false)
156
+ expect(result.error).toMatch(/ghost/)
157
+ })
158
+ })
159
+
160
+ describe('alerts.* — not implemented', () => {
161
+ const tools = ['alerts.addPriceCross', 'alerts.addIndicatorCross', 'alerts.remove']
162
+
163
+ for (const name of tools) {
164
+ it(`returns not-implemented for ${name}`, () => {
165
+ const chart = createMockChart()
166
+ const result = executeTool(chart, { name, input: {} })
167
+ expect(result.success).toBe(false)
168
+ expect(result.error).toMatch(/not implemented/)
169
+ expect(result.error).toMatch(/alerts controller/)
170
+ })
171
+ }
172
+ })
173
+
174
+ describe('replay.* — not implemented', () => {
175
+ const tools = ['replay.seekTo', 'replay.play', 'replay.pause', 'replay.setSpeed']
176
+
177
+ for (const name of tools) {
178
+ it(`returns not-implemented for ${name}`, () => {
179
+ const chart = createMockChart()
180
+ const result = executeTool(chart, { name, input: {} })
181
+ expect(result.success).toBe(false)
182
+ expect(result.error).toMatch(/not implemented/)
183
+ expect(result.error).toMatch(/replay controller/)
184
+ })
185
+ }
186
+ })
187
+ })
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, afterAll } from 'vitest'
2
+ import { WebSocket } from 'ws'
3
+ import { createMcpServer } from '../mcpServer'
4
+
5
+ const PORT = 9876
6
+
7
+ const server = createMcpServer({
8
+ serverInfo: { name: 'test', version: '1.0.0' },
9
+ ws: { port: PORT, host: '127.0.0.1' },
10
+ })
11
+
12
+ afterAll(async () => {
13
+ await server.stop()
14
+ })
15
+
16
+ function connectClient(sessionId = 'test-session'): Promise<WebSocket> {
17
+ return new Promise((resolve, reject) => {
18
+ const ws = new WebSocket(`ws://127.0.0.1:${PORT}`)
19
+ const timeout = setTimeout(() => {
20
+ ws.close()
21
+ reject(new Error('connectClient timeout'))
22
+ }, 3000)
23
+
24
+ ws.on('open', () => {
25
+ ws.send(JSON.stringify({ type: 'register', sessionId }))
26
+ })
27
+
28
+ ws.on('message', (raw) => {
29
+ try {
30
+ const msg = JSON.parse(raw.toString())
31
+ if (msg.type === 'registered' && msg.sessionId === sessionId) {
32
+ clearTimeout(timeout)
33
+ resolve(ws)
34
+ }
35
+ } catch {
36
+ // ignore parse errors during connect
37
+ }
38
+ })
39
+
40
+ ws.on('error', (err) => {
41
+ clearTimeout(timeout)
42
+ reject(err)
43
+ })
44
+ })
45
+ }
46
+
47
+ function waitForMessage(ws: WebSocket, timeout = 3000): Promise<unknown> {
48
+ return new Promise((resolve, reject) => {
49
+ const timer = setTimeout(() => reject(new Error('Timeout')), timeout)
50
+ ws.once('message', (raw) => {
51
+ clearTimeout(timer)
52
+ try {
53
+ resolve(JSON.parse(raw.toString()))
54
+ } catch {
55
+ reject(new Error('Failed to parse WS message'))
56
+ }
57
+ })
58
+ })
59
+ }
60
+
61
+ describe('mcpServer WebSocket integration', { timeout: 10_000 }, () => {
62
+ it('accepts registration and replies with registered', async () => {
63
+ const ws = await connectClient('reg-test')
64
+ expect(server.registry.has('reg-test')).toBe(true)
65
+ ws.close()
66
+ })
67
+
68
+ it('registers session in the registry', async () => {
69
+ const ws = await connectClient('registry-test')
70
+ expect(server.registry.has('registry-test')).toBe(true)
71
+ ws.close()
72
+ })
73
+
74
+ it('removes session on disconnect', async () => {
75
+ const ws = await connectClient('disconnect-test')
76
+ ws.close()
77
+ await new Promise((r) => setTimeout(r, 150))
78
+ expect(server.registry.has('disconnect-test')).toBe(false)
79
+ })
80
+
81
+ it('forwards tool:call and receives tool:result', async () => {
82
+ const ws = await connectClient('tool-test')
83
+ const handle = server.registry.get('tool-test')!
84
+
85
+ const resultPromise = handle.executeTool({
86
+ name: 'chart.zoomToLevel',
87
+ input: { level: 5 },
88
+ })
89
+
90
+ const msg = (await waitForMessage(ws)) as {
91
+ type: string
92
+ requestId: string
93
+ call: { name: string; input: Record<string, unknown> }
94
+ }
95
+ expect(msg.type).toBe('tool:call')
96
+ expect(msg.call.name).toBe('chart.zoomToLevel')
97
+ expect(msg.call.input).toEqual({ level: 5 })
98
+
99
+ ws.send(
100
+ JSON.stringify({
101
+ type: 'tool:result',
102
+ requestId: msg.requestId,
103
+ result: { success: true },
104
+ }),
105
+ )
106
+
107
+ const result = await resultPromise
108
+ expect(result.success).toBe(true)
109
+ ws.close()
110
+ })
111
+
112
+ it('accepts state:update and caches it', async () => {
113
+ const ws = await connectClient('state-test')
114
+
115
+ ws.send(
116
+ JSON.stringify({
117
+ type: 'state:update',
118
+ descriptions: {
119
+ vp: {
120
+ controllerId: 'vp',
121
+ summary: 'Test VP state',
122
+ facts: { ready: true },
123
+ },
124
+ },
125
+ }),
126
+ )
127
+
128
+ await new Promise((r) => setTimeout(r, 100))
129
+ const state = server.registry.getState('state-test')
130
+ expect(state?.vp?.summary).toBe('Test VP state')
131
+ ws.close()
132
+ })
133
+
134
+ it('handles multiple concurrent sessions', async () => {
135
+ const ws1 = await connectClient('multi-1')
136
+ const ws2 = await connectClient('multi-2')
137
+
138
+ expect(server.registry.getActiveSessionIds()).toContain('multi-1')
139
+ expect(server.registry.getActiveSessionIds()).toContain('multi-2')
140
+
141
+ ws1.close()
142
+ ws2.close()
143
+ await new Promise((r) => setTimeout(r, 150))
144
+ expect(server.registry.has('multi-1')).toBe(false)
145
+ expect(server.registry.has('multi-2')).toBe(false)
146
+ })
147
+
148
+ it('rejects invalid JSON gracefully', async () => {
149
+ const ws = await connectClient('invalid-test')
150
+ ws.send('not json')
151
+ await new Promise((r) => setTimeout(r, 50))
152
+ expect(server.registry.has('invalid-test')).toBe(true)
153
+ ws.close()
154
+ })
155
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { createMcpServer } from '../mcpServer'
3
+ import { SessionRegistry } from '../sessionRegistry'
4
+
5
+ describe('createMcpServer', () => {
6
+ it('returns server components', () => {
7
+ const instance = createMcpServer({
8
+ ws: { port: 0 },
9
+ })
10
+ expect(instance.server).toBeDefined()
11
+ expect(instance.registry).toBeDefined()
12
+ expect(instance.wss).toBeDefined()
13
+ expect(typeof instance.start).toBe('function')
14
+ expect(typeof instance.stop).toBe('function')
15
+ })
16
+
17
+ it('uses provided registry', () => {
18
+ const registry = new SessionRegistry()
19
+ const instance = createMcpServer({ registry, ws: { port: 0 } })
20
+ expect(instance.registry).toBe(registry)
21
+ })
22
+
23
+ it('accepts custom server info', () => {
24
+ const instance = createMcpServer({
25
+ serverInfo: { name: 'custom', version: '2.0.0' },
26
+ ws: { port: 0 },
27
+ })
28
+ expect(instance.server).toBeDefined()
29
+ })
30
+ })