@upstash/context7-mcp 4.0.6 → 4.1.0
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.
- package/README.md +127 -0
- package/dist/index.js +112 -41
- package/dist/lib/api.js +30 -24
- package/dist/lib/mcp-operation-scope.js +15 -0
- package/dist/lib/mcp-subscription-telemetry.js +466 -0
- package/dist/lib/mcp-telemetry.js +627 -0
- package/dist/lib/process-shutdown.js +63 -0
- package/dist/lib/telemetry-config.js +14 -0
- package/dist/lib/telemetry-contracts.js +1 -0
- package/dist/lib/telemetry-provider.js +72 -0
- package/dist/lib/telemetry-runtime.js +67 -0
- package/dist/lib/telemetry.js +189 -0
- package/dist/lib/tool-names.js +3 -0
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -1490,6 +1490,133 @@ CONTEXT7_API_KEY=your_api_key_here
|
|
|
1490
1490
|
}
|
|
1491
1491
|
```
|
|
1492
1492
|
|
|
1493
|
+
### OpenTelemetry observability
|
|
1494
|
+
|
|
1495
|
+
Context7 instruments individual MCP requests and notifications at the SDK transport boundary,
|
|
1496
|
+
including messages inside a valid batch and MCP v2 `subscriptions/listen` operations handled by the
|
|
1497
|
+
SDK entry layer. Requests rejected by the SDK's HTTP envelope and protocol-version validation before
|
|
1498
|
+
dispatch remain visible in normal HTTP/gateway telemetry, but are not reported as MCP operations.
|
|
1499
|
+
Observed operations follow the
|
|
1500
|
+
development-status [OpenTelemetry MCP semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/mcp.md)
|
|
1501
|
+
for server metrics and spans. Trace context is extracted from the `traceparent`, `tracestate`, and
|
|
1502
|
+
`baggage` fields in MCP `params._meta` as defined by
|
|
1503
|
+
[SEP-414](https://modelcontextprotocol.io/seps/414-request-meta).
|
|
1504
|
+
|
|
1505
|
+
The HTTP transport exposes metrics in Prometheus format on a dedicated listener at
|
|
1506
|
+
`127.0.0.1:9464/metrics` by default. The production Docker image explicitly binds that listener to
|
|
1507
|
+
`0.0.0.0` so an internal Prometheus pod scraper or `PodMonitor` can reach it. The stdio transport
|
|
1508
|
+
does not open a telemetry port. Keeping this listener separate from the public MCP port prevents
|
|
1509
|
+
the metrics endpoint from being routed through a catch-all gateway rule. On SIGTERM, SIGINT, or
|
|
1510
|
+
SIGHUP, both transports use a bounded shutdown path that stops serving, closes active MCP
|
|
1511
|
+
connections and subscriptions, and best-effort flushes externally installed SDK metric and trace
|
|
1512
|
+
providers before exit. Stdio EOF triggers the same path.
|
|
1513
|
+
|
|
1514
|
+
The exporter uses the standard OpenTelemetry Prometheus settings:
|
|
1515
|
+
|
|
1516
|
+
- `OTEL_EXPORTER_PROMETHEUS_HOST` changes the bind address (default `127.0.0.1`; the Docker image
|
|
1517
|
+
sets `0.0.0.0`).
|
|
1518
|
+
- `OTEL_EXPORTER_PROMETHEUS_PORT` changes the port (default `9464`).
|
|
1519
|
+
- `OTEL_METRICS_EXPORTER=none` or `OTEL_SDK_DISABLED=true` disables the embedded exporter.
|
|
1520
|
+
|
|
1521
|
+
`OTEL_SDK_DISABLED=true` is the hard-off switch: provider modules are not loaded and MCP
|
|
1522
|
+
transports and handlers are not wrapped, preserving the baseline request path. In contrast,
|
|
1523
|
+
`OTEL_METRICS_EXPORTER=none` disables only the embedded Prometheus bootstrap, so a provider
|
|
1524
|
+
installed by a Node preload can still receive the MCP signals.
|
|
1525
|
+
|
|
1526
|
+
Exporter bind or configuration failures are logged but do not prevent the MCP endpoint from
|
|
1527
|
+
starting. If a Node preload has already registered global OpenTelemetry providers, they take
|
|
1528
|
+
precedence. The embedded Prometheus listener is not started when a global `MeterProvider` exists,
|
|
1529
|
+
and MCP spans are exported through the preload's `TracerProvider`. This supports an OpenTelemetry
|
|
1530
|
+
Node SDK or Kubernetes auto-instrumentation without creating a second provider in the application.
|
|
1531
|
+
When an external SDK owns the provider, configure its Node runtime instrumentation there as well;
|
|
1532
|
+
the application does not register a duplicate collector.
|
|
1533
|
+
|
|
1534
|
+
It reports bounded-cardinality counters, histograms, and in-flight gauges for MCP methods,
|
|
1535
|
+
subscriptions, tool outcomes, authentication outcomes, Context7 upstream requests, and Node runtime
|
|
1536
|
+
saturation.
|
|
1537
|
+
Prometheus receives these metric families:
|
|
1538
|
+
|
|
1539
|
+
- `mcp_server_operation_duration` (its `_count` series is the MCP operation count, and tool-call
|
|
1540
|
+
series include the `context7_mcp_tool_outcome` label)
|
|
1541
|
+
- `mcp_server_session_duration` for real stateful stdio sessions (stateless HTTP request transports
|
|
1542
|
+
are intentionally excluded)
|
|
1543
|
+
- `context7_mcp_operations_active`
|
|
1544
|
+
- `context7_mcp_subscriptions_active` and `context7_mcp_subscription_duration`
|
|
1545
|
+
- `context7_mcp_upstream_requests_total` and `context7_mcp_upstream_request_duration`
|
|
1546
|
+
- `context7_mcp_authentication_attempts_total` and `context7_mcp_authentication_duration`
|
|
1547
|
+
- `context7_mcp_upstream_requests_active` and `context7_mcp_authentication_active`
|
|
1548
|
+
- `nodejs_eventloop_*`, `v8js_gc_duration`, `v8js_memory_heap_*`, and
|
|
1549
|
+
`v8js_resource_active` from the official OpenTelemetry Node runtime instrumentation
|
|
1550
|
+
|
|
1551
|
+
Tool outcomes on the standard MCP operation metric distinguish `success`, `not_found`, and
|
|
1552
|
+
`error`. An acknowledged `subscriptions/listen` operation is timed through its acknowledgement;
|
|
1553
|
+
the separate subscription metrics track the active stream and its bounded terminal outcome.
|
|
1554
|
+
Upstream outcomes distinguish
|
|
1555
|
+
HTTP, response-decoding, network, timeout, and cancellation failures and include both the bounded
|
|
1556
|
+
status-code class and the exact numeric HTTP status. Authentication reports accepted, missing,
|
|
1557
|
+
invalid, and unexpected-error outcomes. The OAuth authorization-server metadata proxy caps its
|
|
1558
|
+
upstream fetch at 10 seconds and returns `502` if that dependency times out.
|
|
1559
|
+
|
|
1560
|
+
The labels intentionally exclude API keys, client IPs, queries, library IDs, session IDs, and raw
|
|
1561
|
+
error text. Expose port `9464` only to your Prometheus scraper or `ServiceMonitor`, not through the
|
|
1562
|
+
public MCP ingress.
|
|
1563
|
+
|
|
1564
|
+
#### Signal ownership with an Envoy gateway
|
|
1565
|
+
|
|
1566
|
+
Do not treat `mcp_server_operation_duration_count` as another HTTP request counter. An Envoy
|
|
1567
|
+
Gateway observes HTTP envelopes, while this metric observes JSON-RPC requests and notifications
|
|
1568
|
+
after SDK dispatch. A valid batch is one HTTP request but several MCP operations, and HTTP requests
|
|
1569
|
+
rejected before MCP dispatch never increment the MCP metric.
|
|
1570
|
+
|
|
1571
|
+
Context7 deliberately does **not** register generic inbound HTTP server metrics. Keep the following
|
|
1572
|
+
signals in the existing Envoy scrape instead of collecting them again from the application:
|
|
1573
|
+
|
|
1574
|
+
- downstream HTTP request/response totals, status classes, duration, active requests, connections,
|
|
1575
|
+
resets, and gateway timeouts (`envoy_http_*_downstream_*`)
|
|
1576
|
+
- Envoy-to-MCP backend request totals, status codes, duration, active/pending requests, connection
|
|
1577
|
+
failures, retries, resets, timeouts, and circuit-breaker overflows (`envoy_cluster_upstream_*`)
|
|
1578
|
+
- Envoy process health and resource metrics
|
|
1579
|
+
|
|
1580
|
+
The application exporter owns only signals the ingress gateway cannot provide: MCP method and
|
|
1581
|
+
protocol semantics (including batches, notifications, and active MCP v2 subscriptions), tool and
|
|
1582
|
+
authentication outcomes, MCP-to-Context7 API calls, and Node event-loop/V8 health. In the
|
|
1583
|
+
Kubernetes deployment Envoy is a
|
|
1584
|
+
Gateway API proxy rather than a sidecar in the MCP pod, so `context7_mcp_upstream_*` describes the
|
|
1585
|
+
MCP server's outbound Context7 API dependency, not Envoy's inbound MCP backend cluster. Pod and
|
|
1586
|
+
container CPU, memory, network, and restart metrics should continue to come from the Kubernetes
|
|
1587
|
+
monitoring stack.
|
|
1588
|
+
|
|
1589
|
+
See the [Envoy HTTP connection manager statistics](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/stats)
|
|
1590
|
+
and [upstream cluster statistics](https://www.envoyproxy.io/docs/envoy/latest/configuration/upstream/cluster_manager/cluster_stats.html)
|
|
1591
|
+
for the proxy-owned metric families.
|
|
1592
|
+
|
|
1593
|
+
For a replicated Kubernetes deployment, discover and scrape every MCP pod directly. Do not use one
|
|
1594
|
+
static, load-balanced Service target: successive scrapes can reach different replicas and produce
|
|
1595
|
+
incomplete per-process counters and runtime series. For an annotation-based `kubernetes-pods`
|
|
1596
|
+
scrape job, add the following fields to the MCP workload's pod template:
|
|
1597
|
+
|
|
1598
|
+
```yaml
|
|
1599
|
+
spec:
|
|
1600
|
+
template:
|
|
1601
|
+
metadata:
|
|
1602
|
+
annotations:
|
|
1603
|
+
prometheus.io/scrape: "true"
|
|
1604
|
+
prometheus.io/port: "9464"
|
|
1605
|
+
prometheus.io/path: /metrics
|
|
1606
|
+
spec:
|
|
1607
|
+
containers:
|
|
1608
|
+
- name: mcp
|
|
1609
|
+
ports:
|
|
1610
|
+
- name: metrics
|
|
1611
|
+
containerPort: 9464
|
|
1612
|
+
protocol: TCP
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
Prometheus will then scrape `http://<mcp-pod-ip>:9464/metrics` for each replica. Declaring
|
|
1616
|
+
`EXPOSE 9464` in the image does not add the Kubernetes `containerPort` metadata. The scrape interval
|
|
1617
|
+
is controlled by Prometheus; the exporter does not impose one. If the monitoring stack uses the
|
|
1618
|
+
Prometheus Operator instead, configure the equivalent per-pod endpoint with a `PodMonitor`.
|
|
1619
|
+
|
|
1493
1620
|
<details>
|
|
1494
1621
|
<summary><b>Local Configuration Example</b></summary>
|
|
1495
1622
|
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { toNodeHandler } from "@modelcontextprotocol/node";
|
|
3
|
-
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
-
import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
|
|
3
|
+
import { StdioServerTransport, serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
4
|
+
import { McpServer, createMcpHandler, } from "@modelcontextprotocol/server";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { searchLibraries, fetchLibraryContext } from "./lib/api.js";
|
|
7
7
|
import { formatSearchResults, extractClientInfoFromUserAgent, envelopeClientInfo, } from "./lib/utils.js";
|
|
@@ -12,17 +12,25 @@ import { AsyncLocalStorage } from "async_hooks";
|
|
|
12
12
|
import { randomUUID } from "node:crypto";
|
|
13
13
|
import { SERVER_VERSION, RESOURCE_URL, OAUTH_AUTH_SERVER_URL, EMA_ISSUER, OPENAI_APPS_CHALLENGE_TOKEN, } from "./lib/constants.js";
|
|
14
14
|
import { maybeElicitAuthSignIn } from "./lib/auth/auth-prompt.js";
|
|
15
|
+
import { QUERY_DOCS_TOOL, RESOLVE_LIBRARY_ID_TOOL } from "./lib/tool-names.js";
|
|
16
|
+
import { installProcessShutdown } from "./lib/process-shutdown.js";
|
|
15
17
|
import { getMaxSubscriptions } from "./lib/subscriptions.js";
|
|
18
|
+
import { forceFlushTelemetry, initializeTelemetry, observeAuthentication, observeUpstreamRequest, recordToolCallOutcome, } from "./lib/telemetry-runtime.js";
|
|
16
19
|
import { mcpBodyErrorHandler } from "./lib/mcp-body-error-handler.js";
|
|
17
20
|
/** Default HTTP server port */
|
|
18
21
|
const DEFAULT_PORT = 3000;
|
|
22
|
+
const OAUTH_METADATA_TIMEOUT_MS = 10_000;
|
|
19
23
|
const CLAUDE_CODE_PLUGIN = "claude-code-plugin";
|
|
24
|
+
let mcpInstrumentation;
|
|
20
25
|
function getPluginFromRequest(req) {
|
|
21
26
|
return req.query.client === CLAUDE_CODE_PLUGIN ? CLAUDE_CODE_PLUGIN : undefined;
|
|
22
27
|
}
|
|
23
28
|
function requiresAuthentication(req, plugin) {
|
|
24
29
|
// The MCP routes live on a router mounted at /mcp, so req.path is relative to it.
|
|
25
|
-
|
|
30
|
+
const isOAuthEndpoint = `${req.baseUrl}${req.path}` === "/mcp/oauth";
|
|
31
|
+
// The current official Claude plugin expands an unset API key to an empty header.
|
|
32
|
+
const hasEmptyPluginAuthorization = plugin === CLAUDE_CODE_PLUGIN && req.headers.authorization === "";
|
|
33
|
+
return isOAuthEndpoint || (Boolean(plugin) && !hasEmptyPluginAuthorization);
|
|
26
34
|
}
|
|
27
35
|
// Parse CLI arguments using commander
|
|
28
36
|
const program = new Command()
|
|
@@ -113,8 +121,8 @@ function aliasArgs(aliases) {
|
|
|
113
121
|
return args;
|
|
114
122
|
};
|
|
115
123
|
}
|
|
116
|
-
function createMcpServer() {
|
|
117
|
-
const
|
|
124
|
+
function createMcpServer(mcpContext) {
|
|
125
|
+
const serverInfo = {
|
|
118
126
|
name: "Context7",
|
|
119
127
|
version: SERVER_VERSION,
|
|
120
128
|
websiteUrl: "https://context7.com",
|
|
@@ -125,7 +133,8 @@ function createMcpServer() {
|
|
|
125
133
|
mimeType: "image/png",
|
|
126
134
|
},
|
|
127
135
|
],
|
|
128
|
-
}
|
|
136
|
+
};
|
|
137
|
+
const serverOptions = {
|
|
129
138
|
// Declaring the capabilities makes the SDK install prompts/list,
|
|
130
139
|
// resources/list, and resources/templates/list handlers that answer
|
|
131
140
|
// with the registered (i.e. empty) collections, for clients that
|
|
@@ -134,8 +143,11 @@ function createMcpServer() {
|
|
|
134
143
|
instructions: `Use this server to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service — even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer — your training data may not reflect recent changes. Prefer this over web search for library docs.
|
|
135
144
|
|
|
136
145
|
Do not use for: refactoring, writing scripts from scratch, debugging business logic, code review, or general programming concepts.`,
|
|
137
|
-
}
|
|
138
|
-
server
|
|
146
|
+
};
|
|
147
|
+
const server = mcpInstrumentation
|
|
148
|
+
? mcpInstrumentation.createServer(serverInfo, serverOptions, mcpContext)
|
|
149
|
+
: new McpServer(serverInfo, serverOptions);
|
|
150
|
+
server.registerTool(RESOLVE_LIBRARY_ID_TOOL, {
|
|
139
151
|
title: "Resolve Context7 Library ID",
|
|
140
152
|
description: `Resolves a package/product name to a Context7-compatible library ID and returns matching libraries.
|
|
141
153
|
|
|
@@ -190,6 +202,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f
|
|
|
190
202
|
if (!searchResponse.results || searchResponse.results.length === 0) {
|
|
191
203
|
const text = searchResponse.error ?? "No libraries found matching the provided name.";
|
|
192
204
|
maybeElicitAuthSignIn(server, ctx);
|
|
205
|
+
recordToolCallOutcome(searchResponse.error ? "error" : "not_found");
|
|
193
206
|
return {
|
|
194
207
|
content: [
|
|
195
208
|
{
|
|
@@ -202,6 +215,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f
|
|
|
202
215
|
const resultsText = formatSearchResults(searchResponse);
|
|
203
216
|
const responseText = `Available Libraries:\n\n${resultsText}`;
|
|
204
217
|
maybeElicitAuthSignIn(server, ctx);
|
|
218
|
+
recordToolCallOutcome("success");
|
|
205
219
|
return {
|
|
206
220
|
content: [
|
|
207
221
|
{
|
|
@@ -211,7 +225,7 @@ IMPORTANT: Do not call this tool more than 3 times per question. If you cannot f
|
|
|
211
225
|
],
|
|
212
226
|
};
|
|
213
227
|
});
|
|
214
|
-
server.registerTool(
|
|
228
|
+
server.registerTool(QUERY_DOCS_TOOL, {
|
|
215
229
|
title: "Query Documentation",
|
|
216
230
|
description: `Retrieves and queries up-to-date documentation and code examples from Context7 for any programming library or framework.
|
|
217
231
|
|
|
@@ -236,6 +250,7 @@ Do not call this tool more than 3 times per question.`,
|
|
|
236
250
|
const ctx = getClientContext(toolCtx);
|
|
237
251
|
const response = await fetchLibraryContext({ query, libraryId }, ctx);
|
|
238
252
|
maybeElicitAuthSignIn(server, ctx);
|
|
253
|
+
recordToolCallOutcome(response.outcome);
|
|
239
254
|
return {
|
|
240
255
|
content: [
|
|
241
256
|
{
|
|
@@ -248,6 +263,10 @@ Do not call this tool more than 3 times per question.`,
|
|
|
248
263
|
return server;
|
|
249
264
|
}
|
|
250
265
|
async function main() {
|
|
266
|
+
mcpInstrumentation = await initializeTelemetry({
|
|
267
|
+
allowEmbeddedPrometheus: TRANSPORT_TYPE === "http",
|
|
268
|
+
serviceVersion: SERVER_VERSION,
|
|
269
|
+
});
|
|
251
270
|
if (TRANSPORT_TYPE === "http") {
|
|
252
271
|
const initialPort = CLI_PORT ?? DEFAULT_PORT;
|
|
253
272
|
const app = express();
|
|
@@ -306,11 +325,14 @@ async function main() {
|
|
|
306
325
|
// then never closes the stream, and with heartbeats it survived until the
|
|
307
326
|
// gateway's 1200s hard cap (the 2026-08-11 outage). Silent hangs instead
|
|
308
327
|
// go idle and the gateway reaps them at streamIdleTimeout (300s).
|
|
309
|
-
const
|
|
328
|
+
const rawMcpHandler = createMcpHandler((mcpContext) => createMcpServer(mcpContext), {
|
|
310
329
|
keepAliveMs: 0,
|
|
311
330
|
maxSubscriptions: getMaxSubscriptions(),
|
|
312
331
|
onerror: (error) => console.error("MCP handler error:", error),
|
|
313
332
|
});
|
|
333
|
+
const mcpHandler = mcpInstrumentation
|
|
334
|
+
? mcpInstrumentation.instrumentHttpHandler(rawMcpHandler)
|
|
335
|
+
: rawMcpHandler;
|
|
314
336
|
// Without onerror, request-conversion / handler.fetch throws are answered
|
|
315
337
|
// with a bare 500 inside the adapter and never reach our express handler.
|
|
316
338
|
const nodeHandler = toNodeHandler(mcpHandler, {
|
|
@@ -322,34 +344,42 @@ async function main() {
|
|
|
322
344
|
const apiKey = extractApiKey(req);
|
|
323
345
|
const baseUrl = new URL(RESOURCE_URL).origin;
|
|
324
346
|
// OAuth discovery info header, used by MCP clients to discover the authorization server
|
|
325
|
-
// TODO: @modelcontextprotocol/server now ships canonical OAuth helpers
|
|
326
|
-
// (bearerAuthChallengeResponse, buildOAuthProtectedResourceMetadata,
|
|
327
|
-
// oauthMetadataResponse) — replace this hand-rolled header and the
|
|
328
|
-
// /.well-known/oauth-protected-resource route with them.
|
|
329
347
|
res.set("WWW-Authenticate", `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"`);
|
|
330
348
|
if (requiresAuthentication(req, plugin)) {
|
|
331
|
-
|
|
332
|
-
|
|
349
|
+
const authentication = await observeAuthentication(async () => {
|
|
350
|
+
if (!apiKey) {
|
|
351
|
+
return {
|
|
352
|
+
outcome: "missing",
|
|
353
|
+
value: {
|
|
354
|
+
accepted: false,
|
|
355
|
+
error: "Authentication required. Please authenticate to use this MCP server.",
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (isJWT(apiKey)) {
|
|
360
|
+
const validationResult = await validateJWT(apiKey);
|
|
361
|
+
if (!validationResult.valid) {
|
|
362
|
+
return {
|
|
363
|
+
outcome: "invalid",
|
|
364
|
+
value: {
|
|
365
|
+
accepted: false,
|
|
366
|
+
error: validationResult.error || "Invalid token. Please re-authenticate.",
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { outcome: "accepted", value: { accepted: true } };
|
|
372
|
+
});
|
|
373
|
+
if (!authentication.accepted) {
|
|
374
|
+
res.status(401).json({
|
|
333
375
|
jsonrpc: "2.0",
|
|
334
376
|
error: {
|
|
335
377
|
code: -32001,
|
|
336
|
-
message:
|
|
378
|
+
message: authentication.error,
|
|
337
379
|
},
|
|
338
380
|
id: null,
|
|
339
381
|
});
|
|
340
|
-
|
|
341
|
-
if (isJWT(apiKey)) {
|
|
342
|
-
const validationResult = await validateJWT(apiKey);
|
|
343
|
-
if (!validationResult.valid) {
|
|
344
|
-
return res.status(401).json({
|
|
345
|
-
jsonrpc: "2.0",
|
|
346
|
-
error: {
|
|
347
|
-
code: -32001,
|
|
348
|
-
message: validationResult.error || "Invalid token. Please re-authenticate.",
|
|
349
|
-
},
|
|
350
|
-
id: null,
|
|
351
|
-
});
|
|
352
|
-
}
|
|
382
|
+
return;
|
|
353
383
|
}
|
|
354
384
|
}
|
|
355
385
|
const context = {
|
|
@@ -403,16 +433,22 @@ async function main() {
|
|
|
403
433
|
app.get("/.well-known/oauth-authorization-server", async (_req, res) => {
|
|
404
434
|
const authServerUrl = OAUTH_AUTH_SERVER_URL;
|
|
405
435
|
try {
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
436
|
+
const abortSignal = AbortSignal.timeout(OAUTH_METADATA_TIMEOUT_MS);
|
|
437
|
+
const upstream = await observeUpstreamRequest("oauth_metadata", () => fetch(`${authServerUrl}/.well-known/oauth-authorization-server`, {
|
|
438
|
+
signal: abortSignal,
|
|
439
|
+
}), async (response) => {
|
|
440
|
+
if (!response.ok)
|
|
441
|
+
return { ok: false, status: response.status };
|
|
442
|
+
return { ok: true, metadata: await response.json() };
|
|
443
|
+
}, { abortSignal });
|
|
444
|
+
if (!upstream.ok) {
|
|
445
|
+
console.error("[OAuth] Upstream error:", upstream.status);
|
|
446
|
+
return res.status(upstream.status).json({
|
|
410
447
|
error: "upstream_error",
|
|
411
448
|
message: "Failed to fetch authorization server metadata",
|
|
412
449
|
});
|
|
413
450
|
}
|
|
414
|
-
|
|
415
|
-
res.json(metadata);
|
|
451
|
+
res.json(upstream.metadata);
|
|
416
452
|
}
|
|
417
453
|
catch (error) {
|
|
418
454
|
console.error("[OAuth] Error fetching OAuth metadata:", error);
|
|
@@ -439,8 +475,36 @@ async function main() {
|
|
|
439
475
|
message: "Endpoint not found. Use /mcp for MCP protocol communication.",
|
|
440
476
|
});
|
|
441
477
|
});
|
|
478
|
+
let activeHttpServer;
|
|
479
|
+
installProcessShutdown({
|
|
480
|
+
close: async () => {
|
|
481
|
+
const server = activeHttpServer;
|
|
482
|
+
const operations = [mcpHandler.close()];
|
|
483
|
+
if (server) {
|
|
484
|
+
operations.unshift(new Promise((resolve, reject) => {
|
|
485
|
+
server.close((error) => {
|
|
486
|
+
if (error)
|
|
487
|
+
reject(error);
|
|
488
|
+
else
|
|
489
|
+
resolve();
|
|
490
|
+
});
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
const results = await Promise.allSettled(operations);
|
|
494
|
+
const failures = results
|
|
495
|
+
.filter((result) => result.status === "rejected")
|
|
496
|
+
.map((result) => result.reason);
|
|
497
|
+
if (failures.length > 0) {
|
|
498
|
+
throw new AggregateError(failures, "MCP HTTP server failed to close cleanly");
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
}, {
|
|
502
|
+
flush: forceFlushTelemetry,
|
|
503
|
+
onerror: (error) => console.error("Failed to close MCP HTTP server:", error),
|
|
504
|
+
});
|
|
442
505
|
const startServer = (port, maxAttempts = 10) => {
|
|
443
506
|
const httpServer = app.listen(port);
|
|
507
|
+
activeHttpServer = httpServer;
|
|
444
508
|
httpServer.once("error", (err) => {
|
|
445
509
|
if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) {
|
|
446
510
|
console.warn(`Port ${port} is in use, trying port ${port + 1}...`);
|
|
@@ -460,11 +524,12 @@ async function main() {
|
|
|
460
524
|
else {
|
|
461
525
|
stdioApiKey = cliOptions.apiKey || process.env.CONTEXT7_API_KEY;
|
|
462
526
|
stdioSessionId = randomUUID();
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
527
|
+
const rawStdioTransport = new StdioServerTransport();
|
|
528
|
+
const stdioTransport = mcpInstrumentation
|
|
529
|
+
? mcpInstrumentation.instrumentStdioTransport(rawStdioTransport)
|
|
530
|
+
: rawStdioTransport;
|
|
531
|
+
const stdioHandle = serveStdio((mcpContext) => {
|
|
532
|
+
const server = createMcpServer(mcpContext);
|
|
468
533
|
// Capture client info from MCP initialize handshake (stdio only — HTTP
|
|
469
534
|
// mode plumbs client info through requestContext per request).
|
|
470
535
|
server.server.oninitialized = () => {
|
|
@@ -478,8 +543,14 @@ async function main() {
|
|
|
478
543
|
};
|
|
479
544
|
return server;
|
|
480
545
|
}, {
|
|
546
|
+
transport: stdioTransport,
|
|
481
547
|
onerror: (error) => console.error("MCP stdio error:", error),
|
|
482
548
|
});
|
|
549
|
+
installProcessShutdown(stdioHandle, {
|
|
550
|
+
flush: forceFlushTelemetry,
|
|
551
|
+
input: process.stdin,
|
|
552
|
+
onerror: (error) => console.error("Failed to close MCP stdio server:", error),
|
|
553
|
+
});
|
|
483
554
|
console.error(`Context7 Documentation MCP Server v${SERVER_VERSION} running on stdio`);
|
|
484
555
|
}
|
|
485
556
|
}
|
package/dist/lib/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
|
|
|
3
3
|
import { CONTEXT7_API_BASE_URL } from "./constants.js";
|
|
4
4
|
import { readFileSync } from "fs";
|
|
5
5
|
import tls from "tls";
|
|
6
|
+
import { observeUpstreamRequest } from "./telemetry-runtime.js";
|
|
6
7
|
/**
|
|
7
8
|
* Ceiling on a single Context7 API call. Without a signal a stalled backend
|
|
8
9
|
* call rides undici's ~300s default before failing. 60s is generous for these
|
|
@@ -106,15 +107,17 @@ export async function searchLibraries(query, libraryName, context = {}) {
|
|
|
106
107
|
url.searchParams.set("query", query);
|
|
107
108
|
url.searchParams.set("libraryName", libraryName);
|
|
108
109
|
const headers = generateHeaders(context);
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
110
|
+
const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS);
|
|
111
|
+
return await observeUpstreamRequest("search_libraries", () => fetch(url, { headers, signal: abortSignal }), async (response) => {
|
|
112
|
+
readPromptSignal(response, context);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const errorMessage = await parseErrorResponse(response, context.apiKey);
|
|
115
|
+
console.error(errorMessage);
|
|
116
|
+
return { results: [], error: errorMessage };
|
|
117
|
+
}
|
|
118
|
+
const searchData = await response.json();
|
|
119
|
+
return searchData;
|
|
120
|
+
}, { abortSignal });
|
|
118
121
|
}
|
|
119
122
|
catch (error) {
|
|
120
123
|
const errorMessage = `Error searching libraries: ${error}`;
|
|
@@ -134,24 +137,27 @@ export async function fetchLibraryContext(request, context = {}) {
|
|
|
134
137
|
url.searchParams.set("query", request.query);
|
|
135
138
|
url.searchParams.set("libraryId", request.libraryId);
|
|
136
139
|
const headers = generateHeaders(context);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
140
|
+
const abortSignal = AbortSignal.timeout(API_TIMEOUT_MS);
|
|
141
|
+
return await observeUpstreamRequest("fetch_context", () => fetch(url, { headers, signal: abortSignal }), async (response) => {
|
|
142
|
+
readPromptSignal(response, context);
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const errorMessage = await parseErrorResponse(response, context.apiKey);
|
|
145
|
+
console.error(errorMessage);
|
|
146
|
+
return { data: errorMessage, outcome: "error" };
|
|
147
|
+
}
|
|
148
|
+
const text = await response.text();
|
|
149
|
+
if (!text) {
|
|
150
|
+
return {
|
|
151
|
+
data: "Documentation not found or not finalized for this library. This might have happened because you used an invalid Context7-compatible library ID. To get a valid Context7-compatible library ID, use the 'resolve-library-id' with the package name you wish to retrieve documentation for.",
|
|
152
|
+
outcome: "not_found",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { data: text, outcome: "success" };
|
|
156
|
+
}, { abortSignal });
|
|
151
157
|
}
|
|
152
158
|
catch (error) {
|
|
153
159
|
const errorMessage = `Error fetching library context. Please try again later. ${error}`;
|
|
154
160
|
console.error(errorMessage);
|
|
155
|
-
return { data: errorMessage };
|
|
161
|
+
return { data: errorMessage, outcome: "error" };
|
|
156
162
|
}
|
|
157
163
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
const operationScope = new AsyncLocalStorage();
|
|
3
|
+
export function runInMcpOperationScope(target, callback) {
|
|
4
|
+
return operationScope.run(target, callback);
|
|
5
|
+
}
|
|
6
|
+
export function markCurrentMcpOperationError(errorType = "tool_error") {
|
|
7
|
+
const target = operationScope.getStore();
|
|
8
|
+
if (target)
|
|
9
|
+
target.errorType = errorType;
|
|
10
|
+
}
|
|
11
|
+
export function markCurrentMcpToolOutcome(outcome) {
|
|
12
|
+
const target = operationScope.getStore();
|
|
13
|
+
if (target)
|
|
14
|
+
target.toolOutcome = outcome;
|
|
15
|
+
}
|