mcp-capdiff 0.1.1__tar.gz → 0.1.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 (108) hide show
  1. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/CHANGELOG.md +16 -0
  2. {mcp_capdiff-0.1.1/src/mcp_capdiff.egg-info → mcp_capdiff-0.1.2}/PKG-INFO +1 -1
  3. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/precision-matrix.md +2 -0
  4. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/releasing.md +1 -1
  5. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/versioning.md +1 -1
  6. mcp_capdiff-0.1.2/fixtures/filesystem_helper_restricted/server.py +32 -0
  7. mcp_capdiff-0.1.2/fixtures/local_filesystem_operations/server.py +31 -0
  8. mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/__init__.py +1 -0
  9. mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/filesystem.py +23 -0
  10. mcp_capdiff-0.1.2/fixtures/programmatic_registration/fixture_tools/state.py +3 -0
  11. mcp_capdiff-0.1.2/fixtures/programmatic_registration/server.py +10 -0
  12. mcp_capdiff-0.1.2/fixtures/sqlite_read_only/server.py +18 -0
  13. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/__init__.py +1 -1
  14. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/rules/engine.py +7 -6
  15. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/source/python.py +299 -25
  16. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2/src/mcp_capdiff.egg-info}/PKG-INFO +1 -1
  17. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/SOURCES.txt +8 -0
  18. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/scan.json +1 -1
  19. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/scan.sarif.json +1 -1
  20. mcp_capdiff-0.1.2/tests/test_real_world_regressions.py +52 -0
  21. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_reporters.py +2 -2
  22. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/LICENSE +0 -0
  23. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/MANIFEST.in +0 -0
  24. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/README.md +0 -0
  25. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/SECURITY.md +0 -0
  26. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/action.yml +0 -0
  27. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/policy.md +0 -0
  28. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/report-schema.md +0 -0
  29. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP001.md +0 -0
  30. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP002.md +0 -0
  31. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP003.md +0 -0
  32. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP004.md +0 -0
  33. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP005.md +0 -0
  34. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP007.md +0 -0
  35. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP010.md +0 -0
  36. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP016.md +0 -0
  37. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/MCP017.md +0 -0
  38. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/rules/README.md +0 -0
  39. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/docs/v0.1-scope.md +0 -0
  40. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/.github/workflows/mcp-audit.yml +0 -0
  41. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/README.md +0 -0
  42. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/mcp-audit.yaml +0 -0
  43. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/pyproject.toml +0 -0
  44. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/approval_removed/after.py +0 -0
  45. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/approval_removed/before.py +0 -0
  46. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/after.py +0 -0
  47. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/filesystem_expansion/before.py +0 -0
  48. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/after.py +0 -0
  49. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/network_allowlist_added/before.py +0 -0
  50. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/after.py +0 -0
  51. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/examples/vulnerable-fastmcp/scenarios/unrestricted_fetch/before.py +0 -0
  52. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/destructive_with_approval/server.py +0 -0
  53. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/destructive_without_approval/server.py +0 -0
  54. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/fastmcp_async_annotations/server.py +0 -0
  55. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_guard_after_read/server.py +0 -0
  56. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_restricted/server.py +0 -0
  57. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/filesystem_unrestricted/server.py +0 -0
  58. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/postgresql_read_only/server.py +0 -0
  59. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/safe_server/server.py +0 -0
  60. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sensitive_read_only/server.py +0 -0
  61. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sensitive_to_network/server.py +0 -0
  62. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_direct/server.py +0 -0
  63. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_dynamic_no_shell/server.py +0 -0
  64. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/shell_safe_allowlist/server.py +0 -0
  65. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/sql_destructive/server.py +0 -0
  66. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_allowlisted/server.py +0 -0
  67. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_arbitrary/server.py +0 -0
  68. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_guard_after_request/server.py +0 -0
  69. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/url_hostname_unguarded/server.py +0 -0
  70. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/fixtures/vulnerable_server/server.py +0 -0
  71. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/mcp-audit.yaml +0 -0
  72. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/pyproject.toml +0 -0
  73. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/setup.cfg +0 -0
  74. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/__main__.py +0 -0
  75. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/__init__.py +0 -0
  76. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/diff.py +0 -0
  77. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/analysis/scan.py +0 -0
  78. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/cli/__init__.py +0 -0
  79. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/cli/main.py +0 -0
  80. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/__init__.py +0 -0
  81. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/capability.py +0 -0
  82. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/finding.py +0 -0
  83. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/models/report.py +0 -0
  84. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/__init__.py +0 -0
  85. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/evaluator.py +0 -0
  86. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/policy/loader.py +0 -0
  87. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/__init__.py +0 -0
  88. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/json.py +0 -0
  89. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/manifest.py +0 -0
  90. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/sarif.py +0 -0
  91. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/reporters/terminal.py +0 -0
  92. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/rules/__init__.py +0 -0
  93. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/__init__.py +0 -0
  94. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_audit/scanners/source/__init__.py +0 -0
  95. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/dependency_links.txt +0 -0
  96. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/entry_points.txt +0 -0
  97. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/requires.txt +0 -0
  98. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/src/mcp_capdiff.egg-info/top_level.txt +0 -0
  99. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/diff.txt +0 -0
  100. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/manifest.yaml +0 -0
  101. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/golden/terminal.txt +0 -0
  102. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_demo_scenarios.py +0 -0
  103. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_diff.py +0 -0
  104. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_discovery.py +0 -0
  105. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_golden.py +0 -0
  106. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_policy.py +0 -0
  107. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_rule_accuracy.py +0 -0
  108. {mcp_capdiff-0.1.1 → mcp_capdiff-0.1.2}/tests/test_scan.py +0 -0
