feat(mcp): 实现 transpyc_mcp 服务器——stdio JSON-RPC 驱动 TPC/TPV

- transpyc_mcp/server.py:零依赖 MCP 服务器(initialize/tools/list/tools/call)
- 14 个工具按协议实现:tpc_build/tpc_build_file/tpc_run_test/tpc_run_regression/
  tpc_clean_project/tpc_clear_cache/tpc_rebuild_includes/tpv_build/tpv_bootstrap/
  tpv_compile_project/tpv_run_test/tp_read_ast/tp_read_log/tp_sha1_map
- 安全:路径白名单/命令白名单/工程串行锁/TPV 内存保护(>=1GiB)
- 输出统一 {ok, exit_code, stdout, stderr, artifacts, elapsed_ms},日志末尾 50 行
- 冒烟测试通过:init/tools-list/AST 解析/日志/越界拒绝/未知工具/锁
- 修正协议:--clean 为清理后继续完整编译(非独立清理)
This commit is contained in:
TermiNexus committed 2026-08-21 14:05:36 +08:00
1 parent e412885a3e
commit 004121de40
5 files changed
+595 -4

No files matched your search

+1 -1
View File
@@ -48,7 +48,7 @@ MCP 服务器(`transpyc-tools`)以 stdio 方式运行于项目根目录:
| 编译任意工程 | `tpc_build` | `python Projectrans.py --project <project.json> [--clean] [--run] ...` |
| 单文件转 LLVM IR | `tpc_build_file` | `python TransPyC.py -f <in.vp> -o <out.ll>` |
| 全量回归 | `tpc_run_regression` | `python Test/regression_test.py` |
| 清理工程产物 | `tpc_clean_project` | `python Projectrans.py --project <p> --clean` |
| 清理并编译 | `tpc_clean_project` | `python Projectrans.py --project <p> --clean`(清理后继续完整编译) |
| 清全局缓存 | `tpc_clear_cache` | `python Projectrans.py --project <p> --clear-cache` |
| 重建 includes | `tpc_rebuild_includes` | `python Projectrans.py --project <p> --rebuild-includes` |
| 构建 TPV | `tpv_build` | `python Projectrans.py --project TransPyV/project.json --clean` |
+2 -2
View File
@@ -220,9 +220,9 @@ python Test/regression_test.py
- 说明:默认全量耗时较长;客户端应按需使用 `name` 过滤或分片调用。
### 6.5 tpc_clean_project — 清理工程产物
### 6.5 tpc_clean_project — 清理并编译工程
- description:清理工程 `output/` 与 `temp/`(`Projectrans.py --clean`)。
- description:执行 `Projectrans.py --clean`——先清理 `output/` 与 `temp/`,**随后仍继续完整编译流程**(`--clean` 是编译前置步骤,非独立清理命令)。等价 `tpc_build(clean=true)`,仅不含运行。
- inputSchema:`{ "project": {"type":"string"} }`,required: ["project"]。
- command:`python Projectrans.py --project <project> --clean`
+1 -1
View File
@@ -147,7 +147,7 @@
},
{
"name": "tpc_clean_project",
"description": "清理工程 output/temp(--clean)",
"description": "清理并编译工程(--clean,清理后继续完整编译流程)",
"inputSchema": {
"type": "object",
"properties": {
+8
View File
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
"""transpyc_mcp - TransPyC/TransPyV MCP 服务器包
实现 MCP/ 协议(stdio transport,零第三方依赖)。
入口:python -m transpyc_mcp.server
"""
__version__ = '1.0.0'
+583
View File
@@ -0,0 +1,583 @@
# -*- coding: utf-8 -*-
"""transpyc_mcp.server - TransPyC/TransPyV MCP 服务器
实现 MCP/ 协议(v2026-08):
- stdio transport,newline-delimited JSON-RPC 2.0
- 14 个工具(tpc_/tpv_/tp_),schema 从 MCP/tools/tools.json 加载
- 安全:路径白名单 / 命令白名单 / 工程串行锁 / 内存保护
- 输出:统一 {ok, exit_code, stdout, stderr, artifacts, elapsed_ms},日志末尾 50 行
启动:python -m transpyc_mcp.server(cwd = 项目根)
"""
from __future__ import annotations
import ctypes
import hashlib
import json
import os
import shlex
import subprocess
import sys
import time
from typing import Any, Callable
# ---------------------------------------------------------------------------
# 常量
# ---------------------------------------------------------------------------
_ROOT: str = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_TOOLS_JSON: str = os.path.join(_ROOT, 'MCP', 'tools', 'tools.json')
_COMPILE_TIMEOUT: int = 300
_RUN_TIMEOUT: int = 30
_REGRESSION_TIMEOUT: int = 1800
_LOG_TAIL: int = 50
_PROTOCOL_VERSION: str = '2024-11-05'
_SERVER_INFO: dict[str, str] = {'name': 'transpyc-tools', 'version': '1.0.0'}
_TPV_PROJECT: str = os.path.join('TransPyV', 'project.json')
_TPV_EXE: str = os.path.join('TransPyV', 'output', 'TransPyV.exe')
# ---------------------------------------------------------------------------
# 安全:路径白名单
# ---------------------------------------------------------------------------
class _PathError(ValueError):
pass
def _resolve(path: str) -> str:
"""规范化并校验路径必须位于项目根内(拒绝越界)。"""
p: str = path
if not os.path.isabs(p):
p = os.path.join(_ROOT, p)
p = os.path.normpath(p)
if not (p == _ROOT or p.startswith(_ROOT + os.sep)):
raise _PathError(f'path outside root: {path}')
return p
# ---------------------------------------------------------------------------
# 命令执行与输出截断
# ---------------------------------------------------------------------------
def _tail(text: str, n: int = _LOG_TAIL) -> str:
lines: list[str] = text.splitlines()
if len(lines) <= n:
return text
return '\n'.join(lines[-n:])
def _run(cmd: list[str], timeout: int = _COMPILE_TIMEOUT, cwd: str | None = None) -> dict[str, Any]:
"""执行白名单命令,返回统一结果结构。"""
t0: float = time.perf_counter()
try:
r: subprocess.CompletedProcess = subprocess.run(
cmd, cwd=cwd or _ROOT, capture_output=True, text=True,
encoding='utf-8', errors='replace', timeout=timeout,
)
return {
'ok': r.returncode == 0,
'exit_code': r.returncode,
'stdout': _tail(r.stdout),
'stderr': _tail(r.stderr),
'elapsed_ms': int((time.perf_counter() - t0) * 1000),
}
except subprocess.TimeoutExpired:
return {'ok': False, 'exit_code': 6, 'stdout': '', 'stderr': f'timeout after {timeout}s',
'elapsed_ms': timeout * 1000}
except Exception as e: # 捕获启动失败等
return {'ok': False, 'exit_code': 1, 'stdout': '', 'stderr': str(e),
'elapsed_ms': int((time.perf_counter() - t0) * 1000)}
def _result_ok(**kw: Any) -> dict[str, Any]:
base: dict[str, Any] = {'ok': True, 'exit_code': 0, 'stdout': '', 'stderr': '', 'elapsed_ms': 0}
base.update(kw)
return base
def _result_err(code: int, msg: str) -> dict[str, Any]:
return {'ok': False, 'exit_code': code, 'stdout': '', 'stderr': msg, 'elapsed_ms': 0}
# ---------------------------------------------------------------------------
# 工程串行锁
# ---------------------------------------------------------------------------
_BUSY: set[str] = set()
def _with_lock(key: str, fn: Callable[[], dict[str, Any]]) -> dict[str, Any]:
if key in _BUSY:
return _result_err(5, f'project busy: {key}')
_BUSY.add(key)
try:
return fn()
finally:
_BUSY.discard(key)
# ---------------------------------------------------------------------------
# 内存保护(TPV 需 >= 1 GiB)
# ---------------------------------------------------------------------------
def _available_gb() -> float:
try:
class _M(ctypes.Structure):
_fields_ = [('length', ctypes.c_ulong), ('memory_load', ctypes.c_ulong),
('total_phys', ctypes.c_ulonglong), ('avail_phys', ctypes.c_ulonglong),
('total_page', ctypes.c_ulonglong), ('avail_page', ctypes.c_ulonglong),
('total_virtual', ctypes.c_ulonglong), ('avail_virtual', ctypes.c_ulonglong),
('avail_ext', ctypes.c_ulonglong)]
m: _M = _M()
m.length = ctypes.sizeof(_M)
ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(m))
return m.avail_phys / (1024.0 ** 3)
except Exception:
return 99.0
def _check_memory() -> str | None:
if _available_gb() < 1.0:
return 'insufficient memory (< 1 GiB) for TPV'
return None
# ---------------------------------------------------------------------------
# 工具:参数构造辅助
# ---------------------------------------------------------------------------
def _build_project_cmd(args: dict[str, Any], project: str, exe: str | None = None) -> list[str]:
"""构造工程编译命令(exe=None 用 TPC Projectrans.py;否则用 TPV exe)。"""
cmd: list[str] = [sys.executable, 'Projectrans.py'] if exe is None else [exe]
cmd += ['--project', project]
if args.get('clean'):
cmd.append('--clean')
if args.get('run'):
cmd.append('--run')
phase: str = args.get('phase', 'all')
if phase in ('1', '2'):
cmd += ['--phase', phase]
if args.get('rebuild_includes'):
cmd.append('--rebuild-includes')
if args.get('clear_cache'):
cmd.append('--clear-cache')
if args.get('asan'):
cmd.append('--asan')
if args.get('nowarn_register'):
cmd.append('--nowarn-register')
for opt in ('src', 'temp', 'output', 'cc'):
if args.get(opt):
cmd += [f'--{opt}', str(args[opt])]
return cmd
def _find_exe(project: str) -> dict[str, str]:
"""从 project.json 的 linker.output 定位产物 exe(相对根)。"""
proj_path: str = _resolve(project)
proj_dir: str = os.path.dirname(proj_path)
try:
with open(proj_path, 'r', encoding='utf-8') as f:
cfg: dict[str, Any] = json.load(f)
except Exception:
return {}
out_name: str = str(cfg.get('linker', {}).get('output', ''))
out_dir: str = str(cfg.get('output_dir', 'output'))
if out_name:
exe: str = os.path.join(proj_dir, out_dir, out_name)
if os.path.isfile(exe):
return {'exe': os.path.relpath(exe, _ROOT)}
# 兜底:output 目录下最新 .exe
out_abs: str = os.path.join(proj_dir, out_dir)
if os.path.isdir(out_abs):
cands: list[str] = [os.path.join(out_abs, f) for f in os.listdir(out_abs)
if f.lower().endswith('.exe')]
if cands:
latest: str = max(cands, key=os.path.getmtime)
return {'exe': os.path.relpath(latest, _ROOT)}
return {}
# ---------------------------------------------------------------------------
# 工具:tpc_ 系列
# ---------------------------------------------------------------------------
def _tpc_build(args: dict[str, Any]) -> dict[str, Any]:
project: str = str(args.get('project', ''))
if not project:
return _result_err(2, 'missing required argument: project')
proj_path: str = _resolve(project)
if not os.path.isfile(proj_path):
return _result_err(4, f'project not found: {project}')
def run() -> dict[str, Any]:
cmd: list[str] = _build_project_cmd(args, project)
res: dict[str, Any] = _run(cmd)
if res['ok']:
res['artifacts'] = _find_exe(project)
return res
return _with_lock(project, run)
def _tpc_build_file(args: dict[str, Any]) -> dict[str, Any]:
input_f: str = str(args.get('input', ''))
output_f: str = str(args.get('output', ''))
if not input_f or not output_f:
return _result_err(2, 'missing required arguments: input, output')
in_path: str = _resolve(input_f)
out_path: str = _resolve(output_f)
if not os.path.isfile(in_path):
return _result_err(4, f'input not found: {input_f}')
cmd: list[str] = [sys.executable, 'TransPyC.py', '-f', in_path, '-o', out_path]
if args.get('cc'):
cmd += ['-cc', str(args['cc'])]
if args.get('cflags'):
cmd += ['-cflags', str(args['cflags'])]
if args.get('run'):
cmd.append('-run')
if args.get('args'):
cmd += ['-args', str(args['args'])]
if args.get('level'):
cmd += ['-level', str(args['level'])]
cmd += ['-e', str(args.get('encoding', 'utf-8'))]
res: dict[str, Any] = _run(cmd)
if res['ok']:
res['artifacts'] = {'ir': output_f}
return res
def _tpc_run_test(args: dict[str, Any]) -> dict[str, Any]:
name: str = str(args.get('name', ''))
if not name:
return _result_err(2, 'missing required argument: name')
project: str = os.path.join('Test', name, 'project.json')
proj_path: str = _resolve(project)
if not os.path.isfile(proj_path):
return _result_err(4, f'test project not found: {project}')
cargs: dict[str, Any] = dict(args)
cargs['clean'] = True
cargs['run'] = True
def run() -> dict[str, Any]:
cmd: list[str] = _build_project_cmd(cargs, project)
res: dict[str, Any] = _run(cmd, timeout=_RUN_TIMEOUT + _COMPILE_TIMEOUT)
if res['ok']:
res['artifacts'] = _find_exe(project)
return res
return _with_lock(project, run)
def _tpc_run_regression(args: dict[str, Any]) -> dict[str, Any]:
timeout: int = int(args.get('timeout', _REGRESSION_TIMEOUT))
cmd: list[str] = [sys.executable, os.path.join('Test', 'regression_test.py')]
return _run(cmd, timeout=timeout)
def _tpc_flag_only(args: dict[str, Any], flag: str) -> dict[str, Any]:
project: str = str(args.get('project', ''))
if not project:
return _result_err(2, 'missing required argument: project')
proj_path: str = _resolve(project)
if not os.path.isfile(proj_path):
return _result_err(4, f'project not found: {project}')
def run() -> dict[str, Any]:
return _run([sys.executable, 'Projectrans.py', '--project', project, flag])
return _with_lock(project, run)
def _tpc_clean_project(args: dict[str, Any]) -> dict[str, Any]:
return _tpc_flag_only(args, '--clean')
def _tpc_clear_cache(args: dict[str, Any]) -> dict[str, Any]:
return _tpc_flag_only(args, '--clear-cache')
def _tpc_rebuild_includes(args: dict[str, Any]) -> dict[str, Any]:
return _tpc_flag_only(args, '--rebuild-includes')
# ---------------------------------------------------------------------------
# 工具:tpv_ 系列
# ---------------------------------------------------------------------------
def _tpv_build(args: dict[str, Any]) -> dict[str, Any]:
proj_path: str = _resolve(_TPV_PROJECT)
if not os.path.isfile(proj_path):
return _result_err(4, f'project not found: {_TPV_PROJECT}')
if args.get('clear_cache'):
return _result_err(2, 'clear_cache unsupported on tpv_build; use tpc_clear_cache on TPC side first')
def run() -> dict[str, Any]:
cmd: list[str] = [sys.executable, 'Projectrans.py', '--project', _TPV_PROJECT, '--clean']
if args.get('rebuild_includes'):
cmd.append('--rebuild-includes')
res: dict[str, Any] = _run(cmd)
if res['ok']:
exe: str = os.path.join(_ROOT, _TPV_EXE)
res['artifacts'] = {'exe': _TPV_EXE if os.path.isfile(exe) else ''}
return res
return _with_lock(_TPV_PROJECT, run)
def _sha1_file(path: str) -> str:
h: hashlib._Hash = hashlib.sha1()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(65536), b''):
h.update(chunk)
return h.hexdigest()
def _tpv_bootstrap(args: dict[str, Any]) -> dict[str, Any]:
mem_err: str | None = _check_memory()
if mem_err:
return _result_err(7, mem_err)
phases: list[str] = list(args.get('phases') or ['A', 'B', 'C'])
compare: bool = bool(args.get('compare', True))
out_dirs: dict[str, str] = {'A': 'TransPyV/output', 'B': 'TransPyV/output_b', 'C': 'TransPyV/output_c'}
steps: list[dict[str, Any]] = []
# Phase A:TPC 编译 TransPyV → output/TransPyV.exe
if 'A' in phases:
res: dict[str, Any] = _run([sys.executable, 'Projectrans.py', '--project', _TPV_PROJECT, '--clean'])
steps.append({'phase': 'A', 'exe': _TPV_EXE, **res})
if not res['ok']:
return {'ok': False, 'exit_code': res['exit_code'], 'stdout': res['stdout'],
'stderr': 'bootstrap Phase A failed\n' + res['stderr'],
'artifacts': {'steps': steps}, 'elapsed_ms': res['elapsed_ms']}
else:
steps.append({'phase': 'A', 'exe': _TPV_EXE, 'ok': True, 'exit_code': 0, 'stdout': '', 'stderr': '(skipped)'})
# Phase B/C:上一阶段 exe 编译自身到下一 output 目录
for idx, ph in enumerate(('B', 'C')):
if ph not in phases:
steps.append({'phase': ph, 'ok': True, 'exit_code': 0, 'stdout': '', 'stderr': '(skipped)'})
continue
prev_dir: str = out_dirs['A'] if idx == 0 else out_dirs['B']
prev_exe: str = os.path.join(_ROOT, prev_dir, 'TransPyV.exe')
if not os.path.isfile(prev_exe):
steps.append({'phase': ph, 'ok': False, 'exit_code': 4,
'stdout': '', 'stderr': f'previous exe missing: {prev_exe}'})
return {'ok': False, 'exit_code': 4, 'stdout': '', 'stderr': f'bootstrap Phase {ph} missing input',
'artifacts': {'steps': steps}, 'elapsed_ms': 0}
out_dir: str = out_dirs[ph]
r: dict[str, Any] = _run([prev_exe, '--project', _TPV_PROJECT, '--clean', '--output', out_dir])
steps.append({'phase': ph, 'exe': os.path.join(out_dir, 'TransPyV.exe'), **r})
if not r['ok']:
return {'ok': False, 'exit_code': r['exit_code'], 'stdout': r['stdout'],
'stderr': f'bootstrap Phase {ph} failed\n' + r['stderr'],
'artifacts': {'steps': steps}, 'elapsed_ms': r['elapsed_ms']}
# 对比各阶段产物 SHA1(自举一致性)
artifacts: dict[str, Any] = {'steps': steps}
if compare:
hashes: dict[str, str] = {}
for ph in phases:
exe_rel: str = os.path.join(out_dirs[ph], 'TransPyV.exe')
exe_abs: str = os.path.join(_ROOT, exe_rel)
hashes[ph] = _sha1_file(exe_abs) if os.path.isfile(exe_abs) else ''
artifacts['hashes'] = hashes
artifacts['identical'] = len(set(hashes.values()) - {''}) <= 1
return _result_ok(stdout=json.dumps(artifacts, ensure_ascii=False, indent=2),
artifacts=artifacts)
def _tpv_compile_project(args: dict[str, Any]) -> dict[str, Any]:
project: str = str(args.get('project', ''))
if not project:
return _result_err(2, 'missing required argument: project')
proj_path: str = _resolve(project)
if not os.path.isfile(proj_path):
return _result_err(4, f'project not found: {project}')
exe: str = os.path.join(_ROOT, _TPV_EXE)
if not os.path.isfile(exe):
return _result_err(4, f'TPV not built yet; run tpv_build first: {_TPV_EXE}')
mem_err: str | None = _check_memory()
if mem_err:
return _result_err(7, mem_err)
def run() -> dict[str, Any]:
cmd: list[str] = _build_project_cmd(args, project, exe=exe)
res: dict[str, Any] = _run(cmd)
if res['ok']:
res['artifacts'] = _find_exe(project)
return res
return _with_lock(project, run)
def _tpv_run_test(args: dict[str, Any]) -> dict[str, Any]:
exe: str = os.path.join(_ROOT, _TPV_EXE)
if not os.path.isfile(exe):
return _result_err(4, f'TPV not built yet; run tpv_build first: {_TPV_EXE}')
mem_err: str | None = _check_memory()
if mem_err:
return _result_err(7, mem_err)
suite: str = str(args.get('suite', 'all'))
proj: str = os.path.join('TransPyV', 'Test', 'project.vpj')
if not os.path.isfile(_resolve(proj)):
return _result_err(4, f'TPV test project not found: {proj}')
cmd: list[str] = [exe, '--project', proj, '--clean', '--run']
return _with_lock(proj, lambda: _run(cmd))
# ---------------------------------------------------------------------------
# 工具:tp_ 系列(只读诊断)
# ---------------------------------------------------------------------------
def _tp_read_ast(args: dict[str, Any]) -> dict[str, Any]:
file: str = str(args.get('file', ''))
if not file:
return _result_err(2, 'missing required argument: file')
fpath: str = _resolve(file)
if not os.path.isfile(fpath):
return _result_err(4, f'file not found: {file}')
if fpath.lower().endswith('.py') or fpath.lower().endswith('.vp'):
lib: str = os.path.join(_ROOT, 'lib')
if lib not in sys.path:
sys.path.insert(0, lib)
try:
import vast as ast # noqa: PLC0415
except Exception as e:
return _result_err(1, f'vast import failed: {e}')
with open(fpath, 'r', encoding='utf-8', errors='replace') as f:
src: str = f.read()
try:
tree: Any = ast.parse(src)
dump: str = args.get('dump', 'compact')
text: str = ast.dump(tree, indent=2) if dump == 'full' else ast.dump(tree)
except Exception as e:
return _result_err(1, f'parse failed: {e}')
return _result_ok(stdout=text)
return _result_err(2, f'unsupported file type: {file} (only .vp/.py)')
def _tp_read_log(args: dict[str, Any]) -> dict[str, Any]:
file: str = str(args.get('file', ''))
if not file:
return _result_err(2, 'missing required argument: file')
fpath: str = _resolve(file)
if not os.path.isfile(fpath):
return _result_err(4, f'file not found: {file}')
lines: int = int(args.get('lines', _LOG_TAIL))
try:
with open(fpath, 'r', encoding='utf-8', errors='replace') as f:
content: str = f.read()
except Exception as e:
return _result_err(1, f'read failed: {e}')
pattern: str = str(args.get('pattern', ''))
if pattern:
import re # noqa: PLC0415
try:
content = '\n'.join(l for l in content.splitlines() if re.search(pattern, l))
except Exception as e:
return _result_err(2, f'invalid pattern: {e}')
return _result_ok(stdout=_tail(content, max(1, lines)))
def _tp_sha1_map(args: dict[str, Any]) -> dict[str, Any]:
project: str = str(args.get('project', ''))
if not project:
return _result_err(2, 'missing required argument: project')
map_rel: str = os.path.join(project, 'temp', '_sha1_map.txt')
map_path: str = _resolve(map_rel)
if not os.path.isfile(map_path):
return _result_err(4, f'sha1 map not found: {map_rel}')
with open(map_path, 'r', encoding='utf-8', errors='replace') as f:
content: str = f.read()
filt: str = str(args.get('filter', ''))
if filt:
content = '\n'.join(l for l in content.splitlines() if filt in l)
return _result_ok(stdout=_tail(content, 200))
# ---------------------------------------------------------------------------
# 工具分派表
# ---------------------------------------------------------------------------
DISPATCH: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
'tpc_build': _tpc_build,
'tpc_build_file': _tpc_build_file,
'tpc_run_test': _tpc_run_test,
'tpc_run_regression': _tpc_run_regression,
'tpc_clean_project': _tpc_clean_project,
'tpc_clear_cache': _tpc_clear_cache,
'tpc_rebuild_includes': _tpc_rebuild_includes,
'tpv_build': _tpv_build,
'tpv_bootstrap': _tpv_bootstrap,
'tpv_compile_project': _tpv_compile_project,
'tpv_run_test': _tpv_run_test,
'tp_read_ast': _tp_read_ast,
'tp_read_log': _tp_read_log,
'tp_sha1_map': _tp_sha1_map,
}
def _load_tools() -> list[dict[str, Any]]:
try:
with open(_TOOLS_JSON, 'r', encoding='utf-8') as f:
tools: list[dict[str, Any]] = json.load(f)
if isinstance(tools, list):
return tools
except Exception:
pass
# 兜底:从分派表生成最小 schema
return [{'name': n, 'description': n, 'inputSchema': {'type': 'object', 'properties': {}, 'required': []}}
for n in DISPATCH]
# ---------------------------------------------------------------------------
# JSON-RPC 处理
# ---------------------------------------------------------------------------
def _rpc_result(msg: dict[str, Any], result: Any) -> dict[str, Any]:
return {'jsonrpc': '2.0', 'id': msg.get('id'), 'result': result}
def _rpc_error(msg: dict[str, Any], code: int, message: str) -> dict[str, Any]:
return {'jsonrpc': '2.0', 'id': msg.get('id'),
'error': {'code': code, 'message': message}}
def _handle(msg: dict[str, Any]) -> dict[str, Any] | None:
method: str = str(msg.get('method', ''))
if method == 'initialize':
return _rpc_result(msg, {
'protocolVersion': _PROTOCOL_VERSION,
'capabilities': {'tools': {}},
'serverInfo': _SERVER_INFO,
})
if method in ('notifications/initialized', 'notifications/cancelled'):
return None
if method == 'tools/list':
return _rpc_result(msg, {'tools': _load_tools()})
if method == 'tools/call':
params: dict[str, Any] = msg.get('params') or {}
name: str = str(params.get('name', ''))
fn: Callable[[dict[str, Any]], dict[str, Any]] | None = DISPATCH.get(name)
if fn is None:
return _rpc_error(msg, -32602, f'unknown tool: {name}')
args: dict[str, Any] = params.get('arguments') or {}
if not isinstance(args, dict):
return _rpc_error(msg, -32602, 'arguments must be an object')
try:
result: dict[str, Any] = fn(args)
except _PathError as e:
result = _result_err(3, str(e))
except Exception as e: # 工具内部未预期异常
result = _result_err(1, f'{type(e).__name__}: {e}')
text: str = json.dumps(result, ensure_ascii=False, indent=2)
return _rpc_result(msg, {'content': [{'type': 'text', 'text': text}],
'isError': not result.get('ok', True)})
if msg.get('id') is None:
return None # 其它 notification
return _rpc_error(msg, -32601, f'method not found: {method}')
def main() -> None:
"""stdio 循环:逐行读 JSON-RPC 请求,逐行写响应。"""
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg: dict[str, Any] = json.loads(line)
except Exception:
continue
resp: dict[str, Any] | None = _handle(msg)
if resp is not None:
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
sys.stdout.flush()
if __name__ == '__main__':
main()