Skip to content

Custom Plugins ​

metro-mcp is plugin-based. You can extend it with your own MCP tools, resources, and prompts — either as local files or published npm packages.

Quickstart ​

Scaffold a new plugin package interactively:

bash
bunx metro-mcp create-plugin

This prompts for a name, description, and author, then generates a ready-to-use package in a new directory (metro-mcp-plugin-<name>/) with a hello-world plugin, build config, README, and LICENSE.

✓ Plugin metro-mcp-plugin-my-plugin ready!

Next steps:
  cd metro-mcp-plugin-my-plugin
  bun run build

Plugin structure ​

A plugin is a definePlugin() call with a name and an async setup function:

typescript
import { definePlugin } from 'metro-mcp';
import { z } from 'zod';

export default definePlugin({
  name: 'my-plugin',
  version: '1.0.0',
  description: 'Does useful things',

  async setup(ctx) {
    ctx.registerTool('my_tool', {
      description: 'Does something useful',
      parameters: z.object({
        input: z.string().describe('Input value'),
      }),
      handler: async ({ input }) => {
        return `You said: ${input}`;
      },
    });
  },
});

The setup function receives a PluginContext and is called once when the server starts. Register all your tools, resources, and prompts here.

Registering tools ​

Tools are the primary way to expose functionality to AI clients.

typescript
ctx.registerTool('evaluate_expression', {
  description: 'Evaluate a JS expression in the running app',
  parameters: z.object({
    expression: z.string().describe('JavaScript expression'),
    awaitPromise: z.boolean().default(true),
  }),
  annotations: {
    readOnlyHint: false,
    destructiveHint: true,
  },
  handler: async ({ expression, awaitPromise }, toolCtx) => {
    const result = await ctx.evalInApp(expression, { awaitPromise });
    return result ?? 'undefined';
  },
});

Tool annotations ​

Annotations are hints for AI clients about how the tool behaves. They help clients decide when to auto-approve tool calls.

AnnotationTypeDescription
titlestringHuman-readable display name
readOnlyHintbooleanTool does not modify state (safe to auto-approve)
destructiveHintbooleanTool may perform irreversible actions
idempotentHintbooleanCalling multiple times with same args has no extra effect
openWorldHintbooleanTool may interact with external systems

Progress reporting ​

The handler receives a second ctx argument with an optional sendProgress helper for long-running operations:

typescript
handler: async ({ items }, { sendProgress }) => {
  for (let i = 0; i < items.length; i++) {
    await processItem(items[i]);
    await sendProgress?.(i + 1, items.length, `Processing ${items[i]}`);
  }
  return 'Done';
},

Native MCP tool results ​

Ordinary objects returned by handlers are JSON-serialized into a text content block. To deliberately return native MCP content such as an image, validate and mark the result with nativeToolResult():

typescript
import { definePlugin, nativeToolResult } from 'metro-mcp';

handler: async () => nativeToolResult({
  content: [{
    type: 'image',
    data: pngBuffer.toString('base64'),
    mimeType: 'image/png',
  }],
}),

The explicit helper prevents an existing plugin object that merely has a content property from being reinterpreted as MCP protocol output.

Registering resources ​

Resources expose readable data to AI clients (component trees, logs, state snapshots, etc.).

typescript
ctx.registerResource('metro://my-plugin/state', {
  name: 'App State',
  description: 'Current application state snapshot',
  mimeType: 'application/json',  // defaults to 'application/json'
  handler: async () => {
    const state = await ctx.evalInApp('JSON.stringify(global.__REDUX_STORE__?.getState())');
    return state as string;
  },
  onSubscribe: (uri) => {
    ctx.logger.info(`Client subscribed to ${uri}`);
  },
  onUnsubscribe: (uri) => {
    ctx.logger.info(`Client unsubscribed from ${uri}`);
  },
});

Push updates ​

When your resource data changes, notify subscribed clients:

typescript
async setup(ctx) {
  // Listen for CDP events and push updates
  ctx.cdp.on('Runtime.consoleAPICalled', () => {
    ctx.notifyResourceUpdated('metro://my-plugin/logs');
  });

  ctx.registerResource('metro://my-plugin/logs', {
    name: 'Plugin Logs',
    description: 'Streaming log output',
    handler: async () => JSON.stringify(logBuffer),
  });
},

