diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index a6d18eb2..3c7d47b5 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,8 +1,16 @@ import { Elysia } from 'elysia' import { corsMiddleware, errorMiddleware } from './middlewares' -import { agentModule, authModule, modelModule, scheduleModule, settingsModule, userModule } from './modules' -import { memoryModule } from './modules/memory' -import { platformModule } from './modules/platform' +import { + agentModule, + authModule, + modelModule, + scheduleModule, + settingsModule, + userModule, + platformModule, + memoryModule, + mcpModule, +} from './modules' import openapi from '@elysiajs/openapi' const port = process.env.API_SERVER_PORT || 7002 @@ -19,6 +27,7 @@ export const app = new Elysia() .use(settingsModule) .use(userModule) .use(platformModule) + .use(mcpModule) .listen(port) console.log( diff --git a/packages/api/src/modules/index.ts b/packages/api/src/modules/index.ts index bd5d0877..9a20a6cd 100644 --- a/packages/api/src/modules/index.ts +++ b/packages/api/src/modules/index.ts @@ -3,4 +3,8 @@ export * from './auth' export * from './model' export * from './schedule' export * from './settings' -export * from './user' \ No newline at end of file +export * from './user' +export * from './mcp' +export * from './platform' +export * from './schedule' +export * from './memory' \ No newline at end of file diff --git a/packages/api/src/modules/mcp/index.ts b/packages/api/src/modules/mcp/index.ts new file mode 100644 index 00000000..f0221bdf --- /dev/null +++ b/packages/api/src/modules/mcp/index.ts @@ -0,0 +1,152 @@ +import Elysia from 'elysia' +import { authMiddleware } from '../../middlewares/auth' +import { + CreateMCPConnectionModel, + UpdateMCPConnectionModel, + GetMCPConnectionByIdModel, + DeleteMCPConnectionModel, + GetMCPConnectionsModel, +} from './model' +import { + getMCPConnections, + getMCPConnection, + createMCPConnection, + updateMCPConnection, + deleteMCPConnection, +} from './service' + +export const mcpModule = new Elysia({ prefix: '/mcp' }) + .use(authMiddleware) + // Get all MCP connections for current user + .get('/', async ({ user, query }) => { + try { + const page = parseInt(query.page as string) || 1 + const limit = parseInt(query.limit as string) || 10 + const sortOrder = (query.sortOrder as string) || 'desc' + + const result = await getMCPConnections(user.userId, { + page, + limit, + sortOrder: sortOrder as 'asc' | 'desc', + }) + + return { + success: true, + ...result, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch MCP connections', + } + } + }, GetMCPConnectionsModel) + // Get MCP connection by ID + .get('/:id', async ({ user, params, set }) => { + try { + const connection = await getMCPConnection(params.id) + + if (!connection) { + set.status = 404 + return { + success: false, + error: 'MCP connection not found', + } + } + + if (connection.user !== user.userId) { + set.status = 403 + return { + success: false, + error: 'Forbidden: You do not have permission to access this MCP connection', + } + } + + return { + success: true, + data: connection, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to fetch MCP connection', + } + } + }, GetMCPConnectionByIdModel) + // Create new MCP connection + .post('/', async ({ user, body, set }) => { + try { + const newConnection = await createMCPConnection(user.userId, body) + + set.status = 201 + return { + success: true, + data: newConnection, + } + } catch (error) { + set.status = 500 + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create MCP connection', + } + } + }, CreateMCPConnectionModel) + // Update MCP connection + .put('/:id', async ({ user, params, body, set }) => { + try { + const updatedConnection = await updateMCPConnection(params.id, user.userId, body) + + if (!updatedConnection) { + set.status = 404 + return { + success: false, + error: 'MCP connection not found', + } + } + + return { + success: true, + data: updatedConnection, + } + } catch (error) { + if (error instanceof Error && error.message.includes('Forbidden')) { + set.status = 403 + } else { + set.status = 500 + } + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update MCP connection', + } + } + }, UpdateMCPConnectionModel) + // Delete MCP connection + .delete('/:id', async ({ user, params, set }) => { + try { + const deletedConnection = await deleteMCPConnection(params.id, user.userId) + + if (!deletedConnection) { + set.status = 404 + return { + success: false, + error: 'MCP connection not found', + } + } + + return { + success: true, + data: deletedConnection, + } + } catch (error) { + if (error instanceof Error && error.message.includes('Forbidden')) { + set.status = 403 + } else { + set.status = 500 + } + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to delete MCP connection', + } + } + }, DeleteMCPConnectionModel) diff --git a/packages/api/src/modules/mcp/model.ts b/packages/api/src/modules/mcp/model.ts new file mode 100644 index 00000000..06a925d2 --- /dev/null +++ b/packages/api/src/modules/mcp/model.ts @@ -0,0 +1,90 @@ +import { z } from 'zod' + +// Stdio MCP 连接配置 +const StdioMCPConnectionSchema = z.object({ + type: z.literal('stdio'), + name: z.string().min(1, 'Name is required').max(100), + active: z.boolean(), + command: z.string().min(1, 'Command is required'), + args: z.array(z.string()), + env: z.record(z.string(), z.string()), + cwd: z.string(), +}) + +// HTTP MCP 连接配置 +const HTTPMCPConnectionSchema = z.object({ + type: z.literal('http'), + name: z.string().min(1, 'Name is required').max(100), + active: z.boolean(), + url: z.string().url('Invalid URL'), + headers: z.record(z.string(), z.string()), +}) + +// SSE MCP 连接配置 +const SSEMCPConnectionSchema = z.object({ + type: z.literal('sse'), + name: z.string().min(1, 'Name is required').max(100), + active: z.boolean(), + url: z.string().url('Invalid URL'), + headers: z.record(z.string(), z.string()), +}) + +// 联合类型 +const MCPConnectionConfigSchema = z.union([ + StdioMCPConnectionSchema, + HTTPMCPConnectionSchema, + SSEMCPConnectionSchema, +]) + +// 创建 MCP 连接的 Schema +const CreateMCPConnectionSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + config: MCPConnectionConfigSchema, + active: z.boolean().default(true), +}) + +// 更新 MCP 连接的 Schema +const UpdateMCPConnectionSchema = z.object({ + name: z.string().min(1).max(100).optional(), + config: MCPConnectionConfigSchema.optional(), + active: z.boolean().optional(), +}) + +// 查询参数 Schema +const GetMCPConnectionsQuerySchema = z.object({ + page: z.string().optional(), + limit: z.string().optional(), + sortOrder: z.enum(['asc', 'desc']).optional(), +}) + +export type CreateMCPConnectionInput = z.infer +export type UpdateMCPConnectionInput = z.infer +export type GetMCPConnectionsQuery = z.infer + +export const CreateMCPConnectionModel = { + body: CreateMCPConnectionSchema, +} + +export const UpdateMCPConnectionModel = { + params: z.object({ + id: z.string().uuid('Invalid MCP connection ID format'), + }), + body: UpdateMCPConnectionSchema, +} + +export const GetMCPConnectionByIdModel = { + params: z.object({ + id: z.string().uuid('Invalid MCP connection ID format'), + }), +} + +export const DeleteMCPConnectionModel = { + params: z.object({ + id: z.string().uuid('Invalid MCP connection ID format'), + }), +} + +export const GetMCPConnectionsModel = { + query: GetMCPConnectionsQuerySchema, +} + diff --git a/packages/api/src/modules/mcp/service.ts b/packages/api/src/modules/mcp/service.ts new file mode 100644 index 00000000..9d8b8f22 --- /dev/null +++ b/packages/api/src/modules/mcp/service.ts @@ -0,0 +1,239 @@ +import { db } from '@memoh/db' +import { mcpConnection } from '@memoh/db/schema' +import { eq, desc, asc, sql } from 'drizzle-orm' +import { calculateOffset, createPaginatedResult, type PaginatedResult } from '../../utils/pagination' +import type { CreateMCPConnectionInput, UpdateMCPConnectionInput } from './model' + +/** + * MCP Connection 列表返回类型 + */ +type MCPConnectionListItem = { + id: string + type: string + name: string + config: unknown + active: boolean + user: string + createdAt: Date + updatedAt: Date +} + +/** + * 获取用户的所有 MCP 连接(支持分页) + */ +export const getMCPConnections = async ( + userId: string, + params?: { + limit?: number + page?: number + sortOrder?: 'asc' | 'desc' + } +): Promise> => { + const limit = params?.limit || 10 + const page = params?.page || 1 + const sortOrder = params?.sortOrder || 'desc' + const offset = calculateOffset(page, limit) + + // 获取总数 + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(mcpConnection) + .where(eq(mcpConnection.user, userId)) + + // 获取分页数据 + const orderFn = sortOrder === 'desc' ? desc : asc + const connections = await db + .select() + .from(mcpConnection) + .where(eq(mcpConnection.user, userId)) + .orderBy(orderFn(mcpConnection.id)) + .limit(limit) + .offset(offset) + + // 类型转换 + const formattedConnections = connections.map(conn => ({ + id: conn.id, + type: conn.type, + name: conn.name, + config: conn.config, + active: conn.active, + user: conn.user, + createdAt: new Date(), + updatedAt: new Date(), + })) + + return createPaginatedResult(formattedConnections, Number(count), page, limit) +} + +/** + * 获取用户的所有活跃 MCP 连接 + */ +export const getActiveMCPConnections = async ( + userId: string +) => { + const connections = await db + .select() + .from(mcpConnection) + .where(eq(mcpConnection.user, userId)) + .orderBy(desc(mcpConnection.id)) + + return connections.filter(conn => conn.active).map(conn => ({ + id: conn.id, + type: conn.type, + name: conn.name, + config: conn.config, + active: conn.active, + user: conn.user, + })) +} + +/** + * 根据 ID 获取单个 MCP 连接 + */ +export const getMCPConnection = async ( + connectionId: string +) => { + const [result] = await db + .select() + .from(mcpConnection) + .where(eq(mcpConnection.id, connectionId)) + + if (!result) { + return null + } + + return { + id: result.id, + type: result.type, + name: result.name, + config: result.config, + active: result.active, + user: result.user, + } +} + +/** + * 创建新的 MCP 连接 + */ +export const createMCPConnection = async ( + userId: string, + data: CreateMCPConnectionInput +) => { + const [newConnection] = await db + .insert(mcpConnection) + .values({ + user: userId, + type: data.config.type, + name: data.name, + config: data.config, + active: data.active, + }) + .returning() + + return { + id: newConnection.id, + type: newConnection.type, + name: newConnection.name, + config: newConnection.config, + active: newConnection.active, + user: newConnection.user, + } +} + +/** + * 更新 MCP 连接 + */ +export const updateMCPConnection = async ( + connectionId: string, + userId: string, + data: UpdateMCPConnectionInput +) => { + // 检查 MCP 连接是否存在且属于该用户 + const existingConnection = await getMCPConnection(connectionId) + if (!existingConnection) { + return null + } + + if (existingConnection.user !== userId) { + throw new Error('Forbidden: You do not have permission to update this MCP connection') + } + + const updateData: { + name?: string + config?: unknown + type?: string + active?: boolean + } = {} + + if (data.name !== undefined) { + updateData.name = data.name + } + if (data.config !== undefined) { + updateData.config = data.config + updateData.type = data.config.type + } + if (data.active !== undefined) { + updateData.active = data.active + } + + const [updatedConnection] = await db + .update(mcpConnection) + .set(updateData) + .where(eq(mcpConnection.id, connectionId)) + .returning() + + return { + id: updatedConnection.id, + type: updatedConnection.type, + name: updatedConnection.name, + config: updatedConnection.config, + active: updatedConnection.active, + user: updatedConnection.user, + } +} + +/** + * 删除 MCP 连接 + */ +export const deleteMCPConnection = async ( + connectionId: string, + userId: string +) => { + // 检查 MCP 连接是否存在且属于该用户 + const existingConnection = await getMCPConnection(connectionId) + if (!existingConnection) { + return null + } + + if (existingConnection.user !== userId) { + throw new Error('Forbidden: You do not have permission to delete this MCP connection') + } + + const [deletedConnection] = await db + .delete(mcpConnection) + .where(eq(mcpConnection.id, connectionId)) + .returning() + + return { + id: deletedConnection.id, + type: deletedConnection.type, + name: deletedConnection.name, + config: deletedConnection.config, + active: deletedConnection.active, + user: deletedConnection.user, + } +} + +/** + * 设置 MCP 连接的活跃状态 + */ +export const setMCPConnectionActive = async ( + connectionId: string, + active: boolean +) => { + await db + .update(mcpConnection) + .set({ active }) + .where(eq(mcpConnection.id, connectionId)) +} + diff --git a/packages/db/src/mcp-connection.ts b/packages/db/src/mcp-connection.ts new file mode 100644 index 00000000..b864c720 --- /dev/null +++ b/packages/db/src/mcp-connection.ts @@ -0,0 +1,11 @@ +import { boolean, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core' +import { users } from './users' + +export const mcpConnection = pgTable('mcp_connection', { + id: uuid('id').primaryKey().defaultRandom(), + type: text('type').notNull(), + name: text('name').notNull(), + config: jsonb('config').notNull(), + active: boolean('active').notNull().default(true), + user: uuid('user').notNull().references(() => users.id), +}) \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 8d758267..a7610079 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -3,4 +3,5 @@ export * from './model' export * from './settings' export * from './schedule' export * from './users' -export * from './platform' \ No newline at end of file +export * from './platform' +export * from './mcp-connection' \ No newline at end of file diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts new file mode 100644 index 00000000..e8ec964f --- /dev/null +++ b/packages/shared/src/mcp.ts @@ -0,0 +1,31 @@ +export interface BaseMCPConnection { + type: string + name: string + active: boolean +} + +export interface StdioMCPConnection extends BaseMCPConnection { + type: 'stdio' + command: string + args: string[] + env: Record + cwd: string +} + +export interface BaseHTTPMCPConnection extends BaseMCPConnection { + url: string + headers: Record +} + +export interface HTTPMCPConnection extends BaseHTTPMCPConnection { + type: 'http' +} + +export interface SSEMCPConnection extends BaseHTTPMCPConnection { + type: 'sse' +} + +export type MCPConnection = + | StdioMCPConnection + | HTTPMCPConnection + | SSEMCPConnection