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.
- nucliadb_agentic_api/VERSION +1 -0
- nucliadb_agentic_api/__init__.py +20 -0
- nucliadb_agentic_api/agentic/__init__.py +0 -0
- nucliadb_agentic_api/agentic/ask_handler.py +66 -0
- nucliadb_agentic_api/agentic/ask_result.py +389 -0
- nucliadb_agentic_api/agentic/ask_transform_to_interaction.py +10 -0
- nucliadb_agentic_api/app.py +147 -0
- nucliadb_agentic_api/commands.py +66 -0
- nucliadb_agentic_api/db/__init__.py +0 -0
- nucliadb_agentic_api/db/agentic_configs.py +296 -0
- nucliadb_agentic_api/db/settings.py +11 -0
- nucliadb_agentic_api/db/sources.py +202 -0
- nucliadb_agentic_api/db/transform.py +208 -0
- nucliadb_agentic_api/exceptions.py +10 -0
- nucliadb_agentic_api/logging.py +18 -0
- nucliadb_agentic_api/models.py +321 -0
- nucliadb_agentic_api/py.typed +0 -0
- nucliadb_agentic_api/server/__init__.py +1 -0
- nucliadb_agentic_api/server/server.py +113 -0
- nucliadb_agentic_api/server/session.py +276 -0
- nucliadb_agentic_api/server/settings.py +22 -0
- nucliadb_agentic_api/settings.py +39 -0
- nucliadb_agentic_api/utils.py +284 -0
- nucliadb_agentic_api/v1/__init__.py +19 -0
- nucliadb_agentic_api/v1/agentic_configs.py +117 -0
- nucliadb_agentic_api/v1/ask.py +303 -0
- nucliadb_agentic_api/v1/ask_websocket.py +141 -0
- nucliadb_agentic_api/v1/mcp_content.py +310 -0
- nucliadb_agentic_api/v1/mcp_nucliadb.py +785 -0
- nucliadb_agentic_api/v1/oauth.py +60 -0
- nucliadb_agentic_api/v1/router.py +3 -0
- nucliadb_agentic_api/v1/sources.py +158 -0
- nucliadb_agentic_api/v1/utils.py +12 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/METADATA +150 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/RECORD +37 -0
- nucliadb_agentic_api-1.0.0.post156.dist-info/WHEEL +4 -0
- 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)]
|