wreiske:meteor-wormhole

v0.3.0•Published 2 weeks ago

meteor-wormhole

A cosmic bridge connecting Meteor methods to AI agents through MCP

Meteor Package

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.userId just 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

OptionTypeDefaultDescription
mode'all' | 'opt-in''all''all' auto-registers all methods. 'opt-in' requires explicit Wormhole.expose() calls.
pathstring'/mcp'HTTP path where the MCP server listens.
namestring'meteor-wormhole'Display name for the MCP server.
versionstring'1.0.0'Semantic version of the MCP server.
apiKeystring | nullnullBearer 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.
restobject | booleanfalseREST API configuration (see below). Pass true to enable with defaults.
authboolean | objectfalseSession auth for signed-in users: true (accounts-base login tokens) or { resolveUser(req) } (see Authentication).
mcpobject | boolean{ enabled: true }MCP transport. { enabled: false } mounts no MCP server (REST-only).
onBeforeInvokefunctionnullasync ({ req, methodName, userId, auth }) called before every invocation; throw a Meteor.Error to reject.
contextobject{}App-provided context object passed to every plugin's start(api) (see Plugins).

REST Options (options.rest)

OptionTypeDefaultDescription
enabledbooleanfalseEnable the REST API (opt-in).
pathstring'/api'Base path for REST endpoints.
docsbooleantrueServe Swagger UI at <path>/docs.
apiKeystring | 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

ParameterTypeDescription
methodNamestringThe exact Meteor method name (e.g., 'todos.add').
options.descriptionstringHuman-readable description. Used in MCP tool listings and OpenAPI spec.
options.inputSchemaobjectJSON Schema defining expected input parameters.
options.outputSchemaobjectJSON 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 MethodMCP Tool / REST Route
todos.addtodos_add
user-service.getUseruser_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 the mcp-session-id header.
  • 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: The args array 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

MethodPathDescriptionAuth Required
POST/api/<tool_name>Invoke a methodYes (if apiKey set)
GET/api/List all endpointsYes (if apiKey set)
GET/api/openapi.jsonOpenAPI 3.1 specNo
GET/api/docsSwagger UINo

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()

PropertyDescription
api.registryThe method registry — inspect which methods are exposed.
api.optionsThe resolved Wormhole.init() options.
api.contextThe 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's stop() in reverse order, awaiting each one (and any start() still in flight) before the next. Host state is cleared synchronously so init() can run again immediately; _reset() returns a promise that settles when every stop() has finished.
  • A plugin whose async start() resolves after the host stopped is stopped again right away, and its stale api.mount() throws instead of leaking a handler into the next generation.
  • Registering two plugins with the same name throws.

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>:

CallerTokenEnabled bythis.userId
Service / agentthe configured apiKeyapiKey: '...'null
Signed-in usera Meteor login tokenauth: truethe 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 }))

PolicyBehaviour
'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:

ThrownStatus
Match.Error (from check)400
Meteor.Error(404, ...) (any numeric 4xx/5xx code)that code
not-authorized, forbidden403
unauthorized, not-logged-in, *-login-failed, invalid-code, session-expired401
not-found, no-such-*404
already-exists, account-exists409
rate-limited, too-many-requests429
any other Meteor.Error400
any other error500 (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 call todos_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