@@ -4,6 +4,22 @@ All notable changes follow Keep a Changelog. Releases use semantic versioning fo
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.2] - 2026-10-01
8
+
9
+ ### Fixed
10
+
11
+ - Discover tools registered with `mcp.tool()(function)`, including functions imported from scanned project modules; unresolved static registrations now fail closed.
12
+ - Require a concrete outbound network destination before MCP005 can classify a tool as an exfiltration sink.
13
+ - Propagate approved-root guarantees from path-validation helpers such as `_resolve(path)` to later filesystem operations.
14
+ - Classify `open(..., "w")`, directory creation, and local filesystem mutations as local writes instead of reads or external writes.
15
+
16
+ ### Verified
17
+
18
+ - `python-fastmcp-server`: 3 programmatically registered tools discovered instead of a zero-tool result.
19
+ - `sqlite-explorer-fastmcp-mcp-server`: 3 read-only tools, 0 side-effect or MCP005 findings.
20
+ - `obsidian-mcp`: helper-scoped filesystem tools recognized as allowlisted; only 3 findings remain.
21
+ - `smart_terminal_mcp`: critical execution findings preserved, write-mode evidence corrected, and no MCP005 paths remain.
22
+
7
23
  ## [0.1.1] - 2026-10-01
8
24
 
9
25
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-capdiff
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Security regression scanner for Model Context Protocol servers.
5
5
  Author: MCP Audit contributors
6
6
  License-Expression: MIT
@@ -16,3 +16,5 @@ MCP004, MCP007, and MCP010 also have paired approved/bounded and unapproved/unbo
16
16
  `postgresql_read_only` protects the semantic classifier from matching `post` inside `PostgreSQL`; `sql_destructive` proves that actual `DELETE`/`DROP` semantics still produce MCP004 and MCP010 with relevant evidence.
17
17
 
18
18
  Discovery is separately benchmarked by `fastmcp_async_annotations`, which must find both plain async `@mcp.tool()` and `@mcp.tool(annotations=ToolAnnotations(...))` decorators. Missing targets, unreadable trees, parse failures, and unexpected zero-tool scans fail closed.
19
+
20
+ Real-world regression fixtures also cover imported `mcp.tool()(function)` registration, helper-based path containment, SQLite reads, local file modes, and local directory creation. MCP005 requires an actual outbound network destination; local writes and semantic-only action names cannot become exfiltration sinks.
@@ -19,7 +19,7 @@ Install the wheel into a clean environment and verify `mcp-audit --version`, a p
19
19
 
20
20
  ## Publish
21
21
 
22
- 1. Create and push the matching Git tag, such as `v0.1.1`.
22
+ 1. Create and push the matching Git tag, such as `v0.1.2`.
23
23
  2. Publish the matching GitHub Release. This automatically runs `release.yml`.
24
24
  3. Verify `pipx install mcp-capdiff` in a clean environment.
25
25
  4. Run the tagged Action from the demo repository and confirm SARIF appears in Code Scanning.
@@ -4,7 +4,7 @@ MCP Audit maintains four independent versions:
4
4
 
5
5
  | Contract | Current | Change policy |
6
6
  | --- | --- | --- |
7
- | CLI/package | `0.1.1` | Semantic versioning; pre-1.0 minor releases may change CLI behavior |
7
+ | CLI/package | `0.1.2` | Semantic versioning; pre-1.0 minor releases may change CLI behavior |
8
8
  | Ruleset | `0.1` | Minor adds or materially changes rules; patch corrects implementation without intended semantic change |
9
9
  | JSON report schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
10
10
  | Manifest schema | `1.0` | Minor changes are additive; major changes may remove fields or change meaning |