Registering prompts ​

Prompts are pre-built message templates that AI clients can invoke.

typescript
ctx.registerPrompt('debug_crash', {
  description: 'Gather context for a crash report',
  arguments: [
    { name: 'error', description: 'The error message', required: true },
  ],
  handler: async ({ error }) => [
    {
      role: 'user',
      content: `The app crashed with: ${error}\n\nPlease check the recent logs and component tree to diagnose the issue.`,
    },
  ],
});

Plugin context API ​

The ctx object passed to setup provides everything you need to interact with the app:

ctx.cdp — Chrome DevTools Protocol ​

Send CDP commands and listen to CDP events directly:

typescript
// Send a command
const result = await ctx.cdp.send('Runtime.evaluate', {
  expression: 'window.__MY_GLOBAL__',
  returnByValue: true,
});

// Listen for events
ctx.cdp.on('Runtime.consoleAPICalled', (params) => {
  console.log('Console event:', params);
});

// Check connection status
if (ctx.cdp.isConnected) {
  const target = ctx.cdp.getTarget(); // MetroTarget | null
}

ctx.events — Metro bundler events ​

Listen to Metro build events over WebSocket:

typescript
ctx.events.on('bundling_error', (event) => {
  ctx.logger.error('Bundle error:', event);
});

ctx.events.on('bundle_transform_progressed', (event) => {
  // Build progress
});

ctx.evalInApp — Evaluate JavaScript ​

Run JavaScript in the connected app and return the result:

typescript
const value = await ctx.evalInApp('global.__MY_STORE__.getState()');

// Await promises returned by the expression
const data = await ctx.evalInApp('fetch("/api/data").then(r => r.json())', {
  awaitPromise: true,
  timeout: 15000,
});

ctx.metro — Metro HTTP access ​

Make HTTP requests to the Metro bundler:

typescript
const { host, port } = ctx.metro;
const response = await ctx.metro.fetch('/status');
const data = await response.json();

ctx.exec — Shell commands ​

Run shell commands and capture output:

typescript
const devices = await ctx.exec('xcrun simctl list devices --json');
const parsed = JSON.parse(devices);

Use ctx.execFile() when arguments must be passed literally or stdout is binary. It does not invoke a shell and returns a Buffer:

typescript
const png = await ctx.execFile('adb', ['exec-out', 'screencap', '-p'], {
  maxBuffer: 64 * 1024 * 1024,
});

ctx.format — Formatting helpers ​

Token-efficient helpers for formatting data to return to AI clients:

MethodDescription
format.summarize(items, lastN?)"47 items. Last 5: ..." — avoids flooding context with large arrays
format.compact(obj)"key=value key2=value2" — flat key-value string
format.truncate(str, maxLen)Truncate with ...
format.structureOnly(tree)Strip props/state from a component tree, keep structure

ctx.logger — Plugin logger ​

Prefixed logger for your plugin's output:

typescript
ctx.logger.info('Plugin ready');
ctx.logger.warn('Something unexpected');
ctx.logger.error('Failed to connect', err);
ctx.logger.debug('Raw CDP response', data);  // Only shown with DEBUG=1

Device helpers ​

typescript
// Returns a stable key for the active device, e.g. "8081-abc123"
const key = ctx.getActiveDeviceKey();

// Returns a human-readable name, e.g. "iPhone 16 Pro Simulator"
const name = ctx.getActiveDeviceName();

Loading plugins ​

Via config file ​

typescript
// metro-mcp.config.ts
import { defineConfig } from 'metro-mcp';

export default defineConfig({
  plugins: [
    'metro-mcp-plugin-my-plugin',  // npm package
    './local-plugin.ts',           // local file (relative to config)
  ],
});

Via CLI flag ​

bash
bunx metro-mcp --plugin ./my-plugin.ts

Repeatable — pass --plugin multiple times to load several plugins.

Via environment variable ​

bash
METRO_MCP_PLUGINS=./plugin-a.ts:metro-mcp-plugin-foo bunx metro-mcp

Plugins from CLI flags and env vars are appended after any plugins in the config file.

In an MCP client config ​

