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.
Files changed (90) hide show
  1. hyperforge/__init__.py +16 -0
  2. hyperforge/agent.py +81 -0
  3. hyperforge/api/__init__.py +20 -0
  4. hyperforge/api/app.py +155 -0
  5. hyperforge/api/authentication.py +271 -0
  6. hyperforge/api/commands.py +33 -0
  7. hyperforge/api/internal/__init__.py +4 -0
  8. hyperforge/api/internal/inspect.py +30 -0
  9. hyperforge/api/internal/router.py +3 -0
  10. hyperforge/api/logging.py +18 -0
  11. hyperforge/api/models.py +129 -0
  12. hyperforge/api/session.py +197 -0
  13. hyperforge/api/settings.py +38 -0
  14. hyperforge/api/utils.py +354 -0
  15. hyperforge/api/v1/__init__.py +23 -0
  16. hyperforge/api/v1/agents.py +531 -0
  17. hyperforge/api/v1/interaction.py +430 -0
  18. hyperforge/api/v1/mcp_content.py +311 -0
  19. hyperforge/api/v1/mcp_interaction.py +322 -0
  20. hyperforge/api/v1/oauth.py +60 -0
  21. hyperforge/api/v1/prompt.py +129 -0
  22. hyperforge/api/v1/router.py +3 -0
  23. hyperforge/api/v1/schema.py +56 -0
  24. hyperforge/api/v1/session.py +182 -0
  25. hyperforge/api/v1/utils.py +12 -0
  26. hyperforge/api/v1/workflows.py +643 -0
  27. hyperforge/arag.py +28 -0
  28. hyperforge/broker/__init__.py +52 -0
  29. hyperforge/broker/local.py +116 -0
  30. hyperforge/broker/redis.py +161 -0
  31. hyperforge/configure.py +571 -0
  32. hyperforge/context/__init__.py +0 -0
  33. hyperforge/context/agent.py +377 -0
  34. hyperforge/context/config.py +103 -0
  35. hyperforge/database.py +3 -0
  36. hyperforge/db/__init__.py +6 -0
  37. hyperforge/db/agents.py +1521 -0
  38. hyperforge/db/encryption.py +91 -0
  39. hyperforge/db/exceptions.py +26 -0
  40. hyperforge/db/settings.py +16 -0
  41. hyperforge/db/workflow_cleanup.py +69 -0
  42. hyperforge/definition.py +13 -0
  43. hyperforge/driver.py +31 -0
  44. hyperforge/dummy.py +28 -0
  45. hyperforge/engine.py +189 -0
  46. hyperforge/exceptions.py +14 -0
  47. hyperforge/feature_flag.py +105 -0
  48. hyperforge/fixtures.py +602 -0
  49. hyperforge/interaction.py +116 -0
  50. hyperforge/llm.py +75 -0
  51. hyperforge/manager.py +432 -0
  52. hyperforge/memory/__init__.py +5 -0
  53. hyperforge/memory/memory.py +974 -0
  54. hyperforge/minimal_fixtures.py +75 -0
  55. hyperforge/models.py +336 -0
  56. hyperforge/nua.py +336 -0
  57. hyperforge/openapi.py +63 -0
  58. hyperforge/prompts.py +188 -0
  59. hyperforge/pubsub.py +90 -0
  60. hyperforge/py.typed +0 -0
  61. hyperforge/redis_utils.py +82 -0
  62. hyperforge/retrieval/__init__.py +0 -0
  63. hyperforge/retrieval/agent.py +169 -0
  64. hyperforge/retrieval/config.py +94 -0
  65. hyperforge/server/__init__.py +5 -0
  66. hyperforge/server/cache.py +131 -0
  67. hyperforge/server/run.py +109 -0
  68. hyperforge/server/sandbox.py +60 -0
  69. hyperforge/server/session.py +421 -0
  70. hyperforge/server/settings.py +47 -0
  71. hyperforge/server/utils.py +57 -0
  72. hyperforge/server/web.py +31 -0
  73. hyperforge/settings.py +18 -0
  74. hyperforge/standalone/__init__.py +5 -0
  75. hyperforge/standalone/agent.py +189 -0
  76. hyperforge/standalone/app.py +264 -0
  77. hyperforge/standalone/config.py +137 -0
  78. hyperforge/standalone/const.py +1 -0
  79. hyperforge/standalone/run.py +60 -0
  80. hyperforge/standalone/settings.py +133 -0
  81. hyperforge/standalone/ui_router.py +241 -0
  82. hyperforge/trace.py +42 -0
  83. hyperforge/utils/__init__.py +112 -0
  84. hyperforge/utils/http.py +48 -0
  85. hyperforge/workflows.py +44 -0
  86. hyperforge-1.0.0.post19.dist-info/METADATA +95 -0
  87. hyperforge-1.0.0.post19.dist-info/RECORD +90 -0
  88. hyperforge-1.0.0.post19.dist-info/WHEEL +5 -0
  89. hyperforge-1.0.0.post19.dist-info/entry_points.txt +8 -0
  90. 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)