@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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { metrics } from "@opentelemetry/api";
|
|
2
|
+
import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
|
|
3
|
+
import { RuntimeNodeInstrumentation } from "@opentelemetry/instrumentation-runtime-node";
|
|
4
|
+
import { defaultResource, resourceFromAttributes } from "@opentelemetry/resources";
|
|
5
|
+
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
|
6
|
+
import { embeddedPrometheusIsEnabled } from "./telemetry-config.js";
|
|
7
|
+
const DEFAULT_PROMETHEUS_HOST = "127.0.0.1";
|
|
8
|
+
const DEFAULT_PROMETHEUS_PORT = 9464;
|
|
9
|
+
let embeddedRuntimeInstrumentation;
|
|
10
|
+
function prometheusPort(environment) {
|
|
11
|
+
const configuredPort = environment.OTEL_EXPORTER_PROMETHEUS_PORT;
|
|
12
|
+
if (!configuredPort)
|
|
13
|
+
return DEFAULT_PROMETHEUS_PORT;
|
|
14
|
+
const port = Number(configuredPort);
|
|
15
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
|
|
16
|
+
throw new Error(`Invalid OTEL_EXPORTER_PROMETHEUS_PORT: '${configuredPort}'`);
|
|
17
|
+
}
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Installs the embedded Prometheus MetricReader for HTTP serving. A provider
|
|
22
|
+
* installed by an OpenTelemetry preload script wins, so no second SDK is
|
|
23
|
+
* installed. This module is dynamically imported only when the embedded
|
|
24
|
+
* exporter is enabled; stdio and SDK-disabled processes never load it.
|
|
25
|
+
*/
|
|
26
|
+
export async function startPrometheusMetrics(serviceVersion, environment = process.env) {
|
|
27
|
+
if (!embeddedPrometheusIsEnabled(environment))
|
|
28
|
+
return undefined;
|
|
29
|
+
let provider;
|
|
30
|
+
let providerWasRegistered = false;
|
|
31
|
+
try {
|
|
32
|
+
const host = environment.OTEL_EXPORTER_PROMETHEUS_HOST || DEFAULT_PROMETHEUS_HOST;
|
|
33
|
+
const port = prometheusPort(environment);
|
|
34
|
+
const exporter = new PrometheusExporter({ host, port, preventServerStart: true });
|
|
35
|
+
provider = new MeterProvider({
|
|
36
|
+
resource: defaultResource().merge(resourceFromAttributes({
|
|
37
|
+
"service.name": "context7-mcp",
|
|
38
|
+
"service.version": serviceVersion,
|
|
39
|
+
})),
|
|
40
|
+
readers: [exporter],
|
|
41
|
+
});
|
|
42
|
+
providerWasRegistered = metrics.setGlobalMeterProvider(provider);
|
|
43
|
+
if (!providerWasRegistered) {
|
|
44
|
+
await provider.shutdown();
|
|
45
|
+
console.error("Embedded Prometheus exporter not started because a global OpenTelemetry MeterProvider is already registered");
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
await exporter.startServer();
|
|
49
|
+
try {
|
|
50
|
+
// Keep the native 10 ms precision. The same setting controls the
|
|
51
|
+
// monitorEventLoopDelay resolution, so increasing it to the scrape
|
|
52
|
+
// interval would make healthy delay percentiles appear artificially high.
|
|
53
|
+
embeddedRuntimeInstrumentation = new RuntimeNodeInstrumentation();
|
|
54
|
+
embeddedRuntimeInstrumentation.setMeterProvider(provider);
|
|
55
|
+
embeddedRuntimeInstrumentation.enable();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
embeddedRuntimeInstrumentation?.disable();
|
|
59
|
+
embeddedRuntimeInstrumentation = undefined;
|
|
60
|
+
console.error("OpenTelemetry Node runtime metrics failed to start:", error);
|
|
61
|
+
}
|
|
62
|
+
console.error(`OpenTelemetry metrics available at http://${host}:${port}/metrics`);
|
|
63
|
+
return provider;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (providerWasRegistered)
|
|
67
|
+
metrics.disable();
|
|
68
|
+
await provider?.shutdown().catch(() => undefined);
|
|
69
|
+
console.error("Embedded Prometheus exporter failed to start; MCP serving will continue:", error);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { embeddedPrometheusIsEnabled, telemetryIsDisabled } from "./telemetry-config.js";
|
|
2
|
+
const TELEMETRY_DISABLED = telemetryIsDisabled();
|
|
3
|
+
let implementation;
|
|
4
|
+
let implementationPromise;
|
|
5
|
+
let mcpImplementationPromise;
|
|
6
|
+
let prometheusStartup;
|
|
7
|
+
function loadImplementation() {
|
|
8
|
+
implementationPromise ??= import("./telemetry.js")
|
|
9
|
+
.then((loaded) => {
|
|
10
|
+
implementation = loaded;
|
|
11
|
+
return loaded;
|
|
12
|
+
})
|
|
13
|
+
.catch((error) => {
|
|
14
|
+
implementationPromise = undefined;
|
|
15
|
+
throw error;
|
|
16
|
+
});
|
|
17
|
+
return implementationPromise;
|
|
18
|
+
}
|
|
19
|
+
function loadMcpImplementation() {
|
|
20
|
+
mcpImplementationPromise ??= import("./mcp-telemetry.js").catch((error) => {
|
|
21
|
+
mcpImplementationPromise = undefined;
|
|
22
|
+
throw error;
|
|
23
|
+
});
|
|
24
|
+
return mcpImplementationPromise;
|
|
25
|
+
}
|
|
26
|
+
async function startEmbeddedPrometheus(serviceVersion) {
|
|
27
|
+
prometheusStartup ??= import("./telemetry-provider.js")
|
|
28
|
+
.then(({ startPrometheusMetrics }) => startPrometheusMetrics(serviceVersion))
|
|
29
|
+
.catch((error) => {
|
|
30
|
+
prometheusStartup = undefined;
|
|
31
|
+
throw error;
|
|
32
|
+
});
|
|
33
|
+
await prometheusStartup;
|
|
34
|
+
}
|
|
35
|
+
export async function initializeTelemetry(options) {
|
|
36
|
+
if (TELEMETRY_DISABLED)
|
|
37
|
+
return undefined;
|
|
38
|
+
const [loadedTelemetry, loadedMcpTelemetry] = await Promise.all([
|
|
39
|
+
loadImplementation(),
|
|
40
|
+
loadMcpImplementation(),
|
|
41
|
+
]);
|
|
42
|
+
implementation = loadedTelemetry;
|
|
43
|
+
if (options.allowEmbeddedPrometheus && embeddedPrometheusIsEnabled()) {
|
|
44
|
+
await startEmbeddedPrometheus(options.serviceVersion);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
createServer: (serverInfo, serverOptions, requestContext) => new loadedMcpTelemetry.InstrumentedMcpServer(serverInfo, serverOptions, requestContext),
|
|
48
|
+
instrumentHttpHandler: loadedMcpTelemetry.instrumentMcpHttpHandler,
|
|
49
|
+
instrumentStdioTransport: loadedMcpTelemetry.instrumentStdioTransport,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export async function observeAuthentication(operation) {
|
|
53
|
+
if (TELEMETRY_DISABLED)
|
|
54
|
+
return (await operation()).value;
|
|
55
|
+
return (await loadImplementation()).observeAuthentication(operation);
|
|
56
|
+
}
|
|
57
|
+
export async function observeUpstreamRequest(operationName, request, consumeResponse, options = {}) {
|
|
58
|
+
if (TELEMETRY_DISABLED)
|
|
59
|
+
return consumeResponse(await request());
|
|
60
|
+
return (await loadImplementation()).observeUpstreamRequest(operationName, request, consumeResponse, options);
|
|
61
|
+
}
|
|
62
|
+
export function recordToolCallOutcome(outcome) {
|
|
63
|
+
implementation?.recordToolCallOutcome(outcome);
|
|
64
|
+
}
|
|
65
|
+
export async function forceFlushTelemetry() {
|
|
66
|
+
await implementation?.forceFlushTelemetry();
|
|
67
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { metrics, trace } from "@opentelemetry/api";
|
|
2
|
+
import { markCurrentMcpOperationError, markCurrentMcpToolOutcome } from "./mcp-operation-scope.js";
|
|
3
|
+
const METER_NAME = "io.github.upstash.context7.mcp";
|
|
4
|
+
const DURATION_BUCKETS_SECONDS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
5
|
+
const TIMEOUT_ERROR_CODES = new Set([
|
|
6
|
+
"ETIMEDOUT",
|
|
7
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
8
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
9
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
10
|
+
]);
|
|
11
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
12
|
+
"EAI_AGAIN",
|
|
13
|
+
"ECONNREFUSED",
|
|
14
|
+
"ECONNRESET",
|
|
15
|
+
"EHOSTUNREACH",
|
|
16
|
+
"ENETUNREACH",
|
|
17
|
+
"ENOTFOUND",
|
|
18
|
+
"EPIPE",
|
|
19
|
+
]);
|
|
20
|
+
function createInstruments() {
|
|
21
|
+
const meter = metrics.getMeter(METER_NAME);
|
|
22
|
+
return {
|
|
23
|
+
upstreamRequests: meter.createCounter("context7.mcp.upstream.requests", {
|
|
24
|
+
description: "Number of requests made to Context7 dependencies",
|
|
25
|
+
unit: "{request}",
|
|
26
|
+
}),
|
|
27
|
+
upstreamRequestDuration: meter.createHistogram("context7.mcp.upstream.request.duration", {
|
|
28
|
+
description: "Duration of requests made to Context7 dependencies",
|
|
29
|
+
unit: "s",
|
|
30
|
+
advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS },
|
|
31
|
+
}),
|
|
32
|
+
activeUpstreamRequests: meter.createUpDownCounter("context7.mcp.upstream.requests.active", {
|
|
33
|
+
description: "Number of requests to Context7 dependencies currently in flight",
|
|
34
|
+
unit: "{request}",
|
|
35
|
+
}),
|
|
36
|
+
authenticationAttempts: meter.createCounter("context7.mcp.authentication.attempts", {
|
|
37
|
+
description: "Number of authentication attempts on the OAuth-protected MCP endpoint",
|
|
38
|
+
unit: "{attempt}",
|
|
39
|
+
}),
|
|
40
|
+
authenticationDuration: meter.createHistogram("context7.mcp.authentication.duration", {
|
|
41
|
+
description: "Duration of authentication on the OAuth-protected MCP endpoint",
|
|
42
|
+
unit: "s",
|
|
43
|
+
advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS },
|
|
44
|
+
}),
|
|
45
|
+
activeAuthentications: meter.createUpDownCounter("context7.mcp.authentication.active", {
|
|
46
|
+
description: "Number of OAuth-protected MCP requests currently authenticating",
|
|
47
|
+
unit: "{request}",
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let instruments;
|
|
52
|
+
function getInstruments() {
|
|
53
|
+
instruments ??= createInstruments();
|
|
54
|
+
return instruments;
|
|
55
|
+
}
|
|
56
|
+
function elapsedSeconds(startedAt) {
|
|
57
|
+
return (performance.now() - startedAt) / 1_000;
|
|
58
|
+
}
|
|
59
|
+
function statusClass(statusCode) {
|
|
60
|
+
if (statusCode >= 100 && statusCode <= 599) {
|
|
61
|
+
return `${Math.floor(statusCode / 100)}xx`;
|
|
62
|
+
}
|
|
63
|
+
return "unknown";
|
|
64
|
+
}
|
|
65
|
+
export function classifyUpstreamError(error, abortSignal, fallback = "network_error") {
|
|
66
|
+
let timeout = false;
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
let networkError = false;
|
|
69
|
+
const rootCount = abortSignal?.aborted ? 2 : 1;
|
|
70
|
+
for (let rootIndex = 0; rootIndex < rootCount; rootIndex += 1) {
|
|
71
|
+
let value = rootIndex === 0 ? error : abortSignal?.reason;
|
|
72
|
+
for (let depth = 0; value && typeof value === "object" && depth < 8; depth += 1) {
|
|
73
|
+
const current = value;
|
|
74
|
+
const name = current.name;
|
|
75
|
+
const code = current.code;
|
|
76
|
+
if (name === "TimeoutError" || (typeof code === "string" && TIMEOUT_ERROR_CODES.has(code))) {
|
|
77
|
+
timeout = true;
|
|
78
|
+
}
|
|
79
|
+
else if (name === "AbortError" || code === "ABORT_ERR") {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
}
|
|
82
|
+
else if (typeof code === "string" &&
|
|
83
|
+
(code.startsWith("UND_ERR_") || NETWORK_ERROR_CODES.has(code))) {
|
|
84
|
+
networkError = true;
|
|
85
|
+
}
|
|
86
|
+
value = current.cause;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (timeout)
|
|
90
|
+
return "timeout";
|
|
91
|
+
if (cancelled || abortSignal?.aborted)
|
|
92
|
+
return "cancelled";
|
|
93
|
+
if (networkError)
|
|
94
|
+
return "network_error";
|
|
95
|
+
return fallback;
|
|
96
|
+
}
|
|
97
|
+
export function recordToolCallOutcome(outcome) {
|
|
98
|
+
if (outcome === "error")
|
|
99
|
+
markCurrentMcpOperationError();
|
|
100
|
+
markCurrentMcpToolOutcome(outcome);
|
|
101
|
+
}
|
|
102
|
+
export async function observeUpstreamRequest(operationName, request, consumeResponse, options = {}) {
|
|
103
|
+
const { activeUpstreamRequests, upstreamRequestDuration, upstreamRequests } = getInstruments();
|
|
104
|
+
const activeAttributes = { "context7.upstream.operation": operationName };
|
|
105
|
+
const startedAt = performance.now();
|
|
106
|
+
let outcome = "network_error";
|
|
107
|
+
let responseStatus;
|
|
108
|
+
let responseStatusClass = "none";
|
|
109
|
+
activeUpstreamRequests.add(1, activeAttributes);
|
|
110
|
+
try {
|
|
111
|
+
let response;
|
|
112
|
+
try {
|
|
113
|
+
response = await request();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
outcome = classifyUpstreamError(error, options.abortSignal);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
responseStatus = response.status;
|
|
120
|
+
responseStatusClass = statusClass(response.status);
|
|
121
|
+
outcome = response.ok ? "success" : "http_error";
|
|
122
|
+
try {
|
|
123
|
+
return await consumeResponse(response);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
outcome = classifyUpstreamError(error, options.abortSignal, "response_error");
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
const attributes = {
|
|
132
|
+
...activeAttributes,
|
|
133
|
+
"http.response.status_code_class": responseStatusClass,
|
|
134
|
+
"context7.upstream.outcome": outcome,
|
|
135
|
+
};
|
|
136
|
+
if (responseStatus !== undefined) {
|
|
137
|
+
attributes["http.response.status_code"] = responseStatus;
|
|
138
|
+
}
|
|
139
|
+
activeUpstreamRequests.add(-1, activeAttributes);
|
|
140
|
+
upstreamRequests.add(1, attributes);
|
|
141
|
+
upstreamRequestDuration.record(elapsedSeconds(startedAt), attributes);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function flushableProvider(provider) {
|
|
145
|
+
let current = provider;
|
|
146
|
+
for (let depth = 0; current && typeof current === "object" && depth < 4; depth += 1) {
|
|
147
|
+
if ("forceFlush" in current && typeof current.forceFlush === "function") {
|
|
148
|
+
return current;
|
|
149
|
+
}
|
|
150
|
+
if (!("getDelegate" in current) || typeof current.getDelegate !== "function")
|
|
151
|
+
return undefined;
|
|
152
|
+
const delegate = current.getDelegate();
|
|
153
|
+
if (delegate === current)
|
|
154
|
+
return undefined;
|
|
155
|
+
current = delegate;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
export async function forceFlushTelemetry() {
|
|
160
|
+
const providers = [
|
|
161
|
+
flushableProvider(metrics.getMeterProvider()),
|
|
162
|
+
flushableProvider(trace.getTracerProvider()),
|
|
163
|
+
].filter((provider) => provider !== undefined);
|
|
164
|
+
const results = await Promise.allSettled(providers.map((provider) => provider.forceFlush.call(provider)));
|
|
165
|
+
const failures = results
|
|
166
|
+
.filter((result) => result.status === "rejected")
|
|
167
|
+
.map((result) => result.reason);
|
|
168
|
+
if (failures.length > 0) {
|
|
169
|
+
throw new AggregateError(failures, "OpenTelemetry providers failed to flush during shutdown");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
export async function observeAuthentication(operation) {
|
|
173
|
+
const { activeAuthentications, authenticationAttempts, authenticationDuration } = getInstruments();
|
|
174
|
+
const activeAttributes = { "context7.mcp.route": "oauth" };
|
|
175
|
+
const startedAt = performance.now();
|
|
176
|
+
let outcome = "error";
|
|
177
|
+
activeAuthentications.add(1, activeAttributes);
|
|
178
|
+
try {
|
|
179
|
+
const observed = await operation();
|
|
180
|
+
outcome = observed.outcome;
|
|
181
|
+
return observed.value;
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
const attributes = { "context7.authentication.outcome": outcome };
|
|
185
|
+
activeAuthentications.add(-1, activeAttributes);
|
|
186
|
+
authenticationAttempts.add(1, attributes);
|
|
187
|
+
authenticationDuration.record(elapsedSeconds(startedAt), attributes);
|
|
188
|
+
}
|
|
189
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@upstash/context7-mcp",
|
|
3
|
-
"version": "4.0
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"mcpName": "io.github.upstash/context7",
|
|
5
5
|
"description": "MCP server for Context7",
|
|
6
6
|
"repository": {
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/node": "2.0.0",
|
|
37
37
|
"@modelcontextprotocol/server": "2.0.0",
|
|
38
|
+
"@opentelemetry/api": "^1.9.1",
|
|
39
|
+
"@opentelemetry/exporter-prometheus": "^0.221.0",
|
|
40
|
+
"@opentelemetry/instrumentation-runtime-node": "^0.34.0",
|
|
41
|
+
"@opentelemetry/resources": "^2.10.0",
|
|
42
|
+
"@opentelemetry/sdk-metrics": "^2.10.0",
|
|
38
43
|
"@types/express": "^5.0.4",
|
|
39
44
|
"commander": "^13.1.0",
|
|
40
45
|
"express": "^5.1.0",
|
|
@@ -44,6 +49,9 @@
|
|
|
44
49
|
},
|
|
45
50
|
"devDependencies": {
|
|
46
51
|
"@modelcontextprotocol/client": "2.0.0",
|
|
52
|
+
"@opentelemetry/context-async-hooks": "^2.10.0",
|
|
53
|
+
"@opentelemetry/core": "^2.10.0",
|
|
54
|
+
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
|
47
55
|
"@types/node": "^25.0.3",
|
|
48
56
|
"esbuild": "^0.28.2",
|
|
49
57
|
"typescript": "^5.8.2",
|