equinix-docs-mcp-server 0.3.1__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.
- equinix_docs_mcp_server/__init__.py +21 -0
- equinix_docs_mcp_server/api_notices.py +384 -0
- equinix_docs_mcp_server/arazzo_manager.py +1032 -0
- equinix_docs_mcp_server/auth.py +217 -0
- equinix_docs_mcp_server/catalog_discovery.py +224 -0
- equinix_docs_mcp_server/config.py +272 -0
- equinix_docs_mcp_server/data/config/apis.yaml +362 -0
- equinix_docs_mcp_server/data/config/workflows-dcim.yaml +350 -0
- equinix_docs_mcp_server/data/config/workflows.yaml +25 -0
- equinix_docs_mcp_server/data/overlays/accesstoken.yaml +17 -0
- equinix_docs_mcp_server/data/overlays/billing.yaml +43 -0
- equinix_docs_mcp_server/data/overlays/fabric.yaml +43 -0
- equinix_docs_mcp_server/data/overlays/network-edge.yaml +31 -0
- equinix_docs_mcp_server/data/overlays/workvisitsv2.yaml +31 -0
- equinix_docs_mcp_server/data/overlays/workvisitv1.yaml +31 -0
- equinix_docs_mcp_server/docs.py +458 -0
- equinix_docs_mcp_server/lunr_search/search_client.py +251 -0
- equinix_docs_mcp_server/main.py +616 -0
- equinix_docs_mcp_server/openapi_annotations.py +143 -0
- equinix_docs_mcp_server/openapi_overlays/__init__.py +10 -0
- equinix_docs_mcp_server/openapi_overlays/overlay_manager.py +171 -0
- equinix_docs_mcp_server/openrpc.py +694 -0
- equinix_docs_mcp_server/rate_limits.py +183 -0
- equinix_docs_mcp_server/response_formatter.py +216 -0
- equinix_docs_mcp_server/spec_manager.py +864 -0
- equinix_docs_mcp_server/swagger2openapi/converter.py +343 -0
- equinix_docs_mcp_server/tool_ranking.py +104 -0
- equinix_docs_mcp_server/tool_search.py +303 -0
- equinix_docs_mcp_server/workflow_http.py +255 -0
- equinix_docs_mcp_server-0.3.1.dist-info/METADATA +342 -0
- equinix_docs_mcp_server-0.3.1.dist-info/RECORD +35 -0
- equinix_docs_mcp_server-0.3.1.dist-info/WHEEL +5 -0
- equinix_docs_mcp_server-0.3.1.dist-info/entry_points.txt +2 -0
- equinix_docs_mcp_server-0.3.1.dist-info/licenses/LICENSE +21 -0
- equinix_docs_mcp_server-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Package initialization for equinix_docs_mcp_server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
# Set from the repository's version file at build time
|
|
7
|
+
__version__ = version("equinix-docs-mcp-server")
|
|
8
|
+
except PackageNotFoundError: # pragma: no cover - running from a source tree
|
|
9
|
+
__version__ = "0.0.0"
|
|
10
|
+
__author__ = "Equinix Labs"
|
|
11
|
+
__description__ = "Equinix Docs and API specifications MCP Server (experimental)"
|
|
12
|
+
|
|
13
|
+
from .auth import AuthManager
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .docs import DocsManager
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Config",
|
|
19
|
+
"AuthManager",
|
|
20
|
+
"DocsManager",
|
|
21
|
+
]
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Deprecation, sunset and retry notices for Equinix API tool calls.
|
|
2
|
+
|
|
3
|
+
Equinix APIs announce that an endpoint is going away with the
|
|
4
|
+
``Deprecation`` header and the date it stops working with ``Sunset``
|
|
5
|
+
(RFC 8594), and may link a successor with ``Link`` relations. Those headers
|
|
6
|
+
never reach the agent through the tool result, so they are collected from
|
|
7
|
+
each API response and appended to it.
|
|
8
|
+
|
|
9
|
+
A deprecation is mainly advice for whoever calls the API, here the agent.
|
|
10
|
+
It is described in full once per client (the server process over stdio,
|
|
11
|
+
the MCP session over HTTP) and API family, and in one line after that, so the agent can act on it (use the successor) without
|
|
12
|
+
repeating it to the user on every call.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import contextvars
|
|
16
|
+
import logging
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from email.utils import parsedate_to_datetime
|
|
21
|
+
from typing import Any, Callable, Mapping, Optional
|
|
22
|
+
|
|
23
|
+
import httpx2
|
|
24
|
+
from fastmcp.exceptions import ToolError
|
|
25
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
26
|
+
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
27
|
+
from fastmcp.server.providers.openapi import OpenAPITool
|
|
28
|
+
from fastmcp.tools.base import ToolResult
|
|
29
|
+
from mcp_types import TextContent
|
|
30
|
+
|
|
31
|
+
from .rate_limits import retry_hint
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Link relations worth passing on: RFC 8594 (sunset), RFC 9745 (deprecation)
|
|
36
|
+
# and RFC 5829 (successor-version, latest-version)
|
|
37
|
+
NOTICE_LINK_RELS = (
|
|
38
|
+
"successor-version",
|
|
39
|
+
"latest-version",
|
|
40
|
+
"alternate",
|
|
41
|
+
"deprecation",
|
|
42
|
+
"sunset",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Distinct notices remembered per server before the oldest are forgotten
|
|
46
|
+
MAX_SEEN_NOTICES = 10_000
|
|
47
|
+
|
|
48
|
+
_LINK_RE = re.compile(r"<([^>]*)>([^,<]*)")
|
|
49
|
+
_REL_RE = re.compile(r'\brel\s*=\s*"?([^";]+)"?', re.IGNORECASE)
|
|
50
|
+
|
|
51
|
+
# Notices recorded by API responses during the current tool call
|
|
52
|
+
_notices: contextvars.ContextVar[Optional[list["ApiNotice"]]] = contextvars.ContextVar(
|
|
53
|
+
"equinix_api_notices", default=None
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_http_date(value: Optional[str]) -> Optional[datetime]:
|
|
58
|
+
"""Parse an HTTP-date or an RFC 9745 ``@<epoch seconds>`` date."""
|
|
59
|
+
if not value:
|
|
60
|
+
return None
|
|
61
|
+
value = value.strip()
|
|
62
|
+
if value.startswith("@"):
|
|
63
|
+
try:
|
|
64
|
+
return datetime.fromtimestamp(int(value[1:]), tz=timezone.utc)
|
|
65
|
+
except (ValueError, OverflowError, OSError):
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
when = parsedate_to_datetime(value)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
if when is not None and when.tzinfo is None:
|
|
72
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
73
|
+
return when
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_links(value: Optional[str]) -> list[tuple[str, str]]:
|
|
77
|
+
"""Return (rel, url) pairs for the notice relations in a Link header."""
|
|
78
|
+
links: list[tuple[str, str]] = []
|
|
79
|
+
for url, params in _LINK_RE.findall(value or ""):
|
|
80
|
+
match = _REL_RE.search(params)
|
|
81
|
+
if not match:
|
|
82
|
+
continue
|
|
83
|
+
for rel in match.group(1).lower().split():
|
|
84
|
+
if rel in NOTICE_LINK_RELS:
|
|
85
|
+
links.append((rel, url.strip()))
|
|
86
|
+
return links
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class ApiNotice:
|
|
91
|
+
"""Deprecation and sunset details from one API response."""
|
|
92
|
+
|
|
93
|
+
method: str
|
|
94
|
+
url: str
|
|
95
|
+
status_code: int
|
|
96
|
+
deprecated: bool
|
|
97
|
+
deprecated_since: Optional[datetime]
|
|
98
|
+
sunset: Optional[datetime]
|
|
99
|
+
links: tuple[tuple[str, str], ...]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def sunset_passed(self) -> bool:
|
|
103
|
+
return self.sunset is not None and self.sunset <= datetime.now(timezone.utc)
|
|
104
|
+
|
|
105
|
+
def link(self, *rels: str) -> Optional[str]:
|
|
106
|
+
for rel in rels:
|
|
107
|
+
for link_rel, url in self.links:
|
|
108
|
+
if link_rel == rel:
|
|
109
|
+
return url
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def notice_from_headers(
|
|
114
|
+
method: str, url: str, status_code: int, headers: Mapping[str, str]
|
|
115
|
+
) -> Optional[ApiNotice]:
|
|
116
|
+
"""Build a notice when response headers carry Deprecation or Sunset.
|
|
117
|
+
|
|
118
|
+
``headers`` must look names up case-insensitively, as both httpx and
|
|
119
|
+
requests headers do. ``url`` should have its query removed.
|
|
120
|
+
"""
|
|
121
|
+
deprecation = headers.get("Deprecation")
|
|
122
|
+
sunset = headers.get("Sunset")
|
|
123
|
+
if not deprecation and not sunset:
|
|
124
|
+
return None
|
|
125
|
+
return ApiNotice(
|
|
126
|
+
method=method,
|
|
127
|
+
url=url,
|
|
128
|
+
status_code=status_code,
|
|
129
|
+
# A Sunset header alone is not a deprecation. Early drafts of the
|
|
130
|
+
# Deprecation header used "true" rather than a date.
|
|
131
|
+
deprecated=bool(deprecation),
|
|
132
|
+
deprecated_since=parse_http_date(deprecation),
|
|
133
|
+
sunset=parse_http_date(sunset),
|
|
134
|
+
links=tuple(parse_links(headers.get("Link"))),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def notice_from_response(response: httpx2.Response) -> Optional[ApiNotice]:
|
|
139
|
+
"""Build a notice when a response carries Deprecation or Sunset headers."""
|
|
140
|
+
return notice_from_headers(
|
|
141
|
+
response.request.method,
|
|
142
|
+
str(response.request.url.copy_with(query=None)),
|
|
143
|
+
response.status_code,
|
|
144
|
+
response.headers,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def record_notice(notice: Optional[ApiNotice]) -> None:
|
|
149
|
+
"""Note a notice for the running tool call, if a tool call is running.
|
|
150
|
+
|
|
151
|
+
Safe from a worker thread started with ``asyncio.to_thread``, which
|
|
152
|
+
copies the tool call's context.
|
|
153
|
+
"""
|
|
154
|
+
notices = _notices.get()
|
|
155
|
+
if notices is not None and notice is not None:
|
|
156
|
+
notices.append(notice)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def record_response(response: httpx2.Response) -> None:
|
|
160
|
+
"""httpx response hook: note deprecation headers for the running tool call."""
|
|
161
|
+
if _notices.get() is not None:
|
|
162
|
+
record_notice(notice_from_response(response))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _day(when: Optional[datetime]) -> str:
|
|
166
|
+
return when.strftime("%Y-%m-%d") if when else ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def describe_notice(notice: ApiNotice, successor_tool: Optional[str] = None) -> str:
|
|
170
|
+
"""The full notice, given once per client and API family."""
|
|
171
|
+
path = httpx2.URL(notice.url).path
|
|
172
|
+
what = f"{notice.method} {path}"
|
|
173
|
+
parts = []
|
|
174
|
+
if notice.deprecated:
|
|
175
|
+
since = (
|
|
176
|
+
f" since {_day(notice.deprecated_since)}" if notice.deprecated_since else ""
|
|
177
|
+
)
|
|
178
|
+
parts.append(f"is deprecated{since}")
|
|
179
|
+
if notice.sunset_passed:
|
|
180
|
+
parts.append(f"reached its sunset date on {_day(notice.sunset)}")
|
|
181
|
+
elif notice.sunset:
|
|
182
|
+
parts.append(f"is scheduled to stop working on {_day(notice.sunset)}")
|
|
183
|
+
lines = [f"Equinix API notice: {what} {' and '.join(parts)}."]
|
|
184
|
+
|
|
185
|
+
successor = notice.link("successor-version", "latest-version", "alternate")
|
|
186
|
+
if successor:
|
|
187
|
+
tool = f" (tool {successor_tool})" if successor_tool else ""
|
|
188
|
+
lines.append(f"Successor: {successor}{tool}.")
|
|
189
|
+
info = notice.link("deprecation", "sunset")
|
|
190
|
+
if info:
|
|
191
|
+
lines.append(f"More information: {info}")
|
|
192
|
+
lines.append(
|
|
193
|
+
"This is advice for the caller: use the successor, or a newer version "
|
|
194
|
+
"of this API found with search_tools, where one exists. Tell the user "
|
|
195
|
+
"only if it affects their resources or plans, not on every call."
|
|
196
|
+
)
|
|
197
|
+
return "\n".join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def describe_notice_briefly(notice: ApiNotice) -> str:
|
|
201
|
+
"""The one-line reminder for later calls from the same client and family."""
|
|
202
|
+
path = httpx2.URL(notice.url).path
|
|
203
|
+
when = f"; sunset {_day(notice.sunset)}" if notice.sunset else ""
|
|
204
|
+
state = "deprecated" if notice.deprecated else "scheduled for retirement"
|
|
205
|
+
return (
|
|
206
|
+
f"Equinix API notice: {notice.method} {path} is {state}{when} "
|
|
207
|
+
"(described earlier in this session)."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def describe_sunset_failure(notice: ApiNotice) -> Optional[str]:
|
|
212
|
+
"""Explain a failure from an endpoint that is past its sunset or gone."""
|
|
213
|
+
if notice.status_code == 410 or (
|
|
214
|
+
notice.sunset_passed and notice.status_code == 404
|
|
215
|
+
):
|
|
216
|
+
return (
|
|
217
|
+
"This endpoint appears to have been retired, so retrying won't "
|
|
218
|
+
"help. Use the successor if one is linked above, or search_tools "
|
|
219
|
+
"for a newer version of this API."
|
|
220
|
+
)
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class _ToolIndex:
|
|
225
|
+
"""Find the tool for a URL, e.g. a successor-version link."""
|
|
226
|
+
|
|
227
|
+
def __init__(self) -> None:
|
|
228
|
+
self._routes: list[tuple[str, re.Pattern[str], str]] = []
|
|
229
|
+
|
|
230
|
+
def add(self, method: str, path: str, tool_name: str) -> None:
|
|
231
|
+
# Each {param} in the path template matches one path segment
|
|
232
|
+
parts = re.split(r"\{[^}/]+\}", path)
|
|
233
|
+
pattern = "[^/]+".join(re.escape(part) for part in parts)
|
|
234
|
+
self._routes.append((method.upper(), re.compile(f"^{pattern}/?$"), tool_name))
|
|
235
|
+
|
|
236
|
+
def find(self, url: str, method: str = "GET") -> Optional[str]:
|
|
237
|
+
"""The tool for a URL's path, preferring one with the same method."""
|
|
238
|
+
path = httpx2.URL(url).path
|
|
239
|
+
matches = [(m, name) for m, rx, name in self._routes if rx.match(path)]
|
|
240
|
+
for m, name in matches:
|
|
241
|
+
if m == method.upper():
|
|
242
|
+
return name
|
|
243
|
+
return matches[0][1] if matches else None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _http_error_response(error: BaseException) -> Optional[httpx2.Response]:
|
|
247
|
+
"""The upstream response behind a tool error, if any."""
|
|
248
|
+
seen: set[int] = set()
|
|
249
|
+
current: Optional[BaseException] = error
|
|
250
|
+
while current is not None and id(current) not in seen:
|
|
251
|
+
seen.add(id(current))
|
|
252
|
+
if isinstance(current, httpx2.HTTPStatusError):
|
|
253
|
+
return current.response
|
|
254
|
+
current = current.__cause__ or current.__context__
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class ApiNoticeMiddleware(Middleware):
|
|
259
|
+
"""Append deprecation, sunset and retry advice to API tool results.
|
|
260
|
+
|
|
261
|
+
Only the outermost tool call adds notices: ``call_tool`` from the search
|
|
262
|
+
transform and ``call_api_filtered`` run the API tool as a nested call,
|
|
263
|
+
and the notice belongs on the result the agent receives.
|
|
264
|
+
|
|
265
|
+
Add it before ``ResponseFormattingMiddleware`` so it runs outside it and
|
|
266
|
+
the text it appends isn't replaced by the YAML rendering.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, api_names: list[str]):
|
|
270
|
+
self._api_prefixes = tuple(f"{name}_" for name in api_names)
|
|
271
|
+
self._seen: dict[tuple[Any, ...], None] = {}
|
|
272
|
+
self.tools = _ToolIndex()
|
|
273
|
+
|
|
274
|
+
def component_hook(self, namespace: str) -> Callable[[Any, Any], None]:
|
|
275
|
+
"""An ``mcp_component_fn`` that indexes tools by path for successors."""
|
|
276
|
+
|
|
277
|
+
def hook(route: Any, component: Any) -> None:
|
|
278
|
+
if isinstance(component, OpenAPITool):
|
|
279
|
+
self.tools.add(
|
|
280
|
+
route.method, route.path, f"{namespace}_{component.name}"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return hook
|
|
284
|
+
|
|
285
|
+
def _family(self, tool_name: str) -> str:
|
|
286
|
+
for prefix in self._api_prefixes:
|
|
287
|
+
if tool_name.startswith(prefix):
|
|
288
|
+
return prefix[:-1]
|
|
289
|
+
return tool_name
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _api_tool_name(context: MiddlewareContext) -> str:
|
|
293
|
+
name = getattr(context.message, "name", "") or ""
|
|
294
|
+
arguments = getattr(context.message, "arguments", None) or {}
|
|
295
|
+
# call_tool and call_api_filtered name the API tool they run
|
|
296
|
+
inner = arguments.get("name") if isinstance(arguments, dict) else None
|
|
297
|
+
return inner if isinstance(inner, str) and inner else name
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _session_id() -> Optional[str]:
|
|
301
|
+
"""The HTTP session, if any. Over stdio a server has one client."""
|
|
302
|
+
# Context.session_id isn't stable across requests on every transport
|
|
303
|
+
return get_http_headers(include={"mcp-session-id"}).get("mcp-session-id")
|
|
304
|
+
|
|
305
|
+
def _first_time(self, key: tuple[Any, ...]) -> bool:
|
|
306
|
+
if key in self._seen:
|
|
307
|
+
return False
|
|
308
|
+
if len(self._seen) >= MAX_SEEN_NOTICES:
|
|
309
|
+
self._seen.pop(next(iter(self._seen)))
|
|
310
|
+
self._seen[key] = None
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
def _describe(
|
|
314
|
+
self, context: MiddlewareContext, notices: list[ApiNotice], always: bool
|
|
315
|
+
) -> list[str]:
|
|
316
|
+
family = self._family(self._api_tool_name(context))
|
|
317
|
+
session = self._session_id()
|
|
318
|
+
lines: list[str] = []
|
|
319
|
+
shown: set[tuple[Any, ...]] = set()
|
|
320
|
+
for notice in notices:
|
|
321
|
+
key = (
|
|
322
|
+
session,
|
|
323
|
+
family,
|
|
324
|
+
notice.deprecated,
|
|
325
|
+
notice.deprecated_since,
|
|
326
|
+
notice.sunset,
|
|
327
|
+
)
|
|
328
|
+
if key in shown:
|
|
329
|
+
continue
|
|
330
|
+
shown.add(key)
|
|
331
|
+
if self._first_time(key) or always:
|
|
332
|
+
successor = notice.link(
|
|
333
|
+
"successor-version", "latest-version", "alternate"
|
|
334
|
+
)
|
|
335
|
+
tool = self.tools.find(successor, notice.method) if successor else None
|
|
336
|
+
lines.append(describe_notice(notice, tool))
|
|
337
|
+
else:
|
|
338
|
+
lines.append(describe_notice_briefly(notice))
|
|
339
|
+
return lines
|
|
340
|
+
|
|
341
|
+
async def on_call_tool(
|
|
342
|
+
self,
|
|
343
|
+
context: MiddlewareContext,
|
|
344
|
+
call_next: CallNext,
|
|
345
|
+
) -> ToolResult:
|
|
346
|
+
if _notices.get() is not None:
|
|
347
|
+
return await call_next(context)
|
|
348
|
+
|
|
349
|
+
notices: list[ApiNotice] = []
|
|
350
|
+
token = _notices.set(notices)
|
|
351
|
+
try:
|
|
352
|
+
result = await call_next(context)
|
|
353
|
+
except Exception as e:
|
|
354
|
+
response = _http_error_response(e)
|
|
355
|
+
if response is None and not notices:
|
|
356
|
+
raise
|
|
357
|
+
# Errors always get the full notice: the failure may be the
|
|
358
|
+
# endpoint going away
|
|
359
|
+
extra = self._describe(context, notices, always=True)
|
|
360
|
+
if response is not None:
|
|
361
|
+
hint = retry_hint(response.request.method, response)
|
|
362
|
+
if hint:
|
|
363
|
+
extra.append(hint)
|
|
364
|
+
for notice in notices:
|
|
365
|
+
failure = describe_sunset_failure(notice)
|
|
366
|
+
if failure:
|
|
367
|
+
extra.append(failure)
|
|
368
|
+
break
|
|
369
|
+
if not extra:
|
|
370
|
+
raise
|
|
371
|
+
raise ToolError("\n\n".join([str(e), *extra])) from e
|
|
372
|
+
finally:
|
|
373
|
+
_notices.reset(token)
|
|
374
|
+
|
|
375
|
+
if not notices:
|
|
376
|
+
return result
|
|
377
|
+
lines = self._describe(context, notices, always=False)
|
|
378
|
+
content = list(result.content or [])
|
|
379
|
+
content.append(TextContent(type="text", text="\n\n".join(lines)))
|
|
380
|
+
return ToolResult(
|
|
381
|
+
content=content,
|
|
382
|
+
structured_content=result.structured_content,
|
|
383
|
+
meta=result.meta,
|
|
384
|
+
)
|