muscles-jsonrpc 1.0.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,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-jsonrpc
3
+ Version: 1.0.0
4
+ Summary: JSON-RPC adapter for Muscles actions
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles<2.0.0,>=1.0.0rc1
9
+
10
+ # Muscles JSON-RPC
11
+
12
+ JSON-RPC protocol projection for Muscles actions.
13
+
14
+ This package makes Muscles action-first applications callable over JSON-RPC
15
+ while preserving the same schemas, rules, dispatcher, and context used by other
16
+ projections.
17
+
18
+ ## Related Repositories
19
+
20
+ - [`muscles`](https://github.com/butkoden/muscles) - core action contracts, dispatcher and inspect contract.
21
+ - [`muscles-cli`](https://github.com/butkoden/muscles-cli) - CLI action inspection and execution over the same contracts.
22
+ - [`muscles-mcp`](https://github.com/butkoden/muscles-mcp) - MCP projection over the same action model.
23
+ - [`muscles-sse`](https://github.com/butkoden/muscles-sse) - streaming projection for action output.
24
+ - [`muscles-benchmarks`](https://github.com/butkoden/muscles-benchmarks) - protocol projection regression checks.
25
+
26
+ ## Concept Guardrails
27
+
28
+ - JSON-RPC is a protocol projection, not a new framework layer.
29
+ - Methods must be discovered from `inspect_application(app)`.
30
+ - Validation and error normalization must reuse Muscles core schema/rules/errors.
31
+ - Auth/rules must run through `ActionDispatcher`.
32
+ - The same use case must be callable from CLI, HTTP, MCP, and JSON-RPC without
33
+ duplicating implementation.
34
+ - JSON-RPC state is scoped to the application instance; no mutable module-level
35
+ method registry is the source of truth.
36
+
37
+ ## Initial Goal
38
+
39
+ Implement a JSON-RPC 2.0 projection around Muscles actions with typed
40
+ request/response contracts, deterministic error mapping, notifications, and
41
+ batch dispatch.
42
+
43
+ ## Current Stage (Issue #1)
44
+
45
+ Implemented JSON-RPC 2.0 projection:
46
+
47
+ - request validation (`jsonrpc`, `method`, `params`);
48
+ - method discovery from `inspect_application(app)`;
49
+ - dispatch through `ActionDispatcher(..., transport="jsonrpc")`;
50
+ - notification calls;
51
+ - batch calls;
52
+ - deterministic error mapping:
53
+ - `-32600` invalid request;
54
+ - `-32601` method not found;
55
+ - `-32602` invalid params;
56
+ - `-32001` permission denied;
57
+ - `-32603` internal error.
58
+
59
+ ### Run tests
60
+
61
+ ```bash
62
+ python -m pytest -q
63
+ ```
64
+
65
+ When testing against local core changes:
66
+
67
+ ```bash
68
+ PYTHONPATH=../muscles/src:src python -m pytest -q
69
+ ```
70
+
71
+ User docs:
72
+
73
+ - English: [docs/jsonrpc-projection.en.md](docs/jsonrpc-projection.en.md)
74
+ - Русский: [docs/jsonrpc-projection.ru.md](docs/jsonrpc-projection.ru.md)
@@ -0,0 +1,65 @@
1
+ # Muscles JSON-RPC
2
+
3
+ JSON-RPC protocol projection for Muscles actions.
4
+
5
+ This package makes Muscles action-first applications callable over JSON-RPC
6
+ while preserving the same schemas, rules, dispatcher, and context used by other
7
+ projections.
8
+
9
+ ## Related Repositories
10
+
11
+ - [`muscles`](https://github.com/butkoden/muscles) - core action contracts, dispatcher and inspect contract.
12
+ - [`muscles-cli`](https://github.com/butkoden/muscles-cli) - CLI action inspection and execution over the same contracts.
13
+ - [`muscles-mcp`](https://github.com/butkoden/muscles-mcp) - MCP projection over the same action model.
14
+ - [`muscles-sse`](https://github.com/butkoden/muscles-sse) - streaming projection for action output.
15
+ - [`muscles-benchmarks`](https://github.com/butkoden/muscles-benchmarks) - protocol projection regression checks.
16
+
17
+ ## Concept Guardrails
18
+
19
+ - JSON-RPC is a protocol projection, not a new framework layer.
20
+ - Methods must be discovered from `inspect_application(app)`.
21
+ - Validation and error normalization must reuse Muscles core schema/rules/errors.
22
+ - Auth/rules must run through `ActionDispatcher`.
23
+ - The same use case must be callable from CLI, HTTP, MCP, and JSON-RPC without
24
+ duplicating implementation.
25
+ - JSON-RPC state is scoped to the application instance; no mutable module-level
26
+ method registry is the source of truth.
27
+
28
+ ## Initial Goal
29
+
30
+ Implement a JSON-RPC 2.0 projection around Muscles actions with typed
31
+ request/response contracts, deterministic error mapping, notifications, and
32
+ batch dispatch.
33
+
34
+ ## Current Stage (Issue #1)
35
+
36
+ Implemented JSON-RPC 2.0 projection:
37
+
38
+ - request validation (`jsonrpc`, `method`, `params`);
39
+ - method discovery from `inspect_application(app)`;
40
+ - dispatch through `ActionDispatcher(..., transport="jsonrpc")`;
41
+ - notification calls;
42
+ - batch calls;
43
+ - deterministic error mapping:
44
+ - `-32600` invalid request;
45
+ - `-32601` method not found;
46
+ - `-32602` invalid params;
47
+ - `-32001` permission denied;
48
+ - `-32603` internal error.
49
+
50
+ ### Run tests
51
+
52
+ ```bash
53
+ python -m pytest -q
54
+ ```
55
+
56
+ When testing against local core changes:
57
+
58
+ ```bash
59
+ PYTHONPATH=../muscles/src:src python -m pytest -q
60
+ ```
61
+
62
+ User docs:
63
+
64
+ - English: [docs/jsonrpc-projection.en.md](docs/jsonrpc-projection.en.md)
65
+ - Русский: [docs/jsonrpc-projection.ru.md](docs/jsonrpc-projection.ru.md)
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "muscles-jsonrpc"
7
+ version = "1.0.0"
8
+ description = "JSON-RPC adapter for Muscles actions"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = ["muscles>=1.0.0rc1,<2.0.0"]
13
+
14
+ [tool.pytest.ini_options]
15
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .adapter import JsonRpcAdapter, JsonRpcError, JsonRpcServer
2
+
3
+ __all__ = ["JsonRpcAdapter", "JsonRpcError", "JsonRpcServer"]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from typing import Any
6
+
7
+
8
+ JSONRPC_VERSION = "2.0"
9
+ DISCOVERY_METHODS = {"rpc.discover", "rpc.methods"}
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class JsonRpcError(Exception):
14
+ code: int
15
+ message: str
16
+ data: dict[str, Any] | None = None
17
+
18
+
19
+ class JsonRpcAdapter:
20
+ """Projects Muscles actions into JSON-RPC 2.0 methods."""
21
+
22
+ def __init__(self, app) -> None:
23
+ self._app = app
24
+
25
+ @classmethod
26
+ def from_application(cls, app):
27
+ return cls(app)
28
+
29
+ @property
30
+ def _contract(self) -> dict[str, Any]:
31
+ from muscles.core import inspect_application
32
+
33
+ return inspect_application(self._app)
34
+
35
+ def list_methods(self) -> list[dict[str, Any]]:
36
+ methods: list[dict[str, Any]] = []
37
+ for action in self._contract.get("actions", []):
38
+ name = action.get("name")
39
+ if not name:
40
+ continue
41
+ transports = action.get("transports") or []
42
+ if transports and "jsonrpc" not in transports:
43
+ continue
44
+ methods.append(
45
+ {
46
+ "name": name,
47
+ "description": action.get("description", ""),
48
+ "params_schema": action.get("input_schema", {"type": "object", "properties": {}}),
49
+ }
50
+ )
51
+ return methods
52
+
53
+ def handle(self, request: dict[str, Any] | list[Any]) -> dict[str, Any] | list[dict[str, Any]] | None:
54
+ if isinstance(request, list):
55
+ if not request:
56
+ return self._error(None, -32600, "Invalid Request")
57
+ responses = [response for item in request if (response := self._handle_single(item)) is not None]
58
+ return responses or None
59
+ return self._handle_single(request)
60
+
61
+ def _handle_single(self, request: Any) -> dict[str, Any] | None:
62
+ if not isinstance(request, dict):
63
+ return self._error(None, -32600, "Invalid Request")
64
+ req_id = request.get("id")
65
+ is_notification = "id" not in request
66
+ if request.get("jsonrpc") != JSONRPC_VERSION:
67
+ return self._error(req_id, -32600, "Invalid Request")
68
+ if "method" not in request or not isinstance(request.get("method"), str):
69
+ return self._error(req_id, -32600, "Invalid Request")
70
+
71
+ params = request.get("params", {})
72
+ if params is None:
73
+ params = {}
74
+ if not isinstance(params, dict):
75
+ if is_notification:
76
+ return None
77
+ return self._error(req_id, -32602, "Invalid params")
78
+ if request["method"] in DISCOVERY_METHODS:
79
+ if is_notification:
80
+ return None
81
+ return {"jsonrpc": JSONRPC_VERSION, "id": req_id, "result": self.list_methods()}
82
+
83
+ try:
84
+ from muscles.core import ActionDispatcher
85
+
86
+ result = ActionDispatcher(self._app).execute(request["method"], params, transport="jsonrpc")
87
+ if result.is_stream:
88
+ raise JsonRpcError(
89
+ -32002,
90
+ "Streaming not supported",
91
+ {
92
+ "action": request["method"],
93
+ "reason": "Use a stream-capable projection such as SSE or MCP for stream actions.",
94
+ },
95
+ )
96
+ if is_notification:
97
+ return None
98
+ return {
99
+ "jsonrpc": JSONRPC_VERSION,
100
+ "id": req_id,
101
+ "result": self._json_value(result.value),
102
+ }
103
+ except Exception as exc:
104
+ if is_notification:
105
+ return None
106
+ mapped = self._map_core_error(exc)
107
+ if mapped is not None:
108
+ return self._error(req_id, mapped.code, mapped.message, mapped.data)
109
+ return self._error(req_id, -32603, "Internal error")
110
+
111
+ @staticmethod
112
+ def _map_core_error(exc: Exception) -> JsonRpcError | None:
113
+ try:
114
+ from muscles.core import (
115
+ ActionExecutionError,
116
+ ActionNotFound,
117
+ ActionPermissionDenied,
118
+ ActionValidationError,
119
+ )
120
+ except Exception:
121
+ return None
122
+
123
+ if isinstance(exc, ActionNotFound):
124
+ return JsonRpcError(-32601, "Method not found")
125
+ if isinstance(exc, ActionValidationError):
126
+ return JsonRpcError(-32602, "Invalid params", {"reason": exc.message, "data": exc.data})
127
+ if isinstance(exc, ActionPermissionDenied):
128
+ return JsonRpcError(-32001, "Permission denied", {"reason": exc.message, "data": exc.data})
129
+ if isinstance(exc, ActionExecutionError):
130
+ return JsonRpcError(-32603, "Internal error")
131
+ if isinstance(exc, JsonRpcError):
132
+ return exc
133
+ return None
134
+
135
+ @staticmethod
136
+ def _error(req_id: Any, code: int, message: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
137
+ payload: dict[str, Any] = {"jsonrpc": JSONRPC_VERSION, "id": req_id, "error": {"code": code, "message": message}}
138
+ if data is not None:
139
+ payload["error"]["data"] = data
140
+ return payload
141
+
142
+ @staticmethod
143
+ def _json_value(value: Any) -> Any:
144
+ """Validate the JSON-RPC result without silently stringifying values."""
145
+
146
+ return json.loads(json.dumps(value, ensure_ascii=False))
147
+
148
+
149
+ JsonRpcServer = JsonRpcAdapter
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.4
2
+ Name: muscles-jsonrpc
3
+ Version: 1.0.0
4
+ Summary: JSON-RPC adapter for Muscles actions
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: muscles<2.0.0,>=1.0.0rc1
9
+
10
+ # Muscles JSON-RPC
11
+
12
+ JSON-RPC protocol projection for Muscles actions.
13
+
14
+ This package makes Muscles action-first applications callable over JSON-RPC
15
+ while preserving the same schemas, rules, dispatcher, and context used by other
16
+ projections.
17
+
18
+ ## Related Repositories
19
+
20
+ - [`muscles`](https://github.com/butkoden/muscles) - core action contracts, dispatcher and inspect contract.
21
+ - [`muscles-cli`](https://github.com/butkoden/muscles-cli) - CLI action inspection and execution over the same contracts.
22
+ - [`muscles-mcp`](https://github.com/butkoden/muscles-mcp) - MCP projection over the same action model.
23
+ - [`muscles-sse`](https://github.com/butkoden/muscles-sse) - streaming projection for action output.
24
+ - [`muscles-benchmarks`](https://github.com/butkoden/muscles-benchmarks) - protocol projection regression checks.
25
+
26
+ ## Concept Guardrails
27
+
28
+ - JSON-RPC is a protocol projection, not a new framework layer.
29
+ - Methods must be discovered from `inspect_application(app)`.
30
+ - Validation and error normalization must reuse Muscles core schema/rules/errors.
31
+ - Auth/rules must run through `ActionDispatcher`.
32
+ - The same use case must be callable from CLI, HTTP, MCP, and JSON-RPC without
33
+ duplicating implementation.
34
+ - JSON-RPC state is scoped to the application instance; no mutable module-level
35
+ method registry is the source of truth.
36
+
37
+ ## Initial Goal
38
+
39
+ Implement a JSON-RPC 2.0 projection around Muscles actions with typed
40
+ request/response contracts, deterministic error mapping, notifications, and
41
+ batch dispatch.
42
+
43
+ ## Current Stage (Issue #1)
44
+
45
+ Implemented JSON-RPC 2.0 projection:
46
+
47
+ - request validation (`jsonrpc`, `method`, `params`);
48
+ - method discovery from `inspect_application(app)`;
49
+ - dispatch through `ActionDispatcher(..., transport="jsonrpc")`;
50
+ - notification calls;
51
+ - batch calls;
52
+ - deterministic error mapping:
53
+ - `-32600` invalid request;
54
+ - `-32601` method not found;
55
+ - `-32602` invalid params;
56
+ - `-32001` permission denied;
57
+ - `-32603` internal error.
58
+
59
+ ### Run tests
60
+
61
+ ```bash
62
+ python -m pytest -q
63
+ ```
64
+
65
+ When testing against local core changes:
66
+
67
+ ```bash
68
+ PYTHONPATH=../muscles/src:src python -m pytest -q
69
+ ```
70
+
71
+ User docs:
72
+
73
+ - English: [docs/jsonrpc-projection.en.md](docs/jsonrpc-projection.en.md)
74
+ - Русский: [docs/jsonrpc-projection.ru.md](docs/jsonrpc-projection.ru.md)
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/muscles_jsonrpc/__init__.py
4
+ src/muscles_jsonrpc/adapter.py
5
+ src/muscles_jsonrpc.egg-info/PKG-INFO
6
+ src/muscles_jsonrpc.egg-info/SOURCES.txt
7
+ src/muscles_jsonrpc.egg-info/dependency_links.txt
8
+ src/muscles_jsonrpc.egg-info/requires.txt
9
+ src/muscles_jsonrpc.egg-info/top_level.txt
10
+ tests/test_adapter.py
@@ -0,0 +1 @@
1
+ muscles<2.0.0,>=1.0.0rc1
@@ -0,0 +1 @@
1
+ muscles_jsonrpc
@@ -0,0 +1,280 @@
1
+ from muscles.core import (
2
+ ActionPermissionDenied,
3
+ ApplicationMeta,
4
+ BaseStrategy,
5
+ Context,
6
+ StreamResult,
7
+ register_action,
8
+ )
9
+
10
+ from muscles_jsonrpc import JsonRpcAdapter
11
+
12
+
13
+ class _EchoStrategy(BaseStrategy):
14
+ def execute(self, *args, **kwargs):
15
+ return kwargs
16
+
17
+
18
+ BOOKING_INPUT_SCHEMA = {
19
+ "type": "object",
20
+ "properties": {
21
+ "title": {"type": "string"},
22
+ "guest_count": {"type": "integer"},
23
+ },
24
+ "required": ["title"],
25
+ }
26
+
27
+
28
+ def _build_app(handler=None, transports=None):
29
+ class _App(metaclass=ApplicationMeta):
30
+ context = Context(_EchoStrategy)
31
+
32
+ app = _App()
33
+ calls = []
34
+
35
+ def default_handler(payload, context):
36
+ calls.append((context.action.name, payload, context.transport))
37
+ return {
38
+ "id": len(calls),
39
+ "title": payload["title"],
40
+ "guest_count": payload.get("guest_count", 1),
41
+ }
42
+
43
+ register_action(
44
+ app,
45
+ name="bookings.create",
46
+ description="Create booking",
47
+ input_schema=BOOKING_INPUT_SCHEMA,
48
+ output_schema={"type": "object", "properties": {"id": {"type": "integer"}}},
49
+ transports=transports if transports is not None else ["http", "jsonrpc"],
50
+ handler=handler or default_handler,
51
+ )
52
+ return app, calls
53
+
54
+
55
+ def test_jsonrpc_success_uses_core_dispatcher_once():
56
+ app, calls = _build_app()
57
+ adapter = JsonRpcAdapter.from_application(app)
58
+
59
+ response = adapter.handle(
60
+ {"jsonrpc": "2.0", "id": 7, "method": "bookings.create", "params": {"title": "Call"}}
61
+ )
62
+
63
+ assert calls == [("bookings.create", {"title": "Call"}, "jsonrpc")]
64
+ assert response == {
65
+ "jsonrpc": "2.0",
66
+ "id": 7,
67
+ "result": {"id": 1, "title": "Call", "guest_count": 1},
68
+ }
69
+
70
+
71
+ def test_jsonrpc_method_list_comes_from_inspect_contract_and_transport_filter():
72
+ app, _ = _build_app()
73
+ register_action(
74
+ app,
75
+ name="bookings.http_only",
76
+ input_schema=BOOKING_INPUT_SCHEMA,
77
+ transports=["http"],
78
+ handler=lambda payload, context: {"ok": True},
79
+ )
80
+ adapter = JsonRpcAdapter.from_application(app)
81
+
82
+ assert adapter.list_methods() == [
83
+ {
84
+ "name": "bookings.create",
85
+ "description": "Create booking",
86
+ "params_schema": BOOKING_INPUT_SCHEMA,
87
+ }
88
+ ]
89
+
90
+
91
+ def test_jsonrpc_discovery_method_returns_action_surface():
92
+ app, _ = _build_app()
93
+ adapter = JsonRpcAdapter.from_application(app)
94
+
95
+ response = adapter.handle({"jsonrpc": "2.0", "id": "discover", "method": "rpc.discover"})
96
+
97
+ assert response == {"jsonrpc": "2.0", "id": "discover", "result": adapter.list_methods()}
98
+
99
+
100
+ def test_jsonrpc_methods_alias_returns_action_surface():
101
+ app, _ = _build_app()
102
+ adapter = JsonRpcAdapter.from_application(app)
103
+
104
+ response = adapter.handle({"jsonrpc": "2.0", "id": "methods", "method": "rpc.methods"})
105
+
106
+ assert response == {"jsonrpc": "2.0", "id": "methods", "result": adapter.list_methods()}
107
+
108
+
109
+ def test_jsonrpc_method_not_found_maps_core_error():
110
+ app, _ = _build_app()
111
+ adapter = JsonRpcAdapter.from_application(app)
112
+
113
+ response = adapter.handle({"jsonrpc": "2.0", "id": 5, "method": "missing", "params": {}})
114
+
115
+ assert response["error"]["code"] == -32601
116
+ assert response["error"]["message"] == "Method not found"
117
+
118
+
119
+ def test_jsonrpc_invalid_params_shape():
120
+ app, _ = _build_app()
121
+ adapter = JsonRpcAdapter.from_application(app)
122
+
123
+ response = adapter.handle({"jsonrpc": "2.0", "id": 1, "method": "bookings.create", "params": []})
124
+
125
+ assert response["error"]["code"] == -32602
126
+
127
+
128
+ def test_jsonrpc_invalid_params_from_core_validation():
129
+ app, _ = _build_app()
130
+ adapter = JsonRpcAdapter.from_application(app)
131
+
132
+ response = adapter.handle({"jsonrpc": "2.0", "id": 1, "method": "bookings.create", "params": {}})
133
+
134
+ assert response["error"]["code"] == -32602
135
+ assert response["error"]["message"] == "Invalid params"
136
+ assert "title" in response["error"]["data"]["reason"]
137
+
138
+
139
+ def test_jsonrpc_permission_denied_from_core_rules():
140
+ def deny(payload, context):
141
+ raise ActionPermissionDenied(context.action.name, "Denied by core rules")
142
+
143
+ app, _ = _build_app(handler=deny)
144
+ adapter = JsonRpcAdapter.from_application(app)
145
+
146
+ response = adapter.handle({"jsonrpc": "2.0", "id": 2, "method": "bookings.create", "params": {"title": "Call"}})
147
+
148
+ assert response["error"]["code"] == -32001
149
+ assert response["error"]["message"] == "Permission denied"
150
+ assert response["error"]["data"]["reason"] == "Denied by core rules"
151
+
152
+
153
+ def test_jsonrpc_denies_method_not_open_to_jsonrpc_transport():
154
+ app, _ = _build_app(transports=["http"])
155
+ adapter = JsonRpcAdapter.from_application(app)
156
+
157
+ response = adapter.handle({"jsonrpc": "2.0", "id": 3, "method": "bookings.create", "params": {"title": "Call"}})
158
+
159
+ assert response["error"]["code"] == -32001
160
+
161
+
162
+ def test_jsonrpc_execution_error_for_async_handler():
163
+ async def create_booking(payload, context):
164
+ return {"title": payload["title"]}
165
+
166
+ app, _ = _build_app(handler=create_booking)
167
+ adapter = JsonRpcAdapter.from_application(app)
168
+
169
+ response = adapter.handle({"jsonrpc": "2.0", "id": 4, "method": "bookings.create", "params": {"title": "Call"}})
170
+
171
+ assert response["error"]["code"] == -32603
172
+
173
+
174
+ def test_jsonrpc_rejects_non_serializable_result_with_stable_internal_error():
175
+ app, _ = _build_app(handler=lambda payload, context: object())
176
+ adapter = JsonRpcAdapter.from_application(app)
177
+
178
+ response = adapter.handle(
179
+ {"jsonrpc": "2.0", "id": 11, "method": "bookings.create", "params": {"title": "Call"}}
180
+ )
181
+
182
+ assert response == {
183
+ "jsonrpc": "2.0",
184
+ "id": 11,
185
+ "error": {"code": -32603, "message": "Internal error"},
186
+ }
187
+
188
+
189
+ def test_jsonrpc_stream_action_returns_explicit_unsupported_error():
190
+ app, _ = _build_app()
191
+
192
+ def stream_booking(payload, context):
193
+ return StreamResult(source=[])
194
+
195
+ register_action(
196
+ app,
197
+ name="bookings.stream",
198
+ input_schema=BOOKING_INPUT_SCHEMA,
199
+ transports=["jsonrpc"],
200
+ stream_output=True,
201
+ handler=stream_booking,
202
+ )
203
+ adapter = JsonRpcAdapter.from_application(app)
204
+
205
+ response = adapter.handle({"jsonrpc": "2.0", "id": 9, "method": "bookings.stream", "params": {"title": "Live"}})
206
+
207
+ assert response["error"]["code"] == -32002
208
+ assert response["error"]["message"] == "Streaming not supported"
209
+ assert response["error"]["data"]["action"] == "bookings.stream"
210
+
211
+
212
+ def test_jsonrpc_notification_returns_none_but_dispatches():
213
+ app, calls = _build_app()
214
+ adapter = JsonRpcAdapter.from_application(app)
215
+
216
+ response = adapter.handle({"jsonrpc": "2.0", "method": "bookings.create", "params": {"title": "Notify"}})
217
+
218
+ assert response is None
219
+ assert calls == [("bookings.create", {"title": "Notify"}, "jsonrpc")]
220
+
221
+
222
+ def test_jsonrpc_invalid_notification_returns_none():
223
+ app, _ = _build_app()
224
+ adapter = JsonRpcAdapter.from_application(app)
225
+
226
+ response = adapter.handle({"jsonrpc": "2.0", "method": "bookings.create", "params": []})
227
+
228
+ assert response is None
229
+
230
+
231
+ def test_jsonrpc_batch_dispatches_each_request_without_bypassing_core():
232
+ app, calls = _build_app()
233
+ adapter = JsonRpcAdapter.from_application(app)
234
+
235
+ response = adapter.handle(
236
+ [
237
+ {"jsonrpc": "2.0", "id": 1, "method": "bookings.create", "params": {"title": "A"}},
238
+ {"jsonrpc": "2.0", "id": 2, "method": "missing", "params": {}},
239
+ {"jsonrpc": "2.0", "method": "bookings.create", "params": {"title": "Notification"}},
240
+ ]
241
+ )
242
+
243
+ assert response == [
244
+ {"jsonrpc": "2.0", "id": 1, "result": {"id": 1, "title": "A", "guest_count": 1}},
245
+ {"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "Method not found"}},
246
+ ]
247
+ assert calls == [
248
+ ("bookings.create", {"title": "A"}, "jsonrpc"),
249
+ ("bookings.create", {"title": "Notification"}, "jsonrpc"),
250
+ ]
251
+
252
+
253
+ def test_jsonrpc_invalid_request():
254
+ app, _ = _build_app()
255
+ adapter = JsonRpcAdapter.from_application(app)
256
+
257
+ response = adapter.handle({"id": 1, "method": "x"})
258
+
259
+ assert response["error"]["code"] == -32600
260
+
261
+
262
+ def test_jsonrpc_state_is_scoped_to_application_instance():
263
+ app_a, _ = _build_app()
264
+
265
+ class _OtherApp(metaclass=ApplicationMeta):
266
+ context = Context(_EchoStrategy)
267
+
268
+ app_b = _OtherApp()
269
+ register_action(
270
+ app_b,
271
+ name="tasks.create",
272
+ input_schema={"type": "object", "properties": {}},
273
+ handler=lambda payload, context: {"ok": True},
274
+ )
275
+
276
+ methods_a = JsonRpcAdapter.from_application(app_a).list_methods()
277
+ methods_b = JsonRpcAdapter.from_application(app_b).list_methods()
278
+
279
+ assert [method["name"] for method in methods_a] == ["bookings.create"]
280
+ assert [method["name"] for method in methods_b] == ["tasks.create"]