Skip to content

Commit 7411bb5

Browse files
committed
refactor: day-2 hotspot hardening with parser/context modules and tests
1 parent 142ed4a commit 7411bb5

8 files changed

Lines changed: 784 additions & 58 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ jobs:
1818
cache: npm
1919
cache-dependency-path: package-lock.json
2020
- run: npm ci
21-
- run: npx tsc --noEmit
21+
- run: npm run typecheck
22+
- run: npm test

command-line.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { strict as assert } from "node:assert";
2+
import test from "node:test";
3+
import { parseCommandLine } from "./command-line";
4+
5+
test("parseCommandLine handles quoted arguments", () => {
6+
const [cmd, args] = parseCommandLine('npx "foo bar" --x');
7+
assert.equal(cmd, "npx");
8+
assert.deepEqual(args, ["foo bar", "--x"]);
9+
});
10+
11+
test("parseCommandLine handles escaped spaces", () => {
12+
const [cmd, args] = parseCommandLine("tool foo\\ bar baz");
13+
assert.equal(cmd, "tool");
14+
assert.deepEqual(args, ["foo bar", "baz"]);
15+
});
16+
17+
test("parseCommandLine returns empty tuple for empty input", () => {
18+
const [cmd, args] = parseCommandLine("");
19+
assert.equal(cmd, "");
20+
assert.deepEqual(args, []);
21+
});

command-line.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export function parseCommandLine(cmd: string): [string, string[]] {
2+
const args: string[] = [];
3+
let current = "";
4+
let inQuote = false;
5+
let quoteChar = "";
6+
7+
for (let i = 0; i < cmd.length; i++) {
8+
const char = cmd[i];
9+
10+
if (char === "\\" && i + 1 < cmd.length) {
11+
current += cmd[++i];
12+
continue;
13+
}
14+
15+
if ((char === '"' || char === "'") && !inQuote) {
16+
inQuote = true;
17+
quoteChar = char;
18+
continue;
19+
}
20+
21+
if (char === quoteChar && inQuote) {
22+
inQuote = false;
23+
quoteChar = "";
24+
continue;
25+
}
26+
27+
if (char === " " && !inQuote) {
28+
if (current) {
29+
args.push(current);
30+
current = "";
31+
}
32+
continue;
33+
}
34+
35+
current += char;
36+
}
37+
38+
if (current) {
39+
args.push(current);
40+
}
41+
42+
if (args.length === 0) {
43+
return ["", []];
44+
}
45+
46+
return [args[0], args.slice(1)];
47+
}

context7.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { strict as assert } from "node:assert";
2+
import test from "node:test";
3+
import { normaliseContext7LibraryId } from "./context7";
4+
5+
test("normaliseContext7LibraryId keeps leading slash", () => {
6+
assert.equal(normaliseContext7LibraryId("/reactjs/react.dev"), "/reactjs/react.dev");
7+
});
8+
9+
test("normaliseContext7LibraryId adds leading slash", () => {
10+
assert.equal(normaliseContext7LibraryId("reactjs/react.dev"), "/reactjs/react.dev");
11+
});

context7.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const CONTEXT7_API_BASE_URL = "https://context7.com/api/v2";
2+
3+
type Context7Endpoint = "libs/search" | "context";
4+
5+
function getContext7Headers(): Record<string, string> {
6+
const token = process.env.CONTEXT7_API_KEY?.trim();
7+
const headers: Record<string, string> = {};
8+
9+
if (token) {
10+
headers.Authorization = `Bearer ${token}`;
11+
}
12+
13+
return headers;
14+
}
15+
16+
export async function callContext7Api(
17+
endpoint: Context7Endpoint,
18+
params: Record<string, string | number>,
19+
): Promise<string> {
20+
const url = new URL(`${CONTEXT7_API_BASE_URL}/${endpoint}`);
21+
for (const [key, value] of Object.entries(params)) {
22+
url.searchParams.set(key, String(value));
23+
}
24+
25+
const response = await fetch(url, {
26+
headers: getContext7Headers(),
27+
});
28+
29+
const text = await response.text();
30+
if (!response.ok) {
31+
throw new Error(
32+
`Context7 API error (${response.status} ${response.statusText}): ${text}`,
33+
);
34+
}
35+
36+
return text;
37+
}
38+
39+
export function normaliseContext7LibraryId(libraryId: string): string {
40+
const normalised = libraryId.trim();
41+
if (normalised.startsWith("/")) {
42+
return normalised;
43+
}
44+
return `/${normalised}`;
45+
}

main.ts

Lines changed: 101 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { platform, tmpdir } from "os";
88
import { spawn } from "child_process";
99
import { join } from "path";
1010
import { z } from "zod";
11+
import { parseCommandLine } from "./command-line";
12+
import { callContext7Api, normaliseContext7LibraryId } from "./context7";
1113

1214
const tmp = join(tmpdir(), "pkgx-mcp");
1315

