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,241 @@
import { prisma } from '@tpmjs/db';
import { type NextRequest, NextResponse } from 'next/server';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* Bridge Registration & Status API
*
* POST: Register bridge tools
* GET: Get bridge status and pending tool calls
* DELETE: Disconnect bridge
*/
// Validate API key and get user
async function validateApiKey(token: string | null | undefined) {
if (!token) return null;
// For now, use session-based auth
// In production, you'd want proper API key validation with encrypted keys
const session = await prisma.session.findUnique({
where: { token },
include: { user: true },
});
return session?.user || null;
}
// POST: Register bridge and its tools
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
const user = await validateApiKey(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { type, tools, callId, result, error: toolError } = body;
if (type === 'register') {
// Register bridge and tools
await prisma.bridgeConnection.upsert({
where: { userId: user.id },
update: {
status: 'connected',
tools: tools || [],
lastSeen: new Date(),
clientVersion: body.clientVersion,
clientOS: body.clientOS,
},
create: {
userId: user.id,
status: 'connected',
tools: tools || [],
lastSeen: new Date(),
clientVersion: body.clientVersion,
clientOS: body.clientOS,
},
});
return NextResponse.json({
success: true,
message: `Registered ${tools?.length || 0} tools`,
});
}
if (type === 'tool_result') {
// Store tool result for polling
// We use a simple in-memory store for now
// In production, use Redis or similar
const key = `bridge_result:${callId}`;
pendingResults.set(key, { result, error: toolError, timestamp: Date.now() });
return NextResponse.json({ success: true });
}
if (type === 'heartbeat') {
// Update last seen
await prisma.bridgeConnection.update({
where: { userId: user.id },
data: { lastSeen: new Date() },
});
return NextResponse.json({ success: true });
}
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
} catch (error) {
console.error('Bridge POST error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
);
}
}
// GET: Get pending tool calls (polling)
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
const user = await validateApiKey(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get pending tool calls for this user
const pendingCalls = Array.from(pendingToolCalls.entries())
.filter(([key]) => key.startsWith(`${user.id}:`))
.map(([key, value]) => {
pendingToolCalls.delete(key); // Remove after returning
return value;
});
// Update last seen
await prisma.bridgeConnection.update({
where: { userId: user.id },
data: { lastSeen: new Date() },
});
return NextResponse.json({
success: true,
calls: pendingCalls,
});
} catch (error) {
console.error('Bridge GET error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
);
}
}
// DELETE: Disconnect bridge
export async function DELETE(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
const user = await validateApiKey(token);
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await prisma.bridgeConnection.update({
where: { userId: user.id },
data: { status: 'disconnected' },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Bridge DELETE error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
);
}
}
// In-memory stores for tool calls and results
// In production, use Redis or a proper message queue
const pendingToolCalls = new Map<
string,
{
callId: string;
serverId: string;
toolName: string;
args: Record<string, unknown>;
timestamp: number;
}
>();
const pendingResults = new Map<
string,
{
result?: unknown;
error?: { code: string; message: string };
timestamp: number;
}
>();
// Helper function to queue a tool call for a user's bridge
export function queueBridgeToolCall(
userId: string,
callId: string,
serverId: string,
toolName: string,
args: Record<string, unknown>
): void {
const key = `${userId}:${callId}`;
pendingToolCalls.set(key, {
callId,
serverId,
toolName,
args,
timestamp: Date.now(),
});
}
// Helper function to wait for a tool result
export async function waitForBridgeResult(
callId: string,
timeoutMs: number = 300000 // 5 minutes
): Promise<{ result?: unknown; error?: { code: string; message: string } }> {
const key = `bridge_result:${callId}`;
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const result = pendingResults.get(key);
if (result) {
pendingResults.delete(key);
return result;
}
// Wait 100ms before checking again
await new Promise((resolve) => setTimeout(resolve, 100));
}
return { error: { code: 'TIMEOUT', message: 'Bridge tool call timed out' } };
}
// Cleanup old entries periodically
setInterval(() => {
const now = Date.now();
const maxAge = 5 * 60 * 1000; // 5 minutes
for (const [key, value] of pendingToolCalls.entries()) {
if (now - value.timestamp > maxAge) {
pendingToolCalls.delete(key);
}
}
for (const [key, value] of pendingResults.entries()) {
if (now - value.timestamp > maxAge) {
pendingResults.delete(key);
}
}
}, 60000); // Run every minute

View file

