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.
- fractal_graph_react_agent-0.0.0.dist-info/METADATA +112 -0
- fractal_graph_react_agent-0.0.0.dist-info/RECORD +10 -0
- fractal_graph_react_agent-0.0.0.dist-info/WHEEL +5 -0
- fractal_graph_react_agent-0.0.0.dist-info/top_level.txt +1 -0
- react_agent/__init__.py +28 -0
- react_agent/agent.py +470 -0
- react_agent/utils/__init__.py +0 -0
- react_agent/utils/mcp_interceptors.py +126 -0
- react_agent/utils/token.py +240 -0
- react_agent/utils/tools.py +94 -0
|
@@ -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 @@
|
|
|
1
|
+
react_agent
|
react_agent/__init__.py
ADDED
|
@@ -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
|