store-mcp-py 0.1.0__tar.gz
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.
- store_mcp_py-0.1.0/PKG-INFO +10 -0
- store_mcp_py-0.1.0/pyproject.toml +18 -0
- store_mcp_py-0.1.0/setup.cfg +4 -0
- store_mcp_py-0.1.0/store_mcp/__init__.py +314 -0
- store_mcp_py-0.1.0/store_mcp_py.egg-info/PKG-INFO +10 -0
- store_mcp_py-0.1.0/store_mcp_py.egg-info/SOURCES.txt +9 -0
- store_mcp_py-0.1.0/store_mcp_py.egg-info/dependency_links.txt +1 -0
- store_mcp_py-0.1.0/store_mcp_py.egg-info/requires.txt +4 -0
- store_mcp_py-0.1.0/store_mcp_py.egg-info/top_level.txt +1 -0
- store_mcp_py-0.1.0/tests/test_conformance.py +153 -0
- store_mcp_py-0.1.0/tests/test_mcp.py +292 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-mcp-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP (Model Context Protocol) adapter that auto-generates tool surfaces for py-store schemas (schema-driven CRUD + sandboxed GQL query + optional ask), stdio transport
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,mcp,model-context-protocol,crud,gql,ai
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: mcp<3,>=2.2
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "store-mcp-py"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP (Model Context Protocol) adapter that auto-generates tool surfaces for py-store schemas (schema-driven CRUD + sandboxed GQL query + optional ask), stdio transport"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
keywords = ["py-store", "storepy", "mcp", "model-context-protocol", "crud", "gql", "ai"]
|
|
12
|
+
dependencies = ["mcp>=2.2,<3"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8"]
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
include = ["store_mcp*"]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""store-mcp-py — 为 py-store 已注册 schema 自动生成 MCP 工具面。
|
|
2
|
+
|
|
3
|
+
语义依据:../spec/*.md(双端 parity,改动先改 spec)。
|
|
4
|
+
设计哲学:MCP 只是 GQL 的又一层 AI 客户端皮肤 —— 适配层零语义发明。
|
|
5
|
+
双出口:
|
|
6
|
+
export_tools(store, opts) → list[dict](纯构建,零 SDK 依赖,永远可用)
|
|
7
|
+
create_server(store, opts) → 低层 Server(装配全部守卫,不绑传输)
|
|
8
|
+
run_stdio(store, opts) → stdio 阻塞运行(create_server + stdio_server)
|
|
9
|
+
|
|
10
|
+
store 端口契约见 spec/00:query / query_one / insert / update / remove / set_context;
|
|
11
|
+
元数据(list / get)走 opts.schema_module → duck(store.list/store.get) → py_store.schema;
|
|
12
|
+
档位上下文 text2query(query 工具强制沙箱)取用顺序:opts["text2query"] → py_store.schema。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
__all__ = ['export_tools', 'create_server', 'run_stdio',
|
|
21
|
+
'filter_archived', 'schema_projection', 'build_surface', 'error_of']
|
|
22
|
+
|
|
23
|
+
ARCHIVE_SUFFIX = 'Deleted'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── spec/01:归档表过滤(与 store-api / store-graphql / store-grpc 逐字一致)──
|
|
27
|
+
|
|
28
|
+
def filter_archived(names: list[str]) -> list[str]:
|
|
29
|
+
names_set = set(names)
|
|
30
|
+
return [n for n in names
|
|
31
|
+
if not (n.endswith(ARCHIVE_SUFFIX) and n[:-len(ARCHIVE_SUFFIX)] in names_set)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── spec/01:schema 投影(fields + computes 均视为字段,与 store-graphql 对齐)──
|
|
35
|
+
|
|
36
|
+
def schema_projection(defn: dict | None) -> str:
|
|
37
|
+
if not defn:
|
|
38
|
+
return ''
|
|
39
|
+
keys = list({**(defn.get('fields') or {}), **(defn.get('computes') or {})})
|
|
40
|
+
return f' {{ {", ".join(keys)} }}' if keys else ''
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def id_field_of(defn: dict | None) -> str:
|
|
44
|
+
return (defn or {}).get('idField') or '_id'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── 协议面错误(spec/03 判定链的皮肤侧子集)──
|
|
48
|
+
|
|
49
|
+
class SkinError(Exception):
|
|
50
|
+
def __init__(self, code: str, message: str):
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _assert_object(value: Any, field: str) -> None:
|
|
56
|
+
if not isinstance(value, dict):
|
|
57
|
+
raise SkinError('invalidBody', f'{field} 必须为 JSON 对象(数组/标量拒绝,spec/02)')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── spec/03:错误判定链(类名判定跨包安全,不依赖宿主 isinstance)──
|
|
61
|
+
|
|
62
|
+
def error_of(e: BaseException) -> dict:
|
|
63
|
+
name = getattr(e, 'name', None) or e.__class__.__name__
|
|
64
|
+
if name == 'SkinError':
|
|
65
|
+
return {'code': e.code, 'message': str(e)} # type: ignore[attr-defined]
|
|
66
|
+
if name == 'AskExhausted':
|
|
67
|
+
return {'code': 'askExhausted', 'message': str(e)}
|
|
68
|
+
if name == 'PermissionError':
|
|
69
|
+
return {'code': 'permissionDenied', 'message': str(e)}
|
|
70
|
+
if name == 'ProfileViolation':
|
|
71
|
+
return {'code': 'profileBlocked', 'message': str(e)}
|
|
72
|
+
return {'code': 'planError', 'message': str(e)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── 元数据 provider(spec/00:opts.schema_module → duck → py_store.schema)──
|
|
76
|
+
|
|
77
|
+
def _meta_provider(store: Any, opts: dict):
|
|
78
|
+
module = opts.get('schema_module')
|
|
79
|
+
if module is not None:
|
|
80
|
+
return module
|
|
81
|
+
if hasattr(store, 'list') and hasattr(store, 'get'):
|
|
82
|
+
return store
|
|
83
|
+
try:
|
|
84
|
+
from py_store import schema
|
|
85
|
+
return schema
|
|
86
|
+
except ImportError as e:
|
|
87
|
+
raise RuntimeError(
|
|
88
|
+
'store-mcp: 取不到 schema 元数据(store 无 list/get,且未安装 py_store)。'
|
|
89
|
+
'传 opts["schema_module"] 或安装 py-store') from e
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ── spec/01:工具清单构建(纯 JSON Schema 直出)──
|
|
93
|
+
|
|
94
|
+
def _obj(properties: dict, required: list[str]) -> dict:
|
|
95
|
+
return {'type': 'object', 'properties': properties, 'required': required}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def model_tools(name: str, defn: dict | None, description: str | None) -> list[dict]:
|
|
99
|
+
desc = description or f'{name}(MCP 工具面,由 schema 自动生成)'
|
|
100
|
+
d = lambda s: f'{desc} — {s}' # noqa: E731
|
|
101
|
+
return [
|
|
102
|
+
{'name': f'get_{name}', 'description': d('按 id 查单条'),
|
|
103
|
+
'inputSchema': _obj({'id': {'type': 'string', 'description': '文档 id(原样透传)'}}, ['id'])},
|
|
104
|
+
{'name': f'list_{name}', 'description': d('列表查询:q 为自模型名始的 GQL 余部(与 REST ?q= 同源),空则按 schema 投影'),
|
|
105
|
+
'inputSchema': _obj({'q': {'type': 'string', 'description': 'GQL 余部,如 "($condition: @c0) -> { name }"'},
|
|
106
|
+
'params': {'type': 'object', 'description': 'GQL params(键 = 去掉 @ 的引用名)'}}, [])},
|
|
107
|
+
{'name': f'create_{name}', 'description': d('插入文档'),
|
|
108
|
+
'inputSchema': _obj({'body': {'type': 'object', 'description': '文档对象'}}, ['body'])},
|
|
109
|
+
{'name': f'update_{name}', 'description': d('按 id 定位后部分更新'),
|
|
110
|
+
'inputSchema': _obj({'id': {'type': 'string'},
|
|
111
|
+
'set': {'type': 'object', 'description': '待更新字段集'}}, ['id', 'set'])},
|
|
112
|
+
{'name': f'delete_{name}', 'description': d('按 id 定位删除'),
|
|
113
|
+
'inputSchema': _obj({'id': {'type': 'string'}}, ['id'])},
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
QUERY_TOOL = {
|
|
118
|
+
'name': 'query',
|
|
119
|
+
'description': ('全局 GQL 直查(只读沙箱:强制 text2query 档,core 硬限 1000 行 / 深度 3,'
|
|
120
|
+
'禁 route_override)。gql 为自模型名始的完整 GQL 查询串,条件值一律参数化(@key 引用,值放 params)'),
|
|
121
|
+
'inputSchema': _obj({'gql': {'type': 'string'}, 'params': {'type': 'object'}}, ['gql']),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
ASK_TOOL = {
|
|
125
|
+
'name': 'ask',
|
|
126
|
+
'description': ('AI 问数(只读):自然语言 → 宿主 ask() 编排(LLM 翻译 → text2query 档校验执行 → '
|
|
127
|
+
'失败结构化回喂重试)。成功返回 {data, attempts, events};失败显式报错(不降级、不返回空结果)'),
|
|
128
|
+
'inputSchema': _obj({'question': {'type': 'string', 'description': '自然语言问题'}}, ['question']),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_surface(store: Any, opts: dict):
|
|
133
|
+
"""收集工具面(归档过滤 + x-mcp 注记 + ask 启用守卫)。返回 (tools, handlers)。"""
|
|
134
|
+
meta = _meta_provider(store, opts)
|
|
135
|
+
names = opts.get('resources') or filter_archived(list(meta.list()))
|
|
136
|
+
tools: list[dict] = []
|
|
137
|
+
handlers: dict[str, Any] = {}
|
|
138
|
+
|
|
139
|
+
for name in names:
|
|
140
|
+
defn = meta.get(name) if hasattr(meta, 'get') else None
|
|
141
|
+
xm = (defn or {}).get('x-mcp') or {}
|
|
142
|
+
if xm.get('hidden'):
|
|
143
|
+
continue # spec/01 注记:模型级 hidden
|
|
144
|
+
proj = schema_projection(defn)
|
|
145
|
+
id_field = id_field_of(defn)
|
|
146
|
+
description = defn.get('description') if defn else None
|
|
147
|
+
for tool in model_tools(name, defn, description):
|
|
148
|
+
if tool['name'] in handlers:
|
|
149
|
+
raise RuntimeError(f'ERR_NAME_CONFLICT:工具名 "{tool["name"]}" 冲突(spec/01)')
|
|
150
|
+
tools.append(tool)
|
|
151
|
+
handlers[tool['name']] = _model_handler_curry(store, tool['name'], name, proj, id_field)
|
|
152
|
+
|
|
153
|
+
# spec/02:query 工具强制 text2query 档 —— 档位上下文取用顺序见模块头
|
|
154
|
+
tools.append(dict(QUERY_TOOL))
|
|
155
|
+
handlers['query'] = _query_handler(store, opts)
|
|
156
|
+
|
|
157
|
+
# spec/01:ask 仅 opts.llm 提供时注册;配 llm 缺 ctx 装配期抛错(fail-secure)
|
|
158
|
+
if opts.get('llm') is not None:
|
|
159
|
+
if opts.get('ctx') is None:
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
'store-mcp: opts["llm"] 已提供但缺 opts["ctx"] —— ask 工具需要服务端用户上下文'
|
|
162
|
+
'(如 {"userId": ..., "roles": [...]});ctx 属受信参数,不进任何工具入参(D5)')
|
|
163
|
+
tools.append(dict(ASK_TOOL))
|
|
164
|
+
handlers['ask'] = _ask_handler(opts)
|
|
165
|
+
|
|
166
|
+
return tools, handlers
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── spec/02:映射表 handler ──
|
|
170
|
+
|
|
171
|
+
async def _model_handler(store, tool_name, name, proj, id_field, args):
|
|
172
|
+
verb = tool_name[:tool_name.index('_')]
|
|
173
|
+
if verb == 'get':
|
|
174
|
+
row = await store.query_one(f'{name}($condition: @c0){proj}', {'c0': {id_field: args['id']}}, None)
|
|
175
|
+
if row is None:
|
|
176
|
+
raise SkinError('notFound', f'{name} {args["id"]} not found')
|
|
177
|
+
return row
|
|
178
|
+
if verb == 'list':
|
|
179
|
+
q = args.get('q')
|
|
180
|
+
return await store.query(f'{name}{q}' if q else f'{name}{proj}', args.get('params'), None)
|
|
181
|
+
if verb == 'create':
|
|
182
|
+
_assert_object(args.get('body'), 'body')
|
|
183
|
+
return await store.insert(name, args['body'])
|
|
184
|
+
if verb == 'update':
|
|
185
|
+
_assert_object(args.get('set'), 'set')
|
|
186
|
+
return await store.update(name, {id_field: args['id']}, args['set'])
|
|
187
|
+
if verb == 'delete':
|
|
188
|
+
return await store.remove(name, {id_field: args['id']})
|
|
189
|
+
raise SkinError('planError', f'未知工具动词: {verb}')
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _model_handler_curry(store, tool_name, name, proj, id_field):
|
|
193
|
+
async def handler(args):
|
|
194
|
+
return await _model_handler(store, tool_name, name, proj, id_field, args)
|
|
195
|
+
return handler
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _query_handler(store, opts):
|
|
199
|
+
async def handler(args):
|
|
200
|
+
gql = args.get('gql')
|
|
201
|
+
if not isinstance(gql, str) or not gql.strip():
|
|
202
|
+
raise SkinError('invalidParam', 'gql 必须为非空字符串(自模型名始,spec/02)')
|
|
203
|
+
t2q = opts.get('text2query')
|
|
204
|
+
|
|
205
|
+
async def run():
|
|
206
|
+
# routeOverride 恒 None(D5/CWE-639)
|
|
207
|
+
return await store.query(gql, args.get('params'), None)
|
|
208
|
+
|
|
209
|
+
# 形态契约(spec/00):与宿主逐字同形的 sync contextmanager 工厂——
|
|
210
|
+
# py_store.schema.text2query 即 `@contextmanager def text2query(): ...`
|
|
211
|
+
# (实为 `with t2q(): await run()`;回调/awaitable 形态一律不收,守卫按 callable 放行后
|
|
212
|
+
# 在此处以 with 进入——非 contextmanager 形态会在此 TypeError 显式暴露,禁静默降级)
|
|
213
|
+
if callable(t2q):
|
|
214
|
+
with t2q():
|
|
215
|
+
return await run()
|
|
216
|
+
return await run() # 测试注入路径之外不可达:装配期已守卫
|
|
217
|
+
return handler
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _ask_handler(opts):
|
|
221
|
+
async def handler(args):
|
|
222
|
+
question = args.get('question')
|
|
223
|
+
if not isinstance(question, str) or not question.strip():
|
|
224
|
+
raise SkinError('invalidParam', 'question 必须为非空自然语言字符串')
|
|
225
|
+
# 护栏零新增:档位/只读/回喂/硬限全在宿主 ask();AskResult(data, attempts, events) 全量返回
|
|
226
|
+
r = await opts['host_ask'](question)
|
|
227
|
+
return {'data': r.data, 'attempts': r.attempts, 'events': r.events}
|
|
228
|
+
return handler
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ── 双出口 ──
|
|
232
|
+
|
|
233
|
+
def export_tools(store: Any, opts: dict | None = None) -> list[dict]:
|
|
234
|
+
"""纯构建工具清单(零 SDK 依赖,永远可用;对齐 store-grpc export_proto 先例)。"""
|
|
235
|
+
return build_surface(store, opts or {})[0]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def create_server(store: Any, opts: dict | None = None):
|
|
239
|
+
"""装配 MCP Server(全部守卫在此,不绑传输——测试经 memory 流直连)。"""
|
|
240
|
+
opts = dict(opts or {})
|
|
241
|
+
try:
|
|
242
|
+
import mcp.types as types
|
|
243
|
+
from mcp.server.lowlevel import Server
|
|
244
|
+
except ImportError as e:
|
|
245
|
+
raise RuntimeError(
|
|
246
|
+
f'store-mcp-py 缺承载依赖 mcp({e})。安装:pip install "mcp>=2.2,<3"') from e
|
|
247
|
+
|
|
248
|
+
# spec/02:query 工具强制 text2query 档 —— opts["text2query"] 优先,缺省从宿主取,两者皆无 ⇒ 拒绝装配
|
|
249
|
+
if not callable(opts.get('text2query')):
|
|
250
|
+
try:
|
|
251
|
+
from py_store.schema import text2query
|
|
252
|
+
opts['text2query'] = text2query
|
|
253
|
+
except ImportError:
|
|
254
|
+
pass
|
|
255
|
+
if not callable(opts.get('text2query')):
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
'store-mcp: query 工具强制 text2query 档(spec/02),但未取得档位上下文。'
|
|
258
|
+
'安装 py-store 或传 opts["text2query"](async 上下文管理器/装饰器,py_store.schema 导出)')
|
|
259
|
+
|
|
260
|
+
if opts.get('llm') is not None:
|
|
261
|
+
if opts.get('ctx') is None:
|
|
262
|
+
raise RuntimeError('store-mcp: opts["llm"] 已提供但缺 opts["ctx"](fail-secure,spec/01)')
|
|
263
|
+
if not callable(opts.get('host_ask')):
|
|
264
|
+
try:
|
|
265
|
+
from py_store import ask as host_ask
|
|
266
|
+
opts['host_ask'] = lambda q: host_ask(q, llm=opts['llm'], ctx=opts['ctx'])
|
|
267
|
+
except ImportError as e:
|
|
268
|
+
raise RuntimeError(f'store-mcp: ask 工具需要宿主 py_store({e})。安装:pip install py-store') from e
|
|
269
|
+
|
|
270
|
+
tools, handlers = build_surface(store, opts)
|
|
271
|
+
app = Server(opts.get('name') or 'store-mcp')
|
|
272
|
+
|
|
273
|
+
async def list_tools(ctx, params):
|
|
274
|
+
return types.ListToolsResult(tools=[
|
|
275
|
+
types.Tool(name=t['name'], description=t['description'], inputSchema=t['inputSchema'])
|
|
276
|
+
for t in tools])
|
|
277
|
+
|
|
278
|
+
async def call_tool(ctx, params):
|
|
279
|
+
name = params.name
|
|
280
|
+
args = params.arguments or {}
|
|
281
|
+
try:
|
|
282
|
+
handler = handlers.get(name)
|
|
283
|
+
if handler is None:
|
|
284
|
+
raise SkinError('invalidParam', f'未知工具: {name}')
|
|
285
|
+
provider = opts.get('context_provider')
|
|
286
|
+
if provider is not None:
|
|
287
|
+
ctx = await provider(getattr(params, 'meta', None))
|
|
288
|
+
await store.set_context(ctx or None) # 显式清除语义,spec/02
|
|
289
|
+
data = await handler(args)
|
|
290
|
+
# 成功响应不含任何 error 键(spec/03 正向断言)
|
|
291
|
+
return types.CallToolResult(content=[types.TextContent(
|
|
292
|
+
type='text', text=json.dumps(data if data is not None else None, ensure_ascii=False))])
|
|
293
|
+
except Exception as e: # noqa: BLE001 —— 一切失败显式 isError(spec/03,禁静默失守)
|
|
294
|
+
err = error_of(e)
|
|
295
|
+
return types.CallToolResult(isError=True, content=[types.TextContent(
|
|
296
|
+
type='text', text=json.dumps(err, ensure_ascii=False))])
|
|
297
|
+
|
|
298
|
+
app.add_request_handler('tools/list', types.PaginatedRequestParams, list_tools)
|
|
299
|
+
app.add_request_handler('tools/call', types.CallToolRequestParams, call_tool)
|
|
300
|
+
return {'server': app, 'tools': tools}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def run_stdio(store: Any, opts: dict | None = None) -> None:
|
|
304
|
+
"""stdio 阻塞运行:create_server + stdio_server(对齐 node createStdioServer)。"""
|
|
305
|
+
import anyio
|
|
306
|
+
from mcp.server.stdio import stdio_server
|
|
307
|
+
|
|
308
|
+
app = create_server(store, opts)['server']
|
|
309
|
+
|
|
310
|
+
async def _main():
|
|
311
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
312
|
+
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
313
|
+
|
|
314
|
+
anyio.run(_main)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-mcp-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP (Model Context Protocol) adapter that auto-generates tool surfaces for py-store schemas (schema-driven CRUD + sandboxed GQL query + optional ask), stdio transport
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,storepy,mcp,model-context-protocol,crud,gql,ai
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: mcp<3,>=2.2
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
store_mcp/__init__.py
|
|
3
|
+
store_mcp_py.egg-info/PKG-INFO
|
|
4
|
+
store_mcp_py.egg-info/SOURCES.txt
|
|
5
|
+
store_mcp_py.egg-info/dependency_links.txt
|
|
6
|
+
store_mcp_py.egg-info/requires.txt
|
|
7
|
+
store_mcp_py.egg-info/top_level.txt
|
|
8
|
+
tests/test_conformance.py
|
|
9
|
+
tests/test_mcp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_mcp
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""conformance 一致性测试 —— 加载 ../../conformance/cases.json(双端唯一断言事实源),
|
|
2
|
+
断言值逐字取自 JSON,本文件零内联期望(改断言先改 cases.json)。"""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import anyio
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from mock_store import mock_store
|
|
12
|
+
from store_mcp import create_server, filter_archived
|
|
13
|
+
from test_mcp import make_session
|
|
14
|
+
|
|
15
|
+
_CASES = Path(__file__).resolve().parents[2] / 'conformance' / 'cases.json'
|
|
16
|
+
cases = json.loads(_CASES.read_text(encoding='utf-8'))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse(result):
|
|
20
|
+
assert result.content, '必须有 content'
|
|
21
|
+
return json.loads(result.content[0].text)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.anyio
|
|
25
|
+
async def test_surface_tool_names():
|
|
26
|
+
store, _ = mock_store()
|
|
27
|
+
async with await make_session(store) as s:
|
|
28
|
+
tools = (await s.list_tools()).tools
|
|
29
|
+
assert sorted(t.name for t in tools) == cases['surface']['expected_tool_names']
|
|
30
|
+
assert len(tools) == cases['surface']['expected_count']
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.mark.anyio
|
|
34
|
+
async def test_get_projection_and_miss():
|
|
35
|
+
store, calls = mock_store()
|
|
36
|
+
async with await make_session(store) as s:
|
|
37
|
+
r = await s.call_tool(cases['get']['tool'], cases['get']['args'])
|
|
38
|
+
assert not r.is_error
|
|
39
|
+
assert parse(r) == cases['get']['hit_data']
|
|
40
|
+
op, q, params = calls[0]
|
|
41
|
+
assert op == cases['get']['expected_op']['py']
|
|
42
|
+
assert q == cases['get']['expected_q']
|
|
43
|
+
assert params == cases['get']['expected_params']
|
|
44
|
+
|
|
45
|
+
miss = await s.call_tool(cases['get']['tool'], cases['get']['miss_args'])
|
|
46
|
+
assert miss.is_error
|
|
47
|
+
assert parse(miss)['code'] == cases['get']['miss_code']
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.mark.anyio
|
|
51
|
+
async def test_list_q_handling():
|
|
52
|
+
store, calls = mock_store()
|
|
53
|
+
async with await make_session(store) as s:
|
|
54
|
+
await s.call_tool(cases['list']['tool'], {})
|
|
55
|
+
await s.call_tool(cases['list']['tool'], cases['list']['with_q_args'])
|
|
56
|
+
assert calls[0][0] == cases['list']['empty_q_op']['py']
|
|
57
|
+
assert calls[0][1] == cases['list']['empty_q_expected']
|
|
58
|
+
assert calls[1][1] == cases['list']['with_q_expected_q']
|
|
59
|
+
assert calls[1][2] == cases['list']['with_q_expected_params']
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@pytest.mark.anyio
|
|
63
|
+
async def test_create_body_validation():
|
|
64
|
+
store, calls = mock_store()
|
|
65
|
+
async with await make_session(store) as s:
|
|
66
|
+
await s.call_tool(cases['create']['tool'], cases['create']['args'])
|
|
67
|
+
op, name, body = calls[0]
|
|
68
|
+
assert op == cases['create']['expected_op']['py']
|
|
69
|
+
assert name == cases['create']['expected_q']
|
|
70
|
+
assert body == cases['create']['expected_params']
|
|
71
|
+
bad = await s.call_tool(cases['create']['tool'], cases['create']['bad_args'])
|
|
72
|
+
assert parse(bad)['code'] == cases['create']['bad_code']
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.anyio
|
|
76
|
+
async def test_update_and_remove_id_field():
|
|
77
|
+
store, calls = mock_store()
|
|
78
|
+
async with await make_session(store) as s:
|
|
79
|
+
await s.call_tool(cases['update']['tool'], cases['update']['args'])
|
|
80
|
+
await s.call_tool(cases['remove']['tool'], cases['remove']['args'])
|
|
81
|
+
assert calls[0][0] == cases['update']['expected_op']['py']
|
|
82
|
+
assert calls[0][1] == cases['update']['expected_q']
|
|
83
|
+
assert calls[0][2] == cases['update']['expected_params']
|
|
84
|
+
assert calls[0][3] == cases['update']['expected_set']
|
|
85
|
+
assert calls[1][0] == cases['remove']['expected_op']['py']
|
|
86
|
+
assert calls[1][2] == cases['remove']['expected_params']
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.anyio
|
|
90
|
+
async def test_query_profile_passthrough():
|
|
91
|
+
store, calls = mock_store()
|
|
92
|
+
entered = []
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def t2q():
|
|
96
|
+
entered.append(True)
|
|
97
|
+
yield
|
|
98
|
+
|
|
99
|
+
async with await make_session(store, text2query=t2q) as s:
|
|
100
|
+
r = await s.call_tool(cases['query']['tool'], cases['query']['args'])
|
|
101
|
+
assert not r.is_error
|
|
102
|
+
assert entered, 'query 必须在 text2query 档内执行'
|
|
103
|
+
op, q, params = calls[0]
|
|
104
|
+
assert op == cases['query']['expected_op']['py']
|
|
105
|
+
assert q == cases['query']['expected_q']
|
|
106
|
+
assert params == cases['query']['expected_params']
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.anyio
|
|
110
|
+
async def test_error_mappings():
|
|
111
|
+
store, _ = mock_store()
|
|
112
|
+
|
|
113
|
+
async def boom(q, params=None, route_override=None):
|
|
114
|
+
raise ValueError(cases['errors']['stable_prefix'] + '深度超限')
|
|
115
|
+
|
|
116
|
+
store.query = boom
|
|
117
|
+
|
|
118
|
+
async with await make_session(store) as s:
|
|
119
|
+
r = await s.call_tool(cases['query']['tool'], cases['query']['args'])
|
|
120
|
+
assert r.is_error
|
|
121
|
+
err = parse(r)
|
|
122
|
+
assert err['code'] == cases['errors']['plan_error_code']
|
|
123
|
+
assert cases['errors']['stable_prefix'] in err['message']
|
|
124
|
+
|
|
125
|
+
s2, _ = mock_store()
|
|
126
|
+
|
|
127
|
+
class PermissionError(Exception): # noqa: A001 —— 模拟宿主同名异常(跨包按名判定)
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
async def denied(q, params=None, route_override=None):
|
|
131
|
+
raise PermissionError('denied')
|
|
132
|
+
|
|
133
|
+
s2.query_one = denied
|
|
134
|
+
async with await make_session(s2) as s:
|
|
135
|
+
r = await s.call_tool(cases['get']['tool'], cases['get']['args'])
|
|
136
|
+
assert parse(r)['code'] == cases['errors']['permission_code']
|
|
137
|
+
|
|
138
|
+
s3, _ = mock_store()
|
|
139
|
+
|
|
140
|
+
class ProfileViolation(Exception):
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
async def blocked(q, params=None, route_override=None):
|
|
144
|
+
raise ProfileViolation('blocked')
|
|
145
|
+
|
|
146
|
+
s3.query_one = blocked
|
|
147
|
+
async with await make_session(s3) as s:
|
|
148
|
+
r = await s.call_tool(cases['get']['tool'], cases['get']['args'])
|
|
149
|
+
assert parse(r)['code'] == cases['errors']['profile_code']
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_archive_filter_pure():
|
|
153
|
+
assert filter_archived(cases['archive_filter']['input']) == cases['archive_filter']['expected']
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""store-mcp-py 测试 — 全部跑在 mock store + mcp SDK memory 流上,
|
|
2
|
+
零真实库、零宿主包依赖(语义一致性以 ../spec 为准;conformance/cases.json 见步骤 8)。
|
|
3
|
+
|
|
4
|
+
mcp 2.x 握手只协商 legacy 版本(实测 2025-11-25):tools/call 裸调用即可;
|
|
5
|
+
add_request_handler 的 params_type 必须是 params 模型(CallToolRequestParams /
|
|
6
|
+
PaginatedRequestParams),传整 Request 模型会拿 params dict 去验 id/jsonrpc 而必拒。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
|
|
12
|
+
import anyio
|
|
13
|
+
import pytest
|
|
14
|
+
from mcp import ClientSession
|
|
15
|
+
from mcp.shared.memory import create_client_server_memory_streams
|
|
16
|
+
|
|
17
|
+
from mock_store import mock_store
|
|
18
|
+
from store_mcp import (create_server, error_of, export_tools, filter_archived)
|
|
19
|
+
|
|
20
|
+
USER_PROJ = ' { name, full }'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def make_session(store, **opts):
|
|
24
|
+
"""起 server(后台任务组)+ 已 initialize 的 ClientSession;用 async with 组合。"""
|
|
25
|
+
if 'text2query' not in opts:
|
|
26
|
+
opts['text2query'] = _passthrough_profile
|
|
27
|
+
|
|
28
|
+
app = create_server(store, opts)['server']
|
|
29
|
+
streams = create_client_server_memory_streams()
|
|
30
|
+
|
|
31
|
+
class Ctx:
|
|
32
|
+
async def __aenter__(self):
|
|
33
|
+
self._cm = streams.__aenter__()
|
|
34
|
+
cs, ss = await self._cm
|
|
35
|
+
self._tg = anyio.create_task_group()
|
|
36
|
+
await self._tg.__aenter__()
|
|
37
|
+
self._tg.start_soon(app.run, ss[0], ss[1], app.create_initialization_options())
|
|
38
|
+
self._session = ClientSession(cs[0], cs[1])
|
|
39
|
+
await self._session.__aenter__()
|
|
40
|
+
await self._session.initialize()
|
|
41
|
+
return self._session
|
|
42
|
+
|
|
43
|
+
async def __aexit__(self, *exc):
|
|
44
|
+
await self._session.__aexit__(*exc)
|
|
45
|
+
self._tg.cancel_scope.cancel()
|
|
46
|
+
await self._tg.__aexit__(*exc)
|
|
47
|
+
await streams.__aexit__(*exc)
|
|
48
|
+
|
|
49
|
+
return Ctx()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse(result):
|
|
53
|
+
assert result.content, '必须有 content'
|
|
54
|
+
return json.loads(result.content[0].text)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── 1. 工具清单:归档过滤 + x-mcp hidden + query ──
|
|
58
|
+
|
|
59
|
+
@pytest.mark.anyio
|
|
60
|
+
async def test_tools_list():
|
|
61
|
+
store, _ = mock_store()
|
|
62
|
+
async with await make_session(store) as s:
|
|
63
|
+
tools = (await s.list_tools()).tools
|
|
64
|
+
names = sorted(t.name for t in tools)
|
|
65
|
+
assert names == sorted([
|
|
66
|
+
'create_Order', 'create_User', 'delete_Order', 'delete_User',
|
|
67
|
+
'get_Order', 'get_User', 'list_Order', 'list_User',
|
|
68
|
+
'query', 'update_Order', 'update_User'])
|
|
69
|
+
assert len(tools) == 11
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.anyio
|
|
73
|
+
async def test_ask_tool_registered_only_with_llm():
|
|
74
|
+
store, _ = mock_store()
|
|
75
|
+
|
|
76
|
+
async def host_ask(question):
|
|
77
|
+
class R:
|
|
78
|
+
data = [1]
|
|
79
|
+
attempts = [{'rows': 1}]
|
|
80
|
+
events = []
|
|
81
|
+
return R()
|
|
82
|
+
|
|
83
|
+
async with await make_session(store, llm='gpt', ctx={'userId': 'u1', 'roles': ['admin']},
|
|
84
|
+
host_ask=host_ask) as s:
|
|
85
|
+
tools = (await s.list_tools()).tools
|
|
86
|
+
assert any(t.name == 'ask' for t in tools)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.anyio
|
|
90
|
+
async def test_ask_guard_missing_ctx():
|
|
91
|
+
store, _ = mock_store()
|
|
92
|
+
with pytest.raises(RuntimeError, match='缺 opts\\["ctx"\\]'):
|
|
93
|
+
create_server(store, {'llm': 'gpt', 'text2query': lambda fn: fn()})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ── 2. get_X ──
|
|
97
|
+
|
|
98
|
+
@pytest.mark.anyio
|
|
99
|
+
async def test_get_user_projection_and_hit():
|
|
100
|
+
store, calls = mock_store()
|
|
101
|
+
async with await make_session(store) as s:
|
|
102
|
+
r = await s.call_tool('get_User', {'id': 'u1'})
|
|
103
|
+
assert not r.is_error
|
|
104
|
+
assert parse(r) == {'_id': 'u1', 'name': 'Alice'}
|
|
105
|
+
op, q, params = calls[0]
|
|
106
|
+
assert op == 'query_one'
|
|
107
|
+
assert q == f'User($condition: @c0){USER_PROJ}'
|
|
108
|
+
assert params == {'c0': {'_id': 'u1'}}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.anyio
|
|
112
|
+
async def test_get_user_not_found():
|
|
113
|
+
store, _ = mock_store()
|
|
114
|
+
async with await make_session(store) as s:
|
|
115
|
+
r = await s.call_tool('get_User', {'id': 'nope'})
|
|
116
|
+
assert r.is_error
|
|
117
|
+
assert parse(r)['code'] == 'notFound'
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── 3. list_X ──
|
|
121
|
+
|
|
122
|
+
@pytest.mark.anyio
|
|
123
|
+
async def test_list_user_q_handling():
|
|
124
|
+
store, calls = mock_store()
|
|
125
|
+
async with await make_session(store) as s:
|
|
126
|
+
await s.call_tool('list_User', {})
|
|
127
|
+
await s.call_tool('list_User', {'q': '($condition: @c1) -> { name }', 'params': {'c1': {'name': 'A'}}},
|
|
128
|
+
)
|
|
129
|
+
assert calls[0][1] == f'User{USER_PROJ}'
|
|
130
|
+
assert calls[1][1] == 'User($condition: @c1) -> { name }'
|
|
131
|
+
assert calls[1][2] == {'c1': {'name': 'A'}}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── 4. create / update / delete ──
|
|
135
|
+
|
|
136
|
+
@pytest.mark.anyio
|
|
137
|
+
async def test_create_user_body_validation():
|
|
138
|
+
store, calls = mock_store()
|
|
139
|
+
async with await make_session(store) as s:
|
|
140
|
+
r = await s.call_tool('create_User', {'body': {'name': 'Bob'}})
|
|
141
|
+
assert parse(r)['_id'] == 'n1'
|
|
142
|
+
assert calls[0] == ['insert', 'User', {'name': 'Bob'}]
|
|
143
|
+
|
|
144
|
+
bad = await s.call_tool('create_User', {'body': [1, 2]})
|
|
145
|
+
assert bad.is_error
|
|
146
|
+
assert parse(bad)['code'] == 'invalidBody'
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.anyio
|
|
150
|
+
async def test_update_delete_user_id_field():
|
|
151
|
+
store, calls = mock_store()
|
|
152
|
+
async with await make_session(store) as s:
|
|
153
|
+
await s.call_tool('update_User', {'id': 'u1', 'set': {'name': 'A2'}})
|
|
154
|
+
await s.call_tool('delete_User', {'id': 'u1'})
|
|
155
|
+
assert calls[0] == ['update', 'User', {'_id': 'u1'}, {'name': 'A2'}]
|
|
156
|
+
assert calls[1] == ['remove', 'User', {'_id': 'u1'}]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── 5. query 工具:强制 text2query 档 ──
|
|
160
|
+
|
|
161
|
+
@pytest.mark.anyio
|
|
162
|
+
async def test_query_runs_in_text2query_profile():
|
|
163
|
+
store, calls = mock_store()
|
|
164
|
+
entered = []
|
|
165
|
+
|
|
166
|
+
@contextmanager
|
|
167
|
+
def t2q():
|
|
168
|
+
entered.append(True)
|
|
169
|
+
yield
|
|
170
|
+
|
|
171
|
+
async with await make_session(store, text2query=t2q) as s:
|
|
172
|
+
r = await s.call_tool('query', {'gql': 'User -> { name }', 'params': {}})
|
|
173
|
+
assert not r.is_error
|
|
174
|
+
assert entered, 'query 必须在 text2query 档内执行'
|
|
175
|
+
assert calls[0] == ['query', 'User -> { name }', {}]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@pytest.mark.anyio
|
|
179
|
+
async def test_text2query_defaults_to_host():
|
|
180
|
+
"""缺省路径:本机 py-store 已装 ⇒ 从宿主 schema 模块取得 text2query,装配成功。
|
|
181
|
+
|
|
182
|
+
(与 node 侧「缺失 ⇒ 装配期抛错」用例对称互补:py 环境宿主常驻,缺失场景
|
|
183
|
+
由 create_server 守卫段的 RuntimeError 路径覆盖——opts 与宿主皆无时抛错。)
|
|
184
|
+
"""
|
|
185
|
+
store, _ = mock_store()
|
|
186
|
+
r = create_server(store, {})
|
|
187
|
+
assert any(t['name'] == 'query' for t in r['tools'])
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── 6. 错误透传 ──
|
|
191
|
+
|
|
192
|
+
@pytest.mark.anyio
|
|
193
|
+
async def test_err_prefix_passthrough():
|
|
194
|
+
store, calls = mock_store()
|
|
195
|
+
|
|
196
|
+
async def boom(q, params=None, route_override=None):
|
|
197
|
+
raise ValueError('ERR_TEXT2QUERY:深度超限')
|
|
198
|
+
|
|
199
|
+
store.query = boom
|
|
200
|
+
|
|
201
|
+
async with await make_session(store) as s:
|
|
202
|
+
r = await s.call_tool('query', {'gql': 'User'})
|
|
203
|
+
assert r.is_error
|
|
204
|
+
err = parse(r)
|
|
205
|
+
assert err['code'] == 'planError'
|
|
206
|
+
assert 'ERR_TEXT2QUERY:' in err['message'], '前缀必须原样保留'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.anyio
|
|
210
|
+
async def test_permission_error_mapping_by_name():
|
|
211
|
+
store, calls = mock_store()
|
|
212
|
+
|
|
213
|
+
class PermissionError(Exception): # noqa: A001 —— 模拟宿主同名异常(跨包按名判定)
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
async def denied(q, params=None, route_override=None):
|
|
217
|
+
raise PermissionError('denied')
|
|
218
|
+
|
|
219
|
+
store.query_one = denied
|
|
220
|
+
async with await make_session(store) as s:
|
|
221
|
+
r = await s.call_tool('get_User', {'id': 'u1'})
|
|
222
|
+
assert parse(r)['code'] == 'permissionDenied'
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── 7. context_provider ──
|
|
226
|
+
|
|
227
|
+
@pytest.mark.anyio
|
|
228
|
+
async def test_context_provider_explicit_clear():
|
|
229
|
+
store, calls = mock_store()
|
|
230
|
+
async def provider(_meta):
|
|
231
|
+
return None
|
|
232
|
+
async with await make_session(store, context_provider=provider) as s:
|
|
233
|
+
await s.call_tool('get_User', {'id': 'u1'})
|
|
234
|
+
assert calls[0] == ['set_context', None]
|
|
235
|
+
assert calls[1][0] == 'query_one'
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── 8. export_tools:纯构建零 SDK 侧效应 ──
|
|
239
|
+
|
|
240
|
+
@pytest.mark.anyio
|
|
241
|
+
async def test_export_tools_matches_surface():
|
|
242
|
+
store, _ = mock_store()
|
|
243
|
+
async def host_ask(question):
|
|
244
|
+
class R:
|
|
245
|
+
data = []
|
|
246
|
+
attempts = []
|
|
247
|
+
events = []
|
|
248
|
+
return R()
|
|
249
|
+
tools = export_tools(store, {'llm': 'gpt', 'ctx': {'userId': 'u1'}, 'host_ask': host_ask})
|
|
250
|
+
assert sum(1 for t in tools if t['name'] == 'ask') == 1
|
|
251
|
+
assert len(tools) == 12 # 2 模型 × 5 + query + ask
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ── 9. 纯函数:filter_archived / error_of ──
|
|
255
|
+
|
|
256
|
+
def test_filter_archived():
|
|
257
|
+
assert filter_archived(['User', 'OrderDeleted', 'Order', 'StandaloneDeleted']) == \
|
|
258
|
+
['User', 'Order', 'StandaloneDeleted']
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_error_of_known_names():
|
|
262
|
+
class ProfileViolation(Exception):
|
|
263
|
+
pass
|
|
264
|
+
assert error_of(ProfileViolation('x'))['code'] == 'profileBlocked'
|
|
265
|
+
assert error_of(ValueError('boom')) == {'code': 'planError', 'message': 'boom'}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@contextmanager
|
|
269
|
+
def _passthrough_profile():
|
|
270
|
+
"""mock 档位上下文——与 py_store.schema.text2query 逐字同形(@contextmanager 工厂)。"""
|
|
271
|
+
yield
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@pytest.mark.anyio
|
|
275
|
+
async def test_query_host_profile_actually_enters():
|
|
276
|
+
"""宿主兜底回归:opts 不注入时从 py_store.schema 取得真实 text2query
|
|
277
|
+
(@contextmanager 工厂),query 执行期档位必须**真实进入**——mock store 内
|
|
278
|
+
断言 core 判决面 get_profile()=='text2query'(此前 mock 走 async 回调形态,
|
|
279
|
+
掩盖了 `with` 包裹缺失的缺陷,本用例堵住该回归)。"""
|
|
280
|
+
from py_store.schema import get_profile
|
|
281
|
+
store, calls = mock_store()
|
|
282
|
+
|
|
283
|
+
async def spy_query(q, params=None, route_override=None):
|
|
284
|
+
calls.append(['query', q, params, get_profile()])
|
|
285
|
+
return [{'profile': get_profile()}]
|
|
286
|
+
|
|
287
|
+
store.query = spy_query
|
|
288
|
+
async with await make_session(store, text2query=False) as s: # False ⇒ 走宿主兜底 import
|
|
289
|
+
r = await s.call_tool('query', {'gql': 'User -> { name }'}, )
|
|
290
|
+
assert not r.is_error, parse(r)
|
|
291
|
+
assert calls[0][3] == 'text2query', 'query 必须真实进入 text2query 档'
|
|
292
|
+
assert parse(r) == [{'profile': 'text2query'}]
|