@ -0,0 +1,260 @@
import { prisma } from '@tpmjs/db';
import { UpdateCollectionBridgeToolSchema } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string; bridgeToolId: string }>;
}
/**
* PATCH /api/collections/[id]/bridge-tools/[bridgeToolId]
* Update a bridge tool in a collection
*/
export async function PATCH(
request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id: collectionId, bridgeToolId } = await context.params;
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Verify collection exists and user owns it
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (collection.userId !== session.user.id) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'Access denied' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
// Verify bridge tool exists in collection
const bridgeTool = await prisma.collectionBridgeTool.findFirst({
where: {
id: bridgeToolId,
collectionId,
},
});
if (!bridgeTool) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Bridge tool not found in collection' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Parse and validate request body
const body = await request.json();
const parseResult = UpdateCollectionBridgeToolSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: { errors: parseResult.error.flatten().fieldErrors },
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 400 }
);
}
const { displayName, note } = parseResult.data;
// Update the bridge tool
const updated = await prisma.collectionBridgeTool.update({
where: { id: bridgeToolId },
data: {
...(displayName !== undefined && { displayName }),
...(note !== undefined && { note }),
},
});
return NextResponse.json({
success: true,
data: {
id: updated.id,
serverId: updated.serverId,
toolName: updated.toolName,
displayName: updated.displayName,
note: updated.note,
updatedAt: updated.updatedAt,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] PATCH /api/collections/[id]/bridge-tools/[bridgeToolId]:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to update bridge tool' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* DELETE /api/collections/[id]/bridge-tools/[bridgeToolId]
* Remove a bridge tool from a collection
*/
export async function DELETE(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id: collectionId, bridgeToolId } = await context.params;
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Verify collection exists and user owns it
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (collection.userId !== session.user.id) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'Access denied' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
// Verify bridge tool exists in collection
const bridgeTool = await prisma.collectionBridgeTool.findFirst({
where: {
id: bridgeToolId,
collectionId,
},
});
if (!bridgeTool) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Bridge tool not found in collection' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Delete the bridge tool from collection
await prisma.collectionBridgeTool.delete({
where: { id: bridgeToolId },
});
return NextResponse.json({
success: true,
data: {
deleted: true,
serverId: bridgeTool.serverId,
toolName: bridgeTool.toolName,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] DELETE /api/collections/[id]/bridge-tools/[bridgeToolId]:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to remove bridge tool' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,354 @@
import { prisma } from '@tpmjs/db';
import { AddBridgeToolToCollectionSchema, COLLECTION_LIMITS } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
const API_VERSION = '1.0.0';
interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: Record<string, unknown>;
};
meta: {
version: string;
timestamp: string;
requestId?: string;
};
}
interface RouteContext {
params: Promise<{ id: string }>;
}
/**
* GET /api/collections/[id]/bridge-tools
* List all bridge tools in a collection
*/
export async function GET(
_request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id: collectionId } = await context.params;
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Verify collection exists and user owns it
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
include: {
bridgeTools: {
orderBy: { createdAt: 'asc' },
},
user: {
include: {
bridgeConnection: true,
},
},
},
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (collection.userId !== session.user.id) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'Access denied' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
// Get available bridge tools from the user's bridge connection
interface BridgeTool {
serverId: string;
serverName: string;
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
const availableBridgeTools =
(collection.user.bridgeConnection?.tools as unknown as BridgeTool[]) || [];
const bridgeStatus = collection.user.bridgeConnection?.status || 'disconnected';
// Enrich collection bridge tools with definitions
const enrichedTools = collection.bridgeTools.map((bt) => {
const definition = availableBridgeTools.find(
(t) => t.serverId === bt.serverId && t.name === bt.toolName
);
return {
id: bt.id,
serverId: bt.serverId,
toolName: bt.toolName,
displayName: bt.displayName,
note: bt.note,
createdAt: bt.createdAt,
// Include definition info if available
serverName: definition?.serverName,
description: definition?.description,
available: !!definition && bridgeStatus === 'connected',
};
});
return NextResponse.json({
success: true,
data: {
bridgeTools: enrichedTools,
bridgeStatus,
availableTools: bridgeStatus === 'connected' ? availableBridgeTools : [],
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
});
} catch (error) {
console.error('[API Error] GET /api/collections/[id]/bridge-tools:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to list bridge tools' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}
/**
* POST /api/collections/[id]/bridge-tools
* Add a bridge tool to a collection
*/
export async function POST(
request: NextRequest,
context: RouteContext
): Promise<NextResponse<ApiResponse>> {
const requestId = crypto.randomUUID();
const { id: collectionId } = await context.params;
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json(
{
success: false,
error: { code: 'UNAUTHORIZED', message: 'Authentication required' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 401 }
);
}
// Verify collection exists and user owns it
const collection = await prisma.collection.findUnique({
where: { id: collectionId },
include: {
_count: { select: { bridgeTools: true } },
user: {
include: {
bridgeConnection: true,
},
},
},
});
if (!collection) {
return NextResponse.json(
{
success: false,
error: { code: 'NOT_FOUND', message: 'Collection not found' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
if (collection.userId !== session.user.id) {
return NextResponse.json(
{
success: false,
error: { code: 'FORBIDDEN', message: 'Access denied' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 403 }
);
}
// Parse and validate request body
const body = await request.json();
const parseResult = AddBridgeToolToCollectionSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json(
{
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request body',
details: { errors: parseResult.error.flatten().fieldErrors },
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 400 }
);
}
const { serverId, toolName, displayName, note } = parseResult.data;
// Check bridge tool limit
if (collection._count.bridgeTools >= COLLECTION_LIMITS.MAX_BRIDGE_TOOLS_PER_COLLECTION) {
return NextResponse.json(
{
success: false,
error: {
code: 'LIMIT_EXCEEDED',
message: `Maximum ${COLLECTION_LIMITS.MAX_BRIDGE_TOOLS_PER_COLLECTION} bridge tools per collection`,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 400 }
);
}
// Check if the user has a bridge connection with this tool
const bridgeConnection = collection.user.bridgeConnection;
if (!bridgeConnection) {
return NextResponse.json(
{
success: false,
error: {
code: 'NO_BRIDGE',
message: 'No bridge connection found. Please start the bridge CLI first.',
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 400 }
);
}
interface BridgeTool {
serverId: string;
serverName: string;
name: string;
description?: string;
}
const availableTools = (bridgeConnection.tools as unknown as BridgeTool[]) || [];
const bridgeTool = availableTools.find((t) => t.serverId === serverId && t.name === toolName);
if (!bridgeTool) {
return NextResponse.json(
{
success: false,
error: {
code: 'TOOL_NOT_FOUND',
message: `Tool ${toolName} from server ${serverId} not found in your bridge connection`,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 404 }
);
}
// Check if tool is already in collection
const existingEntry = await prisma.collectionBridgeTool.findUnique({
where: {
collectionId_serverId_toolName: {
collectionId,
serverId,
toolName,
},
},
});
if (existingEntry) {
return NextResponse.json(
{
success: false,
error: {
code: 'DUPLICATE_TOOL',
message: 'This bridge tool is already in the collection',
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 409 }
);
}
// Add bridge tool to collection
const collectionBridgeTool = await prisma.collectionBridgeTool.create({
data: {
collectionId,
serverId,
toolName,
displayName: displayName || null,
note: note || null,
},
});
return NextResponse.json(
{
success: true,
data: {
id: collectionBridgeTool.id,
serverId: collectionBridgeTool.serverId,
toolName: collectionBridgeTool.toolName,
displayName: collectionBridgeTool.displayName,
note: collectionBridgeTool.note,
createdAt: collectionBridgeTool.createdAt,
serverName: bridgeTool.serverName,
description: bridgeTool.description,
},
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 201 }
);
} catch (error) {
console.error('[API Error] POST /api/collections/[id]/bridge-tools:', error);
return NextResponse.json(
{
success: false,
error: { code: 'INTERNAL_ERROR', message: 'Failed to add bridge tool' },
meta: { version: API_VERSION, timestamp: new Date().toISOString(), requestId },
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,84 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
/**
* GET /api/user/bridge - Get current user's bridge status
*/
export async function GET() {
try {
const headersList = await headers();
const session = await auth.api.getSession({ headers: headersList });
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const bridge = await prisma.bridgeConnection.findUnique({
where: { userId: session.user.id },
});
if (!bridge) {
return NextResponse.json({
success: true,
data: {
status: 'never_connected',
tools: [],
lastSeen: null,
},
});
}
// Check if bridge is stale (not seen in 2 minutes)
const isStale = bridge.lastSeen ? Date.now() - bridge.lastSeen.getTime() > 2 * 60 * 1000 : true;
const status = isStale && bridge.status === 'connected' ? 'stale' : bridge.status;
// Parse tools from JSON
const tools =
(bridge.tools as Array<{
serverId: string;
serverName: string;
name: string;
description?: string;
}>) || [];
// Group tools by server
const servers: Record<string, { name: string; tools: string[] }> = {};
for (const tool of tools) {
const server = servers[tool.serverId] ?? {
name: tool.serverName || tool.serverId,
tools: [],
};
servers[tool.serverId] = server;
server.tools.push(tool.name);
}
return NextResponse.json({
success: true,
data: {
status,
lastSeen: bridge.lastSeen?.toISOString() || null,
clientVersion: bridge.clientVersion,
clientOS: bridge.clientOS,
toolCount: tools.length,
servers: Object.entries(servers).map(([id, data]) => ({
id,
name: data.name,
toolCount: data.tools.length,
tools: data.tools,
})),
},
});
} catch (error) {
console.error('Failed to get bridge status:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Internal error' },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,282 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import {
Table,
TableBody,
TableCell,
TableEmpty,
TableHead,
TableHeader,
TableRow,
} from '@tpmjs/ui/Table/Table';
import { useCallback, useEffect, useState } from 'react';
import { DashboardLayout } from '~/components/dashboard/DashboardLayout';
interface BridgeServer {
id: string;
name: string;
toolCount: number;
tools: string[];
}
interface BridgeStatus {
status: 'connected' | 'disconnected' | 'stale' | 'never_connected';
lastSeen: string | null;
clientVersion: string | null;
clientOS: string | null;
toolCount: number;
servers: BridgeServer[];
}
function getStatusBadge(status: BridgeStatus['status']) {
switch (status) {
case 'connected':
return <Badge variant="success">Connected</Badge>;
case 'stale':
return <Badge variant="warning">Stale</Badge>;
case 'disconnected':
return <Badge variant="secondary">Disconnected</Badge>;
case 'never_connected':
return <Badge variant="secondary">Never Connected</Badge>;
}
}
function formatLastSeen(lastSeen: string | null): string {
if (!lastSeen) return 'Never';
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
if (diffSecs < 60) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
}
export default function BridgePage(): React.ReactElement {
const [bridgeStatus, setBridgeStatus] = useState<BridgeStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchStatus = useCallback(async () => {
try {
const response = await fetch('/api/user/bridge');
const data = await response.json();
if (data.success) {
setBridgeStatus(data.data);
} else {
setError(data.error || 'Failed to fetch bridge status');
}
} catch (err) {
console.error('Failed to fetch bridge status:', err);
setError('Failed to fetch bridge status');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
// Refresh every 10 seconds
const interval = setInterval(fetchStatus, 10000);
return () => clearInterval(interval);
}, [fetchStatus]);
if (error) {
return (
<DashboardLayout title="Bridge">
<div className="text-center py-16">
<Icon icon="alertCircle" size="lg" className="mx-auto text-error mb-4" />
<h2 className="text-lg font-medium text-foreground mb-2">Error</h2>
<p className="text-foreground-secondary mb-4">{error}</p>
<Button onClick={fetchStatus}>Try Again</Button>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout
title="Bridge"
subtitle="Connect local MCP servers to TPMJS"
actions={
<Button variant="outline" size="sm" onClick={fetchStatus}>
<Icon icon="loader" size="sm" className="mr-2" />
Refresh
</Button>
}
>
{/* Status Card */}
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-foreground">Connection Status</h2>
{!isLoading && bridgeStatus && getStatusBadge(bridgeStatus.status)}
</div>
{isLoading ? (
<div className="space-y-3">
<div className="h-4 w-48 bg-surface-secondary rounded animate-pulse" />
<div className="h-4 w-32 bg-surface-secondary rounded animate-pulse" />
</div>
) : bridgeStatus?.status === 'never_connected' ? (
<div className="space-y-4">
<p className="text-foreground-secondary">
The TPMJS Bridge allows you to connect local MCP servers (like Chrome DevTools, file
systems, or custom tools) to your TPMJS collections.
</p>
<div className="bg-surface-secondary rounded-lg p-4">
<h3 className="text-sm font-medium text-foreground mb-2">Quick Start</h3>
<ol className="space-y-2 text-sm text-foreground-secondary">
<li>
1. Install the bridge CLI:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-xs font-mono">
npm install -g @tpmjs/bridge
</code>
</li>
<li>
2. Initialize:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-xs font-mono">
tpmjs-bridge init
</code>
</li>
<li>
3. Add an MCP server:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-xs font-mono">
tpmjs-bridge add chrome-devtools
</code>
</li>
<li>
4. Start the bridge:{' '}
<code className="px-1.5 py-0.5 bg-surface rounded text-xs font-mono">
tpmjs-bridge start
</code>
</li>
</ol>
</div>
</div>
) : (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-foreground-tertiary">Last Seen:</span>
<span className="ml-2 text-foreground">
{formatLastSeen(bridgeStatus?.lastSeen ?? null)}
</span>
</div>
<div>
<span className="text-foreground-tertiary">Tools:</span>
<span className="ml-2 text-foreground">{bridgeStatus?.toolCount ?? 0}</span>
</div>
{bridgeStatus?.clientVersion && (
<div>
<span className="text-foreground-tertiary">Version:</span>
<span className="ml-2 text-foreground">{bridgeStatus.clientVersion}</span>
</div>
)}
{bridgeStatus?.clientOS && (
<div>
<span className="text-foreground-tertiary">Platform:</span>
<span className="ml-2 text-foreground">{bridgeStatus.clientOS}</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Connected Servers Table */}
{!isLoading && bridgeStatus && bridgeStatus.servers.length > 0 && (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-medium text-foreground">Connected MCP Servers</h2>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[200px]">Server</TableHead>
<TableHead className="w-[100px]">Tools</TableHead>
<TableHead>Available Tools</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bridgeStatus.servers.map((server) => (
<TableRow key={server.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon icon="terminal" size="sm" className="text-primary" />
</div>
<span className="font-medium text-foreground">{server.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{server.toolCount}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{server.tools.slice(0, 5).map((tool) => (
<span
key={tool}
className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-secondary"
>
{tool}
</span>
))}
{server.tools.length > 5 && (
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
+{server.tools.length - 5} more
</span>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Empty State for no servers */}
{!isLoading &&
bridgeStatus &&
bridgeStatus.status !== 'never_connected' &&
bridgeStatus.servers.length === 0 && (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead>Connected MCP Servers</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty
colSpan={1}
icon={
<div className="w-16 h-16 rounded-full bg-warning/10 flex items-center justify-center">
<Icon icon="alertCircle" size="lg" className="text-warning" />
</div>
}
title="No MCP servers connected"
description="Your bridge is running but no MCP servers are configured. Add a server to make its tools available."
/>
</TableBody>
</Table>
</div>
)}
{/* Help Section */}
<div className="mt-6 text-sm text-foreground-tertiary">
<p>
Bridge tools can be added to your collections and used in agents. When the bridge is
connected, the tools will be available for execution through your local MCP servers.
</p>
</div>
</DashboardLayout>
);
}

View file

@ -0,0 +1,712 @@
'use client';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import Link from 'next/link';
import { useState } from 'react';
import { AppFooter } from '~/components/AppFooter';
import { AppHeader } from '~/components/AppHeader';
interface Slide {
id: string;
title: string;
subtitle?: string;
content: React.ReactNode;
icon?: string;
}
const slides: Slide[] = [
{
id: 'intro',
title: 'Connect Local Tools to TPMJS',
subtitle: 'Use the Bridge to run MCP servers on your machine',
icon: '🌉',
content: (
<div className="text-center space-y-6">
<p className="text-xl text-foreground-secondary max-w-2xl mx-auto">
The TPMJS Bridge lets you connect local MCP servers—like Chrome DevTools, filesystem
access, or custom tools—to your TPMJS collections.
</p>
<div className="flex flex-wrap justify-center gap-4 mt-8">
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary rounded-full">
<span>🖥️</span>
<span className="text-sm">Local MCP Servers</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary rounded-full">
<span>🌉</span>
<span className="text-sm">Bridge CLI</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary rounded-full">
<span>☁️</span>
<span className="text-sm">TPMJS Cloud</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary rounded-full">
<span>🤖</span>
<span className="text-sm">AI Access</span>
</div>
</div>
<div className="pt-6">
<p className="text-sm text-foreground-tertiary">
Use cases: Chrome automation • Local file access • Database queries • Custom APIs
</p>
</div>
</div>
),
},
{
id: 'why-bridge',
title: 'Why Use the Bridge?',
subtitle: 'Access tools that require local execution',
icon: '🤔',
content: (
<div className="space-y-8">
<div className="max-w-3xl mx-auto grid md:grid-cols-2 gap-8">
<div className="p-6 bg-surface-secondary rounded-lg border border-border">
<h4 className="text-lg font-semibold text-foreground mb-3">Cloud-Only Tools</h4>
<ul className="space-y-3 text-foreground-secondary">
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Code execution (sandboxed)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Web fetching</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Web search</span>
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">✗</span>
<span>Browser automation</span>
</li>
<li className="flex items-start gap-2">
<span className="text-red-500 mt-0.5">✗</span>
<span>Local file access</span>
</li>
</ul>
</div>
<div className="p-6 bg-primary/5 rounded-lg border border-primary/20">
<h4 className="text-lg font-semibold text-foreground mb-3">With Bridge</h4>
<ul className="space-y-3 text-foreground-secondary">
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Chrome DevTools control</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Read/write local files</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Local database access</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Custom internal APIs</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">✓</span>
<span>Any stdio MCP server</span>
</li>
</ul>
</div>
</div>
<div className="text-center">
<p className="text-foreground-secondary">
The Bridge runs on your machine and securely proxies tool calls from TPMJS.
</p>
</div>
</div>
),
},
{
id: 'how-it-works',
title: 'How It Works',
subtitle: 'A simple proxy between local tools and TPMJS',
icon: '⚙️',
content: (
<div className="space-y-8">
<div className="max-w-4xl mx-auto">
<div className="bg-surface-secondary rounded-lg border border-border p-6">
<pre className="text-sm text-foreground-secondary font-mono overflow-x-auto whitespace-pre">
{`┌─────────────────────────────────────────────────────────────┐
│ Your Machine │
│ │
│ ┌─────────────┐ ┌─────────────────────────────────┐ │
│ │ Chrome MCP │────▶│ │ │
│ │ Server │ │ @tpmjs/bridge CLI │ │
│ └─────────────┘ │ │ │
│ ┌─────────────┐ │ • Connects to MCP servers │ │
│ │ Filesystem │────▶│ • Registers tools with TPMJS │ │
│ │ Server │ │ • Polls for tool calls │ │
│ └─────────────┘ │ • Returns results │ │
│ ┌─────────────┐ │ │ │
│ │ Your Custom │────▶│ │ │
│ │ Server │ └──────────────┬──────────────────┘ │
│ └─────────────┘ │ │
└─────────────────────────────────────┼────────────────────────┘
│ HTTPS
▼
┌─────────────────┐
│ TPMJS Cloud │
│ │
│ Your AI uses │
│ bridge tools │
└─────────────────┘`}
</pre>
</div>
</div>
<div className="max-w-2xl mx-auto text-center">
<p className="text-foreground-secondary">
The bridge polls TPMJS for tool calls, executes them locally, and returns results. Tools
run on <strong>your machine</strong> with <strong>your permissions</strong>.
</p>
</div>
</div>
),
},
{
id: 'step-1-install',
title: 'Step 1: Install the Bridge',
subtitle: 'Install the CLI globally',
icon: '📦',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Install the TPMJS Bridge CLI using npm, pnpm, or yarn.
</p>
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border flex items-center gap-2">
<span className="text-foreground-tertiary">$</span>
<span className="text-sm font-medium text-foreground">Terminal</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`# Using npm
npm install -g @tpmjs/bridge
# Using pnpm
pnpm add -g @tpmjs/bridge
# Using yarn
yarn global add @tpmjs/bridge`}</code>
</pre>
</div>
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<p className="text-sm text-foreground">
<strong>Verify installation:</strong> Run{' '}
<code className="bg-background px-1.5 py-0.5 rounded text-xs">
tpmjs-bridge --version
</code>
</p>
</div>
</div>
</div>
),
},
{
id: 'step-2-init',
title: 'Step 2: Initialize Configuration',
subtitle: 'Create your bridge config file',
icon: '⚡',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Initialize the bridge to create a config file at{' '}
<code className="bg-surface-secondary px-2 py-0.5 rounded">~/.tpmjs/bridge.json</code>
</p>
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border">
<span className="text-sm font-medium text-foreground">Terminal</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`$ tpmjs-bridge init
✓ Created config file: ~/.tpmjs/bridge.json
Edit the config file to add your MCP servers, then run:
tpmjs-bridge login
tpmjs-bridge start`}</code>
</pre>
</div>
</div>
</div>
),
},
{
id: 'step-3-add-server',
title: 'Step 3: Add MCP Servers',
subtitle: 'Configure the MCP servers you want to connect',
icon: '➕',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Add MCP servers to your bridge config. Each server runs locally via stdio.
</p>
<div className="max-w-3xl mx-auto space-y-4">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border">
<span className="text-sm font-medium text-foreground">Add Chrome DevTools MCP</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`$ tpmjs-bridge add chrome-devtools \\
--command "npx" \\
--args "-y @anthropic/claude-in-chrome"
✓ Added server: chrome-devtools`}</code>
</pre>
</div>
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border">
<span className="text-sm font-medium text-foreground">
Or edit ~/.tpmjs/bridge.json directly
</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`{
"servers": [
{
"id": "chrome-devtools",
"name": "Chrome DevTools",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@anthropic/claude-in-chrome"]
},
{
"id": "filesystem",
"name": "Filesystem Access",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
}
]
}`}</code>
</pre>
</div>
</div>
</div>
),
},
{
id: 'step-4-login',
title: 'Step 4: Authenticate',
subtitle: 'Connect the bridge to your TPMJS account',
icon: '🔐',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Log in to associate the bridge with your TPMJS account.
</p>
<div className="max-w-2xl mx-auto space-y-4">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border">
<span className="text-sm font-medium text-foreground">Terminal</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`$ tpmjs-bridge login
Opening browser for authentication...
✓ Logged in as yourname@example.com`}</code>
</pre>
</div>
<div className="p-4 bg-surface-secondary rounded-lg">
<p className="text-sm text-foreground-secondary">
<strong>Alternative:</strong> Pass a token directly with{' '}
<code className="bg-background px-1.5 py-0.5 rounded text-xs">
--token YOUR_TOKEN
</code>
</p>
</div>
</div>
</div>
),
},
{
id: 'step-5-start',
title: 'Step 5: Start the Bridge',
subtitle: 'Run the bridge to connect your tools',
icon: '🚀',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Start the bridge and it will connect to your MCP servers and register tools with TPMJS.
</p>
<div className="max-w-3xl mx-auto">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<div className="px-4 py-2 bg-surface-secondary border-b border-border">
<span className="text-sm font-medium text-foreground">Terminal</span>
</div>
<pre className="p-4 text-sm overflow-x-auto">
<code className="text-foreground-secondary">{`$ tpmjs-bridge start
[10:30:15] Starting TPMJS Bridge...
[10:30:15] Connecting to MCP servers:
[10:30:15] Starting Chrome DevTools...
[10:30:16] ✓ chrome-devtools connected
[10:30:16] ✓ Chrome DevTools: 12 tools
[10:30:16] - screenshot
[10:30:16] - click_element
[10:30:16] - navigate
[10:30:16] - ...and 9 more
[10:30:16] Connecting to TPMJS...
[10:30:17] ✓ Registered 12 tools with TPMJS
[10:30:17]
[10:30:17] Bridge running. Press Ctrl+C to stop.
[10:30:17] Tools are now available in your TPMJS collections.`}</code>
</pre>
</div>
</div>
<div className="text-center">
<p className="text-foreground-secondary text-sm">
Keep the bridge running while you want to use local tools.
</p>
</div>
</div>
),
},
{
id: 'step-6-add-to-collection',
title: 'Step 6: Add Bridge Tools to Collections',
subtitle: 'Include bridge tools in your MCP collections',
icon: '📦',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Once connected, add bridge tools to your collections from the dashboard.
</p>
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-start gap-4 p-4 bg-surface-secondary rounded-lg">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary font-bold">
1
</div>
<div>
<p className="text-foreground font-medium">Go to Dashboard → Bridge</p>
<p className="text-foreground-secondary text-sm">
Verify your bridge is connected and see available tools
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-surface-secondary rounded-lg">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary font-bold">
2
</div>
<div>
<p className="text-foreground font-medium">Open a Collection</p>
<p className="text-foreground-secondary text-sm">
Go to the collection where you want bridge tools
</p>
</div>
</div>
<div className="flex items-start gap-4 p-4 bg-surface-secondary rounded-lg">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center text-primary font-bold">
3
</div>
<div>
<p className="text-foreground font-medium">Add Bridge Tools</p>
<p className="text-foreground-secondary text-sm">
Select tools from your connected MCP servers to add to the collection
</p>
</div>
</div>
</div>
<div className="text-center">
<Link href="/dashboard/settings/bridge">
<Button variant="default">Check Bridge Status →</Button>
</Link>
</div>
</div>
),
},
{
id: 'step-7-use',
title: 'Step 7: Use Your Tools!',
subtitle: 'Bridge tools work just like any other TPMJS tool',
icon: '🎉',
content: (
<div className="space-y-8">
<p className="text-lg text-foreground-secondary text-center max-w-2xl mx-auto">
Now you can use bridge tools from Claude Desktop, Cursor, or any MCP client!
</p>
<div className="max-w-3xl mx-auto">
<div className="bg-surface-secondary rounded-lg border border-border overflow-hidden">
<div className="p-4 border-b border-border bg-background">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-orange-500/20 rounded-lg flex items-center justify-center">
<span className="text-sm">🦊</span>
</div>
<p className="font-medium text-foreground">Claude Desktop</p>
</div>
</div>
<div className="p-4 space-y-4">
<div className="flex justify-end">
<div className="bg-primary text-primary-foreground rounded-lg px-4 py-2 max-w-[80%]">
<p className="text-sm">Take a screenshot of the current Chrome tab</p>
</div>
</div>
<div className="flex justify-start">
<div className="bg-background rounded-lg px-4 py-2 max-w-[80%] border border-border">
<p className="text-xs text-primary mb-2">
Using tool: bridge--chrome-devtools--screenshot
</p>
<p className="text-sm text-foreground">Here&apos;s the screenshot:</p>
<div className="mt-2 p-4 bg-surface-secondary rounded border border-border text-center text-foreground-tertiary">
[Screenshot image]
</div>
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-center gap-3 text-sm text-foreground-secondary">
<span className="flex items-center gap-1">
<span className="text-green-500">✓</span> Chrome automation
</span>
<span className="flex items-center gap-1">
<span className="text-green-500">✓</span> Local files
</span>
<span className="flex items-center gap-1">
<span className="text-green-500">✓</span> Custom tools
</span>
</div>
</div>
),
},
{
id: 'commands',
title: 'CLI Reference',
subtitle: 'All available bridge commands',
icon: '📖',
content: (
<div className="space-y-8">
<div className="max-w-3xl mx-auto">
<div className="bg-background rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-secondary border-b border-border">
<th className="text-left px-4 py-3 font-medium text-foreground">Command</th>
<th className="text-left px-4 py-3 font-medium text-foreground">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge init
</td>
<td className="px-4 py-3 text-foreground-secondary">Create config file</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge login
</td>
<td className="px-4 py-3 text-foreground-secondary">Authenticate with TPMJS</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge logout
</td>
<td className="px-4 py-3 text-foreground-secondary">Remove credentials</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge add &lt;name&gt;
</td>
<td className="px-4 py-3 text-foreground-secondary">Add an MCP server</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge remove &lt;name&gt;
</td>
<td className="px-4 py-3 text-foreground-secondary">Remove an MCP server</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge list
</td>
<td className="px-4 py-3 text-foreground-secondary">List configured servers</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge start
</td>
<td className="px-4 py-3 text-foreground-secondary">Start the bridge</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge status
</td>
<td className="px-4 py-3 text-foreground-secondary">Show connection status</td>
</tr>
<tr>
<td className="px-4 py-3 font-mono text-foreground-secondary">
tpmjs-bridge config
</td>
<td className="px-4 py-3 text-foreground-secondary">Show config file path</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
),
},
{
id: 'complete',
title: 'Bridge Connected!',
subtitle: 'Your local tools are now accessible via TPMJS',
icon: '🌉',
content: (
<div className="space-y-8 text-center">
<p className="text-xl text-foreground-secondary max-w-2xl mx-auto">
Your bridge is set up! Local MCP servers can now be used through TPMJS.
</p>
<div className="flex flex-wrap justify-center gap-4">
<Link href="/dashboard/settings/bridge">
<Button size="lg">View Bridge Status</Button>
</Link>
<Link href="/dashboard/collections">
<Button variant="secondary" size="lg">
Manage Collections
</Button>
</Link>
</div>
<div className="max-w-2xl mx-auto pt-8 border-t border-border mt-8">
<h4 className="text-lg font-semibold text-foreground mb-4">Tips</h4>
<div className="grid md:grid-cols-2 gap-4 text-left">
<div className="p-4 bg-surface-secondary rounded-lg">
<p className="font-medium text-foreground">Keep Bridge Running</p>
<p className="text-sm text-foreground-tertiary">
Bridge tools only work while the CLI is running
</p>
</div>
<div className="p-4 bg-surface-secondary rounded-lg">
<p className="font-medium text-foreground">Run on Startup</p>
<p className="text-sm text-foreground-tertiary">
Add to your shell profile or use a process manager
</p>
</div>
</div>
</div>
</div>
),
},
];
export default function BridgeTutorialPage(): React.ReactElement {
const [currentSlide, setCurrentSlide] = useState(0);
const goToSlide = (index: number) => {
setCurrentSlide(Math.max(0, Math.min(slides.length - 1, index)));
};
const nextSlide = () => goToSlide(currentSlide + 1);
const prevSlide = () => goToSlide(currentSlide - 1);
const slide = slides[currentSlide];
const progress = ((currentSlide + 1) / slides.length) * 100;
if (!slide) {
return <></>;
}
return (
<div className="min-h-screen bg-background flex flex-col">
<AppHeader />
{/* Progress bar */}
<div className="w-full h-1 bg-surface-secondary">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<main className="flex-1 flex flex-col">
{/* Navigation header */}
<div className="border-b border-border">
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<Link
href="/docs/tutorials"
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
>
<Icon icon="arrowLeft" size="sm" />
<span>Back to Tutorials</span>
</Link>
<div className="text-sm text-foreground-secondary">
{currentSlide + 1} / {slides.length}
</div>
</div>
</div>
{/* Slide content */}
<div className="flex-1 flex items-center justify-center px-4 py-8">
<div className="w-full max-w-5xl">
{/* Slide header */}
<div className="text-center mb-12">
{slide.icon && <span className="text-6xl mb-4 block">{slide.icon}</span>}
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-3">{slide.title}</h1>
{slide.subtitle && (
<p className="text-lg text-foreground-secondary">{slide.subtitle}</p>
)}
</div>
{/* Slide content */}
<div className="mb-12">{slide.content}</div>
</div>
</div>
{/* Navigation footer */}
<div className="border-t border-border">
<div className="max-w-6xl mx-auto px-4 py-4">
{/* Slide indicators */}
<div className="flex justify-center gap-2 mb-4">
{slides.map((s, index) => (
<button
key={s.id}
type="button"
onClick={() => goToSlide(index)}
className={`w-3 h-3 rounded-full transition-colors ${
index === currentSlide
? 'bg-primary'
: index < currentSlide
? 'bg-primary/40'
: 'bg-surface-secondary'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
{/* Navigation buttons */}
<div className="flex items-center justify-between">
<Button
variant="secondary"
onClick={prevSlide}
disabled={currentSlide === 0}
className="min-w-[120px]"
>
<Icon icon="arrowLeft" size="xs" className="mr-2" />
Previous
</Button>
{currentSlide === slides.length - 1 ? (
<Link href="/dashboard/settings/bridge">
<Button className="min-w-[120px]">View Bridge Status →</Button>
</Link>
) : (
<Button onClick={nextSlide} className="min-w-[120px]">
Next →
</Button>
)}
</div>
</div>
</div>
</main>
<AppFooter />
</div>
);
}

View file

@ -31,6 +31,15 @@ const tutorials: Tutorial[] = [
duration: '4 min',
steps: 8,
},
{
title: 'Connect Local Tools with Bridge',
description:
'Use the TPMJS Bridge to connect local MCP servers like Chrome DevTools, filesystem access, or custom tools to your TPMJS collections.',
icon: '🌉',
href: '/docs/tutorials/bridge',
duration: '6 min',
steps: 11,
},
{
title: 'Deploy Your Own Executor',
description:

View file

@ -20,6 +20,7 @@ const navItems: NavItem[] = [
{ href: '/dashboard/agents', label: 'Agents', icon: 'terminal' },
{ href: '/dashboard/collections', label: 'Collections', icon: 'folder' },
{ href: '/dashboard/settings/api-keys', label: 'API Keys', icon: 'key' },
{ href: '/dashboard/settings/bridge', label: 'Bridge', icon: 'link' },
];
const likesNavItems: NavItem[] = [

View file

@ -1,7 +1,12 @@
import { prisma } from '@tpmjs/db';
import { queueBridgeToolCall, waitForBridgeResult } from '~/app/api/bridge/route';
import { executeWithExecutor, parseExecutorConfig } from '../executors';
import { convertToMcpTool, parseToolName } from './tool-converter';
import {
type BridgeTool,
convertBridgeToolToMcp,
convertToMcpTool,
parseToolName,
} from './tool-converter';
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
@ -58,18 +63,49 @@ export async function handleToolsList(
include: { tool: { include: { package: true } } },
orderBy: { position: 'asc' },
},
bridgeTools: true,
user: {
include: {
bridgeConnection: true,
},
},
},
}),
DB_TIMEOUT_MS,
'Database query timed out'
);
const tools = collection?.tools.map((ct) => convertToMcpTool(ct.tool)) ?? [];
// Convert registry tools to MCP format
const registryTools = collection?.tools.map((ct) => convertToMcpTool(ct.tool)) ?? [];
// Convert bridge tools to MCP format
const bridgeTools: ReturnType<typeof convertBridgeToolToMcp>[] = [];
if (collection?.bridgeTools.length && collection.user.bridgeConnection) {
const bridgeConnection = collection.user.bridgeConnection;
const availableBridgeTools = (bridgeConnection.tools as unknown as BridgeTool[]) || [];
// Only include bridge tools if bridge is connected
if (bridgeConnection.status === 'connected') {
for (const collectionBridgeTool of collection.bridgeTools) {
// Find the tool definition from the bridge connection
const bridgeTool = availableBridgeTools.find(
(bt) =>
bt.serverId === collectionBridgeTool.serverId &&
bt.name === collectionBridgeTool.toolName
);
if (bridgeTool) {
bridgeTools.push(convertBridgeToolToMcp(bridgeTool, collectionBridgeTool.displayName));
}
}
}
}
return {
jsonrpc: '2.0',
id: requestId,
result: { tools },
result: { tools: [...registryTools, ...bridgeTools] },
};
} catch (error) {
console.error('[MCP tools/list] Error:', error);
@ -104,6 +140,18 @@ export async function handleToolsCall(
};
}
// Handle bridge tool calls
if (parsed.type === 'bridge') {
return handleBridgeToolCall(
collectionId,
parsed.serverId,
parsed.toolName,
params.arguments ?? {},
requestId
);
}
// Handle registry tool calls
// Verify tool exists in collection and get executor config
const collection = await withTimeout(
prisma.collection.findUnique({
@ -181,3 +229,148 @@ export async function handleToolsCall(
};
}
}
/**
* Handle a bridge tool call by routing it through the user's bridge connection
*/
async function handleBridgeToolCall(
collectionId: string,
serverId: string,
toolName: string,
args: Record<string, unknown>,
requestId: JsonRpcId
): Promise<JsonRpcResponse> {
try {
// Get the collection's owner and their bridge connection
const collection = await withTimeout(
prisma.collection.findUnique({
where: { id: collectionId },
include: {
bridgeTools: {
where: { serverId, toolName },
},
user: {
include: { bridgeConnection: true },
},
},
}),
DB_TIMEOUT_MS,
'Database query timed out'
);
if (!collection) {
return {
jsonrpc: '2.0',
id: requestId,
error: { code: -32602, message: 'Collection not found' },
};
}
// Verify the bridge tool exists in the collection
const bridgeTool = collection.bridgeTools[0];
if (!bridgeTool) {
return {
jsonrpc: '2.0',
id: requestId,
error: {
code: -32602,
message: `Bridge tool not found in collection: ${serverId}/${toolName}`,
},
};
}
// Verify the user has an active bridge connection
const bridgeConnection = collection.user.bridgeConnection;
if (!bridgeConnection || bridgeConnection.status !== 'connected') {
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: [
{
type: 'text',
text: 'Error: Bridge is not connected. Start the bridge CLI to use bridge tools.',
},
],
isError: true,
},
};
}
// Check if the bridge is stale (not seen in 2 minutes)
if (bridgeConnection.lastSeen) {
const staleThreshold = 2 * 60 * 1000; // 2 minutes
if (Date.now() - bridgeConnection.lastSeen.getTime() > staleThreshold) {
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: [
{
type: 'text',
text: 'Error: Bridge connection appears stale. Please check if the bridge CLI is running.',
},
],
isError: true,
},
};
}
}
// Generate a unique call ID
const callId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
// Queue the tool call for the bridge to pick up
queueBridgeToolCall(collection.user.id, callId, serverId, toolName, args);
// Wait for the result (with 5 minute timeout)
const result = await waitForBridgeResult(callId, 300000);
if (result.error) {
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: [{ type: 'text', text: `Error: ${result.error.message}` }],
isError: true,
},
};
}
// Format the result
const content = result.result as { content?: unknown[]; isError?: boolean } | undefined;
if (content?.content) {
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: content.content,
isError: content.isError,
},
};
}
return {
jsonrpc: '2.0',
id: requestId,
result: {
content: [
{
type: 'text',
text:
typeof result.result === 'string'
? result.result
: JSON.stringify(result.result, null, 2),
},
],
},
};
} catch (error) {
console.error('[MCP bridge tool call] Error:', error);
return {
jsonrpc: '2.0',
id: requestId,
error: { code: -32603, message: error instanceof Error ? error.message : 'Internal error' },
};
}
}

View file

@ -1,4 +1,9 @@
export { convertToMcpTool, parseToolName, sanitizeMcpName } from './tool-converter';
export type { McpToolDefinition } from './tool-converter';
export { handleInitialize, handleToolsCall, handleToolsList } from './handlers';
export type { BridgeTool, McpToolDefinition, ParsedToolName } from './tool-converter';
export {
convertBridgeToolToMcp,
convertToMcpTool,
parseToolName,
sanitizeBridgeToolName,
sanitizeMcpName,
} from './tool-converter';

View file

@ -6,6 +6,17 @@ export interface McpToolDefinition {
inputSchema: Record<string, unknown>;
}
/**
* Bridge tool definition from the BridgeConnection.tools JSON
*/
export interface BridgeTool {
serverId: string;
serverName: string;
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
/**
* Sanitize package name and tool name into a valid MCP tool name.
* MCP tool names must match ^[a-zA-Z0-9_-]+
@ -17,6 +28,17 @@ export function sanitizeMcpName(packageName: string, toolName: string): string {
return `${sanitizedPkg}--${toolName}`;
}
/**
* Create MCP name for a bridge tool
* Example: chrome-devtools + screenshot → bridge--chrome-devtools--screenshot
*/
export function sanitizeBridgeToolName(serverId: string, toolName: string): string {
// Sanitize serverId and toolName to only allow valid MCP characters
const sanitizedServer = serverId.replace(/[^a-zA-Z0-9_-]/g, '-');
const sanitizedTool = toolName.replace(/[^a-zA-Z0-9_-]/g, '-');
return `bridge--${sanitizedServer}--${sanitizedTool}`;
}
/**
* Convert a TPMJS Tool to an MCP tool definition.
*/
@ -32,12 +54,31 @@ export function convertToMcpTool(tool: Tool & { package: Package }): McpToolDefi
}
/**
* Parse an MCP tool name back into package name and tool name.
* Returns null if the name doesn't match the expected format.
*
* Example: tpmjs-hello--helloWorldTool → { packageName: "@tpmjs/hello", toolName: "helloWorldTool" }
* Parsed tool name result - either a registry tool or a bridge tool
*/
export function parseToolName(mcpName: string): { packageName: string; toolName: string } | null {
export type ParsedToolName =
| { type: 'registry'; packageName: string; toolName: string }
| { type: 'bridge'; serverId: string; toolName: string };
/**
* Parse an MCP tool name back into its components.
* Handles both registry tools and bridge tools.
*
* Registry: tpmjs-hello--helloWorldTool → { type: 'registry', packageName: "@tpmjs/hello", toolName: "helloWorldTool" }
* Bridge: bridge--chrome-devtools--screenshot → { type: 'bridge', serverId: "chrome-devtools", toolName: "screenshot" }
*/
export function parseToolName(mcpName: string): ParsedToolName | null {
// Check if it's a bridge tool
const bridgeMatch = mcpName.match(/^bridge--([^-]+(?:-[^-]+)*)--(.+)$/);
if (bridgeMatch && bridgeMatch[1] && bridgeMatch[2]) {
return {
type: 'bridge',
serverId: bridgeMatch[1],
toolName: bridgeMatch[2],
};
}
// Otherwise parse as registry tool
const match = mcpName.match(/^(.+)--(.+)$/);
if (!match || !match[1] || !match[2]) return null;
@ -48,5 +89,24 @@ export function parseToolName(mcpName: string): { packageName: string; toolName:
// tpmjs-hello → @tpmjs/hello (first dash becomes @scope/)
const packageName = pkg.includes('-') ? `@${pkg.replace('-', '/')}` : pkg;
return { packageName, toolName };
return { type: 'registry', packageName, toolName };
}
/**
* Convert a bridge tool definition to an MCP tool definition.
*/
export function convertBridgeToolToMcp(
tool: BridgeTool,
displayName?: string | null
): McpToolDefinition {
return {
name: sanitizeBridgeToolName(tool.serverId, tool.name),
description: displayName
? `[${tool.serverName}] ${displayName}`
: tool.description || `${tool.name} from ${tool.serverName}`,
inputSchema: tool.inputSchema ?? {
type: 'object',
properties: {},
},
};
}