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.
- {editbuffer-0.2.1/src/editbuffer.egg-info → editbuffer-0.2.2}/PKG-INFO +20 -3
- {editbuffer-0.2.1 → editbuffer-0.2.2}/README.md +19 -2
- {editbuffer-0.2.1 → editbuffer-0.2.2}/pyproject.toml +1 -1
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/mcp_server.py +169 -7
- {editbuffer-0.2.1 → editbuffer-0.2.2/src/editbuffer.egg-info}/PKG-INFO +20 -3
- {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_mcp_stdio_eval.py +137 -59
- {editbuffer-0.2.1 → editbuffer-0.2.2}/LICENSE +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/setup.cfg +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/__init__.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/blocks.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/buffer.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/cli.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/errors.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/history.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/operations.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/py.typed +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/resolver.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/selection.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer/validators.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/SOURCES.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/dependency_links.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/entry_points.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/requires.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/src/editbuffer.egg-info/top_level.txt +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_cli.py +0 -0
- {editbuffer-0.2.1 → editbuffer-0.2.2}/tests/test_editbuffer.py +0 -0
- {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.
|
|
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
|
-
|
|
202
|
+
Or install with `uvx` without a persistent environment:
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
|
-
|
|
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
|
-
|
|
184
|
+
Or install with `uvx` without a persistent environment:
|
|
185
185
|
|
|
186
186
|
```bash
|
|
187
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
202
|
+
Or install with `uvx` without a persistent environment:
|
|
203
203
|
|
|
204
204
|
```bash
|
|
205
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
"
|
|
255
|
-
{
|
|
256
|
-
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
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
|