backloop-sdk 0.2.0__tar.gz → 0.2.1__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.
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/.gitignore +6 -1
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/PKG-INFO +1 -1
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/pyproject.toml +1 -1
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/__init__.py +1 -1
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/requests_recorder.py +69 -3
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_requests.py +27 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/LICENSE +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/README.md +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_events.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_generated.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_http.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/client.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/forward.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/ids.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/integrations.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/mcp.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/py.typed +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/redact.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/server.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/tool.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/types.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/validate.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/__init__.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/helpers.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_client.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_forward.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_ids.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_integrations.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_mcp.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_redact.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_server.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_tool.py +0 -0
- {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: backloop-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents.
|
|
5
5
|
Project-URL: Homepage, https://trybackloop.com
|
|
6
6
|
Project-URL: Specification, https://trybackloop.com/spec/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "backloop-sdk"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Python SDK for the Agent Feedback Protocol: validate, send and receive structured feedback from AI agents."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -118,6 +118,63 @@ def detect_agent(headers: Optional[HeadersLike]) -> Optional[str]:
|
|
|
118
118
|
return client["name"] if client else None
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
def json_rpc_call(body: Optional[str]) -> Optional[Tuple[str, Any]]:
|
|
122
|
+
"""Like ``jsonRpcCall`` in ``requests.ts``: (``"tools/call search"``, arguments), or None when it isn't JSON-RPC."""
|
|
123
|
+
if not body:
|
|
124
|
+
return None
|
|
125
|
+
try:
|
|
126
|
+
msg = json.loads(body)
|
|
127
|
+
except ValueError:
|
|
128
|
+
return None
|
|
129
|
+
if isinstance(msg, list):
|
|
130
|
+
return ("batch", None) if any(isinstance(m, dict) and m.get("jsonrpc") == "2.0" for m in msg) else None
|
|
131
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0" or not isinstance(msg.get("method"), str):
|
|
132
|
+
return None
|
|
133
|
+
method = clip(msg["method"], 64)
|
|
134
|
+
params = msg.get("params") if isinstance(msg.get("params"), dict) else {}
|
|
135
|
+
name = params.get("name")
|
|
136
|
+
if method in ("tools/call", "prompts/get") and isinstance(name, str) and name:
|
|
137
|
+
return f"{method} {clip(name, 128)}", params.get("arguments")
|
|
138
|
+
return method, ({"uri": params["uri"]} if "uri" in params else None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def json_rpc_error(body: Optional[str]) -> Optional[str]:
|
|
142
|
+
"""Like ``jsonRpcError`` in ``requests.ts``: a JSON-RPC error, or an MCP tool result's ``isError`` text."""
|
|
143
|
+
if not body:
|
|
144
|
+
return None
|
|
145
|
+
text = body.strip()
|
|
146
|
+
if text[:1] not in ("[", "{"):
|
|
147
|
+
data = [line[5:].strip() for line in text.splitlines() if line.startswith("data:")]
|
|
148
|
+
text = data[-1] if data else ""
|
|
149
|
+
try:
|
|
150
|
+
msg = json.loads(text)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return None
|
|
153
|
+
for m in msg if isinstance(msg, list) else [msg]:
|
|
154
|
+
if not isinstance(m, dict):
|
|
155
|
+
continue
|
|
156
|
+
err = m.get("error")
|
|
157
|
+
if isinstance(err, dict):
|
|
158
|
+
return err["message"] if isinstance(err.get("message"), str) else f"JSON-RPC error {err.get('code', '')}".strip()
|
|
159
|
+
result = m.get("result")
|
|
160
|
+
if isinstance(result, dict) and result.get("isError") is True:
|
|
161
|
+
for c in result.get("content") or []:
|
|
162
|
+
if isinstance(c, dict) and isinstance(c.get("text"), str):
|
|
163
|
+
return c["text"]
|
|
164
|
+
return "The tool returned isError"
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_transport_probe(method: str, path: str, status: int, headers: Mapping[str, str]) -> bool:
|
|
169
|
+
"""An answer the MCP spec asks for: 405 to a GET/DELETE the server doesn't offer, 401 to a request without a credential."""
|
|
170
|
+
mcp = bool(re.search(r"/mcp/?$", path)) or "mcp-session-id" in headers
|
|
171
|
+
if status == 401:
|
|
172
|
+
return mcp
|
|
173
|
+
if status != 405 or method.upper() not in ("GET", "DELETE"):
|
|
174
|
+
return False
|
|
175
|
+
return mcp or "text/event-stream" in headers.get("accept", "")
|
|
176
|
+
|
|
177
|
+
|
|
121
178
|
def collapse_path(path: str) -> str:
|
|
122
179
|
"""``/companies/cmp_001/jobs`` -> ``/companies/:id/jobs``: the route when the framework names none."""
|
|
123
180
|
return "/".join(":id" if seg and _ID_SEGMENT.match(seg) else seg for seg in path.split("/")) or "/"
|
|
@@ -208,6 +265,9 @@ class _Recorder:
|
|
|
208
265
|
error: Optional[BaseException] = None,
|
|
209
266
|
) -> None:
|
|
210
267
|
try:
|
|
268
|
+
if _is_transport_probe(method, path, status, headers):
|
|
269
|
+
return
|
|
270
|
+
rpc = json_rpc_call(body.decode("utf-8", "replace") if body and method.upper() == "POST" else None)
|
|
211
271
|
event: Dict[str, Any] = {"id": new_id("ev"), "kind": "http_request"}
|
|
212
272
|
session_id = headers.get("x-agent-session") or headers.get("mcp-session-id")
|
|
213
273
|
if session_id:
|
|
@@ -220,18 +280,24 @@ class _Recorder:
|
|
|
220
280
|
event["client"] = client
|
|
221
281
|
event.update(
|
|
222
282
|
method=method.upper(),
|
|
223
|
-
route=clip(self.route_for(request, framework_route, path), 512),
|
|
283
|
+
route=clip(self.route_for(request, framework_route, path) + (" " + rpc[0] if rpc else ""), 512),
|
|
224
284
|
status=int(status),
|
|
225
285
|
started_at=iso_now(started),
|
|
226
286
|
duration_ms=max(0, round(time.time() * 1000 - started)),
|
|
227
287
|
)
|
|
228
288
|
text = response.decode("utf-8", "replace") if response else None
|
|
289
|
+
rpc_error = json_rpc_error(text) if rpc and error is None and status < 400 else None
|
|
229
290
|
if error is not None:
|
|
230
291
|
event["error"] = self.scrub.error(str(error) or type(error).__name__)
|
|
292
|
+
elif rpc_error is not None:
|
|
293
|
+
event["ok"] = False
|
|
294
|
+
event["error"] = self.scrub.error(rpc_error)
|
|
231
295
|
elif status >= 400 and text:
|
|
232
296
|
event["error"] = self.scrub.error(text)
|
|
233
|
-
#
|
|
234
|
-
if
|
|
297
|
+
# A JSON-RPC call's arguments; else the query, as requests.ts sends it ("a=1 b=2"); without one, the request body.
|
|
298
|
+
if rpc and rpc[1] is not None:
|
|
299
|
+
preview = self.preview(json.dumps(rpc[1], ensure_ascii=False, separators=(",", ":")))
|
|
300
|
+
elif query:
|
|
235
301
|
preview = self.preview(urllib.parse.unquote(query).replace("&", " "))
|
|
236
302
|
else:
|
|
237
303
|
preview = self.preview(body.decode("utf-8", "replace")) if body else None
|
|
@@ -141,6 +141,33 @@ class ASGITest(unittest.TestCase):
|
|
|
141
141
|
},
|
|
142
142
|
)
|
|
143
143
|
|
|
144
|
+
def test_mcp_calls_are_named_by_tool_and_spec_answers_are_skipped(self):
|
|
145
|
+
def mcp(body):
|
|
146
|
+
return asgi_app(body=body)
|
|
147
|
+
|
|
148
|
+
headers = {"User-Agent": "Codex/0.50", "Content-Type": "application/json"}
|
|
149
|
+
app, events = recorder(asgi_app(status=405, body=b"Method not allowed.", content_type=b"text/plain"))
|
|
150
|
+
call_asgi(app, path="/mcp", headers={"User-Agent": "Codex/0.50", "Accept": "text/event-stream"})
|
|
151
|
+
app, challenged = recorder(asgi_app(status=401, body=b'{"error":"invalid_token"}'))
|
|
152
|
+
call_asgi(app, path="/mcp", method="POST", headers={"User-Agent": "claude-code/2.1.0", "Content-Type": "application/json"}, body=b'{"jsonrpc":"2.0","id":0,"method":"initialize"}')
|
|
153
|
+
app.flush()
|
|
154
|
+
self.assertEqual(events + challenged, [])
|
|
155
|
+
|
|
156
|
+
app, events = recorder(mcp(b'{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}}'))
|
|
157
|
+
req = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"q":"fintech"}}}'
|
|
158
|
+
call_asgi(app, path="/mcp", method="POST", headers=headers, body=req)
|
|
159
|
+
app.flush()
|
|
160
|
+
[event] = events
|
|
161
|
+
self.assertEqual(event["route"], "/mcp tools/call search")
|
|
162
|
+
self.assertEqual(event["input"], '{"q":"fintech"}')
|
|
163
|
+
self.assertNotIn("ok", event)
|
|
164
|
+
|
|
165
|
+
app, events = recorder(mcp(b'{"jsonrpc":"2.0","id":1,"result":{"isError":true,"content":[{"type":"text","text":"upstream timed out"}]}}'))
|
|
166
|
+
call_asgi(app, path="/mcp", method="POST", headers=headers, body=req)
|
|
167
|
+
app.flush()
|
|
168
|
+
[event] = events
|
|
169
|
+
self.assertEqual((event["ok"], event["error"]), (False, "upstream timed out"))
|
|
170
|
+
|
|
144
171
|
def test_humans_are_not_recorded_unless_asked(self):
|
|
145
172
|
app, events = recorder(asgi_app())
|
|
146
173
|
call_asgi(app, headers={"User-Agent": BROWSER})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|