nucliadb-agentic-api 1.0.0.post156__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 (37) hide show
  1. nucliadb_agentic_api/VERSION +1 -0
  2. nucliadb_agentic_api/__init__.py +20 -0
  3. nucliadb_agentic_api/agentic/__init__.py +0 -0
  4. nucliadb_agentic_api/agentic/ask_handler.py +66 -0
  5. nucliadb_agentic_api/agentic/ask_result.py +389 -0
  6. nucliadb_agentic_api/agentic/ask_transform_to_interaction.py +10 -0
  7. nucliadb_agentic_api/app.py +147 -0
  8. nucliadb_agentic_api/commands.py +66 -0
  9. nucliadb_agentic_api/db/__init__.py +0 -0
  10. nucliadb_agentic_api/db/agentic_configs.py +296 -0
  11. nucliadb_agentic_api/db/settings.py +11 -0
  12. nucliadb_agentic_api/db/sources.py +202 -0
  13. nucliadb_agentic_api/db/transform.py +208 -0
  14. nucliadb_agentic_api/exceptions.py +10 -0
  15. nucliadb_agentic_api/logging.py +18 -0
  16. nucliadb_agentic_api/models.py +321 -0
  17. nucliadb_agentic_api/py.typed +0 -0
  18. nucliadb_agentic_api/server/__init__.py +1 -0
  19. nucliadb_agentic_api/server/server.py +113 -0
  20. nucliadb_agentic_api/server/session.py +276 -0
  21. nucliadb_agentic_api/server/settings.py +22 -0
  22. nucliadb_agentic_api/settings.py +39 -0
  23. nucliadb_agentic_api/utils.py +284 -0
  24. nucliadb_agentic_api/v1/__init__.py +19 -0
  25. nucliadb_agentic_api/v1/agentic_configs.py +117 -0
  26. nucliadb_agentic_api/v1/ask.py +303 -0
  27. nucliadb_agentic_api/v1/ask_websocket.py +141 -0
  28. nucliadb_agentic_api/v1/mcp_content.py +310 -0
  29. nucliadb_agentic_api/v1/mcp_nucliadb.py +785 -0
  30. nucliadb_agentic_api/v1/oauth.py +60 -0
  31. nucliadb_agentic_api/v1/router.py +3 -0
  32. nucliadb_agentic_api/v1/sources.py +158 -0
  33. nucliadb_agentic_api/v1/utils.py +12 -0
  34. nucliadb_agentic_api-1.0.0.post156.dist-info/METADATA +150 -0
  35. nucliadb_agentic_api-1.0.0.post156.dist-info/RECORD +37 -0
  36. nucliadb_agentic_api-1.0.0.post156.dist-info/WHEEL +4 -0
  37. nucliadb_agentic_api-1.0.0.post156.dist-info/entry_points.txt +6 -0
