@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.
- package/.oxfmtrc.json +9 -0
- package/AGENTS.md +126 -0
- package/README.md +215 -0
- package/index.js +11 -0
- package/lib/Client.js +56 -0
- package/lib/Gateway.js +76 -0
- package/lib/Guard.js +67 -0
- package/lib/Http.js +37 -0
- package/lib/Server.js +111 -0
- package/lib/Validator.js +34 -0
- package/package.json +41 -0
- package/test/Client.test.js +125 -0
- package/test/Gateway.test.js +179 -0
- package/test/Guard.test.js +174 -0
- package/test/Http.test.js +55 -0
- package/test/Server.test.js +172 -0
- package/test/Validator.test.js +30 -0
- package/test/fixtures/stdio-server.js +19 -0
package/lib/Server.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server'
|
|
2
|
+
|
|
3
|
+
import { defineSchema } from '@empyria/common'
|
|
4
|
+
|
|
5
|
+
import { principiaJsonSchemaValidator } from './Validator.js'
|
|
6
|
+
import { withMetaGuard } from './Guard.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} ActionDef
|
|
10
|
+
* @property {string} [description] - Shown to MCP clients as the tool's description.
|
|
11
|
+
* @property {Object<string, Object>} [params] - Map of param name to JSON schema, built with
|
|
12
|
+
* `@empyria/common`'s schema helpers (`string()`, `number()`, `bool()`, ...). Omit for a
|
|
13
|
+
* tool that takes no arguments.
|
|
14
|
+
* @property {(params: Object, extra: Object) => *} handler - Receives the validated params
|
|
15
|
+
* (or `{}` if no `params` schema was declared) and the MCP request's `extra` context.
|
|
16
|
+
* May return anything JSON-serializable, a string, or an already MCP-shaped
|
|
17
|
+
* `{ content: [...] }` result; thrown errors are reported back to the client as a
|
|
18
|
+
* tool-call error automatically.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A Moleculer-shaped service definition: `{ name, actions }`, where each action is either
|
|
23
|
+
* a bare async handler or an {@link ActionDef}. Every action becomes an MCP tool named
|
|
24
|
+
* `"<service.name>.<actionName>"`.
|
|
25
|
+
* @typedef {Object} ServiceDef
|
|
26
|
+
* @property {string} name
|
|
27
|
+
* @property {Object<string, ActionDef|((params: Object, extra: Object) => *)>} actions
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalizes an MCP tool handler's return value into a `CallToolResult`. Passes an
|
|
32
|
+
* already-shaped result through unchanged; wraps a string as-is and anything else as
|
|
33
|
+
* pretty-printed JSON, both as a single text content block.
|
|
34
|
+
* @param {*} result
|
|
35
|
+
* @returns {{content: Array<{type: string, text: string}>}}
|
|
36
|
+
*/
|
|
37
|
+
function toCallToolResult(result) {
|
|
38
|
+
if (result && typeof result === 'object' && Array.isArray(result.content)) return result
|
|
39
|
+
if (typeof result === 'string') return { content: [{ type: 'text', text: result }] }
|
|
40
|
+
return { content: [{ type: 'text', text: JSON.stringify(result ?? null) }] }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Registers every action of a service as an MCP tool on `server`.
|
|
45
|
+
* @param {McpServer} server
|
|
46
|
+
* @param {ServiceDef} service
|
|
47
|
+
*/
|
|
48
|
+
function registerService(server, service) {
|
|
49
|
+
if (!service?.name) throw new Error('Each service must have a name')
|
|
50
|
+
|
|
51
|
+
for (const [actionName, action] of Object.entries(service.actions ?? {})) {
|
|
52
|
+
const isBareHandler = typeof action === 'function'
|
|
53
|
+
const handler = isBareHandler ? action : action.handler
|
|
54
|
+
if (typeof handler !== 'function') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Service '${service.name}' action '${actionName}' has no handler function`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const config = { description: isBareHandler ? undefined : action.description }
|
|
61
|
+
const params = isBareHandler ? undefined : action.params
|
|
62
|
+
// Always declare an inputSchema, even an empty one: the SDK only calls the tool
|
|
63
|
+
// callback with (args, extra) when inputSchema is set. Omitting it for a
|
|
64
|
+
// no-params action drops to a single-argument (extra) call instead — silently
|
|
65
|
+
// handing the caller's own context object to `args` and leaving `extra` (and
|
|
66
|
+
// therefore any auth context) undefined.
|
|
67
|
+
config.inputSchema = fromJsonSchema(
|
|
68
|
+
defineSchema(params ?? {}),
|
|
69
|
+
principiaJsonSchemaValidator,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
server.registerTool(`${service.name}.${actionName}`, config, async (args, extra) => {
|
|
73
|
+
const result = await handler(args ?? {}, extra)
|
|
74
|
+
return toCallToolResult(result)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds an MCP server exposing a list of Moleculer-shaped services as tools —
|
|
81
|
+
* `{ name: 'Weather', actions: { getForecast: { params, handler } } }` becomes the
|
|
82
|
+
* `"Weather.getForecast"` tool, with `params` validated as JSON schema via
|
|
83
|
+
* `@empyria/common`'s `createValidator` (no Zod, no Ajv).
|
|
84
|
+
*
|
|
85
|
+
* Connecting the server to a transport (stdio, HTTP, ...) is the caller's responsibility —
|
|
86
|
+
* this only builds and registers; see `@modelcontextprotocol/server/stdio`'s
|
|
87
|
+
* `StdioServerTransport` or `serveStdio`, or `WebStandardStreamableHTTPServerTransport`.
|
|
88
|
+
* @param {Object} options
|
|
89
|
+
* @param {string} options.name - MCP server name, shown to clients.
|
|
90
|
+
* @param {string} options.version - MCP server version, shown to clients.
|
|
91
|
+
* @param {ServiceDef[]} [options.services] - Services to publish as tools.
|
|
92
|
+
* @param {import('@modelcontextprotocol/server').ServerCapabilities} [options.capabilities] -
|
|
93
|
+
* Defaults to `{ tools: {} }`.
|
|
94
|
+
* @param {Object} [options.guard] - Passed through to {@link withMetaGuard} for every service
|
|
95
|
+
* that declares a `resolveToken` (e.g. `{ tokenHeader: 'x-token-key' }`); services without
|
|
96
|
+
* one are unaffected. See `lib/Guard.js`.
|
|
97
|
+
* @returns {McpServer}
|
|
98
|
+
*/
|
|
99
|
+
export function createMcpServer({
|
|
100
|
+
name,
|
|
101
|
+
version,
|
|
102
|
+
services = [],
|
|
103
|
+
capabilities = { tools: {} },
|
|
104
|
+
guard,
|
|
105
|
+
}) {
|
|
106
|
+
const server = new McpServer({ name, version }, { capabilities })
|
|
107
|
+
|
|
108
|
+
for (const service of services) registerService(server, withMetaGuard(service, guard))
|
|
109
|
+
|
|
110
|
+
return server
|
|
111
|
+
}
|
package/lib/Validator.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createValidator } from '@empyria/common'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} JsonSchemaValidatorResult
|
|
5
|
+
* @property {boolean} valid
|
|
6
|
+
* @property {*} [data]
|
|
7
|
+
* @property {string} [errorMessage]
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The MCP SDK's `jsonSchemaValidator` provider interface (see
|
|
12
|
+
* `@modelcontextprotocol/server`'s `fromJsonSchema`), backed by
|
|
13
|
+
* `@empyria/common`'s `createValidator` — no Ajv, no Zod.
|
|
14
|
+
*
|
|
15
|
+
* `createValidator` throws a `PrincipiaError` on invalid input; this adapts
|
|
16
|
+
* that into the provider's `{valid, data, errorMessage}` result shape.
|
|
17
|
+
*/
|
|
18
|
+
export const principiaJsonSchemaValidator = {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} schema - A JSON schema (Draft 2020-12-compatible).
|
|
21
|
+
* @returns {(input: unknown) => JsonSchemaValidatorResult}
|
|
22
|
+
*/
|
|
23
|
+
getValidator(schema) {
|
|
24
|
+
const validate = createValidator(schema)
|
|
25
|
+
|
|
26
|
+
return (input) => {
|
|
27
|
+
try {
|
|
28
|
+
return { valid: true, data: validate(input) }
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { valid: false, errorMessage: err.message }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@empyria/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP (Model Context Protocol) helpers for the Principia nanoservice framework",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Imre Fazekas <imre.fazekas@icloud.com>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/imrefazekas/empyria-guard-mcp.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "./index.js",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./lib/*": "./lib/*.js",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"format": "bun oxfmt --check",
|
|
20
|
+
"format:fix": "bun oxfmt",
|
|
21
|
+
"lint": "bun oxlint",
|
|
22
|
+
"lint:fix": "bun oxlint --fix",
|
|
23
|
+
"test": "bun test --coverage",
|
|
24
|
+
"test:watch": "bun test --watch"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@empyria/classification": "0.1.0",
|
|
28
|
+
"@empyria/common": "0.1.0",
|
|
29
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
30
|
+
"@modelcontextprotocol/server": "2.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"oxfmt": "0.63.0",
|
|
34
|
+
"oxlint": "1.78.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"bun": ">=1.4.0",
|
|
38
|
+
"node": ">=26"
|
|
39
|
+
},
|
|
40
|
+
"packageManager": "bun@1.4.0"
|
|
41
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { string } from '@empyria/common'
|
|
3
|
+
import { createMcpClient, connectStdioClient, connectHttpClient } from '../lib/Client.js'
|
|
4
|
+
import { serveMcpHttp } from '../lib/Http.js'
|
|
5
|
+
|
|
6
|
+
describe('createMcpClient', () => {
|
|
7
|
+
test('builds an unconnected client', () => {
|
|
8
|
+
const client = createMcpClient({ name: 'x', version: '1.0.0' })
|
|
9
|
+
expect(client).toBeDefined()
|
|
10
|
+
expect(typeof client.connect).toBe('function')
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('connectStdioClient', () => {
|
|
15
|
+
test('spawns a real server process and connects over stdio', async () => {
|
|
16
|
+
const client = await connectStdioClient({
|
|
17
|
+
name: 'test-client',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
command: 'bun',
|
|
20
|
+
args: [new URL('./fixtures/stdio-server.js', import.meta.url).pathname],
|
|
21
|
+
})
|
|
22
|
+
try {
|
|
23
|
+
const { tools } = await client.listTools()
|
|
24
|
+
expect(tools.map((t) => t.name)).toEqual(['Echo.say'])
|
|
25
|
+
|
|
26
|
+
const result = await client.callTool({ name: 'Echo.say', arguments: { text: 'hi' } })
|
|
27
|
+
expect(result.content).toEqual([{ type: 'text', text: 'hi' }])
|
|
28
|
+
} finally {
|
|
29
|
+
await client.close()
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('connectHttpClient', () => {
|
|
35
|
+
let server
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
if (server) {
|
|
39
|
+
await server.close()
|
|
40
|
+
server = undefined
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('connects over HTTP and can call a tool', async () => {
|
|
45
|
+
const Service = {
|
|
46
|
+
name: 'Echo',
|
|
47
|
+
actions: { say: { params: { text: string() }, handler: async ({ text }) => text } },
|
|
48
|
+
}
|
|
49
|
+
server = serveMcpHttp({ name: 'x', version: '1.0.0', services: [Service] }, { port: 0 })
|
|
50
|
+
|
|
51
|
+
const client = await connectHttpClient({
|
|
52
|
+
name: 'c',
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
url: server.url.toString(),
|
|
55
|
+
})
|
|
56
|
+
try {
|
|
57
|
+
const { tools } = await client.listTools()
|
|
58
|
+
expect(tools.map((t) => t.name)).toEqual(['Echo.say'])
|
|
59
|
+
} finally {
|
|
60
|
+
await client.close()
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('the "token" shorthand sends an Authorization: Bearer header', async () => {
|
|
65
|
+
let receivedToken
|
|
66
|
+
const Service = {
|
|
67
|
+
name: 'Secrets',
|
|
68
|
+
resolveToken: async (tokenKey) => {
|
|
69
|
+
receivedToken = tokenKey
|
|
70
|
+
return { name: 'Bob' }
|
|
71
|
+
},
|
|
72
|
+
actions: { reveal: async () => 'secret' },
|
|
73
|
+
}
|
|
74
|
+
server = serveMcpHttp({ name: 'x', version: '1.0.0', services: [Service] }, { port: 0 })
|
|
75
|
+
|
|
76
|
+
const client = await connectHttpClient({
|
|
77
|
+
name: 'c',
|
|
78
|
+
version: '1.0.0',
|
|
79
|
+
url: server.url.toString(),
|
|
80
|
+
token: 'my-token',
|
|
81
|
+
})
|
|
82
|
+
try {
|
|
83
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: {} })
|
|
84
|
+
expect(result.isError).toBeFalsy()
|
|
85
|
+
expect(receivedToken).toBe('my-token')
|
|
86
|
+
} finally {
|
|
87
|
+
await client.close()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('a custom header (matching a custom tokenHeader guard) also works', async () => {
|
|
92
|
+
let receivedToken
|
|
93
|
+
const Service = {
|
|
94
|
+
name: 'Secrets',
|
|
95
|
+
resolveToken: async (tokenKey) => {
|
|
96
|
+
receivedToken = tokenKey
|
|
97
|
+
return { name: 'Bob' }
|
|
98
|
+
},
|
|
99
|
+
actions: { reveal: async () => 'secret' },
|
|
100
|
+
}
|
|
101
|
+
server = serveMcpHttp(
|
|
102
|
+
{
|
|
103
|
+
name: 'x',
|
|
104
|
+
version: '1.0.0',
|
|
105
|
+
services: [Service],
|
|
106
|
+
guard: { tokenHeader: 'x-token-key' },
|
|
107
|
+
},
|
|
108
|
+
{ port: 0 },
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const client = await connectHttpClient({
|
|
112
|
+
name: 'c',
|
|
113
|
+
version: '1.0.0',
|
|
114
|
+
url: server.url.toString(),
|
|
115
|
+
headers: { 'X-Token-Key': 'custom-token' },
|
|
116
|
+
})
|
|
117
|
+
try {
|
|
118
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: {} })
|
|
119
|
+
expect(result.isError).toBeFalsy()
|
|
120
|
+
expect(receivedToken).toBe('custom-token')
|
|
121
|
+
} finally {
|
|
122
|
+
await client.close()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { InMemoryTransport } from '@modelcontextprotocol/server'
|
|
3
|
+
import { Client } from '@modelcontextprotocol/client'
|
|
4
|
+
import { string } from '@empyria/common'
|
|
5
|
+
import { createMcpServer } from '../lib/Server.js'
|
|
6
|
+
import { createMcpGateway } from '../lib/Gateway.js'
|
|
7
|
+
|
|
8
|
+
const openClients = []
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(openClients.splice(0).map((c) => c.close()))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/** Builds a backend server from `services` and returns a connected client to it. */
|
|
15
|
+
async function backendClient(services) {
|
|
16
|
+
const backend = createMcpServer({ name: 'backend', version: '1.0.0', services })
|
|
17
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
18
|
+
await backend.connect(serverTransport)
|
|
19
|
+
const client = new Client({ name: 'upstream-client', version: '1.0.0' })
|
|
20
|
+
await client.connect(clientTransport)
|
|
21
|
+
openClients.push(client)
|
|
22
|
+
return client
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Connects a client to an already-built gateway server. */
|
|
26
|
+
async function connectTo(server) {
|
|
27
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
28
|
+
await server.connect(serverTransport)
|
|
29
|
+
const client = new Client({ name: 'gateway-client', version: '1.0.0' })
|
|
30
|
+
await client.connect(clientTransport)
|
|
31
|
+
openClients.push(client)
|
|
32
|
+
return client
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createMcpGateway', () => {
|
|
36
|
+
test('aggregates tools from every upstream, namespaced by upstream name', async () => {
|
|
37
|
+
const weather = await backendClient([
|
|
38
|
+
{ name: 'Weather', actions: { getForecast: async () => 'sunny' } },
|
|
39
|
+
])
|
|
40
|
+
const search = await backendClient([
|
|
41
|
+
{ name: 'Search', actions: { query: async () => 'results' } },
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
const gateway = await createMcpGateway({
|
|
45
|
+
name: 'gw',
|
|
46
|
+
version: '1.0.0',
|
|
47
|
+
upstreams: [
|
|
48
|
+
{ name: 'WeatherSrv', client: weather },
|
|
49
|
+
{ name: 'SearchSrv', client: search },
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
const client = await connectTo(gateway)
|
|
53
|
+
|
|
54
|
+
const { tools } = await client.listTools()
|
|
55
|
+
expect(tools.map((t) => t.name).sort()).toEqual([
|
|
56
|
+
'SearchSrv.Search.query',
|
|
57
|
+
'WeatherSrv.Weather.getForecast',
|
|
58
|
+
])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('forwards a call to the owning upstream and returns its result unchanged', async () => {
|
|
62
|
+
const weather = await backendClient([
|
|
63
|
+
{
|
|
64
|
+
name: 'Weather',
|
|
65
|
+
actions: {
|
|
66
|
+
getForecast: {
|
|
67
|
+
params: { city: string() },
|
|
68
|
+
handler: async ({ city }) => `sunny in ${city}`,
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
])
|
|
73
|
+
const gateway = await createMcpGateway({
|
|
74
|
+
name: 'gw',
|
|
75
|
+
version: '1.0.0',
|
|
76
|
+
upstreams: [{ name: 'WeatherSrv', client: weather }],
|
|
77
|
+
})
|
|
78
|
+
const client = await connectTo(gateway)
|
|
79
|
+
|
|
80
|
+
const result = await client.callTool({
|
|
81
|
+
name: 'WeatherSrv.Weather.getForecast',
|
|
82
|
+
arguments: { city: 'Zurich' },
|
|
83
|
+
})
|
|
84
|
+
expect(result.content).toEqual([{ type: 'text', text: 'sunny in Zurich' }])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('an invalid-argument error from the upstream is forwarded, not swallowed', async () => {
|
|
88
|
+
const weather = await backendClient([
|
|
89
|
+
{
|
|
90
|
+
name: 'Weather',
|
|
91
|
+
actions: {
|
|
92
|
+
getForecast: { params: { city: string() }, handler: async ({ city }) => city },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
])
|
|
96
|
+
const gateway = await createMcpGateway({
|
|
97
|
+
name: 'gw',
|
|
98
|
+
version: '1.0.0',
|
|
99
|
+
upstreams: [{ name: 'WeatherSrv', client: weather }],
|
|
100
|
+
})
|
|
101
|
+
const client = await connectTo(gateway)
|
|
102
|
+
|
|
103
|
+
const result = await client.callTool({
|
|
104
|
+
name: 'WeatherSrv.Weather.getForecast',
|
|
105
|
+
arguments: {},
|
|
106
|
+
})
|
|
107
|
+
expect(result.isError).toBe(true)
|
|
108
|
+
expect(result.content[0].text).toContain('city')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('a schema-less upstream tool proxies correctly too', async () => {
|
|
112
|
+
const svc = await backendClient([{ name: 'Svc', actions: { ping: async () => 'pong' } }])
|
|
113
|
+
const gateway = await createMcpGateway({
|
|
114
|
+
name: 'gw',
|
|
115
|
+
version: '1.0.0',
|
|
116
|
+
upstreams: [{ name: 'SvcSrv', client: svc }],
|
|
117
|
+
})
|
|
118
|
+
const client = await connectTo(gateway)
|
|
119
|
+
|
|
120
|
+
const result = await client.callTool({ name: 'SvcSrv.Svc.ping', arguments: {} })
|
|
121
|
+
expect(result.content[0].text).toBe('pong')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('rejects two upstreams sharing a name, before connecting anything', async () => {
|
|
125
|
+
const weather = await backendClient([
|
|
126
|
+
{ name: 'Weather', actions: { ping: async () => 'a' } },
|
|
127
|
+
])
|
|
128
|
+
const search = await backendClient([{ name: 'Search', actions: { ping: async () => 'b' } }])
|
|
129
|
+
|
|
130
|
+
await expect(
|
|
131
|
+
createMcpGateway({
|
|
132
|
+
name: 'gw',
|
|
133
|
+
version: '1.0.0',
|
|
134
|
+
upstreams: [
|
|
135
|
+
{ name: 'Dup', client: weather },
|
|
136
|
+
{ name: 'Dup', client: search },
|
|
137
|
+
],
|
|
138
|
+
}),
|
|
139
|
+
).rejects.toThrow(/Duplicate upstream name/)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('an empty upstream list produces a server with no tools', async () => {
|
|
143
|
+
const gateway = await createMcpGateway({ name: 'gw', version: '1.0.0', upstreams: [] })
|
|
144
|
+
const client = await connectTo(gateway)
|
|
145
|
+
expect((await client.listTools()).tools).toEqual([])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('one built gateway instance, reused (not rebuilt) as the factory, serves multiple HTTP requests', async () => {
|
|
149
|
+
// Gateways are expensive to build (a listTools() round trip per upstream) — the
|
|
150
|
+
// documented pattern is build once, hand createMcpHandler a factory that returns
|
|
151
|
+
// that same instance every time, rather than a factory that rebuilds per request.
|
|
152
|
+
const svc = await backendClient([{ name: 'Svc', actions: { ping: async () => 'pong' } }])
|
|
153
|
+
const gateway = await createMcpGateway({
|
|
154
|
+
name: 'gw',
|
|
155
|
+
version: '1.0.0',
|
|
156
|
+
upstreams: [{ name: 'SvcSrv', client: svc }],
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const { createMcpHandler } = await import('@modelcontextprotocol/server')
|
|
160
|
+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client')
|
|
161
|
+
const handler = createMcpHandler(() => gateway)
|
|
162
|
+
const bunServer = Bun.serve({ port: 0, fetch: handler.fetch })
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
for (let i = 0; i < 2; i++) {
|
|
166
|
+
const client = new Client({ name: `http-client-${i}`, version: '1.0.0' })
|
|
167
|
+
await client.connect(
|
|
168
|
+
new StreamableHTTPClientTransport(new URL(bunServer.url.toString())),
|
|
169
|
+
)
|
|
170
|
+
const result = await client.callTool({ name: 'SvcSrv.Svc.ping', arguments: {} })
|
|
171
|
+
expect(result.content[0].text).toBe('pong')
|
|
172
|
+
await client.close()
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await handler.close()
|
|
176
|
+
bunServer.stop()
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach } from 'bun:test'
|
|
2
|
+
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
|
|
3
|
+
import { InMemoryTransport } from '@modelcontextprotocol/server'
|
|
4
|
+
import { string } from '@empyria/common'
|
|
5
|
+
import { createMcpServer } from '../lib/Server.js'
|
|
6
|
+
import { serveMcpHttp } from '../lib/Http.js'
|
|
7
|
+
import { withMetaGuard } from '../lib/Guard.js'
|
|
8
|
+
|
|
9
|
+
let server
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
if (server) {
|
|
13
|
+
await server.close()
|
|
14
|
+
server = undefined
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('withMetaGuard', () => {
|
|
19
|
+
test('returns the service unchanged when it has no resolveToken', () => {
|
|
20
|
+
const service = { name: 'Open', actions: { ping: async () => 'pong' } }
|
|
21
|
+
expect(withMetaGuard(service)).toBe(service)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('wraps every action of a service that declares resolveToken', () => {
|
|
25
|
+
const service = {
|
|
26
|
+
name: 'Guarded',
|
|
27
|
+
resolveToken: async () => ({ id: 'u1' }),
|
|
28
|
+
actions: { a: async () => 'a', b: { params: {}, handler: async () => 'b' } },
|
|
29
|
+
}
|
|
30
|
+
const guarded = withMetaGuard(service)
|
|
31
|
+
expect(guarded).not.toBe(service)
|
|
32
|
+
expect(typeof guarded.actions.a).toBe('function')
|
|
33
|
+
expect(typeof guarded.actions.b.handler).toBe('function')
|
|
34
|
+
// non-handler action config (params, description, ...) is preserved
|
|
35
|
+
expect(guarded.actions.b.params).toEqual({})
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Auth tokens only exist as HTTP headers — `extra.http.req` is undefined over stdio (and any
|
|
40
|
+
// other non-HTTP transport), so these run against a real Bun.serve HTTP server, not
|
|
41
|
+
// InMemoryTransport, to exercise real header extraction.
|
|
42
|
+
describe('withMetaGuard: end-to-end over HTTP', () => {
|
|
43
|
+
function protectedServer(resolveToken, guardOptions) {
|
|
44
|
+
const Service = {
|
|
45
|
+
name: 'Secrets',
|
|
46
|
+
resolveToken,
|
|
47
|
+
actions: {
|
|
48
|
+
reveal: {
|
|
49
|
+
params: { id: string() },
|
|
50
|
+
handler: async ({ id }, extra) => `secret-${id}-for-${extra.user.name}`,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return serveMcpHttp(
|
|
55
|
+
{ name: 'guard-test', version: '1.0.0', services: [Service], guard: guardOptions },
|
|
56
|
+
{ port: 0 },
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function connectWithHeaders(headers) {
|
|
61
|
+
const client = new Client({ name: 'c', version: '1.0.0' })
|
|
62
|
+
const transport = new StreamableHTTPClientTransport(new URL(server.url.toString()), {
|
|
63
|
+
requestInit: { headers },
|
|
64
|
+
})
|
|
65
|
+
await client.connect(transport)
|
|
66
|
+
return client
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('rejects a call with no token at all', async () => {
|
|
70
|
+
server = protectedServer(async () => ({ name: 'Bob' }))
|
|
71
|
+
const client = await connectWithHeaders({})
|
|
72
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: { id: '1' } })
|
|
73
|
+
expect(result.isError).toBe(true)
|
|
74
|
+
expect(result.content[0].text).toContain('missing auth token')
|
|
75
|
+
await client.close()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('rejects a call whose token resolves to nothing', async () => {
|
|
79
|
+
server = protectedServer(async () => undefined)
|
|
80
|
+
const client = await connectWithHeaders({ Authorization: 'Bearer bad-token' })
|
|
81
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: { id: '1' } })
|
|
82
|
+
expect(result.isError).toBe(true)
|
|
83
|
+
expect(result.content[0].text).toContain('invalid auth token')
|
|
84
|
+
await client.close()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('accepts a Bearer token, strips the prefix, and resolves it into extra.user', async () => {
|
|
88
|
+
let receivedToken
|
|
89
|
+
server = protectedServer(async (tokenKey) => {
|
|
90
|
+
receivedToken = tokenKey
|
|
91
|
+
return { name: 'Bob' }
|
|
92
|
+
})
|
|
93
|
+
const client = await connectWithHeaders({ Authorization: 'Bearer good-token' })
|
|
94
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: { id: '1' } })
|
|
95
|
+
expect(result.isError).toBeFalsy()
|
|
96
|
+
expect(result.content[0].text).toBe('secret-1-for-Bob')
|
|
97
|
+
expect(receivedToken).toBe('good-token')
|
|
98
|
+
await client.close()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('passes service/action context to resolveToken, mirroring getUser(ctx, action)', async () => {
|
|
102
|
+
let seenContext
|
|
103
|
+
server = protectedServer(async (tokenKey, context) => {
|
|
104
|
+
seenContext = context
|
|
105
|
+
return { name: 'Bob' }
|
|
106
|
+
})
|
|
107
|
+
const client = await connectWithHeaders({ Authorization: 'Bearer t' })
|
|
108
|
+
await client.callTool({ name: 'Secrets.reveal', arguments: { id: '1' } })
|
|
109
|
+
expect(seenContext.service).toBe('Secrets')
|
|
110
|
+
expect(seenContext.action).toBe('reveal')
|
|
111
|
+
expect(seenContext.extra).toBeDefined()
|
|
112
|
+
await client.close()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('a configurable tokenHeader reads from a custom header instead of Authorization', async () => {
|
|
116
|
+
let receivedToken
|
|
117
|
+
server = protectedServer(
|
|
118
|
+
async (tokenKey) => {
|
|
119
|
+
receivedToken = tokenKey
|
|
120
|
+
return { name: 'Bob' }
|
|
121
|
+
},
|
|
122
|
+
{ tokenHeader: 'x-token-key' },
|
|
123
|
+
)
|
|
124
|
+
const client = await connectWithHeaders({ 'X-Token-Key': 'custom-key' })
|
|
125
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: { id: '1' } })
|
|
126
|
+
expect(result.isError).toBeFalsy()
|
|
127
|
+
expect(receivedToken).toBe('custom-key')
|
|
128
|
+
await client.close()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('a service without resolveToken stays public even when others are guarded', async () => {
|
|
132
|
+
const PublicService = { name: 'Public', actions: { ping: async () => 'pong' } }
|
|
133
|
+
const GuardedService = {
|
|
134
|
+
name: 'Guarded',
|
|
135
|
+
resolveToken: async () => ({ name: 'Bob' }),
|
|
136
|
+
actions: { secret: async () => 'shh' },
|
|
137
|
+
}
|
|
138
|
+
server = serveMcpHttp(
|
|
139
|
+
{ name: 'mixed', version: '1.0.0', services: [PublicService, GuardedService] },
|
|
140
|
+
{ port: 0 },
|
|
141
|
+
)
|
|
142
|
+
const client = await connectWithHeaders({})
|
|
143
|
+
|
|
144
|
+
const publicResult = await client.callTool({ name: 'Public.ping', arguments: {} })
|
|
145
|
+
expect(publicResult.isError).toBeFalsy()
|
|
146
|
+
expect(publicResult.content[0].text).toBe('pong')
|
|
147
|
+
|
|
148
|
+
const guardedResult = await client.callTool({ name: 'Guarded.secret', arguments: {} })
|
|
149
|
+
expect(guardedResult.isError).toBe(true)
|
|
150
|
+
|
|
151
|
+
await client.close()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('withMetaGuard: no HTTP context (stdio-equivalent)', () => {
|
|
156
|
+
test('rejects — there is no request/headers to read a token from', async () => {
|
|
157
|
+
const Service = {
|
|
158
|
+
name: 'Secrets',
|
|
159
|
+
resolveToken: async () => ({ name: 'Bob' }),
|
|
160
|
+
actions: { reveal: async () => 'secret' },
|
|
161
|
+
}
|
|
162
|
+
const mcpServer = createMcpServer({ name: 'x', version: '1.0.0', services: [Service] })
|
|
163
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
164
|
+
await mcpServer.connect(serverTransport)
|
|
165
|
+
const client = new Client({ name: 'c', version: '1.0.0' })
|
|
166
|
+
await client.connect(clientTransport)
|
|
167
|
+
|
|
168
|
+
const result = await client.callTool({ name: 'Secrets.reveal', arguments: {} })
|
|
169
|
+
expect(result.isError).toBe(true)
|
|
170
|
+
expect(result.content[0].text).toContain('missing auth token')
|
|
171
|
+
|
|
172
|
+
await client.close()
|
|
173
|
+
})
|
|
174
|
+
})
|