invar-tools 1.10.0__py3-none-any.whl → 1.11.0__py3-none-any.whl

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.
invar/mcp/server.py CHANGED
@@ -4,54 +4,33 @@ Invar MCP Server implementation.
4
4
  Exposes invar guard, sig, and map as first-class MCP tools.
5
5
  Part of DX-16: Agent Tool Enforcement.
6
6
  DX-52: Added Phase 2 smart re-spawn for project Python compatibility.
7
+ DX-76: Added doc_toc, doc_read, doc_find for structured document queries.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
10
11
 
11
- import json
12
12
  import os
13
- import subprocess
14
- import sys
15
13
  from pathlib import Path
16
14
  from typing import Any
17
15
 
18
16
  from mcp.server import Server
19
17
  from mcp.types import TextContent, Tool
20
18
 
19
+ from invar.mcp.handlers import (
20
+ _run_doc_delete,
21
+ _run_doc_find,
22
+ _run_doc_insert,
23
+ _run_doc_read,
24
+ _run_doc_read_many,
25
+ _run_doc_replace,
26
+ _run_doc_toc,
27
+ _run_guard,
28
+ _run_map,
29
+ _run_sig,
30
+ )
21
31
  from invar.shell.subprocess_env import should_respawn
22
32
 
23
-
24
- # @invar:allow shell_result: Pure validation helper, no I/O, returns tuple not Result
25
- # @shell_complexity: Security validation requires multiple checks
26
- def _validate_path(path: str) -> tuple[bool, str]:
27
- """Validate path argument for safety.
28
-
29
- Returns (is_valid, error_message).
30
- Rejects paths that could be interpreted as shell commands or flags.
31
- """
32
- if not path:
33
- return True, "" # Empty path defaults to "." in handlers
34
-
35
- # Reject if looks like a flag (starts with -)
36
- if path.startswith("-"):
37
- return False, f"Invalid path: cannot start with '-': {path}"
38
-
39
- # Reject shell metacharacters that could cause issues
40
- dangerous_chars = [";", "&", "|", "$", "`", "\n", "\r"]
41
- for char in dangerous_chars:
42
- if char in path:
43
- return False, f"Invalid path: contains forbidden character: {char!r}"
44
-
45
- # Try to resolve path - this catches malformed paths
46
- try:
47
- Path(path).resolve()
48
- except (OSError, ValueError) as e:
49
- return False, f"Invalid path: {e}"
50
-
51
- return True, ""
52
-
53
-
54
- # Strong instructions for agent behavior (DX-16 + DX-17 + DX-26)
33
+ # Strong instructions for agent behavior (DX-16 + DX-17 + DX-26 + DX-76)
55
34
  INVAR_INSTRUCTIONS = """
56
35
  ## Invar Tool Usage (MANDATORY)
57
36
 
@@ -77,6 +56,26 @@ Then read `.invar/examples/` and `.invar/context.md` for project context.
77
56
  | Symbolic verification | `Bash("crosshair ...")` | `invar_guard` (included by default) |
78
57
  | Understand file structure | `Read` entire .py file | `invar_sig` |
79
58
  | Find entry points | `Grep` for "def " | `invar_map` |
59
+ | View document structure | `Read` entire .md file | `invar_doc_toc` |
60
+ | Read document section | `Read` with manual line counting | `invar_doc_read` |
61
+ | Read multiple sections | Multiple `invar_doc_read` calls | `invar_doc_read_many` |
62
+ | Find sections by pattern | `Grep` in markdown files | `invar_doc_find` |
63
+
64
+ ### Document Tools (DX-76)
65
+
66
+ | I want to... | Use |
67
+ |--------------|-----|
68
+ | View document structure | `invar_doc_toc(file="path.md")` |
69
+ | Read specific section | `invar_doc_read(file="path.md", section="slug")` |
70
+ | Read multiple sections | `invar_doc_read_many(file="path.md", sections=["slug1", "slug2"])` |
71
+ | Search sections by title | `invar_doc_find(file="path.md", pattern="*auth*")` |
72
+ | Replace section content | `invar_doc_replace(file="path.md", section="slug", content="...")` |
73
+ | Insert new section | `invar_doc_insert(file="path.md", anchor="slug", content="...")` |
74
+ | Delete section | `invar_doc_delete(file="path.md", section="slug")` |
75
+
76
+ **Section addressing:** slug path (`requirements/auth`), fuzzy (`auth`), index (`#0/#1`), line (`@48`)
77
+
78
+ **Workflow:** ALWAYS call `invar_doc_toc` first to understand document structure before editing.
80
79
 
81
80
  ### Common Mistakes to AVOID
82
81
 
@@ -86,6 +85,8 @@ Then read `.invar/examples/` and `.invar/context.md` for project context.
86
85
  ❌ `Read("src/foo.py")` just to see signatures - Use invar_sig instead
87
86
  ❌ `Grep` for function definitions - Use invar_map instead
88
87
  ❌ `Bash("invar guard ...")` - Use invar_guard MCP tool instead
88
+ ❌ `Read("docs/file.md")` to understand structure - Use invar_doc_toc instead
89
+ ❌ `Grep` in markdown files - Use invar_doc_find instead
89
90
 
90
91
  ### Task Completion
91
92
 
@@ -99,6 +100,7 @@ A task is complete ONLY when:
99
100
  1. **invar_guard** = Smart Guard (static + doctests + CrossHair + Hypothesis)
100
101
  2. **invar_sig** shows @pre/@post contracts that Read misses
101
102
  3. **invar_map** includes reference counts for importance ranking
103
+ 4. **invar_doc_toc** shows document structure that Read doesn't parse
102
104
 
103
105
  ### Correct Usage Examples
104
106
 
@@ -112,6 +114,12 @@ invar_guard(changed=true)
112
114
 
113
115
  # Understand a file's structure
114
116
  invar_sig(target="src/invar/core/parser.py")
117
+
118
+ # Understand a document's structure
119
+ invar_doc_toc(file="docs/proposals/DX-76.md")
120
+
121
+ # Read specific section
122
+ invar_doc_read(file="docs/proposals/DX-76.md", section="phase-a")
115
123
  ```
116
124
 
117
125
  IMPORTANT: Using Bash commands for Invar operations bypasses
@@ -125,6 +133,7 @@ def _get_guard_tool() -> Tool:
125
133
  """Define the invar_guard tool."""
126
134
  return Tool(
127
135
  name="invar_guard",
136
+ title="Smart Guard",
128
137
  description=(
129
138
  "Smart Guard: Verify code quality with static analysis + doctests. "
130
139
  "Use this INSTEAD of Bash('pytest ...') or Bash('crosshair ...'). "
@@ -149,6 +158,7 @@ def _get_sig_tool() -> Tool:
149
158
  """Define the invar_sig tool."""
150
159
  return Tool(
151
160
  name="invar_sig",
161
+ title="Show Signatures",
152
162
  description=(
153
163
  "Show function signatures and contracts (@pre/@post). "
154
164
  "Use this INSTEAD of Read('file.py') when you want to understand structure."
@@ -169,6 +179,7 @@ def _get_map_tool() -> Tool:
169
179
  """Define the invar_map tool."""
170
180
  return Tool(
171
181
  name="invar_map",
182
+ title="Symbol Map",
172
183
  description=(
173
184
  "Symbol map with reference counts. "
174
185
  "Use this INSTEAD of Grep for 'def ' to find functions."
@@ -183,6 +194,217 @@ def _get_map_tool() -> Tool:
183
194
  )
184
195
 
185
196
 
197
+ # DX-76: Document query tools
198
+ # @shell_orchestration: MCP tool factory - creates Tool objects
199
+ # @invar:allow shell_result: MCP tool factory for doc_toc command
200
+ def _get_doc_toc_tool() -> Tool:
201
+ """Define the invar_doc_toc tool."""
202
+ return Tool(
203
+ name="invar_doc_toc",
204
+ title="Markdown TOC",
205
+ description=(
206
+ "Extract document structure (Table of Contents) from markdown files. "
207
+ "Shows headings hierarchy with line numbers and character counts. "
208
+ "Use this INSTEAD of Read() to understand markdown structure."
209
+ ),
210
+ inputSchema={
211
+ "type": "object",
212
+ "properties": {
213
+ "file": {"type": "string", "description": "Path to markdown file"},
214
+ "depth": {
215
+ "type": "integer",
216
+ "description": "Maximum heading depth to include (1-6)",
217
+ "default": 6,
218
+ },
219
+ },
220
+ "required": ["file"],
221
+ },
222
+ )
223
+
224
+
225
+ # @shell_orchestration: MCP tool factory - creates Tool objects
226
+ # @invar:allow shell_result: MCP tool factory for doc_read command
227
+ def _get_doc_read_tool() -> Tool:
228
+ """Define the invar_doc_read tool."""
229
+ return Tool(
230
+ name="invar_doc_read",
231
+ title="Read Markdown Section",
232
+ description=(
233
+ "Read a specific section from a markdown document. "
234
+ "Supports multiple addressing formats: slug path, fuzzy match, "
235
+ "index (#0/#1), or line anchor (@48). "
236
+ "Use this INSTEAD of Read() with manual line counting."
237
+ ),
238
+ inputSchema={
239
+ "type": "object",
240
+ "properties": {
241
+ "file": {"type": "string", "description": "Path to markdown file"},
242
+ "section": {
243
+ "type": "string",
244
+ "description": (
245
+ "Section path: slug ('requirements/auth'), "
246
+ "fuzzy ('auth'), index ('#0/#1'), or line ('@48')"
247
+ ),
248
+ },
249
+ },
250
+ "required": ["file", "section"],
251
+ },
252
+ )
253
+
254
+
255
+ # @shell_orchestration: MCP tool factory - creates Tool objects
256
+ # @invar:allow shell_result: MCP tool factory for doc_read_many command
257
+ def _get_doc_read_many_tool() -> Tool:
258
+ """Define the invar_doc_read_many tool."""
259
+ return Tool(
260
+ name="invar_doc_read_many",
261
+ title="Read Multiple Markdown Sections",
262
+ description=(
263
+ "Read multiple sections from a markdown document in one call. "
264
+ "Reduces tool calls by batching section reads. "
265
+ "Use this INSTEAD of multiple invar_doc_read() calls."
266
+ ),
267
+ inputSchema={
268
+ "type": "object",
269
+ "properties": {
270
+ "file": {"type": "string", "description": "Path to markdown file"},
271
+ "sections": {
272
+ "type": "array",
273
+ "items": {"type": "string"},
274
+ "description": (
275
+ "List of section paths (slug, fuzzy, index, or line anchor)"
276
+ ),
277
+ },
278
+ "include_children": {
279
+ "type": "boolean",
280
+ "description": "Include child sections in output",
281
+ "default": True,
282
+ },
283
+ },
284
+ "required": ["file", "sections"],
285
+ },
286
+ )
287
+
288
+
289
+ # @shell_orchestration: MCP tool factory - creates Tool objects
290
+ # @invar:allow shell_result: MCP tool factory for doc_find command
291
+ def _get_doc_find_tool() -> Tool:
292
+ """Define the invar_doc_find tool."""
293
+ return Tool(
294
+ name="invar_doc_find",
295
+ title="Find Markdown Sections",
296
+ description=(
297
+ "Find sections in markdown documents matching a pattern. "
298
+ "Supports glob patterns for titles and optional content search. "
299
+ "Use this INSTEAD of Grep in markdown files."
300
+ ),
301
+ inputSchema={
302
+ "type": "object",
303
+ "properties": {
304
+ "file": {"type": "string", "description": "Path to markdown file"},
305
+ "pattern": {
306
+ "type": "string",
307
+ "description": "Title pattern (glob-style, e.g., '*auth*')",
308
+ },
309
+ "content": {
310
+ "type": "string",
311
+ "description": "Optional content search pattern",
312
+ },
313
+ },
314
+ "required": ["file", "pattern"],
315
+ },
316
+ )
317
+
318
+
319
+ # DX-76 Phase A-2: Extended editing tools
320
+ # @shell_orchestration: MCP tool factory - creates Tool objects
321
+ # @invar:allow shell_result: MCP tool factory for doc_replace command
322
+ def _get_doc_replace_tool() -> Tool:
323
+ """Define the invar_doc_replace tool."""
324
+ return Tool(
325
+ name="invar_doc_replace",
326
+ title="Replace Markdown Section",
327
+ description=(
328
+ "Replace a section's content in a markdown document. "
329
+ "Use this INSTEAD of Edit()/Write() for section replacement."
330
+ ),
331
+ inputSchema={
332
+ "type": "object",
333
+ "properties": {
334
+ "file": {"type": "string", "description": "Path to markdown file"},
335
+ "section": {
336
+ "type": "string",
337
+ "description": "Section path to replace (slug, fuzzy, index, or line anchor)",
338
+ },
339
+ "content": {"type": "string", "description": "New content to replace the section with"},
340
+ "keep_heading": {
341
+ "type": "boolean",
342
+ "description": "If true, preserve the original heading line",
343
+ "default": True,
344
+ },
345
+ },
346
+ "required": ["file", "section", "content"],
347
+ },
348
+ )
349
+
350
+
351
+ # @shell_orchestration: MCP tool factory - creates Tool objects
352
+ # @invar:allow shell_result: MCP tool factory for doc_insert command
353
+ def _get_doc_insert_tool() -> Tool:
354
+ """Define the invar_doc_insert tool."""
355
+ return Tool(
356
+ name="invar_doc_insert",
357
+ title="Insert Markdown Section",
358
+ description=(
359
+ "Insert new content relative to a section in a markdown document. "
360
+ "Use this INSTEAD of Edit()/Write() for section insertion."
361
+ ),
362
+ inputSchema={
363
+ "type": "object",
364
+ "properties": {
365
+ "file": {"type": "string", "description": "Path to markdown file"},
366
+ "anchor": {
367
+ "type": "string",
368
+ "description": "Section path for the anchor (slug, fuzzy, index, or line anchor)",
369
+ },
370
+ "content": {"type": "string", "description": "Content to insert (include heading if new section)"},
371
+ "position": {
372
+ "type": "string",
373
+ "description": "Where to insert: 'before', 'after', 'first_child', 'last_child'",
374
+ "default": "after",
375
+ "enum": ["before", "after", "first_child", "last_child"],
376
+ },
377
+ },
378
+ "required": ["file", "anchor", "content"],
379
+ },
380
+ )
381
+
382
+
383
+ # @shell_orchestration: MCP tool factory - creates Tool objects
384
+ # @invar:allow shell_result: MCP tool factory for doc_delete command
385
+ def _get_doc_delete_tool() -> Tool:
386
+ """Define the invar_doc_delete tool."""
387
+ return Tool(
388
+ name="invar_doc_delete",
389
+ title="Delete Markdown Section",
390
+ description=(
391
+ "Delete a section from a markdown document. "
392
+ "Use this INSTEAD of Edit()/Write() for section deletion."
393
+ ),
394
+ inputSchema={
395
+ "type": "object",
396
+ "properties": {
397
+ "file": {"type": "string", "description": "Path to markdown file"},
398
+ "section": {
399
+ "type": "string",
400
+ "description": "Section path to delete (slug, fuzzy, index, or line anchor)",
401
+ },
402
+ },
403
+ "required": ["file", "section"],
404
+ },
405
+ )
406
+
407
+
186
408
  # @shell_orchestration: MCP server setup - registers handlers with framework
187
409
  # @invar:allow shell_result: MCP framework API returns Server
188
410
  def create_server() -> Server:
@@ -191,11 +413,37 @@ def create_server() -> Server:
191
413
 
192
414
  @server.list_tools()
193
415
  async def list_tools() -> list[Tool]:
194
- return [_get_guard_tool(), _get_sig_tool(), _get_map_tool()]
416
+ return [
417
+ _get_guard_tool(),
418
+ _get_sig_tool(),
419
+ _get_map_tool(),
420
+ # DX-76: Document query tools
421
+ _get_doc_toc_tool(),
422
+ _get_doc_read_tool(),
423
+ _get_doc_read_many_tool(), # DX-77: Batch section reading
424
+ _get_doc_find_tool(),
425
+ # DX-76 Phase A-2: Document editing tools
426
+ _get_doc_replace_tool(),
427
+ _get_doc_insert_tool(),
428
+ _get_doc_delete_tool(),
429
+ ]
195
430
 
196
431
  @server.call_tool()
197
432
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
198
- handlers = {"invar_guard": _run_guard, "invar_sig": _run_sig, "invar_map": _run_map}
433
+ handlers = {
434
+ "invar_guard": _run_guard,
435
+ "invar_sig": _run_sig,
436
+ "invar_map": _run_map,
437
+ # DX-76: Document query handlers
438
+ "invar_doc_toc": _run_doc_toc,
439
+ "invar_doc_read": _run_doc_read,
440
+ "invar_doc_read_many": _run_doc_read_many, # DX-77: Batch reading
441
+ "invar_doc_find": _run_doc_find,
442
+ # DX-76 Phase A-2: Document editing handlers
443
+ "invar_doc_replace": _run_doc_replace,
444
+ "invar_doc_insert": _run_doc_insert,
445
+ "invar_doc_delete": _run_doc_delete,
446
+ }
199
447
  handler = handlers.get(name)
200
448
  if handler:
201
449
  return await handler(arguments)
@@ -204,109 +452,6 @@ def create_server() -> Server:
204
452
  return server
205
453
 
206
454
 
207
- # @shell_orchestration: MCP handler - subprocess is called inside
208
- # @shell_complexity: Guard command with multiple optional flags
209
- # @invar:allow shell_result: MCP handler for guard tool
210
- async def _run_guard(args: dict[str, Any]) -> list[TextContent]:
211
- """Run invar guard command."""
212
- path = args.get("path", ".")
213
- is_valid, error = _validate_path(path)
214
- if not is_valid:
215
- return [TextContent(type="text", text=f"Error: {error}")]
216
-
217
- cmd = [sys.executable, "-m", "invar.shell.commands.guard", "guard"]
218
- cmd.append(path)
219
-
220
- if args.get("changed", True):
221
- cmd.append("--changed")
222
- if args.get("strict", False):
223
- cmd.append("--strict")
224
- # DX-37: Optional coverage collection
225
- if args.get("coverage", False):
226
- cmd.append("--coverage")
227
- # DX-63: Contract coverage check only
228
- if args.get("contracts_only", False):
229
- cmd.append("--contracts-only")
230
-
231
- # DX-26: TTY auto-detection - MCP runs in non-TTY, so agent JSON output is automatic
232
- # No explicit flag needed
233
-
234
- return await _execute_command(cmd)
235
-
236
-
237
- # @shell_orchestration: MCP handler - subprocess is called inside
238
- # @invar:allow shell_result: MCP handler for sig tool
239
- async def _run_sig(args: dict[str, Any]) -> list[TextContent]:
240
- """Run invar sig command."""
241
- target = args.get("target", "")
242
- if not target:
243
- return [TextContent(type="text", text="Error: target is required")]
244
-
245
- # Validate target (can be file path or file::symbol)
246
- target_path = target.split("::")[0] if "::" in target else target
247
- is_valid, error = _validate_path(target_path)
248
- if not is_valid:
249
- return [TextContent(type="text", text=f"Error: {error}")]
250
-
251
- cmd = [sys.executable, "-m", "invar.shell.commands.guard", "sig", target, "--json"]
252
- return await _execute_command(cmd)
253
-
254
-
255
- # @shell_orchestration: MCP handler - subprocess is called inside
256
- # @invar:allow shell_result: MCP handler for map tool
257
- async def _run_map(args: dict[str, Any]) -> list[TextContent]:
258
- """Run invar map command."""
259
- path = args.get("path", ".")
260
- is_valid, error = _validate_path(path)
261
- if not is_valid:
262
- return [TextContent(type="text", text=f"Error: {error}")]
263
-
264
- cmd = [sys.executable, "-m", "invar.shell.commands.guard", "map"]
265
- cmd.append(path)
266
-
267
- top = args.get("top", 10)
268
- cmd.extend(["--top", str(top)])
269
-
270
- cmd.append("--json")
271
- return await _execute_command(cmd)
272
-
273
-
274
- # @shell_complexity: Command execution with error handling branches
275
- # @invar:allow shell_result: MCP subprocess wrapper utility
276
- async def _execute_command(cmd: list[str], timeout: int = 600) -> list[TextContent]:
277
- """Execute a command and return the result.
278
-
279
- Args:
280
- cmd: Command to execute
281
- timeout: Maximum time in seconds (default: 600, accommodates full Guard cycle)
282
- """
283
- try:
284
- result = subprocess.run(
285
- cmd,
286
- capture_output=True,
287
- text=True,
288
- timeout=timeout,
289
- )
290
-
291
- output = result.stdout
292
- if result.stderr:
293
- output += f"\n\nStderr:\n{result.stderr}"
294
-
295
- # Try to parse as JSON for better formatting
296
- try:
297
- parsed = json.loads(result.stdout)
298
- output = json.dumps(parsed, indent=2)
299
- except json.JSONDecodeError:
300
- pass
301
-
302
- return [TextContent(type="text", text=output)]
303
-
304
- except subprocess.TimeoutExpired:
305
- return [TextContent(type="text", text=f"Error: Command timed out ({timeout}s)")]
306
- except Exception as e:
307
- return [TextContent(type="text", text=f"Error: {e}")]
308
-
309
-
310
455
  # @shell_orchestration: MCP server entry point - runs async server
311
456
  def run_server() -> None:
312
457
  """Run the Invar MCP server.
@@ -315,6 +460,8 @@ def run_server() -> None:
315
460
  to ensure C extensions are compatible with project's Python version.
316
461
  """
317
462
  import asyncio
463
+ import subprocess
464
+ import sys
318
465
 
319
466
  from mcp.server.stdio import stdio_server
320
467
 
@@ -324,8 +471,6 @@ def run_server() -> None:
324
471
 
325
472
  if do_respawn and project_python is not None:
326
473
  # Re-spawn with project Python (has both invar AND project deps)
327
- import subprocess
328
- import sys
329
474
 
330
475
  if os.name == "nt":
331
476
  # Windows: execv doesn't replace process, use subprocess + exit
@@ -339,7 +484,7 @@ def run_server() -> None:
339
484
  )
340
485
 
341
486
  # Phase 1 fallback: Continue with uvx + PYTHONPATH injection
342
- async def main():
487
+ async def main() -> None:
343
488
  server = create_server()
344
489
  async with stdio_server() as (read_stream, write_stream):
345
490
  await server.run(