feat: complete MCP Bridge implementation

- Add @tpmjs/mcp-client package for connecting to MCP servers
- Add @tpmjs/bridge CLI for bridging local MCP servers to TPMJS
- Add @tpmjs/test-file-writer test MCP server
- Add BridgeConnection and CollectionBridgeTool database models
- Add /api/bridge endpoints for bridge communication
- Add /api/collections/[id]/bridge-tools API for managing bridge tools
- Update MCP handlers to include bridge tools in tools/list
- Add bridge status UI at /dashboard/settings/bridge
- Add interactive bridge tutorial at /docs/tutorials/bridge
This commit is contained in:
Ajax Davis 2026-01-13 00:59:34 +10:00
parent 490d76a50b
commit c8c1a12f22
37 changed files with 7318 additions and 23 deletions

View file

@ -0,0 +1,56 @@
{
"name": "@tpmjs/bridge",
"version": "0.1.0",
"description": "Bridge CLI for connecting local MCP servers to TPMJS",
"author": "TPMJS",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tpmjs/tpmjs.git",
"directory": "packages/bridge"
},
"homepage": "https://tpmjs.com",
"keywords": [
"tpmjs",
"mcp",
"bridge",
"cli",
"model-context-protocol"
],
"type": "module",
"bin": {
"tpmjs-bridge": "./dist/cli.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"start": "node dist/cli.js",
"type-check": "tsc --noEmit",
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"@tpmjs/mcp-client": "workspace:*",
"commander": "^14.0.0",
"picocolors": "^1.1.1",
"ws": "^8.18.2"
},
"devDependencies": {
"@tpmjs/tsconfig": "workspace:*",
"@types/node": "^22.15.29",
"@types/ws": "^8.18.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
},
"publishConfig": {
"access": "public"
}
}

View file

