meteor-wormhole
A cosmic bridge connecting Meteor methods to AI agents through MCP
meteor-wormhole is a server-only Meteor 3 package that exposes your Meteor.methods as MCP (Model Context Protocol) tools so AI agents (Claude, GPT, Copilot, etc.) can discover and invoke them. It also optionally exposes them as REST endpoints with an auto-generated OpenAPI 3.1 spec and Swagger UI.
Features
- All-In Mode — Automatically expose every Meteor method as an MCP tool
- Opt-In Mode — Selectively expose methods with rich metadata and schemas
- Streamable HTTP Transport — MCP server embedded in your Meteor app via
WebApp - REST API — Optional REST endpoints for every exposed method (
POST /api/<method>) - OpenAPI 3.1 Spec — Auto-generated from your method registry
- Swagger UI — Built-in API docs browser at
/api/docs - API Key Authentication — Optional bearer token auth for MCP and REST endpoints
- Session Authentication — Call methods as a signed-in user with a Meteor login token (
this.userIdjust works) - Input & Output Schemas — Define JSON Schema for method parameters and return values
- Smart Defaults — Auto-excludes internal Meteor, DDP, and Accounts methods
- Plugin Host — Register custom transports and HTTP endpoints with
Wormhole.use(), no forking required - App Context — Pass app-provided dependencies (auth resolvers, audit loggers, storage paths) through to plugins
Requirements
- Meteor 3.4+
- Server-only (this package does not run on the client)
Install
meteor add wreiske:meteor-wormhole
Quick Start
1import { Wormhole } from 'meteor/wreiske:meteor-wormhole'; 2 3// Expose all Meteor methods as MCP tools (default mode) 4Wormhole.init();
That's it. Every method defined via Meteor.methods() is now available to MCP clients at http://localhost:3000/mcp.
Configuration
Wormhole.init(options)
Initialize the MCP bridge and optionally the REST API. Must be called once at server startup.
1Wormhole.init({ 2 mode: 'all', 3 path: '/mcp', 4 name: 'my-app', 5 version: '1.0.0', 6 apiKey: 'my-secret-key', 7 exclude: [/^admin\./, 'internalMethod'], 8 rest: { 9 enabled: true, 10 path: '/api', 11 docs: true, 12 }, 13});
Options
| Option | Type | Default | Description |
|---|---|---|---|
mode | 'all' | 'opt-in' | 'all' | 'all' auto-registers all methods. 'opt-in' requires explicit Wormhole.expose() calls. |
path | string | '/mcp' | HTTP path where the MCP server listens. |
name | string | 'meteor-wormhole' | Display name for the MCP server. |
version | string | '1.0.0' | Semantic version of the MCP server. |
apiKey | string | null | null | Bearer token for authentication. If set, all requests must include Authorization: Bearer <apiKey>. |
exclude | (string | RegExp)[] | [] | Method name patterns to exclude in 'all' mode. Supports exact strings and RegExp. |
rest | object | boolean | false | REST API configuration (see below). Pass true to enable with defaults. |
auth | boolean | object | false | Session auth for signed-in users: true (accounts-base login tokens) or { resolveUser(req) } (see Authentication). |
mcp | object | boolean | { enabled: true } | MCP transport. { enabled: false } mounts no MCP server (REST-only). |
onBeforeInvoke | function | null | async ({ req, methodName, userId, auth }) called before every invocation; throw a Meteor.Error to reject. |
context | object | {} | App-provided context object passed to every plugin's start(api) (see Plugins). |
REST Options (options.rest)
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable the REST API (opt-in). |
path | string | '/api' | Base path for REST endpoints. |
docs | boolean | true | Serve Swagger UI at <path>/docs. |
apiKey | string | null | (inherits from parent) | Override the API key for REST only. |
Default Exclusions (All Mode)
In 'all' mode, the following methods are always excluded automatically:
- Methods starting with
/(DDP internal) - Methods starting with
_(private convention) - Accounts methods:
login,logout,getNewToken,removeOtherTokens,configureLoginService,changePassword,forgotPassword,resetPassword,verifyEmail,createUser,ATRemoveToken,ATCreateUserServer
Use the exclude option to add your own patterns on top of these defaults.
Exposing Methods
Wormhole.expose(methodName, options)
Explicitly register a Meteor method as an MCP tool. Required in 'opt-in' mode, but also works in 'all' mode to enrich auto-registered methods with descriptions and schemas.
1Wormhole.expose('todos.add', { 2 description: 'Add a new todo item to the list', 3 inputSchema: { 4 type: 'object', 5 properties: { 6 title: { type: 'string', description: 'The todo title' }, 7 priority: { 8 type: 'number', 9 description: 'Priority level (1=low, 5=high)', 10 }, 11 }, 12 required: ['title'], 13 }, 14 outputSchema: { 15 type: 'object', 16 properties: { 17 _id: { type: 'string' }, 18 title: { type: 'string' }, 19 done: { type: 'boolean' }, 20 }, 21 }, 22});
Parameters
| Parameter | Type | Description |
|---|---|---|
methodName | string | The exact Meteor method name (e.g., 'todos.add'). |
options.description | string | Human-readable description. Used in MCP tool listings and OpenAPI spec. |
options.inputSchema | object | JSON Schema defining expected input parameters. |
options.outputSchema | object | JSON Schema for the return value. Used in OpenAPI spec. REST wraps this in { result: <value> }. |
Wormhole.unexpose(methodName)
Remove a method from MCP/REST exposure.
1Wormhole.unexpose('todos.add'); // returns true if it was registered
Tool Name Mapping
Method names are sanitized for MCP and REST: special characters (., -, /) are replaced with underscores.
| Meteor Method | MCP Tool / REST Route |
|---|---|
todos.add | todos_add |
user-service.getUser | user_service_getUser |
MCP Transport
The MCP server uses Streamable HTTP transport, mounted at the configured path (default /mcp).
Connecting an MCP Client
Configure your MCP client (Claude Desktop, VS Code, etc.) to connect:
1{ 2 "mcpServers": { 3 "my-meteor-app": { 4 "type": "streamablehttp", 5 "url": "http://localhost:3000/mcp" 6 } 7 } 8}
With API key authentication:
1{ 2 "mcpServers": { 3 "my-meteor-app": { 4 "type": "streamablehttp", 5 "url": "http://localhost:3000/mcp", 6 "headers": { 7 "Authorization": "Bearer my-secret-key" 8 } 9 } 10 } 11}
How It Works
- POST
/mcp— Send JSON-RPC 2.0 requests. First request creates a session; subsequent requests include themcp-session-idheader. - GET
/mcp— Long-poll/stream events for an existing session. - DELETE
/mcp— Close a session.
Tool Invocation
When an MCP client calls a tool:
- With
inputSchema: The entire params object is passed as a single argument to the Meteor method. - Without
inputSchema: Theargsarray is spread as positional arguments.
Errors from Meteor methods are returned as MCP error content:
1{ 2 "content": [{ "type": "text", "text": "not-found: Todo not found" }], 3 "isError": true 4}
REST API
Enable the REST API to call exposed methods via standard HTTP.
1Wormhole.init({ 2 mode: 'all', 3 rest: { enabled: true }, 4});
Endpoints
| Method | Path | Description | Auth Required |
|---|---|---|---|
POST | /api/<tool_name> | Invoke a method | Yes (if apiKey set) |
GET | /api/ | List all endpoints | Yes (if apiKey set) |
GET | /api/openapi.json | OpenAPI 3.1 spec | No |
GET | /api/docs | Swagger UI | No |
Calling a Method
curl -X POST http://localhost:3000/api/todos_add \ -H 'Content-Type: application/json' \ -d '{"title": "Buy milk", "priority": 3}'
Success response:
1{ 2 "result": { "_id": "abc123", "title": "Buy milk", "done": false } 3}
Error response:
1{ 2 "error": "not-found", 3 "reason": "Todo not found", 4 "message": "Todo not found [not-found]" 5}
Request Limits
- Max body size: 1 MB
- Content-Type: Must be
application/json
Plugins
The built-in MCP and REST bridges cover most cases, but some apps need their own transport (e.g. a custom Fastify server) or a bespoke HTTP endpoint (e.g. a file ingest route) — and they often need app-specific dependencies inside those endpoints, like "who is the current user", an audit logger, or a storage path.
The plugin host lets you add those without forking the package. Register a
plugin with Wormhole.use() and it receives a small, stable API plus any
app-provided context.
Wormhole.use(plugin)
Register a plugin. May be called before or after Wormhole.init() — plugins
registered after init start immediately.
1import { Wormhole } from 'meteor/wreiske:meteor-wormhole'; 2 3const myTransport = { 4 name: 'my-transport', // unique; duplicate names throw 5 start(api) { 6 // Mount a connect-style handler on Meteor's web server. 7 api.mount('/my-endpoint', (req, res, next) => { 8 const user = api.context.resolveUser?.(req); 9 res.end(JSON.stringify({ exposed: api.registry.names(), user: user?._id })); 10 }); 11 }, 12 stop() { 13 // Optional: clean up when Wormhole is reset/destroyed. 14 }, 15}; 16 17Wormhole.init({ 18 mode: 'opt-in', 19 context: { 20 resolveUser: (req) => /* ...resolve from headers... */ null, 21 audit: (action, meta) => AuditLog.record(action, meta), 22 storagePath: process.env.STORAGE_PATH, 23 }, 24}); 25 26Wormhole.use(myTransport);
Plugin shape
1interface WormholePlugin<TContext = Record<string, unknown>> { 2 name: string; 3 start(api: WormholePluginApi<TContext>): void | Promise<void>; 4 stop?(): void | Promise<void>; 5}
The api passed to start()
| Property | Description |
|---|---|
api.registry | The method registry — inspect which methods are exposed. |
api.options | The resolved Wormhole.init() options. |
api.context | The app-provided context object from init(). |
api.mount(path, fn) | Mount a connect-style (req, res, next) handler on the web server. |
Lifecycle & error handling
- Plugins start in registration order; a plugin that throws (or rejects) during
start()is logged and skipped without taking down the others. - On
Wormhole._reset()the host calls each plugin'sstop()in reverse order, awaiting each one (and anystart()still in flight) before the next. Host state is cleared synchronously soinit()can run again immediately;_reset()returns a promise that settles when everystop()has finished. - A plugin whose async
start()resolves after the host stopped is stopped again right away, and its staleapi.mount()throws instead of leaking a handler into the next generation. - Registering two plugins with the same
namethrows.
sanitizeToolName(name)
Exported helper that maps a Meteor method name to the same URL/tool-safe name the
built-in bridges use (e.g. Listen.End → Listen_End). Useful when your custom
transport needs to route to exposed methods.
1import { sanitizeToolName } from 'meteor/wreiske:meteor-wormhole'; 2 3sanitizeToolName('todos.add'); // 'todos_add'
Authentication
Wormhole supports two kinds of callers, both sent as Authorization: Bearer <token>:
| Caller | Token | Enabled by | this.userId |
|---|---|---|---|
| Service / agent | the configured apiKey | apiKey: '...' | null |
| Signed-in user | a Meteor login token | auth: true | the user id |
API key (agents)
When apiKey is set, all MCP and REST requests (except Swagger UI and the OpenAPI spec) require it:
curl -H "Authorization: Bearer my-secret-key" http://localhost:3000/mcp
Session auth (mobile apps, SPAs, authenticated clients)
Enable auth to let clients call methods as a signed-in user. The REST bridge resolves the
Bearer token to a user id and invokes the method with this.userId set, exactly as a DDP
client would. this.connection.clientAddress and this.connection.httpHeaders are populated
from the HTTP request.
1Wormhole.init({ 2 mode: 'opt-in', 3 mcp: { enabled: false }, // REST-only app API 4 auth: true, // accepts Meteor login tokens (accounts-base) 5 rest: { enabled: true, path: '/api/app', spec: 'auth' }, 6}); 7 8Wormhole.expose('login.withGoogle', { 9 auth: 'none', 10 inputSchema: { 11 /* ... */ 12 }, 13}); 14Wormhole.expose('todos.add', { 15 auth: 'required', 16 inputSchema: { 17 /* ... */ 18 }, 19}); 20Wormhole.expose('feed.list', { auth: 'optional' }); // default
auth: true uses accounts-base: the token is hashed with Accounts._hashLoginToken and looked
up in services.resume.loginTokens; expired tokens are rejected. Issue tokens from your own login
method with Accounts._generateStampedLoginToken() + Accounts._insertLoginToken(), or let a web
client reuse Accounts._storedLoginToken().
To plug in a different scheme (JWTs, your own session store), pass a resolver instead:
1Wormhole.init({ 2 auth: { 3 resolveUser: async (req) => { 4 const token = (req.headers.authorization || '').replace(/^Bearer /, ''); 5 return (await Sessions.findOneAsync({ token }))?.userId ?? null; 6 }, 7 }, 8});
Per-method policy (expose(name, { auth }))
| Policy | Behaviour |
|---|---|
'optional' | Default. Invoked with the caller's user id when a valid token is present, otherwise null. |
'required' | 401 unless a valid user token is presented (the service apiKey does not count). |
'none' | Always invoked anonymously and exempt from the apiKey gate (use for login methods). |
An invalid or expired Bearer token is always 401, whatever the policy. When apiKey is set,
anonymous callers are rejected on 'optional' and 'required' methods.
Hooks
onBeforeInvoke runs before every REST and MCP invocation. Throw a Meteor.Error to reject,
for example to rate limit login attempts per IP:
1Wormhole.init({ 2 onBeforeInvoke: async ({ req, methodName, userId, auth }) => { 3 if (methodName.startsWith('login.') && (await tooManyAttempts(req))) { 4 throw new Meteor.Error('rate-limited', 'Too many attempts'); // → HTTP 429 5 } 6 }, 7});
Wormhole.resolveAuth(req) returns { kind, userId, token } for any request, so app-owned
HTTP routes can share the same credential rules. Plugins receive the same resolver as
api.auth.resolve(req).
HTTP status mapping
REST responses map thrown errors to statuses instead of a blanket 500:
| Thrown | Status |
|---|---|
Match.Error (from check) | 400 |
Meteor.Error(404, ...) (any numeric 4xx/5xx code) | that code |
not-authorized, forbidden | 403 |
unauthorized, not-logged-in, *-login-failed, invalid-code, session-expired | 401 |
not-found, no-such-* | 404 |
already-exists, account-exists | 409 |
rate-limited, too-many-requests | 429 |
any other Meteor.Error | 400 |
| any other error | 500 (message hidden in production) |
The body is { error, message }; reason is added for Meteor.Error / Match.Error responses and
details when the thrown Meteor.Error carried one. Generic 500 and credential rejections
carry only error and message.
Disabling the MCP transport
Wormhole.init({ mcp: { enabled: false } }) mounts no MCP server; only the REST bridge (and
your plugins) are served. Without this, an MCP server with neither apiKey nor auth is
reachable anonymously and Wormhole logs a warning at startup.
OpenAPI spec visibility
rest.spec controls <path>/openapi.json: true (default), false, or 'auth' to require
an API key or user token. Each operation carries x-meteor-method and x-wormhole-auth, and
security reflects the method's policy, so generated clients know which calls need a token.
The spec declares two bearer schemes: bearerAuth (the service API key) and sessionAuth (a
Meteor login token). auth: 'required' operations reference only sessionAuth, because an API
key is rejected there with 401.
Client address behind proxies
this.connection.clientAddress follows Meteor's own trust model: set HTTP_FORWARDED_COUNT to
the number of proxies in front of the app and the matching X-Forwarded-For entry is used;
without it the socket address is reported. Proxy headers are never trusted blindly, so an
IP-based limiter in onBeforeInvoke cannot be bypassed by a spoofed header.
Additional API
Wormhole.registry
Read-only access to the internal method registry.
1Wormhole.registry.names(); // ['todos.add', 'todos.list', ...] 2Wormhole.registry.size(); // 5 3Wormhole.registry.has('todos.add'); // true 4Wormhole.registry.get('todos.add'); 5// { description: '...', inputSchema: {...}, outputSchema: {...}, registeredAt: 1709... }
Wormhole.initialized
boolean — Whether Wormhole.init() has been called.
Wormhole.options
Returns a copy of the resolved configuration options.
Wormhole.use(plugin)
Register a Wormhole plugin (custom transport or HTTP endpoint). See Plugins.
generateOpenApiSpec(registry, options)
Generate an OpenAPI 3.1.0 spec object from a registry. Exported for advanced use cases.
1import { generateOpenApiSpec } from 'meteor/wreiske:meteor-wormhole'; 2 3const spec = generateOpenApiSpec(Wormhole.registry, { 4 name: 'My API', 5 version: '2.0.0', 6 restPath: '/api', 7 apiKey: 'secret', 8 description: 'My custom API', 9});
Full Example
1import { Meteor } from 'meteor/meteor'; 2import { Wormhole } from 'meteor/wreiske:meteor-wormhole'; 3 4// Initialize with all features 5Wormhole.init({ 6 mode: 'all', 7 path: '/mcp', 8 name: 'my-app', 9 version: '1.0.0', 10 apiKey: process.env.MCP_API_KEY || null, 11 exclude: [/^admin\./], 12 rest: { 13 enabled: true, 14 path: '/api', 15 docs: true, 16 }, 17}); 18 19// Enrich specific methods with schemas 20Wormhole.expose('todos.add', { 21 description: 'Add a new todo item', 22 inputSchema: { 23 type: 'object', 24 properties: { 25 title: { type: 'string' }, 26 }, 27 required: ['title'], 28 }, 29 outputSchema: { 30 type: 'object', 31 properties: { 32 _id: { type: 'string' }, 33 title: { type: 'string' }, 34 done: { type: 'boolean' }, 35 }, 36 }, 37}); 38 39// Define the actual Meteor method 40Meteor.methods({ 41 'todos.add'({ title }) { 42 const todoId = TodosCollection.insertAsync({ title, done: false }); 43 return TodosCollection.findOneAsync(todoId); 44 }, 45});
Now available:
- MCP:
http://localhost:3000/mcp— AI agents can discover and calltodos_add - REST:
POST http://localhost:3000/api/todos_add— HTTP clients can call the method - Docs:
http://localhost:3000/api/docs— Interactive Swagger UI - Spec:
http://localhost:3000/api/openapi.json— OpenAPI 3.1 spec
Testing
# Run package unit tests (Tinytest) cd apps/test-app && meteor test-packages ../../packages/meteor-wormhole # Run MCP integration test client meteor npm run test:client
License
MIT