@@ -110,60 +112,6 @@ async function runPkgxCommand(program: string, args: string[], cwd?: string): Pr
110112
}
111113
}
112114

113-
// Parse command line with proper quote handling
114-
function parseCommandLine(cmd: string): [string, string[]] {
115-
const args: string[] = [];
116-
let current = '';
117-
let inQuote = false;
118-
let quoteChar = '';
119-
120-
for (let i = 0; i < cmd.length; i++) {
121-
const char = cmd[i];
122-
123-
if (char === '\\' && i + 1 < cmd.length) {
124-
// Handle escaped characters
125-
current += cmd[++i];
126-
continue;
127-
}
128-
129-
if ((char === '"' || char === "'") && !inQuote) {
130-
// Start of quoted section
131-
inQuote = true;
132-
quoteChar = char;
133-
continue;
134-
}
135-
136-
if (char === quoteChar && inQuote) {
137-
// End of quoted section
138-
inQuote = false;
139-
quoteChar = '';
140-
continue;
141-
}
142-
143-
if (char === ' ' && !inQuote) {
144-
// Space outside quotes - split argument
145-
if (current) {
146-
args.push(current);
147-
current = '';
148-
}
149-
continue;
150-
}
151-
152-
current += char;
153-
}
154-
155-
// Add the last argument if there is one
156-
if (current) {
157-
args.push(current);
158-
}
159-
160-
if (args.length === 0) {
161-
return ['', []];
162-
}
163-
164-
return [args[0], args.slice(1)];
165-
}
166-
167115
server.tool(
168116
"run-command-line",
169117
`
@@ -297,3 +245,102 @@ async function get_pkgx() {
297245
return filePath;
298246
}
299247
}
248+
249+
server.tool(
250+
"resolve-library-id",
251+
`
252+
Resolve a library name to Context7-compatible library IDs.
253+
This can be used before querying documentation.
254+
`,
255+
{
256+
libraryName: z.string().describe("The library name to search for, for example: react"),
257+
query: z.string().describe("The question or task to rank matches by relevance"),
258+
},
259+
async ({ libraryName, query }) => {
260+
try {
261+
const responseText = await callContext7Api("libs/search", {
262+
libraryName,
263+
query,
264+
});
265+
return {
266+
content: [{
267+
type: "text",
268+
text: responseText,
269+
}],
270+
};
271+
} catch (error: any) {
272+
return {
273+
isError: true,
274+
content: [{ type: "text", text: `resolve-library-id failed: ${error.message}` }],
275+
};
276+
}
277+
}
278+
);
279+
280+
server.tool(
281+
"query-docs",
282+
`
283+
Fetch context-aware docs snippets for a Context7 library.
284+
You must pass a Context7-compatible library ID, e.g. /reactjs/react.dev
285+
`,
286+
{
287+
libraryId: z.string().describe("Context7 library ID, e.g. /reactjs/react.dev"),
288+
query: z.string().describe("The question or task to fetch relevant docs for"),
289+
},
290+
async ({ libraryId, query }) => {
291+
try {
292+
const responseText = await callContext7Api("context", {
293+
libraryId: normaliseContext7LibraryId(libraryId),
294+
query,
295+
});
296+
return {
297+
content: [{
298+
type: "text",
299+
text: responseText,
300+
}],
301+
};
302+
} catch (error: any) {
303+
return {
304+
isError: true,
305+
content: [{ type: "text", text: `query-docs failed: ${error.message}` }],
306+
};
307+
}
308+
}
309+
);
310+
311+
server.tool(
312+
"get-library-docs",
313+
`
314+
Legacy Context7-compatible docs tool.
315+
Fetch documentation for a Context7-compatible library ID.
316+
Use resolve-library-id first unless you already have an ID.
317+
`,
318+
{
319+
context7CompatibleLibraryID: z.string().describe("Context7-compatible library ID, e.g. /reactjs/react.dev"),
320+
topic: z.string().default("").describe("Optional topic to focus docs lookup"),
321+
tokens: z.number().int().min(1).default(10000).describe("Retained for compatibility; currently ignored"),
322+
page: z.number().int().min(1).default(1).describe("Retained for compatibility; currently ignored"),
323+
},
324+
async ({ context7CompatibleLibraryID, topic }) => {
325+
const requestedTopic = topic?.trim();
326+
const query = requestedTopic ? `${requestedTopic} documentation` : "library documentation";
327+
328+
try {
329+
const responseText = await callContext7Api("context", {
330+
libraryId: normaliseContext7LibraryId(context7CompatibleLibraryID),
331+
query,
332+
});
333+
return {
334+
content: [{
335+
type: "text",
336+
text: responseText,
337+
}],
338+
};
339+
} catch (error: any) {
340+
return {
341+
isError: true,
342+
content: [{ type: "text", text: `get-library-docs failed: ${error.message}` }],
343+
};
344+
}
345+
}
346+
);

0 commit comments

Comments
 (0)