mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat: mcp-connection
This commit is contained in:
@@ -1,8 +1,16 @@
|
|||||||
import { Elysia } from 'elysia'
|
import { Elysia } from 'elysia'
|
||||||
import { corsMiddleware, errorMiddleware } from './middlewares'
|
import { corsMiddleware, errorMiddleware } from './middlewares'
|
||||||
import { agentModule, authModule, modelModule, scheduleModule, settingsModule, userModule } from './modules'
|
import {
|
||||||
import { memoryModule } from './modules/memory'
|
agentModule,
|
||||||
import { platformModule } from './modules/platform'
|
authModule,
|
||||||
|
modelModule,
|
||||||
|
scheduleModule,
|
||||||
|
settingsModule,
|
||||||
|
userModule,
|
||||||
|
platformModule,
|
||||||
|
memoryModule,
|
||||||
|
mcpModule,
|
||||||
|
} from './modules'
|
||||||
import openapi from '@elysiajs/openapi'
|
import openapi from '@elysiajs/openapi'
|
||||||
|
|
||||||
const port = process.env.API_SERVER_PORT || 7002
|
const port = process.env.API_SERVER_PORT || 7002
|
||||||
@@ -19,6 +27,7 @@ export const app = new Elysia()
|
|||||||
.use(settingsModule)
|
.use(settingsModule)
|
||||||
.use(userModule)
|
.use(userModule)
|
||||||
.use(platformModule)
|
.use(platformModule)
|
||||||
|
.use(mcpModule)
|
||||||
.listen(port)
|
.listen(port)
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ export * from './auth'
|
|||||||
export * from './model'
|
export * from './model'
|
||||||
export * from './schedule'
|
export * from './schedule'
|
||||||
export * from './settings'
|
export * from './settings'
|
||||||
export * from './user'
|
export * from './user'
|
||||||
|
export * from './mcp'
|
||||||
|
export * from './platform'
|
||||||
|
export * from './schedule'
|
||||||
|
export * from './memory'
|
||||||
@@ -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)
|
||||||
@@ -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<typeof CreateMCPConnectionSchema>
|
||||||
|
export type UpdateMCPConnectionInput = z.infer<typeof UpdateMCPConnectionSchema>
|
||||||
|
export type GetMCPConnectionsQuery = z.infer<typeof GetMCPConnectionsQuerySchema>
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<PaginatedResult<MCPConnectionListItem>> => {
|
||||||
|
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<number>`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))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
@@ -3,4 +3,5 @@ export * from './model'
|
|||||||
export * from './settings'
|
export * from './settings'
|
||||||
export * from './schedule'
|
export * from './schedule'
|
||||||
export * from './users'
|
export * from './users'
|
||||||
export * from './platform'
|
export * from './platform'
|
||||||
|
export * from './mcp-connection'
|
||||||
@@ -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<string, string>
|
||||||
|
cwd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BaseHTTPMCPConnection extends BaseMCPConnection {
|
||||||
|
url: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HTTPMCPConnection extends BaseHTTPMCPConnection {
|
||||||
|
type: 'http'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEMCPConnection extends BaseHTTPMCPConnection {
|
||||||
|
type: 'sse'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MCPConnection =
|
||||||
|
| StdioMCPConnection
|
||||||
|
| HTTPMCPConnection
|
||||||
|
| SSEMCPConnection
|
||||||
Reference in New Issue
Block a user