json
{
  "mcpServers": {
    "metro-mcp": {
      "command": "bunx",
      "args": ["metro-mcp", "--plugin", "./my-plugin.ts"]
    }
  }
}

Package naming ​

npm packages must follow the metro-mcp-plugin-* naming convention. When a plugin path in your config doesn't start with . or /, metro-mcp resolves it as an npm package name.

typescript
plugins: [
  'metro-mcp-plugin-mmkv',    // resolves from node_modules
  './local-plugin.ts',        // resolves as a local file
]

Publishing to npm ​

The create-plugin scaffold generates a package ready to publish:

bash
# Build the plugin
bun run build

# Publish to npm
npm publish

The generated package.json lists metro-mcp and zod as peerDependencies — users provide them through their metro-mcp installation, so there's no version conflict.

MCP Apps — interactive UIs ​

MCP Apps lets tools return interactive HTML UIs that render inside a sandboxed iframe in MCP-capable hosts (Claude Desktop, VS Code Copilot, Goose, and others). Instead of returning text for the AI to summarize, the tool opens a live dashboard the user can filter, explore, and interact with directly.

metro-mcp exposes two primitives for this:

APIPurpose
ctx.registerAppResource(uri, config)Register an HTML page at a ui:// URI
appUri in registerToolLink a tool definition to an app resource

How it works ​

  1. You register an HTML resource at a ui://my-plugin/dashboard URI
  2. You add appUri: 'ui://my-plugin/dashboard' to a tool
  3. When an MCP Apps host invokes the tool, it fetches the ui:// resource and renders it in a sandboxed iframe
  4. The iframe communicates with the host via a postMessage JSON-RPC bridge — receiving the tool's result and calling other tools for live updates

Non-MCP-Apps hosts (CLI, simple clients) ignore appUri and display the tool's text result normally — fully backward-compatible.

Registering an app resource ​

typescript
ctx.registerAppResource('ui://my-plugin/dashboard', {
  name: 'My Dashboard',
  description: 'Interactive data viewer',
  minHeight: 560, // Optional; defaults to 420px
  handler: async () => MY_HTML,
});

The HTML is served with MIME type text/html;profile=mcp-app automatically. metro-mcp also gives app iframes a default minimum height of 420px, advertises that height through _meta["mcpui.dev/ui-preferred-frame-size"], and sends resize notifications so hosts do not collapse the frame before content renders.

The URI must start with ui://. Use ui://your-plugin-name/... as a namespace to avoid collisions with built-in apps (ui://metro/...).

Linking a tool to an app ​

typescript
ctx.registerTool('get_my_data', {
  description: 'Get data with a visual dashboard',
  parameters: z.object({}),
  appUri: 'ui://my-plugin/dashboard',  // ← that's all
  handler: async () => fetchMyData(),
});

metro-mcp automatically exposes _meta.ui.resourceUri on the tool definition returned by tools/list — you don't touch _meta in your handler.

The postMessage bridge ​

The iframe communicates with the host via JSON-RPC over postMessage. Copy this bootstrap snippet into your app's <script> tag — it exposes a mcpBridge global:

