223 lines
6.7 KiB
TypeScript
223 lines
6.7 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
import { Hono } from 'hono'
|
|
import { cors } from 'hono/cors'
|
|
import { resolveCommand } from 'package-manager-detector/commands'
|
|
import { detect, getUserAgent } from 'package-manager-detector/detect'
|
|
import { WebSocketServer, type WebSocket } from 'ws'
|
|
|
|
import packageJson from '../package.json'
|
|
import { bearerToken, isAuthorized, mcpRequestToken } from './auth'
|
|
import { createBrowserRpcBridge } from './browser-rpc'
|
|
import { MCP_CORS_HEADERS, MCP_CORS_METHODS, MCP_EXPOSED_HEADERS } from './http-options'
|
|
import { preprocessRpc } from './jsx-preprocess'
|
|
import { createMcpSessionManager } from './mcp-sessions'
|
|
import { registerTools } from './tool/registration'
|
|
|
|
export const MCP_VERSION: string = packageJson.version
|
|
|
|
let installCommandPromise: Promise<string> | null = null
|
|
|
|
async function resolveMcpInstallCommand(): Promise<string> {
|
|
const agent =
|
|
getUserAgent() ??
|
|
(
|
|
await detect({
|
|
strategies: ['install-metadata', 'lockfile', 'packageManager-field', 'devEngines-field']
|
|
})
|
|
)?.agent ??
|
|
'npm'
|
|
const resolved = resolveCommand(agent, 'global', [`@open-pencil/mcp@${MCP_VERSION}`])
|
|
if (!resolved) return `npm install -g @open-pencil/mcp@${MCP_VERSION}`
|
|
return [resolved.command, ...resolved.args].join(' ')
|
|
}
|
|
|
|
function mcpInstallCommand(): Promise<string> {
|
|
installCommandPromise ??= resolveMcpInstallCommand()
|
|
return installCommandPromise
|
|
}
|
|
|
|
export { fail, ok, type MCPContent, type MCPResult } from './result'
|
|
|
|
export { registerTools, type RegisterToolsOptions, type RpcSender } from './tool/registration'
|
|
export { paramToZod } from './tool/schema'
|
|
|
|
export interface ServerOptions {
|
|
httpPort?: number
|
|
wsPort?: number
|
|
enableEval?: boolean
|
|
mcpRoot?: string | null
|
|
authToken?: string | null
|
|
corsOrigin?: string | null
|
|
}
|
|
|
|
export function startServer(options: ServerOptions = {}) {
|
|
const httpPort = options.httpPort ?? 7600
|
|
const wsPort = options.wsPort ?? 7601
|
|
const enableEval = options.enableEval ?? false
|
|
const mcpRoot = options.mcpRoot ?? null
|
|
const authToken = options.authToken ?? null
|
|
const corsOrigin = options.corsOrigin ?? null
|
|
|
|
const mcpSessions = createMcpSessionManager({
|
|
serverVersion: MCP_VERSION,
|
|
registerTools: (mcpServer: McpServer) =>
|
|
registerTools(mcpServer, { enableEval, mcpRoot, sendRpc: sendToBrowser })
|
|
})
|
|
const browserRpc = createBrowserRpcBridge({
|
|
authToken,
|
|
onConnectionChange: mcpSessions.notifyToolsChanged
|
|
})
|
|
const sendToBrowser = browserRpc.sendRpc
|
|
|
|
// --- WebSocket: browser connects here ---
|
|
|
|
const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' })
|
|
const wsClients = new Set<WebSocket>()
|
|
|
|
function sendRegisterToken(ws: WebSocket) {
|
|
const token = browserRpc.currentRpcToken()
|
|
if (token && ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'register', token }))
|
|
}
|
|
}
|
|
|
|
function broadcastRegisterToken() {
|
|
for (const client of wsClients) sendRegisterToken(client)
|
|
}
|
|
|
|
async function handleClientRequest(ws: WebSocket, msg: Record<string, unknown>) {
|
|
const id = typeof msg.id === 'string' ? msg.id : null
|
|
if (!id) return
|
|
const { type: _type, id: _id, ...body } = msg
|
|
try {
|
|
const result = await sendToBrowser(body)
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(JSON.stringify({ type: 'response', id, ok: true, ...(result as object) }))
|
|
}
|
|
} catch (e) {
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: 'response',
|
|
id,
|
|
ok: false,
|
|
error: e instanceof Error ? e.message : String(e)
|
|
})
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
wss.on('connection', (ws) => {
|
|
wsClients.add(ws)
|
|
sendRegisterToken(ws)
|
|
|
|
ws.on('message', (raw) => {
|
|
const data = typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8')
|
|
let msg: Record<string, unknown>
|
|
try {
|
|
msg = JSON.parse(data) as Record<string, unknown>
|
|
} catch {
|
|
return
|
|
}
|
|
if (msg.type === 'request') {
|
|
void handleClientRequest(ws, msg)
|
|
return
|
|
}
|
|
browserRpc.handleMessage(data, ws)
|
|
if (msg.type === 'register') broadcastRegisterToken()
|
|
})
|
|
|
|
ws.on('close', () => {
|
|
wsClients.delete(ws)
|
|
browserRpc.handleClose(ws)
|
|
})
|
|
})
|
|
|
|
// --- HTTP server ---
|
|
|
|
const app = new Hono()
|
|
|
|
if (corsOrigin) {
|
|
app.use(
|
|
'*',
|
|
cors({
|
|
origin: corsOrigin,
|
|
allowMethods: MCP_CORS_METHODS,
|
|
allowHeaders: MCP_CORS_HEADERS,
|
|
exposeHeaders: MCP_EXPOSED_HEADERS
|
|
})
|
|
)
|
|
}
|
|
|
|
app.get('/health', async (c) =>
|
|
c.json({
|
|
status: browserRpc.isConnected() ? 'ok' : 'no_app',
|
|
version: MCP_VERSION,
|
|
installCommand: await mcpInstallCommand(),
|
|
authRequired: authToken !== null,
|
|
...(browserRpc.currentRpcToken() ? { token: browserRpc.currentRpcToken() } : {})
|
|
})
|
|
)
|
|
|
|
app.use('/rpc', async (c, next) => {
|
|
const rpcToken = browserRpc.currentRpcToken()
|
|
if (!browserRpc.isConnected() || !rpcToken) {
|
|
return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503)
|
|
}
|
|
const provided = bearerToken(c.req.header('authorization'))
|
|
if (!isAuthorized(provided, rpcToken)) {
|
|
return c.json({ error: 'Unauthorized' }, 401)
|
|
}
|
|
return next()
|
|
})
|
|
|
|
app.post('/rpc', async (c) => {
|
|
let body = await c.req.json().catch(() => null)
|
|
if (!body || typeof body !== 'object') {
|
|
return c.json({ error: 'Invalid request body' }, 400)
|
|
}
|
|
try {
|
|
body = preprocessRpc(body as Record<string, unknown>)
|
|
const result = await sendToBrowser(body as Record<string, unknown>)
|
|
return c.json(result)
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
return c.json({ ok: false, error: msg }, 502)
|
|
}
|
|
})
|
|
|
|
// --- MCP Streamable HTTP ---
|
|
|
|
app.all('/mcp', async (c) => {
|
|
if (authToken) {
|
|
const token = mcpRequestToken(c.req.header('authorization'), c.req.header('x-mcp-token'))
|
|
if (!isAuthorized(token, authToken)) {
|
|
return c.json({ error: 'Unauthorized' }, 401)
|
|
}
|
|
}
|
|
const sessionId = c.req.header('mcp-session-id') ?? undefined
|
|
const transport = mcpSessions.resolveTransport(sessionId)
|
|
if ('error' in transport) {
|
|
return c.json(
|
|
{ error: 'Too many active MCP sessions' },
|
|
{ status: 503, headers: { 'Retry-After': '5' } }
|
|
)
|
|
}
|
|
mcpSessions.touch(sessionId, transport)
|
|
const response = await transport.handleRequest(c.req.raw)
|
|
if (c.req.method === 'DELETE') {
|
|
mcpSessions.deleteSession(sessionId)
|
|
}
|
|
return response
|
|
})
|
|
|
|
function close() {
|
|
browserRpc.close()
|
|
mcpSessions.clear()
|
|
wss.close()
|
|
}
|
|
|
|
return { app, wss, httpPort, close }
|
|
}
|