diff --git a/agent/README.md b/agent/README.md index ce9d4c4..7cf2bcf 100644 --- a/agent/README.md +++ b/agent/README.md @@ -69,3 +69,23 @@ Every deep-agent run is **one Langfuse trace** built by `deep_agent.observabilit - run-level scores: `deep-agent.model_calls`, `.tool_calls`, `.sql_executions`, `.executed_successfully`, `.final_sql_present`, `.latency_s` In Langfuse, filter traces by **tag = `deep-agent`** (save it as a view). Disable tracing with `DEEP_AGENT_TRACING_ENABLED=false`. + +### Utils-Server tools (deep agent, optional) + +A curated subset of [Utils-Server](https://github.com/matzpen-agency/Utils-Server) tools is loaded from the Jarvis Registry +MCP gateway at startup (`deep_agent/utils_mcp.py`). Set: + +```bash +JARVIS_GATEWAY_URL=http://jarvis-mcp-gateway:8001 # unset = disabled +JARVIS_MCP_NAMESPACE=global # gateway namespace exposing Utils-Server +JARVIS_API_KEY=... # sent as "Authorization: ApiKey " +``` + +| Agent | Tools | +|---|---| +| main | `datetime_parse_relative_expression`, `text_normalize`, `trino_validate_spatial_expression` | +| value-resolver | `datetime_parse_relative_expression`, `text_normalize`, `geo_create_buffer`, `geo_validate_geometry` | + +The registry service identity needs the `datetime:read`, `text:read`, `geo:read` and `trino:read` scopes, and each tool must be +approved in the registry. SQL is never executed through Utils-Server. If the gateway is unreachable the agent starts without +these tools and logs a warning. diff --git a/agent/pyproject.toml b/agent/pyproject.toml index 9b7296a..fd83470 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "deepagents>=0.6.12", "urllib3>=2.7.0", "pyyaml>=6.0", + "langchain-mcp-adapters>=0.2,<0.3", ] [project.scripts] diff --git a/agent/src/agent/config.py b/agent/src/agent/config.py index d5bdd10..ab0608f 100644 --- a/agent/src/agent/config.py +++ b/agent/src/agent/config.py @@ -67,6 +67,11 @@ class AgentSettings(BaseSettings): DEEP_AGENT_PROMPT_CACHE_TTL_S: int = Field(default=60, ge=0) DEEP_AGENT_SUBAGENT_MODEL: str | None = None # smaller/faster model for subagents (default: main model) DEEP_AGENT_SUBAGENT_MAX_TOOL_CALLS: int = Field(default=15, gt=0) + # Utils-Server tools via Jarvis Registry MCP gateway (see deep_agent/utils_mcp.py); unset URL disables + JARVIS_GATEWAY_URL: str | None = None # e.g. http://jarvis-mcp-gateway:8001 + JARVIS_MCP_NAMESPACE: str = "global" # gateway namespace (provider slug) exposing Utils-Server + JARVIS_API_KEY: str = "" # sent as "Authorization: ApiKey " + UTILS_MCP_TIMEOUT_S: float = Field(default=10.0, gt=0) # ── Trino connection info for explicit catalog/schema qualification ── TRINO_CATALOG: str = "" diff --git a/agent/src/deep_agent/agent.py b/agent/src/deep_agent/agent.py index 45a6e8d..4cd775f 100644 --- a/agent/src/deep_agent/agent.py +++ b/agent/src/deep_agent/agent.py @@ -16,6 +16,7 @@ from deep_agent.resources import build_resources from deep_agent.subagents import build_subagents from deep_agent.tools import DEEP_AGENT_TOOLS +from deep_agent.utils_mcp import load_utils_tools # Packaged system prompt (the Langfuse-managed version, when available, is loaded per agent build and refreshed per call). @@ -27,7 +28,12 @@ def _default_llm() -> BaseChatModel: return get_llm().model_copy(update={"request_timeout": settings.DEEP_AGENT_LLM_TIMEOUT_S, "max_retries": 0}) -def create_deep_agent(model: BaseChatModel | None = None): +async def acreate_deep_agent(model: BaseChatModel | None = None): + """create_deep_agent plus the curated Utils-Server tools from the Jarvis Registry gateway (when configured).""" + return create_deep_agent(model=model, extra_tools=await load_utils_tools()) + + +def create_deep_agent(model: BaseChatModel | None = None, extra_tools: dict[str, list] | None = None): """ Creates and returns a compiled LangGraph deep agent equipped with all exploration, enrichment, temporal, geospatial, and semantic validation tools. @@ -37,7 +43,9 @@ def create_deep_agent(model: BaseChatModel | None = None): Args: model: Chat model override (tests); defaults to the configured LLM. + extra_tools: Additional tools per agent ("main" or a subagent name), e.g. from load_utils_tools(). """ + extra_tools = extra_tools or {} llm = model or _default_llm() memory = MemorySaver() resources = build_resources() @@ -45,9 +53,9 @@ def create_deep_agent(model: BaseChatModel | None = None): agent = create_harness_agent( model=llm, - tools=DEEP_AGENT_TOOLS, + tools=[*DEEP_AGENT_TOOLS, *extra_tools.get("main", [])], system_prompt=system_prompt, - subagents=build_subagents(llm), + subagents=build_subagents(llm, extra_tools), middleware=[ DeepAgentContextMiddleware(), PromptRefreshMiddleware("system", system_prompt), diff --git a/agent/src/deep_agent/benchmark.py b/agent/src/deep_agent/benchmark.py index 6bac285..ec874ba 100644 --- a/agent/src/deep_agent/benchmark.py +++ b/agent/src/deep_agent/benchmark.py @@ -137,13 +137,14 @@ def main() -> None: args = parser.parse_args() from agent.utils.jeen_metadata_client import close_mcp_sessions - from deep_agent.agent import create_deep_agent + from deep_agent.agent import acreate_deep_agent questions = json.loads(args.questions.read_text(encoding="utf-8")) async def _run(): try: - return await run_benchmark(create_deep_agent(), questions, DeepAgentContext(connection_id=args.connection_id), + return await run_benchmark(await acreate_deep_agent(), questions, + DeepAgentContext(connection_id=args.connection_id), concurrency=args.concurrency) finally: await close_mcp_sessions() diff --git a/agent/src/deep_agent/cli.py b/agent/src/deep_agent/cli.py index a52cbb0..26a269d 100644 --- a/agent/src/deep_agent/cli.py +++ b/agent/src/deep_agent/cli.py @@ -19,7 +19,7 @@ from langchain_core.messages import BaseMessage from agent.config import settings -from deep_agent.agent import create_deep_agent +from deep_agent.agent import acreate_deep_agent from agent.utils.jeen_metadata_client import close_mcp_sessions from deep_agent import observability from deep_agent.answer import best_effort_result, render_markdown @@ -246,7 +246,7 @@ def next_thread_id() -> str: print(f"{MAGENTA}{BOLD}================================================================{RESET}\n") print(f"{MAGENTA}{BOLD}Initializing Deep Agent...{RESET}") - agent = create_deep_agent() + agent = await acreate_deep_agent() if args.interactive: print(f"{GREEN}{BOLD}Starting Interactive Mode. Type 'quit', 'exit', or 'q' to stop.{RESET}") diff --git a/agent/src/deep_agent/envelope.py b/agent/src/deep_agent/envelope.py index 1a154ec..7f70091 100644 --- a/agent/src/deep_agent/envelope.py +++ b/agent/src/deep_agent/envelope.py @@ -16,6 +16,7 @@ "INVALID_INPUT": "Fix the tool arguments as described in the message and call again.", "ENRICHMENT_ERROR": "Enrichment failed; the original SQL is unchanged — verify filter values with search_column_values instead.", "LOCATION_NOT_FOUND": "The location could not be geocoded. Try the Hebrew or English official name, or filter by a text column instead.", + "EXTERNAL_TOOL_ERROR": "The Utils-Server utility failed — continue without it (fall back to the skill's rules).", } diff --git a/agent/src/deep_agent/skills/spatial-analyst/SKILL.md b/agent/src/deep_agent/skills/spatial-analyst/SKILL.md index 795611a..66a4677 100644 --- a/agent/src/deep_agent/skills/spatial-analyst/SKILL.md +++ b/agent/src/deep_agent/skills/spatial-analyst/SKILL.md @@ -107,6 +107,11 @@ WHERE ST_Distance(to_spherical_geography(ST_Point(e.lon, e.lat)), If the landmark is only known by name, `resolve_location_to_wkt` gives the surrounding area polygon; use its centroid `ST_Centroid(ST_GeometryFromText(...))` as the point, and say so in `assumptions`. +**If the Utils-Server tools are available:** check a spatial expression with `trino_validate_spatial_expression` +before putting it in a query — it reports whether the expression is valid and the functions exist on this Trino (with +close-name suggestions for typos), and can evaluate it on one row of a table. `geo_create_buffer` turns a point + +radius (true metres) into a polygon returned as GeoJSON; use it in SQL with `from_geojson_geometry('')`. + --- ## 4. Proximity & Directional Intent Patterns diff --git a/agent/src/deep_agent/skills/temporal-analyst/SKILL.md b/agent/src/deep_agent/skills/temporal-analyst/SKILL.md index a502855..05b8a76 100644 --- a/agent/src/deep_agent/skills/temporal-analyst/SKILL.md +++ b/agent/src/deep_agent/skills/temporal-analyst/SKILL.md @@ -101,6 +101,11 @@ The Israeli week starts on **Sunday**, but Trino's `date_trunc('week', ...)` ret If a month is given without a year, assume the most recent past occurrence relative to today and state it in `assumptions`. +**If the `datetime_parse_relative_expression` tool is available** (Utils-Server), use it to resolve point-in-time +phrases deterministically — "לפני 3 ימים", "מחר", "יום ראשון הבא", "בעוד שבועיים" — with `timezone: "Asia/Jerusalem"`. +It returns a single instant, not a period: "חודש שעבר" comes back as *one month ago*, not last month's bounds, and +period phrases such as "השבוע" are rejected. Use its date as the anchor, then build the period with the rules above. + --- ## Grouping by period (SELECT / GROUP BY — functions are fine here) diff --git a/agent/src/deep_agent/subagents.py b/agent/src/deep_agent/subagents.py index 981d3df..add38bb 100644 --- a/agent/src/deep_agent/subagents.py +++ b/agent/src/deep_agent/subagents.py @@ -51,8 +51,10 @@ def _subagent_model(main_model: BaseChatModel) -> BaseChatModel | None: return main_model.model_copy(update={"model_name": settings.DEEP_AGENT_SUBAGENT_MODEL}) -def build_subagents(main_model: BaseChatModel) -> list[dict]: +def build_subagents(main_model: BaseChatModel, extra_tools: dict[str, list] | None = None) -> list[dict]: + """Subagent specs; `extra_tools` maps subagent name -> additional tools (e.g. Utils-Server).""" model = _subagent_model(main_model) + extra_tools = extra_tools or {} subagents = [] for name, tools in SUBAGENT_TOOLS.items(): prompt = load_prompt(name).text @@ -60,7 +62,7 @@ def build_subagents(main_model: BaseChatModel) -> list[dict]: "name": name, "description": SUBAGENT_DESCRIPTIONS[name], "system_prompt": prompt, - "tools": tools, + "tools": [*tools, *extra_tools.get(name, [])], "skills": [SKILLS_ROUTE], "middleware": [ DeepAgentContextMiddleware(), diff --git a/agent/src/deep_agent/utils_mcp.py b/agent/src/deep_agent/utils_mcp.py new file mode 100644 index 0000000..f9c2bdb --- /dev/null +++ b/agent/src/deep_agent/utils_mcp.py @@ -0,0 +1,138 @@ +""" +Curated Utils-Server tools, loaded through the Jarvis Registry MCP gateway. + +Deterministic utilities beat prompt rules where they apply (Hebrew text normalisation, +relative-date anchors, spatial-expression validation, buffers around points). They are +optional: with JARVIS_GATEWAY_URL unset, or the gateway unreachable, the agent starts +without them and logs why. + +- Endpoint: {JARVIS_GATEWAY_URL}/mcp/{JARVIS_MCP_NAMESPACE}/ (streamable HTTP), + auth `Authorization: ApiKey ` (the gateway's API-key scheme). +- Only allow-listed tools are used, and each goes only to the agent that needs it + (UTILS_TOOL_ALLOCATION). SQL never runs through Utils-Server: all execution stays on the + guarded execute_trino_query path. +- Every tool is wrapped in the Deep Agent envelope ({ok, data, error}); gateway errors + ({"error", "status_code", "response_text"}) become ok=false with EXTERNAL_TOOL_ERROR. +""" + +import asyncio +import json +import logging +import re +import time +from datetime import timedelta +from typing import Any + +from langchain_core.tools import BaseTool, StructuredTool + +from agent.config import settings +from deep_agent.envelope import fail, ok + +logger = logging.getLogger(__name__) + +# Which agent gets which Utils-Server tool ("main" = the top-level Deep Agent). +UTILS_TOOL_ALLOCATION: dict[str, tuple[str, ...]] = { + "main": ("datetime_parse_relative_expression", "text_normalize", "trino_validate_spatial_expression"), + "value-resolver": ("datetime_parse_relative_expression", "text_normalize", "geo_create_buffer", + "geo_validate_geometry"), + "schema-scout": (), + "result-verifier": (), +} +ALLOWED_UTILS_TOOLS = frozenset(name for names in UTILS_TOOL_ALLOCATION.values() for name in names) + + +def _normalize(name: str) -> str: + return re.sub(r"[^a-z0-9_]", "_", name.lower()) + + +def _canonical(gateway_name: str) -> str | None: + """Map a gateway tool name (possibly namespaced, e.g. 'utils__text_normalize') to an allow-listed name.""" + norm = _normalize(gateway_name) + for name in ALLOWED_UTILS_TOOLS: + if norm == name or norm.endswith(f"_{name}"): + return name + return None + + +def _parse(raw: Any) -> Any: + if isinstance(raw, tuple): # (content, artifact) + raw = raw[0] + if isinstance(raw, list): # content blocks + raw = "".join(b.get("text", "") if isinstance(b, dict) else str(b) for b in raw) + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError: + return raw + return raw + + +def wrap_utils_tool(tool: BaseTool, name: str) -> BaseTool: + """Expose an MCP tool under its canonical name, returning the Deep Agent envelope.""" + + async def _call(**kwargs: Any) -> str: + start = time.perf_counter() + try: + payload = _parse(await tool.ainvoke(kwargs)) + except Exception as exc: + logger.warning("Utils tool %s failed: %s", name, exc) + return fail("EXTERNAL_TOOL_ERROR", f"{name} failed: {exc}") + elapsed_ms = int((time.perf_counter() - start) * 1000) + if isinstance(payload, dict) and "error" in payload and ("status_code" in payload or len(payload) == 1): + return fail("EXTERNAL_TOOL_ERROR", str(payload.get("response_text") or payload["error"]), + status_code=payload.get("status_code"), latency_ms=elapsed_ms) + logger.debug("Utils tool %s answered in %d ms", name, elapsed_ms) + return ok(payload) + + return StructuredTool.from_function( + coroutine=_call, + name=name, + description=f"[Utils-Server] {tool.description}", + args_schema=tool.args_schema, + ) + + +def allocate(tools: list[BaseTool]) -> dict[str, list[BaseTool]]: + """Pick the allow-listed gateway tools and hand each agent its share.""" + by_name: dict[str, BaseTool] = {} + for tool in tools: + name = _canonical(tool.name) + if name and name not in by_name: + by_name[name] = wrap_utils_tool(tool, name) + missing = ALLOWED_UTILS_TOOLS - set(by_name) + if missing: + logger.warning("Utils-Server tools not available on the gateway (pending approval?): %s", sorted(missing)) + return {agent: [by_name[n] for n in names if n in by_name] for agent, names in UTILS_TOOL_ALLOCATION.items()} + + +def gateway_connection() -> dict[str, Any] | None: + if not settings.JARVIS_GATEWAY_URL: + return None + connection: dict[str, Any] = { + "transport": "streamable_http", + "url": f"{settings.JARVIS_GATEWAY_URL.rstrip('/')}/mcp/{settings.JARVIS_MCP_NAMESPACE}/", + "timeout": timedelta(seconds=settings.UTILS_MCP_TIMEOUT_S), + } + if settings.JARVIS_API_KEY: + connection["headers"] = {"Authorization": f"ApiKey {settings.JARVIS_API_KEY}"} + return connection + + +async def load_utils_tools() -> dict[str, list[BaseTool]]: + """Load and allocate the curated Utils-Server tools; empty allocation when unavailable.""" + empty: dict[str, list[BaseTool]] = {agent: [] for agent in UTILS_TOOL_ALLOCATION} + connection = gateway_connection() + if connection is None: + logger.info("JARVIS_GATEWAY_URL not set — Utils-Server tools disabled") + return empty + try: + from langchain_mcp_adapters.client import MultiServerMCPClient + + client = MultiServerMCPClient({"jarvis": connection}) + tools = await asyncio.wait_for(client.get_tools(), timeout=settings.UTILS_MCP_TIMEOUT_S) + except Exception as exc: + logger.warning("Utils-Server tools unavailable (%s: %s) — continuing without them", type(exc).__name__, exc) + return empty + allocation = allocate(tools) + logger.info("Loaded Utils-Server tools: %s", {k: [t.name for t in v] for k, v in allocation.items() if v}) + return allocation diff --git a/agent/tests/deep_agent/test_utils_mcp.py b/agent/tests/deep_agent/test_utils_mcp.py new file mode 100644 index 0000000..5e91b3c --- /dev/null +++ b/agent/tests/deep_agent/test_utils_mcp.py @@ -0,0 +1,142 @@ +import asyncio +import json +from unittest.mock import MagicMock, patch + +import pytest +from langchain_core.messages import AIMessage +from langchain_core.tools import StructuredTool + +from agent.config import settings +from deep_agent import utils_mcp +from deep_agent.agent import acreate_deep_agent, create_deep_agent +from deep_agent.utils_mcp import UTILS_TOOL_ALLOCATION, allocate, gateway_connection, load_utils_tools, wrap_utils_tool + +SCHEMA = {"type": "object", "properties": {"expression": {"type": "string"}}, "required": ["expression"]} + + +def fake_tool(name: str, result=None, exc: Exception | None = None) -> StructuredTool: + async def _run(**kwargs): + if exc: + raise exc + return result if result is not None else json.dumps({"echo": kwargs, "tool": name}) + return StructuredTool.from_function(coroutine=_run, name=name, description=f"{name} description", args_schema=SCHEMA) + + +GATEWAY_TOOLS = [ + fake_tool("utils__datetime_parse_relative_expression"), # namespaced by the gateway + fake_tool("text_normalize"), + fake_tool("trino_validate_spatial_expression"), + fake_tool("geo_create_buffer"), + fake_tool("geo_validate_geometry"), + fake_tool("trino_run_query"), # not allow-listed: SQL stays on the guarded path + fake_tool("langfuse_add_score"), # write tool, never exposed +] + + +@pytest.fixture +def gateway(monkeypatch): + monkeypatch.setattr(settings, "JARVIS_GATEWAY_URL", "http://jarvis-gateway:8001/") + monkeypatch.setattr(settings, "JARVIS_API_KEY", "test-key") + client = MagicMock() + + async def get_tools(): + return GATEWAY_TOOLS + + client.get_tools.side_effect = get_tools + with patch("langchain_mcp_adapters.client.MultiServerMCPClient", return_value=client) as cls: + yield cls + + +def test_gateway_connection_uses_namespace_and_api_key(gateway): + conn = gateway_connection() + assert conn["url"] == "http://jarvis-gateway:8001/mcp/global/" + assert conn["transport"] == "streamable_http" + assert conn["headers"] == {"Authorization": "ApiKey test-key"} + + +@pytest.mark.asyncio +async def test_disabled_without_gateway_url(): + with patch("langchain_mcp_adapters.client.MultiServerMCPClient") as cls: + allocation = await load_utils_tools() + cls.assert_not_called() + assert all(tools == [] for tools in allocation.values()) + + +@pytest.mark.asyncio +async def test_allocation_by_agent(gateway): + allocation = await load_utils_tools() + names = {agent: sorted(t.name for t in tools) for agent, tools in allocation.items()} + assert names["main"] == sorted(UTILS_TOOL_ALLOCATION["main"]) + assert names["value-resolver"] == sorted(UTILS_TOOL_ALLOCATION["value-resolver"]) + assert names["result-verifier"] == [] and names["schema-scout"] == [] + every = {n for ns in names.values() for n in ns} + assert "trino_run_query" not in every and "langfuse_add_score" not in every + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", [ConnectionError("gateway down"), "timeout"]) +async def test_unreachable_gateway_degrades_gracefully(monkeypatch, failure, caplog): + monkeypatch.setattr(settings, "JARVIS_GATEWAY_URL", "http://jarvis-gateway:8001") + monkeypatch.setattr(settings, "UTILS_MCP_TIMEOUT_S", 0.05) + client = MagicMock() + + async def get_tools(): + if failure == "timeout": + await asyncio.sleep(1) + raise failure + + client.get_tools.side_effect = get_tools + with patch("langchain_mcp_adapters.client.MultiServerMCPClient", return_value=client): + allocation = await load_utils_tools() + assert all(tools == [] for tools in allocation.values()) + assert "continuing without them" in caplog.text + + +@pytest.mark.asyncio +async def test_wrapped_tool_returns_envelope(): + wrapped = wrap_utils_tool(fake_tool("x"), "text_normalize") + out = json.loads(await wrapped.ainvoke({"expression": "ת״א"})) + assert out["ok"] is True and out["data"]["echo"] == {"expression": "ת״א"} + assert wrapped.name == "text_normalize" + assert wrapped.description.startswith("[Utils-Server]") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool", [ + fake_tool("x", result=json.dumps({"error": "Unprocessable", "status_code": 422, "response_text": "cannot parse 'השבוע'"})), + fake_tool("x", exc=RuntimeError("connection reset")), +]) +async def test_wrapped_tool_errors_are_external_tool_errors(tool): + out = json.loads(await wrap_utils_tool(tool, "datetime_parse_relative_expression").ainvoke({"expression": "השבוע"})) + assert out["ok"] is False and out["error"]["category"] == "EXTERNAL_TOOL_ERROR" + + +@pytest.mark.asyncio +async def test_content_blocks_are_parsed(): + tool = fake_tool("x", result=[{"type": "text", "text": '{"date": "2026-10-17"}'}]) + out = json.loads(await wrap_utils_tool(tool, "datetime_parse_relative_expression").ainvoke({"expression": "לפני 3 ימים"})) + assert out["data"] == {"date": "2026-10-17"} + + +def test_duplicate_gateway_names_are_deduplicated(): + allocation = allocate([fake_tool("text_normalize"), fake_tool("utils__text_normalize")]) + assert [t.name for t in allocation["main"]] == ["text_normalize"] + + +@pytest.mark.asyncio +async def test_agents_are_offered_their_utils_tools(gateway, scripted_model): + model = scripted_model(AIMessage("done")) + agent = await acreate_deep_agent(model=model) + await agent.ainvoke({"messages": [("user", "q")]}, {"configurable": {"thread_id": "u"}}) + offered = set(model.bound_tool_names[0]) + assert set(UTILS_TOOL_ALLOCATION["main"]) <= offered + assert "geo_create_buffer" not in offered # value-resolver only + + +def test_subagents_receive_their_share(gateway): + allocation = asyncio.run(load_utils_tools()) + with patch("deep_agent.agent.create_harness_agent") as harness: + create_deep_agent(model=MagicMock(), extra_tools=allocation) + subagents = {s["name"]: {t.name for t in s["tools"]} for s in harness.call_args.kwargs["subagents"]} + assert set(UTILS_TOOL_ALLOCATION["value-resolver"]) <= subagents["value-resolver"] + assert not subagents["result-verifier"] & utils_mcp.ALLOWED_UTILS_TOOLS diff --git a/agent/uv.lock b/agent/uv.lock index a9409bc..d05d65b 100644 --- a/agent/uv.lock +++ b/agent/uv.lock @@ -14,6 +14,7 @@ dependencies = [ { name = "greenlet" }, { name = "json-repair" }, { name = "langchain" }, + { name = "langchain-mcp-adapters" }, { name = "langchain-ollama" }, { name = "langchain-openai" }, { name = "langfuse" }, @@ -47,6 +48,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.5.1" }, { name = "json-repair", specifier = ">=0.25.0" }, { name = "langchain", specifier = "==1.3.13" }, + { name = "langchain-mcp-adapters", specifier = ">=0.2,<0.3" }, { name = "langchain-ollama" }, { name = "langchain-openai", specifier = "==1.3.5" }, { name = "langfuse", specifier = ">=2.0.0" }, @@ -741,6 +743,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/70/b6e29470356cbb2c87db2344df44182dac90d69c0cc4b929a805184a54c3/langchain_google_genai-4.3.3-py3-none-any.whl", hash = "sha256:cb189f6eebe801fda4416525a357d82f033efc36713a7c15a45c25eb1e2ddbff", size = 72859, upload-time = "2026-08-10T18:34:25.283Z" }, ] +[[package]] +name = "langchain-mcp-adapters" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "mcp" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/66/1cc7039e2daaddcdea9d8887851fe6eb67401925999b2aa394aa855c7132/langchain_mcp_adapters-0.2.2.tar.gz", hash = "sha256:12d39e91ae4389c54b61b221094e53850b6e152934d8bc10c80665d600e76530", size = 37942, upload-time = "2026-03-16T17:13:30.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/2f/15d5e6c1765d8404a9cce38d8c81d7b33fb3392f9db5b992c000dddbd2a3/langchain_mcp_adapters-0.2.2-py3-none-any.whl", hash = "sha256:d08e64954e86281002653071b7430e0377c9a577cb4ac3143abfeb3e24ef8797", size = 23288, upload-time = "2026-03-16T17:13:29.073Z" }, +] + [[package]] name = "langchain-ollama" version = "1.1.0"