@@ -0,0 +1,32 @@
1
+ from pathlib import Path
2
+
3
+ from fastmcp import FastMCP
4
+
5
+
6
+ ROOT = Path("vault").resolve()
7
+ mcp = FastMCP("filesystem-helper-restricted")
8
+
9
+
10
+ def _resolve(relative_path: str) -> Path:
11
+ candidate = (ROOT / relative_path).resolve()
12
+ try:
13
+ candidate.relative_to(ROOT)
14
+ except ValueError as exc:
15
+ raise ValueError("path escapes the approved root") from exc
16
+ return candidate
17
+
18
+
19
+ @mcp.tool()
20
+ def read_note(path: str) -> str:
21
+ return _resolve(path).read_text(encoding="utf-8")
22
+
23
+
24
+ @mcp.tool()
25
+ def search_content(folder: str) -> list[str]:
26
+ base = _resolve(folder)
27
+ return [path.read_text(encoding="utf-8") for path in base.rglob("*.md")]
28
+
29
+
30
+ @mcp.tool()
31
+ def delete_note(path: str) -> None:
32
+ _resolve(path).unlink()
@@ -0,0 +1,31 @@
1
+ import os
2
+
3
+ from fastmcp import FastMCP
4
+
5
+
6
+ mcp = FastMCP("local-filesystem-operations")
7
+
8
+
9
+ @mcp.tool()
10
+ def read_file(file: str) -> str:
11
+ with open(file, "r") as handle:
12
+ return handle.read()
13
+
14
+
15
+ @mcp.tool()
16
+ def write_file(file: str, content: str) -> str:
17
+ with open(file, "w") as handle:
18
+ handle.write(content)
19
+ return file
20
+
21
+
22
+ @mcp.tool()
23
+ def create_folder(folder: str) -> str:
24
+ os.makedirs(folder)
25
+ return folder
26
+
27
+
28
+ @mcp.tool()
29
+ def send_message(message: str) -> str:
30
+ """Prepare a message locally without any network destination."""
31
+ return message
@@ -0,0 +1 @@
1
+ """Functions registered as MCP tools by the fixture server."""
@@ -0,0 +1,23 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+
6
+ ALLOWED_ROOTS = [str(Path("data").resolve())]
7
+
8
+
9
+ def _validate_path(path: str) -> str:
10
+ resolved = os.path.realpath(path)
11
+ for root in ALLOWED_ROOTS:
12
+ prefix = root if root.endswith(os.sep) else root + os.sep
13
+ if resolved == root or resolved.startswith(prefix):
14
+ return resolved
15
+ raise ValueError("path is outside allowed roots")
16
+
17
+
18
+ def read_write_file(path: str, operation: Literal["read", "write"], content: str = "") -> str:
19
+ resolved = _validate_path(path)
20
+ if operation == "read":
21
+ return Path(resolved).read_text(encoding="utf-8")
22
+ Path(resolved).write_text(content, encoding="utf-8")
23
+ return resolved
@@ -0,0 +1,3 @@
1
+ def get_state() -> dict:
2
+ """Return read-only server state."""
3
+ return {"status": "ready"}
@@ -0,0 +1,10 @@
1
+ from fastmcp import FastMCP
2
+
3
+ from fixture_tools.filesystem import read_write_file
4
+ from fixture_tools.state import get_state
5
+
6
+
7
+ mcp = FastMCP("programmatic-registration")
8
+
9
+ mcp.tool()(read_write_file)
10
+ mcp.tool(name="state")(get_state)
@@ -0,0 +1,18 @@
1
+ from fastmcp import FastMCP
2
+
3
+
4
+ mcp = FastMCP("sqlite-read-only")
5
+
6
+
7
+ @mcp.tool()
8
+ def read_query(query: str) -> list[dict]:
9
+ """Execute a read-only query on the Messages database."""
10
+ cursor.execute(query)
11
+ return cursor.fetchall()
12
+
13
+
14
+ @mcp.tool()
15
+ def list_tables() -> list[str]:
16
+ """List tables from the Messages database."""
17
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
18
+ return [row[0] for row in cursor.fetchall()]
@@ -1,6 +1,6 @@
1
1
  """MCP Audit public package and independently evolving contract versions."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.1.2"
4
4
  RULESET_VERSION = "0.1"
5
5
  REPORT_SCHEMA_VERSION = "1.0"
6
6
  MANIFEST_SCHEMA_VERSION = "1.0"
@@ -115,11 +115,7 @@ def _tool_findings(tool: Tool) -> list[Finding]:
115
115
  def _cross_tool_findings(tools: list[Tool]) -> list[Finding]:
116
116
  findings: list[Finding] = []
117
117
  sensitive_sources = [tool for tool in tools if tool.capability.data_access and tool.capability.sensitivity != "public"]
118
- external_sinks = [
119
- tool
120
- for tool in tools
121
- if tool.capability.network == "unrestricted_outbound" or tool.capability.side_effect in {"external_write", "public_action"}
122
- ]
118
+ external_sinks = [tool for tool in tools if _has_external_destination(tool)]
123
119
  for source in sensitive_sources:
124
120
  for sink in external_sinks:
125
121
  if source.name == sink.name or source.context != sink.context:
@@ -152,12 +148,17 @@ def _cross_tool_findings(tools: list[Tool]) -> list[Finding]:
152
148
  ],
153
149
  path=[source.name, "agent_context", sink.name],
154
150
  data_classification=data_classes,
155
- destination=sink.capability.network_destination or sink.capability.network,
151
+ destination=sink.capability.network_destination,
156
152
  )
157
153
  )
158
154
  return findings
159
155
 
160
156
 
157
+ def _has_external_destination(tool: Tool) -> bool:
158
+ destination = tool.capability.network_destination
159
+ return tool.capability.network != "none" and bool(destination and destination != "none")
160
+
161
+
161
162
  def _evidence(tool: Tool, kinds: set[str]) -> list[Evidence]:
162
163
  return [item for item in tool.evidence if item.kind in kinds]
163
164
 
@@ -25,25 +25,76 @@ NETWORK_WRITES = {
25
25
  "httpx.post", "httpx.put", "httpx.patch", "httpx.delete",
26
26
  }
27
27
 
28
+ type FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
29
+ type SourceUnit = tuple[str, ast.Module, str, set[str], dict[str, FunctionNode]]
30
+
28
31
 
29
32
  def scan_python_sources(root: Path) -> list[Tool]:
30
33
  files = _python_files(root)
31
- tools: list[Tool] = []
34
+ units: dict[Path, SourceUnit] = {}
35
+ module_index: dict[str, Path] = {}
36
+
32
37
  for file_path in files:
33
- try:
34
- source_text = file_path.read_text(encoding="utf-8")
35
- tree = ast.parse(source_text, filename=str(file_path))
36
- except SyntaxError as exc:
37
- raise ValueError(f"cannot parse Python source {file_path}:{exc.lineno}: {exc.msg}") from exc
38
- except UnicodeDecodeError as exc:
39
- raise ValueError(f"Python source is not UTF-8: {file_path}") from exc
40
- except OSError as exc:
41
- raise OSError(exc.errno, f"cannot read Python source {file_path}: {exc.strerror}", str(file_path)) from exc
38
+ source_text, tree = _parse_source(file_path)
42
39
  context_path = file_path.name if root.is_file() else str(file_path.relative_to(root))
43
- visitor = FastMCPVisitor(file_path, source_text, context_path)
40
+ functions = {
41
+ node.name: node
42
+ for node in tree.body
43
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
44
+ }
45
+ units[file_path] = (source_text, tree, context_path, _path_guard_helpers(tree), functions)
46
+ for module_name in _module_names(root, file_path):
47
+ module_index[module_name] = file_path
48
+
49
+ tools: list[Tool] = []
50
+ for file_path, (source_text, tree, context_path, path_helpers, _) in units.items():
51
+ visitor = FastMCPVisitor(file_path, source_text, context_path, path_helpers)
44
52
  visitor.visit(tree)
45
53
  tools.extend(visitor.tools)
46
- return tools
54
+
55
+ for file_path, unit in units.items():
56
+ source_text, tree, context_path, _, _ = unit
57
+ for node in ast.walk(tree):
58
+ if not isinstance(node, ast.Call):
59
+ continue
60
+ registration = _tool_registration(node)
61
+ if registration is None:
62
+ continue
63
+ decorator, target = registration
64
+ resolved = _resolve_registered_function(target, file_path, units, module_index, root)
65
+ if resolved is None:
66
+ snippet = ast.get_source_segment(source_text, node) or ast.unparse(node)
67
+ raise ValueError(f"cannot resolve registered MCP tool in {file_path}:{node.lineno}: {snippet}")
68
+ target_file, function = resolved
69
+ target_source, _, target_context, target_helpers, _ = units[target_file]
70
+ tools.append(
71
+ _tool_from_function(
72
+ target_file,
73
+ target_source,
74
+ target_context,
75
+ function,
76
+ path_helpers=target_helpers,
77
+ registered_decorator=decorator,
78
+ registered_context=_registered_tool_context(context_path, decorator),
79
+ )
80
+ )
81
+
82
+ unique: dict[tuple[str, str, str], Tool] = {}
83
+ for tool in tools:
84
+ unique[(tool.source, tool.context, tool.name)] = tool
85
+ return list(unique.values())
86
+
87
+
88
+ def _parse_source(file_path: Path) -> tuple[str, ast.Module]:
89
+ try:
90
+ source_text = file_path.read_text(encoding="utf-8")
91
+ return source_text, ast.parse(source_text, filename=str(file_path))
92
+ except SyntaxError as exc:
93
+ raise ValueError(f"cannot parse Python source {file_path}:{exc.lineno}: {exc.msg}") from exc
94
+ except UnicodeDecodeError as exc:
95
+ raise ValueError(f"Python source is not UTF-8: {file_path}") from exc
96
+ except OSError as exc:
97
+ raise OSError(exc.errno, f"cannot read Python source {file_path}: {exc.strerror}", str(file_path)) from exc
47
98
 
48
99
 
49
100
  def _python_files(root: Path) -> list[Path]:
@@ -73,16 +124,138 @@ def _python_files(root: Path) -> list[Path]:
73
124
  return sorted(files)
74
125
 
75
126
 
127
+ def _module_names(root: Path, file_path: Path) -> set[str]:
128
+ relative = Path(file_path.name) if root.is_file() else file_path.relative_to(root)
129
+ parts = list(relative.with_suffix("").parts)
130
+ if parts and parts[-1] == "__init__":
131
+ parts.pop()
132
+ if not parts:
133
+ return set()
134
+ names = {".".join(parts)}
135
+ if parts[0] in {"src", "lib"} and len(parts) > 1:
136
+ names.add(".".join(parts[1:]))
137
+ return names
138
+
139
+
140
+ def _tool_registration(node: ast.Call) -> tuple[ast.Call, ast.AST] | None:
141
+ if not isinstance(node.func, ast.Call) or not node.args:
142
+ return None
143
+ decorator = node.func
144
+ target = decorator.func
145
+ if isinstance(target, ast.Attribute) and target.attr == "tool":
146
+ return decorator, node.args[0]
147
+ if isinstance(target, ast.Name) and target.id in {"tool", "mcp_tool"}:
148
+ return decorator, node.args[0]
149
+ return None
150
+
151
+
152
+ def _resolve_registered_function(
153
+ target: ast.AST,
154
+ registration_file: Path,
155
+ units: dict[Path, SourceUnit],
156
+ module_index: dict[str, Path],
157
+ root: Path,
158
+ ) -> tuple[Path, FunctionNode] | None:
159
+ _, tree, _, _, local_functions = units[registration_file]
160
+ if isinstance(target, ast.Name) and target.id in local_functions:
161
+ return registration_file, local_functions[target.id]
162
+
163
+ current_modules = _module_names(root, registration_file)
164
+ current_module = min(current_modules, key=lambda value: (value.count("."), len(value)), default="")
165
+ imported_functions: dict[str, tuple[str, str]] = {}
166
+ module_aliases: dict[str, str] = {}
167
+ for statement in tree.body:
168
+ if isinstance(statement, ast.ImportFrom):
169
+ module_name = _absolute_import_module(current_module, statement.module, statement.level)
170
+ for alias in statement.names:
171
+ imported_functions[alias.asname or alias.name] = (module_name, alias.name)
172
+ elif isinstance(statement, ast.Import):
173
+ for alias in statement.names:
174
+ module_aliases[alias.asname or alias.name.split(".")[0]] = alias.name
175
+
176
+ module_name = ""
177
+ function_name = ""
178
+ if isinstance(target, ast.Name) and target.id in imported_functions:
179
+ module_name, function_name = imported_functions[target.id]
180
+ elif isinstance(target, ast.Attribute):
181
+ qualified = _call_name(target)
182
+ if "." in qualified:
183
+ owner, function_name = qualified.rsplit(".", 1)
184
+ module_name = module_aliases.get(owner, owner)
185
+ if not module_name or not function_name:
186
+ return None
187
+ target_file = module_index.get(module_name)
188
+ if target_file is None:
189
+ suffix_matches = {
190
+ path
191
+ for indexed_name, path in module_index.items()
192
+ if indexed_name.endswith(f".{module_name}")
193
+ }
194
+ if len(suffix_matches) == 1:
195
+ target_file = suffix_matches.pop()
196
+ if target_file is None:
197
+ return None
198
+ function = units[target_file][4].get(function_name)
199
+ return (target_file, function) if function is not None else None
200
+
201
+
202
+ def _absolute_import_module(current_module: str, imported_module: str | None, level: int) -> str:
203
+ if level == 0:
204
+ return imported_module or ""
205
+ package = current_module.split(".")[:-1]
206
+ keep = max(0, len(package) - (level - 1))
207
+ prefix = package[:keep]
208
+ if imported_module:
209
+ prefix.extend(imported_module.split("."))
210
+ return ".".join(prefix)
211
+
212
+
213
+ def _registered_tool_context(context_path: str, decorator: ast.Call) -> str:
214
+ target = decorator.func
215
+ if isinstance(target, ast.Attribute) and target.attr == "tool":
216
+ owner = _call_name(target.value) or "mcp"
217
+ return f"{context_path}:{owner}"
218
+ return context_path
219
+
220
+
221
+ def _path_guard_helpers(tree: ast.Module) -> set[str]:
222
+ helpers: set[str] = set()
223
+ for node in tree.body:
224
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
225
+ continue
226
+ text = ast.unparse(node).lower()
227
+ has_resolution = any(token in text for token in {".resolve(", "realpath(", "abspath("})
228
+ has_boundary_check = any(token in text for token in {"relative_to(", "is_relative_to("}) or (
229
+ ".startswith(" in text
230
+ and any(token in text for token in {"root", "vault", "base", "directory", "workspace", "allowed"})
231
+ )
232
+ has_rejection = any(isinstance(child, ast.Raise) for child in ast.walk(node)) or any(
233
+ token in text for token in {"outside allowed", "path_violation", "path escapes", "return none"}
234
+ )
235
+ if has_resolution and has_boundary_check and has_rejection:
236
+ helpers.add(node.name)
237
+ return helpers
238
+
239
+
76
240
  class FastMCPVisitor(ast.NodeVisitor):
77
- def __init__(self, file_path: Path, source_text: str, context_path: str) -> None:
241
+ def __init__(self, file_path: Path, source_text: str, context_path: str, path_helpers: set[str]) -> None:
78
242
  self.file_path = file_path
79
243
  self.source_text = source_text
80
244
  self.context_path = context_path
245
+ self.path_helpers = path_helpers
81
246
  self.tools: list[Tool] = []
82
247
 
83
248
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
84
249
  if _is_tool_function(node):
85
- self.tools.append(_tool_from_function(self.file_path, self.source_text, self.context_path, node))
250
+ self.tools.append(
251
+ _tool_from_function(
252
+ self.file_path,
253
+ self.source_text,
254
+ self.context_path,
255
+ node,
256
+ path_helpers=self.path_helpers,
257
+ )
258
+ )
86
259
  self.generic_visit(node)
87
260
 
88
261
  visit_AsyncFunctionDef = visit_FunctionDef
@@ -103,11 +276,15 @@ def _tool_from_function(
103
276
  source_text: str,
104
277
  context_path: str,
105
278
  node: ast.FunctionDef | ast.AsyncFunctionDef,
279
+ *,
280
+ path_helpers: set[str] | None = None,
281
+ registered_decorator: ast.Call | None = None,
282
+ registered_context: str | None = None,
106
283
  ) -> Tool:
107
- name = _tool_name(node)
284
+ name = _tool_name(node, registered_decorator)
108
285
  description = ast.get_docstring(node) or ""
109
286
  parameters = [_parameter(arg, node) for arg in node.args.args if arg.arg not in {"self", "cls"}]
110
- analyzer = FunctionAnalyzer(file_path, source_text, parameters)
287
+ analyzer = FunctionAnalyzer(file_path, source_text, parameters, path_helpers or set())
111
288
  analyzer.visit(node)
112
289
  capability = Capability()
113
290
  lower_text = f"{name.lower()} {description.lower()}"
@@ -132,7 +309,7 @@ def _tool_from_function(
132
309
  capability.network_destination = analyzer.network_destination
133
310
  if analyzer.network_write:
134
311
  capability.side_effect = "external_write"
135
- if _looks_external_write(lower_text):
312
+ if _looks_external_write(name):
136
313
  capability.side_effect = "external_write"
137
314
  analyzer.evidence.append(
138
315
  Evidence(
@@ -168,7 +345,7 @@ def _tool_from_function(
168
345
  return Tool(
169
346
  name=name,
170
347
  source=f"{file_path}:{node.lineno}",
171
- context=_tool_context(context_path, node),
348
+ context=registered_context or _tool_context(context_path, node),
172
349
  description=description,
173
350
  parameters=parameters,
174
351
  capability=capability,
@@ -177,10 +354,17 @@ def _tool_from_function(
177
354
 
178
355
 
179
356
  class FunctionAnalyzer(ast.NodeVisitor):
180
- def __init__(self, file_path: Path, source_text: str, parameters: list[Parameter]) -> None:
357
+ def __init__(
358
+ self,
359
+ file_path: Path,
360
+ source_text: str,
361
+ parameters: list[Parameter],
362
+ path_helpers: set[str],
363
+ ) -> None:
181
364
  self.file_path = file_path
182
365
  self.source_text = source_text
183
366
  self.parameter_names = {parameter.name for parameter in parameters}
367
+ self.path_helpers = path_helpers
184
368
  self.aliases: dict[str, set[str]] = {}
185
369
  self.host_guarded_parameters: set[str] = set()
186
370
  self.path_guarded_parameters: set[str] = set()
@@ -192,6 +376,7 @@ class FunctionAnalyzer(ast.NodeVisitor):
192
376
  self.network_call = False
193
377
  self.network_write = False
194
378
  self.path_guard_lines: list[int] = []
379
+ self.inline_path_guard_lines: list[int] = []
195
380
  self.host_guard_lines: list[int] = []
196
381
  self.filesystem_operation_lines: list[int] = []
197
382
  self.network_call_lines: list[int] = []
@@ -206,8 +391,31 @@ class FunctionAnalyzer(ast.NodeVisitor):
206
391
  self.aliases[target.id] = roots
207
392
  self.generic_visit(node)
208
393
 
394
+ def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
395
+ roots = self._parameter_roots(node.value)
396
+ if isinstance(node.target, ast.Name) and roots:
397
+ self.aliases[node.target.id] = roots
398
+ self.generic_visit(node)
399
+
400
+ def visit_For(self, node: ast.For) -> None:
401
+ roots = self._parameter_roots(node.iter)
402
+ if isinstance(node.target, ast.Name) and roots:
403
+ self.aliases[node.target.id] = roots
404
+ self.generic_visit(node)
405
+
209
406
  def visit_Call(self, node: ast.Call) -> None:
210
407
  call_name = _call_name(node.func)
408
+ if call_name.rsplit(".", 1)[-1] in self.path_helpers:
409
+ roots = set().union(*(self._parameter_roots(argument) for argument in node.args))
410
+ roots.update(
411
+ set().union(*(self._parameter_roots(keyword.value) for keyword in node.keywords))
412
+ if node.keywords
413
+ else set()
414
+ )
415
+ if roots:
416
+ self.path_guard_lines.append(node.lineno)
417
+ self.path_guarded_parameters.update(roots)
418
+ self._record(node, f"Tool input is constrained by approved-root helper {call_name}(...).", "filesystem_guard")
211
419
  if call_name in {"eval", "exec", "compile"}:
212
420
  self.arbitrary_code = True
213
421
  self._record(node, f"Dynamic {call_name}(...) executes model-controlled code.", "execution")
@@ -218,13 +426,43 @@ class FunctionAnalyzer(ast.NodeVisitor):
218
426
  elif call_name.startswith("subprocess.") and _has_model_controlled_executable(node, self._parameter_roots):
219
427
  self.shell_execution = True
220
428
  self._record(node, f"{call_name}(...) uses a model-controlled executable or interpreter payload.", "execution")
221
- if call_name in {"open", "read_text", "read_bytes", "Path.open", "Path.read_text", "Path.read_bytes"} or call_name.endswith((".read_text", ".read_bytes")):
429
+ open_access = _file_open_access(node, call_name)
430
+ if open_access is not None:
431
+ reads, writes = open_access
432
+ if reads:
433
+ self.filesystem_read = True
434
+ self._record(node, "A filesystem read is reachable from this MCP tool.", "filesystem")
435
+ if writes:
436
+ self.filesystem_write = True
437
+ self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
438
+ self.filesystem_operation_lines.append(node.lineno)
439
+ self._mark_inline_path_guard(node)
440
+ if call_name in {"read_text", "read_bytes", "Path.read_text", "Path.read_bytes"} or call_name.endswith((".read_text", ".read_bytes")):
222
441
  self.filesystem_read = True
223
442
  self.filesystem_operation_lines.append(node.lineno)
443
+ self._mark_inline_path_guard(node)
224
444
  self._record(node, "A filesystem read is reachable from this MCP tool.", "filesystem")
225
445
  if call_name in {"write_text", "write_bytes", "Path.write_text", "Path.write_bytes"} or call_name.endswith((".write_text", ".write_bytes")):
226
446
  self.filesystem_write = True
227
447
  self.filesystem_operation_lines.append(node.lineno)
448
+ self._mark_inline_path_guard(node)
449
+ self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
450
+ if call_name in {
451
+ "os.mkdir",
452
+ "os.makedirs",
453
+ "os.remove",
454
+ "os.unlink",
455
+ "os.rename",
456
+ "os.replace",
457
+ "shutil.copy",
458
+ "shutil.copy2",
459
+ "shutil.copyfile",
460
+ "shutil.move",
461
+ "shutil.rmtree",
462
+ } or call_name.endswith((".mkdir", ".unlink", ".rename", ".replace")):
463
+ self.filesystem_write = True
464
+ self.filesystem_operation_lines.append(node.lineno)
465
+ self._mark_inline_path_guard(node)
228
466
  self._record(node, "A filesystem write is reachable from this MCP tool.", "filesystem")
229
467
  if call_name.endswith((".fetchone", ".fetchall", ".fetchmany")) or _is_select_query(node, call_name):
230
468
  self.database_read = True
@@ -264,7 +502,23 @@ class FunctionAnalyzer(ast.NodeVisitor):
264
502
 
265
503
  @property
266
504
  def path_guard(self) -> bool:
267
- return _guard_precedes_operations(self.path_guard_lines, self.filesystem_operation_lines)
505
+ return bool(self.path_guard_lines and self.filesystem_operation_lines) and all(
506
+ any(guard < operation for guard in self.path_guard_lines)
507
+ or operation in self.inline_path_guard_lines
508
+ for operation in self.filesystem_operation_lines
509
+ )
510
+
511
+ def _mark_inline_path_guard(self, operation: ast.Call) -> None:
512
+ for child in ast.walk(operation):
513
+ if not isinstance(child, ast.Call) or child is operation:
514
+ continue
515
+ call_name = _call_name(child.func)
516
+ if call_name.rsplit(".", 1)[-1] not in self.path_helpers:
517
+ continue
518
+ roots = set().union(*(self._parameter_roots(argument) for argument in child.args))
519
+ if roots:
520
+ self.inline_path_guard_lines.append(operation.lineno)
521
+ self.path_guarded_parameters.update(roots)
268
522
 
269
523
  def _parameter_roots(self, node: ast.AST | None) -> set[str]:
270
524
  if node is None:
@@ -295,8 +549,9 @@ class FunctionAnalyzer(ast.NodeVisitor):
295
549
  )
296
550
 
297
551
 
298
- def _tool_name(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
299
- for decorator in node.decorator_list:
552
+ def _tool_name(node: ast.FunctionDef | ast.AsyncFunctionDef, registered_decorator: ast.Call | None = None) -> str:
553
+ decorators = ([registered_decorator] if registered_decorator is not None else []) + list(node.decorator_list)
554
+ for decorator in decorators:
300
555
  if isinstance(decorator, ast.Call):
301
556
  for keyword in decorator.keywords:
302
557
  if keyword.arg == "name" and isinstance(keyword.value, ast.Constant):
@@ -339,6 +594,22 @@ def _keyword_value(node: ast.Call, name: str) -> ast.AST | None:
339
594
  return next((keyword.value for keyword in node.keywords if keyword.arg == name), None)
340
595
 
341
596
 
597
+ def _file_open_access(node: ast.Call, call_name: str) -> tuple[bool, bool] | None:
598
+ if call_name != "open" and not call_name.endswith(".open"):
599
+ return None
600
+ mode_index = 1 if call_name == "open" else 0
601
+ mode_node = node.args[mode_index] if len(node.args) > mode_index else _keyword_value(node, "mode")
602
+ if mode_node is None:
603
+ mode = "r"
604
+ elif isinstance(mode_node, ast.Constant) and isinstance(mode_node.value, str):
605
+ mode = mode_node.value
606
+ else:
607
+ return True, True
608
+ writes = any(flag in mode for flag in "wax") or "+" in mode
609
+ reads = "r" in mode or "+" in mode or not writes
610
+ return reads, writes
611
+
612
+
342
613
  def _has_model_controlled_executable(node: ast.Call, roots_for) -> bool:
343
614
  if not node.args:
344
615
  return False
@@ -397,7 +668,7 @@ def _is_host_allowlist_guard(node: ast.AST) -> bool:
397
668
  def _is_path_root_guard(node: ast.AST) -> bool:
398
669
  text = ast.unparse(node).lower()
399
670
  return (".parents" in text or "relative_to(" in text or "is_relative_to(" in text) and any(
400
- token in text for token in {"root", "base", "directory", "workspace"}
671
+ token in text for token in {"root", "vault", "base", "directory", "workspace", "allowed"}
401
672
  )
402
673
 
403
674
 
@@ -434,7 +705,10 @@ def _highest_sensitivity(data_classes: set[str]) -> str:
434
705
 
435
706
 
436
707
  def _looks_external_write(text: str) -> bool:
437
- return bool(_semantic_tokens(text) & {"send", "email", "slack", "webhook", "publish", "post", "message"})
708
+ return bool(
709
+ _semantic_tokens(text)
710
+ & {"send", "email", "slack", "webhook", "publish", "post", "message", "notify", "upload"}
711
+ )
438
712
 
439
713
 
440
714
  def _looks_destructive(text: str) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mcp-capdiff
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Security regression scanner for Model Context Protocol servers.
5
5
  Author: MCP Audit contributors
6
6
  License-Expression: MIT
@@ -38,9 +38,15 @@ fixtures/destructive_with_approval/server.py
38
38
  fixtures/destructive_without_approval/server.py
39
39
  fixtures/fastmcp_async_annotations/server.py
40
40
  fixtures/filesystem_guard_after_read/server.py
41
+ fixtures/filesystem_helper_restricted/server.py
41
42
  fixtures/filesystem_restricted/server.py
42
43
  fixtures/filesystem_unrestricted/server.py
44
+ fixtures/local_filesystem_operations/server.py
43
45
  fixtures/postgresql_read_only/server.py
46
+ fixtures/programmatic_registration/server.py
47
+ fixtures/programmatic_registration/fixture_tools/__init__.py
48
+ fixtures/programmatic_registration/fixture_tools/filesystem.py
49
+ fixtures/programmatic_registration/fixture_tools/state.py
44
50
  fixtures/safe_server/server.py
45
51
  fixtures/sensitive_read_only/server.py
46
52
  fixtures/sensitive_to_network/server.py
@@ -48,6 +54,7 @@ fixtures/shell_direct/server.py
48
54
  fixtures/shell_dynamic_no_shell/server.py
49
55
  fixtures/shell_safe_allowlist/server.py
50
56
  fixtures/sql_destructive/server.py
57
+ fixtures/sqlite_read_only/server.py
51
58
  fixtures/url_allowlisted/server.py
52
59
  fixtures/url_arbitrary/server.py
53
60
  fixtures/url_guard_after_request/server.py
@@ -88,6 +95,7 @@ tests/test_diff.py
88
95
  tests/test_discovery.py
89
96
  tests/test_golden.py
90
97
  tests/test_policy.py
98
+ tests/test_real_world_regressions.py
91
99
  tests/test_reporters.py
92
100
  tests/test_rule_accuracy.py
93
101
  tests/test_scan.py
@@ -74,7 +74,7 @@
74
74
  }
75
75
  ],
76
76
  "versions": {
77
- "cli": "0.1.1",
77
+ "cli": "0.1.2",
78
78
  "rules": "0.1"
79
79
  }
80
80
  }
@@ -81,7 +81,7 @@
81
81
  }
82
82
  }
83
83
  ],
84
- "semanticVersion": "0.1.1"
84
+ "semanticVersion": "0.1.2"
85
85
  }
86
86
  }
87
87
  }
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from mcp_audit.analysis.scan import scan_path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+
12
+
13
+ def test_programmatic_imported_tools_are_discovered() -> None:
14
+ report = scan_path(ROOT / "fixtures/programmatic_registration", require_tools=True)
15
+ assert {tool.name for tool in report.tools} == {"read_write_file", "state"}
16
+ assert {tool.context for tool in report.tools} == {"server.py:mcp"}
17
+ filesystem_tool = next(tool for tool in report.tools if tool.name == "read_write_file")
18
+ assert filesystem_tool.capability.filesystem == "allowlisted_files"
19
+
20
+
21
+ def test_unresolved_programmatic_registration_fails_closed(tmp_path: Path) -> None:
22
+ (tmp_path / "server.py").write_text(
23
+ "from fastmcp import FastMCP\nmcp = FastMCP()\nmcp.tool()(missing_tool)\n",
24
+ encoding="utf-8",
25
+ )
26
+ with pytest.raises(ValueError, match="cannot resolve registered MCP tool"):
27
+ scan_path(tmp_path, require_tools=True)
28
+
29
+
30
+ def test_path_containment_helper_bounds_filesystem_tools() -> None:
31
+ report = scan_path(ROOT / "fixtures/filesystem_helper_restricted")
32
+ assert {tool.capability.filesystem for tool in report.tools} == {"allowlisted_files"}
33
+ assert not {"MCP002", "MCP007"} & {finding.rule_id for finding in report.findings}
34
+
35
+
36
+ def test_local_writes_are_not_external_sinks() -> None:
37
+ report = scan_path(ROOT / "fixtures/local_filesystem_operations")
38
+ tools = {tool.name: tool for tool in report.tools}
39
+ assert tools["read_file"].capability.side_effect == "none"
40
+ assert tools["write_file"].capability.side_effect == "local_write"
41
+ assert tools["write_file"].capability.data_access == set()
42
+ assert tools["create_folder"].capability.side_effect == "local_write"
43
+ assert tools["send_message"].capability.side_effect == "external_write"
44
+ assert "MCP005" not in {finding.rule_id for finding in report.findings}
45
+ write_evidence = [item.message for item in tools["write_file"].evidence if item.kind == "filesystem"]
46
+ assert write_evidence == ["A filesystem write is reachable from this MCP tool."]
47
+
48
+
49
+ def test_sqlite_reads_are_not_writes_or_exfiltration_sinks() -> None:
50
+ report = scan_path(ROOT / "fixtures/sqlite_read_only")
51
+ assert all(tool.capability.side_effect == "none" for tool in report.tools)
52
+ assert not {"MCP004", "MCP005"} & {finding.rule_id for finding in report.findings}
@@ -15,7 +15,7 @@ def test_json_schema_has_stable_version_and_structured_evidence() -> None:
15
15
  payload = scan_path(ROOT / "fixtures/url_arbitrary").as_dict()
16
16
  assert payload["schema_version"] == "1.0"
17
17
  assert payload["report_type"] == "scan"
18
- assert payload["versions"] == {"cli": "0.1.1", "rules": "0.1"}
18
+ assert payload["versions"] == {"cli": "0.1.2", "rules": "0.1"}
19
19
  evidence = payload["findings"][0]["evidence"][0]
20
20
  assert set(evidence) == {"kind", "message", "location", "snippet"}
21
21
  assert payload["tools"][0]["context"] == "server.py:mcp"
@@ -31,7 +31,7 @@ def test_terminal_finding_answers_risk_and_remediation() -> None:
31
31
  def test_sarif_contains_source_region_snippet_and_help() -> None:
32
32
  payload = json.loads(render_sarif(scan_path(ROOT / "fixtures/url_arbitrary")))
33
33
  run = payload["runs"][0]
34
- assert run["tool"]["driver"]["semanticVersion"] == "0.1.1"
34
+ assert run["tool"]["driver"]["semanticVersion"] == "0.1.2"
35
35
  assert run["tool"]["driver"]["properties"]["rulesVersion"] == "0.1"
36
36
  result = next(item for item in run["results"] if item["ruleId"] == "MCP003")
37
37
  region = result["locations"][0]["physicalLocation"]["region"]
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