From 273aded1cb9695f8d213e8707aa8e33dc6fec310 Mon Sep 17 00:00:00 2001 From: Steve Pelech Date: Thu, 12 Nov 2026 15:43:53 -0600 Subject: [PATCH] feat(mcp): add Code Mode (execute_script) with a sandboxed V8 engine An agent that traces a problem makes many small tool calls that depend on each other. Each round trip costs time and tokens. Code Mode runs one agent-written async JavaScript body that calls the tools on the server and returns only the final result. The design follows Code Mode in model-context-gateway: `script`/`code` and `arguments`/`args`; `tools.(args)`, `cc.callTool(name, args)`, and the gateway forms `tools.contextcortex.` and `mcg.callTool`; parsed JSON results; a tool error throws a JavaScript Error; console logs; the result envelope {result, logs, executionTimeMs, toolCallsCount}; no recursive execute_script; and limits on time (30 s), tool calls (50), and memory (64 MB), set with CODE_MODE_* environment variables. The sandbox is a new V8 isolate per run (mini-racer 0.14.1). The script reaches Python only through one async bridge function, and values cross as JSON strings. Every tool call goes through the normal handler, so its role check applies. The resource contextcortex://code-mode/sdk.d.ts gives one typed declaration per tool, made from the tool schemas. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- REQUIREMENTS.md | 19 ++- app/mcp/handlers/__init__.py | 6 + app/mcp/handlers/script_handlers.py | 141 +++++++++++++++++ app/mcp/tools.py | 20 ++- app/services/script_sandbox.py | 171 +++++++++++++++++++++ app/services/script_sdk_types.py | 110 +++++++++++++ contextcortex.spec | 1 + docs/architecture/mcp-protocol.md | 2 +- docs/guide/index.md | 2 +- docs/index.md | 6 +- docs/mcp-tools-and-resources.md | 7 +- docs/reference/mcp-tools.md | 37 ++++- docs/requirements/functional.md | 4 +- docs/requirements/index.md | 2 +- docs/requirements/verification.md | 2 +- python-constraints.txt | 1 + requirements.txt | 1 + tests/test_execute_script.py | 141 +++++++++++++++++ tests/test_mcp_pagination_and_resources.py | 4 +- 20 files changed, 663 insertions(+), 18 deletions(-) create mode 100644 app/mcp/handlers/script_handlers.py create mode 100644 app/services/script_sandbox.py create mode 100644 app/services/script_sdk_types.py create mode 100644 tests/test_execute_script.py diff --git a/README.md b/README.md index a1d83e6..a00618b 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ ContextCortex is a multi-repository Model Context Protocol (MCP) server providin See the full **[MCP Tools, Resources, and Pagination Reference](docs/mcp-tools-and-resources.md)** for complete schemas, [pagination conventions](docs/mcp-tools-and-resources.md#pagination-conventions), [tool parameter bounds](docs/mcp-tools-and-resources.md#mcp-tools-reference), [resource templates](docs/mcp-tools-and-resources.md#mcp-resources-and-templates), and [workflow prompts](docs/mcp-tools-and-resources.md#mcp-workflow-prompts). -### Tools & RBAC Permissions (19 Tools) +### Tools & RBAC Permissions (20 Tools) | Tool | Role | Key Parameters & Bounds | Description | | :--- | :---: | :--- | :--- | @@ -89,12 +89,14 @@ See the full **[MCP Tools, Resources, and Pagination Reference](docs/mcp-tools-a | `summarize_file` | `viewer` | `path`, `repo`, `force_refresh` (`False`) | Retrieves a cached summary or generates a structured LLM executive file summary. | | `grep_code` | `viewer` | `pattern`, `repo`, `path_glob`, `is_regex`, `case_sensitive`, `context_lines` (`0..10`), `max_results`/`limit` (`1..100`), `offset` | Literal/regex search with AST attribution; truncates lines `> 400` chars cleanly. | | `diagnose_issue` | `viewer` | `issue_text`, `repo`, `context_lines` (`1..15`), `limit` (`1..20`) | Forensic triage resolving stack traces, error literals, routes, and semantic candidates. | +| `execute_script` | `viewer` (each tool call keeps its own role check) | `script`/`code`, `arguments`/`args` | Code Mode: one sandboxed JavaScript script chains several tool calls and returns only the final result (limits: `30` s, `50` tool calls, `64` MB). | ### Resources & Paginated Resource Templates | Resource URI / Template | MIME Type | Pagination / Scope | Description | | :--- | :---: | :---: | :--- | | `knowledge://catalog/summary` | `application/json` | Compact (`<= 40` sample files) | Global catalog summary plus `resource_templates` discovery metadata. | +| `contextcortex://code-mode/sdk.d.ts` | `text/plain` | Single | TypeScript declarations of every tool that an `execute_script` script can call. | | `knowledge://repo/{repo}/summary` | `application/json` | Per-repo | Status, commit SHA, language breakdown, symbol/route/ADR counts, and sub-resource URIs. | | `knowledge://repo/{repo}/architecture` | `application/json` | Per-repo | Synthesized architecture overview (manifests, entry points, modules, hub symbols). | | `knowledge://repo/{repo}/files/{page}` | `application/json` | `100` files / page | 1-indexed paginated file inventory (`total_files`, `total_pages`, `has_more`, `next_uri`). | diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index ce17591..4666d6a 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1199 Automated Tests** (835 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1212 Automated Tests** (848 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). --- @@ -1069,6 +1069,21 @@ code results are enriched with signature/kind/ast_symbol_id from SQLite._ - `test_all_frontend_and_script_files_under_500_loc` - _Verify every TypeScript, TSX, CSS, shell, and PowerShell file in source/tests/scripts stays <= 500 LOC._ - `test_semantic_naming_bans_junk_drawer_modules_and_classes` - _Verify case-insensitively that no new *Manager, *Helper, *Util, *Utils, or *Data modules/classes exist._ +#### `tests/test_execute_script.py` (13 tests) +- `test_script_calls_tools_and_returns_a_small_result` +- `test_gateway_form_and_call_tool_work` +- `test_tool_errors_throw_and_can_be_caught` +- `test_script_errors_report_script_line_numbers` +- `test_no_return_gives_null` +- `test_limits_stop_loops_memory_and_tool_fan_out` +- `test_host_is_not_reachable_from_the_script` +- `test_dispatch_checks_tool_names_and_arguments` +- `test_execute_script_runs_real_tools_end_to_end` +- `test_execute_script_reports_failures_with_logs` +- `test_execute_script_rejects_empty_input` +- `test_limits_come_from_the_environment` +- `test_sdk_declarations_from_tool_schemas` + #### `tests/test_field_workup_regressions.py` (9 tests) - `test_finding_1_git_env_strips_ambient_git_vars` - `test_finding_1_shallow_clone_stages_fresh_clone_on_fetch_failure` @@ -1184,7 +1199,7 @@ and leaves the prior indexed state intact without data loss._ - `test_get_local_storage_service_singleton` #### `tests/test_mcp_pagination_and_resources.py` (4 tests) -- `test_all_19_mcp_tools_have_rich_schemas_and_clean_reexports` - _Verifies all 19 tools expose rich descriptions, parameter Field docs, and backward-compatible imports._ +- `test_all_19_mcp_tools_have_rich_schemas_and_clean_reexports` - _Verifies all 20 tools expose rich descriptions, parameter Field docs, and backward-compatible imports._ - `test_symbol_outline_trace_and_route_pagination` - _Verifies limit clamping and offset pagination across find_symbol, get_file_outline, trace_path, find_routes, and find_api_callers._ - `test_grep_search_recent_changes_adr_ingested_and_read_file_bounds` - _Verifies line/snippet truncation and pagination for grep_code, search_code/docs, get_recent_changes, manage_adr, what_is_ingested, and read_file._ - `test_granular_mcp_resources_and_paginated_templates` - _Verifies compact catalog summary and all 5 per-repository MCP resource templates end-to-end._ diff --git a/app/mcp/handlers/__init__.py b/app/mcp/handlers/__init__.py index a3c50c0..8174478 100644 --- a/app/mcp/handlers/__init__.py +++ b/app/mcp/handlers/__init__.py @@ -48,6 +48,10 @@ from app.mcp.handlers.diagnostic_handlers import ( handle_diagnose_issue, ) +from app.mcp.handlers.script_handlers import ( + handle_execute_script, + handle_code_mode_sdk_resource, +) __all__ = [ "handle_search_code", @@ -80,4 +84,6 @@ "handle_summarize_file", "handle_grep_code", "handle_diagnose_issue", + "handle_execute_script", + "handle_code_mode_sdk_resource", ] diff --git a/app/mcp/handlers/script_handlers.py b/app/mcp/handlers/script_handlers.py new file mode 100644 index 0000000..c6dc909 --- /dev/null +++ b/app/mcp/handlers/script_handlers.py @@ -0,0 +1,141 @@ +""" +Code Mode for ContextCortex: the `execute_script` tool and the `contextcortex://code-mode/sdk.d.ts` resource. + +Same pattern as Code Mode in model-context-gateway: the agent sends one async JavaScript body; the script calls +ContextCortex tools in a sandbox and returns only the final, small result. Every tool call from a script goes +through the normal tool handler, so the normal permission checks apply to each call. +""" + +import inspect +import json +import logging +import os +from typing import Annotated, Any, Dict, Optional + +from pydantic import Field + +from app.services.auth import ForbiddenError, Role, enforce_tool_permission +from app.services.script_sandbox import ScriptLimits, ScriptToolError, code_mode_available, run_script + +logger = logging.getLogger("contextcortex.mcp.code_mode") + +TOOL_NAME = "execute_script" +SDK_URI = "contextcortex://code-mode/sdk.d.ts" +MAX_SCRIPT_CHARS = 50000 +MAX_RESULT_CHARS = 100000 + + +def _env_number(name: str, default: float, low: float, high: float) -> float: + try: + value = float(os.getenv(name, default)) + except ValueError: + value = default + return max(low, min(high, value)) + + +def code_mode_limits() -> ScriptLimits: + """Limits from the environment (same defaults and ranges as model-context-gateway).""" + return ScriptLimits( + timeout_seconds=_env_number("CODE_MODE_TIMEOUT_SECONDS", 30, 1, 300), + max_tool_calls=int(_env_number("CODE_MODE_MAX_TOOL_CALLS", 50, 1, 500)), + max_memory_bytes=int(_env_number("CODE_MODE_MAX_MEMORY_MB", 64, 8, 512)) * 1024 * 1024, + ) + + +def code_mode_enabled() -> bool: + return os.getenv("CODE_MODE_ENABLED", "true").strip().lower() not in ("0", "false", "no", "off") + + +def _tool_handlers() -> Dict[str, Any]: + from app.mcp.tools import _TOOL_DEFINITIONS + + return {name: handler for name, handler, _desc in _TOOL_DEFINITIONS if name != TOOL_NAME} + + +async def call_tool_from_script(name: str, tool_args: Dict[str, Any]) -> Any: + """Runs one tool for a script: checks the name and arguments, calls the handler, and unboxes the JSON result.""" + if name == TOOL_NAME: + raise ScriptToolError("Recursive execute_script calls are not permitted.") + handlers = _tool_handlers() + handler = handlers.get(name) + if handler is None: + raise ScriptToolError(f"Unknown tool '{name}'. Tools: {', '.join(sorted(handlers))}") + params = inspect.signature(handler).parameters + unknown = sorted(set(tool_args) - set(params)) + if unknown: + raise ScriptToolError(f"Unknown arguments for {name}: {', '.join(unknown)}. Valid: {', '.join(params)}") + missing = [p for p, spec in params.items() if spec.default is inspect.Parameter.empty and p not in tool_args] + if missing: + raise ScriptToolError(f"Missing arguments for {name}: {', '.join(missing)}") + raw = await handler(**tool_args) + value = raw + if isinstance(raw, str): + try: + value = json.loads(raw) + except ValueError: + return raw + if isinstance(value, dict) and value.get("error"): + message = value.get("message") + raise ScriptToolError(f"{name}: {value['error']}" + (f": {message}" if message else "")) + return value + + +def _response(payload: Dict[str, Any]) -> str: + text = json.dumps(payload, separators=(",", ":"), default=str) + if len(text) <= MAX_RESULT_CHARS: + return text + preview = json.dumps(payload.get("result"), separators=(",", ":"), default=str)[:MAX_RESULT_CHARS] + return json.dumps({**payload, "result": None, "truncated": True, "resultPreview": preview, + "hint": "Return less data: select fields, slice lists, or summarize in the script."}, + separators=(",", ":")) + + +async def handle_execute_script( + script: Annotated[Optional[str], Field(description=( + "Async JavaScript body. It runs inside an async function, so top-level await and return work. " + "Call tools with `await tools.(args)` (for example `await tools.trace_path({symbol: 'A.B', direction: 'callers'})`) " + "or `await cc.callTool('', args)`; `tools.contextcortex.` also works. Results are parsed JSON; " + "a tool error throws an Error. Return only what you need. Types: resource " + SDK_URI + "."))] = None, + arguments: Annotated[Optional[Dict[str, Any]], Field(description="Optional input object, available in the script as `args` (and `arguments`).")] = None, + code: Annotated[Optional[str], Field(description="Alias for script.")] = None, + args: Annotated[Optional[Dict[str, Any]], Field(description="Alias for arguments.")] = None, +) -> str: + """Runs several ContextCortex tool calls in one sandboxed JavaScript script and returns only the final result.""" + try: + enforce_tool_permission(Role.VIEWER) + if not code_mode_enabled(): + return json.dumps({"error": "code_mode_disabled", "message": "Code Mode is turned off (CODE_MODE_ENABLED=false)."}) + if not code_mode_available(): + return json.dumps({"error": "code_mode_unavailable", "message": "Code Mode needs the mini-racer package."}) + source = script if script is not None else code + if not source or not source.strip(): + return json.dumps({"error": "invalid_argument", "message": "script (or code) must be a non-empty string."}) + if len(source) > MAX_SCRIPT_CHARS: + return json.dumps({"error": "invalid_argument", "message": f"script is longer than {MAX_SCRIPT_CHARS} characters."}) + input_args = arguments if arguments is not None else (args or {}) + if not isinstance(input_args, dict): + return json.dumps({"error": "invalid_argument", "message": "arguments (or args) must be an object."}) + + outcome = await run_script(source, input_args, call_tool_from_script, code_mode_limits()) + payload: Dict[str, Any] = { + "logs": outcome.logs, + "executionTimeMs": outcome.execution_time_ms, + "toolCallsCount": outcome.tool_calls_count, + } + if outcome.ok: + return _response({"result": outcome.result, **payload}) + return _response({"error": "script_failed", "message": outcome.error, **payload}) + except ForbiddenError as fe: + return json.dumps({"error": "forbidden", "message": str(fe)}) + except Exception as e: + logger.error(f"execute_script failed: {e}") + return json.dumps({"error": "script_failed", "message": str(e)}) + + +async def handle_code_mode_sdk_resource() -> str: + """TypeScript declarations of every tool that a Code Mode script can call.""" + from app.mcp.mcp_server import mcp_server + from app.services.script_sdk_types import build_sdk_declarations + + tools = [t for t in mcp_server._tool_manager.list_tools() if t.name != TOOL_NAME] + return build_sdk_declarations(tools) diff --git a/app/mcp/tools.py b/app/mcp/tools.py index ddfc5d5..fbaa604 100644 --- a/app/mcp/tools.py +++ b/app/mcp/tools.py @@ -49,6 +49,8 @@ handle_summarize_file, handle_grep_code, handle_diagnose_issue, + handle_execute_script, + handle_code_mode_sdk_resource, ) logger = logging.getLogger("contextcortex.mcp") @@ -168,6 +170,15 @@ f"Use for forensic triage of stack traces, error logs, or failing HTTP routes across {_SUPPORTED_LANGS}. " "Resolves stack frames (context_lines 1..15), error literals, API routes, and semantic candidates (limit 1..20).", ), + ( + "execute_script", + handle_execute_script, + "Code Mode: use to chain several ContextCortex tool calls in ONE call. Send an async JavaScript body; it calls tools with " + "`await tools.(args)` (results are parsed JSON, a tool error throws) and returns only what you need. " + "Example: `const r = await tools.find_routes({query: 'publish'}); const t = await tools.trace_path({symbol: r.routes[0].handler, " + "direction: 'callees', depth: 3}); return t.callees.map(c => c.symbol);`. Typed signatures: resource contextcortex://code-mode/sdk.d.ts. " + "Limits: 30 s, 50 tool calls, 64 MB (CODE_MODE_* settings). Returns {result, logs, executionTimeMs, toolCallsCount}.", + ), ] _RESOURCE_TEMPLATE_DEFINITIONS = [ @@ -205,7 +216,7 @@ def register_mcp_tools_and_resources(server=None): - """Register all 19 tools, compact catalog resource, 5 per-repo resource templates, and 5 prompts.""" + """Register all 20 tools, the catalog and Code Mode SDK resources, 5 per-repo resource templates, and 5 prompts.""" if server is None: from app.mcp.mcp_server import mcp_server server = mcp_server @@ -223,6 +234,13 @@ def register_mcp_tools_and_resources(server=None): description="Compact catalog of indexed repositories, symbol totals, sample files, and granular per-repo resource template URIs.", mime_type="application/json", )(handle_catalog_summary) + if "contextcortex://code-mode/sdk.d.ts" not in existing_resources: + server.resource( + "contextcortex://code-mode/sdk.d.ts", + name="code_mode_sdk", + description="TypeScript declarations of every tool that an execute_script (Code Mode) script can call, with argument docs.", + mime_type="text/plain", + )(handle_code_mode_sdk_resource) try: existing_templates = {str(t.uri_template) for t in server._resource_manager.list_templates()} diff --git a/app/services/script_sandbox.py b/app/services/script_sandbox.py new file mode 100644 index 0000000..6c350a3 --- /dev/null +++ b/app/services/script_sandbox.py @@ -0,0 +1,171 @@ +""" +Code Mode sandbox: runs an agent-written JavaScript body in a disposable V8 isolate (mini-racer). + +The design follows Code Mode in model-context-gateway (`execute_script`, Jint): one new engine per run, +no host access other than the tool bridge, top-level `await` and `return`, `console` logs, automatic +JSON unboxing of tool results, and limits on time, memory, and the number of tool calls. + +The isolate can reach Python only through two injected async functions: the tool bridge and the log sink. +Values cross the boundary as JSON strings, so no Python object is exposed to JavaScript. +""" + +import asyncio +import json +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional + +MAX_LOG_ENTRIES = 100 +MAX_LOG_CHARS = 2000 + + +class ScriptToolError(Exception): + """A tool called from a script failed; the script receives it as a JavaScript Error.""" + + +@dataclass +class ScriptLimits: + timeout_seconds: float = 30.0 + max_tool_calls: int = 50 + max_memory_bytes: int = 64 * 1024 * 1024 + + +@dataclass +class ScriptOutcome: + ok: bool + result: Any = None + error: Optional[str] = None + logs: List[str] = field(default_factory=list) + execution_time_ms: int = 0 + tool_calls_count: int = 0 + + +def code_mode_available() -> bool: + try: + import py_mini_racer # noqa: F401 + except ImportError: + return False + return True + + +# Runs before the script. `__cc_bridge` is the only host function; logs stay in the isolate until the end. +_PRELUDE = r""" +globalThis.args = JSON.parse(__cc_args_json); +globalThis.arguments = globalThis.args; +globalThis.__cc_logs = []; +const __cc_fmt = (v) => { if (typeof v === "string") { return v; } try { return JSON.stringify(v); } catch (e) { return String(v); } }; +const __cc_push = (prefix, a) => { + if (__cc_logs.length < %(max_logs)d) { __cc_logs.push((prefix + a.map(__cc_fmt).join(" ")).slice(0, %(max_chars)d)); } +}; +globalThis.console = { + log: (...a) => __cc_push("", a), + info: (...a) => __cc_push("", a), + warn: (...a) => __cc_push("[WARN] ", a), + error: (...a) => __cc_push("[ERROR] ", a), +}; +globalThis.cc = { + callTool: async (name, toolArgs) => { + const reply = JSON.parse(await __cc_bridge(String(name), JSON.stringify(toolArgs === undefined ? {} : toolArgs))); + if (reply.error !== undefined) { throw new Error(reply.error); } + return reply.value; + }, +}; +globalThis.mcg = globalThis.cc; +const __cc_tool_proxy = (prefix) => new Proxy({}, { + get: (_t, prop) => { + if (typeof prop !== "string" || prop === "then") { return undefined; } + if (prop === "contextcortex" && prefix === "") { return __cc_tool_proxy("contextcortex"); } + return async (toolArgs) => cc.callTool(prop, toolArgs); + }, +}); +globalThis.tools = __cc_tool_proxy(""); +""" % {"max_logs": MAX_LOG_ENTRIES, "max_chars": MAX_LOG_CHARS} + + +def _wrap(script: str) -> str: + # The body runs inside an async arrow function, so top-level `await` and `return` work (as in MCG). + # The script starts on line 1 of the evaluated code, so line numbers in errors match the script. + return "(async () => {" + script + "\n})().then((v) => JSON.stringify(v === undefined ? null : v))" + + +_REJECTED_PREFIX = "JavaScript rejected promise with reason: " + + +def _clean_error(exc: BaseException) -> str: + """The JavaScript error text without the mini-racer prefix, limited to the first lines of the stack.""" + text = str(exc).strip() or type(exc).__name__ + if text.startswith(_REJECTED_PREFIX): + text = text[len(_REJECTED_PREFIX):] + lines = [line for line in text.splitlines() if line.strip()] + return "\n".join(lines[:4]) + + +async def run_script( + script: str, + script_args: Dict[str, Any], + call_tool: Callable[[str, Dict[str, Any]], Awaitable[Any]], + limits: ScriptLimits, +) -> ScriptOutcome: + """Runs `script` and returns its result, logs, time, and tool call count. Never raises for script errors.""" + from py_mini_racer import JSOOMException, JSTimeoutException, mini_racer + + started = time.perf_counter() + logs: List[str] = [] + calls = [0] + + async def bridge(name: Any, args_json: Any) -> str: + calls[0] += 1 + if calls[0] > limits.max_tool_calls: + return json.dumps({"error": f"Maximum tool calls limit ({limits.max_tool_calls}) exceeded in a single script execution"}) + try: + tool_args = json.loads(args_json) if args_json else {} + if not isinstance(tool_args, dict): + raise ScriptToolError("tool arguments must be an object") + value = await call_tool(str(name), tool_args) + except ScriptToolError as e: + return json.dumps({"error": str(e)}) + except Exception as e: # a tool raised: the script gets a JavaScript Error + return json.dumps({"error": f"{name} failed: {e}"}) + return json.dumps({"value": value}, default=str) + + def outcome(ok: bool, result: Any = None, error: Optional[str] = None) -> ScriptOutcome: + return ScriptOutcome(ok=ok, result=result, error=error, logs=logs, + execution_time_ms=int((time.perf_counter() - started) * 1000), + tool_calls_count=min(calls[0], limits.max_tool_calls)) + + deadline = started + limits.timeout_seconds + + def remaining() -> float: + return max(0.1, deadline - time.perf_counter()) + + with mini_racer() as ctx: + ctx.set_hard_memory_limit(int(limits.max_memory_bytes)) + async with ctx.wrap_py_function(bridge) as js_bridge: + global_this = ctx.eval("this") + global_this["__cc_bridge"] = js_bridge + global_this["__cc_args_json"] = json.dumps(script_args or {}, default=str) + try: + await asyncio.wait_for(ctx.eval_cancelable(_PRELUDE), timeout=remaining()) + promise = await asyncio.wait_for(ctx.eval_cancelable(_wrap(script)), timeout=remaining()) + text = await asyncio.wait_for(promise, timeout=remaining()) if hasattr(promise, "__await__") else promise + result = outcome(True, result=json.loads(text) if isinstance(text, str) else text) + except (asyncio.TimeoutError, JSTimeoutException): + result = outcome(False, error=f"Script timed out after {limits.timeout_seconds:g} seconds") + except JSOOMException: + result = outcome(False, error=f"Memory limit of {limits.max_memory_bytes // (1024 * 1024)} MB exceeded") + except Exception as e: # syntax errors, uncaught JavaScript errors, rejected promises + result = outcome(False, error=_clean_error(e)) + if result.ok and ctx.was_hard_memory_limit_reached(): + result = outcome(False, error=f"Memory limit of {limits.max_memory_bytes // (1024 * 1024)} MB exceeded") + logs.extend(_read_logs(ctx)) + result.logs = logs + return result + + +def _read_logs(ctx: Any) -> List[str]: + """Reads the console entries that the script left in the isolate (empty if the isolate cannot answer).""" + try: + raw = ctx.eval("JSON.stringify(globalThis.__cc_logs || [])") + return [str(entry) for entry in json.loads(raw)][:MAX_LOG_ENTRIES] + except Exception: + return [] diff --git a/app/services/script_sdk_types.py b/app/services/script_sdk_types.py new file mode 100644 index 0000000..623f244 --- /dev/null +++ b/app/services/script_sdk_types.py @@ -0,0 +1,110 @@ +""" +TypeScript declarations for Code Mode scripts (the `contextcortex://code-mode/sdk.d.ts` resource). + +Like `router://code-mode/sdk.d.ts` in model-context-gateway: one typed function per tool, made from the tool's +JSON Schema, with the tool and argument descriptions as JSDoc. Agents read it before they write a script. +""" + +import json +import re +from typing import Any, Dict, Iterable, List + +_IDENTIFIER = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") + +HEADER = """\ +/** + * ContextCortex Code Mode (execute_script). + * Call tools with `await tools.(args)` or `await cc.callTool("", args)`. + * `tools.contextcortex.(args)` also works, so a script runs unchanged through model-context-gateway. + * Results are parsed JSON. A tool error throws an Error (use try/catch). + * Globals: `args` / `arguments` (input object), `console.log/warn/error` (returned in `logs`). + * Top-level `await` and `return` work. The value you `return` is the result. + */ +""" + + +def _doc(text: str, indent: str) -> List[str]: + lines = [line.rstrip() for line in (text or "").strip().splitlines() if line.strip()] + if not lines: + return [] + safe = [line.replace("*/", "* /") for line in lines] + return [f"{indent}/**"] + [f"{indent} * {line}" for line in safe] + [f"{indent} */"] + + +def schema_to_ts(schema: Any, defs: Dict[str, Any], depth: int = 0) -> str: + """A TypeScript type for a JSON Schema fragment (objects, arrays, enums, unions, $ref).""" + if not isinstance(schema, dict) or depth > 6: + return "any" + if "$ref" in schema: + ref = str(schema["$ref"]).rsplit("/", 1)[-1] + return schema_to_ts(defs.get(ref, {}), defs, depth + 1) + if "enum" in schema: + return " | ".join(json.dumps(v) for v in schema["enum"]) or "any" + if "const" in schema: + return json.dumps(schema["const"]) + for key in ("anyOf", "oneOf"): + if key in schema: + parts = [] + for option in schema[key]: + ts = schema_to_ts(option, defs, depth + 1) + if ts not in parts: + parts.append(ts) + return " | ".join(parts) or "any" + kind = schema.get("type") + if isinstance(kind, list): + return " | ".join(schema_to_ts({**schema, "type": k}, defs, depth + 1) for k in kind) + if kind in ("string",): + return "string" + if kind in ("integer", "number"): + return "number" + if kind == "boolean": + return "boolean" + if kind == "null": + return "null" + if kind == "array": + item = schema_to_ts(schema.get("items", {}), defs, depth + 1) + return f"({item})[]" if " " in item else f"{item}[]" + if kind == "object" or "properties" in schema: + props = schema.get("properties") or {} + if not props: + return "Record" + required = set(schema.get("required") or []) + fields = [] + for name, prop in props.items(): + key = name if _IDENTIFIER.match(name) else json.dumps(name) + fields.append(f"{key}{'' if name in required else '?'}: {schema_to_ts(prop, defs, depth + 1)}") + return "{ " + "; ".join(fields) + " }" + return "any" + + +def _args_type(schema: Dict[str, Any], indent: str) -> List[str]: + defs = schema.get("$defs") or schema.get("definitions") or {} + props = schema.get("properties") or {} + required = set(schema.get("required") or []) + lines = ["{"] + for name, prop in props.items(): + lines += _doc((prop or {}).get("description", ""), indent + " ") + key = name if _IDENTIFIER.match(name) else json.dumps(name) + lines.append(f"{indent} {key}{'' if name in required else '?'}: {schema_to_ts(prop, defs)};") + lines.append(f"{indent}}}") + return lines + + +def build_sdk_declarations(tools: Iterable[Any]) -> str: + """`declare const tools: {...}` with one async function per tool (objects with name, description, parameters).""" + lines = [HEADER, "declare const args: Record;", "declare const tools: {"] + for tool in sorted(tools, key=lambda t: t.name): + schema = getattr(tool, "parameters", None) or {} + lines += _doc(getattr(tool, "description", "") or "", " ") + has_required = bool(schema.get("required")) + arg_lines = _args_type(schema, " ") + arg_lines[0] = f" {tool.name}(args{'' if has_required else '?'}: {arg_lines[0]}" + arg_lines[-1] = arg_lines[-1] + "): Promise;" + lines += arg_lines + lines += [ + " contextcortex: Omit;", + "};", + "declare const cc: { callTool(name: string, args?: Record): Promise };", + "", + ] + return "\n".join(lines) diff --git a/contextcortex.spec b/contextcortex.spec index 972dc68..9c73010 100644 --- a/contextcortex.spec +++ b/contextcortex.spec @@ -58,6 +58,7 @@ for pkg in ( "mcp", "pymupdf", "fitz", + "py_mini_racer", # Code Mode: V8 library (mini_racer.dll) and ICU data, loaded from _MEIPASS/py_mini_racer ): try: tmp_ret = collect_all(pkg) diff --git a/docs/architecture/mcp-protocol.md b/docs/architecture/mcp-protocol.md index 3b2715a..383b6a9 100644 --- a/docs/architecture/mcp-protocol.md +++ b/docs/architecture/mcp-protocol.md @@ -30,7 +30,7 @@ flowchart TD end subgraph FastMCPEngine["FastMCP 2.0 Engine"] - Tools["19 MCP Tools"] + Tools["20 MCP Tools"] Resources["Dynamic Catalog Resources"] Prompts["Guided System Prompts"] end diff --git a/docs/guide/index.md b/docs/guide/index.md index 51eaafa..56f1f29 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -55,4 +55,4 @@ ContextCortex natively supports corporate and restricted-network environments: - **[Restricted-Network Builds](/guide/enterprise-build)**: Configure private CA certificates, internal package mirrors (`APT_MIRROR`, `PIP_INDEX_URL`, `NPM_REGISTRY`), and verified local image overlays. - **[User Guide](/guide/user-guide)**: Explore all 9 Web Admin Dashboard modules with step-by-step instructions and screenshots. - **[System Architecture](/architecture/)**: Review component diagrams, AST ingestion pipelines, MCP protocol flows, and database ERDs. -- **[Reference Manual](/reference/mcp-tools)**: Inspect all 19 MCP tools, dynamic resources, guided prompts, and Admin REST API endpoints. +- **[Reference Manual](/reference/mcp-tools)**: Inspect all 20 MCP tools, dynamic resources, guided prompts, and Admin REST API endpoints. diff --git a/docs/index.md b/docs/index.md index af0cada..f0c0137 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,8 +25,8 @@ hero: features: - title: Dual-Engine Persistence details: Run with PostgreSQL 16 and pgvector HNSW cosine indexing in production, or zero-dependency SQLite WAL caching for local development. - - title: FastMCP 2.0 & 19 Agent Tools - details: Expose 19 specialized MCP tools over SSE and Streamable HTTP transports with zero-allocation JSON-RPC ping and RFC 9728 OAuth 2.1 RBAC. + - title: FastMCP 2.0 & 20 Agent Tools + details: Expose 20 specialized MCP tools (including a sandboxed Code Mode) over SSE and Streamable HTTP transports with zero-allocation JSON-RPC ping and RFC 9728 OAuth 2.1 RBAC. - title: Syntax-Aware AST Chunking details: Parse 10 programming languages with Tree-sitter to extract functions, classes, signatures, REST API routes, and caller-callee graphs. - title: Calibrated Hybrid Search & Navigator @@ -40,5 +40,5 @@ features: | **Getting Started & Configuration** | Installation, Docker Compose stack, environment variables, database profiles, LiteLLM proxy setup, and restricted-network mirror builds. | [Overview](/guide/) · [Getting Started](/guide/getting-started) · [Configuration](/guide/configuration) · [Restricted-Network Builds](/guide/enterprise-build) | | **User Guide (Web Dashboard)** | Overview metrics, Codebase Navigator & Omni-Search (`Ctrl+K`), Search & Inspector, Git Repositories, Local Paths, Storage & PDF OCR, Settings, Diagnostics, and Themes. | [User Guide Hub](/guide/user-guide) · [Navigator](/guide/user-guide/navigator) · [Search Inspector](/guide/user-guide/search) | | **System Architecture** | Component interactions, Tree-sitter AST chunking pipeline, calibrated hybrid search scoring, MCP transports, OAuth 2.1 RBAC, and unified database ERD. | [Architecture Overview](/architecture/) · [System Design](/architecture/system-design) · [Data Pipeline](/architecture/data-pipeline) | -| **Reference Manual** | Complete specification of all 19 MCP tools, dynamic resources, guided prompts, Admin REST API routes, and contributor workflows. | [MCP Tools Reference](/reference/mcp-tools) · [Admin REST API](/reference/rest-api) · [Developer Guide](/reference/developer) | +| **Reference Manual** | Complete specification of all 20 MCP tools, dynamic resources, guided prompts, Admin REST API routes, and contributor workflows. | [MCP Tools Reference](/reference/mcp-tools) · [Admin REST API](/reference/rest-api) · [Developer Guide](/reference/developer) | | **Software Requirements (SRS)** | Functional requirements, non-functional latency/maintainability targets, and automated verification traceability across 1,051 tests. | [SRS Overview](/requirements/) · [Functional](/requirements/functional) · [Verification Matrix](/requirements/verification) | diff --git a/docs/mcp-tools-and-resources.md b/docs/mcp-tools-and-resources.md index 8fa0642..ef99ee6 100644 --- a/docs/mcp-tools-and-resources.md +++ b/docs/mcp-tools-and-resources.md @@ -1,6 +1,6 @@ # MCP Tools, Resources, and Pagination Reference -ContextCortex exposes 19 MCP tools, 1 catalog resource, 5 repository resource templates, and 5 guided workflow prompts over SSE (`/sse`) and Streamable HTTP (`/mcp`). Every response emits bounded, deterministic JSON to protect AI agent context windows. +ContextCortex exposes 20 MCP tools, 2 resources (the catalog and the Code Mode SDK types), 5 repository resource templates, and 5 guided workflow prompts over SSE (`/sse`) and Streamable HTTP (`/mcp`). Every response emits bounded, deterministic JSON to protect AI agent context windows. --- @@ -42,7 +42,7 @@ Paginated MCP resource templates (`files/{page}` and `routes/{page}`) use 1-inde ## MCP Tools Reference -### Summary Matrix (19 Tools) +### Summary Matrix (20 Tools) | Tool | Role | Default / Clamped Bounds | Pagination Keys | | :--- | :---: | :--- | :--- | @@ -65,6 +65,7 @@ Paginated MCP resource templates (`files/{page}` and `routes/{page}`) use 1-inde | `list_repositories` | `viewer` | Registered repos & monitored paths | Compact repository metadata list | | `sync_repository` | `editor` | Asynchronous background job | Immediate trigger confirmation | | `index_status` | `viewer` | Global health & vector metrics | Single status payload | +| `execute_script` | `viewer` | One sandboxed script (`30` s, `50` tool calls, `64` MB) | The value the script returns (`< 100,000` chars) | --- @@ -103,6 +104,7 @@ Paginated MCP resource templates (`files/{page}` and `routes/{page}`) use 1-inde - **`manage_local_file`**: Supports `upload`, `replace`, `delete` (`editor` role) and `read`, `preview` (`viewer` role) inside `LOCAL_STORAGE_PATH` with immediate vector and AST indexing. - **`what_is_ingested`**: Inspects `all`, `git`, `monitored_path`, or `local_storage` sources. Set `include_files=True` (or `detail_level='detailed'`) with `limit` (`1..500`, default `100`) and `offset` (`>= 0`) to paginate file paths per source (`total_files`, `offset`, `limit`, `has_more`, `next_offset`) without dumping thousands of paths. +- **`execute_script`** (Code Mode): runs one async JavaScript body that calls other tools with `await tools.(args)` and returns only the final result, so several dependent calls take one round trip. Tool results are parsed JSON, and a tool error throws. Limits: `CODE_MODE_TIMEOUT_SECONDS` (`30`), `CODE_MODE_MAX_TOOL_CALLS` (`50`), `CODE_MODE_MAX_MEMORY_MB` (`64`). Types: resource `contextcortex://code-mode/sdk.d.ts`. Same pattern as Code Mode in model-context-gateway. See [the reference](reference/mcp-tools.md#20-execute-script). - **`diagnose_issue`**: Resolves stack traces, error string literals, HTTP routes, and semantic code candidates across Python, TypeScript/JS/TSX, C#, COBOL, Go, Rust, Java, and C/C++. Clamps `context_lines` (`1..15`, default `5`) and `limit` (`1..20`, default `5`). --- @@ -113,6 +115,7 @@ ContextCortex splits repository metadata into granular, bounded JSON resources ( | Resource URI / Template | Page Size | Key Response Fields | Purpose | | :--- | :---: | :--- | :--- | +| `contextcortex://code-mode/sdk.d.ts` | Single | One `tools.(args)` declaration per tool, with JSDoc | TypeScript types for `execute_script` (Code Mode) scripts. | | `knowledge://catalog/summary` | Sample `<= 40` | `total_files`, `total_symbols`, `git_repositories`, `resource_templates` | Compact global catalog and discovery index for per-repo URIs. | | `knowledge://repo/{repo}/summary` | Single | `status`, `commit_sha`, `file_count`, `languages`, `symbol_count`, `route_count`, `adr_count`, `sub_resources` | Lightweight per-repository health, counts, and sub-resource links. | | `knowledge://repo/{repo}/architecture` | Single | `languages`, `manifests`, `entry_points`, `modules`, `hub_symbols`, `routes_count`, `active_adrs` | Synthesized repository architecture overview. | diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 10bd6ba..c902afd 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools and Resources Reference -This document provides complete reference specifications for all **19 Model Context Protocol (MCP) tools**, dynamic resources, and guided prompts exposed by ContextCortex (`app/mcp/tools.py`). +This document provides complete reference specifications for all **20 Model Context Protocol (MCP) tools**, dynamic resources, and guided prompts exposed by ContextCortex (`app/mcp/tools.py`). --- @@ -27,6 +27,7 @@ This document provides complete reference specifications for all **19 Model Cont | [`manage_local_file`](#17-manage-local-file) | `editor` / `viewer` | Storage & File Access | `action`, `file_path`, `content`, `repo`, `category` | Uploads, replaces, reads, previews, or deletes files in managed local storage with immediate indexing. | | [`read_file`](#18-read-file) | `viewer` | Storage & File Access | `path`, `repo`, `start_line`, `end_line` | Reads full file content or bounded 1-indexed line ranges from disk or reconstructed from vector chunks. | | [`summarize_file`](#19-summarize-file) | `viewer` | Storage & File Access | `path`, `repo`, `force_refresh` | Retrieves a cached summary or generates a structured LLM executive summary for a large file. | +| [`execute_script`](#20-execute-script) | `viewer` | Code Mode | `script`, `arguments` | Runs one sandboxed JavaScript script that chains several tool calls and returns only the final result. | --- @@ -254,6 +255,39 @@ Retrieves a cached executive summary or generates a structured LLM summary for a --- +### 20. `execute_script` +Code Mode. Runs one async JavaScript body that calls other ContextCortex tools and returns only the final result. Use it when a question needs several dependent calls (for example: find a route, trace its handler, read each callee). The design is the same as Code Mode (`execute_script`) in model-context-gateway, so a script also runs there unchanged. + +- **Minimum Role**: `viewer`. Each tool call in the script goes through the normal handler and its own role check. +- **Parameters**: + - `script` (*string, required*; alias `code`): async JavaScript body. Top-level `await` and `return` work. + - `arguments` (*object, optional*; alias `args`): input object, available in the script as `args` and `arguments`. +- **In the script**: + - `await tools.(args)` or `await cc.callTool("", args)` calls a tool. `tools.contextcortex.(args)` and `mcg.callTool(...)` also work. + - Tool results are parsed JSON. A tool error (an unknown tool or argument, or a result with an `error` field) throws an `Error`; catch it with `try`/`catch`. + - `console.log`, `console.warn`, and `console.error` add entries to `logs` (at most `100`). + - The script cannot call `execute_script`, and it has no file, network, or process access. +- **Limits** (environment settings): `CODE_MODE_TIMEOUT_SECONDS` (`30`, `1..300`), `CODE_MODE_MAX_TOOL_CALLS` (`50`, `1..500`), `CODE_MODE_MAX_MEMORY_MB` (`64`, `8..512`). `CODE_MODE_ENABLED=false` turns Code Mode off. Scripts are at most `50,000` characters; results larger than `100,000` characters are cut and marked `truncated`. +- **Returns**: `{"result", "logs", "executionTimeMs", "toolCallsCount"}`. On a failure: `{"error": "script_failed", "message", "logs", "executionTimeMs", "toolCallsCount"}`. Line numbers in error messages count from the first line of the script. +- **Types**: read the resource `contextcortex://code-mode/sdk.d.ts` for one typed function per tool. + +```javascript +// Which classes call each repository method that a route handler reaches? +const routes = await tools.find_routes({ query: args.route, repo: args.repo }); +const handler = routes.routes[0].handler; +const down = await tools.trace_path({ symbol: handler, repo: args.repo, direction: "callees", depth: 3 }); +const repoCalls = down.callees.flatMap(function walk(n) { return [n, ...(n.callees || []).flatMap(walk)]; }) + .filter(n => n.symbol.includes("Repository.")); +const out = {}; +for (const c of repoCalls.slice(0, 10)) { + const up = await tools.trace_path({ symbol: c.symbol, repo: args.repo, direction: "callers", depth: 1 }); + out[c.symbol] = up.callers.map(x => x.symbol); +} +return { handler, repositoryCallers: out }; +``` + +--- + ## Bounded Payload & Pagination Conventions All list- and search-oriented MCP tools enforce safe upper bounds and return standardized pagination metadata: @@ -269,6 +303,7 @@ All list- and search-oriented MCP tools enforce safe upper bounds and return sta | Resource URI / Template | MIME Type | Description | | :--- | :--- | :--- | +| `contextcortex://code-mode/sdk.d.ts` | `text/plain` | TypeScript declarations of every tool that an `execute_script` script can call. | | `knowledge://catalog/summary` | `application/json` | Compact global catalog (`≤ 40` sample files) listing repositories, status, language/symbol totals, and sub-resource templates. | | `knowledge://repo/{repo}/summary` | `application/json` | Single-repository overview with commit SHA, sync status, language distribution, symbol/route/ADR counts, and sub-resource URIs. | | `knowledge://repo/{repo}/architecture` | `application/json` | Synthesized entry points, hub symbols, directory breakdown, API surface summary, and active ADRs for `{repo}`. | diff --git a/docs/requirements/functional.md b/docs/requirements/functional.md index ec237b8..2c6fbcf 100644 --- a/docs/requirements/functional.md +++ b/docs/requirements/functional.md @@ -55,10 +55,10 @@ This section specifies the functional requirements for ContextCortex (`v2.23.3`) --- -### FR-08: Model Context Protocol (MCP) 2.0 Compliance & 19 Tools +### FR-08: Model Context Protocol (MCP) 2.0 Compliance & 20 Tools - **Description**: The system must implement the official Model Context Protocol using `FastMCP` 2.0. - **Transports & Liveness**: The system must provide Server-Sent Events (SSE) at `/sse` (with `/messages/`), Streamable HTTP at `/mcp`, and zero-allocation JSON-RPC `ping` interception via `McpPingMiddleware`. -- **Tools**: The system must expose **19 dedicated agent tools**: `search_code`, `search_docs`, `grep_code`, `diagnose_issue`, `find_symbol`, `trace_path`, `find_routes`, `find_api_callers`, `get_file_outline`, `list_repositories`, `get_recent_changes`, `sync_repository`, `index_status`, `get_architecture`, `manage_adr`, `manage_local_file`, `what_is_ingested`, `read_file`, and `summarize_file`. +- **Tools**: The system must expose **20 dedicated agent tools**: `search_code`, `search_docs`, `grep_code`, `diagnose_issue`, `find_symbol`, `trace_path`, `find_routes`, `find_api_callers`, `get_file_outline`, `list_repositories`, `get_recent_changes`, `sync_repository`, `index_status`, `get_architecture`, `manage_adr`, `manage_local_file`, `what_is_ingested`, `read_file`, `summarize_file`, and `execute_script` (Code Mode). --- diff --git a/docs/requirements/index.md b/docs/requirements/index.md index a8b58f1..1492f5e 100644 --- a/docs/requirements/index.md +++ b/docs/requirements/index.md @@ -28,7 +28,7 @@ ContextCortex provides: The requirements are organized into three primary sections: 1. **[Functional Requirements](/requirements/functional)**: - Specifies the functional behavior, data operations, all 19 MCP tools, and user interface capabilities. + Specifies the functional behavior, data operations, all 20 MCP tools, and user interface capabilities. 2. **[Non-Functional Requirements](/requirements/non-functional)**: Defines performance targets, security standards, maintainability limits (sub-500 LOC per file), and reliability constraints. diff --git a/docs/requirements/verification.md b/docs/requirements/verification.md index e49fed6..de46a49 100644 --- a/docs/requirements/verification.md +++ b/docs/requirements/verification.md @@ -18,7 +18,7 @@ ContextCortex is verified by an automated test suite comprising **1,051 automate | **FR-05** | Universal Git Ingestion & Shallow Retention | Automated Integration Test | `tests/backend/test_multi_git_providers.py`, `tests/backend/test_git_manager.py`, `frontend/src/tests/GitRepoManager.test.tsx` | | **FR-06** | Managed Local Storage, Read & Summarize | Automated Unit & Integration | `tests/backend/test_local_storage_service.py`, `tests/backend/test_file_reader_and_summarizer.py` | | **FR-07** | PDF Ingestion & Vision OCR | Automated Integration Test | `tests/test_pdf_extractor.py`, `tests/test_pdf_storage_api.py`, `frontend/src/tests/PdfPreviewModal.test.tsx` | -| **FR-08** | FastMCP 2.0, 19 Tools & JSON-RPC Ping | Automated End-to-End Test | `tests/backend/test_mcp_v2.py`, `tests/test_mcp_ping.py`, `tests/backend/test_tools.py`, `tests/backend/test_grep_code.py`, `tests/backend/test_diagnostics.py` | +| **FR-08** | FastMCP 2.0, 20 Tools & JSON-RPC Ping | Automated End-to-End Test | `tests/backend/test_mcp_v2.py`, `tests/test_mcp_ping.py`, `tests/backend/test_tools.py`, `tests/backend/test_grep_code.py`, `tests/backend/test_diagnostics.py` | | **FR-09** | RFC 9728 OAuth 2.1 & RBAC | Automated Unit & Security | `tests/backend/test_auth_jwt_and_keys.py`, `tests/backend/test_auth_middleware_and_rbac.py` | | **FR-10** | Hero Split Codebase Navigator & Omni-Search | Automated Component & E2E | `frontend/src/tests/CodeNavigator.test.tsx`, `NavigatorOmniSearch.test.tsx`, `NavigatorCodeViewer.test.tsx`, `e2e/navigator.spec.ts` | | **FR-11** | AI Gateway & Dynamic Model Discovery | Automated Integration Test | `tests/backend/test_ai_gateway_settings.py`, `tests/test_litellm_service.py`, `tests/test_llm_client.py`, `tests/test_model_discovery_api.py`, `frontend/src/tests/AIGatewaySettings.test.tsx` | diff --git a/python-constraints.txt b/python-constraints.txt index 8d2208c..5f7bf9d 100644 --- a/python-constraints.txt +++ b/python-constraints.txt @@ -53,6 +53,7 @@ loguru==0.7.3 markdown-it-py==4.2.0 mcp==1.30.0 mdurl==0.1.2 +mini-racer==0.14.1 mmh3==5.3.1 multidict==7.2.1 networkx==3.7 diff --git a/requirements.txt b/requirements.txt index 772508c..5476326 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ pyjwt[crypto]>=2.8.0 cryptography>=42.0.0 httpx>=0.27.0 pymupdf>=1.24.0 +mini-racer>=0.14.1 diff --git a/tests/test_execute_script.py b/tests/test_execute_script.py new file mode 100644 index 0000000..a3ef662 --- /dev/null +++ b/tests/test_execute_script.py @@ -0,0 +1,141 @@ +"""Code Mode (execute_script): sandbox behavior, limits, tool dispatch through the real handlers, and the SDK types.""" + +import asyncio +import json +from types import SimpleNamespace + +import pytest + +from app.mcp.handlers.script_handlers import call_tool_from_script, code_mode_limits, handle_execute_script +from app.services.script_sandbox import ScriptLimits, ScriptToolError, code_mode_available, run_script +from app.services.script_sdk_types import build_sdk_declarations, schema_to_ts +from tests.test_trace_path_di_callers import REPO, di_graph # noqa: F401 (pytest fixture) + +needs_v8 = pytest.mark.skipif(not code_mode_available(), reason="needs the mini-racer package") + + +async def _fake_tool(name, tool_args): + if name == "boom": + raise ScriptToolError("tool boom failed on purpose") + return {"tool": name, "args": tool_args, "items": [1, 2, 3]} + + +def _run(script, script_args=None, **limits): + limit_values = {"timeout_seconds": 5, "max_tool_calls": 5, "max_memory_bytes": 32 * 1024 * 1024, **limits} + return asyncio.run(run_script(script, script_args or {}, _fake_tool, ScriptLimits(**limit_values))) + + +@needs_v8 +def test_script_calls_tools_and_returns_a_small_result(): + out = _run("const r = await tools.trace_path({symbol: args.sym}); console.log('got', r.items.length);" + " return {n: r.items.length, tool: r.tool, sym: arguments.sym};", {"sym": "A.B"}) + assert out.ok and out.result == {"n": 3, "tool": "trace_path", "sym": "A.B"} + assert out.logs == ["got 3"] and out.tool_calls_count == 1 + + +@needs_v8 +def test_gateway_form_and_call_tool_work(): + assert _run("return (await tools.contextcortex.find_routes({q: 1})).tool;").result == "find_routes" + assert _run("return (await cc.callTool('read_file', {path: 'a'})).args;").result == {"path": "a"} + assert _run("return (await mcg.callTool('read_file', {})).tool;").result == "read_file" + + +@needs_v8 +def test_tool_errors_throw_and_can_be_caught(): + caught = _run("try { await tools.boom({}); } catch (e) { console.warn(e.message); return 'caught'; }") + assert caught.ok and caught.result == "caught" and caught.logs == ["[WARN] tool boom failed on purpose"] + uncaught = _run("console.log('before'); await tools.boom({}); return 1;") + assert not uncaught.ok and uncaught.error.startswith("Error: tool boom failed on purpose") + assert uncaught.logs == ["before"] + + +@needs_v8 +def test_script_errors_report_script_line_numbers(): + out = _run("const a = 1;\nthrow new Error('bad input');") + assert not out.ok and out.error.startswith("Error: bad input") and ":2:" in out.error + syntax = _run("return {;") + assert not syntax.ok and "SyntaxError" in syntax.error + + +@needs_v8 +def test_no_return_gives_null(): + out = _run("console.log('only logs');") + assert out.ok and out.result is None and out.logs == ["only logs"] + + +@needs_v8 +def test_limits_stop_loops_memory_and_tool_fan_out(): + loop = _run("while (true) {}", timeout_seconds=1) + assert not loop.ok and loop.error == "Script timed out after 1 seconds" + memory = _run("const a = []; while (true) { a.push(new Array(100000).fill('xxxxxxxxxx')); }") + assert not memory.ok and memory.error.startswith("Memory limit of 32 MB exceeded") + calls = _run("for (let i = 0; i < 10; i++) { await tools.x({}); } return 'done';", max_tool_calls=3) + assert not calls.ok and "Maximum tool calls limit (3) exceeded" in calls.error and calls.tool_calls_count == 3 + + +@needs_v8 +def test_host_is_not_reachable_from_the_script(): + out = _run("return [typeof require, typeof process, typeof fetch, typeof __cc_bridge];") + assert out.ok and out.result[:3] == ["undefined", "undefined", "undefined"] + + +def test_dispatch_checks_tool_names_and_arguments(): + with pytest.raises(ScriptToolError, match="Recursive execute_script"): + asyncio.run(call_tool_from_script("execute_script", {"script": "1"})) + with pytest.raises(ScriptToolError, match="Unknown tool 'nope'"): + asyncio.run(call_tool_from_script("nope", {})) + with pytest.raises(ScriptToolError, match="Unknown arguments for trace_path: bogus"): + asyncio.run(call_tool_from_script("trace_path", {"symbol": "A", "bogus": 1})) + with pytest.raises(ScriptToolError, match="Missing arguments for trace_path: symbol"): + asyncio.run(call_tool_from_script("trace_path", {})) + + +@needs_v8 +def test_execute_script_runs_real_tools_end_to_end(di_graph): # noqa: F811 + script = """ +const out = {}; +for (const target of args.targets) { + const t = await tools.trace_path({symbol: target, repo: args.repo, direction: "callers", depth: 1}); + out[target] = t.callers.map(c => c.symbol); +} +return out; +""" + raw = asyncio.run(handle_execute_script(script=script, arguments={ + "repo": REPO, "targets": ["AssistantService.PublishInstance", "ToolRepository.PublishInstance"]})) + payload = json.loads(raw) + assert payload["result"] == { + "AssistantService.PublishInstance": ["AssistantsController.PublishInstance"], + "ToolRepository.PublishInstance": ["ToolService.PublishInstance"], + } + assert payload["toolCallsCount"] == 2 and payload["logs"] == [] + + +@needs_v8 +def test_execute_script_reports_failures_with_logs(): + payload = json.loads(asyncio.run(handle_execute_script(code="console.log('x'); await tools.nope({});"))) + assert payload["error"] == "script_failed" and "Unknown tool 'nope'" in payload["message"] + assert payload["logs"] == ["x"] and payload["toolCallsCount"] == 1 + + +def test_execute_script_rejects_empty_input(): + assert json.loads(asyncio.run(handle_execute_script(script=" ")))["error"] in ("invalid_argument", "code_mode_unavailable") + + +def test_limits_come_from_the_environment(monkeypatch): + monkeypatch.setenv("CODE_MODE_TIMEOUT_SECONDS", "999") + monkeypatch.setenv("CODE_MODE_MAX_TOOL_CALLS", "7") + monkeypatch.setenv("CODE_MODE_MAX_MEMORY_MB", "1") + limits = code_mode_limits() + assert (limits.timeout_seconds, limits.max_tool_calls, limits.max_memory_bytes) == (300, 7, 8 * 1024 * 1024) + + +def test_sdk_declarations_from_tool_schemas(): + assert schema_to_ts({"anyOf": [{"type": "string"}, {"type": "null"}]}, {}) == "string | null" + assert schema_to_ts({"type": "array", "items": {"enum": ["a", "b"]}}, {}) == '("a" | "b")[]' + tool = SimpleNamespace(name="trace_path", description="Trace callers.", parameters={ + "type": "object", "required": ["symbol"], + "properties": {"symbol": {"type": "string", "description": "Qualified symbol."}, + "depth": {"type": "integer", "default": 2}}}) + sdk = build_sdk_declarations([tool]) + assert "trace_path(args: {" in sdk and "symbol: string;" in sdk and "depth?: number;" in sdk + assert " * Qualified symbol." in sdk and "): Promise;" in sdk and "contextcortex: Omit= 40, f"Weak description on {tool.name}"