Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| :--- | :---: | :--- | :--- |
Expand All @@ -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`). |
Expand Down
19 changes: 17 additions & 2 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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._
Expand Down
6 changes: 6 additions & 0 deletions app/mcp/handlers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -80,4 +84,6 @@
"handle_summarize_file",
"handle_grep_code",
"handle_diagnose_issue",
"handle_execute_script",
"handle_code_mode_sdk_resource",
]
141 changes: 141 additions & 0 deletions app/mcp/handlers/script_handlers.py
Original file line number Diff line number Diff line change
@@ -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.<tool>(args)` (for example `await tools.trace_path({symbol: 'A.B', direction: 'callers'})`) "
"or `await cc.callTool('<tool>', args)`; `tools.contextcortex.<tool>` 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)
20 changes: 19 additions & 1 deletion app/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.<tool>(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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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()}
Expand Down
Loading
Loading