@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 ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "./node_modules/oxfmt/configuration_schema.json",
3
+ "ignorePatterns": [],
4
+ "useTabs": true,
5
+ "tabWidth": 4,
6
+ "singleQuote": true,
7
+ "trailingComma": "all",
8
+ "semi": false
9
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,126 @@
1
+ # AGENTS.md
2
+
3
+ MCP (Model Context Protocol) helpers for **Principia**, a nanoservice framework built
4
+ primarily on Bun: publishes Moleculer-shaped `{ name, actions }` service objects as MCP tools.
5
+
6
+ ## The package split that matters here
7
+
8
+ There are **two different, unrelated npm package families** claiming the MCP name:
9
+
10
+ - `@modelcontextprotocol/sdk` (currently `1.30.0`) — the original, unified SDK. Its `McpServer`
11
+ convenience API (`tool()`/`registerTool()`) only accepts Zod schemas for tool input, with no
12
+ JSON-Schema escape hatch found in it.
13
+ - `@modelcontextprotocol/server` + `@modelcontextprotocol/core` + `@modelcontextprotocol/client`
14
+ (currently `2.0.0`) — the newer split packages. **This is what this repo uses.** Its
15
+ `registerTool()` still wants a "Standard Schema" too, but it ships `fromJsonSchema(schema,
16
+ validatorProvider)`, a first-class adapter that wraps a plain JSON Schema object into one,
17
+ given a pluggable `{ getValidator(schema) => (input) => {valid, data, errorMessage} }`
18
+ provider — the exact same extension point its own `./validators/ajv` and
19
+ `./validators/cf-worker` subpath exports implement. `lib/Validator.js` is that same interface,
20
+ backed by `@empyria/common`'s `createValidator` instead.
21
+
22
+ Don't add `zod` as a dependency of this repo's own code — we never import it. It's still an
23
+ unavoidable transitive dependency of `@modelcontextprotocol/server` (used internally for the
24
+ SDK's own request/notification envelope schemas), so it'll be in `node_modules`, but nothing
25
+ here should ever construct or reference a Zod schema directly.
26
+
27
+ ## HTTP transport
28
+
29
+ `lib/Http.js`'s `createMcpHttpHandler()` wraps `createMcpHandler(factory)` from
30
+ `@modelcontextprotocol/server` — it wants a **factory** (`() => McpServer`), not an
31
+ already-built server, because HTTP serving defaults to stateless: a fresh server instance per
32
+ request. `createMcpServer()` is cheap (pure registration, no I/O) so `() => createMcpServer(options)`
33
+ is the whole adapter.
34
+
35
+ The resulting handler is `{ fetch: (Request) => Promise<Response>, close }` — plain Web
36
+ Standard, no framework required. `serveMcpHttp()` wires that straight into `Bun.serve` (uses
37
+ the global `Bun`, so it only works under Bun — `createMcpHttpHandler` itself stays
38
+ runtime-agnostic and works under Node too, since nothing in it touches `Bun` unless
39
+ `serveMcpHttp` is actually called). Don't add `@modelcontextprotocol/hono` or another web
40
+ framework as a dependency for this — it's one optional adapter among several, not a
41
+ requirement, and would be an unnecessary dependency here.
42
+
43
+ ## `extra` is only populated when `inputSchema` is set — always declare one
44
+
45
+ `registerTool(name, config, cb)`'s runtime dispatch (`createToolExecutor` in the SDK) checks
46
+ `config.inputSchema` at _call_ time, not just at registration: if it's set, `cb` is invoked as
47
+ `(args, extra)`; if it's unset, `cb` collapses to a **single-argument** `(extra)` call — the
48
+ would-be `args` position silently receives `extra`'s value instead, and the real `extra`
49
+ argument is `undefined`. `lib/Server.js` always builds an `inputSchema` (an empty-object one via
50
+ `defineSchema(params ?? {})` when an action declares no `params`) specifically to avoid this —
51
+ don't special-case the "no params" path back to an omitted `inputSchema`, it silently breaks
52
+ `extra` (and therefore `lib/Guard.js`, which reads `extra.http.req` for every action regardless
53
+ of whether it takes params). See the regression test in `test/Server.test.js` ("still receives a
54
+ real extra context, not undefined").
55
+
56
+ ## Auth (`lib/Guard.js`)
57
+
58
+ `extra.http.req` (the raw `Request`, with real header access via `.headers.get(name)`) is
59
+ only ever populated over HTTP — confirmed empirically, not from docs alone: `extra.http` is
60
+ absent entirely over `InMemoryTransport`/stdio. `withMetaGuard`-protected services will reject
61
+ _every_ call made over a non-HTTP transport, by design (there's nowhere for a token to live).
62
+ Don't try to thread a token through `extra` for stdio — there isn't a protocol-level place to
63
+ put it short of smuggling it into every action's own `params`, which was deliberately not done
64
+ here (it would leak into every tool's public schema).
65
+
66
+ `resolveToken(tokenKey, { service, action, extra })`'s signature intentionally mirrors
67
+ `empyria-guard-moleculer`'s `getUser(ctx, action)` — passing the service/action a guarded call
68
+ is targeting, the same way that file's `action.service.meta` does, for callers whose token
69
+ resolution needs to vary per action.
70
+
71
+ ## Gateway (`lib/Gateway.js`)
72
+
73
+ `createMcpGateway` does **not** re-validate arguments for proxied tools — `lib/Guard.js`'s
74
+ `passthroughValidator` in `Gateway.js` always reports `{valid: true}`. This is deliberate, not a
75
+ shortcut: the upstream server already validates the same arguments when the call is forwarded
76
+ to it via `client.callTool()`. Running a second, independent JSON Schema implementation
77
+ (ata-validator) over an already-upstream-validated payload would risk rejecting input the
78
+ upstream itself accepts (dialect/keyword coverage differences) for zero real benefit. If you
79
+ ever need the gateway to enforce something upstream doesn't, that's a deliberate new feature,
80
+ not "fixing" a missing validation step.
81
+
82
+ An `inputSchema` is still always declared per proxied tool (even a bare passthrough one for a
83
+ schema-less upstream tool) — same reason as `lib/Server.js`'s own rule: omitting it collapses
84
+ `registerTool`'s callback to a single-argument `(extra)` call instead of `(args, extra)`.
85
+
86
+ A gateway is expensive to build (one `listTools()` round trip per upstream) — it's meant to be
87
+ built once and reused, not rebuilt per request. Confirmed empirically: a `createMcpHandler`
88
+ factory can safely return the _same_ already-built `McpServer` on every call (no "already
89
+ connected" error, no per-request rebuild needed) — see the HTTP test in `test/Gateway.test.js`.
90
+
91
+ `createMcpGateway` takes already-connected `Client` instances, not transport configs — it
92
+ doesn't manage upstream connection lifecycle (that's `lib/Client.js`'s job, and the caller's to
93
+ close). Keep it that way; don't fold stdio/HTTP connection logic into `Gateway.js` a third time.
94
+
95
+ ## Runtime
96
+
97
+ - Requires Bun `>=1.4.0` or Node.js `>=26`, inherited from `@empyria/classification`'s use
98
+ of native `Temporal`. Both `@empyria/classification` and `@empyria/common` are **git
99
+ dependencies** — this package only sees their pushed commits, not local working-tree changes
100
+ in sibling repos.
101
+ - Plain ESM, no TypeScript, no build step.
102
+ - Relative imports must include explicit `.js` extensions — Bun tolerates missing ones, Node's
103
+ ESM resolver doesn't.
104
+
105
+ ## Tool errors vs. protocol errors
106
+
107
+ A tool call that fails validation, or whose handler throws, does **not** reject the client's
108
+ `callTool()` promise — `McpServer` catches it and resolves with `{ content: [...], isError:
109
+ true }` (the SDK's own `createToolError`), per MCP's own tool-error convention. Only genuine
110
+ protocol errors (e.g. calling a tool name that was never registered) actually reject. Write
111
+ tests accordingly — `await expect(client.callTool(...)).rejects.toThrow()` is wrong for a
112
+ validation-failure or handler-throw test; check `result.isError` instead. See `test/Server.test.js`.
113
+
114
+ ## Testing
115
+
116
+ Tests drive a real `@modelcontextprotocol/client` `Client` against the server — over
117
+ `InMemoryTransport.createLinkedPair()` for transport-agnostic behavior, a real `Bun.serve` HTTP
118
+ server for anything header/auth-related (`test/Guard.test.js`, `test/Http.test.js`), and a real
119
+ spawned child process (`test/fixtures/stdio-server.js`) for `connectStdioClient`
120
+ (`test/Client.test.js`) — rather than mocking the protocol. `@modelcontextprotocol/client` is a
121
+ real dependency (not devDependency-only): `lib/Client.js` needs it at runtime, not just tests.
122
+
123
+ ## Style
124
+
125
+ - Formatting is enforced by oxfmt ([.oxfmtrc.json](./.oxfmtrc.json)): tabs, single quotes, no
126
+ semicolons, trailing commas. Run `bun run format:fix` before committing.
package/README.md ADDED
@@ -0,0 +1,215 @@
1
+ # @empyria/mcp
2
+
3
+ [MCP (Model Context Protocol)](https://modelcontextprotocol.io) helpers for **Principia**, a
4
+ nanoservice framework built primarily on Bun: publish a list of Moleculer-shaped services as
5
+ MCP tools, with JSON Schema input validation via `@empyria/common`'s `createValidator` —
6
+ no Zod, no Ajv, in our own code.
7
+
8
+ ## Requirements
9
+
10
+ - Bun `>=1.4.0` or Node.js `>=26`
11
+ - Plain ESM, no build step, no TypeScript
12
+
13
+ Both requirements come from [@empyria/classification](https://github.com/imrefazekas/empyria-classification)
14
+ and [@empyria/common](https://github.com/imrefazekas/empyria-common), which this package
15
+ depends on and which use the native `Temporal` global for all date/time handling.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ bun add @empyria/mcp
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```js
26
+ import { createMcpServer } from '@empyria/mcp'
27
+ import { string, number } from '@empyria/common'
28
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
29
+
30
+ const WeatherService = {
31
+ name: 'Weather',
32
+ actions: {
33
+ getForecast: {
34
+ description: 'Get the weather forecast for a city',
35
+ params: { city: string(), days: number(3) },
36
+ handler: async ({ city, days }) => ({ city, days, forecast: 'sunny' }),
37
+ },
38
+ // bare functions work too — no params schema, no description
39
+ ping: async () => 'pong',
40
+ },
41
+ }
42
+
43
+ const server = createMcpServer({
44
+ name: 'my-mcp-server',
45
+ version: '1.0.0',
46
+ services: [WeatherService],
47
+ })
48
+
49
+ await server.connect(new StdioServerTransport())
50
+ ```
51
+
52
+ Each action becomes an MCP tool named `"<service.name>.<actionName>"` — `Weather.getForecast`,
53
+ `Weather.ping` above — mirroring Moleculer's own `service.action` naming. `params` are JSON
54
+ schema property definitions built with `@empyria/common`'s schema helpers (`string()`,
55
+ `number()`, `bool()`, `enumType()`, ...); incoming tool calls are validated against them before
56
+ your handler runs, and validation failures are reported back to the MCP client as a normal
57
+ tool-call error (`isError: true`) — your handler never sees invalid input. A handler's return
58
+ value can be a string, any JSON-serializable value, or an already MCP-shaped
59
+ `{ content: [...] }` result; thrown errors are also reported back as tool-call errors
60
+ automatically.
61
+
62
+ `createMcpServer` only builds and registers — connecting to a transport (stdio, HTTP, ...) is
63
+ up to you. For stdio, see `@modelcontextprotocol/server/stdio`'s `StdioServerTransport`/
64
+ `serveStdio`. For HTTP, this package has its own helper:
65
+
66
+ ```js
67
+ import { serveMcpHttp } from '@empyria/mcp'
68
+
69
+ serveMcpHttp(
70
+ { name: 'my-mcp-server', version: '1.0.0', services: [WeatherService] },
71
+ { port: 3000 },
72
+ )
73
+ ```
74
+
75
+ `serveMcpHttp` serves MCP over `Bun.serve` directly — no web framework needed. MCP's official
76
+ `createMcpHandler` produces a plain Web-standard `{ fetch: (Request) => Promise<Response>,
77
+ close }` handler (usable with any Fetch-API server, Bun's included); `@modelcontextprotocol/hono`
78
+ is only one optional adapter among several for wiring that handler into Hono specifically, not
79
+ a requirement. If you need the bare `{fetch, close}` handler instead of an already-running Bun
80
+ server (e.g. to mount inside your own `Bun.serve`, or another Fetch-based runtime), use
81
+ `createMcpHttpHandler(options)` instead.
82
+
83
+ ### Protecting services with a token
84
+
85
+ A service can require a token by adding a `resolveToken` function — the MCP-side equivalent of
86
+ `empyria-guard-moleculer`'s `MetaGuard.middleware.js`, reusing the same shape as its
87
+ `getUser(ctx, action)`:
88
+
89
+ ```js
90
+ const SecretsService = {
91
+ name: 'Secrets',
92
+ async resolveToken(tokenKey, { service, action }) {
93
+ // look the token up however you like — call another service, hit a DB, etc.
94
+ // return the resolved user/session, or a falsy value to reject the call
95
+ return lookupUser(tokenKey)
96
+ },
97
+ actions: {
98
+ reveal: {
99
+ params: { id: string() },
100
+ handler: async ({ id }, extra) => `secret for ${extra.user.name}`,
101
+ },
102
+ },
103
+ }
104
+ ```
105
+
106
+ The token is read from the `Authorization` header (an optional `Bearer ` prefix is stripped),
107
+ or a custom header via `guard: { tokenHeader: 'x-token-key' }` passed to `createMcpServer`/
108
+ `serveMcpHttp`. Calls with a missing or unresolved token are rejected as a normal tool-call
109
+ error, and the resolved value is attached to the handler's `extra.user`, mirroring Moleculer's
110
+ `ctx.meta.user`. **This only works over HTTP** — tokens travel as request headers, and stdio has
111
+ none; a `resolveToken`-protected service called over stdio always rejects. Services without
112
+ `resolveToken` are unaffected (public) — it's entirely opt-in, per service.
113
+
114
+ ### Connecting as a client
115
+
116
+ ```js
117
+ import { connectStdioClient, connectHttpClient } from '@empyria/mcp'
118
+
119
+ const stdioClient = await connectStdioClient({
120
+ name: 'my-client',
121
+ version: '1.0.0',
122
+ command: 'node',
123
+ args: ['./my-server.js'],
124
+ })
125
+
126
+ const httpClient = await connectHttpClient({
127
+ name: 'my-client',
128
+ version: '1.0.0',
129
+ url: 'http://localhost:3000',
130
+ token: 'my-token', // sends Authorization: Bearer my-token
131
+ })
132
+
133
+ const { tools } = await httpClient.listTools()
134
+ await httpClient.callTool({ name: 'Weather.getForecast', arguments: { city: 'Zurich' } })
135
+ ```
136
+
137
+ ### Aggregating many MCP servers into one (a gateway)
138
+
139
+ If you have several MCP servers and want to expose them to one agentic host as a single
140
+ connection — fewer config entries, one place to apply `lib/Guard.js`, etc. — connect to each as
141
+ a client and hand them to `createMcpGateway`:
142
+
143
+ ```js
144
+ import { connectStdioClient, connectHttpClient, createMcpGateway } from '@empyria/mcp'
145
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
146
+
147
+ const weather = await connectStdioClient({
148
+ name: 'gw',
149
+ version: '1.0.0',
150
+ command: 'node',
151
+ args: ['./weather-server.js'],
152
+ })
153
+ const search = await connectHttpClient({
154
+ name: 'gw',
155
+ version: '1.0.0',
156
+ url: 'http://localhost:4000',
157
+ })
158
+
159
+ const gateway = await createMcpGateway({
160
+ name: 'my-gateway',
161
+ version: '1.0.0',
162
+ upstreams: [
163
+ { name: 'Weather', client: weather },
164
+ { name: 'Search', client: search },
165
+ ],
166
+ })
167
+
168
+ await gateway.connect(new StdioServerTransport())
169
+ ```
170
+
171
+ Each upstream's tools are listed once, up front, and re-exposed as
172
+ `"<upstream.name>.<toolName>"`; calls are forwarded to the owning upstream and its result
173
+ passed straight through, unvalidated a second time — the upstream already validated it.
174
+ Building a gateway does real work (one `listTools()` per upstream), so build it once and reuse
175
+ that same instance — including as `createMcpHandler`'s per-request factory for HTTP, e.g. inside
176
+ `serveMcpHttp`'s `bunOptions.fetch` — rather than reconnecting upstreams on every request.
177
+
178
+ ## Modules
179
+
180
+ | Module | Purpose |
181
+ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
182
+ | [lib/Server.js](./lib/Server.js) | `createMcpServer` — publishes a list of Moleculer-shaped `{ name, actions }` services as MCP tools. |
183
+ | [lib/Http.js](./lib/Http.js) | `createMcpHttpHandler` (runtime-agnostic Web-standard handler) and `serveMcpHttp` (Bun-only convenience, wraps `Bun.serve`). |
184
+ | [lib/Guard.js](./lib/Guard.js) | `withMetaGuard` — optional per-service token protection; see above. |
185
+ | [lib/Client.js](./lib/Client.js) | `createMcpClient`, `connectStdioClient`, `connectHttpClient` — client-side helpers. |
186
+ | [lib/Gateway.js](./lib/Gateway.js) | `createMcpGateway` — aggregates several already-connected upstream MCP clients into one server. |
187
+ | [lib/Validator.js](./lib/Validator.js) | `principiaJsonSchemaValidator` — adapts `@empyria/common`'s `createValidator` to the MCP SDK's pluggable JSON Schema validator interface, the same extension point its own `ajv`/`cf-worker` providers use. |
188
+
189
+ There is no official JSON-Schema-only convenience API in `@modelcontextprotocol/server` — its
190
+ `registerTool()` still needs a "Standard Schema" (what Zod produces). What _is_ official and
191
+ Zod-free is `fromJsonSchema(schema, validatorProvider)`, which wraps a plain JSON Schema object
192
+ into a Standard Schema given a validator provider — that's what `lib/Server.js` uses, backed by
193
+ `lib/Validator.js` instead of the SDK's own `ajv`/`cf-worker` providers.
194
+
195
+ Every exported function is documented with JSDoc directly in its source file — hovering
196
+ a function in VSCode or Zed shows its parameters and return type without any extra
197
+ tooling, since both editors read JSDoc from plain `.js` files automatically.
198
+
199
+ Tests live under [test/](./test/), one file per module. They drive a real
200
+ `@modelcontextprotocol/client` `Client` against the server over `InMemoryTransport` rather than
201
+ mocking the protocol.
202
+
203
+ ## Scripts
204
+
205
+ ```bash
206
+ bun run format # check formatting (oxfmt)
207
+ bun run format:fix # apply formatting
208
+ bun run lint # lint (oxlint)
209
+ bun run lint:fix # lint and fix
210
+ bun run test # run tests with coverage
211
+ ```
212
+
213
+ ## License
214
+
215
+ MIT © Imre Fazekas
package/index.js ADDED
@@ -0,0 +1,11 @@
1
+ export * from './lib/Server.js'
2
+
3
+ export * from './lib/Validator.js'
4
+
5
+ export * from './lib/Http.js'
6
+
7
+ export * from './lib/Guard.js'
8
+
9
+ export * from './lib/Client.js'
10
+
11
+ export * from './lib/Gateway.js'
package/lib/Client.js ADDED
@@ -0,0 +1,56 @@
1
+ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
2
+ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'
3
+
4
+ /**
5
+ * Builds a bare (unconnected) MCP client. See {@link connectStdioClient}/{@link connectHttpClient}
6
+ * for the common case of building and connecting in one call.
7
+ * @param {Object} options
8
+ * @param {string} options.name - Client name, shown to the server.
9
+ * @param {string} options.version - Client version, shown to the server.
10
+ * @returns {Client}
11
+ */
12
+ export function createMcpClient({ name, version }) {
13
+ return new Client({ name, version })
14
+ }
15
+
16
+ /**
17
+ * Builds an MCP client and connects it to a server over stdio, spawning the server as a
18
+ * child process.
19
+ * @param {Object} options
20
+ * @param {string} options.name - Client name, shown to the server.
21
+ * @param {string} options.version - Client version, shown to the server.
22
+ * @param {string} options.command - Executable to run to start the server.
23
+ * @param {string[]} [options.args] - Command-line arguments.
24
+ * @param {Record<string, string>} [options.env] - Environment for the spawned process; inherits
25
+ * a safe default subset of the current process's environment if omitted.
26
+ * @returns {Promise<Client>} The connected client.
27
+ */
28
+ export async function connectStdioClient({ name, version, command, args, env }) {
29
+ const client = createMcpClient({ name, version })
30
+ await client.connect(new StdioClientTransport({ command, args, env }))
31
+ return client
32
+ }
33
+
34
+ /**
35
+ * Builds an MCP client and connects it to a server over streamable HTTP.
36
+ * @param {Object} options
37
+ * @param {string} options.name - Client name, shown to the server.
38
+ * @param {string} options.version - Client version, shown to the server.
39
+ * @param {string} options.url - The server's HTTP endpoint (e.g. what {@link import('./Http.js').serveMcpHttp} serves).
40
+ * @param {string} [options.token] - Shorthand for an `Authorization: Bearer <token>` header —
41
+ * matches `lib/Guard.js`'s default `tokenHeader` convention for a `withMetaGuard`-protected
42
+ * server. Ignored if `headers` already sets `Authorization`.
43
+ * @param {Record<string, string>} [options.headers] - Additional request headers (e.g. for a
44
+ * `withMetaGuard` server configured with a custom `tokenHeader`).
45
+ * @returns {Promise<Client>} The connected client.
46
+ */
47
+ export async function connectHttpClient({ name, version, url, token, headers = {} }) {
48
+ const client = createMcpClient({ name, version })
49
+ const requestHeaders = token ? { Authorization: `Bearer ${token}`, ...headers } : headers
50
+ await client.connect(
51
+ new StreamableHTTPClientTransport(new URL(url), {
52
+ requestInit: { headers: requestHeaders },
53
+ }),
54
+ )
55
+ return client
56
+ }
package/lib/Gateway.js ADDED
@@ -0,0 +1,76 @@
1
+ import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server'
2
+
3
+ /**
4
+ * A validator provider that never actually validates — used for gateway-proxied tools, whose
5
+ * arguments are already validated by the upstream server when the call is forwarded. Re-running
6
+ * a second, independent JSON Schema implementation over the same input here would risk rejecting
7
+ * (or silently reshaping) input the upstream itself considers valid, for zero benefit.
8
+ * @type {import('./Validator.js').principiaJsonSchemaValidator}
9
+ */
10
+ const passthroughValidator = {
11
+ getValidator: () => (input) => ({ valid: true, data: input }),
12
+ }
13
+
14
+ /**
15
+ * @typedef {Object} UpstreamDef
16
+ * @property {string} name - Namespace prefix for this upstream's tools in the gateway
17
+ * (`"<name>.<toolName>"`) — must be unique among the gateway's upstreams.
18
+ * @property {import('@modelcontextprotocol/client').Client} client - An already-connected
19
+ * client (see `lib/Client.js`'s `connectStdioClient`/`connectHttpClient`). The gateway reads
20
+ * its tool list and forwards calls to it, but does not own its lifecycle — closing it is the
21
+ * caller's responsibility.
22
+ */
23
+
24
+ /**
25
+ * Builds an MCP server that aggregates a list of already-connected upstream MCP clients into
26
+ * one: every upstream's tools are listed once, up front, and re-exposed as
27
+ * `"<upstream.name>.<toolName>"`, with calls forwarded to the owning upstream and its result
28
+ * passed straight through. Tool arguments are validated once, by the upstream itself — the
29
+ * gateway does not re-validate.
30
+ *
31
+ * Building a gateway does real work (one `listTools()` round trip per upstream) — build it
32
+ * once and reuse the same instance for every connection, rather than rebuilding per request;
33
+ * `createMcpHandler`'s per-request factory can safely return the same instance every time.
34
+ * @param {Object} options
35
+ * @param {string} options.name - MCP server name, shown to clients.
36
+ * @param {string} options.version - MCP server version, shown to clients.
37
+ * @param {UpstreamDef[]} options.upstreams - Already-connected upstream clients to aggregate.
38
+ * @param {import('@modelcontextprotocol/server').ServerCapabilities} [options.capabilities] -
39
+ * Defaults to `{ tools: {} }`.
40
+ * @returns {Promise<McpServer>}
41
+ * @throws {Error} If two upstreams share a `name`.
42
+ */
43
+ export async function createMcpGateway({ name, version, upstreams, capabilities = { tools: {} } }) {
44
+ const seen = new Set()
45
+ for (const upstream of upstreams) {
46
+ if (seen.has(upstream.name)) {
47
+ throw new Error(`Duplicate upstream name '${upstream.name}'`)
48
+ }
49
+ seen.add(upstream.name)
50
+ }
51
+
52
+ const server = new McpServer({ name, version }, { capabilities })
53
+
54
+ for (const upstream of upstreams) {
55
+ const { tools } = await upstream.client.listTools()
56
+
57
+ for (const tool of tools) {
58
+ const config = {
59
+ description: tool.description,
60
+ // Always declared, even as an empty passthrough — matches lib/Server.js's own
61
+ // rule: registerTool only calls the callback as (args, extra) when inputSchema
62
+ // is set, collapsing to a single (extra) argument otherwise.
63
+ inputSchema: fromJsonSchema(
64
+ tool.inputSchema ?? { type: 'object', properties: {} },
65
+ passthroughValidator,
66
+ ),
67
+ }
68
+
69
+ server.registerTool(`${upstream.name}.${tool.name}`, config, async (args) => {
70
+ return upstream.client.callTool({ name: tool.name, arguments: args })
71
+ })
72
+ }
73
+ }
74
+
75
+ return server
76
+ }
package/lib/Guard.js ADDED
@@ -0,0 +1,67 @@
1
+ /** Header a token is read from by default; a `"Bearer "` prefix, if present, is stripped. */
2
+ export const DEFAULT_TOKEN_HEADER = 'authorization'
3
+
4
+ /**
5
+ * Reads a token from the incoming HTTP request's headers, via the raw `Request` MCP's HTTP
6
+ * transport exposes at `extra.http.req` — `undefined` over stdio (or any non-HTTP transport),
7
+ * which has no headers at all; see the `MetaGuard`-equivalent design note in AGENTS.md.
8
+ * @param {Object} extra - The MCP tool callback's context argument.
9
+ * @param {string} headerName
10
+ * @returns {string|undefined}
11
+ */
12
+ function extractToken(extra, headerName) {
13
+ const raw = extra?.http?.req?.headers?.get?.(headerName)
14
+ if (!raw) return undefined
15
+ return raw.replace(/^Bearer\s+/i, '')
16
+ }
17
+
18
+ /**
19
+ * Wraps a {@link import('./Server.js').ServiceDef} so every action requires a resolved token
20
+ * before its handler runs — the MCP-side equivalent of `empyria-guard-moleculer`'s
21
+ * `MetaGuard.middleware.js`. Optional and per-service: a service without a `resolveToken`
22
+ * function is returned unchanged (public, unguarded).
23
+ *
24
+ * The resolved value `resolveToken` returns is attached to the handler's `extra.user`,
25
+ * mirroring Moleculer's `ctx.meta.user`.
26
+ * @param {import('./Server.js').ServiceDef & {resolveToken?: (tokenKey: string, context: {service: string, action: string, extra: Object}) => Promise<*>}} service
27
+ * @param {Object} [options]
28
+ * @param {string} [options.tokenHeader] - Header to read the token from. Defaults to
29
+ * {@link DEFAULT_TOKEN_HEADER} (`"authorization"`, with an optional `"Bearer "` prefix stripped).
30
+ * @returns {import('./Server.js').ServiceDef}
31
+ */
32
+ export function withMetaGuard(service, options = {}) {
33
+ const { resolveToken } = service
34
+ if (typeof resolveToken !== 'function') return service
35
+
36
+ const tokenHeader = options.tokenHeader ?? DEFAULT_TOKEN_HEADER
37
+ const guardedActions = {}
38
+
39
+ for (const [actionName, action] of Object.entries(service.actions ?? {})) {
40
+ const isBareHandler = typeof action === 'function'
41
+ const handler = isBareHandler ? action : action.handler
42
+
43
+ const guardedHandler = async (params, extra) => {
44
+ const tokenKey = extractToken(extra, tokenHeader)
45
+ if (!tokenKey) {
46
+ throw new Error(`${service.name}.${actionName}: missing auth token`)
47
+ }
48
+
49
+ const user = await resolveToken(tokenKey, {
50
+ service: service.name,
51
+ action: actionName,
52
+ extra,
53
+ })
54
+ if (!user) {
55
+ throw new Error(`${service.name}.${actionName}: invalid auth token`)
56
+ }
57
+
58
+ return handler(params, { ...extra, user })
59
+ }
60
+
61
+ guardedActions[actionName] = isBareHandler
62
+ ? guardedHandler
63
+ : { ...action, handler: guardedHandler }
64
+ }
65
+
66
+ return { ...service, actions: guardedActions }
67
+ }
package/lib/Http.js ADDED
@@ -0,0 +1,37 @@
1
+ import { createMcpHandler } from '@modelcontextprotocol/server'
2
+
3
+ import { createMcpServer } from './Server.js'
4
+
5
+ /**
6
+ * Builds a Web-standard (`fetch`) MCP HTTP handler for a list of Moleculer-shaped services —
7
+ * runtime-agnostic; works with `Bun.serve`, Deno, Cloudflare Workers, Hono, or anything else
8
+ * that speaks the Fetch API. A fresh {@link createMcpServer} instance is built per request
9
+ * (this repo's `Server.js` builder is cheap — pure registration, no I/O), matching the MCP
10
+ * HTTP transport's default stateless serving idiom.
11
+ * @param {Parameters<typeof createMcpServer>[0]} options - Same shape as {@link createMcpServer}'s options.
12
+ * @returns {{fetch: (request: Request) => Promise<Response>, close: () => Promise<void>}}
13
+ */
14
+ export function createMcpHttpHandler(options) {
15
+ return createMcpHandler(() => createMcpServer(options))
16
+ }
17
+
18
+ /**
19
+ * Bun-only convenience: builds an MCP HTTP handler (see {@link createMcpHttpHandler}) and
20
+ * serves it with `Bun.serve`.
21
+ * @param {Parameters<typeof createMcpServer>[0]} options - Same shape as {@link createMcpServer}'s options.
22
+ * @param {Object} [bunOptions] - Passed through to `Bun.serve` (e.g. `port`, `hostname`, `tls`).
23
+ * `fetch` is always taken from the built MCP handler and cannot be overridden here.
24
+ * @returns {ReturnType<typeof Bun.serve> & {close: () => Promise<void>}} The running Bun
25
+ * server, with an added `close()` that stops both the MCP handler and the Bun server.
26
+ */
27
+ export function serveMcpHttp(options, bunOptions = {}) {
28
+ const handler = createMcpHttpHandler(options)
29
+ const server = Bun.serve({ ...bunOptions, fetch: handler.fetch })
30
+
31
+ return Object.assign(server, {
32
+ close: async () => {
33
+ await handler.close()
34
+ server.stop()
35
+ },
36
+ })
37
+ }