Compare commits

...

6 Commits

Author SHA1 Message Date
Junyan Qin
558587883b chore: update project version to 4.7.2 2026-01-13 14:02:00 +08:00
Junyan Qin
2e6a1daf4f feat(mcp): extend mode options in MCPCardVO to include 'http' 2026-01-13 13:59:59 +08:00
Tiankai Ma
1fc5e75f93 feat(mcp): add streamable HTTP and stdio (#1911)
* feat(mcp): add streamable HTTP

alongside with frontend UI change, w/ support for stdio

* fix(mcp): address copilot reviews

* Update src/langbot/pkg/provider/tools/loaders/mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: resolve copilot reviews

* fix: Message -> MessageChunk

* feat: upgrade mcp module

* feat: add i18n

* feat(mcp): enhance MCPCardComponent with mode badge and reorder select items in MCPFormDialog

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
2026-01-13 13:50:06 +08:00
fdc310
a332206ba3 fix: When the deletion of the thinking chain is activated, since the "continue" is triggered as soon as the thinking begins, it causes a bug in the subsequent judgment that breaks out of the loop impression. (#1913) 2026-01-12 00:14:39 +08:00
Junyan Qin
8e620dc635 fix: remove unreachable assertion in ChatMessageHandler to improve error handling 2026-01-09 23:46:43 +08:00
Junyan Qin
c9a21ebace fix: improve error handling in ChatMessageHandler 2026-01-09 23:23:53 +08:00
15 changed files with 493 additions and 150 deletions

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.7.1" version = "4.7.2"
description = "Production-grade platform for building IM bots" description = "Production-grade platform for building IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -23,7 +23,7 @@ dependencies = [
"pynacl>=1.5.0", # Required for Discord voice support "pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5", "gewechat-client>=0.1.5",
"lark-oapi>=1.4.15", "lark-oapi>=1.4.15",
"mcp>=1.8.1", "mcp>=1.20.0",
"nakuru-project-idk>=0.0.2.1", "nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8", "ollama>=0.4.8",
"openai>1.0.0", "openai>1.0.0",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building IM bots""" """LangBot - Production-grade platform for building IM bots"""
__version__ = '4.7.1' __version__ = '4.7.2'

View File

@@ -9,7 +9,7 @@ class MCPServer(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True) uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={}) extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column( updated_at = sqlalchemy.Column(

View File

@@ -145,7 +145,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.extend(query.resp_messages) query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e: except Exception as e:
error_info = f'{type(e).__name__} {str(e)}' error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}') self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
traceback.print_exc() traceback.print_exc()

View File

@@ -529,7 +529,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
think_end = True think_end = True
elif think_end or not think_start: elif think_end or not think_start:
pending_agent_message += chunk['answer'] pending_agent_message += chunk['answer']
if think_start: if think_start and not think_end:
continue continue
else: else:

View File

@@ -215,16 +215,24 @@ class LocalAgentRunner(runner.RequestRunner):
parameters = json.loads(func.arguments) parameters = json.loads(func.arguments)
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query) func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
# Handle return value content
tool_content = None
if isinstance(func_ret, list) and len(func_ret) > 0 and isinstance(func_ret[0], provider_message.ContentElement):
tool_content = func_ret
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
if is_stream: if is_stream:
msg = provider_message.MessageChunk( msg = provider_message.MessageChunk(
role='tool', role='tool',
content=json.dumps(func_ret, ensure_ascii=False), content=tool_content,
tool_call_id=tool_call.id, tool_call_id=tool_call.id,
) )
else: else:
msg = provider_message.Message( msg = provider_message.Message(
role='tool', role='tool',
content=json.dumps(func_ret, ensure_ascii=False), content=tool_content,
tool_call_id=tool_call.id, tool_call_id=tool_call.id,
) )

View File

@@ -7,14 +7,18 @@ import traceback
from langbot_plugin.api.entities.events import pipeline_query from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy import sqlalchemy
import asyncio import asyncio
import httpx
import uuid as uuid_module import uuid as uuid_module
from mcp import ClientSession, StdioServerParameters from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from .. import loader from .. import loader
from ....core import app from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp from ....entity.persistence import mcp as persistence_mcp
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
server_config: dict server_config: dict
session: ClientSession session: ClientSession | None
exit_stack: AsyncExitStack exit_stack: AsyncExitStack
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event _ready_event: asyncio.Event
error_message: str | None = None
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application): def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
self.server_name = server_name self.server_name = server_name
self.server_uuid = server_config.get('uuid', '') self.server_uuid = server_config.get('uuid', '')
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
await self.session.initialize() await self.session.initialize()
async def _init_streamable_http_server(self):
transport = await self.exit_stack.enter_async_context(
streamable_http_client(
self.server_config['url'],
http_client=httpx.AsyncClient(
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
follow_redirects=True,
),
)
)
read, write, _ = transport
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
async def _lifecycle_loop(self): async def _lifecycle_loop(self):
"""在后台任务中管理整个MCP会话的生命周期""" """在后台任务中管理整个MCP会话的生命周期"""
try: try:
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
await self._init_stdio_python_server() await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse': elif self.server_config['mode'] == 'sse':
await self._init_sse_server() await self._init_sse_server()
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
else: else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}') raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
except Exception as e: except Exception as e:
self.status = MCPSessionStatus.ERROR self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# 即使出错也要设置ready事件让start()方法知道初始化已完成 # 即使出错也要设置ready事件让start()方法知道初始化已完成
self._ready_event.set() self._ready_event.set()
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
raise Exception('Connection failed, please check URL') raise Exception('Connection failed, please check URL')
async def refresh(self): async def refresh(self):
if not self.session:
return
self.functions.clear() self.functions.clear()
tools = await self.session.list_tools() tools = await self.session.list_tools()
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
for tool in tools.tools: for tool in tools.tools:
async def func(*, _tool=tool, **kwargs): async def func(*, _tool=tool, **kwargs):
if not self.session:
raise Exception("MCP session is not connected")
result = await self.session.call_tool(_tool.name, kwargs) result = await self.session.call_tool(_tool.name, kwargs)
if result.isError: if result.isError:
raise Exception(result.content[0].text) error_texts = []
return result.content[0].text for content in result.content:
if content.type == 'text':
error_texts.append(content.text)
raise Exception("\n".join(error_texts) if error_texts else "Unknown error from MCP tool")
result_contents: list[provider_message.ContentElement] = []
for content in result.content:
if content.type == 'text':
result_contents.append(provider_message.ContentElement.from_text(content.text))
elif content.type == 'image':
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
elif content.type == 'resource':
# TODO: Handle resource content
pass
return result_contents
func.__name__ = tool.name func.__name__ = tool.name
self.functions.append( self.functions.append(
resource_tool.LLMTool( resource_tool.LLMTool(
name=tool.name, name=tool.name,
human_desc=tool.description, human_desc=tool.description or "",
description=tool.description, description=tool.description or "",
parameters=tool.inputSchema, parameters=tool.inputSchema,
func=func, func=func,
) )
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict: def get_runtime_info_dict(self) -> dict:
return { return {
'status': self.status.value, 'status': self.status.value,
'error_message': self.error_message,
'tool_count': len(self.get_tools()), 'tool_count': len(self.get_tools()),
'tools': [ 'tools': [
{ {

View File

@@ -2,7 +2,7 @@ import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
export class MCPCardVO { export class MCPCardVO {
name: string; name: string;
mode: 'stdio' | 'sse'; mode: 'stdio' | 'sse' | 'http';
enable: boolean; enable: boolean;
status: MCPSessionStatus; status: MCPSessionStatus;
tools: number; tools: number;

View File

@@ -3,6 +3,7 @@ import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react'; import { RefreshCcw, Wrench, Ban, AlertCircle, Loader2 } from 'lucide-react';
@@ -98,9 +99,14 @@ export default function MCPCardComponent({
</svg> </svg>
<div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]"> <div className="w-full h-full flex flex-col items-start justify-between gap-[0.6rem]">
<div className="flex flex-col items-start justify-start"> <div className="flex flex-col items-start justify-start gap-[0.3rem]">
<div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium"> <div className="flex flex-row items-center gap-[0.5rem]">
{cardVO.name} <div className="text-[1.2rem] text-black dark:text-[#f0f0f0] font-medium">
{cardVO.name}
</div>
<Badge variant="secondary" className="text-[0.65rem] px-1.5 py-0">
{cardVO.mode.toUpperCase()}
</Badge>
</div> </div>
</div> </div>

View File

@@ -43,6 +43,9 @@ import {
MCPTool, MCPTool,
MCPServer, MCPServer,
MCPSessionStatus, MCPSessionStatus,
MCPServerExtraArgsSSE,
MCPServerExtraArgsHttp,
MCPServerExtraArgsStdio,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common'; import { CustomApiError } from '@/app/infra/entities/common';
@@ -133,11 +136,11 @@ function StatusDisplay({
</svg> </svg>
<span className="font-medium">{t('mcp.connectionFailed')}</span> <span className="font-medium">{t('mcp.connectionFailed')}</span>
</div> </div>
{/* {runtimeInfo.error_message && ( {runtimeInfo.error_message && (
<div className="text-sm text-red-500 pl-7"> <div className="text-sm text-red-500 pl-7">
{runtimeInfo.error_message} {runtimeInfo.error_message}
</div> </div>
)} */} )}
</div> </div>
); );
} }
@@ -163,31 +166,52 @@ function ToolsList({ tools }: { tools: MCPTool[] }) {
} }
const getFormSchema = (t: (key: string) => string) => const getFormSchema = (t: (key: string) => string) =>
z.object({ z
name: z .object({
.string({ required_error: t('mcp.nameRequired') }) name: z
.min(1, { message: t('mcp.nameRequired') }), .string({ required_error: t('mcp.nameRequired') })
timeout: z .min(1, { message: t('mcp.nameRequired') }),
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') }) mode: z.enum(['sse', 'stdio', 'http']),
.positive({ message: t('mcp.timeoutMustBePositive') }) timeout: z
.default(30), .number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
ssereadtimeout: z .positive({ message: t('mcp.timeoutMustBePositive') })
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') }) .default(30),
.positive({ message: t('mcp.timeoutMustBePositive') }) ssereadtimeout: z
.default(300), .number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
url: z .positive({ message: t('mcp.timeoutMustBePositive') })
.string({ required_error: t('mcp.urlRequired') }) .default(300),
.min(1, { message: t('mcp.urlRequired') }), url: z.string().optional(),
extra_args: z command: z.string().optional(),
.array( args: z.array(z.object({ value: z.string() })).optional(),
z.object({ extra_args: z
key: z.string(), .array(
type: z.enum(['string', 'number', 'boolean']), z.object({
value: z.string(), key: z.string(),
}), type: z.enum(['string', 'number', 'boolean']),
) value: z.string(),
.optional(), }),
}); )
.optional(),
})
.superRefine((data, ctx) => {
if (data.mode === 'sse' || data.mode === 'http') {
if (!data.url || data.url.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.urlRequired'),
path: ['url'],
});
}
} else if (data.mode === 'stdio') {
if (!data.command || data.command.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t('mcp.commandRequired'),
path: ['command'],
});
}
}
});
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & { type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
timeout: number; timeout: number;
@@ -218,7 +242,10 @@ export default function MCPFormDialog({
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>, resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
defaultValues: { defaultValues: {
name: '', name: '',
mode: 'sse',
url: '', url: '',
command: '',
args: [],
timeout: 30, timeout: 30,
ssereadtimeout: 300, ssereadtimeout: 300,
extra_args: [], extra_args: [],
@@ -228,20 +255,33 @@ export default function MCPFormDialog({
const [extraArgs, setExtraArgs] = useState< const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[] { key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]); >([]);
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
const [mcpTesting, setMcpTesting] = useState(false); const [mcpTesting, setMcpTesting] = useState(false);
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>( const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
null, null,
); );
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
// Load server data when editing // Load server data when editing
useEffect(() => { useEffect(() => {
if (open && isEditMode && serverName) { if (open && isEditMode && serverName) {
loadServerForEdit(serverName); loadServerForEdit(serverName);
} else if (open && !isEditMode) { } else if (open && !isEditMode) {
// Reset form when creating new server // Reset form when creating new server
form.reset(); form.reset({
name: '',
mode: 'sse',
url: '',
command: '',
args: [],
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
});
setExtraArgs([]); setExtraArgs([]);
setStdioArgs([]);
setRuntimeInfo(null); setRuntimeInfo(null);
} }
@@ -291,25 +331,49 @@ export default function MCPFormDialog({
const resp = await httpClient.getMCPServer(serverName); const resp = await httpClient.getMCPServer(serverName);
const server = resp.server ?? resp; const server = resp.server ?? resp;
const extraArgs = server.extra_args;
form.setValue('name', server.name); form.setValue('name', server.name);
form.setValue('url', extraArgs.url); form.setValue('mode', server.mode);
form.setValue('timeout', extraArgs.timeout);
form.setValue('ssereadtimeout', extraArgs.ssereadtimeout);
if (extraArgs.headers) { if (server.mode === 'sse' || server.mode === 'http') {
const headers = Object.entries(extraArgs.headers).map( form.setValue('url', server.extra_args.url);
([key, value]) => ({ form.setValue('timeout', server.extra_args.timeout);
key,
type: 'string' as const, if (server.mode === 'sse') {
value: String(value), form.setValue('ssereadtimeout', server.extra_args.ssereadtimeout);
}), }
);
setExtraArgs(headers); if (server.extra_args.headers) {
form.setValue('extra_args', headers); const headers = Object.entries(server.extra_args.headers).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
setExtraArgs(headers);
form.setValue('extra_args', headers);
}
} else if (server.mode === 'stdio') {
form.setValue('command', server.extra_args.command);
const args = (server.extra_args.args || []).map((arg: string) => ({
value: arg,
}));
setStdioArgs(args);
form.setValue('args', args);
if (server.extra_args.env) {
const envs = Object.entries(server.extra_args.env).map(
([key, value]) => ({
key,
type: 'string' as const,
value: String(value),
}),
);
setExtraArgs(envs);
form.setValue('extra_args', envs);
}
} }
// Set runtime_info from server data
if (server.runtime_info) { if (server.runtime_info) {
setRuntimeInfo(server.runtime_info); setRuntimeInfo(server.runtime_info);
} else { } else {
@@ -322,28 +386,60 @@ export default function MCPFormDialog({
} }
async function handleFormSubmit(value: z.infer<typeof formSchema>) { async function handleFormSubmit(value: z.infer<typeof formSchema>) {
// Convert extra_args to headers - all values must be strings according to MCPServerExtraArgsSSE
const headers: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
// Convert all values to strings to match MCPServerExtraArgsSSE.headers type
headers[arg.key] = String(arg.value);
});
try { try {
const serverConfig: Omit< let serverConfig: MCPServer;
MCPServer,
'uuid' | 'created_at' | 'updated_at' | 'runtime_info' if (value.mode === 'sse' || value.mode === 'http') {
> = { const headers: Record<string, string> = {};
name: value.name, value.extra_args?.forEach((arg) => {
mode: 'sse' as const, headers[arg.key] = String(arg.value);
enable: true, });
extra_args: {
url: value.url, if (value.mode === 'sse') {
headers: headers, serverConfig = {
timeout: value.timeout, name: value.name,
ssereadtimeout: value.ssereadtimeout, mode: 'sse',
}, enable: true,
}; extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
};
} else {
serverConfig = {
name: value.name,
mode: 'http',
enable: true,
extra_args: {
url: value.url!,
headers: headers,
timeout: value.timeout,
},
};
}
} else {
// Convert extra_args to env
const env: Record<string, string> = {};
value.extra_args?.forEach((arg) => {
env[arg.key] = String(arg.value);
});
// Convert args object array to string array
const args = value.args?.map((arg) => arg.value) || [];
serverConfig = {
name: value.name,
mode: 'stdio',
enable: true,
extra_args: {
command: value.command!,
args: args,
env: env,
},
};
}
if (isEditMode && serverName) { if (isEditMode && serverName) {
await httpClient.updateMCPServer(serverName, serverConfig); await httpClient.updateMCPServer(serverName, serverConfig);
@@ -365,19 +461,44 @@ export default function MCPFormDialog({
setMcpTesting(true); setMcpTesting(true);
try { try {
const { task_id } = await httpClient.testMCPServer('_', { const mode = form.getValues('mode');
name: form.getValues('name'), let extraArgsData:
mode: 'sse', | MCPServerExtraArgsSSE
enable: true, | MCPServerExtraArgsHttp
extra_args: { | MCPServerExtraArgsStdio;
url: form.getValues('url'),
if (mode === 'sse') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'), timeout: form.getValues('timeout'),
ssereadtimeout: form.getValues('ssereadtimeout'),
headers: Object.fromEntries( headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]), extraArgs.map((arg) => [arg.key, arg.value]),
), ),
}, ssereadtimeout: form.getValues('ssereadtimeout'),
}); };
} else if (mode === 'http') {
extraArgsData = {
url: form.getValues('url')!,
timeout: form.getValues('timeout'),
headers: Object.fromEntries(
extraArgs.map((arg) => [arg.key, arg.value]),
),
};
} else {
extraArgsData = {
command: form.getValues('command')!,
args: stdioArgs.map((arg) => arg.value),
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
};
}
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
mode: mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
if (!task_id) { if (!task_id) {
throw new Error(t('mcp.noTaskId')); throw new Error(t('mcp.noTaskId'));
} }
@@ -448,11 +569,31 @@ export default function MCPFormDialog({
form.setValue('extra_args', newArgs); form.setValue('extra_args', newArgs);
}; };
const addStdioArg = () => {
const newArgs = [...stdioArgs, { value: '' }];
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const removeStdioArg = (index: number) => {
const newArgs = stdioArgs.filter((_, i) => i !== index);
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const updateStdioArg = (index: number, value: string) => {
const newArgs = [...stdioArgs];
newArgs[index] = { value };
setStdioArgs(newArgs);
form.setValue('args', newArgs);
};
const handleDialogClose = (open: boolean) => { const handleDialogClose = (open: boolean) => {
onOpenChange(open); onOpenChange(open);
if (!open) { if (!open) {
form.reset(); form.reset();
setExtraArgs([]); setExtraArgs([]);
setStdioArgs([]);
setRuntimeInfo(null); setRuntimeInfo(null);
} }
}; };
@@ -518,58 +659,155 @@ export default function MCPFormDialog({
<FormField <FormField
control={form.control} control={form.control}
name="url" name="mode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('mcp.url')}</FormLabel> <FormLabel>{t('mcp.serverMode')}</FormLabel>
<FormControl> <Select
<Input {...field} /> onValueChange={field.onChange}
</FormControl> defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t('mcp.selectMode')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">{t('mcp.http')}</SelectItem>
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField {(watchMode === 'sse' || watchMode === 'http') && (
control={form.control} <>
name="timeout" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="url"
<FormLabel>{t('mcp.timeout')}</FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel>{t('mcp.url')}</FormLabel>
type="number" <FormControl>
placeholder={t('mcp.timeout')} <Input {...field} />
{...field} </FormControl>
onChange={(e) => field.onChange(Number(e.target.value))} <FormMessage />
/> </FormItem>
</FormControl> )}
<FormMessage /> />
</FormItem>
)} <FormField
/> control={form.control}
name="timeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.timeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.timeout')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{watchMode === 'sse' && (
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
<FormControl>
<Input
type="number"
placeholder={t('mcp.sseTimeoutDescription')}
{...field}
onChange={(e) =>
field.onChange(Number(e.target.value))
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{watchMode === 'stdio' && (
<>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel>{t('mcp.command')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ssereadtimeout"
render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('mcp.sseTimeout')}</FormLabel> <FormLabel>{t('mcp.args')}</FormLabel>
<FormControl> <div className="space-y-2">
<Input {stdioArgs.map((arg, index) => (
type="number" <div key={index} className="flex gap-2">
placeholder={t('mcp.sseTimeoutDescription')} <Input
{...field} placeholder={t('mcp.args')}
onChange={(e) => field.onChange(Number(e.target.value))} value={arg.value}
/> onChange={(e) =>
</FormControl> updateStdioArg(index, e.target.value)
<FormMessage /> }
/>
<button
type="button"
className="p-2 hover:bg-gray-100 rounded"
onClick={() => removeStdioArg(index)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5 text-red-500"
>
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
</svg>
</button>
</div>
))}
<Button
type="button"
variant="outline"
onClick={addStdioArg}
>
{t('mcp.addArgument')}
</Button>
</div>
</FormItem> </FormItem>
)} </>
/> )}
<FormItem> <FormItem>
<FormLabel>{t('models.extraParameters')}</FormLabel> <FormLabel>
{watchMode === 'sse' || watchMode === 'http'
? t('mcp.headers')
: t('mcp.env')}
</FormLabel>
<div className="space-y-2"> <div className="space-y-2">
{extraArgs.map((arg, index) => ( {extraArgs.map((arg, index) => (
<div key={index} className="flex gap-2"> <div key={index} className="flex gap-2">
@@ -580,7 +818,12 @@ export default function MCPFormDialog({
updateExtraArg(index, 'key', e.target.value) updateExtraArg(index, 'key', e.target.value)
} }
/> />
<Select {/* Only show type select for SSE headers if needed, but usually headers are strings. Env vars are definitely strings.
The original code had type selector. Let's keep it for compatibility or remove if not needed.
Headers are strings. Env vars are strings.
Let's hide the type selector as it was confusing anyway, or force it to string.
*/}
{/* <Select
value={arg.type} value={arg.type}
onValueChange={(value) => onValueChange={(value) =>
updateExtraArg(index, 'type', value) updateExtraArg(index, 'type', value)
@@ -593,14 +836,8 @@ export default function MCPFormDialog({
<SelectItem value="string"> <SelectItem value="string">
{t('models.string')} {t('models.string')}
</SelectItem> </SelectItem>
<SelectItem value="number">
{t('models.number')}
</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<Input <Input
placeholder={t('models.value')} placeholder={t('models.value')}
value={arg.value} value={arg.value}
@@ -625,7 +862,9 @@ export default function MCPFormDialog({
</div> </div>
))} ))}
<Button type="button" variant="outline" onClick={addExtraArg}> <Button type="button" variant="outline" onClick={addExtraArg}>
{t('models.addParameter')} {watchMode === 'sse' || watchMode === 'http'
? t('mcp.addHeader')
: t('mcp.addEnvVar')}
</Button> </Button>
</div> </div>
<FormDescription> <FormDescription>

View File

@@ -365,6 +365,18 @@ export interface MCPServerExtraArgsSSE {
ssereadtimeout: number; ssereadtimeout: number;
} }
export interface MCPServerExtraArgsStdio {
command: string;
args: string[];
env: Record<string, string>;
}
export interface MCPServerExtraArgsHttp {
url: string;
headers: Record<string, string>;
timeout: number;
}
export enum MCPSessionStatus { export enum MCPSessionStatus {
CONNECTING = 'connecting', CONNECTING = 'connecting',
CONNECTED = 'connected', CONNECTED = 'connected',
@@ -373,21 +385,42 @@ export enum MCPSessionStatus {
export interface MCPServerRuntimeInfo { export interface MCPServerRuntimeInfo {
status: MCPSessionStatus; status: MCPSessionStatus;
error_message: string; error_message?: string;
tool_count: number; tool_count: number;
tools: MCPTool[]; tools: MCPTool[];
} }
export interface MCPServer { export type MCPServer =
uuid?: string; | {
name: string; uuid?: string;
mode: 'stdio' | 'sse'; name: string;
enable: boolean; mode: 'sse';
extra_args: MCPServerExtraArgsSSE; enable: boolean;
runtime_info?: MCPServerRuntimeInfo; extra_args: MCPServerExtraArgsSSE;
created_at?: string; runtime_info?: MCPServerRuntimeInfo;
updated_at?: string; created_at?: string;
} updated_at?: string;
}
| {
uuid?: string;
name: string;
mode: 'http';
enable: boolean;
extra_args: MCPServerExtraArgsHttp;
runtime_info?: MCPServerRuntimeInfo;
created_at?: string;
updated_at?: string;
}
| {
uuid?: string;
name: string;
mode: 'stdio';
enable: boolean;
extra_args: MCPServerExtraArgsStdio;
runtime_info?: MCPServerRuntimeInfo;
created_at?: string;
updated_at?: string;
};
export interface MCPTool { export interface MCPTool {
name: string; name: string;

View File

@@ -467,8 +467,10 @@ const enUS = {
getServerListError: 'Failed to get MCP server list: ', getServerListError: 'Failed to get MCP server list: ',
serverName: 'Server Name', serverName: 'Server Name',
serverMode: 'Connection Mode', serverMode: 'Connection Mode',
selectMode: 'Select Mode',
stdio: 'Stdio Mode', stdio: 'Stdio Mode',
sse: 'SSE Mode', sse: 'SSE Mode',
http: 'HTTP Mode',
noServerInstalled: 'No MCP servers configured', noServerInstalled: 'No MCP servers configured',
serverNameRequired: 'Server name cannot be empty', serverNameRequired: 'Server name cannot be empty',
commandRequired: 'Command cannot be empty', commandRequired: 'Command cannot be empty',

View File

@@ -474,6 +474,8 @@ const jaJP = {
serverMode: '接続モード', serverMode: '接続モード',
stdio: 'Stdioモード', stdio: 'Stdioモード',
sse: 'SSEモード', sse: 'SSEモード',
http: 'HTTPモード',
selectMode: '接続モードを選択',
noServerInstalled: 'MCPサーバーが設定されていません', noServerInstalled: 'MCPサーバーが設定されていません',
serverNameRequired: 'サーバー名は必須です', serverNameRequired: 'サーバー名は必須です',
commandRequired: 'コマンドは必須です', commandRequired: 'コマンドは必須です',

View File

@@ -446,8 +446,10 @@ const zhHans = {
getServerListError: '获取 MCP 服务器列表失败:', getServerListError: '获取 MCP 服务器列表失败:',
serverName: '服务器名称', serverName: '服务器名称',
serverMode: '连接模式', serverMode: '连接模式',
selectMode: '选择模式',
stdio: 'Stdio模式', stdio: 'Stdio模式',
sse: 'SSE模式', sse: 'SSE模式',
http: 'HTTP模式',
noServerInstalled: '暂未配置任何 MCP 服务器', noServerInstalled: '暂未配置任何 MCP 服务器',
serverNameRequired: '服务器名称不能为空', serverNameRequired: '服务器名称不能为空',
commandRequired: '命令不能为空', commandRequired: '命令不能为空',

View File

@@ -445,6 +445,8 @@ const zhHant = {
serverMode: '連接模式', serverMode: '連接模式',
stdio: 'Stdio模式', stdio: 'Stdio模式',
sse: 'SSE模式', sse: 'SSE模式',
selectMode: '選擇連接模式',
http: 'HTTP模式',
noServerInstalled: '暫未設定任何MCP伺服器', noServerInstalled: '暫未設定任何MCP伺服器',
serverNameRequired: '伺服器名稱不能為空', serverNameRequired: '伺服器名稱不能為空',
commandRequired: '命令不能為空', commandRequired: '命令不能為空',