Skip to content

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
Terminal window
yarn add @coralstack/cmd-ipc-mcp @modelcontextprotocol/sdk

@coralstack/cmd-ipc is a peer dependency and must also be installed.

ChannelDirectionUse Case
MCPServerChannelcmd-ipc → MCPExpose cmd-ipc commands as MCP tools for AI agents to discover and call
MCPClientChannelMCP → cmd-ipcConnect 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 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.

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,
})
OptionTypeRequiredDefaultDescription
idstringYesUnique channel identifier
serverInfoImplementationNo{ name: 'cmd-ipc', version: '1.0.0' }Server name and version sent to MCP clients
instructionsstringNoInstructions sent to MCP clients during initialization
timeoutnumberNo30000Request timeout in milliseconds

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.

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)

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 commands
class MathCommands {
@Command('math.add', 'Add two numbers')
add({ a, b }: { a: number; b: number }) {
return a + b
}
}
// Setup registry
const registry = new CommandRegistry({ id: 'server' })
registerCommands([new MathCommands()], registry)
// Track active sessions
const 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'))

For advanced use cases, access the underlying SDK McpServer directly:

const mcpServer = channel.server // McpServer instance from @modelcontextprotocol/sdk

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.

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',
},
})
OptionTypeRequiredDefaultDescription
idstringYesUnique channel identifier
transportTransportYesSDK Transport instance (e.g., StreamableHTTPClientTransport, StdioClientTransport)
commandPrefixstringNoPrefix added to registered command IDs (e.g., remote.toolName)
timeoutnumberNo30000Request timeout in milliseconds
clientInfoImplementationNo{ name: 'cmd-ipc', version: '1.0.0' }Client name and version sent to MCP server

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

When the channel is registered (which calls start()):

  1. Creates an SDK Client and connects via the provided transport
  2. The SDK performs the MCP handshake (initialize / initialized)
  3. Calls client.listTools() to discover available tools
  4. Emits register.command.request for each tool (with optional prefix)

After startup, all discovered tools are available as commands in the registry.

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 commands
const result = await registry.executeCommand('example.some-tool', { input: 'value' })

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

  1. The transport sends a request to the MCP server
  2. The server responds with 401 Unauthorized
  3. The SDK discovers the authorization server via Protected Resource Metadata (RFC 9728)
  4. The authProvider.redirectToAuthorization() callback is called with the authorization URL
  5. After user authorization, the code is exchanged for tokens
  6. The transport retries the original request with the access token

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 interface
const 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 completes
const result = await registry.executeCommand('secure.protected-tool', { input: 'value' })

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 provider
const 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 URL
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const error = params.get('error')
// Send the code back to the opener window
if (window.opener) {
window.opener.postMessage(
{ type: 'oauth-callback', code, error },
window.location.origin
)
window.close()
}

A full end-to-end example with an Express server exposing commands and a Node.js client connecting to it.

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'))
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) // 28

If commands aren’t being exposed as tools:

  1. Ensure commands are registered with the CommandRegistry before MCP clients call tools/list
  2. Private commands (prefixed with _) are automatically filtered out
  3. Check that the command has a valid ID and description

If connections timeout:

  1. Increase the timeout configuration option on the channel
  2. Check network connectivity to the MCP server
  3. Verify the transport URL is correct

If you receive “Session not found” errors with the HTTP server:

  1. Ensure you’re passing the MCP-Session-Id header from the initialize response in subsequent requests
  2. Check that your session storage map is persisting correctly
  3. For stateless deployments, pass sessionIdGenerator: undefined to StreamableHTTPServerTransport