@upstash/context7-mcp 1.0.14 → 1.0.16
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 +629 -114
- package/dist/index.js +108 -32
- package/dist/lib/api.js +51 -11
- package/dist/lib/encryption.js +35 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,36 +11,75 @@ import { Command } from "commander";
|
|
|
11
11
|
const DEFAULT_MINIMUM_TOKENS = 10000;
|
|
12
12
|
// Parse CLI arguments using commander
|
|
13
13
|
const program = new Command()
|
|
14
|
-
.option("--transport <stdio|http
|
|
15
|
-
.option("--port <number>", "port for HTTP
|
|
14
|
+
.option("--transport <stdio|http>", "transport type", "stdio")
|
|
15
|
+
.option("--port <number>", "port for HTTP transport", "3000")
|
|
16
|
+
.option("--api-key <key>", "API key for authentication")
|
|
16
17
|
.allowUnknownOption() // let MCP Inspector / other wrappers pass through extra flags
|
|
17
18
|
.parse(process.argv);
|
|
18
19
|
const cliOptions = program.opts();
|
|
19
20
|
// Validate transport option
|
|
20
|
-
const allowedTransports = ["stdio", "http"
|
|
21
|
+
const allowedTransports = ["stdio", "http"];
|
|
21
22
|
if (!allowedTransports.includes(cliOptions.transport)) {
|
|
22
|
-
console.error(`Invalid --transport value: '${cliOptions.transport}'. Must be one of: stdio, http
|
|
23
|
+
console.error(`Invalid --transport value: '${cliOptions.transport}'. Must be one of: stdio, http.`);
|
|
23
24
|
process.exit(1);
|
|
24
25
|
}
|
|
25
26
|
// Transport configuration
|
|
26
27
|
const TRANSPORT_TYPE = (cliOptions.transport || "stdio");
|
|
27
|
-
//
|
|
28
|
+
// Disallow incompatible flags based on transport
|
|
29
|
+
const passedPortFlag = process.argv.includes("--port");
|
|
30
|
+
const passedApiKeyFlag = process.argv.includes("--api-key");
|
|
31
|
+
if (TRANSPORT_TYPE === "http" && passedApiKeyFlag) {
|
|
32
|
+
console.error("The --api-key flag is not allowed when using --transport http. Use header-based auth at the HTTP layer instead.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
if (TRANSPORT_TYPE === "stdio" && passedPortFlag) {
|
|
36
|
+
console.error("The --port flag is not allowed when using --transport stdio.");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
// HTTP port configuration
|
|
28
40
|
const CLI_PORT = (() => {
|
|
29
41
|
const parsed = parseInt(cliOptions.port, 10);
|
|
30
42
|
return isNaN(parsed) ? undefined : parsed;
|
|
31
43
|
})();
|
|
32
44
|
// Store SSE transports by session ID
|
|
33
45
|
const sseTransports = {};
|
|
46
|
+
function getClientIp(req) {
|
|
47
|
+
// Check both possible header casings
|
|
48
|
+
const forwardedFor = req.headers["x-forwarded-for"] || req.headers["X-Forwarded-For"];
|
|
49
|
+
if (forwardedFor) {
|
|
50
|
+
// X-Forwarded-For can contain multiple IPs
|
|
51
|
+
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
|
52
|
+
const ipList = ips.split(",").map((ip) => ip.trim());
|
|
53
|
+
// Find the first public IP address
|
|
54
|
+
for (const ip of ipList) {
|
|
55
|
+
const plainIp = ip.replace(/^::ffff:/, "");
|
|
56
|
+
if (!plainIp.startsWith("10.") &&
|
|
57
|
+
!plainIp.startsWith("192.168.") &&
|
|
58
|
+
!/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(plainIp)) {
|
|
59
|
+
return plainIp;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// If all are private, use the first one
|
|
63
|
+
return ipList[0].replace(/^::ffff:/, "");
|
|
64
|
+
}
|
|
65
|
+
// Fallback: use remote address, strip IPv6-mapped IPv4
|
|
66
|
+
if (req.socket?.remoteAddress) {
|
|
67
|
+
return req.socket.remoteAddress.replace(/^::ffff:/, "");
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
34
71
|
// Function to create a new server instance with all tools registered
|
|
35
|
-
function createServerInstance() {
|
|
72
|
+
function createServerInstance(clientIp, apiKey) {
|
|
36
73
|
const server = new McpServer({
|
|
37
74
|
name: "Context7",
|
|
38
|
-
version: "
|
|
75
|
+
version: "1.0.16",
|
|
39
76
|
}, {
|
|
40
77
|
instructions: "Use this server to retrieve up-to-date documentation and code examples for any library.",
|
|
41
78
|
});
|
|
42
79
|
// Register Context7 tools
|
|
43
|
-
server.
|
|
80
|
+
server.registerTool("resolve-library-id", {
|
|
81
|
+
title: "Resolve Context7 Library ID",
|
|
82
|
+
description: `Resolves a package/product name to a Context7-compatible library ID and returns a list of matching libraries.
|
|
44
83
|
|
|
45
84
|
You MUST call this function before 'get-library-docs' to obtain a valid Context7-compatible library ID UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.
|
|
46
85
|
|
|
@@ -58,12 +97,14 @@ Response Format:
|
|
|
58
97
|
- If multiple good matches exist, acknowledge this but proceed with the most relevant one
|
|
59
98
|
- If no good matches exist, clearly state this and suggest query refinements
|
|
60
99
|
|
|
61
|
-
For ambiguous queries, request clarification before proceeding with a best-guess match.`,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
100
|
+
For ambiguous queries, request clarification before proceeding with a best-guess match.`,
|
|
101
|
+
inputSchema: {
|
|
102
|
+
libraryName: z
|
|
103
|
+
.string()
|
|
104
|
+
.describe("Library name to search for and retrieve a Context7-compatible library ID."),
|
|
105
|
+
},
|
|
65
106
|
}, async ({ libraryName }) => {
|
|
66
|
-
const searchResponse = await searchLibraries(libraryName);
|
|
107
|
+
const searchResponse = await searchLibraries(libraryName, clientIp, apiKey);
|
|
67
108
|
if (!searchResponse.results || searchResponse.results.length === 0) {
|
|
68
109
|
return {
|
|
69
110
|
content: [
|
|
@@ -100,24 +141,28 @@ ${resultsText}`,
|
|
|
100
141
|
],
|
|
101
142
|
};
|
|
102
143
|
});
|
|
103
|
-
server.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
server.registerTool("get-library-docs", {
|
|
145
|
+
title: "Get Library Docs",
|
|
146
|
+
description: "Fetches up-to-date documentation for a library. You must call 'resolve-library-id' first to obtain the exact Context7-compatible library ID required to use this tool, UNLESS the user explicitly provides a library ID in the format '/org/project' or '/org/project/version' in their query.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
context7CompatibleLibraryID: z
|
|
149
|
+
.string()
|
|
150
|
+
.describe("Exact Context7-compatible library ID (e.g., '/mongodb/docs', '/vercel/next.js', '/supabase/supabase', '/vercel/next.js/v14.3.0-canary.87') retrieved from 'resolve-library-id' or directly from user query in the format '/org/project' or '/org/project/version'."),
|
|
151
|
+
topic: z
|
|
152
|
+
.string()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe("Topic to focus documentation on (e.g., 'hooks', 'routing')."),
|
|
155
|
+
tokens: z
|
|
156
|
+
.preprocess((val) => (typeof val === "string" ? Number(val) : val), z.number())
|
|
157
|
+
.transform((val) => (val < DEFAULT_MINIMUM_TOKENS ? DEFAULT_MINIMUM_TOKENS : val))
|
|
158
|
+
.optional()
|
|
159
|
+
.describe(`Maximum number of tokens of documentation to retrieve (default: ${DEFAULT_MINIMUM_TOKENS}). Higher values provide more context but consume more tokens.`),
|
|
160
|
+
},
|
|
116
161
|
}, async ({ context7CompatibleLibraryID, tokens = DEFAULT_MINIMUM_TOKENS, topic = "" }) => {
|
|
117
162
|
const fetchDocsResponse = await fetchLibraryDocumentation(context7CompatibleLibraryID, {
|
|
118
163
|
tokens,
|
|
119
164
|
topic,
|
|
120
|
-
});
|
|
165
|
+
}, clientIp, apiKey);
|
|
121
166
|
if (!fetchDocsResponse) {
|
|
122
167
|
return {
|
|
123
168
|
content: [
|
|
@@ -141,7 +186,7 @@ ${resultsText}`,
|
|
|
141
186
|
}
|
|
142
187
|
async function main() {
|
|
143
188
|
const transportType = TRANSPORT_TYPE;
|
|
144
|
-
if (transportType === "http"
|
|
189
|
+
if (transportType === "http") {
|
|
145
190
|
// Get initial port from environment or use default
|
|
146
191
|
const initialPort = CLI_PORT ?? 3000;
|
|
147
192
|
// Keep track of which port we end up using
|
|
@@ -151,16 +196,47 @@ async function main() {
|
|
|
151
196
|
// Set CORS headers for all responses
|
|
152
197
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
153
198
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE");
|
|
154
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, MCP-Session-Id,
|
|
199
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, MCP-Session-Id, MCP-Protocol-Version, X-Context7-API-Key, Context7-API-Key, X-API-Key, Authorization");
|
|
200
|
+
res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id");
|
|
155
201
|
// Handle preflight OPTIONS requests
|
|
156
202
|
if (req.method === "OPTIONS") {
|
|
157
203
|
res.writeHead(200);
|
|
158
204
|
res.end();
|
|
159
205
|
return;
|
|
160
206
|
}
|
|
207
|
+
// Function to extract header value safely, handling both string and string[] cases
|
|
208
|
+
const extractHeaderValue = (value) => {
|
|
209
|
+
if (!value)
|
|
210
|
+
return undefined;
|
|
211
|
+
return typeof value === "string" ? value : value[0];
|
|
212
|
+
};
|
|
213
|
+
// Extract Authorization header and remove Bearer prefix if present
|
|
214
|
+
const extractBearerToken = (authHeader) => {
|
|
215
|
+
const header = extractHeaderValue(authHeader);
|
|
216
|
+
if (!header)
|
|
217
|
+
return undefined;
|
|
218
|
+
// If it starts with 'Bearer ', remove that prefix
|
|
219
|
+
if (header.startsWith("Bearer ")) {
|
|
220
|
+
return header.substring(7).trim();
|
|
221
|
+
}
|
|
222
|
+
// Otherwise return the raw value
|
|
223
|
+
return header;
|
|
224
|
+
};
|
|
225
|
+
// Check headers in order of preference
|
|
226
|
+
const apiKey = extractBearerToken(req.headers.authorization) ||
|
|
227
|
+
extractHeaderValue(req.headers["Context7-API-Key"]) ||
|
|
228
|
+
extractHeaderValue(req.headers["X-API-Key"]) ||
|
|
229
|
+
extractHeaderValue(req.headers["context7-api-key"]) ||
|
|
230
|
+
extractHeaderValue(req.headers["x-api-key"]) ||
|
|
231
|
+
extractHeaderValue(req.headers["Context7_API_Key"]) ||
|
|
232
|
+
extractHeaderValue(req.headers["X_API_Key"]) ||
|
|
233
|
+
extractHeaderValue(req.headers["context7_api_key"]) ||
|
|
234
|
+
extractHeaderValue(req.headers["x_api_key"]);
|
|
161
235
|
try {
|
|
236
|
+
// Extract client IP address using socket remote address (most reliable)
|
|
237
|
+
const clientIp = getClientIp(req);
|
|
162
238
|
// Create new server instance for each request
|
|
163
|
-
const requestServer = createServerInstance();
|
|
239
|
+
const requestServer = createServerInstance(clientIp, apiKey);
|
|
164
240
|
if (url === "/mcp") {
|
|
165
241
|
const transport = new StreamableHTTPServerTransport({
|
|
166
242
|
sessionIdGenerator: undefined,
|
|
@@ -229,7 +305,7 @@ async function main() {
|
|
|
229
305
|
});
|
|
230
306
|
httpServer.listen(port, () => {
|
|
231
307
|
actualPort = port;
|
|
232
|
-
console.error(`Context7 Documentation MCP Server running on ${transportType.toUpperCase()} at http://localhost:${actualPort}/mcp
|
|
308
|
+
console.error(`Context7 Documentation MCP Server running on ${transportType.toUpperCase()} at http://localhost:${actualPort}/mcp with SSE endpoint at /sse`);
|
|
233
309
|
});
|
|
234
310
|
};
|
|
235
311
|
// Start the server with initial port
|
|
@@ -237,7 +313,7 @@ async function main() {
|
|
|
237
313
|
}
|
|
238
314
|
else {
|
|
239
315
|
// Stdio transport - this is already stateless by nature
|
|
240
|
-
const server = createServerInstance();
|
|
316
|
+
const server = createServerInstance(undefined, cliOptions.apiKey);
|
|
241
317
|
const transport = new StdioServerTransport();
|
|
242
318
|
await server.connect(transport);
|
|
243
319
|
console.error("Context7 Documentation MCP Server running on stdio");
|
package/dist/lib/api.js
CHANGED
|
@@ -1,22 +1,53 @@
|
|
|
1
|
+
import { generateHeaders } from "./encryption.js";
|
|
2
|
+
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
|
1
3
|
const CONTEXT7_API_BASE_URL = "https://context7.com/api";
|
|
2
4
|
const DEFAULT_TYPE = "txt";
|
|
5
|
+
// Pick up proxy configuration in a variety of common env var names.
|
|
6
|
+
const PROXY_URL = process.env.HTTPS_PROXY ??
|
|
7
|
+
process.env.https_proxy ??
|
|
8
|
+
process.env.HTTP_PROXY ??
|
|
9
|
+
process.env.http_proxy ??
|
|
10
|
+
null;
|
|
11
|
+
if (PROXY_URL && !PROXY_URL.startsWith("$") && /^(http|https):\/\//i.test(PROXY_URL)) {
|
|
12
|
+
try {
|
|
13
|
+
// Configure a global proxy agent once at startup. Subsequent fetch calls will
|
|
14
|
+
// automatically use this dispatcher.
|
|
15
|
+
// Using `any` cast because ProxyAgent implements the Dispatcher interface but
|
|
16
|
+
// TS may not infer it correctly in some versions.
|
|
17
|
+
setGlobalDispatcher(new ProxyAgent(PROXY_URL));
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
// Don't crash the app if proxy initialisation fails – just log a warning.
|
|
21
|
+
console.error(`[Context7] Failed to configure proxy agent for provided proxy URL: ${PROXY_URL}:`, error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
3
24
|
/**
|
|
4
25
|
* Searches for libraries matching the given query
|
|
5
26
|
* @param query The search query
|
|
27
|
+
* @param clientIp Optional client IP address to include in headers
|
|
28
|
+
* @param apiKey Optional API key for authentication
|
|
6
29
|
* @returns Search results or null if the request fails
|
|
7
30
|
*/
|
|
8
|
-
export async function searchLibraries(query) {
|
|
31
|
+
export async function searchLibraries(query, clientIp, apiKey) {
|
|
9
32
|
try {
|
|
10
33
|
const url = new URL(`${CONTEXT7_API_BASE_URL}/v1/search`);
|
|
11
34
|
url.searchParams.set("query", query);
|
|
12
|
-
const
|
|
35
|
+
const headers = generateHeaders(clientIp, apiKey);
|
|
36
|
+
const response = await fetch(url, { headers });
|
|
13
37
|
if (!response.ok) {
|
|
14
38
|
const errorCode = response.status;
|
|
15
39
|
if (errorCode === 429) {
|
|
16
|
-
console.error(
|
|
40
|
+
console.error("Rate limited due to too many requests. Please try again later.");
|
|
17
41
|
return {
|
|
18
42
|
results: [],
|
|
19
|
-
error:
|
|
43
|
+
error: "Rate limited due to too many requests. Please try again later.",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (errorCode === 401) {
|
|
47
|
+
console.error("Unauthorized. Please check your API key.");
|
|
48
|
+
return {
|
|
49
|
+
results: [],
|
|
50
|
+
error: "Unauthorized. Please check your API key.",
|
|
20
51
|
};
|
|
21
52
|
}
|
|
22
53
|
console.error(`Failed to search libraries. Please try again later. Error code: ${errorCode}`);
|
|
@@ -36,9 +67,11 @@ export async function searchLibraries(query) {
|
|
|
36
67
|
* Fetches documentation context for a specific library
|
|
37
68
|
* @param libraryId The library ID to fetch documentation for
|
|
38
69
|
* @param options Options for the request
|
|
70
|
+
* @param clientIp Optional client IP address to include in headers
|
|
71
|
+
* @param apiKey Optional API key for authentication
|
|
39
72
|
* @returns The documentation text or null if the request fails
|
|
40
73
|
*/
|
|
41
|
-
export async function fetchLibraryDocumentation(libraryId, options = {}) {
|
|
74
|
+
export async function fetchLibraryDocumentation(libraryId, options = {}, clientIp, apiKey) {
|
|
42
75
|
try {
|
|
43
76
|
if (libraryId.startsWith("/")) {
|
|
44
77
|
libraryId = libraryId.slice(1);
|
|
@@ -49,15 +82,22 @@ export async function fetchLibraryDocumentation(libraryId, options = {}) {
|
|
|
49
82
|
if (options.topic)
|
|
50
83
|
url.searchParams.set("topic", options.topic);
|
|
51
84
|
url.searchParams.set("type", DEFAULT_TYPE);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
"X-Context7-Source": "mcp-server",
|
|
55
|
-
},
|
|
56
|
-
});
|
|
85
|
+
const headers = generateHeaders(clientIp, apiKey, { "X-Context7-Source": "mcp-server" });
|
|
86
|
+
const response = await fetch(url, { headers });
|
|
57
87
|
if (!response.ok) {
|
|
58
88
|
const errorCode = response.status;
|
|
59
89
|
if (errorCode === 429) {
|
|
60
|
-
const errorMessage =
|
|
90
|
+
const errorMessage = "Rate limited due to too many requests. Please try again later.";
|
|
91
|
+
console.error(errorMessage);
|
|
92
|
+
return errorMessage;
|
|
93
|
+
}
|
|
94
|
+
if (errorCode === 404) {
|
|
95
|
+
const errorMessage = "The library you are trying to access does not exist. Please try with a different library ID.";
|
|
96
|
+
console.error(errorMessage);
|
|
97
|
+
return errorMessage;
|
|
98
|
+
}
|
|
99
|
+
if (errorCode === 401) {
|
|
100
|
+
const errorMessage = "Unauthorized. Please check your API key.";
|
|
61
101
|
console.error(errorMessage);
|
|
62
102
|
return errorMessage;
|
|
63
103
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createCipheriv, randomBytes } from "crypto";
|
|
2
|
+
const ENCRYPTION_KEY = process.env.CLIENT_IP_ENCRYPTION_KEY ||
|
|
3
|
+
"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
|
|
4
|
+
const ALGORITHM = "aes-256-cbc";
|
|
5
|
+
function validateEncryptionKey(key) {
|
|
6
|
+
// Must be exactly 64 hex characters (32 bytes)
|
|
7
|
+
return /^[0-9a-fA-F]{64}$/.test(key);
|
|
8
|
+
}
|
|
9
|
+
function encryptClientIp(clientIp) {
|
|
10
|
+
if (!validateEncryptionKey(ENCRYPTION_KEY)) {
|
|
11
|
+
console.error("Invalid encryption key format. Must be 64 hex characters.");
|
|
12
|
+
return clientIp; // Fallback to unencrypted
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const iv = randomBytes(16);
|
|
16
|
+
const cipher = createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, "hex"), iv);
|
|
17
|
+
let encrypted = cipher.update(clientIp, "utf8", "hex");
|
|
18
|
+
encrypted += cipher.final("hex");
|
|
19
|
+
return iv.toString("hex") + ":" + encrypted;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
console.error("Error encrypting client IP:", error);
|
|
23
|
+
return clientIp; // Fallback to unencrypted
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function generateHeaders(clientIp, apiKey, extraHeaders = {}) {
|
|
27
|
+
const headers = { ...extraHeaders };
|
|
28
|
+
if (clientIp) {
|
|
29
|
+
headers["mcp-client-ip"] = encryptClientIp(clientIp);
|
|
30
|
+
}
|
|
31
|
+
if (apiKey) {
|
|
32
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
33
|
+
}
|
|
34
|
+
return headers;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"@upstash/context7-mcp","version":"
|
|
1
|
+
{"name":"@upstash/context7-mcp","version":"1.0.16","description":"MCP server for Context7","scripts":{"test":"echo \"Error: no test specified\" && exit 1","build":"tsc && chmod 755 dist/index.js","format":"prettier --write .","lint":"eslint \"**/*.{js,ts,tsx}\" --fix","lint:check":"eslint \"**/*.{js,ts,tsx}\"","start":"node dist/index.js --transport http","pack-dxt":"bun install && bun run build && rm -rf node_modules && bun install --production && mv dxt/.dxtignore .dxtignore && mv dxt/manifest.json manifest.json && dxt validate manifest.json && dxt pack . dxt/context7.dxt && mv manifest.json dxt/manifest.json && mv .dxtignore dxt/.dxtignore && bun install"},"repository":{"type":"git","url":"git+https://github.com/upstash/context7.git"},"keywords":["modelcontextprotocol","mcp","context7"],"author":"abdush","license":"MIT","type":"module","bin":{"context7-mcp":"dist/index.js"},"files":["dist"],"bugs":{"url":"https://github.com/upstash/context7/issues"},"homepage":"https://github.com/upstash/context7#readme","dependencies":{"@modelcontextprotocol/sdk":"^1.13.2","commander":"^14.0.0","undici":"^6.6.3","zod":"^3.24.2"},"devDependencies":{"@types/node":"^22.13.14","@typescript-eslint/eslint-plugin":"^8.28.0","@typescript-eslint/parser":"^8.28.0","eslint":"^9.23.0","eslint-config-prettier":"^10.1.1","eslint-plugin-prettier":"^5.2.5","prettier":"^3.5.3","typescript":"^5.8.2","typescript-eslint":"^8.28.0"}}
|