feat: mcp

This commit is contained in:
Acbox
2026-01-14 23:57:38 +08:00
parent 83b9e4f09b
commit ce99749bdb
14 changed files with 1242 additions and 294 deletions
+2
View File
@@ -17,10 +17,12 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.9",
"@ai-sdk/google": "^3.0.6",
"@ai-sdk/mcp": "^1.0.6",
"@ai-sdk/openai": "^3.0.7",
"@memoh/ai-gateway": "workspace:*",
"@memoh/memory": "workspace:*",
"@memoh/shared": "workspace:*",
"@modelcontextprotocol/sdk": "^1.25.2",
"ai": "^6.0.25",
"dotenv": "^17.2.3",
"sqlite3": "^5.1.7",
+34 -2
View File
@@ -1,9 +1,11 @@
import { streamText, generateText, ModelMessage, stepCountIs, UserModelMessage } from 'ai'
import { streamText, generateText, ModelMessage, stepCountIs, UserModelMessage, Tool } from 'ai'
import { AgentParams } from './types'
import { system, schedule as schedulePrompt } from './prompts'
import { getMemoryTools, getScheduleTools, getMessageTools } from './tools'
import { createChatGateway } from '@memoh/ai-gateway'
import { Schedule } from '@memoh/shared'
import { MCPConnection, Schedule } from '@memoh/shared'
import { createMCPClient } from '@ai-sdk/mcp'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
export const createAgent = (params: AgentParams) => {
const messages: ModelMessage[] = []
@@ -16,8 +18,37 @@ export const createAgent = (params: AgentParams) => {
const currentPlatform = params.platforms
? platforms.find(p => p.name === params.currentPlatform)?.name ?? 'Unknown Platform'
: 'client'
const mcpConnections = params.mcpConnections ?? []
const launchMCPConnections = async () => {
const launch = async (connection: MCPConnection) => {
if (connection.type === 'http' || connection.type === 'sse') {
return await createMCPClient({
transport: {
url: connection.url,
headers: connection.headers,
type: connection.type,
}
})
} else if (connection.type === 'stdio') {
return await createMCPClient({
transport: new StdioClientTransport({
command: connection.command,
args: connection.args,
env: connection.env,
cwd: connection.cwd,
}),
})
}
}
const connections = await Promise.all(mcpConnections.map(launch))
return connections.filter(connection => connection !== undefined)
}
const getTools = async () => {
const connections = await launchMCPConnections()
const mcpTools = await Promise.all(connections.map(connection => connection.tools())) as Record<string, Tool>[]
const tools = Object.assign({}, ...mcpTools)
return {
...getMemoryTools({
searchMemory: params.onSearchMemory ?? (() => Promise.resolve([]))
@@ -31,6 +62,7 @@ export const createAgent = (params: AgentParams) => {
platforms,
params.onSendMessage ?? (() => Promise.resolve())
),
...tools,
}
}
+3 -1
View File
@@ -1,5 +1,5 @@
import type { MemoryUnit } from '@memoh/memory'
import { ChatModel, Platform, Schedule } from '@memoh/shared'
import { ChatModel, MCPConnection, Platform, Schedule } from '@memoh/shared'
import { ModelMessage } from 'ai'
export interface SendMessageOptions {
@@ -26,6 +26,8 @@ export interface AgentParams {
currentPlatform?: string
mcpConnections?: MCPConnection[]
onSendMessage?: (platform: string, options: SendMessageOptions) => Promise<void>
onReadMemory?: (from: Date, to: Date) => Promise<MemoryUnit[]>
@@ -3,6 +3,7 @@ import { createMemory, filterByTimestamp, MemoryUnit } from '@memoh/memory'
import { ChatModel, EmbeddingModel, Platform, Schedule } from '@memoh/shared'
import { createSchedule, deleteSchedule, getActiveSchedules } from '../schedule/service'
import { getActivePlatforms, sendMessageToPlatform } from '../platform/service'
import { getActiveMCPConnections } from '../mcp/service'
// Type for messages passed to onFinish callback
type MessageType = Record<string, unknown>
@@ -37,6 +38,7 @@ export async function createAgent(params: CreateAgentStreamParams) {
})
const platforms = await getActivePlatforms()
const mcpConnections = await getActiveMCPConnections(userId)
// Create agent
const agent = createAgentService({
@@ -45,6 +47,7 @@ export async function createAgent(params: CreateAgentStreamParams) {
language: language || 'Same as user input',
platforms: platforms as Platform[],
currentPlatform: platform,
mcpConnections,
onSendMessage: async (platform: string, options) => {
await sendMessageToPlatform(platform, {
message: options.message,
-6
View File
@@ -3,8 +3,6 @@ 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()),
@@ -14,8 +12,6 @@ const StdioMCPConnectionSchema = z.object({
// 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()),
})
@@ -23,8 +19,6 @@ const HTTPMCPConnectionSchema = z.object({
// 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()),
})
+2 -8
View File
@@ -3,6 +3,7 @@ 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'
import { MCPConnection } from '@memoh/shared'
/**
* MCP Connection 列表返回类型
@@ -77,14 +78,7 @@ export const getActiveMCPConnections = async (
.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,
}))
return connections.filter(conn => conn.active).map(conn => conn.config) as MCPConnection[]
}
/**
+328
View File
@@ -0,0 +1,328 @@
import type { Command } from 'commander'
import chalk from 'chalk'
import inquirer from 'inquirer'
import ora from 'ora'
import { table } from 'table'
import * as mcpCore from '../../core/mcp'
import { formatError } from '../../utils'
import type { MCPConnectionConfig } from '../../types'
export function mcpCommands(program: Command) {
program
.command('list')
.description('List all MCP connections')
.action(async () => {
try {
const spinner = ora('Fetching MCP connections list...').start()
try {
const connections = await mcpCore.listMCPConnections()
spinner.succeed(chalk.green('MCP Connections List'))
if (connections.length === 0) {
console.log(chalk.yellow('No MCP connections'))
return
}
const tableData = [
['ID', 'Name', 'Type', 'Active'],
...connections.map((conn) => [
conn.id.substring(0, 8) + '...',
conn.name,
conn.type,
conn.active ? chalk.green('Yes') : chalk.red('No'),
]),
]
console.log(table(tableData))
} catch (error) {
spinner.fail(chalk.red('Failed to fetch MCP connections list'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
program
.command('create')
.description('Create MCP connection')
.option('-n, --name <name>', 'Connection name')
.option('-t, --type <type>', 'Connection type (stdio/http/sse)')
.action(async (options) => {
try {
let { name, type } = options
// Get basic info
if (!name || !type) {
const basicAnswers = await inquirer.prompt([
{
type: 'input',
name: 'name',
message: 'Connection name:',
when: !name,
},
{
type: 'list',
name: 'type',
message: 'Connection type:',
choices: ['stdio', 'http', 'sse'],
when: !type,
},
])
name = name || basicAnswers.name
type = type || basicAnswers.type
}
let config: MCPConnectionConfig
// Get type-specific config
if (type === 'stdio') {
const stdioAnswers = await inquirer.prompt([
{
type: 'input',
name: 'command',
message: 'Command:',
},
{
type: 'input',
name: 'args',
message: 'Arguments (comma-separated, optional):',
default: '',
},
{
type: 'input',
name: 'cwd',
message: 'Working directory:',
default: process.cwd(),
},
{
type: 'input',
name: 'env',
message: 'Environment variables (key=value, comma-separated, optional):',
default: '',
},
])
const args = stdioAnswers.args ? stdioAnswers.args.split(',').map((s: string) => s.trim()) : []
const env: Record<string, string> = {}
if (stdioAnswers.env) {
stdioAnswers.env.split(',').forEach((pair: string) => {
const [key, value] = pair.split('=').map((s: string) => s.trim())
if (key && value) {
env[key] = value
}
})
}
config = {
type: 'stdio',
command: stdioAnswers.command,
args,
env,
cwd: stdioAnswers.cwd,
}
} else if (type === 'http' || type === 'sse') {
const httpAnswers = await inquirer.prompt([
{
type: 'input',
name: 'url',
message: 'URL:',
},
{
type: 'input',
name: 'headers',
message: 'Headers (key=value, comma-separated, optional):',
default: '',
},
])
const headers: Record<string, string> = {}
if (httpAnswers.headers) {
httpAnswers.headers.split(',').forEach((pair: string) => {
const [key, value] = pair.split('=').map((s: string) => s.trim())
if (key && value) {
headers[key] = value
}
})
}
config = {
type: type as 'http' | 'sse',
url: httpAnswers.url,
headers,
}
} else {
console.error(chalk.red('Invalid connection type'))
process.exit(1)
}
const { active } = await inquirer.prompt([
{
type: 'confirm',
name: 'active',
message: 'Activate connection?',
default: true,
},
])
const spinner = ora('Creating MCP connection...').start()
try {
const connection = await mcpCore.createMCPConnection({
name,
config,
active,
})
spinner.succeed(chalk.green('MCP connection created successfully'))
console.log(chalk.blue(`Name: ${connection.name}`))
console.log(chalk.blue(`Type: ${connection.type}`))
console.log(chalk.blue(`ID: ${connection.id}`))
} catch (error) {
spinner.fail(chalk.red('Failed to create MCP connection'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
program
.command('get <id>')
.description('Get MCP connection details')
.action(async (id) => {
try {
const spinner = ora('Fetching MCP connection details...').start()
try {
const connection = await mcpCore.getMCPConnection(id)
spinner.succeed(chalk.green('MCP Connection Details'))
console.log(chalk.blue(`ID: ${connection.id}`))
console.log(chalk.blue(`Name: ${connection.name}`))
console.log(chalk.blue(`Type: ${connection.type}`))
console.log(
chalk.blue(`Active: ${connection.active ? chalk.green('Yes') : chalk.red('No')}`)
)
console.log(chalk.blue(`Config:`))
console.log(JSON.stringify(connection.config, null, 2))
} catch (error) {
spinner.fail(chalk.red('Failed to fetch MCP connection'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
program
.command('update <id>')
.description('Update MCP connection')
.option('-n, --name <name>', 'Connection name')
.option('-a, --active <boolean>', 'Active status (true/false)')
.action(async (id, options) => {
try {
const updates: {
name?: string
active?: boolean
} = {}
if (options.name) updates.name = options.name
if (options.active !== undefined) {
updates.active = options.active === 'true' || options.active === true
}
if (Object.keys(updates).length === 0) {
console.log(chalk.yellow('No update parameters provided'))
console.log(chalk.yellow('Note: Config updates are not yet supported via CLI'))
return
}
const spinner = ora('Updating MCP connection...').start()
try {
await mcpCore.updateMCPConnection(id, updates)
spinner.succeed(chalk.green('MCP connection updated'))
} catch (error) {
spinner.fail(chalk.red('Failed to update MCP connection'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
program
.command('delete <id>')
.description('Delete MCP connection')
.action(async (id) => {
try {
const { confirm } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirm',
message: chalk.yellow(`Are you sure you want to delete MCP connection ${id}?`),
default: false,
},
])
if (!confirm) {
console.log(chalk.yellow('Cancelled'))
return
}
const spinner = ora('Deleting MCP connection...').start()
try {
await mcpCore.deleteMCPConnection(id)
spinner.succeed(chalk.green('MCP connection deleted'))
} catch (error) {
spinner.fail(chalk.red('Failed to delete MCP connection'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
program
.command('toggle <id>')
.description('Toggle MCP connection active status')
.action(async (id) => {
try {
const spinner = ora('Toggling connection status...').start()
try {
const newStatus = await mcpCore.toggleMCPConnection(id)
spinner.succeed(
chalk.green(`Connection ${newStatus ? 'activated' : 'deactivated'}`)
)
} catch (error) {
spinner.fail(chalk.red('Failed to toggle connection'))
console.error(chalk.red(formatError(error)))
process.exit(1)
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
console.error(chalk.red('Error:'), message)
process.exit(1)
}
})
}
+5
View File
@@ -10,6 +10,7 @@ import { agentCommands, startInteractiveMode } from './commands/agent'
import { memoryCommands } from './commands/memory'
import { configCommands } from './commands/config'
import { scheduleCommands } from './commands/schedule'
import { mcpCommands } from './commands/mcp'
import { debugCommands } from './commands/debug'
const program = new Command()
@@ -51,6 +52,10 @@ configCommands(config)
const schedule = program.command('schedule').description('Schedule management')
scheduleCommands(schedule)
// MCP management commands
const mcp = program.command('mcp').description('MCP connection management')
mcpCommands(mcp)
// Debug commands
const debug = program.command('debug').description('Debug tools')
debugCommands(debug)
+12
View File
@@ -103,6 +103,18 @@ export {
type UpdateScheduleParams,
} from './schedule'
// MCP
export {
listMCPConnections,
createMCPConnection,
getMCPConnection,
updateMCPConnection,
deleteMCPConnection,
toggleMCPConnection,
type CreateMCPConnectionParams,
type UpdateMCPConnectionParams,
} from './mcp'
// Settings
export {
getSettings,
+212
View File
@@ -0,0 +1,212 @@
import { createClient, requireAuth } from './client'
import type { MCPConnection, MCPConnectionConfig } from '../types'
export interface CreateMCPConnectionParams {
name: string
config: MCPConnectionConfig
active?: boolean
}
export interface UpdateMCPConnectionParams {
name?: string
config?: MCPConnectionConfig
active?: boolean
}
/**
* List all MCP connections
*/
export async function listMCPConnections(): Promise<MCPConnection[]> {
requireAuth()
const client = createClient()
const response = await client.mcp.get()
if (response.error) {
const errorValue = response.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to fetch MCP connections list')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = response.data as any
if (data?.success && data?.data) {
return data.data
}
throw new Error('Failed to fetch MCP connections list')
}
/**
* Create MCP connection
*/
export async function createMCPConnection(params: CreateMCPConnectionParams): Promise<MCPConnection> {
requireAuth()
const client = createClient()
const payload = {
name: params.name,
config: params.config,
active: params.active ?? true,
}
const response = await client.mcp.post(payload)
if (response.error) {
const errorValue = response.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to create MCP connection')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = response.data as any
if (data?.success && data?.data) {
return data.data
}
throw new Error('Failed to create MCP connection')
}
/**
* Get MCP connection by ID
*/
export async function getMCPConnection(id: string): Promise<MCPConnection> {
requireAuth()
const client = createClient()
const response = await client.mcp({ id }).get()
if (response.error) {
const errorValue = response.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to fetch MCP connection')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = response.data as any
if (data?.success && data?.data) {
return data.data
}
throw new Error('Failed to fetch MCP connection')
}
/**
* Update MCP connection
*/
export async function updateMCPConnection(id: string, params: UpdateMCPConnectionParams): Promise<void> {
requireAuth()
const client = createClient()
const response = await client.mcp({ id }).put(params)
if (response.error) {
const errorValue = response.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to update MCP connection')
}
}
/**
* Delete MCP connection
*/
export async function deleteMCPConnection(id: string): Promise<void> {
requireAuth()
const client = createClient()
const response = await client.mcp({ id }).delete()
if (response.error) {
const errorValue = response.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to delete MCP connection')
}
}
/**
* Toggle MCP connection active status
*/
export async function toggleMCPConnection(id: string): Promise<boolean> {
requireAuth()
const client = createClient()
// First get current status
const getResponse = await client.mcp({ id }).get()
if (getResponse.error) {
const errorValue = getResponse.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to get MCP connection')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getData = getResponse.data as any
if (getData?.success && getData?.data) {
const currentActive = getData.data.active
// Update status
const updateResponse = await client.mcp({ id }).put({
active: !currentActive,
})
if (updateResponse.error) {
const errorValue = updateResponse.error.value
if (typeof errorValue === 'string') {
throw new Error(errorValue)
} else if (typeof errorValue === 'object' && errorValue !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorObj = errorValue as any
const errorMsg = errorObj.error || errorObj.message || JSON.stringify(errorValue)
throw new Error(errorMsg)
}
throw new Error('Failed to update MCP connection')
}
return !currentActive
}
throw new Error('Failed to toggle MCP connection status')
}
+34
View File
@@ -79,3 +79,37 @@ export interface Platform {
updatedAt: string
}
export interface MCPConnection {
id: string
type: string
name: string
config: MCPConnectionConfig
active: boolean
user: string
}
export type MCPConnectionConfig =
| StdioMCPConnection
| HTTPMCPConnection
| SSEMCPConnection
export interface StdioMCPConnection {
type: 'stdio'
command: string
args: string[]
env: Record<string, string>
cwd: string
}
export interface HTTPMCPConnection {
type: 'http'
url: string
headers: Record<string, string>
}
export interface SSEMCPConnection {
type: 'sse'
url: string
headers: Record<string, string>
}
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './model'
export * from './schedule'
export * from './platform'
export * from './platform'
export * from './mcp'
-1
View File
@@ -1,7 +1,6 @@
export interface BaseMCPConnection {
type: string
name: string
active: boolean
}
export interface StdioMCPConnection extends BaseMCPConnection {
+605 -275
View File
File diff suppressed because it is too large Load Diff