edgeone 1.6.40 → 1.6.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/edgeone-dist/cli.js +4565 -2515
- package/edgeone-dist/config-schema.js +2 -2
- package/edgeone-dist/edgeone.schema.json +49 -0
- package/edgeone-dist/pages/dev/runner-worker.js +4414 -2364
- package/edgeone-dist/pages/templates/agent-python/adapter.py +111 -0
- package/edgeone-dist/pages/templates/agent-python/mcp_handler.py +393 -0
- package/package.json +6 -3
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import contextlib
|
|
6
7
|
import importlib
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
@@ -414,14 +415,83 @@ if _failed_routes:
|
|
|
414
415
|
)
|
|
415
416
|
|
|
416
417
|
|
|
418
|
+
# --- MCP server bootstrap (Agent as MCP Server) ---
|
|
419
|
+
# 开关与配置由构建期注入的环境变量提供(对齐 Node agent-node.ts 的 MCP_ENABLED / MCP_CONFIG)。
|
|
420
|
+
# AGENT_MCP_ENABLED: "true"/"false"
|
|
421
|
+
# AGENT_MCP_CONFIG: JSON,形如
|
|
422
|
+
# {"name": ..., "tools": [{"route", "toolName", "description", "parameters", "required"}]}
|
|
423
|
+
# tools 由构建期依据路由注释声明生成;平台级鉴权由中控完成,本层不做 token 校验。
|
|
424
|
+
_mcp_enabled = os.environ.get("AGENT_MCP_ENABLED", "").lower() == "true"
|
|
425
|
+
try:
|
|
426
|
+
_mcp_config = json.loads(os.environ.get("AGENT_MCP_CONFIG", "{}")) or {}
|
|
427
|
+
except (json.JSONDecodeError, ValueError):
|
|
428
|
+
_mcp_config = {}
|
|
429
|
+
|
|
430
|
+
_mcp_server = None
|
|
431
|
+
_mcp_session_manager = None
|
|
432
|
+
_mcp_exit_stack = None
|
|
433
|
+
|
|
434
|
+
if _mcp_enabled and _mcp_config.get("tools"):
|
|
435
|
+
try:
|
|
436
|
+
from .mcp_handler import create_mcp_server
|
|
437
|
+
_mcp_server = create_mcp_server(_mcp_config, _runtime)
|
|
438
|
+
# 必须先调 streamable_http_app() 触发 session_manager 惰性创建。
|
|
439
|
+
_mcp_server.streamable_http_app()
|
|
440
|
+
_mcp_session_manager = _mcp_server.session_manager
|
|
441
|
+
print("[agent-python] MCP server enabled at /mcp", file=sys.stderr, flush=True)
|
|
442
|
+
except Exception as _mcp_err:
|
|
443
|
+
print(f"[agent-python] MCP server init failed: {type(_mcp_err).__name__}: {_mcp_err}", file=sys.stderr, flush=True)
|
|
444
|
+
_mcp_enabled = False
|
|
445
|
+
else:
|
|
446
|
+
_mcp_enabled = False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _mcp_pick_identity_context(headers: dict) -> dict:
|
|
450
|
+
"""从入站请求头提取中控注入的身份态,供 MCP tool 转发与主键裁决使用。
|
|
451
|
+
|
|
452
|
+
``makers-user-id`` 由中控在 JWT 验签后无条件 delete + set,客户端无法伪造;
|
|
453
|
+
它存在即表示本请求已过平台身份校验 —— 此时 ``makers-conversation-id`` 也由中控
|
|
454
|
+
按已验证身份稳定派生(覆盖客户端传值),是唯一权威的会话主键。
|
|
455
|
+
|
|
456
|
+
不透传客户端原始 Authorization(平台不需要业务再验一次)。
|
|
457
|
+
|
|
458
|
+
"""
|
|
459
|
+
user_id = str(headers.get("makers-user-id") or "").strip()
|
|
460
|
+
if not user_id:
|
|
461
|
+
return {"headers": {}, "authenticated": False, "conversation_id": ""}
|
|
462
|
+
return {
|
|
463
|
+
"headers": {"makers-user-id": user_id},
|
|
464
|
+
"authenticated": True,
|
|
465
|
+
"conversation_id": str(headers.get("makers-conversation-id") or "").strip(),
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
417
469
|
# --- ASGI app ---
|
|
418
470
|
async def app(scope: dict, receive, send) -> None:
|
|
419
471
|
if scope["type"] == "lifespan":
|
|
420
472
|
msg = await receive()
|
|
421
473
|
if msg["type"] == "lifespan.startup":
|
|
474
|
+
# 启动 MCP session manager 的 task group(stateful 模式,跨请求长驻)。
|
|
475
|
+
# stateful 下 session 由服务端分配并回传 Mcp-Session-Id,客户端后续回带,
|
|
476
|
+
# 中控据此稳定派生 conversation-id(详见 mcp_handler.create_mcp_server)。
|
|
477
|
+
global _mcp_exit_stack
|
|
478
|
+
if _mcp_enabled and _mcp_session_manager is not None:
|
|
479
|
+
try:
|
|
480
|
+
_mcp_exit_stack = contextlib.AsyncExitStack()
|
|
481
|
+
await _mcp_exit_stack.enter_async_context(_mcp_session_manager.run())
|
|
482
|
+
print("[agent-python] MCP session manager started", file=sys.stderr, flush=True)
|
|
483
|
+
except Exception as _mcp_start_err:
|
|
484
|
+
print(f"[agent-python] MCP session manager start failed: {_mcp_start_err}", file=sys.stderr, flush=True)
|
|
485
|
+
_mcp_exit_stack = None
|
|
422
486
|
await send({"type": "lifespan.startup.complete"})
|
|
423
487
|
msg = await receive()
|
|
424
488
|
if msg["type"] == "lifespan.shutdown":
|
|
489
|
+
# 关闭 MCP session manager task group。
|
|
490
|
+
if _mcp_exit_stack is not None:
|
|
491
|
+
try:
|
|
492
|
+
await _mcp_exit_stack.aclose()
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
425
495
|
# Flush telemetry before process exits
|
|
426
496
|
try:
|
|
427
497
|
from _platform._observability.telemetry import get_telemetry
|
|
@@ -436,6 +506,40 @@ async def app(scope: dict, receive, send) -> None:
|
|
|
436
506
|
if scope["type"] != "http":
|
|
437
507
|
return
|
|
438
508
|
|
|
509
|
+
# --- MCP endpoint:拦截 /mcp(及 /mcp/*),交给 MCP session manager 处理 JSON-RPC。---
|
|
510
|
+
# 对齐 Node agent-node.ts 的 requestHandler /mcp 拦截。透传 ASGI 三件套即可,
|
|
511
|
+
# 无需自己读body(StreamableHTTPSessionManager 内部处理)。
|
|
512
|
+
# /mcp 端点:鉴权由中控完成,本层不做 token 校验,直接交给 session manager 处理。
|
|
513
|
+
_mcp_path = scope.get("path", "/")
|
|
514
|
+
if _mcp_enabled and _mcp_session_manager is not None and (_mcp_path == "/mcp" or _mcp_path.startswith("/mcp/")):
|
|
515
|
+
# OPTIONS 预检直接放行(CORS)。
|
|
516
|
+
if scope.get("method", "").upper() == "OPTIONS":
|
|
517
|
+
await send({
|
|
518
|
+
"type": "http.response.start",
|
|
519
|
+
"status": 204,
|
|
520
|
+
"headers": [
|
|
521
|
+
(b"access-control-allow-origin", b"*"),
|
|
522
|
+
(b"access-control-allow-methods", b"POST, GET, OPTIONS"),
|
|
523
|
+
(b"access-control-allow-headers", b"Content-Type, Authorization"),
|
|
524
|
+
],
|
|
525
|
+
})
|
|
526
|
+
await send({"type": "http.response.body", "body": b""})
|
|
527
|
+
return
|
|
528
|
+
# 把中控注入的身份态存入 request-scoped contextvar,供 mcp_handler 转发时
|
|
529
|
+
# 并入 runtime headers、并裁决权威会话主键(tool 函数拿不到 scope)。
|
|
530
|
+
_mcp_headers = {
|
|
531
|
+
(k.decode("utf-8") if isinstance(k, bytes) else k).lower(): (v.decode("utf-8") if isinstance(v, bytes) else v)
|
|
532
|
+
for k, v in scope.get("headers", [])
|
|
533
|
+
}
|
|
534
|
+
try:
|
|
535
|
+
from .mcp_handler import mcp_identity_context as _id_var
|
|
536
|
+
except ImportError:
|
|
537
|
+
_id_var = None
|
|
538
|
+
if _id_var is not None:
|
|
539
|
+
_id_var.set(_mcp_pick_identity_context(_mcp_headers))
|
|
540
|
+
await _mcp_session_manager.handle_request(scope, receive, send)
|
|
541
|
+
return
|
|
542
|
+
|
|
439
543
|
# Read body
|
|
440
544
|
body_parts = []
|
|
441
545
|
while True:
|
|
@@ -485,6 +589,13 @@ async def app(scope: dict, receive, send) -> None:
|
|
|
485
589
|
k.decode("utf-8") if isinstance(k, bytes) else k: v.decode("utf-8") if isinstance(v, bytes) else v
|
|
486
590
|
for k, v in scope.get("headers", [])
|
|
487
591
|
}
|
|
592
|
+
# 平台身份头:中控在 JWT 验签后无条件 delete + set 注入 makers-user-id,
|
|
593
|
+
# 客户端伪造值在边缘即被剥离,到不了这里 —— 故直接信任、无需二次映射。
|
|
594
|
+
_user_id = str(headers.get("makers-user-id") or "").strip()
|
|
595
|
+
if _user_id:
|
|
596
|
+
headers["makers-user-id"] = _user_id
|
|
597
|
+
else:
|
|
598
|
+
headers.pop("makers-user-id", None)
|
|
488
599
|
|
|
489
600
|
# 计算完整请求路径和 scfRequestId(日志合并到 response 后统一打印)
|
|
490
601
|
_pages_host = headers.get("eo-pages-host", "") or headers.get("host", "")
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# src/pages/builder/templates/agent-python/mcp_handler.py
|
|
2
|
+
"""Agent as MCP Server — 把 agent 路由暴露为 MCP tools(对齐 Node agent-mcp-wrap.ts)。
|
|
3
|
+
|
|
4
|
+
基于官方 `mcp` 包(FastMCP + StreamableHTTPSessionManager,stateful HTTP):
|
|
5
|
+
- stateful:服务端分配 Mcp-Session-Id 并在 initialize 响应中回传,客户端后续
|
|
6
|
+
回带,中控据此稳定派生 conversation-id(详见 create_mcp_server 内说明);
|
|
7
|
+
- tool 列表由构建期依据路由 docstring 声明生成(AGENT_MCP_CONFIG.tools),
|
|
8
|
+
运行时只负责注册;平台不再注册任何内置 tool(cancel_run / get_sessions /
|
|
9
|
+
get_session_runs 均已移除);
|
|
10
|
+
- tool 调用转发到 runtime.handle,消费流式 StreamResponse,解析 agent 私有 SSE
|
|
11
|
+
的 text_delta,聚合成纯文本返回;
|
|
12
|
+
- 客户端带 progressToken 时逐 delta 调ctx.report_progress 流式推送
|
|
13
|
+
(report_progress 内部自动判断有无 token,无 token 时静默跳过,天然实现 B/C 分流)。
|
|
14
|
+
|
|
15
|
+
鉴权:平台级身份校验统一由中控(边缘)完成,本层不做任何 token 校验。
|
|
16
|
+
设计对齐 Node 侧:executeRoute 转发 runtime.handle;聚合纯文本兜底 + 可选 progress 流式。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import inspect
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from typing import Any, Callable, Optional
|
|
26
|
+
from uuid import uuid4
|
|
27
|
+
|
|
28
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
29
|
+
from mcp.types import CallToolResult, TextContent
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# 请求作用域的身份上下文。MCP session manager 跨请求长驻,FastMCP tool 签名不能加
|
|
33
|
+
# '_' 前缀参数、也拿不到原始 ASGI scope,故用 contextvar 传递本次请求的身份态。
|
|
34
|
+
# 结构:{"headers": {...}, "authenticated": bool, "conversation_id": str}
|
|
35
|
+
# · headers —— 转发给业务 handler 的身份头(仅 makers-user-id)
|
|
36
|
+
# · authenticated —— 是否已过中控身份校验
|
|
37
|
+
# · conversation_id —— 中控按已验证身份稳定派生的权威会话主键
|
|
38
|
+
# 只承载中控注入的身份态,不承载客户端原始 Authorization。
|
|
39
|
+
# 由 adapter.py 在进入 session manager 前设置。
|
|
40
|
+
mcp_identity_context: "contextvars.ContextVar[dict]" = contextvars.ContextVar(
|
|
41
|
+
"mcp_identity_context", default={}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _identity() -> dict:
|
|
46
|
+
"""读取本次请求的身份态;不在请求作用域内时返回空 dict。"""
|
|
47
|
+
try:
|
|
48
|
+
return mcp_identity_context.get() or {}
|
|
49
|
+
except Exception:
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _identity_headers() -> dict:
|
|
54
|
+
"""转发给业务 handler 的内部身份头。"""
|
|
55
|
+
headers = _identity().get("headers")
|
|
56
|
+
return headers if isinstance(headers, dict) else {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_conversation_id(ai_session_id: Any) -> str:
|
|
60
|
+
"""裁决本次 tool 调用的会话主键(安全关键)。
|
|
61
|
+
|
|
62
|
+
鉴权开启时**必须**用中控派生值并忽略 AI 传参:中控在 route-upstream 阶段对
|
|
63
|
+
makers-conversation-id 做的覆盖(防止 A 填 B 的 id 读 B 的记忆),会被 tool
|
|
64
|
+
参数里的 session_id 绕过 —— MCP 转发是新造 headers,不继承那次覆盖。
|
|
65
|
+
|
|
66
|
+
Python 侧为 stateful_http,传输层有 session,但中控值仍是权威主键(它
|
|
67
|
+
由身份绑定派生、且防止 A 填 B 的 id 读 B 的记忆)。中控值缺失时(异常)
|
|
68
|
+
才新生成,绝不采信 AI 传参。
|
|
69
|
+
"""
|
|
70
|
+
identity = _identity()
|
|
71
|
+
if identity.get("authenticated"):
|
|
72
|
+
return str(identity.get("conversation_id") or "") or _gen_session_id()
|
|
73
|
+
ai_session = str(ai_session_id or "")
|
|
74
|
+
return ai_session if ai_session else _gen_session_id()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _log(msg: str) -> None:
|
|
78
|
+
print(msg, file=sys.stderr, flush=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _normalize_tool_configs(raw_tools: Any) -> list[dict]:
|
|
82
|
+
"""归一构建期注入的 tool 列表(AGENT_MCP_CONFIG.tools)。
|
|
83
|
+
|
|
84
|
+
构建期已完成「哪些路由暴露 / tool 名 / 参数 schema」的判定(见 agent-mcp-tools.ts),
|
|
85
|
+
这里只做防御性校验:缺 route / toolName 的条目直接丢弃,不再自行推导路由。
|
|
86
|
+
"""
|
|
87
|
+
tools: list[dict] = []
|
|
88
|
+
for item in raw_tools or []:
|
|
89
|
+
if not isinstance(item, dict):
|
|
90
|
+
continue
|
|
91
|
+
route = str(item.get("route") or "")
|
|
92
|
+
tool_name = str(item.get("toolName") or item.get("tool_name") or "")
|
|
93
|
+
if not route or not tool_name:
|
|
94
|
+
continue
|
|
95
|
+
parameters = item.get("parameters")
|
|
96
|
+
required = item.get("required")
|
|
97
|
+
tools.append({
|
|
98
|
+
"route": route,
|
|
99
|
+
"tool_name": tool_name,
|
|
100
|
+
"description": str(item.get("description") or ("Agent route: " + route)),
|
|
101
|
+
"parameters": parameters if isinstance(parameters, dict) else {},
|
|
102
|
+
"required": required if isinstance(required, list) else [],
|
|
103
|
+
})
|
|
104
|
+
return tools
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _gen_session_id() -> str:
|
|
108
|
+
return str(uuid4())
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _parse_sse_text(raw_text: str) -> str:
|
|
112
|
+
"""解析 agent 私有 SSE(event: text_delta\\ndata: {"delta":"..."}),聚合纯文本。
|
|
113
|
+
|
|
114
|
+
跳过 done/[DONE] 控制事件。用于非流式 body 的一次性解析(流式路径走 _parse_and_notify)。
|
|
115
|
+
对齐 Node __mcpParseSseText。
|
|
116
|
+
"""
|
|
117
|
+
full_text = ""
|
|
118
|
+
for block in str(raw_text or "").split("\n\n"):
|
|
119
|
+
if not block:
|
|
120
|
+
continue
|
|
121
|
+
event_name = ""
|
|
122
|
+
data_lines = []
|
|
123
|
+
for line in block.split("\n"):
|
|
124
|
+
if line.startswith("event:"):
|
|
125
|
+
event_name = line[6:].strip()
|
|
126
|
+
elif line.startswith("data:"):
|
|
127
|
+
data_lines.append(line[5:].strip())
|
|
128
|
+
if not data_lines:
|
|
129
|
+
continue
|
|
130
|
+
data_str = "\n".join(data_lines)
|
|
131
|
+
if event_name == "done" or data_str == "[DONE]":
|
|
132
|
+
continue
|
|
133
|
+
delta = ""
|
|
134
|
+
try:
|
|
135
|
+
parsed = json.loads(data_str)
|
|
136
|
+
if isinstance(parsed, str):
|
|
137
|
+
delta = parsed
|
|
138
|
+
elif isinstance(parsed, dict):
|
|
139
|
+
if isinstance(parsed.get("delta"), str):
|
|
140
|
+
delta = parsed["delta"]
|
|
141
|
+
elif isinstance(parsed.get("text"), str):
|
|
142
|
+
delta = parsed["text"]
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
delta = data_str
|
|
145
|
+
if delta:
|
|
146
|
+
full_text += delta
|
|
147
|
+
return full_text
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _decode_chunk(chunk: Any) -> str:
|
|
151
|
+
"""把 StreamResponse 产出的 chunk 归一成str(对齐 adapter._encode_stream_chunk 的逆向)。"""
|
|
152
|
+
if chunk is None:
|
|
153
|
+
return ""
|
|
154
|
+
if isinstance(chunk, (bytes, bytearray, memoryview)):
|
|
155
|
+
try:
|
|
156
|
+
return bytes(chunk).decode("utf-8", errors="replace")
|
|
157
|
+
except Exception:
|
|
158
|
+
return ""
|
|
159
|
+
if isinstance(chunk, str):
|
|
160
|
+
return chunk
|
|
161
|
+
try:
|
|
162
|
+
return json.dumps(chunk, ensure_ascii=False)
|
|
163
|
+
except (TypeError, ValueError):
|
|
164
|
+
return str(chunk)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _consume_agent_result(result: Any, on_delta: Optional[Callable[[str], Any]] = None) -> str:
|
|
168
|
+
"""消费 runtime.handle 返回的 RuntimeResult,解析 SSE 聚合纯文本。
|
|
169
|
+
|
|
170
|
+
- 流式(result.is_streaming):async for 迭代 StreamResponse.body,逐帧 decode +解析;
|
|
171
|
+
- 非流式:body 为 str/dict/None,直接归一。
|
|
172
|
+
对齐 Node __mcpConsumeAgentStream(含半包 buffer 处理)。
|
|
173
|
+
"""
|
|
174
|
+
if result is None:
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
if getattr(result, "is_streaming", False):
|
|
178
|
+
stream = result.body
|
|
179
|
+
buffer = ""
|
|
180
|
+
full_text = ""
|
|
181
|
+
agen = stream.body.__aiter__()
|
|
182
|
+
try:
|
|
183
|
+
async for chunk in _aiter(agen):
|
|
184
|
+
buffer += _decode_chunk(chunk)
|
|
185
|
+
# 按完整 SSE event 边界(\n\n)切分,保留半包到 buffer。
|
|
186
|
+
while "\n\n" in buffer:
|
|
187
|
+
idx = buffer.index("\n\n")
|
|
188
|
+
block = buffer[:idx]
|
|
189
|
+
buffer = buffer[idx + 2:]
|
|
190
|
+
full_text += await _parse_and_notify(block, on_delta)
|
|
191
|
+
except Exception:
|
|
192
|
+
# 流中断(网络错误 / 超时 / 上游异常)时优雅降级:返回已聚合的部分文本,
|
|
193
|
+
# 而非让异常冒泡丢弃全部已读内容(对齐 Node __mcpConsumeAgentStream)。
|
|
194
|
+
pass
|
|
195
|
+
if buffer:
|
|
196
|
+
full_text += await _parse_and_notify(buffer, on_delta)
|
|
197
|
+
return full_text
|
|
198
|
+
|
|
199
|
+
# 非流式
|
|
200
|
+
body = result.body
|
|
201
|
+
if body is None:
|
|
202
|
+
return ""
|
|
203
|
+
if isinstance(body, str):
|
|
204
|
+
return _parse_sse_text(body)
|
|
205
|
+
try:
|
|
206
|
+
return json.dumps(body, ensure_ascii=False)
|
|
207
|
+
except (TypeError, ValueError):
|
|
208
|
+
return str(body)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _aiter(agen):
|
|
212
|
+
"""把已取得的 __aiter__ 迭代器包装成 async for 可用形式。"""
|
|
213
|
+
while True:
|
|
214
|
+
try:
|
|
215
|
+
item = await agen.__anext__()
|
|
216
|
+
except StopAsyncIteration:
|
|
217
|
+
return
|
|
218
|
+
yield item
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _parse_and_notify(block: str, on_delta: Optional[Callable[[str], Any]]) -> str:
|
|
222
|
+
"""解析单个 SSE block,逐 delta 触发(可为 async 的)on_delta,返回该 block 的纯文本。"""
|
|
223
|
+
if not block:
|
|
224
|
+
return ""
|
|
225
|
+
event_name = ""
|
|
226
|
+
data_lines = []
|
|
227
|
+
for line in block.split("\n"):
|
|
228
|
+
if line.startswith("event:"):
|
|
229
|
+
event_name = line[6:].strip()
|
|
230
|
+
elif line.startswith("data:"):
|
|
231
|
+
data_lines.append(line[5:].strip())
|
|
232
|
+
if not data_lines:
|
|
233
|
+
return ""
|
|
234
|
+
data_str = "\n".join(data_lines)
|
|
235
|
+
if event_name == "done" or data_str == "[DONE]":
|
|
236
|
+
return ""
|
|
237
|
+
delta = ""
|
|
238
|
+
try:
|
|
239
|
+
parsed = json.loads(data_str)
|
|
240
|
+
if isinstance(parsed, str):
|
|
241
|
+
delta = parsed
|
|
242
|
+
elif isinstance(parsed, dict):
|
|
243
|
+
if isinstance(parsed.get("delta"), str):
|
|
244
|
+
delta = parsed["delta"]
|
|
245
|
+
elif isinstance(parsed.get("text"), str):
|
|
246
|
+
delta = parsed["text"]
|
|
247
|
+
except (ValueError, TypeError):
|
|
248
|
+
delta = data_str
|
|
249
|
+
if delta and on_delta is not None:
|
|
250
|
+
try:
|
|
251
|
+
res = on_delta(delta)
|
|
252
|
+
if hasattr(res, "__await__"):
|
|
253
|
+
await res
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
return delta
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _execute_route(runtime, route_path: str, body: dict, conversation_id: str) -> Any:
|
|
260
|
+
"""转发到 runtime.handle(对齐 Node __mcpExecuteRoute)。
|
|
261
|
+
|
|
262
|
+
把中控注入的身份头(makers-user-id)并入转发 headers,业务侧从
|
|
263
|
+
ctx.request.headers['makers-user-id'] 读取。
|
|
264
|
+
"""
|
|
265
|
+
headers = {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
"makers-conversation-id": conversation_id,
|
|
268
|
+
}
|
|
269
|
+
identity = _identity_headers()
|
|
270
|
+
if identity:
|
|
271
|
+
headers.update(identity)
|
|
272
|
+
return await runtime.handle(
|
|
273
|
+
path=route_path,
|
|
274
|
+
method="POST",
|
|
275
|
+
headers=headers,
|
|
276
|
+
query={},
|
|
277
|
+
body=body or {},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def create_mcp_server(mcp_config: dict, runtime) -> FastMCP:
|
|
282
|
+
"""构建 FastMCP server:注册构建期声明的路由 tool + 平台内置 tool,stateful HTTP。"""
|
|
283
|
+
name = mcp_config.get("name") or "Makers Agent"
|
|
284
|
+
# 关闭 DNS rebinding 保护:生产环境 host 非 localhost,平台级鉴权由中控完成,
|
|
285
|
+
# 本层不依赖 Host/Origin 校验。
|
|
286
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
287
|
+
_security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
|
288
|
+
#注意:FastMCP.__init__ 无 version 参数,仅 name。
|
|
289
|
+
# stateful:必须让服务端分配并回传 Mcp-Session-Id。
|
|
290
|
+
mcp = FastMCP(
|
|
291
|
+
name=name,
|
|
292
|
+
stateless_http=False,
|
|
293
|
+
json_response=False,
|
|
294
|
+
transport_security=_security,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# tool 列表完全来自构建期(路由注释声明),运行时不再推导。
|
|
298
|
+
route_tools = _normalize_tool_configs(mcp_config.get("tools"))
|
|
299
|
+
|
|
300
|
+
def _make_tool_fn(bound_route: str, tool_params: dict, tool_required: list):
|
|
301
|
+
# 工厂捕获 route + 参数配置,避免 late-binding。tool 函数用 **kwargs 接收所有
|
|
302
|
+
# 参数,再通过 __signature__ 告诉 FastMCP 真实参数列表 —— 这样既能动态适配
|
|
303
|
+
# mcp_parameters,又能让 FastMCP.func_metadata 生成正确的 inputSchema。
|
|
304
|
+
# 约束:FastMCP 不允许 '_' 前缀参数;参数名已在采集端用正则约束为合法标识符。
|
|
305
|
+
param_names = list(tool_params.keys())
|
|
306
|
+
required_set = set(tool_required or [])
|
|
307
|
+
|
|
308
|
+
async def tool_fn(**kwargs: Any) -> CallToolResult:
|
|
309
|
+
# session_id 是平台统一注入参数(非业务参数),单独取出。
|
|
310
|
+
session_id = kwargs.pop("session_id", "") or ""
|
|
311
|
+
ctx = kwargs.pop("__ctx__", None)
|
|
312
|
+
# 会话主键统一由 _resolve_conversation_id 裁决:鉴权开启时以中控派生值为准,
|
|
313
|
+
# 忽略 AI 传入的 session_id(否则可越权读写他人会话记忆)。
|
|
314
|
+
conversation_id = _resolve_conversation_id(session_id)
|
|
315
|
+
# 仅转发已声明的业务参数(过滤掉 None 的可选项,避免污染 body)。
|
|
316
|
+
body = {k: v for k, v in kwargs.items() if v is not None}
|
|
317
|
+
result = await _execute_route(runtime, bound_route, body, conversation_id)
|
|
318
|
+
|
|
319
|
+
async def _on_delta(delta: str) -> None:
|
|
320
|
+
if ctx is not None:
|
|
321
|
+
try:
|
|
322
|
+
await ctx.report_progress(progress=len(delta), total=None, message=delta)
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
full_text = await _consume_agent_result(result, _on_delta)
|
|
327
|
+
# 提取不到文本时给出可诊断提示,而非静默返回空串(与 Node 侧对齐):
|
|
328
|
+
# 否则调用方只看到 session_id,无从判断是 SSE 增量格式不匹配
|
|
329
|
+
# (data 需含 delta / text 字段)还是 Agent 确实没有产出。
|
|
330
|
+
if not full_text:
|
|
331
|
+
full_text = '(No text content could be extracted from the response. Make sure the route returns an SSE stream and that each text delta carries a "delta" or "text" field, e.g. data: {"delta":"..."})'
|
|
332
|
+
# structuredContent 带 route,供调用方定位对应 store。
|
|
333
|
+
return CallToolResult(
|
|
334
|
+
content=[TextContent(type="text", text=full_text)],
|
|
335
|
+
structuredContent={
|
|
336
|
+
"session_id": conversation_id,
|
|
337
|
+
"route": bound_route,
|
|
338
|
+
"status": "completed",
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# 动态构造签名:每个业务参数 + session_id(可选) + ctx(FastMCP 注入)。
|
|
343
|
+
_type_map = {"string": str, "number": float, "boolean": bool}
|
|
344
|
+
sig_params = []
|
|
345
|
+
annotations: dict = {}
|
|
346
|
+
for name in param_names:
|
|
347
|
+
schema = tool_params.get(name) or {}
|
|
348
|
+
py_type = _type_map.get(schema.get("type"), str)
|
|
349
|
+
is_required = name in required_set
|
|
350
|
+
default = inspect.Parameter.empty if is_required else None
|
|
351
|
+
sig_params.append(
|
|
352
|
+
inspect.Parameter(
|
|
353
|
+
name,
|
|
354
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
355
|
+
default=default,
|
|
356
|
+
annotation=py_type if is_required else Optional[py_type],
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
annotations[name] = py_type if is_required else Optional[py_type]
|
|
360
|
+
# session_id:可选,续接会话。
|
|
361
|
+
sig_params.append(
|
|
362
|
+
inspect.Parameter("session_id", inspect.Parameter.KEYWORD_ONLY, default="", annotation=str)
|
|
363
|
+
)
|
|
364
|
+
annotations["session_id"] = str
|
|
365
|
+
# ctx:FastMCP 依据类型注解自动注入 Context,不进 inputSchema。
|
|
366
|
+
sig_params.append(
|
|
367
|
+
inspect.Parameter("ctx", inspect.Parameter.KEYWORD_ONLY, default=None, annotation=Context)
|
|
368
|
+
)
|
|
369
|
+
annotations["ctx"] = Context
|
|
370
|
+
annotations["return"] = CallToolResult
|
|
371
|
+
|
|
372
|
+
# kwargs 里 ctx 需转成内部约定的 __ctx__ 传给闭包(因 tool_fn 用 **kwargs)。
|
|
373
|
+
async def tool_fn_wrapper(**kwargs: Any) -> CallToolResult:
|
|
374
|
+
if "ctx" in kwargs:
|
|
375
|
+
kwargs["__ctx__"] = kwargs.pop("ctx")
|
|
376
|
+
return await tool_fn(**kwargs)
|
|
377
|
+
|
|
378
|
+
tool_fn_wrapper.__signature__ = inspect.Signature(sig_params) # type: ignore[attr-defined]
|
|
379
|
+
tool_fn_wrapper.__annotations__ = annotations
|
|
380
|
+
return tool_fn_wrapper
|
|
381
|
+
|
|
382
|
+
for tool in route_tools:
|
|
383
|
+
mcp.add_tool(
|
|
384
|
+
_make_tool_fn(tool["route"], tool.get("parameters") or {}, tool.get("required") or []),
|
|
385
|
+
name=tool["tool_name"],
|
|
386
|
+
description=tool["description"],
|
|
387
|
+
# tool_fn 直接返回 CallToolResult(自带 content + structuredContent),
|
|
388
|
+
# 关闭自动结构化输出,让 FastMCP 原样透传我们构造的结果(含 route 字段)。
|
|
389
|
+
structured_output=False,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# MCP 工具集完全等于路由 docstring 声明的 tool 列表,平台不额外注入能力。
|
|
393
|
+
return mcp
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edgeone",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.41",
|
|
4
4
|
"description": "Command-line interface for TencentCloud Pages Functions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"edgeone": "./edgeone-bin/edgeone.js"
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@babel/traverse": "^7.28.0",
|
|
58
58
|
"@babel/types": "^7.28.0",
|
|
59
59
|
"@edgeone/nuxt-pages": "1.1.2-beta.1",
|
|
60
|
-
"@edgeone/opennextjs-pages": "0.2.10-beta.
|
|
60
|
+
"@edgeone/opennextjs-pages": "0.2.10-beta.9",
|
|
61
61
|
"@edgeone/pages-agent-toolkit": "0.1.38",
|
|
62
62
|
"@edgeone/pages-blob": "^0.0.16",
|
|
63
63
|
"chokidar": "^4.0.1",
|
|
@@ -73,7 +73,8 @@
|
|
|
73
73
|
"@e2b/code-interpreter": "2.4.2",
|
|
74
74
|
"@edgeone/framework-detect": "^0.0.2",
|
|
75
75
|
"@edgeone/is-spa": "^0.0.3",
|
|
76
|
-
"@edgeone/types": "^1.0.
|
|
76
|
+
"@edgeone/types": "^1.0.2",
|
|
77
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
77
78
|
"@opentelemetry/api": "^1.9.1",
|
|
78
79
|
"@opentelemetry/core": "^1.30.1",
|
|
79
80
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.55.0",
|
|
@@ -89,6 +90,7 @@
|
|
|
89
90
|
"@types/http-proxy": "^1.17.12",
|
|
90
91
|
"@types/inquirer": "^9.0.8",
|
|
91
92
|
"@types/ip": "^1.1.0",
|
|
93
|
+
"@types/js-yaml": "^4.0.9",
|
|
92
94
|
"@types/lodash": "^4.14.198",
|
|
93
95
|
"@types/prompts": "^2.4.4",
|
|
94
96
|
"@types/react": "^17.0.2",
|
|
@@ -114,6 +116,7 @@
|
|
|
114
116
|
"ink-table": "^3.1.0",
|
|
115
117
|
"inquirer": "^8.2.4",
|
|
116
118
|
"ip": "^2.0.1",
|
|
119
|
+
"js-yaml": "^4.3.1",
|
|
117
120
|
"json5": "^2.2.3",
|
|
118
121
|
"lint-staged": "^15.2.0",
|
|
119
122
|
"lodash": "^4.17.21",
|