html
<script>
(function() {
  var pending = new Map(), handlers = new Map(), nextId = 1;
  function send(msg) { window.parent.postMessage(msg, '*'); }
  function applyHostContext(params) {
    var styleVariables = params && params.styles && params.styles.variables;
    var legacyVariables = params && params.cssVariables;
    var variables = styleVariables || legacyVariables;
    if (variables && typeof variables === 'object') {
      Object.keys(variables).forEach(function(k) {
        if (document.documentElement.style.getPropertyValue(k) !== variables[k]) {
          document.documentElement.style.setProperty(k, variables[k]);
        }
      });
    }
    if (params && params.theme && document.documentElement.dataset.theme !== params.theme) {
      document.documentElement.dataset.theme = params.theme;
    }
  }
  window.addEventListener('message', function(e) {
    var msg; try { msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; } catch { return; }
    if (!msg || msg.jsonrpc !== '2.0') return;
    if (msg.id != null && pending.has(msg.id)) {
      var cb = pending.get(msg.id); pending.delete(msg.id);
      msg.error ? cb.reject(new Error(msg.error.message)) : cb.resolve(msg.result);
      return;
    }
    if (msg.method) {
      if (msg.method === 'ui/notifications/host-context-changed') {
        applyHostContext(msg.params || {});
      }
      var h = handlers.get(msg.method); if (h) h(msg.params || {});
    }
  });
  window.mcpBridge = {
    initialize: function() {
      return new Promise(function(resolve, reject) {
        var id = nextId++; pending.set(id, { resolve, reject });
        send({ jsonrpc: '2.0', id, method: 'ui/initialize',
          params: { appInfo: { name: 'my-app', version: '1.0' }, appCapabilities: {}, protocolVersion: '2026-01-26' } });
      }).then(function(result) {
        if (result && result.hostContext) applyHostContext(result.hostContext);
        send({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
        return result;
      });
    },
    call: function(method, params) {
      return new Promise(function(resolve, reject) {
        var id = nextId++; pending.set(id, { resolve, reject });
        send({ jsonrpc: '2.0', id, method, params: params || {} });
      });
    },
    on: function(method, handler) { handlers.set(method, handler); }
  };
})();
</script>
MethodDescription
mcpBridge.initialize()Handshake with host (call first, returns Promise<void>)
mcpBridge.call(method, params)JSON-RPC call — returns Promise<result>
mcpBridge.on(method, handler)Listen for host notifications

Calling tools and resources from the app ​

javascript
// Call a tool (re-fetch data)
mcpBridge.call('tools/call', { name: 'get_my_data', arguments: { limit: 50 } })
  .then(function(result) {
    var text = result.content[0].text;
    // render...
  });

// Read a resource
mcpBridge.call('resources/read', { uri: 'metro://profiler/data' })
  .then(function(result) {
    var json = JSON.parse(result.contents[0].text);
    // render...
  });

Receiving the tool result automatically ​

The host sends ui/notifications/tool-result to the iframe when the linked tool finishes:

javascript
mcpBridge.on('ui/notifications/tool-result', function(params) {
  var text = params.result.content[0].text;
  // render the initial data without calling tools/call
});

Theming ​

The host sends CSS variables via ui/notifications/host-context-changed. The bridge bootstrap applies them to :root automatically. Use CSS custom properties with safe defaults:

css
body {
  background: var(--bg, #0d0d0d);
  color: var(--text, #e8e8e8);
  font-family: var(--font, -apple-system, sans-serif);
}

Security ​

MCP App iframes run in a strict sandbox:

  • No external network access by default — CSP blocks all external connections. Your HTML must be fully self-contained (no CDN links).
  • No parent page access — the iframe cannot read cookies, localStorage, or the parent DOM.
  • External access requires declaring permissions explicitly in your resource — most hosts will reject or warn.

Full example ​

typescript
import { definePlugin } from 'metro-mcp';
import { z } from 'zod';

const BRIDGE = `/* paste the bootstrap snippet above */`;

const MY_HTML = \`<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    body { font-family: var(--font, sans-serif); color: var(--text, #e8e8e8); padding: 16px; }
  </style>
</head>
<body>
  <h2>My Plugin</h2>
  <div id="root">Loading…</div>
  <script>
    \${BRIDGE}
    mcpBridge.initialize()
      .then(() => mcpBridge.call('tools/call', { name: 'my_tool', arguments: {} }))
      .then(function(result) {
        document.getElementById('root').textContent = result.content[0].text;
      });
  <\/script>
</body>
</html>\`;

export default definePlugin({
  name: 'my-plugin',
  async setup(ctx) {
    // 1. Register the HTML resource
    ctx.registerAppResource('ui://my-plugin/dashboard', {
      name: 'My Dashboard',
      description: 'Interactive data viewer',
      handler: async () => MY_HTML,
    });

    // 2. Link the tool — appUri is the only change needed
    ctx.registerTool('my_tool', {
      description: 'Returns data shown in the dashboard',
      parameters: z.object({}),
      appUri: 'ui://my-plugin/dashboard',
      handler: async () => {
        const data = await ctx.evalInApp('JSON.stringify(global.__MY_DATA__)');
        return data || '{}';
      },
    });
  },
});

Validating a plugin ​

Use the validate-plugin CLI command to check that your plugin file exports a valid PluginDefinition before loading it into the server:

bash
bunx metro-mcp validate-plugin ./src/index.ts

Released under the MIT License.