@@ -0,0 +1,785 @@
1
+ import json
2
+ from asyncio import gather
3
+ from collections.abc import MutableMapping
4
+ from dataclasses import dataclass
5
+ from functools import partial
6
+ from itertools import chain
7
+ from typing import TYPE_CHECKING, Any, Iterable, Sequence
8
+
9
+ import anyio
10
+ import pydantic_core
11
+ from fastapi import Header
12
+ from hyperforge.api.authentication import requires_one
13
+ from mcp.server.fastmcp.exceptions import ResourceError
14
+ from mcp.server.fastmcp.utilities.types import Image
15
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
16
+ from mcp.server.lowlevel.server import Server as MCPServer, lifespan as default_lifespan
17
+ from mcp.types import (
18
+ Annotations,
19
+ EmbeddedResource,
20
+ GetPromptResult,
21
+ ImageContent,
22
+ Prompt,
23
+ Resource,
24
+ ResourceTemplate,
25
+ TextContent,
26
+ Tool,
27
+ )
28
+ from nucliadb_sdk import NucliaDBAsync
29
+ from nucliadb_sdk.v2.exceptions import AccountLimitError, NotFoundError, RateLimitError
30
+ from pydantic import AnyUrl, BaseModel, ConfigDict, Field, ValidationError
31
+ from starlette.requests import Request
32
+ from starlette.responses import Response
33
+
34
+ from nucliadb_agentic_api import logger
35
+
36
+ if TYPE_CHECKING:
37
+ from hyperforge.api.app import HTTPApplication
38
+
39
+ from anyio.abc import TaskStatus
40
+ from hyperforge_nucliadb_agentic.ask.exceptions import (
41
+ InvalidQueryError,
42
+ KnowledgeBoxNotFound,
43
+ NoRetrievalResultsError,
44
+ )
45
+ from hyperforge_nucliadb_agentic.ask.model import (
46
+ AskRequest,
47
+ CitationsType,
48
+ FieldExtensionStrategy,
49
+ Filter,
50
+ MetadataExtensionStrategy,
51
+ MetadataExtensionType,
52
+ NeighbouringParagraphsStrategy,
53
+ )
54
+ from hyperforge_nucliadb_agentic.ask.search.ask import (
55
+ AskResult,
56
+ ask,
57
+ )
58
+ from mcp.server.streamable_http import (
59
+ StreamableHTTPServerTransport,
60
+ )
61
+ from mcp.server.transport_security import TransportSecuritySettings
62
+ from nucliadb_models.resource import (
63
+ ConversationFieldData,
64
+ FileFieldData,
65
+ GenericFieldData,
66
+ LinkFieldData,
67
+ TextFieldData,
68
+ )
69
+ from nucliadb_models.search import (
70
+ NucliaDBClientType,
71
+ )
72
+
73
+ from nucliadb_agentic_api.models import (
74
+ NucliaDBRoles,
75
+ )
76
+ from nucliadb_agentic_api.v1.router import router
77
+
78
+ BATCH_GET_DOCUMENTS_MAX = 20
79
+
80
+
81
+ @dataclass
82
+ class MCPContext:
83
+ ndb_reader: NucliaDBAsync
84
+ ndb_search: NucliaDBAsync
85
+ kbid: str
86
+ x_nucliadb_user: str
87
+ x_ndb_client: NucliaDBClientType
88
+ x_forwarded_for: str
89
+
90
+
91
+ class SearchDocumentsArgs(BaseModel):
92
+ model_config = ConfigDict(extra="forbid")
93
+
94
+ query: str = Field(
95
+ ...,
96
+ min_length=1,
97
+ description="Required. The raw query string provided by the user.",
98
+ )
99
+ search_configuration: str | None = Field(
100
+ None,
101
+ description="Optional. The name of the Search Configuration to use. If not provided, default settings are used.",
102
+ )
103
+ filters: list[Filter] | None = Field(
104
+ None,
105
+ description="Optional. A list of filter objects to narrow results. Each filter can include 'all' (AND), 'any' (OR), and/or 'not_all' (NOT AND) label paths like '/l/labelset/label'.",
106
+ )
107
+
108
+
109
+ class GetDocumentArgs(BaseModel):
110
+ model_config = ConfigDict(extra="forbid")
111
+
112
+ name: str = Field(
113
+ ...,
114
+ min_length=1,
115
+ description="Required. The resource ID of the document to retrieve, as returned in the 'parent' field by the 'search_documents' tool.",
116
+ )
117
+
118
+
119
+ class BatchGetDocumentsArgs(BaseModel):
120
+ model_config = ConfigDict(extra="forbid")
121
+
122
+ names: list[str] = Field(
123
+ ...,
124
+ min_length=1,
125
+ max_length=BATCH_GET_DOCUMENTS_MAX,
126
+ description=f"Required. Resource IDs of up to {BATCH_GET_DOCUMENTS_MAX} documents to retrieve, as returned in the 'parent' field by the 'search_documents' tool.",
127
+ )
128
+
129
+
130
+ @dataclass
131
+ class ToolDefinition:
132
+ name: str
133
+ description: str
134
+ args_model: type[BaseModel]
135
+ handler: Any
136
+
137
+
138
+ async def get_resource(
139
+ ndb_search: NucliaDBAsync, kbid: str, parent: str
140
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
141
+ """Get a resource by rid."""
142
+ converted_result: list[TextContent | ImageContent | EmbeddedResource] = []
143
+ try:
144
+ resp = await ndb_search.get_resource_by_id(
145
+ rid=parent, kbid=kbid, query_params={"show": ["basic", "extracted"]}
146
+ )
147
+ except NotFoundError as e:
148
+ raise ResourceError(f"Document not found: '{parent}'") from e
149
+ except Exception as e:
150
+ logger.exception(f"Error fetching document '{parent}'")
151
+ raise ResourceError(
152
+ f"Failed to fetch document '{parent}'" + (f": {e}" if str(e) else "")
153
+ ) from e
154
+ if resp.data is not None:
155
+ field_collections: list[
156
+ dict[str, GenericFieldData]
157
+ | dict[str, TextFieldData]
158
+ | dict[str, FileFieldData]
159
+ | dict[str, LinkFieldData]
160
+ | dict[str, ConversationFieldData]
161
+ | None,
162
+ ] = [
163
+ resp.data.generics,
164
+ resp.data.texts,
165
+ resp.data.files,
166
+ resp.data.links,
167
+ resp.data.conversations,
168
+ ]
169
+
170
+ for fields in field_collections:
171
+ if fields is not None:
172
+ for key, field in fields.items():
173
+ converted_result.append(
174
+ TextContent(
175
+ type="text",
176
+ text=field.extracted.text.text
177
+ if field.extracted
178
+ and field.extracted.text
179
+ and field.extracted.text.text
180
+ else "",
181
+ annotations=Annotations(audience=["user", "assistant"]),
182
+ _meta={"field_id": key},
183
+ )
184
+ )
185
+ return converted_result
186
+
187
+
188
+ def _extract_resource_id(citation: str) -> str:
189
+ resource_id = citation.split("/", 2)[0]
190
+ return resource_id
191
+
192
+
193
+ def _get_document_ids(
194
+ citation_footnote_to_context: dict[str, str] | None,
195
+ ) -> dict[str, str]:
196
+ if not citation_footnote_to_context:
197
+ return {}
198
+
199
+ return {
200
+ ident: _extract_resource_id(citation)
201
+ for ident, citation in citation_footnote_to_context.items()
202
+ }
203
+
204
+
205
+ def _append_document_ids_to_answer(
206
+ answer: str | None, document_ids: dict[str, str]
207
+ ) -> str:
208
+ answer_text = answer or ""
209
+ if not document_ids:
210
+ return answer_text
211
+
212
+ document_ids_text = "\n".join(
213
+ f"{ident}: {resource_id}" for ident, resource_id in document_ids.items()
214
+ )
215
+ return f"{answer_text}\n\nDocument IDs:\n{document_ids_text}"
216
+
217
+
218
+ async def _tool_search_documents(
219
+ context: MCPContext,
220
+ args: SearchDocumentsArgs,
221
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
222
+ ask_request = AskRequest(query=args.query)
223
+ ask_request.audit_metadata = {"source": "mcp_tool"}
224
+
225
+ ask_request.rag_strategies = [
226
+ FieldExtensionStrategy(fields=["a/title", "a/summary"]),
227
+ NeighbouringParagraphsStrategy(before=5, after=5),
228
+ MetadataExtensionStrategy(
229
+ types=[
230
+ MetadataExtensionType.CLASSIFICATION_LABELS,
231
+ MetadataExtensionType.ORIGIN,
232
+ ]
233
+ ),
234
+ ]
235
+
236
+ if args.filters is not None:
237
+ ask_request.filters = args.filters
238
+
239
+ if args.search_configuration is not None:
240
+ ask_request.search_configuration = args.search_configuration
241
+
242
+ # citations does not work with rag strategies yet
243
+
244
+ ask_request.generate_answer = True
245
+ ask_request.citations = CitationsType.LLM_FOOTNOTES
246
+ ask_request.debug = True
247
+ try:
248
+ ask_result: AskResult = await ask(
249
+ search_sdk=context.ndb_search,
250
+ reader_sdk=context.ndb_reader,
251
+ kbid=context.kbid,
252
+ ask_request=ask_request,
253
+ user_id=context.x_nucliadb_user,
254
+ client_type=context.x_ndb_client,
255
+ origin=context.x_forwarded_for,
256
+ extra_predict_headers={
257
+ "X-Show-Consumption": "True",
258
+ },
259
+ )
260
+ except KnowledgeBoxNotFound as e:
261
+ raise ResourceError(f"Knowledge base '{context.kbid}' was not found") from e
262
+ except InvalidQueryError as e:
263
+ raise ResourceError(f"Invalid search query — {e}") from e
264
+ except NoRetrievalResultsError as e:
265
+ raise ResourceError("No documents matched the search query") from e
266
+ except (RateLimitError, AccountLimitError) as e:
267
+ raise ResourceError(
268
+ "Service limit reached" + (f": {e}" if str(e) else "")
269
+ ) from e
270
+ except NotFoundError as e:
271
+ raise ResourceError(
272
+ "Resource not found during search" + (f": {e}" if str(e) else "")
273
+ ) from e
274
+ except Exception as e:
275
+ logger.exception("Unexpected error in search_documents")
276
+ raise ResourceError("Search failed" + (f": {e}" if str(e) else "")) from e
277
+
278
+ resp = await ask_result.model_dump()
279
+ document_ids = _get_document_ids(resp.citation_footnote_to_context)
280
+
281
+ return _build_citation_blocks(resp) + [
282
+ TextContent(
283
+ type="text",
284
+ text=_append_document_ids_to_answer(resp.answer, document_ids),
285
+ annotations=Annotations(audience=["user", "assistant"]),
286
+ _meta={
287
+ "type": "answer",
288
+ "document_ids": document_ids,
289
+ },
290
+ )
291
+ ]
292
+
293
+
294
+ def _build_citation_blocks(
295
+ resp: Any,
296
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
297
+ """Convert citation footnotes from an ask response into TextContent blocks."""
298
+ result: list[TextContent | ImageContent | EmbeddedResource] = []
299
+ if not resp.citation_footnote_to_context:
300
+ return result
301
+
302
+ for ident, citation in resp.citation_footnote_to_context.items():
303
+ is_field: bool | None = None
304
+ if citation.count("/") == 4:
305
+ resource_id, field_type, field, split, positions = citation.split("/")
306
+ elif citation.count("/") == 3:
307
+ resource_id, field_type, field, positions = citation.split("/")
308
+ elif citation.count("/") == 2:
309
+ resource_id, field_type, field = citation.split("/")
310
+ is_field = True
311
+ else:
312
+ raise ResourceError(
313
+ f"Unexpected citation format for '{ident}'"
314
+ f" (got {citation.count('/')} slashes): {citation!r}"
315
+ )
316
+ try:
317
+ resource = resp.retrieval_results.resources[resource_id]
318
+ except KeyError:
319
+ logger.warning(
320
+ f"Citation '{ident}' references unknown resource '{resource_id}', skipping"
321
+ )
322
+ continue
323
+
324
+ meta = {
325
+ "type": "citations",
326
+ "paragraph_id": ident,
327
+ "resource_labels": resource.computedmetadata.field_classifications
328
+ if resource.computedmetadata
329
+ else [],
330
+ "resource_title": resource.title,
331
+ "parent": resource_id,
332
+ "field_type": field_type,
333
+ "field": field,
334
+ }
335
+
336
+ if (
337
+ is_field is True
338
+ and resp.augmented_context is not None
339
+ and citation in resp.augmented_context.fields
340
+ ):
341
+ block = resp.augmented_context.fields[citation]
342
+ result.append(
343
+ TextContent(
344
+ type="text",
345
+ text=block.text,
346
+ annotations=Annotations(audience=["user", "assistant"]),
347
+ _meta=meta,
348
+ )
349
+ )
350
+ elif (
351
+ resp.augmented_context is not None
352
+ and citation in resp.augmented_context.paragraphs
353
+ ):
354
+ block = resp.augmented_context.paragraphs[citation]
355
+ result.append(
356
+ TextContent(
357
+ type="text",
358
+ text=block.text,
359
+ annotations=Annotations(audience=["user", "assistant"]),
360
+ _meta=meta,
361
+ )
362
+ )
363
+
364
+ # if paragraph.reference:
365
+ # data = await ndb_search.get_image_data(paragraph.reference)
366
+ # result.append(
367
+ # ImageContent(
368
+ # type="image",
369
+ # mimeType="image/png",
370
+ # data=data,
371
+ # annotations=Annotations(audience=["user"]),
372
+ # _meta={
373
+ # "type": "citations_image",
374
+ # "paragraph_id": ident,
375
+ # "resource_labels": resource.computedmetadata.field_classifications,
376
+ # "resource_title": resource.title,
377
+ # },
378
+ # )
379
+ # )
380
+
381
+ return result
382
+
383
+
384
+ async def _tool_get_document(
385
+ context: MCPContext,
386
+ args: GetDocumentArgs,
387
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
388
+ return await get_resource(context.ndb_reader, context.kbid, args.name)
389
+
390
+
391
+ async def _tool_batch_get_documents(
392
+ context: MCPContext,
393
+ args: BatchGetDocumentsArgs,
394
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
395
+ tasks = [
396
+ get_resource(context.ndb_reader, context.kbid, name) for name in args.names
397
+ ]
398
+ results = await gather(*tasks, return_exceptions=True)
399
+ converted_result: list[TextContent | ImageContent | EmbeddedResource] = []
400
+ for doc_name, result in zip(args.names, results):
401
+ if isinstance(result, BaseException) and not isinstance(result, Exception):
402
+ raise result
403
+ elif isinstance(result, Exception):
404
+ logger.warning(f"Failed to fetch document '{doc_name}': {result}")
405
+ converted_result.append(
406
+ TextContent(
407
+ type="text",
408
+ text=f"[Error fetching '{doc_name}'"
409
+ + (f": {result}" if str(result) else "")
410
+ + "]",
411
+ annotations=Annotations(audience=["user", "assistant"]),
412
+ )
413
+ )
414
+ else:
415
+ converted_result.extend(result)
416
+ return converted_result
417
+
418
+
419
+ _TOOL_DEFINITIONS: list[ToolDefinition] = [
420
+ ToolDefinition(
421
+ name="search_documents",
422
+ description=(
423
+ "Use this tool to find documents about {kb_description}. "
424
+ "This tool returns chunks of text, names and URLs for matching documents. "
425
+ "If the returned chunks are not detailed enough to answer the user's question "
426
+ "use 'get_document' or 'batch_get_documents' with the 'parent' from this tool's "
427
+ "output to retrieve the full document content. You can narrow results using "
428
+ "optional 'filters' to match on classification labels or metadata values."
429
+ ),
430
+ args_model=SearchDocumentsArgs,
431
+ handler=_tool_search_documents,
432
+ ),
433
+ ToolDefinition(
434
+ name="get_document",
435
+ description=(
436
+ "Use this tool to get the full content of a single document. "
437
+ "The document ID should be obtained from the 'parent' field "
438
+ "of results from a call to the 'search_documents' tool. "
439
+ "If you need to retrieve multiple documents use 'batch_get_documents' instead."
440
+ ),
441
+ args_model=GetDocumentArgs,
442
+ handler=_tool_get_document,
443
+ ),
444
+ ToolDefinition(
445
+ name="batch_get_documents",
446
+ description=(
447
+ f"Use this tool to retrieve the full content of up to {BATCH_GET_DOCUMENTS_MAX} "
448
+ "documents in a single call. The document IDs should be obtained from the 'parent' "
449
+ "field of results from a call to the 'search_documents' tool. "
450
+ "If you only need to retrieve a single document use 'get_document' instead."
451
+ ),
452
+ args_model=BatchGetDocumentsArgs,
453
+ handler=_tool_batch_get_documents,
454
+ ),
455
+ ]
456
+
457
+ _TOOL_REGISTRY: dict[str, ToolDefinition] = {td.name: td for td in _TOOL_DEFINITIONS}
458
+
459
+
460
+ async def list_tools(description: str) -> list[Tool]:
461
+ return [
462
+ Tool(
463
+ name=td.name,
464
+ description=td.description.format(kb_description=description),
465
+ inputSchema=td.args_model.model_json_schema(),
466
+ )
467
+ for td in _TOOL_DEFINITIONS
468
+ ]
469
+
470
+
471
+ async def call_tool(
472
+ x_stf_account: str,
473
+ x_nucliadb_user: str,
474
+ x_ndb_client: NucliaDBClientType,
475
+ x_forwarded_for: str,
476
+ ndb_reader: NucliaDBAsync,
477
+ ndb_search: NucliaDBAsync,
478
+ kbid: str,
479
+ name: str,
480
+ arguments: dict[str, Any],
481
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
482
+ """Dispatch an MCP tool call to its handler."""
483
+ if name not in _TOOL_REGISTRY:
484
+ raise ResourceError(f"Unknown tool: {name!r}")
485
+ tool_def = _TOOL_REGISTRY[name]
486
+ try:
487
+ args = tool_def.args_model.model_validate(arguments)
488
+ except ValidationError as e:
489
+ error_details = "; ".join(
490
+ f"'{'.'.join(str(p) for p in err['loc'])}': {err['msg']}"
491
+ for err in e.errors()
492
+ )
493
+ raise ResourceError(f"Invalid arguments for '{name}': {error_details}") from e
494
+ context = MCPContext(
495
+ ndb_reader=ndb_reader,
496
+ ndb_search=ndb_search,
497
+ kbid=kbid,
498
+ x_nucliadb_user=x_nucliadb_user,
499
+ x_ndb_client=x_ndb_client,
500
+ x_forwarded_for=x_forwarded_for,
501
+ )
502
+ return await tool_def.handler(context, args)
503
+
504
+
505
+ async def list_resources(ndb: NucliaDBAsync, kbid: str) -> list[Resource]:
506
+ resources = await ndb.list_resources(kbid=kbid)
507
+
508
+ # TODO : Resource 1 : The list of tools ??
509
+ return [
510
+ Resource(
511
+ uri=AnyUrl(f"nucliadb://{kbid}/{resource.id}"),
512
+ name=resource.title if resource.title is not None else "",
513
+ description=resource.summary,
514
+ mimeType=resource.icon,
515
+ size=0,
516
+ )
517
+ for resource in resources.resources
518
+ ]
519
+
520
+
521
+ async def list_resource_templates() -> list[ResourceTemplate]:
522
+ return []
523
+
524
+
525
+ async def list_prompts() -> list[Prompt]:
526
+ """List all available prompts."""
527
+ return []
528
+
529
+
530
+ async def get_prompt(
531
+ name: str, arguments: dict[str, Any] | None = None
532
+ ) -> GetPromptResult:
533
+ """Get a prompt by name with arguments."""
534
+ raise ResourceError(f"Unknown prompt: {name!r}")
535
+
536
+
537
+ async def read_resource(
538
+ ndb: NucliaDBAsync, kbid: str, uri: AnyUrl | str
539
+ ) -> Iterable[ReadResourceContents]:
540
+ """Read a resource by URI."""
541
+ str_uri = str(uri)
542
+ if str_uri.startswith("nucliadb://"):
543
+ str_uri = str_uri.replace("nucliadb://", "")
544
+ parts = str_uri.split("/")
545
+ if len(parts) != 2:
546
+ raise ResourceError(
547
+ f"Malformed resource URI, expected 'nucliadb://{{kbid}}/{{rid}}': {uri!r}"
548
+ )
549
+ kbid_uri, rid = parts
550
+ try:
551
+ resource = await ndb.get_resource_by_id(
552
+ kbid=kbid,
553
+ rid=rid,
554
+ query_params={"show": ["basic", "extracted"], "extracted": ["text"]},
555
+ )
556
+ except NotFoundError as e:
557
+ raise ResourceError(f"Resource not found: {uri!r}") from e
558
+ except Exception as e:
559
+ logger.exception(f"Error fetching resource {uri!r}")
560
+ raise ResourceError(
561
+ f"Failed to fetch resource {uri!r}" + (f": {e}" if str(e) else "")
562
+ ) from e
563
+
564
+ if not resource:
565
+ raise ResourceError(f"Resource not found: {uri!r}")
566
+
567
+ try:
568
+ content = ""
569
+ if resource.data:
570
+ if resource.data.files:
571
+ for file_field in resource.data.files.values():
572
+ if (
573
+ file_field.extracted
574
+ and file_field.extracted.text
575
+ and file_field.extracted.text.text
576
+ ):
577
+ content += file_field.extracted.text.text
578
+ content += " \n\n "
579
+ if resource.data.links:
580
+ for link_field in resource.data.links.values():
581
+ if (
582
+ link_field.extracted
583
+ and link_field.extracted.text
584
+ and link_field.extracted.text.text
585
+ ):
586
+ content += link_field.extracted.text.text
587
+ content += " \n\n "
588
+ if resource.data.texts:
589
+ for text_field in resource.data.texts.values():
590
+ if (
591
+ text_field.extracted
592
+ and text_field.extracted.text
593
+ and text_field.extracted.text.text
594
+ ):
595
+ content += text_field.extracted.text.text
596
+ content += " \n\n "
597
+ if resource.data.conversations:
598
+ for conversation_field in resource.data.conversations.values():
599
+ if (
600
+ conversation_field.extracted
601
+ and conversation_field.extracted.text
602
+ ):
603
+ if conversation_field.extracted.text.split_text:
604
+ for split_text in conversation_field.extracted.text.split_text.values():
605
+ content += split_text
606
+ content += " \n\n "
607
+ if conversation_field.extracted.text.text:
608
+ content += conversation_field.extracted.text.text
609
+ content += " \n\n "
610
+ return [ReadResourceContents(content=content, mime_type=resource.icon)]
611
+ except ResourceError:
612
+ raise
613
+ except Exception as e:
614
+ logger.exception(f"Error reading resource {uri!r}")
615
+ raise ResourceError(
616
+ f"Failed to read resource {uri!r}" + (f": {e}" if str(e) else "")
617
+ ) from e
618
+ else:
619
+ raise ResourceError(
620
+ f"Unknown resource URI scheme (expected 'nucliadb://'): {uri!r}"
621
+ )
622
+
623
+
624
+ @router.get("/.well-known/oauth-protected-resource/api/v1/kb/{kbid}/mcp")
625
+ async def mcp_protected_resource_metadata(
626
+ request: Request,
627
+ kbid: str,
628
+ ):
629
+ """
630
+ Protected resource metadata discovery endpoint for MCP server authorization.
631
+ See https://datatracker.ietf.org/doc/html/rfc9728 for details on the OAuth-protected resource metadata format and discovery process.
632
+ """
633
+ app: HTTPApplication = request.app
634
+ mcp_url = request.url_for("mcp_handler", kbid=kbid)
635
+ mcp_url_https = str(mcp_url).replace(
636
+ "http://", "https://"
637
+ ) # Ensure the URL uses https
638
+ return {
639
+ "resource": mcp_url_https,
640
+ "scopes_supported": app.settings.hydra_scopes_supported,
641
+ "authorization_servers": [app.settings.hydra_public_url],
642
+ }
643
+
644
+
645
+ @router.get("/api/v1/kb/{kbid}/mcp")
646
+ @router.post("/api/v1/kb/{kbid}/mcp")
647
+ @requires_one([NucliaDBRoles.READER])
648
+ async def mcp_handler(
649
+ request: Request,
650
+ kbid: str,
651
+ x_stf_user: str = Header(..., include_in_schema=False),
652
+ x_ndb_client: NucliaDBClientType = Header(NucliaDBClientType.API),
653
+ x_nucliadb_user: str = Header(""),
654
+ x_forwarded_for: str = Header(""),
655
+ x_stf_account: str = Header(..., include_in_schema=False),
656
+ x_stf_account_type: str = Header(..., include_in_schema=False),
657
+ ):
658
+ app: HTTPApplication = request.app
659
+
660
+ logger.debug("Stateless mode: Creating new transport for this request")
661
+ # No session ID needed in stateless mode
662
+ security_settings: TransportSecuritySettings | None = None
663
+ http_transport = StreamableHTTPServerTransport(
664
+ mcp_session_id=None, # No session tracking in stateless mode
665
+ is_json_response_enabled=True,
666
+ event_store=None, # No event store in stateless mode
667
+ security_settings=security_settings,
668
+ )
669
+
670
+ if kbid not in app.mcp_servers:
671
+ mcp_server = MCPServer(
672
+ name=kbid,
673
+ instructions="Instructions for the MCP server",
674
+ lifespan=default_lifespan,
675
+ )
676
+ ndb_reader: NucliaDBAsync = app.arag_reader
677
+ ndb_search: NucliaDBAsync = app.arag_search
678
+ list_resources_partial = partial(list_resources, ndb_reader, kbid)
679
+ call_tool_partial = partial(
680
+ call_tool,
681
+ x_stf_account,
682
+ x_nucliadb_user,
683
+ x_ndb_client,
684
+ x_forwarded_for,
685
+ ndb_reader,
686
+ ndb_search,
687
+ kbid,
688
+ )
689
+ # Ensure MCP framework calls call_tool_partial with name and arguments as positional arguments
690
+ # If not, use: partial(call_tool, x_stf_account, ndb_reader, ndb_search, kbid, name=..., arguments=...)
691
+ read_resource_partial = partial(read_resource, ndb_reader, kbid)
692
+ list_tools_partial = partial(
693
+ list_tools, description=f"the knowledge in the {kbid} knowledge base"
694
+ )
695
+
696
+ mcp_server.list_tools()(list_tools_partial)
697
+ mcp_server.call_tool()(call_tool_partial)
698
+ mcp_server.list_resources()(list_resources_partial)
699
+ mcp_server.read_resource()(read_resource_partial)
700
+ mcp_server.list_prompts()(list_prompts)
701
+ mcp_server.get_prompt()(get_prompt)
702
+ mcp_server.list_resource_templates()(list_resource_templates)
703
+
704
+ app.mcp_servers[kbid] = mcp_server
705
+
706
+ mcp_server = app.mcp_servers[kbid]
707
+
708
+ # Start server in a new task
709
+ async def run_stateless_server(
710
+ *, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORED
711
+ ):
712
+ async with http_transport.connect() as streams:
713
+ read_stream, write_stream = streams
714
+ task_status.started()
715
+ try:
716
+ await mcp_server.run(
717
+ read_stream,
718
+ write_stream,
719
+ mcp_server.create_initialization_options(),
720
+ stateless=True,
721
+ )
722
+ except Exception:
723
+ logger.exception("Stateless session crashed")
724
+
725
+ # Intercept ASGI send messages so FastAPI doesn't attempt to send a
726
+ # second response after the transport has already sent one (which would
727
+ # cause: RuntimeError: Unexpected ASGI message 'http.response.start'
728
+ # sent, after response already completed).
729
+ response_status = 200
730
+ response_headers: dict[str, str] = {}
731
+ body_chunks: list[bytes] = []
732
+
733
+ async def intercepting_send(message: MutableMapping[str, Any]) -> None:
734
+ nonlocal response_status
735
+ if message["type"] == "http.response.start":
736
+ response_status = message["status"]
737
+ response_headers.update(
738
+ {k.decode(): v.decode() for k, v in message.get("headers", [])}
739
+ )
740
+ elif message["type"] == "http.response.body":
741
+ body_chunks.append(message.get("body", b""))
742
+
743
+ # Assert task group is not None for type checking
744
+ async with anyio.create_task_group() as tg:
745
+ # Start the server task
746
+ await tg.start(run_stateless_server)
747
+
748
+ # Handle the HTTP request via the intercepting send
749
+ await http_transport.handle_request(
750
+ request.scope, request._receive, intercepting_send
751
+ )
752
+
753
+ # Terminate the transport after the request is handled
754
+ await http_transport.terminate()
755
+
756
+ return Response(
757
+ content=b"".join(body_chunks),
758
+ status_code=response_status,
759
+ headers=response_headers,
760
+ )
761
+
762
+
763
+ def _convert_to_content(
764
+ result: Any,
765
+ ) -> Sequence[TextContent | ImageContent | EmbeddedResource]:
766
+ """Convert a result to a sequence of content objects."""
767
+ if result is None:
768
+ return []
769
+
770
+ if isinstance(result, (TextContent, ImageContent, EmbeddedResource)):
771
+ return [result]
772
+
773
+ if isinstance(result, Image):
774
+ return [result.to_image_content()]
775
+
776
+ if isinstance(result, (list, tuple)):
777
+ return list(chain.from_iterable(_convert_to_content(item) for item in result))
778
+
779
+ if not isinstance(result, str):
780
+ try:
781
+ result = json.dumps(pydantic_core.to_jsonable_python(result))
782
+ except Exception:
783
+ result = str(result)
784
+
785
+ return [TextContent(type="text", text=result)]