@mhalder/qdrant-mcp-server 3.2.0 → 3.3.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/.dagger/.gitattributes +1 -0
- package/.dagger/package.json +6 -0
- package/.dagger/src/index.ts +83 -0
- package/.dagger/tsconfig.json +13 -0
- package/.dagger/yarn.lock +8 -0
- package/.github/workflows/ci.yml +17 -27
- package/.github/workflows/release.yml +16 -19
- package/CHANGELOG.md +13 -0
- package/README.md +11 -9
- package/build/code/chunker/tree-sitter-chunker.d.ts.map +1 -1
- package/build/code/chunker/tree-sitter-chunker.js +15 -3
- package/build/code/chunker/tree-sitter-chunker.js.map +1 -1
- package/build/code/indexer.d.ts +1 -0
- package/build/code/indexer.d.ts.map +1 -1
- package/build/code/indexer.js +24 -4
- package/build/code/indexer.js.map +1 -1
- package/build/embeddings/cohere.d.ts +1 -0
- package/build/embeddings/cohere.d.ts.map +1 -1
- package/build/embeddings/cohere.js +8 -1
- package/build/embeddings/cohere.js.map +1 -1
- package/build/embeddings/cohere.test.js +11 -0
- package/build/embeddings/cohere.test.js.map +1 -1
- package/build/embeddings/factory.d.ts.map +1 -1
- package/build/embeddings/factory.js +2 -0
- package/build/embeddings/factory.js.map +1 -1
- package/build/embeddings/factory.test.js +12 -1
- package/build/embeddings/factory.test.js.map +1 -1
- package/build/embeddings/ollama.d.ts +1 -0
- package/build/embeddings/ollama.d.ts.map +1 -1
- package/build/embeddings/ollama.js +8 -1
- package/build/embeddings/ollama.js.map +1 -1
- package/build/embeddings/ollama.test.js +11 -0
- package/build/embeddings/ollama.test.js.map +1 -1
- package/build/embeddings/openai.d.ts +1 -0
- package/build/embeddings/openai.d.ts.map +1 -1
- package/build/embeddings/openai.js +8 -1
- package/build/embeddings/openai.js.map +1 -1
- package/build/embeddings/openai.test.js +11 -0
- package/build/embeddings/openai.test.js.map +1 -1
- package/build/embeddings/voyage.d.ts +1 -0
- package/build/embeddings/voyage.d.ts.map +1 -1
- package/build/embeddings/voyage.js +8 -1
- package/build/embeddings/voyage.js.map +1 -1
- package/build/embeddings/voyage.test.js +11 -0
- package/build/embeddings/voyage.test.js.map +1 -1
- package/build/git/indexer.d.ts +1 -0
- package/build/git/indexer.d.ts.map +1 -1
- package/build/git/indexer.js +16 -3
- package/build/git/indexer.js.map +1 -1
- package/build/git/indexer.test.js +15 -9
- package/build/git/indexer.test.js.map +1 -1
- package/build/index.js +35 -26
- package/build/index.js.map +1 -1
- package/build/index.test.js +105 -91
- package/build/index.test.js.map +1 -1
- package/build/logger.d.ts +4 -0
- package/build/logger.d.ts.map +1 -0
- package/build/logger.js +24 -0
- package/build/logger.js.map +1 -0
- package/build/qdrant/client.d.ts +1 -0
- package/build/qdrant/client.d.ts.map +1 -1
- package/build/qdrant/client.js +10 -0
- package/build/qdrant/client.js.map +1 -1
- package/build/qdrant/client.test.js +11 -0
- package/build/qdrant/client.test.js.map +1 -1
- package/build/tools/code.d.ts.map +1 -1
- package/build/tools/code.js +44 -13
- package/build/tools/code.js.map +1 -1
- package/build/tools/collection.d.ts.map +1 -1
- package/build/tools/collection.js +15 -8
- package/build/tools/collection.js.map +1 -1
- package/build/tools/document.d.ts.map +1 -1
- package/build/tools/document.js +9 -4
- package/build/tools/document.js.map +1 -1
- package/build/tools/federated.d.ts.map +1 -1
- package/build/tools/federated.js +9 -4
- package/build/tools/federated.js.map +1 -1
- package/build/tools/federated.test.js +11 -0
- package/build/tools/federated.test.js.map +1 -1
- package/build/tools/git-history.d.ts.map +1 -1
- package/build/tools/git-history.js +44 -12
- package/build/tools/git-history.js.map +1 -1
- package/build/tools/logging.d.ts +16 -0
- package/build/tools/logging.d.ts.map +1 -0
- package/build/tools/logging.js +68 -0
- package/build/tools/logging.js.map +1 -0
- package/build/tools/logging.test.d.ts +2 -0
- package/build/tools/logging.test.d.ts.map +1 -0
- package/build/tools/logging.test.js +139 -0
- package/build/tools/logging.test.js.map +1 -0
- package/build/tools/schemas.d.ts +32 -19
- package/build/tools/schemas.d.ts.map +1 -1
- package/build/tools/schemas.js +9 -3
- package/build/tools/schemas.js.map +1 -1
- package/build/tools/search.d.ts.map +1 -1
- package/build/tools/search.js +13 -4
- package/build/tools/search.js.map +1 -1
- package/dagger.json +8 -0
- package/mise.toml +2 -0
- package/package.json +14 -13
- package/src/code/chunker/tree-sitter-chunker.ts +41 -9
- package/src/code/indexer.ts +41 -6
- package/src/embeddings/cohere.test.ts +12 -0
- package/src/embeddings/cohere.ts +10 -2
- package/src/embeddings/factory.test.ts +13 -1
- package/src/embeddings/factory.ts +3 -0
- package/src/embeddings/ollama.test.ts +12 -0
- package/src/embeddings/ollama.ts +10 -2
- package/src/embeddings/openai.test.ts +12 -0
- package/src/embeddings/openai.ts +10 -2
- package/src/embeddings/voyage.test.ts +12 -0
- package/src/embeddings/voyage.ts +10 -2
- package/src/git/indexer.test.ts +22 -16
- package/src/git/indexer.ts +30 -4
- package/src/index.test.ts +128 -106
- package/src/index.ts +59 -38
- package/src/logger.ts +33 -0
- package/src/qdrant/client.test.ts +12 -0
- package/src/qdrant/client.ts +22 -0
- package/src/tools/code.ts +107 -62
- package/src/tools/collection.ts +39 -22
- package/src/tools/document.ts +52 -22
- package/src/tools/federated.test.ts +12 -0
- package/src/tools/federated.ts +143 -125
- package/src/tools/git-history.ts +117 -60
- package/src/tools/logging.test.ts +206 -0
- package/src/tools/logging.ts +85 -0
- package/src/tools/schemas.ts +9 -3
- package/src/tools/search.ts +93 -71
- package/tests/code/chunker/tree-sitter-chunker.test.ts +13 -1
- package/tests/code/indexer.test.ts +12 -0
- package/tests/code/integration.test.ts +14 -1
package/src/tools/git-history.ts
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import logger from "../logger.js";
|
|
6
7
|
import type { GitHistoryIndexer } from "../git/indexer.js";
|
|
8
|
+
import { withToolLogging } from "./logging.js";
|
|
7
9
|
import * as schemas from "./schemas.js";
|
|
8
10
|
|
|
11
|
+
const log = logger.child({ component: "tools" });
|
|
12
|
+
|
|
9
13
|
export interface GitHistoryToolDependencies {
|
|
10
14
|
gitHistoryIndexer: GitHistoryIndexer;
|
|
11
15
|
}
|
|
@@ -25,30 +29,51 @@ export function registerGitHistoryTools(
|
|
|
25
29
|
"Index a repository's git commit history for semantic search. Extracts commit messages, metadata, and optionally diffs to enable finding relevant past commits. Useful for finding similar fixes, understanding change patterns, or learning from past work.",
|
|
26
30
|
inputSchema: schemas.IndexGitHistorySchema,
|
|
27
31
|
},
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
},
|
|
37
|
-
);
|
|
32
|
+
withToolLogging(
|
|
33
|
+
"index_git_history",
|
|
34
|
+
async ({ path, forceReindex, sinceDate, maxCommits }, extra) => {
|
|
35
|
+
log.info(
|
|
36
|
+
{ tool: "index_git_history", path, forceReindex },
|
|
37
|
+
"Tool called",
|
|
38
|
+
);
|
|
39
|
+
const progressToken = extra._meta?.progressToken;
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
const stats = await gitHistoryIndexer.indexHistory(
|
|
42
|
+
path,
|
|
43
|
+
{ forceReindex, sinceDate, maxCommits },
|
|
44
|
+
(progress) => {
|
|
45
|
+
log.debug(
|
|
46
|
+
{ phase: progress.phase, percentage: progress.percentage },
|
|
47
|
+
progress.message,
|
|
48
|
+
);
|
|
49
|
+
if (progressToken !== undefined) {
|
|
50
|
+
extra.sendNotification({
|
|
51
|
+
method: "notifications/progress",
|
|
52
|
+
params: {
|
|
53
|
+
progressToken,
|
|
54
|
+
progress: progress.percentage,
|
|
55
|
+
total: 100,
|
|
56
|
+
message: `[${progress.phase}] ${progress.message}`,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
);
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
statusMessage += `\n\nWarnings:\n${stats.errors?.join("\n")}`;
|
|
43
|
-
} else if (stats.status === "failed") {
|
|
44
|
-
statusMessage = `Indexing failed:\n${stats.errors?.join("\n")}`;
|
|
45
|
-
}
|
|
63
|
+
let statusMessage = `Indexed ${stats.commitsIndexed}/${stats.commitsScanned} commits (${stats.chunksCreated} chunks) in ${(stats.durationMs / 1000).toFixed(1)}s`;
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
if (stats.status === "partial") {
|
|
66
|
+
statusMessage += `\n\nWarnings:\n${stats.errors?.join("\n")}`;
|
|
67
|
+
} else if (stats.status === "failed") {
|
|
68
|
+
statusMessage = `Indexing failed:\n${stats.errors?.join("\n")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: "text", text: statusMessage }],
|
|
73
|
+
isError: stats.status === "failed",
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
),
|
|
52
77
|
);
|
|
53
78
|
|
|
54
79
|
// search_git_history
|
|
@@ -60,47 +85,62 @@ export function registerGitHistoryTools(
|
|
|
60
85
|
"Search indexed git history using natural language queries. Returns semantically relevant commits with metadata. Useful for finding past fixes, similar changes, or understanding how problems were solved before.",
|
|
61
86
|
inputSchema: schemas.SearchGitHistorySchema,
|
|
62
87
|
},
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
withToolLogging(
|
|
89
|
+
"search_git_history",
|
|
90
|
+
async ({
|
|
91
|
+
path,
|
|
92
|
+
query,
|
|
65
93
|
limit,
|
|
66
94
|
commitTypes,
|
|
67
95
|
authors,
|
|
68
96
|
dateFrom,
|
|
69
97
|
dateTo,
|
|
70
|
-
})
|
|
98
|
+
}) => {
|
|
99
|
+
log.info(
|
|
100
|
+
{ tool: "search_git_history", path, query: query.substring(0, 80) },
|
|
101
|
+
"Tool called",
|
|
102
|
+
);
|
|
103
|
+
const results = await gitHistoryIndexer.searchHistory(path, query, {
|
|
104
|
+
limit,
|
|
105
|
+
commitTypes,
|
|
106
|
+
authors,
|
|
107
|
+
dateFrom,
|
|
108
|
+
dateTo,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (results.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
content: [
|
|
114
|
+
{ type: "text", text: `No results found for query: "${query}"` },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Format results
|
|
120
|
+
const formattedResults = results
|
|
121
|
+
.map(
|
|
122
|
+
(r, idx) =>
|
|
123
|
+
`\n--- Result ${idx + 1} (score: ${r.score.toFixed(3)}) ---\n` +
|
|
124
|
+
`Commit: ${r.shortHash}\n` +
|
|
125
|
+
`Type: ${r.commitType}\n` +
|
|
126
|
+
`Author: ${r.author}\n` +
|
|
127
|
+
`Date: ${r.date.split("T")[0]}\n` +
|
|
128
|
+
`Subject: ${r.subject}\n` +
|
|
129
|
+
`Files: ${r.files.slice(0, 5).join(", ")}${r.files.length > 5 ? ` (+${r.files.length - 5} more)` : ""}\n\n` +
|
|
130
|
+
`${r.content.substring(0, 500)}${r.content.length > 500 ? "..." : ""}\n`,
|
|
131
|
+
)
|
|
132
|
+
.join("\n");
|
|
71
133
|
|
|
72
|
-
if (results.length === 0) {
|
|
73
134
|
return {
|
|
74
135
|
content: [
|
|
75
|
-
{
|
|
136
|
+
{
|
|
137
|
+
type: "text",
|
|
138
|
+
text: `Found ${results.length} result(s):\n${formattedResults}`,
|
|
139
|
+
},
|
|
76
140
|
],
|
|
77
141
|
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Format results
|
|
81
|
-
const formattedResults = results
|
|
82
|
-
.map(
|
|
83
|
-
(r, idx) =>
|
|
84
|
-
`\n--- Result ${idx + 1} (score: ${r.score.toFixed(3)}) ---\n` +
|
|
85
|
-
`Commit: ${r.shortHash}\n` +
|
|
86
|
-
`Type: ${r.commitType}\n` +
|
|
87
|
-
`Author: ${r.author}\n` +
|
|
88
|
-
`Date: ${r.date.split("T")[0]}\n` +
|
|
89
|
-
`Subject: ${r.subject}\n` +
|
|
90
|
-
`Files: ${r.files.slice(0, 5).join(", ")}${r.files.length > 5 ? ` (+${r.files.length - 5} more)` : ""}\n\n` +
|
|
91
|
-
`${r.content.substring(0, 500)}${r.content.length > 500 ? "..." : ""}\n`,
|
|
92
|
-
)
|
|
93
|
-
.join("\n");
|
|
94
|
-
|
|
95
|
-
return {
|
|
96
|
-
content: [
|
|
97
|
-
{
|
|
98
|
-
type: "text",
|
|
99
|
-
text: `Found ${results.length} result(s):\n${formattedResults}`,
|
|
100
|
-
},
|
|
101
|
-
],
|
|
102
|
-
};
|
|
103
|
-
},
|
|
142
|
+
},
|
|
143
|
+
),
|
|
104
144
|
);
|
|
105
145
|
|
|
106
146
|
// index_new_commits
|
|
@@ -112,13 +152,28 @@ export function registerGitHistoryTools(
|
|
|
112
152
|
"Incrementally index only new commits since the last indexing. Much faster than full re-indexing when keeping the index up to date with recent changes.",
|
|
113
153
|
inputSchema: schemas.IndexNewCommitsSchema,
|
|
114
154
|
},
|
|
115
|
-
async ({ path }) => {
|
|
155
|
+
withToolLogging("index_new_commits", async ({ path }, extra) => {
|
|
156
|
+
log.info({ tool: "index_new_commits", path }, "Tool called");
|
|
157
|
+
const progressToken = extra._meta?.progressToken;
|
|
158
|
+
|
|
116
159
|
const stats = await gitHistoryIndexer.indexNewCommits(
|
|
117
160
|
path,
|
|
118
161
|
(progress) => {
|
|
119
|
-
|
|
120
|
-
|
|
162
|
+
log.debug(
|
|
163
|
+
{ phase: progress.phase, percentage: progress.percentage },
|
|
164
|
+
progress.message,
|
|
121
165
|
);
|
|
166
|
+
if (progressToken !== undefined) {
|
|
167
|
+
extra.sendNotification({
|
|
168
|
+
method: "notifications/progress",
|
|
169
|
+
params: {
|
|
170
|
+
progressToken,
|
|
171
|
+
progress: progress.percentage,
|
|
172
|
+
total: 100,
|
|
173
|
+
message: `[${progress.phase}] ${progress.message}`,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|
|
122
177
|
},
|
|
123
178
|
);
|
|
124
179
|
|
|
@@ -134,7 +189,7 @@ export function registerGitHistoryTools(
|
|
|
134
189
|
return {
|
|
135
190
|
content: [{ type: "text", text: message }],
|
|
136
191
|
};
|
|
137
|
-
},
|
|
192
|
+
}),
|
|
138
193
|
);
|
|
139
194
|
|
|
140
195
|
// get_git_index_status
|
|
@@ -146,7 +201,8 @@ export function registerGitHistoryTools(
|
|
|
146
201
|
"Get the indexing status and statistics for a repository's git history index.",
|
|
147
202
|
inputSchema: schemas.GetGitIndexStatusSchema,
|
|
148
203
|
},
|
|
149
|
-
async ({ path }) => {
|
|
204
|
+
withToolLogging("get_git_index_status", async ({ path }) => {
|
|
205
|
+
log.info({ tool: "get_git_index_status", path }, "Tool called");
|
|
150
206
|
const status = await gitHistoryIndexer.getIndexStatus(path);
|
|
151
207
|
|
|
152
208
|
if (status.status === "not_indexed") {
|
|
@@ -184,7 +240,7 @@ export function registerGitHistoryTools(
|
|
|
184
240
|
return {
|
|
185
241
|
content: [{ type: "text", text: JSON.stringify(statusInfo, null, 2) }],
|
|
186
242
|
};
|
|
187
|
-
},
|
|
243
|
+
}),
|
|
188
244
|
);
|
|
189
245
|
|
|
190
246
|
// clear_git_index
|
|
@@ -196,13 +252,14 @@ export function registerGitHistoryTools(
|
|
|
196
252
|
"Delete all indexed git history data for a repository. This is irreversible and will remove the entire git history index.",
|
|
197
253
|
inputSchema: schemas.ClearGitIndexSchema,
|
|
198
254
|
},
|
|
199
|
-
async ({ path }) => {
|
|
255
|
+
withToolLogging("clear_git_index", async ({ path }) => {
|
|
256
|
+
log.info({ tool: "clear_git_index", path }, "Tool called");
|
|
200
257
|
await gitHistoryIndexer.clearIndex(path);
|
|
201
258
|
return {
|
|
202
259
|
content: [
|
|
203
260
|
{ type: "text", text: `Git history index cleared for "${path}".` },
|
|
204
261
|
],
|
|
205
262
|
};
|
|
206
|
-
},
|
|
263
|
+
}),
|
|
207
264
|
);
|
|
208
265
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { withToolLogging } from "./logging.js";
|
|
3
|
+
|
|
4
|
+
vi.mock("../logger.js", () => ({
|
|
5
|
+
default: {
|
|
6
|
+
info: vi.fn(),
|
|
7
|
+
warn: vi.fn(),
|
|
8
|
+
error: vi.fn(),
|
|
9
|
+
debug: vi.fn(),
|
|
10
|
+
fatal: vi.fn(),
|
|
11
|
+
trace: vi.fn(),
|
|
12
|
+
child: vi.fn().mockReturnValue({
|
|
13
|
+
info: vi.fn(),
|
|
14
|
+
warn: vi.fn(),
|
|
15
|
+
error: vi.fn(),
|
|
16
|
+
debug: vi.fn(),
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import logger from "../logger.js";
|
|
22
|
+
|
|
23
|
+
const mockLog = logger.child({ component: "tools" }) as unknown as {
|
|
24
|
+
info: ReturnType<typeof vi.fn>;
|
|
25
|
+
warn: ReturnType<typeof vi.fn>;
|
|
26
|
+
error: ReturnType<typeof vi.fn>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe("withToolLogging", () => {
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should log completion with durationMs on success", async () => {
|
|
35
|
+
const handler = vi.fn().mockResolvedValue({
|
|
36
|
+
content: [{ type: "text", text: "Success" }],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const wrapped = withToolLogging("create_collection", handler);
|
|
40
|
+
const result = await wrapped({ name: "test" });
|
|
41
|
+
|
|
42
|
+
expect(result.content[0]).toEqual({ type: "text", text: "Success" });
|
|
43
|
+
expect(mockLog.info).toHaveBeenCalledWith(
|
|
44
|
+
expect.objectContaining({
|
|
45
|
+
tool: "create_collection",
|
|
46
|
+
durationMs: expect.any(Number),
|
|
47
|
+
}),
|
|
48
|
+
"Tool completed",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should log error when result has isError: true", async () => {
|
|
53
|
+
const handler = vi.fn().mockResolvedValue({
|
|
54
|
+
content: [{ type: "text", text: "Error: Collection not found" }],
|
|
55
|
+
isError: true,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const wrapped = withToolLogging("add_documents", handler);
|
|
59
|
+
const result = await wrapped({ collection: "test" });
|
|
60
|
+
|
|
61
|
+
expect(result.isError).toBe(true);
|
|
62
|
+
expect(mockLog.error).toHaveBeenCalledWith(
|
|
63
|
+
expect.objectContaining({
|
|
64
|
+
tool: "add_documents",
|
|
65
|
+
durationMs: expect.any(Number),
|
|
66
|
+
error: "Error: Collection not found",
|
|
67
|
+
}),
|
|
68
|
+
"Tool failed",
|
|
69
|
+
);
|
|
70
|
+
expect(mockLog.info).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should log error and re-throw when handler throws", async () => {
|
|
74
|
+
const testError = new Error("Connection refused");
|
|
75
|
+
const handler = vi.fn().mockRejectedValue(testError);
|
|
76
|
+
|
|
77
|
+
const wrapped = withToolLogging("list_collections", handler);
|
|
78
|
+
|
|
79
|
+
await expect(wrapped()).rejects.toThrow("Connection refused");
|
|
80
|
+
expect(mockLog.error).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
tool: "list_collections",
|
|
83
|
+
durationMs: expect.any(Number),
|
|
84
|
+
err: testError,
|
|
85
|
+
}),
|
|
86
|
+
"Tool threw an error",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should log warn for search tool with "No results found" response', async () => {
|
|
91
|
+
const handler = vi.fn().mockResolvedValue({
|
|
92
|
+
content: [{ type: "text", text: 'No results found for query: "test"' }],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const wrapped = withToolLogging("semantic_search", handler);
|
|
96
|
+
await wrapped({ collection: "test", query: "test" });
|
|
97
|
+
|
|
98
|
+
expect(mockLog.warn).toHaveBeenCalledWith(
|
|
99
|
+
expect.objectContaining({
|
|
100
|
+
tool: "semantic_search",
|
|
101
|
+
durationMs: expect.any(Number),
|
|
102
|
+
}),
|
|
103
|
+
"Tool completed with no results",
|
|
104
|
+
);
|
|
105
|
+
expect(mockLog.info).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should log warn for search tool with empty JSON array response", async () => {
|
|
109
|
+
const handler = vi.fn().mockResolvedValue({
|
|
110
|
+
content: [{ type: "text", text: "[]" }],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const wrapped = withToolLogging("hybrid_search", handler);
|
|
114
|
+
await wrapped({ collection: "test", query: "test" });
|
|
115
|
+
|
|
116
|
+
expect(mockLog.warn).toHaveBeenCalledWith(
|
|
117
|
+
expect.objectContaining({ tool: "hybrid_search" }),
|
|
118
|
+
"Tool completed with no results",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should log warn for search tool with Found 0 result(s) response", async () => {
|
|
123
|
+
const handler = vi.fn().mockResolvedValue({
|
|
124
|
+
content: [{ type: "text", text: "Found 0 result(s):\n" }],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const wrapped = withToolLogging("search_code", handler);
|
|
128
|
+
await wrapped({ path: "/test", query: "test" });
|
|
129
|
+
|
|
130
|
+
expect(mockLog.warn).toHaveBeenCalledWith(
|
|
131
|
+
expect.objectContaining({ tool: "search_code" }),
|
|
132
|
+
"Tool completed with no results",
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should NOT log warn for non-search tool with empty-looking response", async () => {
|
|
137
|
+
const handler = vi.fn().mockResolvedValue({
|
|
138
|
+
content: [{ type: "text", text: "No results found for query: test" }],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const wrapped = withToolLogging("get_index_status", handler);
|
|
142
|
+
await wrapped({ path: "/test" });
|
|
143
|
+
|
|
144
|
+
// Should log info, not warn, because get_index_status is not a search tool
|
|
145
|
+
expect(mockLog.info).toHaveBeenCalledWith(
|
|
146
|
+
expect.objectContaining({ tool: "get_index_status" }),
|
|
147
|
+
"Tool completed",
|
|
148
|
+
);
|
|
149
|
+
expect(mockLog.warn).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should log info for search tool with actual results", async () => {
|
|
153
|
+
const handler = vi.fn().mockResolvedValue({
|
|
154
|
+
content: [{ type: "text", text: "Found 5 result(s):\n..." }],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const wrapped = withToolLogging("search_code", handler);
|
|
158
|
+
await wrapped({ path: "/test", query: "test" });
|
|
159
|
+
|
|
160
|
+
expect(mockLog.info).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({ tool: "search_code" }),
|
|
162
|
+
"Tool completed",
|
|
163
|
+
);
|
|
164
|
+
expect(mockLog.warn).not.toHaveBeenCalled();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should preserve handler return value exactly", async () => {
|
|
168
|
+
const expected = {
|
|
169
|
+
content: [{ type: "text" as const, text: "data" }],
|
|
170
|
+
isError: false,
|
|
171
|
+
};
|
|
172
|
+
const handler = vi.fn().mockResolvedValue(expected);
|
|
173
|
+
|
|
174
|
+
const wrapped = withToolLogging("list_collections", handler);
|
|
175
|
+
const result = await wrapped();
|
|
176
|
+
|
|
177
|
+
expect(result).toBe(expected);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should pass all arguments through to the handler", async () => {
|
|
181
|
+
const handler = vi.fn().mockResolvedValue({
|
|
182
|
+
content: [{ type: "text", text: "ok" }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const wrapped = withToolLogging("index_codebase", handler);
|
|
186
|
+
const args = { path: "/test" };
|
|
187
|
+
const extra = { _meta: {} };
|
|
188
|
+
await wrapped(args, extra);
|
|
189
|
+
|
|
190
|
+
expect(handler).toHaveBeenCalledWith(args, extra);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("should handle empty content array", async () => {
|
|
194
|
+
const handler = vi.fn().mockResolvedValue({
|
|
195
|
+
content: [],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const wrapped = withToolLogging("search_git_history", handler);
|
|
199
|
+
await wrapped({ path: "/test", query: "test" });
|
|
200
|
+
|
|
201
|
+
expect(mockLog.warn).toHaveBeenCalledWith(
|
|
202
|
+
expect.objectContaining({ tool: "search_git_history" }),
|
|
203
|
+
"Tool completed with no results",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool handler logging wrapper
|
|
3
|
+
*
|
|
4
|
+
* Provides standardized completion, error, and warning logging
|
|
5
|
+
* for all MCP tool handlers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import logger from "../logger.js";
|
|
10
|
+
|
|
11
|
+
const log = logger.child({ component: "tools" });
|
|
12
|
+
|
|
13
|
+
/** Tools that are search operations (empty results trigger a warning) */
|
|
14
|
+
const SEARCH_TOOLS = new Set([
|
|
15
|
+
"semantic_search",
|
|
16
|
+
"hybrid_search",
|
|
17
|
+
"search_code",
|
|
18
|
+
"search_git_history",
|
|
19
|
+
"contextual_search",
|
|
20
|
+
"federated_search",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether a tool result indicates an empty search (zero results).
|
|
25
|
+
*/
|
|
26
|
+
function isEmptySearchResult(result: CallToolResult): boolean {
|
|
27
|
+
if (!result.content || result.content.length === 0) return true;
|
|
28
|
+
const first = result.content[0];
|
|
29
|
+
if (first.type === "text") {
|
|
30
|
+
const text = (first as { type: "text"; text: string }).text;
|
|
31
|
+
return (
|
|
32
|
+
text.startsWith("No results found") ||
|
|
33
|
+
text === "[]" ||
|
|
34
|
+
text.includes("Found 0 result(s)")
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wraps a tool handler with standardized logging:
|
|
42
|
+
* - Logs "Tool completed" at info level with durationMs on success
|
|
43
|
+
* - Logs "Tool failed" at error level when result has isError: true
|
|
44
|
+
* - Logs "Tool completed with no results" at warn level for search tools
|
|
45
|
+
* - Logs "Tool threw an error" at error level when handler throws (re-throws)
|
|
46
|
+
*/
|
|
47
|
+
export function withToolLogging<
|
|
48
|
+
T extends (...args: any[]) => Promise<CallToolResult>,
|
|
49
|
+
>(toolName: string, handler: T): T {
|
|
50
|
+
const wrapped = async (...args: Parameters<T>): Promise<CallToolResult> => {
|
|
51
|
+
const startTime = Date.now();
|
|
52
|
+
try {
|
|
53
|
+
const result = await handler(...args);
|
|
54
|
+
const durationMs = Date.now() - startTime;
|
|
55
|
+
|
|
56
|
+
if (result.isError) {
|
|
57
|
+
const errorText =
|
|
58
|
+
result.content?.[0]?.type === "text"
|
|
59
|
+
? (result.content[0] as { type: "text"; text: string }).text
|
|
60
|
+
: "Unknown error";
|
|
61
|
+
log.error(
|
|
62
|
+
{ tool: toolName, durationMs, error: errorText },
|
|
63
|
+
"Tool failed",
|
|
64
|
+
);
|
|
65
|
+
} else if (SEARCH_TOOLS.has(toolName) && isEmptySearchResult(result)) {
|
|
66
|
+
log.warn(
|
|
67
|
+
{ tool: toolName, durationMs },
|
|
68
|
+
"Tool completed with no results",
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
log.info({ tool: toolName, durationMs }, "Tool completed");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const durationMs = Date.now() - startTime;
|
|
77
|
+
log.error(
|
|
78
|
+
{ tool: toolName, durationMs, err: error },
|
|
79
|
+
"Tool threw an error",
|
|
80
|
+
);
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
return wrapped as T;
|
|
85
|
+
}
|
package/src/tools/schemas.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const AddDocumentsSchema = {
|
|
|
41
41
|
.describe("Unique identifier for the document"),
|
|
42
42
|
text: z.string().describe("Text content to embed and store"),
|
|
43
43
|
metadata: z
|
|
44
|
-
.record(z.any())
|
|
44
|
+
.record(z.string(), z.any())
|
|
45
45
|
.optional()
|
|
46
46
|
.describe("Optional metadata to store with the document"),
|
|
47
47
|
}),
|
|
@@ -64,7 +64,10 @@ export const SemanticSearchSchema = {
|
|
|
64
64
|
.number()
|
|
65
65
|
.optional()
|
|
66
66
|
.describe("Maximum number of results (default: 5)"),
|
|
67
|
-
filter: z
|
|
67
|
+
filter: z
|
|
68
|
+
.record(z.string(), z.any())
|
|
69
|
+
.optional()
|
|
70
|
+
.describe("Optional metadata filter"),
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
export const HybridSearchSchema = {
|
|
@@ -74,7 +77,10 @@ export const HybridSearchSchema = {
|
|
|
74
77
|
.number()
|
|
75
78
|
.optional()
|
|
76
79
|
.describe("Maximum number of results (default: 5)"),
|
|
77
|
-
filter: z
|
|
80
|
+
filter: z
|
|
81
|
+
.record(z.string(), z.any())
|
|
82
|
+
.optional()
|
|
83
|
+
.describe("Optional metadata filter"),
|
|
78
84
|
};
|
|
79
85
|
|
|
80
86
|
// Code indexing schemas
|