@ -0,0 +1,275 @@
import type { MCPServerConfig } from '@tpmjs/mcp-client';
import { MCPClientManager } from '@tpmjs/mcp-client';
import pc from 'picocolors';
interface BridgeToolCall {
callId: string;
serverId: string;
toolName: string;
args: Record<string, unknown>;
}
export interface BridgeOptions {
/** TPMJS API key */
apiKey: string;
/** TPMJS API URL */
apiUrl?: string;
/** MCP servers to connect to */
servers: MCPServerConfig[];
/** Poll interval in ms */
pollInterval?: number;
/** Heartbeat interval in ms */
heartbeatInterval?: number;
/** Verbose logging */
verbose?: boolean;
}
export class Bridge {
private mcpManager: MCPClientManager;
private options: Required<BridgeOptions>;
private isRunning = false;
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
private heartbeatTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(options: BridgeOptions) {
this.options = {
apiUrl: 'https://tpmjs.com',
pollInterval: 1000, // Poll every 1 second
heartbeatInterval: 30000, // Heartbeat every 30 seconds
verbose: false,
...options,
};
this.mcpManager = new MCPClientManager({
onStatusChange: (serverId, status, error) => {
if (this.options.verbose) {
if (status === 'connected') {
this.log(` ${pc.green('✓')} ${serverId} connected`);
} else if (status === 'error') {
this.log(` ${pc.red('✗')} ${serverId} error: ${error}`);
}
}
},
});
}
/**
* Start the bridge
*/
async start(): Promise<void> {
this.isRunning = true;
this.log(pc.bold('Starting TPMJS Bridge...\n'));
// 1. Connect to all local MCP servers
this.log('Connecting to MCP servers:');
for (const server of this.options.servers) {
try {
this.log(` Starting ${pc.cyan(server.name)}...`);
const tools = await this.mcpManager.connect(server);
this.log(` ${pc.green('✓')} ${server.name}: ${tools.length} tools`);
if (this.options.verbose) {
for (const tool of tools) {
this.log(` - ${tool.name}`);
}
}
} catch (error) {
this.log(` ${pc.red('✗')} ${server.name}: ${(error as Error).message}`);
}
}
// 2. Register with TPMJS
this.log('\nConnecting to TPMJS...');
await this.registerTools();
// 3. Start polling for tool calls
this.startPolling();
this.startHeartbeat();
}
/**
* Stop the bridge
*/
async stop(): Promise<void> {
this.isRunning = false;
this.log('\nShutting down...');
// Clear timers
if (this.pollTimeout) {
clearTimeout(this.pollTimeout);
this.pollTimeout = null;
}
if (this.heartbeatTimeout) {
clearTimeout(this.heartbeatTimeout);
this.heartbeatTimeout = null;
}
// Notify TPMJS we're disconnecting
try {
await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${this.options.apiKey}`,
},
});
} catch {
// Ignore disconnect errors
}
// Disconnect all MCP servers
await this.mcpManager.disconnectAll();
this.log('Bridge stopped');
}
private async registerTools(): Promise<void> {
const allTools = this.mcpManager.listAllTools();
const response = await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.options.apiKey}`,
},
body: JSON.stringify({
type: 'register',
tools: allTools.map(({ serverId, serverName, tool }) => ({
serverId,
serverName,
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
})),
clientVersion: '0.1.0',
clientOS: process.platform,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Failed to register: ${error}`);
}
this.log(`${pc.green('✓')} Connected to TPMJS`);
this.log(`Registered ${allTools.length} tools`);
}
private startPolling(): void {
const poll = async () => {
if (!this.isRunning) return;
try {
const response = await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'GET',
headers: {
Authorization: `Bearer ${this.options.apiKey}`,
},
});
if (response.ok) {
const data = (await response.json()) as { calls?: BridgeToolCall[] };
const calls = data.calls || [];
// Process each tool call
for (const call of calls) {
await this.handleToolCall(call);
}
}
} catch (error) {
if (this.options.verbose) {
this.log(`${pc.yellow('!')} Poll error: ${(error as Error).message}`);
}
}
// Schedule next poll
if (this.isRunning) {
this.pollTimeout = setTimeout(poll, this.options.pollInterval);
}
};
poll();
}
private startHeartbeat(): void {
const heartbeat = async () => {
if (!this.isRunning) return;
try {
await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.options.apiKey}`,
},
body: JSON.stringify({ type: 'heartbeat' }),
});
} catch {
// Ignore heartbeat errors
}
if (this.isRunning) {
this.heartbeatTimeout = setTimeout(heartbeat, this.options.heartbeatInterval);
}
};
this.heartbeatTimeout = setTimeout(heartbeat, this.options.heartbeatInterval);
}
private async handleToolCall(call: BridgeToolCall): Promise<void> {
const { callId, serverId, toolName, args } = call;
if (this.options.verbose) {
this.log(`Tool call: ${serverId}/${toolName}`);
}
try {
const result = await this.mcpManager.callTool(serverId, toolName, args);
await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.options.apiKey}`,
},
body: JSON.stringify({
type: 'tool_result',
callId,
result: {
content: result.content,
isError: result.isError,
},
}),
});
if (this.options.verbose) {
this.log(` ${pc.green('✓')} Result sent`);
}
} catch (error) {
await fetch(`${this.options.apiUrl}/api/bridge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.options.apiKey}`,
},
body: JSON.stringify({
type: 'tool_result',
callId,
error: {
code: 'EXECUTION_FAILED',
message: (error as Error).message,
},
}),
});
if (this.options.verbose) {
this.log(` ${pc.red('✗')} Error: ${(error as Error).message}`);
}
}
}
private log(message: string): void {
const timestamp = new Date().toLocaleTimeString();
console.log(`${pc.dim(`[${timestamp}]`)} ${message}`);
}
}

232
packages/bridge/src/cli.ts Normal file
View file

@ -0,0 +1,232 @@
import type { MCPServerConfig } from '@tpmjs/mcp-client';
import { Command } from 'commander';
import pc from 'picocolors';
import { Bridge } from './bridge.js';
import {
createDefaultConfig,
deleteCredentials,
ensureConfigDir,
getConfigPath,
loadConfig,
loadCredentials,
saveConfig,
saveCredentials,
} from './config.js';
const program = new Command();
program.name('tpmjs-bridge').description('Bridge local MCP servers to TPMJS').version('0.1.0');
// Init command
program
.command('init')
.description('Initialize bridge configuration')
.action(() => {
ensureConfigDir();
const config = loadConfig();
if (config.servers.length === 0) {
createDefaultConfig();
console.log(`${pc.green('✓')} Created config file: ${getConfigPath()}`);
console.log('\nEdit the config file to add your MCP servers, then run:');
console.log(` ${pc.cyan('tpmjs-bridge login')}`);
console.log(` ${pc.cyan('tpmjs-bridge start')}`);
} else {
console.log(`Config file already exists: ${getConfigPath()}`);
}
});
// Login command
program
.command('login')
.description('Authenticate with TPMJS')
.option('--api-key <key>', 'API key (or set TPMJS_API_KEY env var)')
.action((options) => {
const apiKey = options.apiKey || process.env.TPMJS_API_KEY;
if (!apiKey) {
console.log(`${pc.red('✗')} No API key provided`);
console.log('\nProvide an API key via:');
console.log(` ${pc.cyan('tpmjs-bridge login --api-key <key>')}`);
console.log(` ${pc.cyan('TPMJS_API_KEY=<key> tpmjs-bridge login')}`);
console.log('\nGet your API key at: https://tpmjs.com/dashboard/settings/api-keys');
process.exit(1);
}
saveCredentials({ apiKey });
console.log(`${pc.green('✓')} API key saved`);
});
// Logout command
program
.command('logout')
.description('Remove saved credentials')
.action(() => {
deleteCredentials();
console.log(`${pc.green('✓')} Credentials removed`);
});
// Add command
program
.command('add <name>')
.description('Add an MCP server to the config')
.option('--command <cmd>', 'Command to run', 'npx')
.option('--args <args>', 'Arguments (comma-separated)', '')
.action((name, options) => {
const config = loadConfig();
// Check if already exists
if (config.servers.some((s) => s.id === name)) {
console.log(`${pc.yellow('!')} Server "${name}" already exists`);
return;
}
const server: MCPServerConfig = {
id: name,
name: name,
transport: 'stdio',
command: options.command,
args: options.args ? options.args.split(',') : [],
};
config.servers.push(server);
saveConfig(config);
console.log(`${pc.green('✓')} Added server: ${name}`);
});
// Remove command
program
.command('remove <name>')
.description('Remove an MCP server from the config')
.action((name) => {
const config = loadConfig();
const index = config.servers.findIndex((s) => s.id === name);
if (index === -1) {
console.log(`${pc.yellow('!')} Server "${name}" not found`);
return;
}
config.servers.splice(index, 1);
saveConfig(config);
console.log(`${pc.green('✓')} Removed server: ${name}`);
});
// List command
program
.command('list')
.description('List configured MCP servers')
.action(() => {
const config = loadConfig();
if (config.servers.length === 0) {
console.log('No servers configured');
console.log(`\nRun ${pc.cyan('tpmjs-bridge init')} to create a config file`);
return;
}
console.log('Configured MCP servers:\n');
for (const server of config.servers) {
console.log(` ${pc.cyan(server.id)}`);
console.log(` Name: ${server.name}`);
console.log(` Command: ${server.command} ${(server.args || []).join(' ')}`);
console.log();
}
});
// Config command
program
.command('config')
.description('Show config file path')
.action(() => {
console.log(`Config file: ${getConfigPath()}`);
});
// Start command
program
.command('start')
.description('Start the bridge')
.option('-v, --verbose', 'Verbose output')
.option('--url <url>', 'Custom WebSocket URL')
.action(async (options) => {
const config = loadConfig();
const credentials = loadCredentials();
// Check for API key
const apiKey = credentials?.apiKey || process.env.TPMJS_API_KEY;
if (!apiKey) {
console.log(`${pc.red('✗')} Not authenticated`);
console.log(`\nRun ${pc.cyan('tpmjs-bridge login')} first`);
process.exit(1);
}
// Check for servers
if (config.servers.length === 0) {
console.log(`${pc.yellow('!')} No MCP servers configured`);
console.log(`\nEdit ${getConfigPath()} to add servers`);
process.exit(1);
}
// Filter out example servers
const servers = config.servers.filter((s) => s.id !== 'example');
if (servers.length === 0) {
console.log(`${pc.yellow('!')} Only example server configured`);
console.log(`\nEdit ${getConfigPath()} to add real servers`);
process.exit(1);
}
const bridge = new Bridge({
apiKey,
servers,
verbose: options.verbose,
apiUrl: options.url,
});
// Handle shutdown
const shutdown = async () => {
await bridge.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
try {
await bridge.start();
console.log(`\n${pc.green('Bridge running.')} Press Ctrl+C to stop.\n`);
} catch (error) {
console.log(`${pc.red('✗')} Failed to start bridge: ${(error as Error).message}`);
process.exit(1);
}
});
// Status command
program
.command('status')
.description('Show bridge status')
.action(() => {
const config = loadConfig();
const credentials = loadCredentials();
console.log('Bridge Status\n');
// Auth status
if (credentials?.apiKey) {
console.log(` Auth: ${pc.green('✓')} Logged in`);
} else {
console.log(` Auth: ${pc.red('✗')} Not logged in`);
}
// Config status
console.log(` Config: ${getConfigPath()}`);
console.log(` Servers: ${config.servers.length}`);
if (config.servers.length > 0) {
console.log('\n Configured servers:');
for (const server of config.servers) {
console.log(` - ${server.name} (${server.id})`);
}
}
});
program.parse();

View file

@ -0,0 +1,113 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { BridgeConfig, BridgeCredentials } from './types.js';
const CONFIG_DIR = path.join(os.homedir(), '.tpmjs');
const CONFIG_FILE = path.join(CONFIG_DIR, 'bridge.json');
const CREDENTIALS_FILE = path.join(CONFIG_DIR, 'credentials.json');
/**
* Ensure the config directory exists
*/
export function ensureConfigDir(): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
}
/**
* Load bridge configuration
*/
export function loadConfig(): BridgeConfig {
ensureConfigDir();
if (!fs.existsSync(CONFIG_FILE)) {
return { servers: [] };
}
try {
const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(content) as BridgeConfig;
} catch {
return { servers: [] };
}
}
/**
* Save bridge configuration
*/
export function saveConfig(config: BridgeConfig): void {
ensureConfigDir();
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Load credentials
*/
export function loadCredentials(): BridgeCredentials | null {
ensureConfigDir();
if (!fs.existsSync(CREDENTIALS_FILE)) {
return null;
}
try {
const content = fs.readFileSync(CREDENTIALS_FILE, 'utf-8');
return JSON.parse(content) as BridgeCredentials;
} catch {
return null;
}
}
/**
* Save credentials
*/
export function saveCredentials(credentials: BridgeCredentials): void {
ensureConfigDir();
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), {
mode: 0o600, // Only owner can read/write
});
}
/**
* Delete credentials
*/
export function deleteCredentials(): void {
if (fs.existsSync(CREDENTIALS_FILE)) {
fs.unlinkSync(CREDENTIALS_FILE);
}
}
/**
* Get config file path
*/
export function getConfigPath(): string {
return CONFIG_FILE;
}
/**
* Get credentials file path
*/
export function getCredentialsPath(): string {
return CREDENTIALS_FILE;
}
/**
* Create default config file
*/
export function createDefaultConfig(): void {
const defaultConfig: BridgeConfig = {
servers: [
{
id: 'example',
name: 'Example MCP Server',
transport: 'stdio',
command: 'npx',
args: ['-y', '@example/mcp-server'],
},
],
};
saveConfig(defaultConfig);
}

View file

@ -0,0 +1,18 @@
export { Bridge, type BridgeOptions } from './bridge.js';
export {
createDefaultConfig,
deleteCredentials,
ensureConfigDir,
getConfigPath,
getCredentialsPath,
loadConfig,
loadCredentials,
saveConfig,
saveCredentials,
} from './config.js';
export type {
BridgeConfig,
BridgeCredentials,
BridgeToServerMessage,
ServerToBridgeMessage,
} from './types.js';

View file

@ -0,0 +1,84 @@
import type { MCPServerConfig, MCPTool } from '@tpmjs/mcp-client';
/**
* Bridge configuration file structure
*/
export interface BridgeConfig {
/** MCP servers to connect to */
servers: MCPServerConfig[];
}
/**
* Credentials file structure
*/
export interface BridgeCredentials {
/** TPMJS API key */
apiKey: string;
/** User ID */
userId?: string;
/** User email */
email?: string;
}
/**
* Message from bridge to TPMJS
*/
export type BridgeToServerMessage =
| {
type: 'register';
tools: Array<{
serverId: string;
serverName: string;
name: string;
description?: string;
inputSchema: MCPTool['inputSchema'];
}>;
}
| {
type: 'tool_result';
callId: string;
result: {
content: Array<{
type: string;
text?: string;
mimeType?: string;
data?: string;
}>;
isError?: boolean;
};
}
| {
type: 'tool_error';
callId: string;
error: {
code: string;
message: string;
};
}
| {
type: 'heartbeat';
timestamp: number;
};
/**
* Message from TPMJS to bridge
*/
export type ServerToBridgeMessage =
| {
type: 'tool_call';
callId: string;
serverId: string;
toolName: string;
args: Record<string, unknown>;
}
| {
type: 'ping';
}
| {
type: 'registered';
toolCount: number;
}
| {
type: 'error';
message: string;
};

View file

@ -0,0 +1,11 @@
{
"extends": "@tpmjs/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"incremental": false,
"composite": false
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,21 @@
import { defineConfig } from 'tsup';
export default defineConfig([
{
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
sourcemap: true,
},
{
entry: ['src/cli.ts'],
format: ['esm'],
dts: false,
clean: false,
sourcemap: true,
banner: {
js: '#!/usr/bin/env node',
},
},
]);