hyperforge 1.0.0.post19__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.
- hyperforge/__init__.py +16 -0
- hyperforge/agent.py +81 -0
- hyperforge/api/__init__.py +20 -0
- hyperforge/api/app.py +155 -0
- hyperforge/api/authentication.py +271 -0
- hyperforge/api/commands.py +33 -0
- hyperforge/api/internal/__init__.py +4 -0
- hyperforge/api/internal/inspect.py +30 -0
- hyperforge/api/internal/router.py +3 -0
- hyperforge/api/logging.py +18 -0
- hyperforge/api/models.py +129 -0
- hyperforge/api/session.py +197 -0
- hyperforge/api/settings.py +38 -0
- hyperforge/api/utils.py +354 -0
- hyperforge/api/v1/__init__.py +23 -0
- hyperforge/api/v1/agents.py +531 -0
- hyperforge/api/v1/interaction.py +430 -0
- hyperforge/api/v1/mcp_content.py +311 -0
- hyperforge/api/v1/mcp_interaction.py +322 -0
- hyperforge/api/v1/oauth.py +60 -0
- hyperforge/api/v1/prompt.py +129 -0
- hyperforge/api/v1/router.py +3 -0
- hyperforge/api/v1/schema.py +56 -0
- hyperforge/api/v1/session.py +182 -0
- hyperforge/api/v1/utils.py +12 -0
- hyperforge/api/v1/workflows.py +643 -0
- hyperforge/arag.py +28 -0
- hyperforge/broker/__init__.py +52 -0
- hyperforge/broker/local.py +116 -0
- hyperforge/broker/redis.py +161 -0
- hyperforge/configure.py +571 -0
- hyperforge/context/__init__.py +0 -0
- hyperforge/context/agent.py +377 -0
- hyperforge/context/config.py +103 -0
- hyperforge/database.py +3 -0
- hyperforge/db/__init__.py +6 -0
- hyperforge/db/agents.py +1521 -0
- hyperforge/db/encryption.py +91 -0
- hyperforge/db/exceptions.py +26 -0
- hyperforge/db/settings.py +16 -0
- hyperforge/db/workflow_cleanup.py +69 -0
- hyperforge/definition.py +13 -0
- hyperforge/driver.py +31 -0
- hyperforge/dummy.py +28 -0
- hyperforge/engine.py +189 -0
- hyperforge/exceptions.py +14 -0
- hyperforge/feature_flag.py +105 -0
- hyperforge/fixtures.py +602 -0
- hyperforge/interaction.py +116 -0
- hyperforge/llm.py +75 -0
- hyperforge/manager.py +432 -0
- hyperforge/memory/__init__.py +5 -0
- hyperforge/memory/memory.py +974 -0
- hyperforge/minimal_fixtures.py +75 -0
- hyperforge/models.py +336 -0
- hyperforge/nua.py +336 -0
- hyperforge/openapi.py +63 -0
- hyperforge/prompts.py +188 -0
- hyperforge/pubsub.py +90 -0
- hyperforge/py.typed +0 -0
- hyperforge/redis_utils.py +82 -0
- hyperforge/retrieval/__init__.py +0 -0
- hyperforge/retrieval/agent.py +169 -0
- hyperforge/retrieval/config.py +94 -0
- hyperforge/server/__init__.py +5 -0
- hyperforge/server/cache.py +131 -0
- hyperforge/server/run.py +109 -0
- hyperforge/server/sandbox.py +60 -0
- hyperforge/server/session.py +421 -0
- hyperforge/server/settings.py +47 -0
- hyperforge/server/utils.py +57 -0
- hyperforge/server/web.py +31 -0
- hyperforge/settings.py +18 -0
- hyperforge/standalone/__init__.py +5 -0
- hyperforge/standalone/agent.py +189 -0
- hyperforge/standalone/app.py +264 -0
- hyperforge/standalone/config.py +137 -0
- hyperforge/standalone/const.py +1 -0
- hyperforge/standalone/run.py +60 -0
- hyperforge/standalone/settings.py +133 -0
- hyperforge/standalone/ui_router.py +241 -0
- hyperforge/trace.py +42 -0
- hyperforge/utils/__init__.py +112 -0
- hyperforge/utils/http.py +48 -0
- hyperforge/workflows.py +44 -0
- hyperforge-1.0.0.post19.dist-info/METADATA +95 -0
- hyperforge-1.0.0.post19.dist-info/RECORD +90 -0
- hyperforge-1.0.0.post19.dist-info/WHEEL +5 -0
- hyperforge-1.0.0.post19.dist-info/entry_points.txt +8 -0
- hyperforge-1.0.0.post19.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""MCP content conversion utilities.
|
|
2
|
+
|
|
3
|
+
Converts AragAnswer and generic Python objects into MCP content blocks
|
|
4
|
+
(TextContent, ImageContent, EmbeddedResource) suitable for returning from
|
|
5
|
+
tool calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from itertools import chain
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
import pydantic_core
|
|
13
|
+
from mcp.server.fastmcp.utilities.types import Image
|
|
14
|
+
from mcp.types import (
|
|
15
|
+
Annotations,
|
|
16
|
+
EmbeddedResource,
|
|
17
|
+
ImageContent,
|
|
18
|
+
TextContent,
|
|
19
|
+
TextResourceContents,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from hyperforge.interaction import AragAnswer
|
|
23
|
+
from hyperforge.models import Answer, Chunk
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _answer_obj_to_content(
|
|
27
|
+
answer: Answer,
|
|
28
|
+
) -> list[TextContent | ImageContent | EmbeddedResource]:
|
|
29
|
+
"""Convert a hyperforge Answer object to MCP content blocks.
|
|
30
|
+
|
|
31
|
+
Handles all fields that Answer can carry: text, citations, chunks,
|
|
32
|
+
structured items, images, image_urls, and visualizations.
|
|
33
|
+
"""
|
|
34
|
+
contents: list[TextContent | ImageContent | EmbeddedResource] = []
|
|
35
|
+
|
|
36
|
+
if answer.answer:
|
|
37
|
+
contents.append(TextContent(type="text", text=answer.answer))
|
|
38
|
+
|
|
39
|
+
if answer.citations and answer.citations.metadata:
|
|
40
|
+
citations_json = json.dumps(pydantic_core.to_jsonable_python(answer.citations))
|
|
41
|
+
contents.append(
|
|
42
|
+
EmbeddedResource(
|
|
43
|
+
type="resource",
|
|
44
|
+
resource=TextResourceContents(
|
|
45
|
+
uri="rao-response://answer/citations", # type: ignore[arg-type]
|
|
46
|
+
text=citations_json,
|
|
47
|
+
mimeType="application/json",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if answer.chunks:
|
|
53
|
+
for idx, chunk in enumerate(answer.chunks):
|
|
54
|
+
if chunk:
|
|
55
|
+
# Extract text from Chunk object or use string directly
|
|
56
|
+
chunk_text = chunk.text if isinstance(chunk, Chunk) else chunk
|
|
57
|
+
if chunk_text:
|
|
58
|
+
contents.append(
|
|
59
|
+
EmbeddedResource(
|
|
60
|
+
type="resource",
|
|
61
|
+
resource=TextResourceContents(
|
|
62
|
+
uri=f"rao-response://answer/chunk/{idx}", # type: ignore[arg-type]
|
|
63
|
+
text=chunk_text,
|
|
64
|
+
mimeType="text/plain",
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if answer.structured:
|
|
70
|
+
for idx, item in enumerate(answer.structured):
|
|
71
|
+
if item:
|
|
72
|
+
contents.append(
|
|
73
|
+
EmbeddedResource(
|
|
74
|
+
type="resource",
|
|
75
|
+
resource=TextResourceContents(
|
|
76
|
+
uri=f"rao-response://answer/structured/{idx}", # type: ignore[arg-type]
|
|
77
|
+
text=item,
|
|
78
|
+
mimeType="text/plain",
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if answer.images:
|
|
84
|
+
for image in answer.images.values():
|
|
85
|
+
contents.append(
|
|
86
|
+
ImageContent(
|
|
87
|
+
type="image",
|
|
88
|
+
data=image.b64encoded,
|
|
89
|
+
mimeType=image.content_type,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if answer.image_urls:
|
|
94
|
+
contents.append(
|
|
95
|
+
EmbeddedResource(
|
|
96
|
+
type="resource",
|
|
97
|
+
resource=TextResourceContents(
|
|
98
|
+
uri="rao-response://answer/image-urls", # type: ignore[arg-type]
|
|
99
|
+
text=json.dumps(answer.image_urls),
|
|
100
|
+
mimeType="application/json",
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if answer.data_visualizations:
|
|
106
|
+
for idx, viz in enumerate(answer.data_visualizations):
|
|
107
|
+
contents.append(
|
|
108
|
+
EmbeddedResource(
|
|
109
|
+
type="resource",
|
|
110
|
+
resource=TextResourceContents(
|
|
111
|
+
uri=f"rao-response://answer/visualization/{idx}", # type: ignore[arg-type]
|
|
112
|
+
text=json.dumps(
|
|
113
|
+
pydantic_core.to_jsonable_python(viz.vega_lite_obj)
|
|
114
|
+
),
|
|
115
|
+
mimeType="application/vnd.vegalite.v5+json",
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return contents
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def convert_arag_answer_to_content(
|
|
124
|
+
msg: AragAnswer,
|
|
125
|
+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
|
|
126
|
+
"""Convert an AragAnswer to a sequence of MCP content objects.
|
|
127
|
+
|
|
128
|
+
Parses **every** populated field independently — the same AragAnswer
|
|
129
|
+
message never carries all fields at once, but any combination is safe.
|
|
130
|
+
|
|
131
|
+
Emits (in order):
|
|
132
|
+
- possible_answer content (text, citations, chunks, structured, images,
|
|
133
|
+
image_urls, visualizations) via _answer_obj_to_content
|
|
134
|
+
- TextContent for the main answer text
|
|
135
|
+
- EmbeddedResource (application/json) for top-level citations
|
|
136
|
+
- EmbeddedResource (application/json) for answer URLs
|
|
137
|
+
- EmbeddedResource (text/plain) per context chunk, with metadata
|
|
138
|
+
- ImageContent per base64 image in the context
|
|
139
|
+
- EmbeddedResource (text/plain) per structured item in the context
|
|
140
|
+
- EmbeddedResource (application/json) for context image URLs
|
|
141
|
+
- EmbeddedResource (application/vnd.vegalite.v5+json) per visualization
|
|
142
|
+
- TextContent (assistant-only annotation) for the step, if present
|
|
143
|
+
- TextContent for exception detail, if present
|
|
144
|
+
"""
|
|
145
|
+
contents: list[TextContent | ImageContent | EmbeddedResource] = []
|
|
146
|
+
|
|
147
|
+
# --- Possible answer (emitted by add_answer callbacks during generation) ---
|
|
148
|
+
if msg.possible_answer:
|
|
149
|
+
contents.extend(_answer_obj_to_content(msg.possible_answer))
|
|
150
|
+
|
|
151
|
+
# --- Main answer text (emitted by send_final_answer / session.py) ---
|
|
152
|
+
if msg.answer:
|
|
153
|
+
contents.append(TextContent(type="text", text=msg.answer))
|
|
154
|
+
|
|
155
|
+
# --- Top-level citations (from send_final_answer / session.py) ---
|
|
156
|
+
if msg.answer_citations and msg.answer_citations.metadata:
|
|
157
|
+
citations_json = json.dumps(
|
|
158
|
+
pydantic_core.to_jsonable_python(msg.answer_citations)
|
|
159
|
+
)
|
|
160
|
+
contents.append(
|
|
161
|
+
EmbeddedResource(
|
|
162
|
+
type="resource",
|
|
163
|
+
resource=TextResourceContents(
|
|
164
|
+
uri="rao-response://citations", # type: ignore[arg-type]
|
|
165
|
+
text=citations_json,
|
|
166
|
+
mimeType="application/json",
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# --- Answer URLs ---
|
|
172
|
+
if msg.answer_urls:
|
|
173
|
+
contents.append(
|
|
174
|
+
EmbeddedResource(
|
|
175
|
+
type="resource",
|
|
176
|
+
resource=TextResourceContents(
|
|
177
|
+
uri="rao-response://answer-urls", # type: ignore[arg-type]
|
|
178
|
+
text=json.dumps(msg.answer_urls),
|
|
179
|
+
mimeType="application/json",
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# --- Context chunks and images (emitted by save_context callbacks) ---
|
|
185
|
+
if msg.context:
|
|
186
|
+
context = msg.context
|
|
187
|
+
for chunk in context.chunks:
|
|
188
|
+
meta: dict[str, Any] = {}
|
|
189
|
+
if chunk.title:
|
|
190
|
+
meta["title"] = chunk.title
|
|
191
|
+
if chunk.source:
|
|
192
|
+
meta["source"] = chunk.source
|
|
193
|
+
if chunk.labels:
|
|
194
|
+
meta["labels"] = chunk.labels
|
|
195
|
+
if chunk.origin_url:
|
|
196
|
+
meta["origin_url"] = chunk.origin_url
|
|
197
|
+
if chunk.url:
|
|
198
|
+
meta["url"] = chunk.url
|
|
199
|
+
contents.append(
|
|
200
|
+
EmbeddedResource(
|
|
201
|
+
type="resource",
|
|
202
|
+
resource=TextResourceContents(
|
|
203
|
+
uri=f"rao-response://context/{context.id}/chunk/{chunk.chunk_id}", # type: ignore[arg-type]
|
|
204
|
+
text=chunk.text,
|
|
205
|
+
mimeType="text/plain",
|
|
206
|
+
**{"_meta": meta} if meta else {},
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
for image in context.images.values():
|
|
212
|
+
contents.append(
|
|
213
|
+
ImageContent(
|
|
214
|
+
type="image",
|
|
215
|
+
data=image.b64encoded,
|
|
216
|
+
mimeType=image.content_type,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
for idx, structured in enumerate(context.structured):
|
|
221
|
+
contents.append(
|
|
222
|
+
EmbeddedResource(
|
|
223
|
+
type="resource",
|
|
224
|
+
resource=TextResourceContents(
|
|
225
|
+
uri=f"rao-response://context/{context.id}/structured/{idx}", # type: ignore[arg-type]
|
|
226
|
+
text=structured,
|
|
227
|
+
mimeType="text/plain",
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if context.image_urls:
|
|
233
|
+
contents.append(
|
|
234
|
+
EmbeddedResource(
|
|
235
|
+
type="resource",
|
|
236
|
+
resource=TextResourceContents(
|
|
237
|
+
uri=f"rao-response://context/{context.id}/image-urls", # type: ignore[arg-type]
|
|
238
|
+
text=json.dumps(context.image_urls),
|
|
239
|
+
mimeType="application/json",
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# --- Top-level visualizations (from session.py final answer) ---
|
|
245
|
+
if msg.data_visualizations:
|
|
246
|
+
for idx, viz in enumerate(msg.data_visualizations):
|
|
247
|
+
contents.append(
|
|
248
|
+
EmbeddedResource(
|
|
249
|
+
type="resource",
|
|
250
|
+
resource=TextResourceContents(
|
|
251
|
+
uri=f"rao-response://visualization/{idx}", # type: ignore[arg-type]
|
|
252
|
+
text=json.dumps(
|
|
253
|
+
pydantic_core.to_jsonable_python(viz.vega_lite_obj)
|
|
254
|
+
),
|
|
255
|
+
mimeType="application/vnd.vegalite.v5+json",
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# --- Step (assistant-only, not shown to user) ---
|
|
261
|
+
if msg.step:
|
|
262
|
+
step = msg.step
|
|
263
|
+
parts = [f"Step: {step.title}"]
|
|
264
|
+
if step.value:
|
|
265
|
+
parts.append(f"Value: {step.value}")
|
|
266
|
+
if step.reason:
|
|
267
|
+
parts.append(f"Reason: {step.reason}")
|
|
268
|
+
if step.error:
|
|
269
|
+
parts.append(f"Error: {step.error}")
|
|
270
|
+
contents.append(
|
|
271
|
+
TextContent(
|
|
272
|
+
type="text",
|
|
273
|
+
text="\n".join(parts),
|
|
274
|
+
annotations=Annotations(audience=["assistant"]),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# --- Exception ---
|
|
279
|
+
if msg.exception:
|
|
280
|
+
contents.append(TextContent(type="text", text=f"Error: {msg.exception.detail}"))
|
|
281
|
+
|
|
282
|
+
return contents
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def convert_to_content(
|
|
286
|
+
result: Any,
|
|
287
|
+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
|
|
288
|
+
"""Convert a generic result to a sequence of MCP content objects.
|
|
289
|
+
|
|
290
|
+
Handles MCP content types directly, Image objects, lists/tuples
|
|
291
|
+
(recursively), and serialises everything else as JSON text.
|
|
292
|
+
"""
|
|
293
|
+
if result is None:
|
|
294
|
+
return []
|
|
295
|
+
|
|
296
|
+
if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
|
|
297
|
+
return [result]
|
|
298
|
+
|
|
299
|
+
if isinstance(result, Image):
|
|
300
|
+
return [result.to_image_content()]
|
|
301
|
+
|
|
302
|
+
if isinstance(result, (list, tuple)):
|
|
303
|
+
return list(chain.from_iterable(convert_to_content(item) for item in result))
|
|
304
|
+
|
|
305
|
+
if not isinstance(result, str):
|
|
306
|
+
try:
|
|
307
|
+
result = json.dumps(pydantic_core.to_jsonable_python(result))
|
|
308
|
+
except Exception:
|
|
309
|
+
result = str(result)
|
|
310
|
+
|
|
311
|
+
return [TextContent(type="text", text=result)]
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from collections.abc import MutableMapping
|
|
3
|
+
from functools import partial
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Iterable, Sequence
|
|
5
|
+
|
|
6
|
+
import anyio
|
|
7
|
+
from fastapi import Header
|
|
8
|
+
from mcp.server.fastmcp.exceptions import ResourceError
|
|
9
|
+
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
|
10
|
+
from mcp.server.lowlevel.server import Server as MCPServer
|
|
11
|
+
from mcp.server.lowlevel.server import lifespan as default_lifespan
|
|
12
|
+
from mcp.server.streamable_http import (
|
|
13
|
+
MCP_SESSION_ID_HEADER,
|
|
14
|
+
StreamableHTTPServerTransport,
|
|
15
|
+
)
|
|
16
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
17
|
+
from mcp.types import (
|
|
18
|
+
EmbeddedResource,
|
|
19
|
+
GetPromptResult,
|
|
20
|
+
ImageContent,
|
|
21
|
+
Prompt,
|
|
22
|
+
PromptMessage,
|
|
23
|
+
Resource,
|
|
24
|
+
ResourceTemplate,
|
|
25
|
+
TextContent,
|
|
26
|
+
Tool,
|
|
27
|
+
)
|
|
28
|
+
from nucliadb_sdk import NucliaDBAsync
|
|
29
|
+
from pydantic import AnyUrl
|
|
30
|
+
from starlette.datastructures import Headers
|
|
31
|
+
from starlette.requests import Request
|
|
32
|
+
from starlette.responses import Response
|
|
33
|
+
|
|
34
|
+
from hyperforge.api.authentication import requires_one
|
|
35
|
+
from hyperforge.api.models import InteractionRequest
|
|
36
|
+
from hyperforge.api.v1.interaction import WebsocketReceiver, stream_response
|
|
37
|
+
from hyperforge.db.agents import AgentManager
|
|
38
|
+
from hyperforge.interaction import AnswerOperation
|
|
39
|
+
from hyperforge.prompts import PromptConfig
|
|
40
|
+
from hyperforge.pubsub import UserToAgentInteraction
|
|
41
|
+
from hyperforge.workflows import WorkflowData
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from hyperforge.api.app import HTTPApplication
|
|
45
|
+
from anyio.abc import TaskStatus
|
|
46
|
+
|
|
47
|
+
from hyperforge.api import logger
|
|
48
|
+
from hyperforge.api.models import (
|
|
49
|
+
AgentRole,
|
|
50
|
+
)
|
|
51
|
+
from hyperforge.api.v1.mcp_content import convert_arag_answer_to_content
|
|
52
|
+
from hyperforge.api.v1.router import router
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def list_tools(workflows: list[WorkflowData]) -> list[Tool]:
|
|
56
|
+
return [
|
|
57
|
+
Tool(
|
|
58
|
+
name=workflow.name,
|
|
59
|
+
description=workflow.description,
|
|
60
|
+
inputSchema={
|
|
61
|
+
"type": "object",
|
|
62
|
+
"required": workflow.required,
|
|
63
|
+
"properties": workflow.parameters,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
for workflow in workflows
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def list_prompts(prompts: list[PromptConfig]) -> list[Prompt]:
|
|
71
|
+
"""List all available prompts."""
|
|
72
|
+
return [Prompt(**prompt.model_dump()) for prompt in prompts]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def call_tool(
|
|
76
|
+
app: "HTTPApplication",
|
|
77
|
+
mcp_server: MCPServer,
|
|
78
|
+
x_stf_account: str,
|
|
79
|
+
agent_id: str,
|
|
80
|
+
session: str,
|
|
81
|
+
workflows: list[WorkflowData],
|
|
82
|
+
headers: Headers,
|
|
83
|
+
name: str,
|
|
84
|
+
arguments: dict[str, Any],
|
|
85
|
+
) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
|
|
86
|
+
"""Call a tool by name with arguments."""
|
|
87
|
+
workflow = next((w for w in workflows if w.name == name), None)
|
|
88
|
+
if workflow is None:
|
|
89
|
+
raise ResourceError(f"Unknown tool: {name}")
|
|
90
|
+
|
|
91
|
+
for parameter in workflow.required:
|
|
92
|
+
if parameter not in arguments:
|
|
93
|
+
raise ResourceError(f"Missing required parameter: {parameter}")
|
|
94
|
+
|
|
95
|
+
question = f"Calling tool: {workflow.description or workflow.name} with arguments: {arguments}"
|
|
96
|
+
|
|
97
|
+
interaction = InteractionRequest(
|
|
98
|
+
question=question, headers=dict(headers.items()), arguments=arguments
|
|
99
|
+
)
|
|
100
|
+
mcp_session = mcp_server.request_context.session
|
|
101
|
+
websocket = WebsocketReceiver(websocket=None)
|
|
102
|
+
|
|
103
|
+
messages = []
|
|
104
|
+
async for msg in stream_response(
|
|
105
|
+
app,
|
|
106
|
+
websocket,
|
|
107
|
+
account=x_stf_account,
|
|
108
|
+
agent_id=agent_id,
|
|
109
|
+
session=session,
|
|
110
|
+
interaction=interaction,
|
|
111
|
+
workflow_id=workflow.id,
|
|
112
|
+
):
|
|
113
|
+
if msg.operation == AnswerOperation.AGENT_REQUEST and msg.oauth:
|
|
114
|
+
pass
|
|
115
|
+
elif msg.operation == AnswerOperation.AGENT_REQUEST and msg.feedback:
|
|
116
|
+
# DO NOTHING FOR NOW
|
|
117
|
+
result = await mcp_session.elicit_form(
|
|
118
|
+
message=msg.feedback.question,
|
|
119
|
+
requestedSchema=msg.feedback.response_schema,
|
|
120
|
+
related_request_id=msg.feedback.request_id,
|
|
121
|
+
)
|
|
122
|
+
websocket.queue.put_nowait(
|
|
123
|
+
UserToAgentInteraction(
|
|
124
|
+
request_id=msg.feedback.request_id, response=result.content
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
elif msg.operation == AnswerOperation.ANSWER:
|
|
128
|
+
result_contents = convert_arag_answer_to_content(msg)
|
|
129
|
+
for content in result_contents:
|
|
130
|
+
if isinstance(content, TextContent):
|
|
131
|
+
logger.debug(f"Tool output text: {content.text}")
|
|
132
|
+
messages.append(content)
|
|
133
|
+
|
|
134
|
+
return messages
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def list_resources(agent_id: str) -> list[Resource]:
|
|
138
|
+
# TODO : Resource 1 : The list of tools ??
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def list_resource_templates() -> list[ResourceTemplate]:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def get_prompt(
|
|
147
|
+
prompts_list: list[PromptConfig], name: str, arguments: dict[str, Any] | None = None
|
|
148
|
+
) -> GetPromptResult:
|
|
149
|
+
"""Get a prompt by name with arguments."""
|
|
150
|
+
prompt = next((p for p in prompts_list if p.name == name), None)
|
|
151
|
+
if prompt is None:
|
|
152
|
+
raise ResourceError(f"Unknown prompt: {name}")
|
|
153
|
+
message = prompt.prompt.format(**(arguments or {}))
|
|
154
|
+
return GetPromptResult(
|
|
155
|
+
description=prompt.description,
|
|
156
|
+
messages=[
|
|
157
|
+
PromptMessage(role="user", content=TextContent(type="text", text=message))
|
|
158
|
+
],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def read_resource(
|
|
163
|
+
ndb: NucliaDBAsync, kbid: str, uri: AnyUrl | str
|
|
164
|
+
) -> Iterable[ReadResourceContents]:
|
|
165
|
+
"""Read a resource by URI."""
|
|
166
|
+
|
|
167
|
+
raise ResourceError(f"Unknown uri: {uri}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.get(
|
|
171
|
+
"/.well-known/oauth-protected-resource/api/v1/agent/{agent_id}/session/{session}/mcp"
|
|
172
|
+
)
|
|
173
|
+
async def mcp_interaction_protected_resource_metadata(
|
|
174
|
+
request: Request,
|
|
175
|
+
agent_id: str,
|
|
176
|
+
session: str,
|
|
177
|
+
):
|
|
178
|
+
"""
|
|
179
|
+
Protected resource metadata discovery endpoint for MCP server authorization.
|
|
180
|
+
See https://datatracker.ietf.org/doc/html/rfc9728 for details on the OAuth-protected resource metadata format and discovery process.
|
|
181
|
+
"""
|
|
182
|
+
app: "HTTPApplication" = request.app
|
|
183
|
+
mcp_url = request.url_for(
|
|
184
|
+
"interaction_mcp_handler", agent_id=agent_id, session=session
|
|
185
|
+
)
|
|
186
|
+
mcp_url_https = str(mcp_url).replace(
|
|
187
|
+
"http://", "https://"
|
|
188
|
+
) # Ensure the URL uses https
|
|
189
|
+
return {
|
|
190
|
+
"resource": mcp_url_https,
|
|
191
|
+
"scopes_supported": app.settings.hydra_scopes_supported,
|
|
192
|
+
"authorization_servers": [app.settings.hydra_public_url],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@router.delete("/api/v1/agent/{agent_id}/session/{session}/mcp")
|
|
197
|
+
@requires_one([AgentRole.MEMBER])
|
|
198
|
+
async def mcp_handler_delete(
|
|
199
|
+
request: Request,
|
|
200
|
+
agent_id: str,
|
|
201
|
+
session: str,
|
|
202
|
+
x_stf_user: str = Header(..., include_in_schema=False),
|
|
203
|
+
x_stf_account: str = Header(..., include_in_schema=False),
|
|
204
|
+
x_stf_account_type: str = Header(..., include_in_schema=False),
|
|
205
|
+
):
|
|
206
|
+
app: HTTPApplication = request.app
|
|
207
|
+
if (agent_id, session) in app.sses:
|
|
208
|
+
await app.sses[(agent_id, session)].terminate()
|
|
209
|
+
if (agent_id, session) in app.sses:
|
|
210
|
+
del app.sses[(agent_id, session)]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.get("/api/v1/agent/{agent_id}/session/{session}/mcp")
|
|
214
|
+
@router.post("/api/v1/agent/{agent_id}/session/{session}/mcp")
|
|
215
|
+
@requires_one([AgentRole.MEMBER])
|
|
216
|
+
async def interaction_mcp_handler(
|
|
217
|
+
request: Request,
|
|
218
|
+
agent_id: str,
|
|
219
|
+
session: str,
|
|
220
|
+
x_stf_user: str = Header(..., include_in_schema=False),
|
|
221
|
+
x_stf_account: str = Header(..., include_in_schema=False),
|
|
222
|
+
x_stf_account_type: str = Header(..., include_in_schema=False),
|
|
223
|
+
):
|
|
224
|
+
app: HTTPApplication = request.app
|
|
225
|
+
agent_manager: AgentManager = request.app.agent_manager
|
|
226
|
+
request._headers._list.append((MCP_SESSION_ID_HEADER.encode(), session.encode()))
|
|
227
|
+
|
|
228
|
+
# No session ID needed in stateless mode
|
|
229
|
+
security_settings: TransportSecuritySettings | None = None
|
|
230
|
+
http_transport = StreamableHTTPServerTransport(
|
|
231
|
+
mcp_session_id=session, # No session tracking in stateless mode
|
|
232
|
+
is_json_response_enabled=True,
|
|
233
|
+
event_store=None, # No event store in stateless mode
|
|
234
|
+
security_settings=security_settings,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
workflows, agent_config, prompts = await asyncio.gather(
|
|
238
|
+
agent_manager.workflows_list(account=x_stf_account, agent_id=agent_id),
|
|
239
|
+
agent_manager.get_agent_config_basic(account=x_stf_account, agent_id=agent_id),
|
|
240
|
+
agent_manager.get_prompts(account=x_stf_account, agent_id=agent_id),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
mcp_server = MCPServer(
|
|
244
|
+
name=agent_id,
|
|
245
|
+
version="1.0.0",
|
|
246
|
+
instructions=agent_config.instructions,
|
|
247
|
+
lifespan=default_lifespan,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
list_tools_partial = partial(list_tools, workflows)
|
|
251
|
+
mcp_server.list_tools()(list_tools_partial)
|
|
252
|
+
|
|
253
|
+
call_tool_partial = partial(
|
|
254
|
+
call_tool,
|
|
255
|
+
app,
|
|
256
|
+
mcp_server,
|
|
257
|
+
x_stf_account,
|
|
258
|
+
agent_id,
|
|
259
|
+
session,
|
|
260
|
+
workflows,
|
|
261
|
+
request.headers,
|
|
262
|
+
)
|
|
263
|
+
mcp_server.call_tool()(call_tool_partial)
|
|
264
|
+
|
|
265
|
+
list_prompts_partial = partial(list_prompts, prompts=prompts)
|
|
266
|
+
mcp_server.list_prompts()(list_prompts_partial)
|
|
267
|
+
|
|
268
|
+
get_prompt_partial = partial(get_prompt, prompts)
|
|
269
|
+
mcp_server.get_prompt()(get_prompt_partial)
|
|
270
|
+
|
|
271
|
+
# Start server in a new task
|
|
272
|
+
async def run_stateless_server(
|
|
273
|
+
*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
|
|
274
|
+
):
|
|
275
|
+
async with http_transport.connect() as streams:
|
|
276
|
+
read_stream, write_stream = streams
|
|
277
|
+
task_status.started()
|
|
278
|
+
try:
|
|
279
|
+
await mcp_server.run(
|
|
280
|
+
read_stream,
|
|
281
|
+
write_stream,
|
|
282
|
+
mcp_server.create_initialization_options(),
|
|
283
|
+
stateless=True,
|
|
284
|
+
)
|
|
285
|
+
except Exception:
|
|
286
|
+
logger.exception("Stateless session crashed")
|
|
287
|
+
|
|
288
|
+
# Intercept ASGI send messages so FastAPI doesn't attempt to send a
|
|
289
|
+
# second response after the transport has already sent one (which would
|
|
290
|
+
# cause: RuntimeError: Unexpected ASGI message 'http.response.start'
|
|
291
|
+
# sent, after response already completed).
|
|
292
|
+
response_status = 200
|
|
293
|
+
response_headers: dict[str, str] = {}
|
|
294
|
+
body_chunks: list[bytes] = []
|
|
295
|
+
|
|
296
|
+
async def intercepting_send(message: MutableMapping[str, Any]) -> None:
|
|
297
|
+
nonlocal response_status
|
|
298
|
+
if message["type"] == "http.response.start":
|
|
299
|
+
response_status = message["status"]
|
|
300
|
+
response_headers.update(
|
|
301
|
+
{k.decode(): v.decode() for k, v in message.get("headers", [])}
|
|
302
|
+
)
|
|
303
|
+
elif message["type"] == "http.response.body":
|
|
304
|
+
body_chunks.append(message.get("body", b""))
|
|
305
|
+
|
|
306
|
+
async with anyio.create_task_group() as tg:
|
|
307
|
+
# Start the server task
|
|
308
|
+
await tg.start(run_stateless_server)
|
|
309
|
+
|
|
310
|
+
# Handle the HTTP request via the intercepting send
|
|
311
|
+
await http_transport.handle_request(
|
|
312
|
+
request.scope, request._receive, intercepting_send
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Terminate the transport after the request is handled
|
|
316
|
+
await http_transport.terminate()
|
|
317
|
+
|
|
318
|
+
return Response(
|
|
319
|
+
content=b"".join(body_chunks),
|
|
320
|
+
status_code=response_status,
|
|
321
|
+
headers=response_headers,
|
|
322
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from fastapi import Query
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import HTMLResponse
|
|
6
|
+
|
|
7
|
+
from hyperforge.api.settings import Settings
|
|
8
|
+
from hyperforge.api.v1.router import router
|
|
9
|
+
from hyperforge.api.v1.utils import tracer
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
RENDER = "<html><body><h1>OAuth Completed</h1><p>You can close this window and return to the application.</p></body></html>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get(
|
|
17
|
+
"/api/auth/agent/{agent_id}/workflow/{workflow_id}/session/{session}/oauth/{oauth_uuid}/callback",
|
|
18
|
+
status_code=200,
|
|
19
|
+
description="Get Agent Schema",
|
|
20
|
+
tags=["Retrieval Agent"],
|
|
21
|
+
include_in_schema=False,
|
|
22
|
+
)
|
|
23
|
+
async def oauth_callback(
|
|
24
|
+
request: Request,
|
|
25
|
+
agent_id: str,
|
|
26
|
+
session: str,
|
|
27
|
+
workflow_id: str,
|
|
28
|
+
oauth_uuid: str,
|
|
29
|
+
question_id: str = Query(..., include_in_schema=False),
|
|
30
|
+
state: str = Query(..., include_in_schema=False),
|
|
31
|
+
account_id: str = Query(..., include_in_schema=False),
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Callback from oauth flow on RAO that requires to send creds to websocket
|
|
35
|
+
"""
|
|
36
|
+
settings: Settings = request.app.settings
|
|
37
|
+
subject = settings.oauth_subject.format(
|
|
38
|
+
account=account_id,
|
|
39
|
+
agent_id=agent_id,
|
|
40
|
+
session=session,
|
|
41
|
+
question=question_id,
|
|
42
|
+
oauth_uuid=oauth_uuid,
|
|
43
|
+
workflow_id=workflow_id,
|
|
44
|
+
)
|
|
45
|
+
# Request a question
|
|
46
|
+
with tracer().start_as_current_span("Request activation"):
|
|
47
|
+
logger.info(
|
|
48
|
+
"OAuth callback received for agent=%s, session=%s, oauth_uuid=%s, question_id=%s",
|
|
49
|
+
agent_id,
|
|
50
|
+
session,
|
|
51
|
+
oauth_uuid,
|
|
52
|
+
question_id,
|
|
53
|
+
)
|
|
54
|
+
await request.app.broker.send_reply(subject, state)
|
|
55
|
+
logger.info(
|
|
56
|
+
"OAuth callback published to stream %s",
|
|
57
|
+
subject,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return HTMLResponse(content=RENDER)
|