MCP Channels
The @coralstack/cmd-ipc-mcp package provides MCP channel implementations that bridge Model Context Protocol (MCP) with cmd-ipc. It uses the official @modelcontextprotocol/sdk under the hood, delegating all protocol handling — JSON-RPC, sessions, streaming, and OAuth — to the SDK.
Use cases include:
- Exposing application commands as AI agent tools
- Connecting to remote MCP servers and using their tools as local commands
- Building AI-powered applications with tool calling
- Creating MCP-compatible APIs
Installation
Section titled “Installation”yarn add @coralstack/cmd-ipc-mcp @modelcontextprotocol/sdk@coralstack/cmd-ipc is a peer dependency and must also be installed.
Two Channel Types
Section titled “Two Channel Types”| Channel | Direction | Use Case |
|---|---|---|
| MCPServerChannel | cmd-ipc → MCP | Expose cmd-ipc commands as MCP tools for AI agents to discover and call |
| MCPClientChannel | MCP → cmd-ipc | Connect to remote MCP servers and use their tools as local commands |
Both channels accept an SDK Transport instance, which handles the underlying communication protocol (HTTP, stdio, WebSocket, etc.). This means you choose the transport separately — the channels focus purely on bridging cmd-ipc and MCP.
MCPServerChannel
Section titled “MCPServerChannel”MCPServerChannel exposes your cmd-ipc commands as MCP tools. When an MCP client calls tools/list, the channel queries the cmd-ipc registry and returns all registered commands. When tools/call is invoked, the channel routes the request through cmd-ipc for execution.
Configuration
Section titled “Configuration”import { MCPServerChannel } from '@coralstack/cmd-ipc-mcp'
const channel = new MCPServerChannel({ id: 'mcp-server', serverInfo: { name: 'my-app', version: '1.0.0', }, instructions: 'Optional instructions for the AI agent', timeout: 30000,})| Option | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | — | Unique channel identifier |
serverInfo | Implementation | No | { name: 'cmd-ipc', version: '1.0.0' } | Server name and version sent to MCP clients |
instructions | string | No | — | Instructions sent to MCP clients during initialization |
timeout | number | No | 30000 | Request timeout in milliseconds |
Connecting a Transport
Section titled “Connecting a Transport”After creating the channel and registering it with a CommandRegistry, connect an SDK transport using connectTransport():
await channel.connectTransport(transport)The transport handles all protocol details (HTTP, stdio, etc.). See the examples below for common setups.
Stdio Server
Section titled “Stdio Server”The simplest setup — ideal for MCP clients like Claude Desktop that communicate over stdio:
import { CommandRegistry, Command, registerCommands } from '@coralstack/cmd-ipc'import { MCPServerChannel } from '@coralstack/cmd-ipc-mcp'import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
class MathCommands { @Command('math.add', 'Add two numbers') add({ a, b }: { a: number; b: number }) { return a + b }}
const registry = new CommandRegistry({ id: 'server' })registerCommands([new MathCommands()], registry)
const channel = new MCPServerChannel({ id: 'mcp-server', serverInfo: { name: 'math-server', version: '1.0.0' },})await registry.registerChannel(channel)
const transport = new StdioServerTransport()await channel.connectTransport(transport)HTTP Server (Express)
Section titled “HTTP Server (Express)”For HTTP-based MCP servers, use the SDK’s StreamableHTTPServerTransport. Each transport instance handles one session, so you create a new transport (and channel) per session:
import express from 'express'import { randomUUID } from 'crypto'import { CommandRegistry, Command, registerCommands } from '@coralstack/cmd-ipc'import { MCPServerChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
// Define commandsclass MathCommands { @Command('math.add', 'Add two numbers') add({ a, b }: { a: number; b: number }) { return a + b }}
// Setup registryconst registry = new CommandRegistry({ id: 'server' })registerCommands([new MathCommands()], registry)
// Track active sessionsconst sessions = new Map<string, { transport: StreamableHTTPServerTransport channel: MCPServerChannel}>()
const app = express()app.use(express.json())
// Handle POST (initialize, tools/list, tools/call)app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined
// Route to existing session if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId)!.transport.handleRequest(req, res, req.body) return }
// New session const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), })
const channel = new MCPServerChannel({ id: `mcp-session-${randomUUID()}`, serverInfo: { name: 'math-server', version: '1.0.0' }, })
await registry.registerChannel(channel) await channel.connectTransport(transport)
transport.onSessionInitialized = (sid) => { sessions.set(sid, { transport, channel }) }
await transport.handleRequest(req, res, req.body)})
// Handle GET (SSE stream for server-initiated messages)app.get('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId)!.transport.handleRequest(req, res) } else { res.status(400).end('Session not found') }})
// Handle DELETE (terminate session)app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (sessionId && sessions.has(sessionId)) { const session = sessions.get(sessionId)! await session.transport.handleRequest(req, res) await session.channel.close() sessions.delete(sessionId) } else { res.status(400).end('Session not found') }})
app.listen(3000, () => console.log('MCP server running on port 3000'))Accessing the SDK McpServer
Section titled “Accessing the SDK McpServer”For advanced use cases, access the underlying SDK McpServer directly:
const mcpServer = channel.server // McpServer instance from @modelcontextprotocol/sdkMCPClientChannel
Section titled “MCPClientChannel”MCPClientChannel connects to a remote MCP server, discovers its tools, and registers them as cmd-ipc commands. Once connected, you can execute remote MCP tools using the cmd-ipc CommandRegistry like any other command.
Configuration
Section titled “Configuration”import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'
const channel = new MCPClientChannel({ id: 'mcp-client', transport, // SDK Transport instance commandPrefix: 'remote', // Optional: prefix for registered commands timeout: 30000, // Optional: request timeout in ms clientInfo: { // Optional: client name/version name: 'my-app', version: '1.0.0', },})| Option | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | — | Unique channel identifier |
transport | Transport | Yes | — | SDK Transport instance (e.g., StreamableHTTPClientTransport, StdioClientTransport) |
commandPrefix | string | No | — | Prefix added to registered command IDs (e.g., remote.toolName) |
timeout | number | No | 30000 | Request timeout in milliseconds |
clientInfo | Implementation | No | { name: 'cmd-ipc', version: '1.0.0' } | Client name and version sent to MCP server |
Command Prefix
Section titled “Command Prefix”The commandPrefix option helps organize commands from multiple MCP servers:
// Without prefix: remote tool 'calculator' → command 'calculator'// With prefix 'stripe': remote tool 'create_invoice' → command 'stripe.create_invoice'
const channel = new MCPClientChannel({ id: 'stripe-mcp', transport, commandPrefix: 'stripe',})
await registry.registerChannel(channel)await registry.executeCommand('stripe.create_invoice', { amount: 1000 })Startup Flow
Section titled “Startup Flow”When the channel is registered (which calls start()):
- Creates an SDK
Clientand connects via the provided transport - The SDK performs the MCP handshake (
initialize/initialized) - Calls
client.listTools()to discover available tools - Emits
register.command.requestfor each tool (with optional prefix)
After startup, all discovered tools are available as commands in the registry.
Connecting Without OAuth
Section titled “Connecting Without OAuth”For MCP servers that don’t require authentication:
import { CommandRegistry } from '@coralstack/cmd-ipc'import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const registry = new CommandRegistry({ id: 'client' })
const transport = new StreamableHTTPClientTransport( new URL('https://mcp.example.com/mcp'))
const channel = new MCPClientChannel({ id: 'example-mcp', transport, commandPrefix: 'example',})
await registry.registerChannel(channel)
// Execute remote tools as local commandsconst result = await registry.executeCommand('example.some-tool', { input: 'value' })import { CommandRegistry } from '@coralstack/cmd-ipc'import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const registry = new CommandRegistry({ id: 'client' })
const transport = new StdioClientTransport({ command: 'npx', args: ['-y', '@some-org/mcp-server'],})
const channel = new MCPClientChannel({ id: 'local-mcp', transport, commandPrefix: 'local',})
await registry.registerChannel(channel)
const result = await registry.executeCommand('local.some-tool', { input: 'value' })Accessing Server Information
Section titled “Accessing Server Information”After connection, you can inspect the remote server:
await registry.registerChannel(channel)
console.log(channel.serverInfo) // { name: 'server-name', version: '1.0.0' }console.log(channel.serverCapabilities) // { tools: { listChanged: true } }console.log(channel.client) // SDK Client instance (for advanced usage)OAuth Authentication
Section titled “OAuth Authentication”OAuth is handled at the transport level by the SDK — not by the cmd-ipc channels. When an MCP server requires authentication, you configure an authProvider on the StreamableHTTPClientTransport. The SDK handles the full OAuth 2.1 flow: metadata discovery, dynamic client registration, PKCE, token exchange, and refresh.
How OAuth Works
Section titled “How OAuth Works”- The transport sends a request to the MCP server
- The server responds with
401 Unauthorized - The SDK discovers the authorization server via Protected Resource Metadata (RFC 9728)
- The
authProvider.redirectToAuthorization()callback is called with the authorization URL - After user authorization, the code is exchanged for tokens
- The transport retries the original request with the access token
Node.js OAuth Example
Section titled “Node.js OAuth Example”For Node.js applications, implement the SDK’s OAuthClientProvider interface:
import { CommandRegistry } from '@coralstack/cmd-ipc'import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
// Implement the SDK's OAuthClientProvider interfaceconst authProvider: OAuthClientProvider = { get redirectUrl() { return 'http://localhost:3000/oauth/callback' },
get clientMetadata() { return { client_name: 'my-app', redirect_uris: ['http://localhost:3000/oauth/callback'], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', } },
// Store and retrieve client registration info clientInformation: async () => loadFromStorage('client-info'), saveClientInformation: async (info) => saveToStorage('client-info', info),
// Store and retrieve OAuth tokens tokens: async () => loadFromStorage('tokens'), saveTokens: async (tokens) => saveToStorage('tokens', tokens),
// Called when user needs to authorize — open browser redirectToAuthorization: async (authorizationUrl) => { const { default: open } = await import('open') await open(authorizationUrl.toString()) },
// PKCE code verifier storage saveCodeVerifier: async (verifier) => saveToStorage('verifier', verifier), codeVerifier: async () => loadFromStorage('verifier'),}
const transport = new StreamableHTTPClientTransport( new URL('https://mcp.example.com/mcp'), { authProvider })
const channel = new MCPClientChannel({ id: 'secure-mcp', transport, commandPrefix: 'secure',})
const registry = new CommandRegistry({ id: 'client' })await registry.registerChannel(channel)
// Tools are available after OAuth completesconst result = await registry.executeCommand('secure.protected-tool', { input: 'value' })Browser OAuth Example
Section titled “Browser OAuth Example”For browser-based applications, use a popup window for authorization and a CORS proxy for cross-origin requests:
import { CommandRegistry } from '@coralstack/cmd-ipc'import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
class BrowserOAuthProvider implements OAuthClientProvider { private serverUrl: string
constructor(serverUrl: string) { this.serverUrl = serverUrl }
get redirectUrl() { return `${window.location.origin}/oauth/callback` }
get clientMetadata() { return { client_name: 'my-web-app', redirect_uris: [this.redirectUrl], grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', } }
async clientInformation() { const stored = localStorage.getItem(`mcp-oauth-client:${this.serverUrl}`) return stored ? JSON.parse(stored) : undefined }
async saveClientInformation(info: unknown) { localStorage.setItem(`mcp-oauth-client:${this.serverUrl}`, JSON.stringify(info)) }
async tokens() { const stored = localStorage.getItem(`mcp-oauth-tokens:${this.serverUrl}`) return stored ? JSON.parse(stored) : undefined }
async saveTokens(tokens: unknown) { localStorage.setItem(`mcp-oauth-tokens:${this.serverUrl}`, JSON.stringify(tokens)) }
async redirectToAuthorization(authorizationUrl: URL) { // Open OAuth in a popup window const width = 600, height = 700 const left = window.screenX + (window.outerWidth - width) / 2 const top = window.screenY + (window.outerHeight - height) / 2 window.open( authorizationUrl.toString(), 'oauth', `width=${width},height=${height},left=${left},top=${top},popup=1` ) }
async saveCodeVerifier(verifier: string) { localStorage.setItem(`mcp-oauth-verifier:${this.serverUrl}`, verifier) }
async codeVerifier() { return localStorage.getItem(`mcp-oauth-verifier:${this.serverUrl}`) ?? '' }}
// Create transport with auth providerconst serverUrl = 'https://mcp.example.com'const authProvider = new BrowserOAuthProvider(serverUrl)
const transport = new StreamableHTTPClientTransport( new URL(`${serverUrl}/mcp`), { authProvider })
const channel = new MCPClientChannel({ id: 'browser-mcp', transport, commandPrefix: 'remote',})
const registry = new CommandRegistry({ id: 'browser-client' })await registry.registerChannel(channel)In your OAuth callback page (e.g., /oauth/callback):
// Parse the authorization code from the URLconst params = new URLSearchParams(window.location.search)const code = params.get('code')const error = params.get('error')
// Send the code back to the opener windowif (window.opener) { window.opener.postMessage( { type: 'oauth-callback', code, error }, window.location.origin ) window.close()}Complete Example
Section titled “Complete Example”A full end-to-end example with an Express server exposing commands and a Node.js client connecting to it.
Server
Section titled “Server”import express from 'express'import { randomUUID } from 'crypto'import { CommandRegistry, Command, registerCommands } from '@coralstack/cmd-ipc'import { MCPServerChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
class MathCommands { @Command('math.add', 'Add two numbers') add({ a, b }: { a: number; b: number }) { return a + b }
@Command('math.multiply', 'Multiply two numbers') multiply({ a, b }: { a: number; b: number }) { return a * b }}
const registry = new CommandRegistry({ id: 'math-server' })registerCommands([new MathCommands()], registry)
const sessions = new Map<string, { transport: StreamableHTTPServerTransport channel: MCPServerChannel}>()
const app = express()app.use(express.json())
app.post('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined
if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId)!.transport.handleRequest(req, res, req.body) return }
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), })
const channel = new MCPServerChannel({ id: `mcp-session-${randomUUID()}`, serverInfo: { name: 'math-server', version: '1.0.0' }, })
await registry.registerChannel(channel) await channel.connectTransport(transport)
transport.onSessionInitialized = (sid) => { sessions.set(sid, { transport, channel }) }
await transport.handleRequest(req, res, req.body)})
app.get('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId)!.transport.handleRequest(req, res) } else { res.status(400).end('Session not found') }})
app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined if (sessionId && sessions.has(sessionId)) { const session = sessions.get(sessionId)! await session.transport.handleRequest(req, res) await session.channel.close() sessions.delete(sessionId) } else { res.status(400).end('Session not found') }})
app.listen(3000, () => console.log('MCP server on http://localhost:3000'))Client
Section titled “Client”import { CommandRegistry } from '@coralstack/cmd-ipc'import { MCPClientChannel } from '@coralstack/cmd-ipc-mcp'import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
const registry = new CommandRegistry({ id: 'client' })
const transport = new StreamableHTTPClientTransport( new URL('http://localhost:3000/mcp'))
const channel = new MCPClientChannel({ id: 'math-client', transport, commandPrefix: 'math',})
await registry.registerChannel(channel)
const sum = await registry.executeCommand('math.math.add', { a: 5, b: 3 })console.log(sum) // 8
const product = await registry.executeCommand('math.math.multiply', { a: 4, b: 7 })console.log(product) // 28Common Issues
Section titled “Common Issues”Tool Not Found
Section titled “Tool Not Found”If commands aren’t being exposed as tools:
- Ensure commands are registered with the
CommandRegistrybefore MCP clients calltools/list - Private commands (prefixed with
_) are automatically filtered out - Check that the command has a valid ID and description
Connection Timeout
Section titled “Connection Timeout”If connections timeout:
- Increase the
timeoutconfiguration option on the channel - Check network connectivity to the MCP server
- Verify the transport URL is correct
Session Not Found (HTTP Server)
Section titled “Session Not Found (HTTP Server)”If you receive “Session not found” errors with the HTTP server:
- Ensure you’re passing the
MCP-Session-Idheader from the initialize response in subsequent requests - Check that your session storage map is persisting correctly
- For stateless deployments, pass
sessionIdGenerator: undefinedtoStreamableHTTPServerTransport