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.
Files changed (35) hide show
  1. equinix_docs_mcp_server/__init__.py +21 -0
  2. equinix_docs_mcp_server/api_notices.py +384 -0
  3. equinix_docs_mcp_server/arazzo_manager.py +1032 -0
  4. equinix_docs_mcp_server/auth.py +217 -0
  5. equinix_docs_mcp_server/catalog_discovery.py +224 -0
  6. equinix_docs_mcp_server/config.py +272 -0
  7. equinix_docs_mcp_server/data/config/apis.yaml +362 -0
  8. equinix_docs_mcp_server/data/config/workflows-dcim.yaml +350 -0
  9. equinix_docs_mcp_server/data/config/workflows.yaml +25 -0
  10. equinix_docs_mcp_server/data/overlays/accesstoken.yaml +17 -0
  11. equinix_docs_mcp_server/data/overlays/billing.yaml +43 -0
  12. equinix_docs_mcp_server/data/overlays/fabric.yaml +43 -0
  13. equinix_docs_mcp_server/data/overlays/network-edge.yaml +31 -0
  14. equinix_docs_mcp_server/data/overlays/workvisitsv2.yaml +31 -0
  15. equinix_docs_mcp_server/data/overlays/workvisitv1.yaml +31 -0
  16. equinix_docs_mcp_server/docs.py +458 -0
  17. equinix_docs_mcp_server/lunr_search/search_client.py +251 -0
  18. equinix_docs_mcp_server/main.py +616 -0
  19. equinix_docs_mcp_server/openapi_annotations.py +143 -0
  20. equinix_docs_mcp_server/openapi_overlays/__init__.py +10 -0
  21. equinix_docs_mcp_server/openapi_overlays/overlay_manager.py +171 -0
  22. equinix_docs_mcp_server/openrpc.py +694 -0
  23. equinix_docs_mcp_server/rate_limits.py +183 -0
  24. equinix_docs_mcp_server/response_formatter.py +216 -0
  25. equinix_docs_mcp_server/spec_manager.py +864 -0
  26. equinix_docs_mcp_server/swagger2openapi/converter.py +343 -0
  27. equinix_docs_mcp_server/tool_ranking.py +104 -0
  28. equinix_docs_mcp_server/tool_search.py +303 -0
  29. equinix_docs_mcp_server/workflow_http.py +255 -0
  30. equinix_docs_mcp_server-0.3.1.dist-info/METADATA +342 -0
  31. equinix_docs_mcp_server-0.3.1.dist-info/RECORD +35 -0
  32. equinix_docs_mcp_server-0.3.1.dist-info/WHEEL +5 -0
  33. equinix_docs_mcp_server-0.3.1.dist-info/entry_points.txt +2 -0
  34. equinix_docs_mcp_server-0.3.1.dist-info/licenses/LICENSE +21 -0
  35. 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
+ )