editbuffer 0.2.1__tar.gz → 0.2.2__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 (27) hide show
  1. {editbuffer-0.2.1/src/editbuffer.egg-info → editbuffer-0.2.2}/PKG-INFO +20 -3
  2. {editbuffer-0.2.1 → editbuffer-0.2.2}/README.md +19 -2
  3. {editbuffer-0.2.1 → editbuffer-0.2.2}/pyproject.toml +1 -1
  4. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/mcp_server.py +169 -7
  5. {editbuffer-0.2.1 → editbuffer-0.2.2/src/editbuffer.egg-info}/PKG-INFO +20 -3
  6. {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_mcp_stdio_eval.py +137 -59
  7. {editbuffer-0.2.1 → editbuffer-0.2.2}/LICENSE +0 -0
  8. {editbuffer-0.2.1 → editbuffer-0.2.2}/setup.cfg +0 -0
  9. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/__init__.py +0 -0
  10. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/blocks.py +0 -0
  11. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/buffer.py +0 -0
  12. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/cli.py +0 -0
  13. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/errors.py +0 -0
  14. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/history.py +0 -0
  15. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/operations.py +0 -0
  16. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/py.typed +0 -0
  17. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/resolver.py +0 -0
  18. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/selection.py +0 -0
  19. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/validators.py +0 -0
  20. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/SOURCES.txt +0 -0
  21. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/dependency_links.txt +0 -0
  22. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/entry_points.txt +0 -0
  23. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/requires.txt +0 -0
  24. {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/top_level.txt +0 -0
  25. {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_cli.py +0 -0
  26. {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_editbuffer.py +0 -0
  27. {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_mcp_server.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: editbuffer
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Selection-based mutable output buffer for LLM tools
5
5
  Author: averagedigital
6
6
  License-Expression: MIT
@@ -199,10 +199,10 @@ Install the optional server:
199
199
  pipx install 'editbuffer[mcp]'
200
200
  ```
201
201
 
202
- Until a PyPI release exists, install directly from GitHub:
202
+ Or install with `uvx` without a persistent environment:
203
203
 
204
204
  ```bash
205
- pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
205
+ uvx --from 'editbuffer[mcp]' editbuffer-mcp
206
206
  ```
207
207
 
208
208
  Connect it to Codex:
@@ -220,9 +220,14 @@ Claude Desktop and generic MCP client examples are in
220
220
  The server exposes:
221
221
 
222
222
  - `buffer_create`
223
+ - `buffer_append`
223
224
  - `buffer_list`
224
225
  - `buffer_view`
225
226
  - `buffer_edit`
227
+ - `buffer_replace`
228
+ - `buffer_insert_before`
229
+ - `buffer_insert_after`
230
+ - `buffer_delete`
226
231
  - `buffer_history`
227
232
  - `buffer_rollback`
228
233
  - `buffer_commit`
@@ -232,6 +237,18 @@ The server exposes:
232
237
  Buffers are in-memory and live for the MCP server process. The MCP layer calls
233
238
  the same core API and does not implement separate edit semantics.
234
239
 
240
+ Use the first-class selection tools for normal agent use:
241
+
242
+ ```json
243
+ {
244
+ "buffer_id": "answer",
245
+ "target": {"type": "exact", "text": "old"},
246
+ "text": "new"
247
+ }
248
+ ```
249
+
250
+ `buffer_edit` remains available for raw JSON operations.
251
+
235
252
  `buffer_commit` remembers non-empty committed output as a reusable command.
236
253
  `command_history` returns the last 10 commands, newest first. `command_select`
237
254
  creates a new pending buffer from a previous command so the model can reuse it
@@ -181,10 +181,10 @@ Install the optional server:
181
181
  pipx install 'editbuffer[mcp]'
182
182
  ```
183
183
 
184
- Until a PyPI release exists, install directly from GitHub:
184
+ Or install with `uvx` without a persistent environment:
185
185
 
186
186
  ```bash
187
- pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
187
+ uvx --from 'editbuffer[mcp]' editbuffer-mcp
188
188
  ```
189
189
 
190
190
  Connect it to Codex:
@@ -202,9 +202,14 @@ Claude Desktop and generic MCP client examples are in
202
202
  The server exposes:
203
203
 
204
204
  - `buffer_create`
205
+ - `buffer_append`
205
206
  - `buffer_list`
206
207
  - `buffer_view`
207
208
  - `buffer_edit`
209
+ - `buffer_replace`
210
+ - `buffer_insert_before`
211
+ - `buffer_insert_after`
212
+ - `buffer_delete`
208
213
  - `buffer_history`
209
214
  - `buffer_rollback`
210
215
  - `buffer_commit`
@@ -214,6 +219,18 @@ The server exposes:
214
219
  Buffers are in-memory and live for the MCP server process. The MCP layer calls
215
220
  the same core API and does not implement separate edit semantics.
216
221
 
222
+ Use the first-class selection tools for normal agent use:
223
+
224
+ ```json
225
+ {
226
+ "buffer_id": "answer",
227
+ "target": {"type": "exact", "text": "old"},
228
+ "text": "new"
229
+ }
230
+ ```
231
+
232
+ `buffer_edit` remains available for raw JSON operations.
233
+
217
234
  `buffer_commit` remembers non-empty committed output as a reusable command.
218
235
  `command_history` returns the last 10 commands, newest first. `command_select`
219
236
  creates a new pending buffer from a previous command so the model can reuse it
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "editbuffer"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Selection-based mutable output buffer for LLM tools"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -5,6 +5,14 @@ from typing import Any
5
5
  from uuid import uuid4
6
6
 
7
7
  from .buffer import EditBuffer
8
+ from .errors import (
9
+ AmbiguousTargetError,
10
+ EditBufferError,
11
+ FuzzyMatchError,
12
+ InvalidOperationError,
13
+ StaleVersionError,
14
+ TargetNotFoundError,
15
+ )
8
16
  from .history import EditRecord
9
17
 
10
18
 
@@ -61,6 +69,12 @@ class BufferRegistry:
61
69
  def command_history(self) -> list[dict[str, Any]]:
62
70
  return list(self._commands)
63
71
 
72
+ def current_version(self, buffer_id: str | None) -> int | None:
73
+ if buffer_id is None:
74
+ return None
75
+ buffer = self._buffers.get(buffer_id)
76
+ return None if buffer is None else buffer.version
77
+
64
78
  def select_command(
65
79
  self,
66
80
  command_id: str,
@@ -140,7 +154,11 @@ def create_server() -> Any:
140
154
  buffer_id: str | None = None,
141
155
  ) -> dict[str, Any]:
142
156
  """Create an in-memory pending output buffer."""
143
- return registry.create(content, buffer_id=buffer_id)
157
+ return _tool_result(
158
+ lambda: registry.create(content, buffer_id=buffer_id),
159
+ registry,
160
+ buffer_id=buffer_id,
161
+ )
144
162
 
145
163
  @server.tool()
146
164
  def buffer_list() -> list[dict[str, Any]]:
@@ -150,7 +168,7 @@ def create_server() -> Any:
150
168
  @server.tool()
151
169
  def buffer_view(buffer_id: str) -> dict[str, Any]:
152
170
  """View current content, version, snapshots, and commit state."""
153
- return registry.view(buffer_id)
171
+ return _tool_result(lambda: registry.view(buffer_id), registry, buffer_id=buffer_id)
154
172
 
155
173
  @server.tool()
156
174
  def buffer_edit(
@@ -164,22 +182,79 @@ def create_server() -> Any:
164
182
  Operations: append, replace, insert_before, insert_after, delete, rollback.
165
183
  Targets: exact/context/range/fuzzy/block. Ambiguous edits fail without mutation.
166
184
  """
167
- return registry.edit(buffer_id, operation)
185
+ return _tool_result(
186
+ lambda: registry.edit(buffer_id, operation),
187
+ registry,
188
+ buffer_id=buffer_id,
189
+ )
190
+
191
+ @server.tool()
192
+ def buffer_append(buffer_id: str, text: str) -> dict[str, Any]:
193
+ """Append text to a pending buffer."""
194
+ return _tool_result(
195
+ lambda: registry.edit(buffer_id, {"op": "append", "text": text}),
196
+ registry,
197
+ buffer_id=buffer_id,
198
+ )
199
+
200
+ @server.tool()
201
+ def buffer_replace(
202
+ buffer_id: str,
203
+ target: dict[str, Any],
204
+ text: str,
205
+ ) -> dict[str, Any]:
206
+ """Replace a selection with text. Target can be exact/context/range/fuzzy/block."""
207
+ return _selection_tool(registry, buffer_id, "replace", target, text)
208
+
209
+ @server.tool()
210
+ def buffer_insert_before(
211
+ buffer_id: str,
212
+ target: dict[str, Any],
213
+ text: str,
214
+ ) -> dict[str, Any]:
215
+ """Insert text before a selection. Target can be exact/context/range/fuzzy/block."""
216
+ return _selection_tool(registry, buffer_id, "insert_before", target, text)
217
+
218
+ @server.tool()
219
+ def buffer_insert_after(
220
+ buffer_id: str,
221
+ target: dict[str, Any],
222
+ text: str,
223
+ ) -> dict[str, Any]:
224
+ """Insert text after a selection. Target can be exact/context/range/fuzzy/block."""
225
+ return _selection_tool(registry, buffer_id, "insert_after", target, text)
226
+
227
+ @server.tool()
228
+ def buffer_delete(buffer_id: str, target: dict[str, Any]) -> dict[str, Any]:
229
+ """Delete a selection. Target can be exact/context/range/fuzzy/block."""
230
+ return _tool_result(
231
+ lambda: registry.edit(buffer_id, {"op": "delete", "target": target}),
232
+ registry,
233
+ buffer_id=buffer_id,
234
+ )
168
235
 
169
236
  @server.tool()
170
237
  def buffer_history(buffer_id: str) -> list[dict[str, Any]]:
171
238
  """Return the audit trail for successful edits."""
172
- return registry.history(buffer_id)
239
+ return _tool_result(
240
+ lambda: registry.history(buffer_id),
241
+ registry,
242
+ buffer_id=buffer_id,
243
+ )
173
244
 
174
245
  @server.tool()
175
246
  def buffer_rollback(buffer_id: str, version: int) -> dict[str, Any]:
176
247
  """Restore a prior snapshot as a new audited version."""
177
- return registry.rollback(buffer_id, version)
248
+ return _tool_result(
249
+ lambda: registry.rollback(buffer_id, version),
250
+ registry,
251
+ buffer_id=buffer_id,
252
+ )
178
253
 
179
254
  @server.tool()
180
255
  def buffer_commit(buffer_id: str) -> dict[str, Any]:
181
256
  """Commit final output, close the buffer, and remember it as a reusable command."""
182
- return registry.commit(buffer_id)
257
+ return _tool_result(lambda: registry.commit(buffer_id), registry, buffer_id=buffer_id)
183
258
 
184
259
  @server.tool()
185
260
  def command_history() -> list[dict[str, Any]]:
@@ -192,11 +267,98 @@ def create_server() -> Any:
192
267
  buffer_id: str | None = None,
193
268
  ) -> dict[str, Any]:
194
269
  """Create a new pending buffer from a previous command instead of regenerating it."""
195
- return registry.select_command(command_id, buffer_id=buffer_id)
270
+ return _tool_result(
271
+ lambda: registry.select_command(command_id, buffer_id=buffer_id),
272
+ registry,
273
+ buffer_id=buffer_id,
274
+ )
196
275
 
197
276
  return server
198
277
 
199
278
 
279
+ def _selection_tool(
280
+ registry: BufferRegistry,
281
+ buffer_id: str,
282
+ op: str,
283
+ target: dict[str, Any],
284
+ text: str,
285
+ ) -> dict[str, Any]:
286
+ return _tool_result(
287
+ lambda: registry.edit(buffer_id, {"op": op, "target": target, "text": text}),
288
+ registry,
289
+ buffer_id=buffer_id,
290
+ )
291
+
292
+
293
+ def _tool_result(
294
+ call: Any,
295
+ registry: BufferRegistry,
296
+ *,
297
+ buffer_id: str | None = None,
298
+ ) -> Any:
299
+ try:
300
+ return call()
301
+ except (EditBufferError, KeyError, ValueError) as error:
302
+ return {
303
+ "ok": False,
304
+ "error": _structured_error(
305
+ error,
306
+ current_version=registry.current_version(buffer_id),
307
+ ),
308
+ }
309
+
310
+
311
+ def _structured_error(
312
+ error: Exception,
313
+ *,
314
+ current_version: int | None,
315
+ ) -> dict[str, Any]:
316
+ payload: dict[str, Any] = {
317
+ "type": _error_type(error),
318
+ "message": _message(error),
319
+ }
320
+ if current_version is not None:
321
+ payload["current_version"] = current_version
322
+ if isinstance(error, AmbiguousTargetError):
323
+ payload["candidates"] = [list(candidate) for candidate in error.candidates]
324
+ if isinstance(error, FuzzyMatchError):
325
+ payload["reason"] = error.reason
326
+ payload["candidates"] = [list(candidate) for candidate in error.candidates]
327
+ return payload
328
+
329
+
330
+ def _error_type(error: Exception) -> str:
331
+ if isinstance(error, TargetNotFoundError):
332
+ return "target_not_found"
333
+ if isinstance(error, AmbiguousTargetError):
334
+ return "ambiguous_target"
335
+ if isinstance(error, FuzzyMatchError):
336
+ return "fuzzy_match"
337
+ if isinstance(error, StaleVersionError):
338
+ return "stale_version"
339
+ if isinstance(error, InvalidOperationError):
340
+ return "invalid_operation"
341
+ if isinstance(error, KeyError):
342
+ message = _message(error)
343
+ if message.startswith("unknown buffer:"):
344
+ return "unknown_buffer"
345
+ if message.startswith("unknown command:"):
346
+ return "unknown_command"
347
+ return "not_found"
348
+ if isinstance(error, ValueError):
349
+ message = _message(error)
350
+ if message.startswith("buffer already exists:"):
351
+ return "duplicate_buffer"
352
+ return "invalid_value"
353
+ return "editbuffer_error"
354
+
355
+
356
+ def _message(error: Exception) -> str:
357
+ if isinstance(error, KeyError) and error.args:
358
+ return str(error.args[0])
359
+ return str(error)
360
+
361
+
200
362
  def main() -> None:
201
363
  create_server().run(transport="stdio")
202
364
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: editbuffer
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Selection-based mutable output buffer for LLM tools
5
5
  Author: averagedigital
6
6
  License-Expression: MIT
@@ -199,10 +199,10 @@ Install the optional server:
199
199
  pipx install 'editbuffer[mcp]'
200
200
  ```
201
201
 
202
- Until a PyPI release exists, install directly from GitHub:
202
+ Or install with `uvx` without a persistent environment:
203
203
 
204
204
  ```bash
205
- pipx install 'editbuffer[mcp] @ git+https://github.com/averagedigital/editbuffer.git'
205
+ uvx --from 'editbuffer[mcp]' editbuffer-mcp
206
206
  ```
207
207
 
208
208
  Connect it to Codex:
@@ -220,9 +220,14 @@ Claude Desktop and generic MCP client examples are in
220
220
  The server exposes:
221
221
 
222
222
  - `buffer_create`
223
+ - `buffer_append`
223
224
  - `buffer_list`
224
225
  - `buffer_view`
225
226
  - `buffer_edit`
227
+ - `buffer_replace`
228
+ - `buffer_insert_before`
229
+ - `buffer_insert_after`
230
+ - `buffer_delete`
226
231
  - `buffer_history`
227
232
  - `buffer_rollback`
228
233
  - `buffer_commit`
@@ -232,6 +237,18 @@ The server exposes:
232
237
  Buffers are in-memory and live for the MCP server process. The MCP layer calls
233
238
  the same core API and does not implement separate edit semantics.
234
239
 
240
+ Use the first-class selection tools for normal agent use:
241
+
242
+ ```json
243
+ {
244
+ "buffer_id": "answer",
245
+ "target": {"type": "exact", "text": "old"},
246
+ "text": "new"
247
+ }
248
+ ```
249
+
250
+ `buffer_edit` remains available for raw JSON operations.
251
+
235
252
  `buffer_commit` remembers non-empty committed output as a reusable command.
236
253
  `command_history` returns the last 10 commands, newest first. `command_select`
237
254
  creates a new pending buffer from a previous command so the model can reuse it
@@ -18,18 +18,23 @@ class McpStdioEvalTests(unittest.TestCase):
18
18
  async with _session() as session:
19
19
  tools = await session.list_tools()
20
20
  self.assertEqual(
21
- [tool.name for tool in tools.tools],
22
- [
21
+ {tool.name for tool in tools.tools},
22
+ {
23
23
  "buffer_create",
24
+ "buffer_append",
24
25
  "buffer_list",
25
26
  "buffer_view",
26
27
  "buffer_edit",
28
+ "buffer_replace",
29
+ "buffer_insert_before",
30
+ "buffer_insert_after",
31
+ "buffer_delete",
27
32
  "buffer_history",
28
33
  "buffer_rollback",
29
34
  "buffer_commit",
30
35
  "command_history",
31
36
  "command_select",
32
- ],
37
+ },
33
38
  )
34
39
  cases = [
35
40
  (
@@ -99,6 +104,59 @@ class McpStdioEvalTests(unittest.TestCase):
99
104
 
100
105
  asyncio.run(run())
101
106
 
107
+ def test_selection_edit_tools_are_first_class(self) -> None:
108
+ async def run() -> None:
109
+ async with _session() as session:
110
+ await _call(
111
+ session,
112
+ "buffer_create",
113
+ {"buffer_id": "selection-tools", "content": "alpha beta"},
114
+ )
115
+ await _call(
116
+ session,
117
+ "buffer_replace",
118
+ {
119
+ "buffer_id": "selection-tools",
120
+ "target": {"type": "exact", "text": "beta"},
121
+ "text": "gamma",
122
+ },
123
+ )
124
+ await _call(
125
+ session,
126
+ "buffer_insert_before",
127
+ {
128
+ "buffer_id": "selection-tools",
129
+ "target": {"type": "exact", "text": "gamma"},
130
+ "text": "new ",
131
+ },
132
+ )
133
+ await _call(
134
+ session,
135
+ "buffer_insert_after",
136
+ {
137
+ "buffer_id": "selection-tools",
138
+ "target": {"type": "exact", "text": "alpha"},
139
+ "text": " old",
140
+ },
141
+ )
142
+ await _call(
143
+ session,
144
+ "buffer_delete",
145
+ {
146
+ "buffer_id": "selection-tools",
147
+ "target": {"type": "exact", "text": "old "},
148
+ },
149
+ )
150
+ result = await _call(
151
+ session,
152
+ "buffer_append",
153
+ {"buffer_id": "selection-tools", "text": "!"},
154
+ )
155
+
156
+ self.assertEqual(result["content"], "alpha new gamma!")
157
+
158
+ asyncio.run(run())
159
+
102
160
  def test_failure_modes_are_actionable_and_atomic(self) -> None:
103
161
  async def run() -> None:
104
162
  async with _session() as session:
@@ -116,7 +174,8 @@ class McpStdioEvalTests(unittest.TestCase):
116
174
  },
117
175
  },
118
176
  )
119
- self.assertIn("selection did not match", error)
177
+ self.assertEqual(error["type"], "target_not_found")
178
+ self.assertIn("selection did not match", error["message"])
120
179
  self.assertEqual(
121
180
  await _call(session, "buffer_view", {"buffer_id": "missing"}),
122
181
  before,
@@ -134,7 +193,8 @@ class McpStdioEvalTests(unittest.TestCase):
134
193
  },
135
194
  },
136
195
  )
137
- self.assertIn("selection matched 2 targets", error)
196
+ self.assertEqual(error["type"], "ambiguous_target")
197
+ self.assertEqual(error["candidates"], [[0, 1], [2, 3]])
138
198
 
139
199
  await _call(
140
200
  session,
@@ -162,7 +222,9 @@ class McpStdioEvalTests(unittest.TestCase):
162
222
  },
163
223
  },
164
224
  )
165
- self.assertIn("expected version 0", error)
225
+ self.assertEqual(error["type"], "stale_version")
226
+ self.assertEqual(error["current_version"], 1)
227
+ self.assertIn("expected version 0", error["message"])
166
228
  self.assertEqual(
167
229
  (await _call(session, "buffer_view", {"buffer_id": "stale"}))["content"],
168
230
  "abcd",
@@ -193,71 +255,80 @@ class McpStdioEvalTests(unittest.TestCase):
193
255
  },
194
256
  },
195
257
  )
196
- self.assertIn("fuzzy selection ambiguous", error)
258
+ self.assertEqual(error["type"], "fuzzy_match")
259
+ self.assertEqual(error["reason"], "ambiguous")
260
+ self.assertGreaterEqual(len(error["candidates"]), 2)
197
261
 
198
262
  await _call(
199
263
  session,
200
264
  "buffer_create",
201
265
  {"buffer_id": "blocks", "content": "``` editbuffer:id=x\na\n```\n``` editbuffer:id=x\nb\n```"},
202
266
  )
203
- self.assertIn(
204
- "selection matched 2 targets",
205
- await _call_error(
206
- session,
207
- "buffer_edit",
208
- {
209
- "buffer_id": "blocks",
210
- "operation": {
211
- "op": "delete",
212
- "target": {"type": "block", "block_id": "x"},
267
+ self.assertEqual(
268
+ (
269
+ await _call_error(
270
+ session,
271
+ "buffer_edit",
272
+ {
273
+ "buffer_id": "blocks",
274
+ "operation": {
275
+ "op": "delete",
276
+ "target": {"type": "block", "block_id": "x"},
277
+ },
213
278
  },
214
- },
215
- ),
279
+ )
280
+ )["type"],
281
+ "ambiguous_target",
216
282
  )
217
- self.assertIn(
218
- "selection did not match",
219
- await _call_error(
220
- session,
221
- "buffer_edit",
222
- {
223
- "buffer_id": "blocks",
224
- "operation": {
225
- "op": "delete",
226
- "target": {"type": "block", "block_id": "missing"},
283
+ self.assertEqual(
284
+ (
285
+ await _call_error(
286
+ session,
287
+ "buffer_edit",
288
+ {
289
+ "buffer_id": "blocks",
290
+ "operation": {
291
+ "op": "delete",
292
+ "target": {"type": "block", "block_id": "missing"},
293
+ },
227
294
  },
228
- },
229
- ),
295
+ )
296
+ )["type"],
297
+ "target_not_found",
230
298
  )
231
- self.assertIn(
232
- "unknown operation",
233
- await _call_error(
234
- session,
235
- "buffer_edit",
236
- {"buffer_id": "blocks", "operation": {"op": "move"}},
237
- ),
299
+ self.assertEqual(
300
+ (
301
+ await _call_error(
302
+ session,
303
+ "buffer_edit",
304
+ {"buffer_id": "blocks", "operation": {"op": "move"}},
305
+ )
306
+ )["type"],
307
+ "invalid_operation",
238
308
  )
239
- self.assertIn(
240
- "unknown version",
241
- await _call_error(
242
- session,
243
- "buffer_rollback",
244
- {"buffer_id": "blocks", "version": 99},
245
- ),
309
+ self.assertEqual(
310
+ (
311
+ await _call_error(
312
+ session,
313
+ "buffer_rollback",
314
+ {"buffer_id": "blocks", "version": 99},
315
+ )
316
+ )["type"],
317
+ "invalid_operation",
246
318
  )
247
319
 
248
320
  await _call(session, "buffer_create", {"buffer_id": "commit", "content": "done"})
249
321
  await _call(session, "buffer_commit", {"buffer_id": "commit"})
250
- self.assertIn(
251
- "buffer is already committed",
252
- await _call_error(
253
- session,
254
- "buffer_edit",
255
- {
256
- "buffer_id": "commit",
257
- "operation": {"op": "append", "text": "!"},
258
- },
259
- ),
322
+ error = await _call_error(
323
+ session,
324
+ "buffer_edit",
325
+ {
326
+ "buffer_id": "commit",
327
+ "operation": {"op": "append", "text": "!"},
328
+ },
260
329
  )
330
+ self.assertEqual(error["type"], "invalid_operation")
331
+ self.assertIn("buffer is already committed", error["message"])
261
332
 
262
333
  asyncio.run(run())
263
334
 
@@ -285,8 +356,15 @@ async def _call(session: Any, name: str, arguments: dict[str, Any]) -> dict[str,
285
356
  return structured
286
357
 
287
358
 
288
- async def _call_error(session: Any, name: str, arguments: dict[str, Any]) -> str:
359
+ async def _call_error(session: Any, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
289
360
  result = await session.call_tool(name, arguments)
290
- if not result.isError:
291
- raise AssertionError(f"{name} unexpectedly succeeded: {result.structuredContent}")
292
- return " ".join(getattr(block, "text", "") for block in result.content)
361
+ if result.isError:
362
+ text = " ".join(getattr(block, "text", "") for block in result.content)
363
+ raise AssertionError(f"{name} returned unstructured MCP error: {text}")
364
+ structured = result.structuredContent
365
+ if not isinstance(structured, dict) or structured.get("ok") is not False:
366
+ raise AssertionError(f"{name} unexpectedly succeeded: {structured}")
367
+ error = structured.get("error")
368
+ if not isinstance(error, dict):
369
+ raise AssertionError(f"{name} did not return a structured error: {structured}")
370
+ return error
File without changes
File without changes
File without changes