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
10 changes: 10 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,16 @@ rejects the analysis. Empty-citation answers are allowed only when they pass the
boundary checks (source facts, tool trace, and unsupported source/reference claims); these checks
are not a guarantee of perfect semantic verification. Offline scripted results are not real-model
acceptance evidence.

The optional MCP bridge keeps this same read-only boundary. `ULTICODE_AGENT_MCP_TOOLS=1` makes
analysis discover the single `get_my_submissions` tool through a request-scoped in-process MCP
Client before the model loop; the model's call and the revalidated result still pass through the
existing graph. The standalone local stdio server is `services/agent/src/agent_mcp_server.py` and
uses the existing authenticated Java API client. Direct tools remain the default. MCP adds
standardized discovery and a separately launchable endpoint, but it does not add business access
or change Java authorization. See the [Agent guide](../services/agent/README.md#optional-mcp-read-only-bridge)
for environment and command details.

Thread status and drafts are canonical in the Agent's private SQLite store; LangGraph checkpoints
only resume control flow. The default store is
`~/.local/state/ulticode/u03-workflow/state.sqlite3` (private directory/database); workflow rows,
Expand Down
34 changes: 34 additions & 0 deletions services/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,40 @@ only local draft/workflow state and events.
- A Java LearningPlan is persisted only after a separate explicit confirmation and save. Unknown
write outcomes remain unresolved until same-key Java readback; no automatic retry or new key.

### Optional MCP read-only bridge

The local stdio server in `src/agent_mcp_server.py` exposes only `get_my_submissions`, reusing the
same authenticated Java client and strict field projection as the direct Agent tool. It has no
Shell, SQL, file, or write tools. For standalone stdio use, configure `ULTICODE_APP_BASE`,
`ULTICODE_AUTH_BASE`, and `ULTICODE_AGENT_ACCESS_TOKEN` in the MCP host's secure environment;
`ULTICODE_AGENT_CSRF_TOKEN` is optional for this read-only GET. Start it from this directory with:

```bash
uv run python src/agent_mcp_server.py
```

The Agent's in-process MCP Client path is opt-in through `ULTICODE_AGENT_MCP_TOOLS=1`; unset or
`0` keeps the direct tool path. Before the model loop, each request discovers the single expected
tool and verifies its argument schema. The model's existing `get_my_submissions` call then goes
through MCP and the response is projected and validated again before it returns to the same graph.
The tool accepts only optional `page` (default `1`, range `1..2147483647`) and `pageSize` (default
`5`, range `1..100`) integer arguments. It returns only `id`, `problemId`, `language`, `status`,
`createdAt`, and paging totals; source code and user identity are excluded. Safe error labels are
`invalid_arguments`, `business_error`, `tool_error`, `protocol_error`, and `timeout`; raw transport
and server error text is discarded.
The session uses the authenticated request client directly, so cookies are not copied to a child
process. Protocol errors, business errors, invalid arguments, timeouts, and cancellation are
handled separately; cancellation closes the request-scoped MCP session. Authorization still comes
from the Java API and program code, not MCP.

The direct path remains the default because it has less protocol setup for one in-process tool.
MCP is useful when standardized discovery and an independently launchable local server are needed;
it adds no business capability or authorization guarantee by itself. This integration resolves to
the official Python SDK `mcp==2.3.0` and negotiates protocol version `2026-07-28`; `uv.lock` pins the
complete dependency graph. The focused `tests/test_ulticode_tools.py` suite includes a real stdio subprocess
against a local HTTP stub and an in-process Agent model/tool round trip; it does not call a live
UltiCode stack or model provider.

Workflow route and field contracts, U02 evidence gate, and budget behavior are in the canonical
[API reference](../../docs/REFERENCE.md#agent-workflow-api) and
[Development guide](../../docs/DEVELOPMENT.md#u03-agent-workflow).
Expand Down
4 changes: 2 additions & 2 deletions services/agent/data/repository_corpus_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@
},
{
"doc_id": "repository-development",
"version": "sha256-f937107ae45635902eefbee833b3f1314476f2949296adf4e623c4043e579cf8",
"chunk_id": "repository-development:sha256-f937107ae45635902eefbee833b3f1314476f2949296adf4e623c4043e579cf8:1",
"version": "sha256-2a8318a999c15e963c9b73d052cfc92348bdde4d69472e9e62eaa7a546dded4b",
"chunk_id": "repository-development:sha256-2a8318a999c15e963c9b73d052cfc92348bdde4d69472e9e62eaa7a546dded4b:1",
"source_path": "docs/DEVELOPMENT.md",
"access_scope": "repository-public",
"sample_kind": "real",
Expand Down
1 change: 1 addition & 0 deletions services/agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ dependencies = [
"uvicorn>=0.30,<1",
"langgraph>=1,<2",
"langgraph-checkpoint-sqlite>=3,<4",
"mcp>=2.3.0,<3",
]

[dependency-groups]
Expand Down
119 changes: 119 additions & 0 deletions services/agent/src/agent_mcp_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Request-scoped MCP Client bridge for the one allowlisted submission tool."""

from __future__ import annotations

import asyncio
from contextlib import AsyncExitStack

from mcp import Client

from agent_mcp_server import TOOL_NAME, create_server
from ulticode_tools import parse_my_submissions_arguments, project_my_submissions_listing

MCP_CALL_TIMEOUT_SECONDS = 5.0
_TOOL_ERRORS = {"business_error", "invalid_arguments", "tool_error"}


class MCPProtocolError(RuntimeError):
"""The expected read-only MCP tool could not be discovered safely."""


def _valid_tool_definition(tool: object) -> bool:
schema = getattr(tool, "input_schema", None)
if not isinstance(schema, dict):
return False
properties = schema.get("properties")
if not isinstance(properties, dict) or set(properties) != {"page", "pageSize"}:
return False
if schema.get("required", []) != []:
return False
return all(
isinstance(properties[name], dict)
and properties[name].get("type") == "integer"
for name in ("page", "pageSize")
)


class MCPSubmissionSession:
"""Discover once before the model loop and close the protocol session after it."""

def __init__(self, business_client: object) -> None:
self._business_client = business_client
self._stack: AsyncExitStack | None = None
self.client: Client | None = None

async def __aenter__(self) -> dict[str, object]:
stack = AsyncExitStack()
self._stack = stack
try:
self.client = await stack.enter_async_context(
Client(
create_server(self._business_client),
read_timeout_seconds=MCP_CALL_TIMEOUT_SECONDS,
)
)
discovered = await self.client.list_tools(cache_mode="refresh")
if (
discovered.next_cursor is not None
or len(discovered.tools) != 1
or discovered.tools[0].name != TOOL_NAME
or not _valid_tool_definition(discovered.tools[0])
):
raise MCPProtocolError("mcp_discovery_failed")
except asyncio.CancelledError:
await stack.aclose()
self._stack = None
self.client = None
raise
except Exception:
await stack.aclose()
self._stack = None
self.client = None
raise MCPProtocolError("mcp_discovery_failed") from None
return {TOOL_NAME: self._call}

async def __aexit__(self, exc_type, exc_value, traceback) -> None:
stack, self._stack = self._stack, None
self.client = None
if stack is not None:
await stack.__aexit__(exc_type, exc_value, traceback)

async def _call(self, arguments: dict[str, object]) -> object:
try:
page, page_size = parse_my_submissions_arguments(arguments)
except (TypeError, ValueError):
return {"error": "invalid_arguments"}
if self.client is None:
return {"error": "protocol_error"}
try:
async with asyncio.timeout(MCP_CALL_TIMEOUT_SECONDS):
result = await self.client.call_tool(
TOOL_NAME,
{"page": page, "pageSize": page_size},
read_timeout_seconds=MCP_CALL_TIMEOUT_SECONDS,
)
except TimeoutError:
return {"error": "timeout"}
except asyncio.CancelledError:
raise
except Exception:
return {"error": "protocol_error"}

if result.is_error:
safe_text = " ".join(
text for item in result.content
if isinstance((text := getattr(item, "text", None)), str)
)
label = next(
(error for error in _TOOL_ERRORS if error in safe_text),
"business_error",
)
return {"error": label}
try:
return project_my_submissions_listing(
result.structured_content,
page=page,
page_size=page_size,
)
except (TypeError, ValueError):
return {"error": "protocol_error"}
80 changes: 80 additions & 0 deletions services/agent/src/agent_mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""One local MCP endpoint over the existing authenticated read-only tool."""

from __future__ import annotations

import asyncio
import os
import sys

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from pydantic import StrictInt

from ulticode_client import UlticodeClient, UlticodeError
from ulticode_tools import build_tools, parse_my_submissions_arguments

TOOL_NAME = "get_my_submissions"
TOOL_DESCRIPTION = (
"Read the authenticated user's paged submission metadata. Java enforces the "
"session and owner scope; source code and user identity fields are excluded."
)


def create_server(client: UlticodeClient) -> MCPServer:
handler = build_tools(client)[TOOL_NAME]
server = MCPServer(name="ulticode-agent-readonly", version="0.1.0")

@server.tool(
name=TOOL_NAME,
description=TOOL_DESCRIPTION,
structured_output=True,
)
async def get_my_submissions(
page: StrictInt = 1, pageSize: StrictInt = 5
) -> dict[str, object]:
try:
normalized_page, page_size = parse_my_submissions_arguments(
{"page": page, "pageSize": pageSize}
)
except ValueError:
raise ToolError("invalid_arguments") from None
try:
return await handler({"page": normalized_page, "pageSize": page_size}) # type: ignore[no-any-return]
except UlticodeError:
raise ToolError("business_error") from None
except ValueError:
raise ToolError("business_error") from None
except Exception:
raise ToolError("tool_error") from None

return server


async def _run_stdio() -> None:
app_base = os.environ.get("ULTICODE_APP_BASE", "")
auth_base = os.environ.get("ULTICODE_AUTH_BASE", "")
access_token = os.environ.get("ULTICODE_AGENT_ACCESS_TOKEN", "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep access tokens in HttpOnly cookies

In the documented standalone stdio configuration, this reads the user's bearer access token from an environment variable, requiring it to be copied out of its HttpOnly cookie and exposing it to process-environment readers and inherited child processes. That bypasses the repository's explicit token-storage boundary; authenticate the standalone bridge through a host-mediated cookie/session channel rather than provisioning the raw token in ULTICODE_AGENT_ACCESS_TOKEN.

AGENTS.md reference: AGENTS.md:L55-L56

Useful? React with 👍 / 👎.

csrf_token = os.environ.get("ULTICODE_AGENT_CSRF_TOKEN") or None
if not app_base or not auth_base or not access_token:
raise ValueError("mcp_configuration_invalid")

client = UlticodeClient.for_session(
app_base,
auth_base,
access_token=access_token,
csrf_token=csrf_token,
)
async with client:
await create_server(client).run_stdio_async()


def main() -> None:
try:
asyncio.run(_run_stdio())
except (ValueError, RuntimeError):
print("mcp_configuration_or_runtime_error", file=sys.stderr)
raise SystemExit(2) from None


if __name__ == "__main__":
main()
15 changes: 15 additions & 0 deletions services/agent/src/agent_service/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ def create_app(
expected_period: object | None = None, budget_guard: object | None = None,
model_purpose: str | None = None,
) -> FastAPI:
mcp_tools_setting = os.environ.get("ULTICODE_AGENT_MCP_TOOLS", "0")
if mcp_tools_setting not in {"0", "1"}:
raise ValueError("ULTICODE_AGENT_MCP_TOOLS must be 0 or 1")
mcp_tools_enabled = mcp_tools_setting == "1"
if candidate_root is not None:
candidate_root = require_execution_candidate(candidate_root)
workflow_store = store or WorkflowStore.open(state_path)
Expand Down Expand Up @@ -747,6 +751,8 @@ async def operation(client: UlticodeClient, owner: str):
kind="analysis_started")
model = judge_model = None
model_entered = judge_entered = False
mcp_session = None
mcp_entered = False
retrieved: list[dict[str, object]] = []
documents = ()
search_results: list[dict[str, object]] = []
Expand Down Expand Up @@ -797,6 +803,13 @@ async def recorded_search(arguments):
raise

tools["search_evidence"] = recorded_search
if mcp_tools_enabled and "get_my_submissions" in tools:
from agent_mcp_client import MCPSubmissionSession

tools = dict(tools)
mcp_session = MCPSubmissionSession(client)
tools.update(await mcp_session.__aenter__())
mcp_entered = True
def record_event(kind, detail):
try:
workflow_store.transition(thread_id, owner, expected_run=run_id,
Expand Down Expand Up @@ -891,6 +904,8 @@ async def execute_action(_, analysis_result):
"failure_reason": "analysis_failed"}, kind="analysis_failed")
raise AgentError(502, "upstream_unavailable", 50000) from None
finally:
if mcp_entered:
await mcp_session.__aexit__(None, None, None)
if judge_entered and judge_model is not model:
await judge_model.__aexit__(None, None, None)
if model_entered:
Expand Down
Loading
Loading