fractal-graph-react-agent 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: fractal-graph-react-agent
3
+ Version: 0.0.0
4
+ Summary: Portable ReAct agent graph with MCP tools and RAG — part of the fractal-agent-graphs catalog
5
+ Author: l4b4r4b4b4
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/l4b4r4b4b4/fractal-agents-runtime
8
+ Project-URL: Repository, https://github.com/l4b4r4b4b4/fractal-agents-runtime
9
+ Project-URL: Issues, https://github.com/l4b4r4b4b4/fractal-agents-runtime/issues
10
+ Keywords: langgraph,langchain,mcp,agent,rag,react,fractal
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Requires-Python: <3.13,>=3.11.0
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: langgraph>=1.0.8
21
+ Requires-Dist: langchain-core>=1.2.11
22
+ Requires-Dist: langchain>=1.2.10
23
+ Requires-Dist: langchain-openai>=1.1.9
24
+ Requires-Dist: langchain-mcp-adapters>=0.2.1
25
+ Requires-Dist: pydantic<3,>=2.11.0
26
+ Requires-Dist: aiohttp>=3.8.0
27
+ Requires-Dist: mcp>=1.9.1
28
+ Requires-Dist: fractal-agent-infra
29
+
30
+ # fractal-graph-react-agent
31
+
32
+ ReAct agent with MCP tools — part of the [fractal-agents-runtime](https://github.com/l4b4r4b4b4/fractal-agents-runtime) graph catalog.
33
+
34
+ ## Overview
35
+
36
+ This package provides a portable, self-contained ReAct agent graph built on LangGraph with:
37
+
38
+ - **MCP tool integration** — connects to Model Context Protocol servers for extensible tool use
39
+ - **RAG tool factory** — creates retrieval-augmented generation tools from document collections
40
+ - **OAuth token exchange** — handles MCP server authentication with token caching
41
+ - **Multi-provider LLM support** — OpenAI, Anthropic, Google, and custom OpenAI-compatible endpoints
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ uv add fractal-graph-react-agent
47
+ ```
48
+
49
+ ## Usage
50
+
51
+ The graph is a portable factory function that accepts a `RunnableConfig` and optional persistence components via dependency injection:
52
+
53
+ ```python
54
+ from react_agent import graph
55
+ from langchain_core.runnables import RunnableConfig
56
+
57
+ # Build the agent graph — runtime injects persistence
58
+ config = RunnableConfig(configurable={"model_name": "openai:gpt-4o"})
59
+ agent = await graph(config, checkpointer=my_checkpointer, store=my_store)
60
+
61
+ # Invoke
62
+ result = await agent.ainvoke({"messages": [{"role": "user", "content": "Hello!"}]})
63
+ ```
64
+
65
+ ### Dependency Injection
66
+
67
+ The `graph()` factory uses dependency injection for persistence — it never imports from any specific runtime:
68
+
69
+ ```python
70
+ # The runtime (e.g., Robyn server) creates and injects these:
71
+ from my_runtime.database import get_checkpointer, get_store
72
+
73
+ agent = await graph(
74
+ config,
75
+ checkpointer=get_checkpointer(), # Thread-level conversation memory
76
+ store=get_store(), # Cross-thread long-term memory
77
+ )
78
+ ```
79
+
80
+ When `checkpointer` and `store` are `None` (the default), the agent runs without persistence — useful for testing or stateless invocations.
81
+
82
+ ## Configuration
83
+
84
+ The graph is configured via `RunnableConfig.configurable`:
85
+
86
+ | Key | Type | Default | Description |
87
+ |-----|------|---------|-------------|
88
+ | `model_name` | `str` | `"openai:gpt-4o"` | LLM provider and model |
89
+ | `temperature` | `float` | `0.7` | Sampling temperature |
90
+ | `max_tokens` | `int` | `4000` | Maximum generation tokens |
91
+ | `system_prompt` | `str` | Generic helpful assistant | System prompt |
92
+ | `mcp_config` | `dict` | `None` | MCP server configuration |
93
+ | `rag` | `dict` | `None` | RAG collection configuration |
94
+ | `base_url` | `str` | `None` | Custom OpenAI-compatible endpoint |
95
+
96
+ ## Architecture
97
+
98
+ This package is part of the 3-layer architecture:
99
+
100
+ ```
101
+ apps/ → Thin HTTP wrappers (Robyn, FastAPI, etc.)
102
+ ↓ depends on
103
+ graphs/ → Portable agent architectures (this package)
104
+ ↓ depends on
105
+ infra/ → Shared runtime infrastructure (tracing, auth, store namespace)
106
+ ```
107
+
108
+ Graphs have **zero coupling** to any runtime — they can be deployed to LangGraph Platform, embedded in any server framework, or run standalone.
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,10 @@
1
+ react_agent/__init__.py,sha256=qM8ayb4rkSKjA1X3EGfwhchj4utroFGtsCbsAUQ_3PQ,851
2
+ react_agent/agent.py,sha256=OwbVC3Z9xxVpm4WDzPg8R9wAtpFsqiJJ4rcztcn5MOE,17411
3
+ react_agent/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ react_agent/utils/mcp_interceptors.py,sha256=Z4VUWtHAvmVdG75pF_52eSu1zYcEENJYDDfRsujjMS8,4382
5
+ react_agent/utils/token.py,sha256=moyNmOCYb_xrygtdrP2ugHO40FMfO4tUFIn4viiD0zw,7413
6
+ react_agent/utils/tools.py,sha256=JNWodIj_HUA7vEmWk88sAWvGJHkWqEi2lfScQHFPnF0,3583
7
+ fractal_graph_react_agent-0.0.0.dist-info/METADATA,sha256=zqBmTkRq6nTF1j8-SFgetLk_HdDg1K974iEJRMRfx6c,4195
8
+ fractal_graph_react_agent-0.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
9
+ fractal_graph_react_agent-0.0.0.dist-info/top_level.txt,sha256=2wR6hO9F5NSWFpBOQjDUfSa5BlChaekPUxuyTi-gG9M,12
10
+ fractal_graph_react_agent-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ react_agent
@@ -0,0 +1,28 @@
1
+ """ReAct agent with MCP tools — portable graph architecture.
2
+
3
+ This package provides a self-contained ReAct agent graph built on LangGraph
4
+ with MCP tool integration, RAG tool factory, and multi-provider LLM support.
5
+
6
+ The graph factory uses dependency injection for persistence — it never
7
+ imports from any specific runtime.
8
+
9
+ Usage::
10
+
11
+ from react_agent import graph
12
+
13
+ # Build the agent — runtime injects persistence
14
+ agent = await graph(config, checkpointer=my_checkpointer, store=my_store)
15
+ """
16
+
17
+ from importlib.metadata import PackageNotFoundError, version
18
+
19
+ from react_agent.agent import graph
20
+
21
+ __all__ = ["graph"]
22
+
23
+ try:
24
+ __version__ = version("fractal-graph-react-agent")
25
+ except PackageNotFoundError:
26
+ # Package is not installed (running from source / editable install
27
+ # before first ``uv sync``).
28
+ __version__ = "0.0.0-dev"
react_agent/agent.py ADDED
@@ -0,0 +1,470 @@
1
+ import logging
2
+ import os
3
+
4
+ from langchain.agents import create_agent
5
+ from langchain.chat_models import init_chat_model
6
+ from langchain_core.runnables import RunnableConfig
7
+ from langchain_mcp_adapters.client import MultiServerMCPClient
8
+ from langchain_openai import ChatOpenAI
9
+ from pydantic import BaseModel, Field
10
+
11
+ from react_agent.utils.mcp_interceptors import (
12
+ handle_interaction_required,
13
+ )
14
+ from react_agent.utils.token import fetch_tokens
15
+ from react_agent.utils.tools import create_rag_tool
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _safe_present_configurable_keys(config: RunnableConfig) -> list[str]:
21
+ """Return a stable, non-sensitive view of the configurable keys present.
22
+
23
+ This intentionally does not log values to avoid leaking secrets.
24
+ """
25
+ configurable: dict = config.get("configurable", {}) or {}
26
+ return sorted(str(key) for key in configurable)
27
+
28
+
29
+ def _safe_mask_url(url: str | None) -> str | None:
30
+ """Mask potentially sensitive URL parts (query strings, userinfo).
31
+
32
+ This keeps the scheme/host/path which is enough to confirm routing.
33
+ """
34
+ if not url:
35
+ return url
36
+ # Avoid importing urllib just for logging; keep it conservative.
37
+ # Drop query fragments if present.
38
+ return url.split("?", 1)[0].split("#", 1)[0]
39
+
40
+
41
+ def _merge_assistant_configurable_into_run_config(
42
+ config: RunnableConfig,
43
+ ) -> RunnableConfig:
44
+ """Merge assistant-level configurable settings into the run config.
45
+
46
+ LangGraph runtime-inmem passes per-run metadata in `configurable`, but in some
47
+ versions it may not automatically inject assistant `configurable` fields into
48
+ `graph(config)`. This merge reads the assistant settings (if present) and
49
+ overlays them onto the run config so fields such as `base_url` reach the agent.
50
+
51
+ Notes:
52
+ - Values are not logged here to avoid leaking secrets.
53
+ - Run-level keys take precedence over assistant-level keys.
54
+
55
+ Returns:
56
+ A new RunnableConfig with merged `configurable`.
57
+ """
58
+ original_configurable: dict = config.get("configurable", {}) or {}
59
+
60
+ # Common places LangGraph API may attach assistant settings:
61
+ # - "assistant" (object)
62
+ # - "assistant_config" (object)
63
+ # - "assistant_configurable" (already flattened)
64
+ assistant_configurable: dict = {}
65
+
66
+ assistant_container = original_configurable.get("assistant")
67
+ if isinstance(assistant_container, dict):
68
+ assistant_cfg = assistant_container.get("configurable")
69
+ if isinstance(assistant_cfg, dict):
70
+ assistant_configurable.update(assistant_cfg)
71
+
72
+ assistant_config_container = original_configurable.get("assistant_config")
73
+ if isinstance(assistant_config_container, dict):
74
+ assistant_cfg = assistant_config_container.get("configurable")
75
+ if isinstance(assistant_cfg, dict):
76
+ assistant_configurable.update(assistant_cfg)
77
+
78
+ assistant_config_flat = original_configurable.get("assistant_configurable")
79
+ if isinstance(assistant_config_flat, dict):
80
+ assistant_configurable.update(assistant_config_flat)
81
+
82
+ if not assistant_configurable:
83
+ return config
84
+
85
+ merged_configurable = {**assistant_configurable, **original_configurable}
86
+ return {**config, "configurable": merged_configurable}
87
+
88
+
89
+ UNEDITABLE_SYSTEM_PROMPT = (
90
+ "\nIf the tool throws an error requiring authentication, provide the user"
91
+ " with a Markdown link to the authentication page and prompt them to"
92
+ " authenticate."
93
+ )
94
+
95
+ DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant that has access to a variety of tools."
96
+
97
+
98
+ class RagConfig(BaseModel):
99
+ rag_url: str | None = None
100
+ """The URL of the rag server"""
101
+ collections: list[str] | None = None
102
+ """The collections to use for rag"""
103
+
104
+
105
+ class MCPServerConfig(BaseModel):
106
+ """Configuration for a single MCP server.
107
+
108
+ Attributes:
109
+ name: Stable identifier for this server entry. Used as the key when
110
+ creating the MultiServerMCPClient config dict.
111
+ url: Base URL for the MCP server (may or may not end with /mcp).
112
+ tools: Optional list of tool names to expose from this server.
113
+ If omitted/None, all tools from the server are exposed.
114
+ auth_required: Whether this server requires auth token exchange.
115
+ """
116
+
117
+ name: str = Field(
118
+ default="default",
119
+ optional=True,
120
+ )
121
+ url: str
122
+ tools: list[str] | None = Field(
123
+ default=None,
124
+ optional=True,
125
+ )
126
+ auth_required: bool = Field(
127
+ default=False,
128
+ optional=True,
129
+ )
130
+
131
+
132
+ class MCPConfig(BaseModel):
133
+ """Multi-server MCP configuration (no backward compatibility)."""
134
+
135
+ servers: list[MCPServerConfig] = Field(default_factory=list)
136
+
137
+
138
+ class GraphConfigPydantic(BaseModel):
139
+ model_name: str | None = Field(
140
+ default="openai:gpt-4o",
141
+ metadata={
142
+ "x_oap_ui_config": {
143
+ "type": "select",
144
+ "default": "openai:gpt-4o",
145
+ "description": "The model to use in all generations",
146
+ "options": [
147
+ {
148
+ "label": "Claude Sonnet 4",
149
+ "value": "anthropic:claude-sonnet-4-0",
150
+ },
151
+ {
152
+ "label": "Claude 3.7 Sonnet",
153
+ "value": "anthropic:claude-3-7-sonnet-latest",
154
+ },
155
+ {
156
+ "label": "Claude 3.5 Sonnet",
157
+ "value": "anthropic:claude-3-5-sonnet-latest",
158
+ },
159
+ {
160
+ "label": "Claude 3.5 Haiku",
161
+ "value": "anthropic:claude-3-5-haiku-latest",
162
+ },
163
+ {"label": "o4 mini", "value": "openai:o4-mini"},
164
+ {"label": "o3", "value": "openai:o3"},
165
+ {"label": "o3 mini", "value": "openai:o3-mini"},
166
+ {"label": "GPT 4o", "value": "openai:gpt-4o"},
167
+ {"label": "GPT 4o mini", "value": "openai:gpt-4o-mini"},
168
+ {"label": "GPT 4.1", "value": "openai:gpt-4.1"},
169
+ {"label": "GPT 4.1 mini", "value": "openai:gpt-4.1-mini"},
170
+ {
171
+ "label": "Custom OpenAI-compatible endpoint",
172
+ "value": "custom:",
173
+ },
174
+ ],
175
+ }
176
+ },
177
+ )
178
+ temperature: float | None = Field(
179
+ default=0.7,
180
+ metadata={
181
+ "x_oap_ui_config": {
182
+ "type": "slider",
183
+ "default": 0.7,
184
+ "min": 0,
185
+ "max": 2,
186
+ "step": 0.1,
187
+ "description": "Controls randomness (0 = deterministic, 2 = creative)",
188
+ }
189
+ },
190
+ )
191
+ max_tokens: int | None = Field(
192
+ default=4000,
193
+ metadata={
194
+ "x_oap_ui_config": {
195
+ "type": "number",
196
+ "default": 4000,
197
+ "min": 1,
198
+ "description": "The maximum number of tokens to generate",
199
+ }
200
+ },
201
+ )
202
+ system_prompt: str | None = Field(
203
+ default=DEFAULT_SYSTEM_PROMPT,
204
+ metadata={
205
+ "x_oap_ui_config": {
206
+ "type": "textarea",
207
+ "placeholder": "Enter a system prompt...",
208
+ "description": (
209
+ "The system prompt to use in all generations."
210
+ " The following prompt will always be included"
211
+ " at the end of the system prompt:\n---"
212
+ f"{UNEDITABLE_SYSTEM_PROMPT}\n---"
213
+ ),
214
+ "default": DEFAULT_SYSTEM_PROMPT,
215
+ }
216
+ },
217
+ )
218
+ mcp_config: MCPConfig | None = Field(
219
+ default=None,
220
+ optional=True,
221
+ metadata={
222
+ "x_oap_ui_config": {
223
+ "type": "mcp",
224
+ }
225
+ },
226
+ )
227
+ rag: RagConfig | None = Field(
228
+ default=None,
229
+ optional=True,
230
+ metadata={
231
+ "x_oap_ui_config": {
232
+ "type": "rag",
233
+ # Here is where you would set the default collection. Use collection IDs
234
+ # "default": {
235
+ # "collections": [
236
+ # "fd4fac19-886c-4ac8-8a59-fff37d2b847f",
237
+ # "659abb76-fdeb-428a-ac8f-03b111183e25",
238
+ # ]
239
+ # },
240
+ }
241
+ },
242
+ )
243
+ # Custom endpoint configuration
244
+ base_url: str | None = Field(
245
+ default=None,
246
+ optional=True,
247
+ metadata={
248
+ "x_oap_ui_config": {
249
+ "type": "text",
250
+ "placeholder": "http://localhost:7374/v1",
251
+ "description": "Base URL for custom OpenAI-compatible API",
252
+ "visible_when": {"model_name": "custom:"},
253
+ }
254
+ },
255
+ )
256
+ custom_model_name: str | None = Field(
257
+ default=None,
258
+ optional=True,
259
+ metadata={
260
+ "x_oap_ui_config": {
261
+ "type": "text",
262
+ "placeholder": "mistralai/ministral-3b-instruct",
263
+ "description": "Model name for custom endpoint",
264
+ "visible_when": {"model_name": "custom:"},
265
+ }
266
+ },
267
+ )
268
+ custom_api_key: str | None = Field(
269
+ default=None,
270
+ optional=True,
271
+ metadata={
272
+ "x_oap_ui_config": {
273
+ "type": "password",
274
+ "placeholder": "Leave empty for local vLLM",
275
+ "description": "API key for custom endpoint (optional)",
276
+ "visible_when": {"model_name": "custom:"},
277
+ }
278
+ },
279
+ )
280
+
281
+
282
+ def get_api_key_for_model(model_name: str, config: RunnableConfig):
283
+ model_name = model_name.lower()
284
+
285
+ # Handle custom endpoints
286
+ if model_name.startswith("custom:"):
287
+ # First check config for custom_api_key
288
+ custom_key = config.get("configurable", {}).get("custom_api_key")
289
+ if custom_key:
290
+ return custom_key
291
+ # Fallback to environment variable
292
+ return os.getenv("CUSTOM_API_KEY")
293
+
294
+ # Existing logic for standard providers
295
+ model_to_key = {
296
+ "openai:": "OPENAI_API_KEY",
297
+ "anthropic:": "ANTHROPIC_API_KEY",
298
+ "google": "GOOGLE_API_KEY",
299
+ }
300
+ key_name = next(
301
+ (key for prefix, key in model_to_key.items() if model_name.startswith(prefix)),
302
+ None,
303
+ )
304
+ if not key_name:
305
+ return None
306
+ api_keys = config.get("configurable", {}).get("apiKeys", {})
307
+ if api_keys and api_keys.get(key_name) and len(api_keys[key_name]) > 0:
308
+ return api_keys[key_name]
309
+ # Fallback to environment variable
310
+ return os.getenv(key_name)
311
+
312
+
313
+ async def graph(config: RunnableConfig, *, checkpointer=None, store=None):
314
+ config = _merge_assistant_configurable_into_run_config(config)
315
+
316
+ # INFO-level, runtime-safe logging to confirm config propagation.
317
+ # Do NOT log values that may contain secrets.
318
+ logger.info(
319
+ "graph() invoked; configurable_keys=%s",
320
+ _safe_present_configurable_keys(config),
321
+ )
322
+
323
+ cfg = GraphConfigPydantic(**(config.get("configurable", {}) or {}))
324
+
325
+ logger.info(
326
+ "graph() parsed_config; model_name=%s base_url_present=%s"
327
+ " custom_model_name_present=%s custom_api_key_present=%s",
328
+ cfg.model_name,
329
+ bool(cfg.base_url),
330
+ bool(cfg.custom_model_name),
331
+ bool(cfg.custom_api_key),
332
+ )
333
+
334
+ tools = []
335
+
336
+ supabase_token = config.get("configurable", {}).get("x-supabase-access-token")
337
+ if cfg.rag and cfg.rag.rag_url and cfg.rag.collections and supabase_token:
338
+ for collection in cfg.rag.collections:
339
+ rag_tool = await create_rag_tool(cfg.rag.rag_url, collection, supabase_token)
340
+ tools.append(rag_tool)
341
+
342
+ if cfg.mcp_config and cfg.mcp_config.servers:
343
+ mcp_server_entries: dict[str, dict] = {}
344
+ server_tool_filters: dict[str, set[str] | None] = {}
345
+ any_auth_required = any(server.auth_required for server in cfg.mcp_config.servers)
346
+
347
+ if any_auth_required:
348
+ mcp_tokens = await fetch_tokens(config)
349
+ else:
350
+ mcp_tokens = None
351
+
352
+ for server in cfg.mcp_config.servers:
353
+ # Append /mcp only if the URL doesn't already end with it.
354
+ raw_url = server.url.rstrip("/")
355
+ server_url = raw_url if raw_url.endswith("/mcp") else raw_url + "/mcp"
356
+
357
+ headers: dict[str, str] = {}
358
+ if server.auth_required:
359
+ if not mcp_tokens:
360
+ # Auth required but token exchange failed / not available.
361
+ # Skip connecting to this server.
362
+ logger.warning(
363
+ "MCP server skipped (auth required but no tokens): name=%s url=%s",
364
+ server.name,
365
+ _safe_mask_url(server_url),
366
+ )
367
+ continue
368
+ headers["Authorization"] = f"Bearer {mcp_tokens['access_token']}"
369
+
370
+ # Ensure unique keys for MultiServerMCPClient config
371
+ server_key = server.name or "default"
372
+ if server_key in mcp_server_entries:
373
+ # Deterministic de-dupe by suffixing with an index.
374
+ index = 2
375
+ while f"{server_key}-{index}" in mcp_server_entries:
376
+ index += 1
377
+ server_key = f"{server_key}-{index}"
378
+
379
+ mcp_server_entries[server_key] = {
380
+ "transport": "http",
381
+ "url": server_url,
382
+ "headers": headers,
383
+ }
384
+ server_tool_filters[server_key] = set(server.tools) if server.tools else None
385
+
386
+ if mcp_server_entries:
387
+ try:
388
+ mcp_client = MultiServerMCPClient(
389
+ mcp_server_entries,
390
+ tool_interceptors=[handle_interaction_required],
391
+ )
392
+ mcp_tools = await mcp_client.get_tools()
393
+
394
+ # Apply per-server filtering when requested.
395
+ filtered_tools = []
396
+ for tool in mcp_tools:
397
+ tool_origin = getattr(tool, "server_name", None)
398
+ if tool_origin and tool_origin in server_tool_filters:
399
+ requested = server_tool_filters[tool_origin]
400
+ if requested is None or tool.name in requested:
401
+ filtered_tools.append(tool)
402
+ else:
403
+ # If origin is unknown, include it (conservative).
404
+ filtered_tools.append(tool)
405
+
406
+ tools.extend(filtered_tools)
407
+ logger.info(
408
+ "MCP tools loaded: count=%d servers=%s",
409
+ len(filtered_tools),
410
+ [_safe_mask_url(entry["url"]) for entry in mcp_server_entries.values()],
411
+ )
412
+ except Exception as e:
413
+ logger.warning("Failed to fetch MCP tools: %s", str(e))
414
+
415
+ # Initialize model based on configuration
416
+ if cfg.base_url:
417
+ # Custom endpoint - use ChatOpenAI with OpenAI-compatible base URL.
418
+ # LangChain's vLLM integration docs recommend `openai_api_base` + `openai_api_key="EMPTY"`.
419
+ masked_base_url = _safe_mask_url(cfg.base_url)
420
+ logger.info("LLM routing: custom endpoint enabled; base_url=%s", masked_base_url)
421
+
422
+ # Get API key for custom endpoint (do not log the key)
423
+ api_key = get_api_key_for_model("custom:", config)
424
+ if not api_key:
425
+ # Use "EMPTY" for local vLLM that doesn't require authentication
426
+ api_key = "EMPTY"
427
+ logger.info("LLM auth: no custom API key provided; using EMPTY")
428
+ else:
429
+ logger.info("LLM auth: custom API key provided (masked)")
430
+
431
+ # Use custom model name if provided, otherwise use the configured model_name
432
+ model_name = cfg.custom_model_name or cfg.model_name
433
+ logger.info("LLM model: %s", model_name)
434
+
435
+ # Prefer the vLLM-recommended parameters. Avoid passing multiple aliases
436
+ # for the same setting to reduce ambiguity across versions.
437
+ model = ChatOpenAI(
438
+ openai_api_base=cfg.base_url,
439
+ openai_api_key=api_key,
440
+ model=model_name,
441
+ temperature=cfg.temperature,
442
+ max_tokens=cfg.max_tokens,
443
+ )
444
+ else:
445
+ # Standard provider - use init_chat_model
446
+ logger.info("LLM routing: standard provider enabled; model_name=%s", cfg.model_name)
447
+ api_key = get_api_key_for_model(cfg.model_name, config)
448
+ logger.info("LLM auth: standard provider api key present=%s", bool(api_key))
449
+
450
+ model = init_chat_model(
451
+ cfg.model_name,
452
+ temperature=cfg.temperature,
453
+ max_tokens=cfg.max_tokens,
454
+ api_key=api_key or "No token found",
455
+ )
456
+
457
+ # Persistence components are injected by the runtime caller.
458
+ # When None (the default), the agent runs without persistence.
459
+ if checkpointer is not None:
460
+ logger.info("graph() using injected checkpointer for thread persistence")
461
+ if store is not None:
462
+ logger.info("graph() using injected store for cross-thread memory")
463
+
464
+ return create_agent(
465
+ model=model,
466
+ tools=tools,
467
+ system_prompt=cfg.system_prompt + UNEDITABLE_SYSTEM_PROMPT,
468
+ checkpointer=checkpointer,
469
+ store=store,
470
+ )
File without changes
@@ -0,0 +1,126 @@
1
+ """MCP tool interceptors for langchain-mcp-adapters.
2
+
3
+ Provides middleware-like interceptors that wrap MCP tool calls to handle
4
+ cross-cutting concerns like authentication errors, logging, and retries.
5
+
6
+ These interceptors are passed to ``MultiServerMCPClient`` via the
7
+ ``tool_interceptors`` parameter and execute in "onion" order (first
8
+ interceptor in the list is the outermost layer).
9
+
10
+ Usage::
11
+
12
+ from langchain_mcp_adapters.client import MultiServerMCPClient
13
+ from react_agent.utils.mcp_interceptors import handle_interaction_required
14
+
15
+ client = MultiServerMCPClient(
16
+ {...},
17
+ tool_interceptors=[handle_interaction_required],
18
+ )
19
+ """
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ from langchain_core.tools import ToolException
25
+ from langchain_mcp_adapters.interceptors import MCPToolCallRequest
26
+ from mcp import McpError
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ def _find_first_mcp_error_nested(exception: BaseException) -> McpError | None:
32
+ """Walk a (possibly nested) exception tree to find the first ``McpError``.
33
+
34
+ The MCP SDK can raise ``McpError`` directly or wrap it inside an
35
+ ``ExceptionGroup`` when multiple tasks fail concurrently. This helper
36
+ recurses into ``ExceptionGroup.exceptions`` to locate the root MCP error.
37
+
38
+ Args:
39
+ exception: The caught exception (may be an ``ExceptionGroup``).
40
+
41
+ Returns:
42
+ The first ``McpError`` found, or ``None``.
43
+ """
44
+ if isinstance(exception, McpError):
45
+ return exception
46
+ if isinstance(exception, ExceptionGroup):
47
+ for sub_exception in exception.exceptions:
48
+ if found := _find_first_mcp_error_nested(sub_exception):
49
+ return found
50
+ return None
51
+
52
+
53
+ def _extract_interaction_message(error_data: dict[str, Any]) -> str:
54
+ """Build a user-facing message from an ``interaction_required`` error payload.
55
+
56
+ The MCP ``interaction_required`` error (code -32003) may carry a nested
57
+ ``message`` dict with a ``text`` field and/or a ``url`` for the user to
58
+ visit. This helper extracts both into a single string that the LLM can
59
+ present to the user (e.g. as a clickable link).
60
+
61
+ Args:
62
+ error_data: The ``data`` dict from the MCP error response.
63
+
64
+ Returns:
65
+ A human-readable error message, optionally including the URL.
66
+ """
67
+ message_payload = error_data.get("message", {})
68
+ error_message_text = "Required interaction"
69
+ if isinstance(message_payload, dict):
70
+ error_message_text = message_payload.get("text") or error_message_text
71
+
72
+ if url := error_data.get("url"):
73
+ error_message_text = f"{error_message_text} {url}"
74
+
75
+ return error_message_text
76
+
77
+
78
+ async def handle_interaction_required(
79
+ request: MCPToolCallRequest,
80
+ handler: Any,
81
+ ) -> Any:
82
+ """Intercept ``interaction_required`` MCP errors and raise ``ToolException``.
83
+
84
+ When an MCP server responds with error code ``-32003``
85
+ (``interaction_required``), the raw ``McpError`` is caught and converted
86
+ into a LangChain ``ToolException`` with a clean, user-facing message.
87
+ This prevents noisy stack traces from cluttering the logs and gives the
88
+ LLM a structured error it can relay to the user.
89
+
90
+ All other exceptions are re-raised unmodified.
91
+
92
+ Args:
93
+ request: The incoming MCP tool call request (contains tool name, args,
94
+ runtime context).
95
+ handler: The next handler in the interceptor chain. Call
96
+ ``await handler(request)`` to proceed with the actual tool
97
+ invocation.
98
+
99
+ Returns:
100
+ The tool call result if the call succeeds.
101
+
102
+ Raises:
103
+ ToolException: If the MCP server returns ``interaction_required``.
104
+ """
105
+ try:
106
+ return await handler(request)
107
+ except BaseException as exception:
108
+ mcp_error = _find_first_mcp_error_nested(exception)
109
+
110
+ if not mcp_error:
111
+ raise
112
+
113
+ error_details = mcp_error.error
114
+ is_interaction_required = getattr(error_details, "code", None) == -32003
115
+ error_data = getattr(error_details, "data", None) or {}
116
+
117
+ if is_interaction_required:
118
+ error_message = _extract_interaction_message(error_data)
119
+ logger.info(
120
+ "MCP interaction_required for tool=%s: %s",
121
+ request.name,
122
+ error_message,
123
+ )
124
+ raise ToolException(error_message) from exception
125
+
126
+ raise
@@ -0,0 +1,240 @@
1
+ """MCP token exchange and caching utilities.
2
+
3
+ Handles OAuth2 token exchange with MCP servers and caches tokens in the
4
+ LangGraph Store using the canonical org-scoped namespace convention::
5
+
6
+ (org_id, user_id, assistant_id, "tokens")
7
+
8
+ See :mod:`fractal_agent_infra.store_namespace` for the namespace contract.
9
+ """
10
+
11
+ import contextlib
12
+ import logging
13
+ from datetime import UTC
14
+ from typing import Any
15
+
16
+ import aiohttp
17
+ from fractal_agent_infra.store_namespace import (
18
+ CATEGORY_TOKENS,
19
+ build_namespace,
20
+ extract_namespace_components,
21
+ )
22
+ from langchain_core.runnables import RunnableConfig
23
+ from langgraph.config import get_store
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ # Store key for the cached token data within the namespace.
28
+ _TOKEN_STORE_KEY = "data"
29
+
30
+
31
+ async def get_mcp_access_token(
32
+ supabase_token: str,
33
+ base_mcp_url: str,
34
+ ) -> dict[str, Any] | None:
35
+ """
36
+ Exchange a Supabase token for an MCP access token.
37
+
38
+ Args:
39
+ supabase_token: The Supabase token to exchange
40
+ base_mcp_url: The base URL for the MCP server
41
+
42
+ Returns:
43
+ The token data as a dictionary if successful, None otherwise
44
+ """
45
+ try:
46
+ # Exchange Supabase token for MCP access token
47
+ form_data = {
48
+ "client_id": "mcp_default",
49
+ "subject_token": supabase_token,
50
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
51
+ "resource": base_mcp_url.rstrip("/") + "/mcp",
52
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
53
+ }
54
+
55
+ async with (
56
+ aiohttp.ClientSession() as session,
57
+ session.post(
58
+ base_mcp_url.rstrip("/") + "/oauth/token",
59
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
60
+ data=form_data,
61
+ ) as token_response,
62
+ ):
63
+ if token_response.status != 200:
64
+ response_text = await token_response.text()
65
+ logger.error("Token exchange failed: %s", response_text)
66
+ return None
67
+
68
+ token_data = await token_response.json()
69
+ return token_data if isinstance(token_data, dict) else None
70
+ except Exception:
71
+ logger.exception("Error during token exchange")
72
+
73
+ return None
74
+
75
+
76
+ def _build_token_namespace(
77
+ config: RunnableConfig,
78
+ ) -> tuple[str, str, str, str] | None:
79
+ """Build the store namespace tuple for token caching.
80
+
81
+ Extracts ``(org_id, user_id, assistant_id)`` from the config and appends
82
+ the ``"tokens"`` category.
83
+
84
+ Returns:
85
+ A 4-tuple namespace, or ``None`` if required components are missing.
86
+ """
87
+ components = extract_namespace_components(config)
88
+ if components is None:
89
+ return None
90
+
91
+ return build_namespace(
92
+ components.org_id,
93
+ components.user_id,
94
+ components.assistant_id,
95
+ CATEGORY_TOKENS,
96
+ )
97
+
98
+
99
+ async def get_tokens(config: RunnableConfig) -> dict[str, Any] | None:
100
+ """Return cached MCP tokens from the LangGraph store if present and valid.
101
+
102
+ Uses the org-scoped namespace ``(org_id, user_id, assistant_id, "tokens")``.
103
+
104
+ This function is deliberately defensive:
105
+ - Store may be unavailable (returns None).
106
+ - Namespace components may be missing (returns None).
107
+ - Token objects may have unexpected shapes.
108
+ - Missing/invalid expiration metadata results in cache eviction + None.
109
+ """
110
+ store = get_store()
111
+ if store is None:
112
+ return None
113
+
114
+ namespace = _build_token_namespace(config)
115
+ if namespace is None:
116
+ return None
117
+
118
+ try:
119
+ token_record = await store.aget(namespace, _TOKEN_STORE_KEY)
120
+ except Exception:
121
+ logger.debug("get_tokens: store.aget failed", exc_info=True)
122
+ return None
123
+
124
+ if not token_record:
125
+ return None
126
+
127
+ tokens_value = getattr(token_record, "value", None)
128
+ if not isinstance(tokens_value, dict):
129
+ return None
130
+
131
+ created_at = getattr(token_record, "created_at", None)
132
+ if created_at is None:
133
+ # Without created_at, we cannot safely evaluate expiry.
134
+ with contextlib.suppress(Exception):
135
+ await store.adelete(namespace, _TOKEN_STORE_KEY)
136
+ return None
137
+
138
+ expires_in_raw = tokens_value.get("expires_in")
139
+ expires_in_seconds: float | None = None
140
+ if expires_in_raw is not None:
141
+ with contextlib.suppress(TypeError, ValueError):
142
+ expires_in_seconds = float(expires_in_raw)
143
+
144
+ if expires_in_seconds is None:
145
+ with contextlib.suppress(Exception):
146
+ await store.adelete(namespace, _TOKEN_STORE_KEY)
147
+ return None
148
+
149
+ # At this point expires_in_seconds is guaranteed to be a float.
150
+ validated_expires: float = expires_in_seconds
151
+
152
+ from datetime import datetime, timedelta
153
+
154
+ current_time = datetime.now(UTC)
155
+ expiration_time = created_at + timedelta(seconds=validated_expires)
156
+
157
+ if current_time > expiration_time:
158
+ with contextlib.suppress(Exception):
159
+ await store.adelete(namespace, _TOKEN_STORE_KEY)
160
+ return None
161
+
162
+ return tokens_value
163
+
164
+
165
+ async def set_tokens(config: RunnableConfig, tokens: dict[str, Any] | None) -> None:
166
+ """Persist MCP tokens to the LangGraph store (best-effort).
167
+
168
+ Uses the org-scoped namespace ``(org_id, user_id, assistant_id, "tokens")``.
169
+ """
170
+ if tokens is None:
171
+ return
172
+
173
+ store = get_store()
174
+ if store is None:
175
+ return
176
+
177
+ namespace = _build_token_namespace(config)
178
+ if namespace is None:
179
+ return
180
+
181
+ try:
182
+ await store.aput(namespace, _TOKEN_STORE_KEY, tokens)
183
+ except Exception:
184
+ # Best-effort cache; ignore storage failures.
185
+ logger.debug("set_tokens: store.aput failed", exc_info=True)
186
+ return
187
+
188
+
189
+ async def fetch_tokens(config: RunnableConfig) -> dict[str, Any] | None:
190
+ """Fetch MCP access token if it doesn't already exist in the store.
191
+
192
+ Supports the multi-server MCP config shape::
193
+
194
+ configurable.mcp_config.servers = [
195
+ {"name": "...", "url": "...", "auth_required": bool, ...},
196
+ ...
197
+ ]
198
+
199
+ Returns a token dict that can be reused for one or more auth-required
200
+ MCP servers, or ``None`` when tokens are unavailable.
201
+ """
202
+ current_tokens = await get_tokens(config)
203
+ if isinstance(current_tokens, dict) and current_tokens:
204
+ return current_tokens
205
+
206
+ configurable = config.get("configurable", {}) or {}
207
+
208
+ supabase_token = configurable.get("x-supabase-access-token")
209
+ if not supabase_token:
210
+ return None
211
+
212
+ mcp_config = configurable.get("mcp_config")
213
+ if not isinstance(mcp_config, dict):
214
+ return None
215
+
216
+ servers = mcp_config.get("servers")
217
+ if not isinstance(servers, list) or not servers:
218
+ return None
219
+
220
+ # Find the first auth-required server with a usable URL.
221
+ base_mcp_url: str | None = None
222
+ for server in servers:
223
+ if not isinstance(server, dict):
224
+ continue
225
+ if not server.get("auth_required"):
226
+ continue
227
+ url_value = server.get("url")
228
+ if isinstance(url_value, str) and url_value.strip():
229
+ base_mcp_url = url_value.strip()
230
+ break
231
+
232
+ if not base_mcp_url:
233
+ return None
234
+
235
+ mcp_tokens: dict[str, Any] | None = await get_mcp_access_token(supabase_token, base_mcp_url)
236
+ if mcp_tokens is None:
237
+ return None
238
+
239
+ await set_tokens(config, mcp_tokens)
240
+ return mcp_tokens
@@ -0,0 +1,94 @@
1
+ import re
2
+ from typing import Annotated
3
+
4
+ import aiohttp
5
+ from langchain_core.tools import tool
6
+
7
+
8
+ async def create_rag_tool(rag_url: str, collection_id: str, access_token: str):
9
+ """Create a RAG tool for a specific collection.
10
+
11
+ Args:
12
+ rag_url: The base URL for the RAG API server
13
+ collection_id: The ID of the collection to query
14
+ access_token: The access token for authentication
15
+
16
+ Returns:
17
+ A structured tool that can be used to query the RAG collection
18
+ """
19
+ rag_url = rag_url.removesuffix("/")
20
+
21
+ collection_endpoint = f"{rag_url}/collections/{collection_id}"
22
+ try:
23
+ async with (
24
+ aiohttp.ClientSession() as session,
25
+ session.get(
26
+ collection_endpoint, headers={"Authorization": f"Bearer {access_token}"}
27
+ ) as response,
28
+ ):
29
+ response.raise_for_status()
30
+ collection_data = await response.json()
31
+
32
+ # Get the collection name and sanitize it to match the required regex pattern
33
+ raw_collection_name = collection_data.get("name", f"collection_{collection_id}")
34
+
35
+ # Sanitize the name to only include alphanumeric characters, underscores, and hyphens
36
+ # Replace any other characters with underscores
37
+ sanitized_name = re.sub(r"[^a-zA-Z0-9_-]", "_", raw_collection_name)
38
+
39
+ # Ensure the name is not empty and doesn't exceed 64 characters
40
+ if not sanitized_name:
41
+ sanitized_name = f"collection_{collection_id}"
42
+ collection_name = sanitized_name[:64]
43
+
44
+ raw_description = collection_data.get("metadata", {}).get("description")
45
+
46
+ base_description = (
47
+ "Search your collection of documents for results"
48
+ " semantically similar to the input query"
49
+ )
50
+ if not raw_description:
51
+ collection_description = base_description
52
+ else:
53
+ collection_description = (
54
+ f"{base_description}. Collection description: {raw_description}"
55
+ )
56
+
57
+ @tool(name_or_callable=collection_name, description=collection_description)
58
+ async def get_documents(
59
+ query: Annotated[str, "The search query to find relevant documents"],
60
+ ) -> str:
61
+ """Search for documents in the collection based on the query"""
62
+
63
+ search_endpoint = f"{rag_url}/collections/{collection_id}/documents/search"
64
+ payload = {"query": query, "limit": 10}
65
+
66
+ try:
67
+ async with (
68
+ aiohttp.ClientSession() as session,
69
+ session.post(
70
+ search_endpoint,
71
+ json=payload,
72
+ headers={"Authorization": f"Bearer {access_token}"},
73
+ ) as search_response,
74
+ ):
75
+ search_response.raise_for_status()
76
+ documents = await search_response.json()
77
+
78
+ formatted_docs = "<all-documents>\n"
79
+
80
+ for doc in documents:
81
+ doc_id = doc.get("id", "unknown")
82
+ content = doc.get("page_content", "")
83
+ formatted_docs += f' <document id="{doc_id}">\n {content}\n </document>\n'
84
+
85
+ formatted_docs += "</all-documents>"
86
+ return formatted_docs
87
+ except Exception as e:
88
+ return f"<all-documents>\n <error>{e!s}</error>\n</all-documents>"
89
+
90
+ return get_documents
91
+
92
+ except Exception as e:
93
+ msg = f"Failed to create RAG tool: {e!s}"
94
+ raise RuntimeError(msg) from e