@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/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
+ }
@@ -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
+ })