maggraph 0.4.0__tar.gz → 0.4.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 (42) hide show
  1. {maggraph-0.4.0 → maggraph-0.4.2}/Cargo.lock +2 -2
  2. {maggraph-0.4.0 → maggraph-0.4.2}/Cargo.toml +1 -1
  3. {maggraph-0.4.0 → maggraph-0.4.2}/PKG-INFO +6 -5
  4. {maggraph-0.4.0 → maggraph-0.4.2}/README.md +5 -4
  5. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/__init__.py +1 -1
  6. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/agent/mod.rs +238 -49
  7. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/index.rs +140 -2
  8. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lib.rs +5 -3
  9. {maggraph-0.4.0 → maggraph-0.4.2}/pyproject.toml +1 -1
  10. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/Cargo.toml +0 -0
  11. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/__init__.pyi +0 -0
  12. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/benches/index_scale.rs +0 -0
  13. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/benches/traversal.rs +0 -0
  14. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/py.typed +0 -0
  15. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/batch.rs +0 -0
  16. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/config/mod.rs +0 -0
  17. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/config/schema.rs +0 -0
  18. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/error.rs +0 -0
  19. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/graph.rs +0 -0
  20. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lakehouse/cache.rs +0 -0
  21. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lakehouse/content.rs +0 -0
  22. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lakehouse/mod.rs +0 -0
  23. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lakehouse/resolver.rs +0 -0
  24. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/lakehouse/uri.rs +0 -0
  25. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/memory.rs +0 -0
  26. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/node/frontmatter.rs +0 -0
  27. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/node/mod.rs +0 -0
  28. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/python/mod.rs +0 -0
  29. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/query.rs +0 -0
  30. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/recall.rs +0 -0
  31. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/security.rs +0 -0
  32. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/sync/engine.rs +0 -0
  33. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/sync/lock.rs +0 -0
  34. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/sync/mod.rs +0 -0
  35. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/sync/policy.rs +0 -0
  36. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/sync/repo.rs +0 -0
  37. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/ui/api.rs +0 -0
  38. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/ui/assets/app.js +0 -0
  39. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/ui/assets/index.html +0 -0
  40. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/ui/assets/style.css +0 -0
  41. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/ui/mod.rs +0 -0
  42. {maggraph-0.4.0 → maggraph-0.4.2}/maggraph/src/wikilink.rs +0 -0
@@ -839,7 +839,7 @@ checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f"
839
839
 
840
840
  [[package]]
841
841
  name = "maggraph"
