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:
parent
490d76a50b
commit
c8c1a12f22
37 changed files with 7318 additions and 23 deletions
241
apps/web/src/app/api/bridge/route.ts
Normal file
241
apps/web/src/app/api/bridge/route.ts
Normal 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
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
354
apps/web/src/app/api/collections/[id]/bridge-tools/route.ts
Normal file
354
apps/web/src/app/api/collections/[id]/bridge-tools/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
84
apps/web/src/app/api/user/bridge/route.ts
Normal file
84
apps/web/src/app/api/user/bridge/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
282
apps/web/src/app/dashboard/settings/bridge/page.tsx
Normal file
282
apps/web/src/app/dashboard/settings/bridge/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
712
apps/web/src/app/docs/tutorials/bridge/page.tsx
Normal file
712
apps/web/src/app/docs/tutorials/bridge/page.tsx
Normal 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'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 <name>
|
||||
</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 <name>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue