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 3cb54e2..b7b8eef 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:** **1231 Automated Tests** (867 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1244 Automated Tests** (880 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). --- @@ -1079,6 +1079,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` @@ -1214,7 +1229,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 2bd21e8..189cc44 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. --- @@ -43,7 +43,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 | | :--- | :---: | :--- | :--- | @@ -66,6 +66,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) | --- @@ -104,6 +105,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`). When `repo` is set, every result section (stack frames, route handlers, external callers, symbols, and candidates) stays in that repo. When an HTTP route matches, `semantic_candidates` starts with the route handler and its callees (`source`: `route_handler` / `route_callee`, `depth` from the handler, `score`: `null`). Search hits (`source`: `semantic`) fill the remaining slots; hits from test files or from a different language than the route handler are dropped. --- @@ -114,6 +116,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 7019fe5..0146d00 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}"