@empyria/mcp 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.
@@ -0,0 +1,55 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test'
2
+ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
3
+ import { string } from '@empyria/common'
4
+ import { createMcpHttpHandler, serveMcpHttp } from '../lib/Http.js'
5
+
6
+ let server
7
+
8
+ afterEach(async () => {
9
+ if (server) {
10
+ await server.close()
11
+ server = undefined
12
+ }
13
+ })
14
+
15
+ const EchoService = {
16
+ name: 'Echo',
17
+ actions: {
18
+ say: { params: { text: string() }, handler: async ({ text }) => text },
19
+ },
20
+ }
21
+
22
+ describe('createMcpHttpHandler', () => {
23
+ test('returns a Web-standard {fetch, close} handler', async () => {
24
+ const handler = createMcpHttpHandler({ name: 'x', version: '1.0.0', services: [] })
25
+ expect(typeof handler.fetch).toBe('function')
26
+ expect(typeof handler.close).toBe('function')
27
+ await handler.close()
28
+ })
29
+ })
30
+
31
+ describe('serveMcpHttp', () => {
32
+ test('serves the MCP handler over a real Bun.serve HTTP server', async () => {
33
+ server = serveMcpHttp(
34
+ { name: 'http-test', version: '1.0.0', services: [EchoService] },
35
+ { port: 0 },
36
+ )
37
+
38
+ const client = new Client({ name: 'test-client', version: '1.0.0' })
39
+ const transport = new StreamableHTTPClientTransport(new URL(server.url.toString()))
40
+ await client.connect(transport)
41
+
42
+ const { tools } = await client.listTools()
43
+ expect(tools.map((t) => t.name)).toEqual(['Echo.say'])
44
+
45
+ const result = await client.callTool({ name: 'Echo.say', arguments: { text: 'hi' } })
46
+ expect(result.content).toEqual([{ type: 'text', text: 'hi' }])
47
+
48
+ await client.close()
49
+ })
50
+
51
+ test('passes bunOptions through to Bun.serve (e.g. explicit port 0 for an ephemeral port)', async () => {
52
+ server = serveMcpHttp({ name: 'x', version: '1.0.0', services: [] }, { port: 0 })
53
+ expect(server.port).toBeGreaterThan(0)
54
+ })
55
+ })
@@ -0,0 +1,172 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { InMemoryTransport } from '@modelcontextprotocol/server'
3
+ import { Client } from '@modelcontextprotocol/client'
4
+ import { string, number } from '@empyria/common'
5
+ import { createMcpServer } from '../lib/Server.js'
6
+
7
+ /** Builds a server from `services` and a connected client pointed at it. */
8
+ async function connected(services) {
9
+ const server = createMcpServer({ name: 'test-server', version: '1.0.0', services })
10
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
11
+ await server.connect(serverTransport)
12
+ const client = new Client({ name: 'test-client', version: '1.0.0' })
13
+ await client.connect(clientTransport)
14
+ return { server, client }
15
+ }
16
+
17
+ describe('createMcpServer', () => {
18
+ test('exposes no tools when given no services', async () => {
19
+ const { client } = await connected([])
20
+ expect((await client.listTools()).tools).toEqual([])
21
+ })
22
+
23
+ test('registers each action as "Service.action", with its JSON schema', async () => {
24
+ const WeatherService = {
25
+ name: 'Weather',
26
+ actions: {
27
+ getForecast: {
28
+ description: 'Get the weather forecast for a city',
29
+ params: { city: string(), days: number(3) },
30
+ handler: async (params) => params,
31
+ },
32
+ },
33
+ }
34
+ const { client } = await connected([WeatherService])
35
+ const { tools } = await client.listTools()
36
+
37
+ expect(tools).toHaveLength(1)
38
+ expect(tools[0].name).toBe('Weather.getForecast')
39
+ expect(tools[0].description).toBe('Get the weather forecast for a city')
40
+ expect(tools[0].inputSchema.properties.city).toEqual({ type: 'string' })
41
+ expect(tools[0].inputSchema.properties.days).toEqual({ type: 'number', default: 3 })
42
+ expect(tools[0].inputSchema.required).toEqual(['city', 'days'])
43
+ })
44
+
45
+ test('applies schema defaults and returns the handler result as JSON text', async () => {
46
+ const WeatherService = {
47
+ name: 'Weather',
48
+ actions: {
49
+ getForecast: {
50
+ params: { city: string(), days: number(3) },
51
+ handler: async (params) => params,
52
+ },
53
+ },
54
+ }
55
+ const { client } = await connected([WeatherService])
56
+ const result = await client.callTool({
57
+ name: 'Weather.getForecast',
58
+ arguments: { city: 'Zurich' },
59
+ })
60
+ expect(result.isError).toBeFalsy()
61
+ expect(JSON.parse(result.content[0].text)).toEqual({ city: 'Zurich', days: 3 })
62
+ })
63
+
64
+ test('a bare-function action needs no params and gets an empty-object schema', async () => {
65
+ const PingService = { name: 'Ping', actions: { ping: async () => 'pong' } }
66
+ const { client } = await connected([PingService])
67
+
68
+ const { tools } = await client.listTools()
69
+ expect(tools[0].inputSchema).toEqual({
70
+ type: 'object',
71
+ properties: {},
72
+ required: [],
73
+ additionalProperties: true,
74
+ })
75
+
76
+ const result = await client.callTool({ name: 'Ping.ping', arguments: {} })
77
+ expect(result.content[0].text).toBe('pong')
78
+ })
79
+
80
+ test('a no-params action still receives a real extra context, not undefined', async () => {
81
+ // Regression test: the SDK only calls the tool callback with (args, extra) when
82
+ // inputSchema is set — omit it for a no-params action and the callback collapses
83
+ // to a single-argument (extra) call, silently handing extra's value to `args`
84
+ // and leaving the handler's own `extra` argument undefined.
85
+ let seenExtra
86
+ const Service = {
87
+ name: 'Svc',
88
+ actions: {
89
+ whoami: async (params, extra) => {
90
+ seenExtra = extra
91
+ return 'ok'
92
+ },
93
+ },
94
+ }
95
+ const { client } = await connected([Service])
96
+ await client.callTool({ name: 'Svc.whoami', arguments: {} })
97
+ expect(seenExtra).toBeDefined()
98
+ expect(typeof seenExtra).toBe('object')
99
+ })
100
+
101
+ test('a string handler result becomes a single text block, unwrapped', async () => {
102
+ const Service = { name: 'Svc', actions: { hello: async () => 'hi there' } }
103
+ const { client } = await connected([Service])
104
+ const result = await client.callTool({ name: 'Svc.hello', arguments: {} })
105
+ expect(result.content).toEqual([{ type: 'text', text: 'hi there' }])
106
+ })
107
+
108
+ test('an already MCP-shaped result passes through unchanged', async () => {
109
+ const shaped = { content: [{ type: 'text', text: 'custom' }], _meta: { x: 1 } }
110
+ const Service = { name: 'Svc', actions: { raw: async () => shaped } }
111
+ const { client } = await connected([Service])
112
+ const result = await client.callTool({ name: 'Svc.raw', arguments: {} })
113
+ expect(result.content).toEqual(shaped.content)
114
+ })
115
+
116
+ test('invalid input is reported as a tool error, not a thrown protocol error', async () => {
117
+ const Service = {
118
+ name: 'Weather',
119
+ actions: {
120
+ getForecast: { params: { city: string() }, handler: async (p) => p },
121
+ },
122
+ }
123
+ const { client } = await connected([Service])
124
+ const result = await client.callTool({ name: 'Weather.getForecast', arguments: {} })
125
+ expect(result.isError).toBe(true)
126
+ expect(result.content[0].text).toContain('city')
127
+ })
128
+
129
+ test('a handler that throws is reported as a tool error', async () => {
130
+ const Service = {
131
+ name: 'Svc',
132
+ actions: {
133
+ explode: async () => {
134
+ throw new Error('kaboom')
135
+ },
136
+ },
137
+ }
138
+ const { client } = await connected([Service])
139
+ const result = await client.callTool({ name: 'Svc.explode', arguments: {} })
140
+ expect(result.isError).toBe(true)
141
+ expect(result.content[0].text).toContain('kaboom')
142
+ })
143
+
144
+ test('calling an unregistered tool rejects', async () => {
145
+ const { client } = await connected([{ name: 'Svc', actions: { ping: async () => 'pong' } }])
146
+ await expect(client.callTool({ name: 'Nope.action', arguments: {} })).rejects.toThrow()
147
+ })
148
+
149
+ test('multiple services register distinct, non-colliding tools', async () => {
150
+ const A = { name: 'A', actions: { act: async () => 'a' } }
151
+ const B = { name: 'B', actions: { act: async () => 'b' } }
152
+ const { client } = await connected([A, B])
153
+ const names = (await client.listTools()).tools.map((t) => t.name).sort()
154
+ expect(names).toEqual(['A.act', 'B.act'])
155
+ })
156
+
157
+ test('throws when a service has no name', () => {
158
+ expect(() =>
159
+ createMcpServer({ name: 'x', version: '1', services: [{ actions: {} }] }),
160
+ ).toThrow(/name/)
161
+ })
162
+
163
+ test('throws when an action has no handler function', () => {
164
+ expect(() =>
165
+ createMcpServer({
166
+ name: 'x',
167
+ version: '1',
168
+ services: [{ name: 'Svc', actions: { bad: { description: 'oops' } } }],
169
+ }),
170
+ ).toThrow(/handler/)
171
+ })
172
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { principiaJsonSchemaValidator } from '../lib/Validator.js'
3
+
4
+ describe('principiaJsonSchemaValidator', () => {
5
+ const schema = {
6
+ type: 'object',
7
+ properties: { name: { type: 'string' }, age: { type: 'number', default: 0 } },
8
+ required: ['name'],
9
+ }
10
+
11
+ test('getValidator returns a reusable validator function', () => {
12
+ const validate = principiaJsonSchemaValidator.getValidator(schema)
13
+ expect(typeof validate).toBe('function')
14
+ })
15
+
16
+ test('valid input resolves with the (defaulted) data', () => {
17
+ const validate = principiaJsonSchemaValidator.getValidator(schema)
18
+ const result = validate({ name: 'Bob' })
19
+ expect(result.valid).toBe(true)
20
+ expect(result.data).toEqual({ name: 'Bob', age: 0 })
21
+ })
22
+
23
+ test('invalid input resolves with an error message, not a throw', () => {
24
+ const validate = principiaJsonSchemaValidator.getValidator(schema)
25
+ const result = validate({})
26
+ expect(result.valid).toBe(false)
27
+ expect(typeof result.errorMessage).toBe('string')
28
+ expect(result.errorMessage).toContain('name')
29
+ })
30
+ })
@@ -0,0 +1,19 @@
1
+ // Minimal MCP server run as a child process by test/Client.test.js's stdio tests.
2
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
3
+
4
+ import { createMcpServer } from '../../lib/Server.js'
5
+ import { string } from '@empyria/common'
6
+
7
+ const EchoService = {
8
+ name: 'Echo',
9
+ actions: {
10
+ say: { params: { text: string() }, handler: async ({ text }) => text },
11
+ },
12
+ }
13
+
14
+ const server = createMcpServer({
15
+ name: 'fixture-server',
16
+ version: '1.0.0',
17
+ services: [EchoService],
18
+ })
19
+ await server.connect(new StdioServerTransport())