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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ mcp<3,>=2.2
2
+
3
+ [dev]
4
+ pytest>=8
@@ -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'}]