842
- version = "0.4.0"
842
+ version = "0.4.2"
843
843
  dependencies = [
844
844
  "axum",
845
845
  "chrono",
@@ -862,7 +862,7 @@ dependencies = [
862
862
 
863
863
  [[package]]
864
864
  name = "maggraph-cli"
865
- version = "0.4.0"
865
+ version = "0.4.2"
866
866
  dependencies = [
867
867
  "assert_cmd",
868
868
  "axum",
@@ -3,7 +3,7 @@ members = ["maggraph"]
3
3
  resolver = "2"
4
4
 
5
5
  [workspace.package]
6
- version = "0.4.0"
6
+ version = "0.4.2"
7
7
  edition = "2021"
8
8
  license = "MIT OR Apache-2.0"
9
9
  repository = "https://github.com/AlexMercedCoder/MagGraph"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: maggraph
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -173,12 +173,13 @@ remote_sources = [
173
173
  ## MCP server scaffold
174
174
 
175
175
  ```bash
176
- maggraph scaffold --mcp --output ./mcp_server
176
+ maggraph scaffold --mcp --output ./agent
177
177
  ```
178
178
 
179
- Generates a ready-to-run FastMCP server at `./mcp_server/server.py` wired to
180
- your graph index — expose `list_nodes`, `read_node`, `traverse`, `create_node`,
181
- and `delete_node` as MCP tools with one command.
179
+ Generates a ready-to-run FastMCP server at `./agent/mcp_server/server.py` wired to
180
+ your graph index — expose `list_nodes`, `get_node`, `traverse_graph`, `create_node`,
181
+ `update_node`, and `delete_node` as MCP tools with one command. Add `--read-only`
182
+ to leave out the write tools, and `--skill` to write `./agent/maggraph-skill/SKILL.md`.
182
183
 
183
184
  ---
184
185
 
@@ -136,12 +136,13 @@ remote_sources = [
136
136
  ## MCP server scaffold
137
137
 
138
138
  ```bash
139
- maggraph scaffold --mcp --output ./mcp_server
139
+ maggraph scaffold --mcp --output ./agent
140
140
  ```
141
141
 
142
- Generates a ready-to-run FastMCP server at `./mcp_server/server.py` wired to
143
- your graph index — expose `list_nodes`, `read_node`, `traverse`, `create_node`,
144
- and `delete_node` as MCP tools with one command.
142
+ Generates a ready-to-run FastMCP server at `./agent/mcp_server/server.py` wired to
143
+ your graph index — expose `list_nodes`, `get_node`, `traverse_graph`, `create_node`,
144
+ `update_node`, and `delete_node` as MCP tools with one command. Add `--read-only`
145
+ to leave out the write tools, and `--skill` to write `./agent/maggraph-skill/SKILL.md`.
145
146
 
146
147
  ---
147
148
 
@@ -50,4 +50,4 @@ __all__ = [
50
50
  "open_index",
51
51
  ]
52
52
 
53
- __version__ = "0.3.0"
53
+ __version__ = "0.4.2"
@@ -203,8 +203,62 @@ pub struct SkillRenderContext<'a> {
203
203
  pub maggraph_version: &'a str,
204
204
  }
205
205
 
206
+ /// Options shared by the `SKILL.md` and MCP scaffold generators.
207
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
208
+ #[non_exhaustive]
209
+ pub struct ScaffoldOptions {
210
+ /// Generate a read-only MCP server: omit the `create_node`, `update_node`, and
211
+ /// `delete_node` tools (and leave them out of the generated docs).
212
+ pub read_only: bool,
213
+ }
214
+
215
+ impl ScaffoldOptions {
216
+ /// Options for a read-only scaffold.
217
+ pub fn read_only() -> Self {
218
+ Self { read_only: true }
219
+ }
220
+ }
221
+
222
+ /// Default directory name, under the scaffold output directory, that holds `SKILL.md`.
223
+ pub const SKILL_DIR_NAME: &str = "maggraph-skill";
224
+
225
+ /// `requirements.txt` line for the published `maggraph` Python bindings, pinned to
226
+ /// this crate's compatible release range (same minor for 0.x, same major after 1.0).
227
+ pub fn maggraph_python_requirement() -> String {
228
+ let major: u64 = env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap_or(0);
229
+ let minor: u64 = env!("CARGO_PKG_VERSION_MINOR").parse().unwrap_or(0);
230
+ if major == 0 {
231
+ format!(
232
+ "maggraph>={major}.{minor},<{major}.{next}",
233
+ next = minor + 1
234
+ )
235
+ } else {
236
+ format!("maggraph>={major}.{minor},<{next}", next = major + 1)
237
+ }
238
+ }
239
+
240
+ const READ_TOOLS_TABLE: &str = "| `list_nodes` | All indexed node ids |
241
+ | `get_node` | Node metadata + body as Markdown |
242
+ | `traverse_graph` | BFS/DFS traversal Markdown report |
243
+ ";
244
+
245
+ const WRITE_TOOLS_TABLE: &str =
246
+ "| `create_node` | Create a new markdown node (optional write policy) |
247
+ | `update_node` | Update node body/frontmatter |
248
+ | `delete_node` | Remove a node from the index |
249
+ ";
250
+
206
251
  /// Render `SKILL.md` content from schema and context.
207
252
  pub fn render_skill_md(schema: &GraphSchema, ctx: &SkillRenderContext<'_>) -> String {
253
+ render_skill_md_with(schema, ctx, ScaffoldOptions::default())
254
+ }
255
+
256
+ /// Render `SKILL.md` content from schema, context, and scaffold options.
257
+ pub fn render_skill_md_with(
258
+ schema: &GraphSchema,
259
+ ctx: &SkillRenderContext<'_>,
260
+ options: ScaffoldOptions,
261
+ ) -> String {
208
262
  let config_line = ctx
209
263
  .config_path
210
264
  .map(|p| format!("config: \"{}\"\n", p.display()))
@@ -340,25 +394,21 @@ Async: `read_node_async`, `traverse_async` (see `planning/PYTHON.md`).
340
394
 
341
395
  ## MCP server
342
396
 
343
- When `mcp_server/` exists:
397
+ When `mcp_server/` exists in the scaffold output directory (next to this skill's
398
+ `{skill_dir}/` directory), run from that output directory:
344
399
 
345
400
  ```bash
346
- pip install -r mcp_server/requirements.txt
347
- # Install maggraph from repo: cd python && maturin develop --release --features python-ext
401
+ pip install -r mcp_server/requirements.txt # fastmcp + maggraph from PyPI
348
402
  export MAGGRAPH_CONFIG="{config_display}"
349
403
  python mcp_server/server.py
350
404
  ```
351
405
 
406
+ {server_mode}
407
+
352
408
  | Tool | Description |
353
409
  |------|-------------|
354
- | `list_nodes` | All indexed node ids |
355
- | `get_node` | Node metadata + body as Markdown |
356
- | `traverse_graph` | BFS/DFS traversal Markdown report |
357
- | `create_node` | Create a new markdown node (optional write policy) |
358
- | `update_node` | Update node body/frontmatter |
359
- | `delete_node` | Remove a node from the index |
360
-
361
- Regenerate when the graph schema changes: `maggraph scaffold --mcp --skill`.
410
+ {tools_table}
411
+ Regenerate when the graph schema changes: `maggraph scaffold --mcp --skill{read_only_flag}`.
362
412
  "#,
363
413
  version = ctx.maggraph_version,
364
414
  root = ctx.graph_root.display(),
@@ -377,9 +427,29 @@ Regenerate when the graph schema changes: `maggraph scaffold --mcp --skill`.
377
427
  .config_path
378
428
  .map(|p| p.display().to_string())
379
429
  .unwrap_or_else(|| "maggraph.toml".to_string()),
430
+ skill_dir = SKILL_DIR_NAME,
431
+ server_mode = if options.read_only {
432
+ "The server is **read-only**: it was generated with `--read-only`, so it exposes no create, update, or delete tools."
433
+ } else {
434
+ "The server is read-write. Regenerate with `--read-only` to omit the create, update, and delete tools."
435
+ },
436
+ tools_table = tools_table(options),
437
+ read_only_flag = if options.read_only {
438
+ " --read-only"
439
+ } else {
440
+ ""
441
+ },
380
442
  )
381
443
  }
382
444
 
445
+ fn tools_table(options: ScaffoldOptions) -> String {
446
+ if options.read_only {
447
+ READ_TOOLS_TABLE.to_string()
448
+ } else {
449
+ format!("{READ_TOOLS_TABLE}{WRITE_TOOLS_TABLE}")
450
+ }
451
+ }
452
+
383
453
  /// Context for MCP scaffold generation.
384
454
  #[derive(Debug, Clone)]
385
455
  pub struct McpScaffoldContext<'a> {
@@ -390,6 +460,15 @@ pub struct McpScaffoldContext<'a> {
390
460
 
391
461
  /// Write FastMCP server scaffold wired to the `maggraph` Python package.
392
462
  pub fn write_mcp_scaffold(dir: &Path, ctx: &McpScaffoldContext<'_>) -> Result<()> {
463
+ write_mcp_scaffold_with(dir, ctx, ScaffoldOptions::default())
464
+ }
465
+
466
+ /// Write FastMCP server scaffold with options (for example a read-only server).
467
+ pub fn write_mcp_scaffold_with(
468
+ dir: &Path,
469
+ ctx: &McpScaffoldContext<'_>,
470
+ options: ScaffoldOptions,
471
+ ) -> Result<()> {
393
472
  fs::create_dir_all(dir).map_err(|e| io_error(format!("create {}: {e}", dir.display())))?;
394
473
 
395
474
  let schema = ctx.schema;
@@ -441,28 +520,27 @@ Generated by `maggraph scaffold --mcp`. Uses the `maggraph` Python bindings (PyO
441
520
  ## Setup
442
521
 
443
522
  ```bash
444
- # From the MagGraph repository root (if maggraph is not on PyPI yet):
445
- cd python && maturin develop --release --features python-ext
446
-
523
+ # Installs fastmcp and the published maggraph bindings from PyPI.
447
524
  pip install -r mcp_server/requirements.txt
448
525
  export MAGGRAPH_CONFIG="{config}"
449
526
  python mcp_server/server.py
450
527
  ```
451
528
 
529
+ To run against a MagGraph source checkout instead of the PyPI release, install the
530
+ bindings from the repository's `python/` directory first
531
+ (`pip install maturin && maturin develop --release --features python-ext`) and drop
532
+ the `maggraph` line from `requirements.txt`.
533
+
452
534
  ## Tools
453
535
 
536
+ {server_mode}
537
+
454
538
  | Tool | Description |
455
539
  |------|-------------|
456
- | `list_nodes` | List indexed node ids |
457
- | `get_node` | Read node Markdown (metadata + body) |
458
- | `traverse_graph` | BFS/DFS traversal report |
459
- | `create_node` | Create a new node (id, type, body) |
460
- | `update_node` | Update node body |
461
- | `delete_node` | Delete a node by id |
540
+ {tools_table}
541
+ See `planning/MCP.md` in the MagGraph repository for deployment (stdio, Cursor, CI).
462
542
 
463
- See [`planning/MCP.md`](../../planning/MCP.md) for deployment (stdio, Cursor, CI).
464
-
465
- Regenerate when the graph changes: `maggraph scaffold --mcp --skill`.
543
+ Regenerate when the graph changes: `maggraph scaffold --mcp --skill{read_only_flag}`.
466
544
  "#,
467
545
  config = ctx.config_path.display(),
468
546
  root = ctx.graph_root.display(),
@@ -474,11 +552,28 @@ Regenerate when the graph changes: `maggraph scaffold --mcp --skill`.
474
552
  } else {
475
553
  ids_sample
476
554
  },
555
+ server_mode = if options.read_only {
556
+ "Read-only server (generated with `--read-only`): no create, update, or delete tools."
557
+ } else {
558
+ "Read-write server. Regenerate with `--read-only` to omit the create, update, and delete tools."
559
+ },
560
+ tools_table = tools_table(options),
561
+ read_only_flag = if options.read_only {
562
+ " --read-only"
563
+ } else {
564
+ ""
565
+ },
477
566
  );
478
567
 
479
- let requirements = r#"fastmcp>=2.0
480
- # maggraph: install from repo - cd python && maturin develop --release --features python-ext
481
- "#;
568
+ let requirements = format!(
569
+ "fastmcp>=2.0
570
+ {maggraph_req}
571
+ # maggraph is the MagGraph Python bindings package on PyPI (https://pypi.org/project/maggraph/).
572
+ # To use a source checkout instead, remove the line above and run, from the MagGraph
573
+ # repository's python/ directory: maturin develop --release --features python-ext
574
+ ",
575
+ maggraph_req = maggraph_python_requirement(),
576
+ );
482
577
 
483
578
  let server_py = format!(
484
579
  r##"# MagGraph FastMCP server - wired to maggraph Python bindings.
@@ -497,6 +592,8 @@ CONFIG_PATH = os.environ.get("MAGGRAPH_CONFIG", r"{config}")
497
592
  GRAPH_ROOT = r"{root}"
498
593
  NODE_TYPES = {node_types:?}
499
594
  KNOWN_NODE_IDS = {node_ids:?}
595
+ # True when generated with `maggraph scaffold --read-only` (write tools omitted).
596
+ READ_ONLY = {read_only}
500
597
 
501
598
 
502
599
  @lru_cache(maxsize=1)
@@ -527,7 +624,35 @@ def traverse_graph(from_id: str, depth: int = 2, order: str = "bfs") -> str:
527
624
  idx = _index()
528
625
  result = idx.traverse(from_id, depth, order)
529
626
  return result.to_markdown(idx)
627
+ {write_tools}
628
+
629
+ if __name__ == "__main__":
630
+ mcp.run()
631
+ "##,
632
+ config = ctx.config_path.display(),
633
+ root = ctx.graph_root.display(),
634
+ node_types = schema.node_types,
635
+ node_ids = schema.node_ids,
636
+ read_only = if options.read_only { "True" } else { "False" },
637
+ write_tools = if options.read_only {
638
+ ""
639
+ } else {
640
+ WRITE_TOOLS_PY
641
+ },
642
+ );
643
+
644
+ fs::write(dir.join("README.md"), readme)
645
+ .map_err(|e| io_error(format!("write README.md: {e}")))?;
646
+ fs::write(dir.join("requirements.txt"), requirements)
647
+ .map_err(|e| io_error(format!("write requirements.txt: {e}")))?;
648
+ fs::write(dir.join("server.py"), server_py)
649
+ .map_err(|e| io_error(format!("write server.py: {e}")))?;
650
+
651
+ Ok(())
652
+ }
530
653
 
654
+ /// FastMCP write tools appended to `server.py` unless the scaffold is read-only.
655
+ const WRITE_TOOLS_PY: &str = r#"
531
656
 
532
657
  @mcp.tool
533
658
  def create_node(
@@ -538,41 +663,23 @@ def create_node(
538
663
  ) -> str:
539
664
  """Create a new markdown node. Requires write access (local or sync leader with lock)."""
540
665
  _index().create_node(node_id, node_type, body, links)
541
- return f"created node {{node_id!r}}"
666
+ return f"created node {node_id!r}"
542
667
 
543
668
 
544
669
  @mcp.tool
545
670
  def update_node(node_id: str, body: str) -> str:
546
671
  """Replace a node's Markdown body (frontmatter preserved)."""
547
672
  _index().update_node(node_id, body)
548
- return f"updated node {{node_id!r}}"
673
+ return f"updated node {node_id!r}"
549
674
 
550
675
 
551
676
  @mcp.tool
552
677
  def delete_node(node_id: str) -> str:
553
678
  """Delete a node by id."""
554
679
  _index().delete_node(node_id)
555
- return f"deleted node {{node_id!r}}"
556
-
557
-
558
- if __name__ == "__main__":
559
- mcp.run()
560
- "##,
561
- config = ctx.config_path.display(),
562
- root = ctx.graph_root.display(),
563
- node_types = schema.node_types,
564
- node_ids = schema.node_ids,
565
- );
566
-
567
- fs::write(dir.join("README.md"), readme)
568
- .map_err(|e| io_error(format!("write README.md: {e}")))?;
569
- fs::write(dir.join("requirements.txt"), requirements)
570
- .map_err(|e| io_error(format!("write requirements.txt: {e}")))?;
571
- fs::write(dir.join("server.py"), server_py)
572
- .map_err(|e| io_error(format!("write server.py: {e}")))?;
680
+ return f"deleted node {node_id!r}"
573
681
 
574
- Ok(())
575
- }
682
+ "#;
576
683
 
577
684
  /// Write `SKILL.md` to `path`.
578
685
  pub fn write_skill_md(
@@ -580,7 +687,17 @@ pub fn write_skill_md(
580
687
  schema: &GraphSchema,
581
688
  ctx: &SkillRenderContext<'_>,
582
689
  ) -> Result<()> {
583
- let content = render_skill_md(schema, ctx);
690
+ write_skill_md_with(path, schema, ctx, ScaffoldOptions::default())
691
+ }
692
+
693
+ /// Write `SKILL.md` to `path` using scaffold options.
694
+ pub fn write_skill_md_with(
695
+ path: &Path,
696
+ schema: &GraphSchema,
697
+ ctx: &SkillRenderContext<'_>,
698
+ options: ScaffoldOptions,
699
+ ) -> Result<()> {
700
+ let content = render_skill_md_with(schema, ctx, options);
584
701
  if let Some(parent) = path.parent() {
585
702
  fs::create_dir_all(parent)
586
703
  .map_err(|e| io_error(format!("create {}: {e}", parent.display())))?;
@@ -649,5 +766,77 @@ mod tests {
649
766
  assert!(server.contains("import maggraph"));
650
767
  assert!(server.contains("def traverse_graph"));
651
768
  assert!(server.contains("def create_node"));
769
+ assert!(server.contains("def delete_node"));
770
+ assert!(server.contains("READ_ONLY = False"));
771
+ assert!(server.contains("return f\"created node {node_id!r}\""));
772
+ }
773
+
774
+ fn basic_schema() -> (PathBuf, GraphSchema) {
775
+ let root =
776
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../examples/basic/knowledge_graph");
777
+ let index = GraphIndex::open(&root).expect("open");
778
+ let schema = GraphSchema::introspect(&index).expect("introspect");
779
+ (root, schema)
780
+ }
781
+
782
+ #[test]
783
+ fn read_only_mcp_scaffold_omits_write_tools() {
784
+ let (root, schema) = basic_schema();
785
+ let tmp = tempfile::tempdir().expect("tempdir");
786
+ let mcp_dir = tmp.path().join("mcp_server");
787
+ let ctx = McpScaffoldContext {
788
+ graph_root: &root,
789
+ config_path: Path::new("maggraph.toml"),
790
+ schema: &schema,
791
+ };
792
+ write_mcp_scaffold_with(&mcp_dir, &ctx, ScaffoldOptions::read_only()).expect("write");
793
+ let server = fs::read_to_string(mcp_dir.join("server.py")).expect("server");
794
+ assert!(server.contains("def traverse_graph"));
795
+ assert!(server.contains("READ_ONLY = True"));
796
+ assert!(!server.contains("create_node"));
797
+ assert!(!server.contains("update_node"));
798
+ assert!(!server.contains("delete_node"));
799
+ let readme = fs::read_to_string(mcp_dir.join("README.md")).expect("readme");
800
+ assert!(!readme.contains("`delete_node`"));
801
+ }
802
+
803
+ #[test]
804
+ fn read_only_skill_omits_write_tools() {
805
+ let (root, schema) = basic_schema();
806
+ let ctx = SkillRenderContext {
807
+ graph_root: &root,
808
+ config_path: Some(Path::new("maggraph.toml")),
809
+ storage_mode: Some("local"),
810
+ maggraph_version: "0.4.1",
811
+ };
812
+ let rw = render_skill_md(&schema, &ctx);
813
+ assert!(rw.contains("`create_node`"));
814
+ let ro = render_skill_md_with(&schema, &ctx, ScaffoldOptions::read_only());
815
+ assert!(ro.contains("`traverse_graph`"));
816
+ assert!(!ro.contains("`create_node`"));
817
+ assert!(ro.contains("maggraph scaffold --mcp --skill --read-only"));
818
+ }
819
+
820
+ #[test]
821
+ fn requirements_pin_published_bindings_to_compatible_range() {
822
+ let req = maggraph_python_requirement();
823
+ let major = env!("CARGO_PKG_VERSION_MAJOR");
824
+ let minor = env!("CARGO_PKG_VERSION_MINOR");
825
+ assert!(
826
+ req.starts_with(&format!("maggraph>={major}.{minor},<")),
827
+ "{req}"
828
+ );
829
+
830
+ let (root, schema) = basic_schema();
831
+ let tmp = tempfile::tempdir().expect("tempdir");
832
+ let ctx = McpScaffoldContext {
833
+ graph_root: &root,
834
+ config_path: Path::new("maggraph.toml"),
835
+ schema: &schema,
836
+ };
837
+ write_mcp_scaffold(tmp.path(), &ctx).expect("write");
838
+ let requirements = fs::read_to_string(tmp.path().join("requirements.txt")).expect("req");
839
+ assert!(requirements.lines().any(|line| line == req));
840
+ assert!(requirements.contains("pypi.org/project/maggraph"));
652
841
  }
653
842
  }
@@ -36,12 +36,39 @@ impl NodeIndexEntry {
36
36
  }
37
37
  }
38
38
 
39
+ /// File names (matched case-insensitively) that are commonly documentation rather
40
+ /// than graph nodes. When one of these files does not parse as a node it is skipped
41
+ /// and reported through [`GraphIndex::skipped_files`] instead of failing the scan.
42
+ /// A file with one of these names that *is* a valid node is still indexed.
43
+ pub const NON_NODE_FILE_NAMES: &[&str] = &["SKILL.md", "README.md", "AGENTS.md", "CLAUDE.md"];
44
+
45
+ /// A markdown file under the graph root that the scan skipped because it is not a node.
46
+ #[derive(Debug, Clone, PartialEq, Eq)]
47
+ pub struct SkippedFile {
48
+ /// Path relative to the graph root.
49
+ pub relative_path: PathBuf,
50
+ /// Why the file was not indexed (the node parse error).
51
+ pub reason: String,
52
+ }
53
+
54
+ /// Whether `path` has one of the [`NON_NODE_FILE_NAMES`].
55
+ pub fn is_non_node_file_name(path: &Path) -> bool {
56
+ path.file_name()
57
+ .and_then(|name| name.to_str())
58
+ .is_some_and(|name| {
59
+ NON_NODE_FILE_NAMES
60
+ .iter()
61
+ .any(|reserved| reserved.eq_ignore_ascii_case(name))
62
+ })
63
+ }
64
+
39
65
  /// In-memory index of markdown nodes under a graph root.
40
66
  #[derive(Debug, Clone, PartialEq, Eq)]
41
67
  pub struct GraphIndex {
42
68
  root_path: PathBuf,
43
69
  by_id: HashMap<String, NodeIndexEntry>,
44
70
  by_path: HashMap<PathBuf, String>,
71
+ skipped: Vec<SkippedFile>,
45
72
  }
46
73
 
47
74
  impl GraphIndex {
@@ -71,6 +98,7 @@ impl GraphIndex {
71
98
  root_path,
72
99
  by_id: HashMap::new(),
73
100
  by_path: HashMap::new(),
101
+ skipped: Vec::new(),
74
102
  };
75
103
  index.rescan()?;
76
104
  Ok(index)
@@ -80,6 +108,7 @@ impl GraphIndex {
80
108
  pub fn rescan(&mut self) -> Result<()> {
81
109
  self.by_id.clear();
82
110
  self.by_path.clear();
111
+ self.skipped.clear();
83
112
 
84
113
  for entry in WalkDir::new(&self.root_path)
85
114
  .follow_links(false)
@@ -111,13 +140,39 @@ impl GraphIndex {
111
140
  continue;
112
141
  }
113
142
 
114
- let node = Node::from_file(path, &self.root_path)?;
143
+ let node = match Node::from_file(path, &self.root_path) {
144
+ Ok(node) => node,
145
+ Err(MagGraphError::NodeParse { message, .. })
146
+ if is_non_node_file_name(relative_path) =>
147
+ {
148
+ tracing::warn!(
149
+ path = %relative_path.display(),
150
+ reason = %message,
151
+ "skipping non-node markdown file"
152
+ );
153
+ self.skipped.push(SkippedFile {
154
+ relative_path: relative_path.to_path_buf(),
155
+ reason: message,
156
+ });
157
+ continue;
158
+ }
159
+ Err(err) => return Err(err),
160
+ };
115
161
  self.insert_entry(node.metadata, relative_path.to_path_buf(), node.body)?;
116
162
  }
117
163
 
164
+ self.skipped
165
+ .sort_by(|a, b| a.relative_path.cmp(&b.relative_path));
118
166
  Ok(())
119
167
  }
120
168
 
169
+ /// Markdown files the last full scan skipped because they are documentation
170
+ /// (see [`NON_NODE_FILE_NAMES`]) that does not parse as a node, such as a
171
+ /// generated `SKILL.md` or a `README.md` without node frontmatter.
172
+ pub fn skipped_files(&self) -> &[SkippedFile] {
173
+ &self.skipped
174
+ }
175
+
121
176
  pub fn root_path(&self) -> &Path {
122
177
  &self.root_path
123
178
  }
@@ -175,10 +230,28 @@ impl GraphIndex {
175
230
  if let Some(existing_id) = self.by_path.remove(&relative_path) {
176
231
  self.by_id.remove(&existing_id);
177
232
  }
233
+ self.skipped.retain(|s| s.relative_path != relative_path);
178
234
  return Ok(None);
179
235
  }
180
236
 
181
- let node = Node::from_file(&full_path, &self.root_path)?;
237
+ let node = match Node::from_file(&full_path, &self.root_path) {
238
+ Ok(node) => node,
239
+ Err(MagGraphError::NodeParse { message, .. })
240
+ if is_non_node_file_name(&relative_path) =>
241
+ {
242
+ if let Some(existing_id) = self.by_path.remove(&relative_path) {
243
+ self.by_id.remove(&existing_id);
244
+ }
245
+ self.skipped.retain(|s| s.relative_path != relative_path);
246
+ self.skipped.push(SkippedFile {
247
+ relative_path,
248
+ reason: message,
249
+ });
250
+ return Ok(None);
251
+ }
252
+ Err(err) => return Err(err),
253
+ };
254
+ self.skipped.retain(|s| s.relative_path != relative_path);
182
255
  let id = node.id().to_string();
183
256
  if let Some(existing) = self.by_id.get(&id) {
184
257
  if existing.relative_path != relative_path {
@@ -615,6 +688,71 @@ links: ["welcome"]
615
688
  assert!(!root.join("customer_churn_q2.md").exists());
616
689
  }
617
690
 
691
+ #[test]
692
+ fn scan_skips_and_reports_documentation_files_that_are_not_nodes() {
693
+ let temp = TempDir::new().expect("temp dir");
694
+ let root = temp.path().join("graph");
695
+ write_example_graph(&root);
696
+ // A generated SKILL.md has frontmatter but no `id`; a README has none at all.
697
+ fs::write(
698
+ root.join("SKILL.md"),
699
+ "---\nmaggraph_skill_version: \"0.4.1\"\nnode_count: 2\n---\n\n# Skill\n",
700
+ )
701
+ .expect("write skill");
702
+ fs::create_dir_all(root.join("docs")).expect("docs dir");
703
+ fs::write(root.join("docs/readme.md"), "# Notes about this graph\n").expect("readme");
704
+
705
+ let mut index = GraphIndex::open(&root).expect("open index despite non-node files");
706
+ assert_eq!(index.len(), 2);
707
+ let skipped: Vec<_> = index
708
+ .skipped_files()
709
+ .iter()
710
+ .map(|s| s.relative_path.clone())
711
+ .collect();
712
+ assert_eq!(
713
+ skipped,
714
+ vec![PathBuf::from("SKILL.md"), PathBuf::from("docs/readme.md")]
715
+ );
716
+ assert!(index.skipped_files()[0].reason.contains("id"));
717
+
718
+ // Incremental refresh of a skipped file is a no-op rather than an error.
719
+ assert_eq!(index.update_file("SKILL.md").expect("refresh skill"), None);
720
+ assert_eq!(index.skipped_files().len(), 2);
721
+ fs::remove_file(root.join("SKILL.md")).expect("remove skill");
722
+ assert_eq!(
723
+ index.update_file("SKILL.md").expect("refresh removed"),
724
+ None
725
+ );
726
+ assert_eq!(index.skipped_files().len(), 1);
727
+ }
728
+
729
+ #[test]
730
+ fn readme_that_is_a_valid_node_is_still_indexed() {
731
+ let temp = TempDir::new().expect("temp dir");
732
+ let root = temp.path().join("graph");
733
+ write_example_graph(&root);
734
+ fs::write(
735
+ root.join("README.md"),
736
+ "---\nid: graph_readme\ntype: note\n---\n\n# Readme node\n",
737
+ )
738
+ .expect("write readme");
739
+
740
+ let index = GraphIndex::open(&root).expect("open index");
741
+ assert!(index.contains("graph_readme"));
742
+ assert!(index.skipped_files().is_empty());
743
+ }
744
+
745
+ #[test]
746
+ fn ordinary_malformed_node_still_fails_the_scan() {
747
+ let temp = TempDir::new().expect("temp dir");
748
+ let root = temp.path().join("graph");
749
+ write_example_graph(&root);
750
+ fs::write(root.join("broken.md"), "---\ntype: note\n---\n\nNo id\n").expect("write");
751
+
752
+ let err = GraphIndex::open(&root).expect_err("malformed node must fail");
753
+ assert!(matches!(err, MagGraphError::NodeParse { .. }));
754
+ }
755
+
618
756
  #[test]
619
757
  fn detects_duplicate_ids_on_scan() {
620
758
  let temp = TempDir::new().expect("temp dir");
@@ -18,8 +18,10 @@ pub mod ui;
18
18
  pub mod wikilink;
19
19
 
20
20
  pub use agent::{
21
- render_skill_md, write_mcp_scaffold, write_skill_md, EdgePattern, EdgeSource, GraphSchema,
22
- McpScaffoldContext, SchemaEdge, SkillRenderContext,
21
+ maggraph_python_requirement, render_skill_md, render_skill_md_with, write_mcp_scaffold,
22
+ write_mcp_scaffold_with, write_skill_md, write_skill_md_with, EdgePattern, EdgeSource,
23
+ GraphSchema, McpScaffoldContext, ScaffoldOptions, SchemaEdge, SkillRenderContext,
24
+ SKILL_DIR_NAME,
23
25
  };
24
26
  pub use batch::{
25
27
  apply_memory_batch, validate_memory_batch, MemoryBatchOperation, MemoryBatchResult,
@@ -32,7 +34,7 @@ pub use error::{MagGraphError, Result};
32
34
  pub use graph::{
33
35
  resolve_target, traverse, GraphAdjacency, TraversalNode, TraversalOrder, TraversalResult,
34
36
  };
35
- pub use index::{GraphIndex, NodeIndexEntry};
37
+ pub use index::{GraphIndex, NodeIndexEntry, SkippedFile};
36
38
  pub use lakehouse::{
37
39
  ContentCache, ContentResolver, FileResolver, HttpResolver, LakehouseReader, NodeWithContent,
38
40
  ResolvedContent, ResolverRegistry, S3StubResolver,
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "maggraph"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "In-process graph engine for AI semantic layers — powered by Rust"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes