feat: replace Jina REST with MCP tools for web search

- Add langchain-mcp-adapters dependency
- New app/tools/mcp_jina.py: Jina MCP client with filtered tool loading
  (search_web, read_url, sort_by_relevance)
- Update resolve_tools to return (tools, needs_jina_mcp) tuple
- Update builder.py: skip graph cache for MCP-bound tools, add search
  tool guidance to system prompt
- Refactor chat.py: extract _run_graph_stream, add MCP/non-MCP paths,
  update tool titles and summaries for MCP tool names
- Tested locally: both MCP search path and non-MCP knowledge path work

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gongzhiyong
2026-04-09 21:01:21 +08:00
co-authored by Claude Sonnet 4.6
parent a11cb56962
commit 707f6bce4b
5 changed files with 101 additions and 32 deletions
+47 -18
View File
@@ -17,7 +17,7 @@ from app.schemas import ChatRequest
from app.store.memory import get_checkpointer
from app.store.postgres import Conversation, Message, async_session_factory
from app.tools import resolve_tools
from app.tools.search import set_search_model
from app.tools.mcp_jina import create_jina_mcp_client, get_jina_mcp_tools
async def _ensure_conversation(conversation_id: str, first_message: str) -> None:
@@ -51,26 +51,19 @@ async def _persist_ai_message(conversation_id: str, content: str) -> None:
await session.commit()
async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
"""Stream LLM response tokens via SSE."""
# Ensure conversation exists and persist user message
await _ensure_conversation(request.conversation_id, request.message)
# Resolve tools from frontend tool keys
active_tools = resolve_tools(request.tools)
# Set search strategy model so the web_search tool knows depth
set_search_model(request.model)
graph = await get_chat_graph(model=request.model, tools=active_tools)
async def _run_graph_stream(
request: ChatRequest,
graph,
all_tools: list,
) -> AsyncIterator[bytes]:
"""Run the LangGraph agent and yield SSE events."""
config = {
"configurable": {"thread_id": request.conversation_id},
}
# When using ReAct agent (with tools), input is just messages.
# When using plain graph (no tools), input includes model key.
if active_tools:
if all_tools:
input_data = {"messages": [HumanMessage(content=request.message)]}
else:
input_data = {
@@ -204,6 +197,27 @@ async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
yield _sse({"type": "done"})
async def _stream_response(request: ChatRequest) -> AsyncIterator[bytes]:
"""Stream LLM response tokens via SSE."""
# Ensure conversation exists and persist user message
await _ensure_conversation(request.conversation_id, request.message)
# Resolve tools from frontend tool keys
regular_tools, needs_jina_mcp = resolve_tools(request.tools)
if needs_jina_mcp:
mcp_client = create_jina_mcp_client()
jina_tools = await get_jina_mcp_tools(mcp_client)
all_tools = regular_tools + jina_tools
graph = await get_chat_graph(model=request.model, tools=all_tools)
async for chunk in _run_graph_stream(request, graph, all_tools):
yield chunk
else:
graph = await get_chat_graph(model=request.model, tools=regular_tools)
async for chunk in _run_graph_stream(request, graph, regular_tools):
yield chunk
# ── Trace helpers ─────────────────────────────────────────────────────────────
def _sse(data: dict) -> bytes:
@@ -214,9 +228,12 @@ _TOOL_TITLES: dict[str, str] = {
"kb_search": "检索知识库",
"ticket_list": "查询工单列表",
"ticket_detail": "查询工单详情",
"web_search": "外部搜索",
"generate_document": "生成文档",
"sandbox_run": "执行沙盒代码",
# Jina MCP tools
"search_web": "外部搜索",
"read_url": "读取网页",
"sort_by_relevance": "相关性排序",
}
_ERROR_KEYWORDS = (
@@ -245,8 +262,15 @@ def _summarize_input(tool_name: str, inp: dict | str) -> str:
return f"第 {inp.get('page', 1)} 页,每页 {inp.get('page_size', 20)} 条"
case "ticket_detail":
return f"工单 ID:{inp.get('ticket_id', '')}"
case "web_search":
case "search_web":
return f"搜索:{str(inp.get('query', ''))[:50]}"
case "read_url":
url = str(inp.get('url', ''))
return f"读取:{url[:60]}"
case "sort_by_relevance":
query = str(inp.get('query', ''))[:30]
count = len(inp.get('documents', []))
return f"排序 {count} 条结果,查询:{query}"
case "generate_document":
return str(inp.get('prompt', ''))[:60]
case "sandbox_run":
@@ -270,9 +294,14 @@ def _summarize_output(tool_name: str, output: str) -> str:
return f"返回 {m.group(1)} 条工单" if m else "工单列表已获取"
case "ticket_detail":
return "工单详情已获取"
case "web_search":
case "search_web":
count = output.count("##")
return f"找到 {max(count, 1)} 条搜索结果"
case "read_url":
lines = len(output.splitlines())
return f"读取完成,{lines} 行内容"
case "sort_by_relevance":
return "排序完成"
case "generate_document":
if "Download:" in output:
if "[PPT]" in output:
+17 -3
View File
@@ -32,7 +32,11 @@ SYSTEM_PROMPT = (
"When the user has enabled specific tools, you may use them if relevant. "
"If you decide not to use an available tool, briefly explain why. "
"Always respond in the same language the user uses. "
"Be concise, accurate, and helpful."
"Be concise, accurate, and helpful.\n\n"
"When web search tools are available:\n"
"- Use search_web first to find relevant pages\n"
"- Use read_url to get full content from the most relevant URLs (1-3 max)\n"
"- Use sort_by_relevance to rank results if you have many documents\n"
)
# Cache compiled graphs to avoid re-creation on every request.
@@ -54,16 +58,25 @@ def _get_llm(model: str) -> AzureChatOpenAI:
)
# MCP tool names that should not be cached (bound to per-request client)
_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"}
async def get_chat_graph(model: str = "flash", tools: list | None = None):
"""Get or create a compiled graph for the given model and tool set.
When tools are provided, creates a ReAct agent that can call tools.
When no tools, falls back to a simple single-node graph.
MCP tools are bound to a per-request client session, so graphs
containing them are never cached.
"""
tools = tools or []
cache_key = (model, frozenset(t.name for t in tools))
tool_names = frozenset(t.name for t in tools)
has_mcp_tools = bool(tool_names & _MCP_TOOL_NAMES)
if cache_key in _graph_cache:
cache_key = (model, tool_names)
if not has_mcp_tools and cache_key in _graph_cache:
return _graph_cache[cache_key]
checkpointer = await get_checkpointer()
@@ -85,5 +98,6 @@ async def get_chat_graph(model: str = "flash", tools: list | None = None):
builder.set_finish_point("agent")
graph = builder.compile(checkpointer=checkpointer)
if not has_mcp_tools:
_graph_cache[cache_key] = graph
return graph
+8 -10
View File
@@ -2,26 +2,24 @@
from app.tools.kb import kb_search
from app.tools.tickets import ticket_list, ticket_detail
from app.tools.search import web_search
from app.tools.document import generate_document
from app.tools.sandbox import sandbox_run
# Mapping from frontend tool names to LangChain tool objects.
# The frontend sends a list of tool *keys* (e.g. ["knowledge", "tickets"]);
# the backend resolves them here and binds them to the ReAct agent.
ALL_TOOLS: dict[str, list] = {
# "search" is no longer here — it is handled via Jina MCP in chat.py.
_TOOL_MAP: dict[str, list] = {
"knowledge": [kb_search],
"tickets": [ticket_list, ticket_detail],
"search": [web_search],
"document": [generate_document],
"sandbox": [sandbox_run],
}
def resolve_tools(tool_keys: list[str]) -> list:
"""Return a flat list of LangChain tools for the given frontend keys."""
def resolve_tools(tool_keys: list[str]) -> tuple[list, bool]:
"""Return (regular_tools, needs_jina_mcp)."""
needs_jina_mcp = "search" in tool_keys
tools = []
for key in tool_keys:
if key in ALL_TOOLS:
tools.extend(ALL_TOOLS[key])
return tools
if key in _TOOL_MAP:
tools.extend(_TOOL_MAP[key])
return tools, needs_jina_mcp
+27
View File
@@ -0,0 +1,27 @@
"""Jina MCP tools via langchain-mcp-adapters."""
from __future__ import annotations
from langchain_mcp_adapters.client import MultiServerMCPClient
from app.config import settings
# Only load the 3 tools we need, avoid 19 tools filling the context
JINA_MCP_TOOL_NAMES = {"search_web", "read_url", "sort_by_relevance"}
JINA_MCP_URL = "https://mcp.jina.ai/v1"
def create_jina_mcp_client() -> MultiServerMCPClient:
return MultiServerMCPClient({
"jina": {
"url": JINA_MCP_URL,
"transport": "streamable_http",
"headers": {"Authorization": f"Bearer {settings.jina_api_key}"},
}
})
async def get_jina_mcp_tools(client: MultiServerMCPClient) -> list:
"""Get filtered Jina MCP tools (search_web / read_url / sort_by_relevance)."""
all_tools = await client.get_tools()
return [t for t in all_tools if t.name in JINA_MCP_TOOL_NAMES]
+1
View File
@@ -16,3 +16,4 @@ redis[hiredis]>=5.0.0
azure-storage-blob>=12.20.0
azure-servicebus>=7.12.0
daytona>=0.6.0
langchain-mcp-adapters>=0.1.0