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.
Files changed (33) hide show
  1. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/.gitignore +6 -1
  2. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/PKG-INFO +1 -1
  3. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/pyproject.toml +1 -1
  4. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/__init__.py +1 -1
  5. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/requests_recorder.py +69 -3
  6. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_requests.py +27 -0
  7. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/LICENSE +0 -0
  8. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/README.md +0 -0
  9. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_events.py +0 -0
  10. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_generated.py +0 -0
  11. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/_http.py +0 -0
  12. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/client.py +0 -0
  13. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/forward.py +0 -0
  14. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/ids.py +0 -0
  15. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/integrations.py +0 -0
  16. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/mcp.py +0 -0
  17. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/py.typed +0 -0
  18. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/redact.py +0 -0
  19. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/server.py +0 -0
  20. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/tool.py +0 -0
  21. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/types.py +0 -0
  22. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/src/backloop/validate.py +0 -0
  23. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/__init__.py +0 -0
  24. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/helpers.py +0 -0
  25. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_client.py +0 -0
  26. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_forward.py +0 -0
  27. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_ids.py +0 -0
  28. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_integrations.py +0 -0
  29. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_mcp.py +0 -0
  30. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_redact.py +0 -0
  31. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_server.py +0 -0
  32. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_tool.py +0 -0
  33. {backloop_sdk-0.2.0 → backloop_sdk-0.2.1}/tests/test_validate.py +0 -0
@@ -1,8 +1,13 @@
1
1
  node_modules/
2
2
  dist/
3
- data/
3
+ .backloop/
4
+ *.db
5
+ *.db-*
4
6
  __pycache__/
5
7
  *.egg-info/
6
8
  .venv/
7
9
  .env
10
+ .env.*
11
+ !.env.example
8
12
  .DS_Store
13
+ dist-website/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: backloop-sdk
3
- Version: 0.2.0
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.0"
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"
@@ -47,7 +47,7 @@ from .validate import (
47
47
  validate_submission,
48
48
  )
49
49
 
50
- __version__ = "0.2.0"
50
+ __version__ = "0.2.1"
51
51
 
52
52
  __all__ = [
53
53
  "ACK_SCHEMA",
@@ -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
- # The query, as requests.ts sends it ("a=1 b=2"); without one, the request body.
234
- if query:
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