fix(tools): address final code review findings on validation, concurrency, and testing

This commit is contained in:
Lucas Winther
2026-09-06 18:37:21 +02:00
parent 9bfedf8a8b
commit 6b8045111b
4 changed files with 57 additions and 10 deletions
+4 -2
View File
@@ -30,6 +30,7 @@ export const allTools: AnyToolDefinition[] = [
export interface RegisterToolsOptions { export interface RegisterToolsOptions {
readOnly?: boolean; readOnly?: boolean;
tools?: AnyToolDefinition[];
} }
/** /**
@@ -41,12 +42,13 @@ export function registerAllTools(
options?: RegisterToolsOptions options?: RegisterToolsOptions
): void { ): void {
const isReadOnly = options?.readOnly ?? false; const isReadOnly = options?.readOnly ?? false;
const toolsToRegister = options?.tools ?? allTools;
for (const tool of allTools) { for (const tool of toolsToRegister) {
// If server is in read-only mode, skip any non-read-only tools // If server is in read-only mode, skip any non-read-only tools
if (isReadOnly && !tool.readOnly) { if (isReadOnly && !tool.readOnly) {
continue; continue;
} }
registerTool(server, tool); registerTool(server, tool as unknown as ToolDefinition);
} }
} }
+8 -2
View File
@@ -47,7 +47,13 @@ export async function handleListProjects(
try { try {
let filters: FilterElement[] | undefined; let filters: FilterElement[] | undefined;
if (args.filters) { if (args.filters) {
filters = JSON.parse(args.filters) as FilterElement[]; const parsed = JSON.parse(args.filters);
if (!Array.isArray(parsed)) {
return formatToolError(
new Error("filters parameter must be a JSON array string")
);
}
filters = parsed as FilterElement[];
} }
const result = await listProjects({ const result = await listProjects({
@@ -71,7 +77,7 @@ export async function handleListProjects(
export const getProjectShape = { export const getProjectShape = {
projectId: z projectId: z
.union([z.number().int().positive(), z.string()]) .union([z.number().int().positive(), z.string().min(1)])
.describe("Numeric project ID or string identifier/slug"), .describe("Numeric project ID or string identifier/slug"),
}; };
+7 -5
View File
@@ -93,11 +93,13 @@ export async function handleGetQuery(
args: GetQueryArgs args: GetQueryArgs
): Promise<McpToolResponse> { ): Promise<McpToolResponse> {
try { try {
const query = await getQuery(args.queryId); const [query, results] = await Promise.all([
const results = await getQueryResults(args.queryId, { getQuery(args.queryId),
pageSize: args.pageSize, getQueryResults(args.queryId, {
offset: args.offset, pageSize: args.pageSize,
}); offset: args.offset,
}),
]);
return formatToolSuccess({ return formatToolSuccess({
query, query,
+38 -1
View File
@@ -252,6 +252,17 @@ describe("Project Tools", () => {
expect(response.content[0]?.text).toContain("Error"); expect(response.content[0]?.text).toContain("Error");
}); });
test("handleListProjects catches non-array JSON filters and returns error response", async () => {
const response = await runWithContext(
{ client: dummyClient, isReadOnly: false },
() => handleListProjects({ filters: '{"not":"an array"}' })
);
expect(response.isError).toBe(true);
expect(response.content[0]?.text).toContain(
"filters parameter must be a JSON array string"
);
});
test("getProject validates schema and retrieves single project", async () => { test("getProject validates schema and retrieves single project", async () => {
const schema = z.object(getProjectShape); const schema = z.object(getProjectShape);
const parsed = schema.parse({ projectId: 4 }); const parsed = schema.parse({ projectId: 4 });
@@ -266,6 +277,13 @@ describe("Project Tools", () => {
expect(result.identifier).toBe("mcp-test-project"); expect(result.identifier).toBe("mcp-test-project");
}); });
test("getProject rejects empty string projectId", () => {
const schema = z.object(getProjectShape);
expect(() => schema.parse({ projectId: "" })).toThrow();
expect(() => schema.parse({ projectId: 0 })).toThrow();
expect(() => schema.parse({ projectId: -1 })).toThrow();
});
test("getProject catches errors and formats error response", async () => { test("getProject catches errors and formats error response", async () => {
const failingClient = { const failingClient = {
get: async () => { get: async () => {
@@ -977,7 +995,20 @@ describe("Tool Registry", () => {
test("registerAllTools filters out non-readOnly tools when readOnly is true", () => { test("registerAllTools filters out non-readOnly tools when readOnly is true", () => {
const server = new McpServer({ name: "test-mcp", version: "1.0.0" }); const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
registerAllTools(server, { readOnly: true }); const syntheticMutatingTool: ToolDefinition = {
name: "openproject_synthetic_mutating_tool",
description: "A synthetic mutating tool for testing readOnly filtering",
readOnly: false,
execute: async () => formatToolSuccess({ mutated: true }),
};
registerAllTools(server, {
readOnly: true,
tools: [
...allTools,
syntheticMutatingTool as unknown as AnyToolDefinition,
],
});
const registeredTools = ( const registeredTools = (
server as unknown as { server as unknown as {
@@ -985,7 +1016,13 @@ describe("Tool Registry", () => {
} }
)._registeredTools; )._registeredTools;
expect(
registeredTools["openproject_synthetic_mutating_tool"]
).toBeUndefined();
expect(Object.keys(registeredTools)).toHaveLength(10); expect(Object.keys(registeredTools)).toHaveLength(10);
for (const tool of allTools) {
expect(registeredTools[tool.name]).toBeDefined();
}
}); });
}); });