honestcode 0.5.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.
@@ -0,0 +1,357 @@
1
+ """
2
+ HonestCode MCP Server.
3
+
4
+ A Model Context Protocol server that exposes hallucination-detection tools
5
+ so any MCP-compatible client (Cursor, Claude Desktop, VS Code, etc.) can
6
+ verify generated code against the real project structure and dependencies.
7
+
8
+ Run with:
9
+ python -m honestcode.mcp.server # stdio (default)
10
+ python -m honestcode.mcp.server --sse # SSE transport
11
+
12
+ Tool visibility is controlled by the ``HONESTCODE_TOOLS`` environment variable.
13
+ By default ``scan_file`` and ``verify_file`` are exposed; set
14
+ ``HONESTCODE_TOOLS`` to a comma-separated list of tool names (or ``all``) to
15
+ expose more:
16
+
17
+ export HONESTCODE_TOOLS=index,check_symbol,check_api,validate_types
18
+ export HONESTCODE_TOOLS=all
19
+
20
+ The SSE transport has no authentication and the sandbox is an accident guard,
21
+ not a containment boundary. Binding it to a non-loopback address therefore
22
+ requires an explicit opt-in (``HONESTCODE_ALLOW_NETWORK=1``) and should be
23
+ fronted by a real access-control layer; a remote caller with ``execute_code``
24
+ can run code as the current user.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import logging
31
+ import os
32
+ import sys
33
+ from typing import TYPE_CHECKING
34
+
35
+ from mcp.server.fastmcp import FastMCP
36
+
37
+ from honestcode.mcp import tools as _tools
38
+
39
+ if TYPE_CHECKING:
40
+ from collections.abc import Callable
41
+
42
+ logging.basicConfig(level=os.environ.get("HONESTCODE_LOG_LEVEL", "INFO"))
43
+ logger = logging.getLogger("honestcode.mcp")
44
+
45
+ mcp = FastMCP("honestcode")
46
+
47
+
48
+ def _mcp_index_project(root_path: str, force_rebuild: bool = False, watch: bool = False) -> dict:
49
+ """Build (or reuse) the symbol index for a project directory.
50
+
51
+ Call this once before other tools. Subsequent calls return a cached index
52
+ unless force_rebuild=True.
53
+
54
+ Args:
55
+ root_path: Absolute path to the project root.
56
+ force_rebuild: If True, discard the cache and rebuild the index.
57
+ watch: If True, start a background watcher that re-indexes on changes.
58
+ """
59
+ logger.info("index_project(%s, force_rebuild=%s, watch=%s)", root_path, force_rebuild, watch)
60
+ return _tools.index_project(root_path, force_rebuild=force_rebuild, watch=watch)
61
+
62
+
63
+ def _mcp_load_project_deps(root_path: str) -> dict:
64
+ """Load dependency APIs declared in requirements.txt / pyproject.toml
65
+ so library calls can be validated.
66
+
67
+ Args:
68
+ root_path: Absolute path to the project root.
69
+ """
70
+ return _tools.load_project_deps(root_path)
71
+
72
+
73
+ def _mcp_check_symbol(symbol_name: str, file_path: str = "") -> dict:
74
+ """Check whether a symbol is actually defined in the project.
75
+
76
+ Args:
77
+ symbol_name: The identifier to verify (function/class/variable name).
78
+ file_path: Optional file the symbol appears in.
79
+ """
80
+ return _tools.check_symbol(symbol_name, file_path or None)
81
+
82
+
83
+ def _mcp_check_api(api_name: str) -> dict:
84
+ """Verify a library API call is correct, e.g. pd.read_exel -> read_excel.
85
+
86
+ Args:
87
+ api_name: Fully qualified API name, e.g. "pandas.read_csv".
88
+ """
89
+ return _tools.check_api(api_name)
90
+
91
+
92
+ def _mcp_execute_code(code: str, prelude: str = "", known_names: list[str] | None = None) -> dict:
93
+ """Execute code in a sandbox and return stdout/stderr plus structured
94
+ error analysis with fix suggestions.
95
+
96
+ Args:
97
+ code: Python source to execute.
98
+ prelude: Optional setup code (imports) run before `code`.
99
+ known_names: Optional list of known symbol names for typo suggestions.
100
+ """
101
+ return _tools.execute_code(code, prelude, known_names)
102
+
103
+
104
+ def _mcp_scan_file(file_path: str) -> dict:
105
+ """Scan a file for potential hallucinations: undefined symbols, missing
106
+ imports, and incorrect API calls.
107
+
108
+ Args:
109
+ file_path: Absolute path to the Python file.
110
+ """
111
+ return _tools.scan_file(file_path)
112
+
113
+
114
+ def _mcp_verify_file(file_path: str) -> dict:
115
+ """Verify a file and return structured evidence for the agent.
116
+
117
+ Returns ``status``, ``findings`` with ``evidence``, and a ``text`` summary.
118
+
119
+ Args:
120
+ file_path: Absolute path to the Python file.
121
+ """
122
+ return _tools.verify_file(file_path)
123
+
124
+
125
+ def _mcp_load_package_apis(package_name: str) -> dict:
126
+ """Load (and cache) API signatures for a specific installed package.
127
+
128
+ Args:
129
+ package_name: Importable package name, e.g. "numpy".
130
+ """
131
+ return _tools.load_package_apis(package_name)
132
+
133
+
134
+ def _mcp_get_project_stats() -> dict:
135
+ """Return statistics about the currently indexed project."""
136
+ return _tools.get_project_stats()
137
+
138
+
139
+ def _mcp_validate_types(code: str) -> dict:
140
+ """Run a lightweight AST-based structural check on code.
141
+
142
+ Args:
143
+ code: Python source to analyze.
144
+ """
145
+ return _tools.validate_types(code)
146
+
147
+
148
+ def _mcp_find_dead_code(
149
+ entrypoints: list[str] | None = None,
150
+ ignore_patterns: list[str] | None = None,
151
+ include_tests: bool = True,
152
+ ) -> dict:
153
+ """Find symbols that appear to be unused in the indexed project.
154
+
155
+ Args:
156
+ entrypoints: Symbol names to always treat as alive (e.g. ["main", "cli"]).
157
+ ignore_patterns: Regex patterns for files to exclude from dead-code results.
158
+ include_tests: Treat symbols in test files as alive.
159
+ """
160
+ return _tools.find_dead_code(entrypoints, ignore_patterns, include_tests)
161
+
162
+
163
+ def _mcp_find_similar_code(threshold: float = 0.85) -> dict:
164
+ """Find function-level code clones across the project.
165
+
166
+ Args:
167
+ threshold: Similarity ratio above which two functions are reported (0-1).
168
+ """
169
+ return _tools.find_similar_code(threshold)
170
+
171
+
172
+ def _mcp_explore_call_graph(symbol_name: str) -> dict:
173
+ """Explore callers and callees of a symbol across the project.
174
+
175
+ Args:
176
+ symbol_name: Name of the symbol to explore.
177
+ """
178
+ return _tools.explore_call_graph(symbol_name)
179
+
180
+
181
+ def _mcp_explore_impact(symbol_name: str, max_depth: int = 3) -> dict:
182
+ """Compute the blast radius of a symbol: transitively impacted symbols/files.
183
+
184
+ Args:
185
+ symbol_name: Name of the symbol to analyze.
186
+ max_depth: Maximum number of call-graph hops to follow.
187
+ """
188
+ return _tools.explore_impact(symbol_name, max_depth)
189
+
190
+
191
+ def _mcp_stop_watching() -> dict:
192
+ """Stop the background file watcher, if any."""
193
+ return _tools.stop_watching()
194
+
195
+
196
+ def _mcp_affected_files(base: str = "HEAD", head: str | None = None, max_depth: int = 4) -> dict:
197
+ """Find files (especially tests) that may be affected by a git diff.
198
+
199
+ Args:
200
+ base: Git ref to diff against (default: HEAD).
201
+ head: Optional second ref; when given, diffs base..head.
202
+ max_depth: Maximum call-graph hops to trace.
203
+ """
204
+ return _tools.affected_files(base, head, max_depth)
205
+
206
+
207
+ def _mcp_search_code(pattern: str, glob: str = "*.py") -> dict:
208
+ """Search project source code with a regex pattern.
209
+
210
+ Args:
211
+ pattern: Regular expression to search for.
212
+ glob: Glob pattern for files to search.
213
+ """
214
+ return _tools.search_code(pattern, glob)
215
+
216
+
217
+ def _mcp_choose_tool(query: str) -> dict:
218
+ """For debugging: choose which tool a natural-language query maps to."""
219
+ return _tools.choose_tool(query)
220
+
221
+
222
+ # ── Tool registry table ────────────────────────────────────────────────
223
+ # Maps the public tool name (used by MCP clients and the HONESTCODE_TOOLS
224
+ # env var) to its wrapper function and short description.
225
+ _ALL_TOOLS: dict[str, tuple[Callable, str]] = {
226
+ "scan_file": (_mcp_scan_file, "Scan a file for undefined calls and API issues."),
227
+ "verify_file": (_mcp_verify_file, "Verify a file and return structured agent evidence."),
228
+ "index": (_mcp_index_project, "Build the project symbol index."),
229
+ "deps": (_mcp_load_project_deps, "Load dependency APIs."),
230
+ "check_symbol": (_mcp_check_symbol, "Verify a symbol is defined in the project."),
231
+ "check_api": (_mcp_check_api, "Verify a library API call exists."),
232
+ "execute_code": (_mcp_execute_code, "Run code in a sandbox."),
233
+ "validate_types": (_mcp_validate_types, "Structural type checks."),
234
+ "find_dead_code": (_mcp_find_dead_code, "Find unused symbols."),
235
+ "find_similar_code": (_mcp_find_similar_code, "Find code clones."),
236
+ "explore_call_graph": (_mcp_explore_call_graph, "Explore callers/callees."),
237
+ "explore_impact": (_mcp_explore_impact, "Blast radius of a symbol."),
238
+ "affected_files": (_mcp_affected_files, "Files/tests affected by a git diff."),
239
+ "stop_watching": (_mcp_stop_watching, "Stop the background file watcher."),
240
+ "search_code": (_mcp_search_code, "Regex search across project files."),
241
+ "load_package_apis": (_mcp_load_package_apis, "Load a package's API signatures."),
242
+ "get_project_stats": (_mcp_get_project_stats, "Return index statistics."),
243
+ "choose_tool": (_mcp_choose_tool, "Map a query to the best tool."),
244
+ }
245
+
246
+ # ``scan_file`` is always exposed; ``verify_file`` is the agent-facing tool and
247
+ # is part of the default surface. The remaining tools are opt-in.
248
+ _PRIMARY_TOOL = "scan_file"
249
+ _DEFAULT_TOOLS = frozenset({"scan_file", "verify_file"})
250
+
251
+
252
+ def _resolve_tool_whitelist() -> set[str]:
253
+ """Resolve which tool names to expose on this server.
254
+
255
+ Reads ``HONESTCODE_TOOLS``. If unset, the two verification tools
256
+ (``scan_file`` and ``verify_file``) are exposed. ``all`` exposes every
257
+ tool. Otherwise the value is treated as a comma-separated list of names;
258
+ unknown names are ignored with a warning.
259
+ """
260
+ raw = os.environ.get("HONESTCODE_TOOLS", "").strip()
261
+ if not raw:
262
+ return set(_DEFAULT_TOOLS)
263
+
264
+ if raw.lower() == "all":
265
+ return set(_ALL_TOOLS.keys())
266
+
267
+ requested = {t.strip() for t in raw.split(",") if t.strip()}
268
+ unknown = requested - set(_ALL_TOOLS.keys())
269
+ for name in unknown:
270
+ logger.warning("HONESTCODE_TOOLS: unknown tool '%s' ignored", name)
271
+ # The primary tool is always included even if not explicitly listed.
272
+ requested.add(_PRIMARY_TOOL)
273
+ return requested & set(_ALL_TOOLS.keys())
274
+
275
+
276
+ def _register_tools() -> None:
277
+ """Register the whitelisted tools into FastMCP.
278
+
279
+ Idempotent: safe to call multiple times; already-registered tools are
280
+ skipped to avoid duplicate registration errors.
281
+ """
282
+ already = set(getattr(mcp, "_registered_tool_names", set())) # type: ignore[attr-defined]
283
+ whitelist = _resolve_tool_whitelist()
284
+ for name in whitelist:
285
+ if name in already:
286
+ continue
287
+ func, _desc = _ALL_TOOLS[name]
288
+ # FastMCP infers the schema from the function signature; pass the
289
+ # public tool name explicitly so it shows up as `name`.
290
+ mcp.tool(name=name)(func)
291
+ already.add(name)
292
+ mcp._registered_tool_names = already # type: ignore[attr-defined]
293
+
294
+
295
+ def register_tools() -> None:
296
+ """Public entry point for registering the whitelisted tools.
297
+
298
+ ``scripts/run_mcp_server.py`` imports the FastMCP object directly; without
299
+ this call it used to start a server with an empty tool registry.
300
+ """
301
+ _register_tools()
302
+
303
+
304
+ def _is_loopback(host: str) -> bool:
305
+ if host in {"localhost", "::1"}:
306
+ return True
307
+ try:
308
+ import ipaddress
309
+
310
+ return ipaddress.ip_address(host).is_loopback
311
+ except ValueError:
312
+ return False
313
+
314
+
315
+ def main(argv: list[str] | None = None) -> None:
316
+ parser = argparse.ArgumentParser(
317
+ prog="honestcode-mcp",
318
+ description="HonestCode MCP server.",
319
+ )
320
+ parser.add_argument(
321
+ "--transport",
322
+ choices=["stdio", "sse"],
323
+ default="stdio",
324
+ help="MCP transport (default: stdio).",
325
+ )
326
+ parser.add_argument("--host", default="127.0.0.1", help="SSE host (default: 127.0.0.1).")
327
+ parser.add_argument("--port", type=int, default=8000, help="SSE port (default: 8000).")
328
+ args = parser.parse_args(argv)
329
+
330
+ if (
331
+ args.transport == "sse"
332
+ and not _is_loopback(args.host)
333
+ and os.environ.get("HONESTCODE_ALLOW_NETWORK") != "1"
334
+ ):
335
+ logger.error(
336
+ "Refusing to expose the unauthenticated MCP server on non-loopback host %s. "
337
+ "Set HONESTCODE_ALLOW_NETWORK=1 only behind a real access-control layer.",
338
+ args.host,
339
+ )
340
+ raise SystemExit(2)
341
+
342
+ register_tools()
343
+
344
+ whitelist = _resolve_tool_whitelist()
345
+ logger.info(
346
+ "Starting HonestCode MCP server (transport=%s, tools=%s)",
347
+ args.transport,
348
+ sorted(whitelist),
349
+ )
350
+ if args.transport == "sse":
351
+ mcp.run(transport="sse", host=args.host, port=args.port)
352
+ else:
353
+ mcp.run(transport="stdio")
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main(sys.argv[1:])