Repository navigation
Expand file tree
/
Copy pathapp.py
More file actions
207 lines (167 loc) · 8.14 KB
/
Copy pathapp.py
File metadata and controls
207 lines (167 loc) · 8.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
"""A minimal Starlette chat app backed by the local MCP server and a chat model.
The MCP server (server.py) already runs as streamable-http at http://localhost:8000/mcp.
This app is an agent loop between the user, a chat model (GLM via the OpenAI-compatible
z.ai API, configured through .env / environment variables) and the MCP server:
user message -> model (with the MCP tools as function definitions)
-> model asks for tool calls -> executed over MCP
-> results fed back to the model -> final reply
When the model calls a tool that carries ``_meta.ui.resourceUri`` (the MCP Apps
extension, ``io.modelcontextprotocol/ui``), the tool result is returned to the
front end as an "artifact". The front end loads the ``ui://`` HTML resource into
a sandboxed iframe and delivers the tool result to it via ``postMessage`` as a
``notifications/call_tool_result`` JSON-RPC notification (the payload shape the
resource expects: ``event.data.params.structuredContent``), rendering the chart
inline in the conversation.
Routes:
* GET / -> serves index.html (the chat UI)
* POST /api/chat -> body: {messages: [{role, content}...]} (user/assistant turns)
returns {reply, artifacts: [{resourceUri, result}]}
* GET /ui-resource -> proxies a ``ui://`` resource (read via resources/read) so the
iframe can load it by URL
Configuration (env or .env): API_KEY, AI_MODEL, AI_BASE_URL, MCP_SERVER_URL.
Run with: uv run uvicorn app:app --port 8080
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import httpx2
import uvicorn
from dotenv import load_dotenv
from mcp import Client
from mcp.types import CallToolResult, Tool
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import FileResponse, HTMLResponse, JSONResponse
from starlette.routing import Route
HERE = Path(__file__).resolve().parent
load_dotenv(HERE / ".env")
MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL")
MODEL = os.environ.get("AI_MODEL")
GLM_BASE_URL = os.environ.get("AI_BASE_URL")
API_KEY = os.environ.get("API_KEY", "")
INDEX_HTML = HERE / "index.html"
SYSTEM_PROMPT = (
"You are a helpful assistant connected to an MCP server with tools."
"Call the available tools whenever they help answer the user's question,"
"then summarize the results."
"Reply in the user's language and with text (no markdown)."
)
MAX_TOOL_ROUNDS = 4
def openai_tools(tools: list[Tool]) -> list[dict]:
"""Translate MCP tool definitions into OpenAI-style function definitions."""
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": tool.input_schema or {"type": "object", "properties": {}},
},
}
for tool in tools
]
async def index(request: Request) -> FileResponse:
"""Serve the static chat front end."""
return FileResponse(INDEX_HTML)
async def chat(request: Request) -> JSONResponse:
"""Run the agent loop: model -> MCP tool calls -> final reply + UI artifacts."""
try:
body = await request.json()
incoming = body.get("messages", [])
except Exception as exc: # noqa: BLE001 - malformed request body
return JSONResponse({"error": f"Bad request body: {exc}"}, status_code=400)
conversation: list[dict] = [{"role": "system", "content": SYSTEM_PROMPT}]
for message in incoming:
if message.get("role") in ("user", "assistant") and message.get("content"):
conversation.append({"role": message["role"], "content": str(message["content"])})
artifacts: list[dict] = []
try:
async with Client(MCP_SERVER_URL) as mcp_client:
tools = (await mcp_client.list_tools()).tools
resource_by_tool = {
tool.name: (tool.meta or {}).get("ui", {}).get("resourceUri") for tool in tools
}
schemas = openai_tools(tools)
async with httpx2.AsyncClient(
base_url=GLM_BASE_URL,
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=120,
) as model:
for _ in range(MAX_TOOL_ROUNDS):
response = await model.post(
"/chat/completions",
json={
"model": MODEL,
"messages": conversation,
"tools": schemas,
"tool_choice": "auto",
},
)
if response.status_code != 200:
return JSONResponse(
{"error": f"Model API error {response.status_code}: {response.text[:500]}"},
status_code=502,
)
message = response.json()["choices"][0]["message"]
conversation.append(message)
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return JSONResponse({"reply": message.get("content") or "", "artifacts": artifacts})
for tool_call in tool_calls:
name = tool_call["function"]["name"]
try:
arguments = json.loads(tool_call["function"].get("arguments") or "{}")
except json.JSONDecodeError:
arguments = {}
result: CallToolResult = await mcp_client.call_tool(name, arguments)
# Spec-shaped CallToolResult params for notifications/call_tool_result.
params = {
k: v
for k, v in result.model_dump(
by_alias=True, exclude_none=True, mode="json"
).items()
if k in ("content", "structuredContent", "isError")
}
resource_uri = resource_by_tool.get(name)
if resource_uri:
artifacts.append({"resourceUri": resource_uri, "result": params})
tool_output = (
params.get("structuredContent")
if params.get("structuredContent") is not None
else params.get("content")
)
conversation.append({
"role": "tool",
"tool_call_id": tool_call.get("id", name),
"content": json.dumps(tool_output, ensure_ascii=False),
})
return JSONResponse({
"reply": "Reached the tool-call limit without a final answer.",
"artifacts": artifacts,
})
except Exception as exc: # noqa: BLE001 - simple test client
return JSONResponse({"error": f"Chat failed: {exc}"}, status_code=500)
async def ui_resource(request: Request) -> HTMLResponse | JSONResponse:
"""Proxy a ui:// resource from the MCP server so the iframe can load it."""
uri = request.query_params.get("uri", "")
if not uri.startswith("ui://"):
return JSONResponse({"error": f"Not a ui:// resource: {uri}"}, status_code=400)
try:
async with Client(MCP_SERVER_URL) as client:
result = await client.read_resource(uri)
except Exception as exc: # noqa: BLE001 - simple test client
return JSONResponse({"error": f"Could not read resource {uri}: {exc}"}, status_code=502)
html = next((c.text for c in result.contents if getattr(c, "text", None)), None)
if html is None:
return JSONResponse({"error": f"Resource {uri} has no text content"}, status_code=404)
return HTMLResponse(html)
app = Starlette(
routes=[
Route("/", index, methods=["GET"]),
Route("/api/chat", chat, methods=["POST"]),
Route("/ui-resource", ui_resource, methods=["GET"]),
]
)
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8080)