@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/.oxfmtrc.json
ADDED
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
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
|
+
}
|