feat(tools): implement metadata tools and registration
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Metadata Tools for OpenProject MCP.
|
||||||
|
* Implements tools for inspecting OpenProject taxonomies and users:
|
||||||
|
* - openproject_list_types
|
||||||
|
* - openproject_list_statuses
|
||||||
|
* - openproject_list_priorities
|
||||||
|
* - openproject_list_users
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import {
|
||||||
|
formatToolSuccess,
|
||||||
|
formatToolError,
|
||||||
|
registerTool,
|
||||||
|
type McpToolResponse,
|
||||||
|
type ToolDefinition,
|
||||||
|
} from "./common";
|
||||||
|
import {
|
||||||
|
listTypes,
|
||||||
|
listStatuses,
|
||||||
|
listPriorities,
|
||||||
|
listUsers,
|
||||||
|
} from "../services/metadata";
|
||||||
|
|
||||||
|
export const listTypesShape = {
|
||||||
|
projectId: z
|
||||||
|
.union([z.number().int().positive(), z.string().min(1)])
|
||||||
|
.optional()
|
||||||
|
.describe("Optional numeric project ID or identifier to scope types"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListTypesArgs = z.infer<z.ZodObject<typeof listTypesShape>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_types.
|
||||||
|
*/
|
||||||
|
export async function handleListTypes(
|
||||||
|
args: ListTypesArgs = {}
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
try {
|
||||||
|
const result = await listTypes(
|
||||||
|
args.projectId !== undefined ? { projectId: args.projectId } : undefined
|
||||||
|
);
|
||||||
|
return formatToolSuccess({
|
||||||
|
types: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listStatusesShape = {};
|
||||||
|
|
||||||
|
export type ListStatusesArgs = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_statuses.
|
||||||
|
*/
|
||||||
|
export async function handleListStatuses(
|
||||||
|
_args: ListStatusesArgs = {}
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
try {
|
||||||
|
const result = await listStatuses();
|
||||||
|
return formatToolSuccess({
|
||||||
|
statuses: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listPrioritiesShape = {};
|
||||||
|
|
||||||
|
export type ListPrioritiesArgs = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_priorities.
|
||||||
|
*/
|
||||||
|
export async function handleListPriorities(
|
||||||
|
_args: ListPrioritiesArgs = {}
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
try {
|
||||||
|
const result = await listPriorities();
|
||||||
|
return formatToolSuccess({
|
||||||
|
priorities: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listUsersShape = {
|
||||||
|
pageSize: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe("Number of users to return per page (max 100, default 20)"),
|
||||||
|
offset: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.describe("Page number to retrieve (1-based, default 1)"),
|
||||||
|
status: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Filter users by status (e.g. 'active', 'locked', 'invited')"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListUsersArgs = z.infer<z.ZodObject<typeof listUsersShape>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool execution handler for openproject_list_users.
|
||||||
|
*/
|
||||||
|
export async function handleListUsers(
|
||||||
|
args: ListUsersArgs = {}
|
||||||
|
): Promise<McpToolResponse> {
|
||||||
|
try {
|
||||||
|
const result = await listUsers(args);
|
||||||
|
return formatToolSuccess({
|
||||||
|
users: result.items,
|
||||||
|
total: result.total,
|
||||||
|
count: result.count,
|
||||||
|
pageSize: result.pageSize,
|
||||||
|
offset: result.offset,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return formatToolError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listTypesTool: ToolDefinition<
|
||||||
|
typeof listTypesShape,
|
||||||
|
ListTypesArgs
|
||||||
|
> = {
|
||||||
|
name: "openproject_list_types",
|
||||||
|
description:
|
||||||
|
"List all available work package types (Task, Bug, Milestone, Feature, etc.), optionally scoped to a project",
|
||||||
|
parameters: listTypesShape,
|
||||||
|
readOnly: true,
|
||||||
|
execute: handleListTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listStatusesTool: ToolDefinition<
|
||||||
|
typeof listStatusesShape,
|
||||||
|
ListStatusesArgs
|
||||||
|
> = {
|
||||||
|
name: "openproject_list_statuses",
|
||||||
|
description:
|
||||||
|
"List all available work package statuses (New, In Progress, Closed, etc.)",
|
||||||
|
parameters: listStatusesShape,
|
||||||
|
readOnly: true,
|
||||||
|
execute: handleListStatuses,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listPrioritiesTool: ToolDefinition<
|
||||||
|
typeof listPrioritiesShape,
|
||||||
|
ListPrioritiesArgs
|
||||||
|
> = {
|
||||||
|
name: "openproject_list_priorities",
|
||||||
|
description: "List all priority levels (Low, Normal, High, Immediate)",
|
||||||
|
parameters: listPrioritiesShape,
|
||||||
|
readOnly: true,
|
||||||
|
execute: handleListPriorities,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listUsersTool: ToolDefinition<
|
||||||
|
typeof listUsersShape,
|
||||||
|
ListUsersArgs
|
||||||
|
> = {
|
||||||
|
name: "openproject_list_users",
|
||||||
|
description: "List users in the OpenProject instance with optional pagination",
|
||||||
|
parameters: listUsersShape,
|
||||||
|
readOnly: true,
|
||||||
|
execute: handleListUsers,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const metadataTools = [
|
||||||
|
listTypesTool,
|
||||||
|
listStatusesTool,
|
||||||
|
listPrioritiesTool,
|
||||||
|
listUsersTool,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers all metadata tools with an McpServer instance.
|
||||||
|
*/
|
||||||
|
export function registerMetadataTools(server: McpServer): void {
|
||||||
|
registerTool(server, listTypesTool);
|
||||||
|
registerTool(server, listStatusesTool);
|
||||||
|
registerTool(server, listPrioritiesTool);
|
||||||
|
registerTool(server, listUsersTool);
|
||||||
|
}
|
||||||
@@ -29,6 +29,18 @@ import {
|
|||||||
queryTools,
|
queryTools,
|
||||||
registerQueryTools,
|
registerQueryTools,
|
||||||
} from "../src/tools/queries";
|
} from "../src/tools/queries";
|
||||||
|
import {
|
||||||
|
listTypesShape,
|
||||||
|
handleListTypes,
|
||||||
|
listStatusesShape,
|
||||||
|
handleListStatuses,
|
||||||
|
listPrioritiesShape,
|
||||||
|
handleListPriorities,
|
||||||
|
listUsersShape,
|
||||||
|
handleListUsers,
|
||||||
|
metadataTools,
|
||||||
|
registerMetadataTools,
|
||||||
|
} from "../src/tools/metadata";
|
||||||
import { OpenProjectNotFoundError } from "../src/client/errors";
|
import { OpenProjectNotFoundError } from "../src/client/errors";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -656,5 +668,266 @@ describe("Query Tools", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Metadata Tools", () => {
|
||||||
|
const dummyClient = {
|
||||||
|
get: async (path: string) => {
|
||||||
|
if (
|
||||||
|
path === "types" ||
|
||||||
|
path === "/api/v3/types" ||
|
||||||
|
path === "projects/4/types" ||
|
||||||
|
path === "projects/mcp-test-project/types"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
_embedded: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Task",
|
||||||
|
color: "#1a73e8",
|
||||||
|
isDefault: true,
|
||||||
|
isMilestone: false,
|
||||||
|
_links: { self: { href: "/api/v3/types/1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === "statuses" || path === "/api/v3/statuses") {
|
||||||
|
return {
|
||||||
|
_embedded: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "New",
|
||||||
|
isClosed: false,
|
||||||
|
isDefault: true,
|
||||||
|
color: "#34a853",
|
||||||
|
_links: { self: { href: "/api/v3/statuses/1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === "priorities" || path === "/api/v3/priorities") {
|
||||||
|
return {
|
||||||
|
_embedded: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Normal",
|
||||||
|
isDefault: true,
|
||||||
|
_links: { self: { href: "/api/v3/priorities/1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === "users" || path === "/api/v3/users") {
|
||||||
|
return {
|
||||||
|
_embedded: {
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "OpenProject Admin",
|
||||||
|
email: "[email protected]",
|
||||||
|
status: "active",
|
||||||
|
admin: true,
|
||||||
|
_links: { self: { href: "/api/v3/users/1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
total: 1,
|
||||||
|
count: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
offset: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { _embedded: { elements: [] }, total: 0, count: 0 };
|
||||||
|
},
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
|
||||||
|
test("listTypes returns types collection", async () => {
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListTypes({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
const result = JSON.parse(response.content[0]?.text ?? "{}");
|
||||||
|
expect(result.types).toHaveLength(1);
|
||||||
|
expect(result.types[0].name).toBe("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listTypes accepts string and numeric projectId", async () => {
|
||||||
|
const schema = z.object(listTypesShape);
|
||||||
|
const parsedNum = schema.parse({ projectId: 4 });
|
||||||
|
const responseNum = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListTypes(parsedNum)
|
||||||
|
);
|
||||||
|
expect(responseNum.isError).toBeUndefined();
|
||||||
|
const resultNum = JSON.parse(responseNum.content[0]?.text ?? "{}");
|
||||||
|
expect(resultNum.types).toHaveLength(1);
|
||||||
|
|
||||||
|
const parsedStr = schema.parse({ projectId: "mcp-test-project" });
|
||||||
|
const responseStr = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListTypes(parsedStr)
|
||||||
|
);
|
||||||
|
expect(responseStr.isError).toBeUndefined();
|
||||||
|
const resultStr = JSON.parse(responseStr.content[0]?.text ?? "{}");
|
||||||
|
expect(resultStr.types).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listTypes rejects invalid projectId schema arguments", () => {
|
||||||
|
const schema = z.object(listTypesShape);
|
||||||
|
expect(() => schema.parse({ projectId: 0 })).toThrow();
|
||||||
|
expect(() => schema.parse({ projectId: -1 })).toThrow();
|
||||||
|
expect(() => schema.parse({ projectId: "" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleListTypes catches errors and returns error response", async () => {
|
||||||
|
const failingClient = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("Types service failure");
|
||||||
|
},
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: failingClient, isReadOnly: false },
|
||||||
|
() => handleListTypes({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content[0]?.text).toContain("Types service failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listStatuses returns status items", async () => {
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListStatuses({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
const result = JSON.parse(response.content[0]?.text ?? "{}");
|
||||||
|
expect(result.statuses).toHaveLength(1);
|
||||||
|
expect(result.statuses[0].name).toBe("New");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleListStatuses catches errors and returns error response", async () => {
|
||||||
|
const failingClient = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("Statuses service failure");
|
||||||
|
},
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: failingClient, isReadOnly: false },
|
||||||
|
() => handleListStatuses({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content[0]?.text).toContain("Statuses service failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listPriorities returns priority items", async () => {
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListPriorities({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
const result = JSON.parse(response.content[0]?.text ?? "{}");
|
||||||
|
expect(result.priorities).toHaveLength(1);
|
||||||
|
expect(result.priorities[0].name).toBe("Normal");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleListPriorities catches errors and returns error response", async () => {
|
||||||
|
const failingClient = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("Priorities service failure");
|
||||||
|
},
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: failingClient, isReadOnly: false },
|
||||||
|
() => handleListPriorities({})
|
||||||
|
);
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content[0]?.text).toContain("Priorities service failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listUsers returns paginated user list", async () => {
|
||||||
|
const schema = z.object(listUsersShape);
|
||||||
|
const parsed = schema.parse({ pageSize: 10, offset: 1 });
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: dummyClient, isReadOnly: false },
|
||||||
|
() => handleListUsers(parsed)
|
||||||
|
);
|
||||||
|
expect(response.isError).toBeUndefined();
|
||||||
|
const result = JSON.parse(response.content[0]?.text ?? "{}");
|
||||||
|
expect(result.users).toHaveLength(1);
|
||||||
|
expect(result.users[0].name).toBe("OpenProject Admin");
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
expect(result.count).toBe(1);
|
||||||
|
expect(result.pageSize).toBe(20);
|
||||||
|
expect(result.offset).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listUsers rejects invalid schema arguments", () => {
|
||||||
|
const schema = z.object(listUsersShape);
|
||||||
|
expect(() => schema.parse({ pageSize: 0 })).toThrow();
|
||||||
|
expect(() => schema.parse({ pageSize: -1 })).toThrow();
|
||||||
|
expect(() => schema.parse({ pageSize: 500 })).toThrow();
|
||||||
|
expect(() => schema.parse({ offset: 0 })).toThrow();
|
||||||
|
expect(() => schema.parse({ offset: -1 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handleListUsers catches errors and returns error response", async () => {
|
||||||
|
const failingClient = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("Users service failure");
|
||||||
|
},
|
||||||
|
} as unknown as OpenProjectClient;
|
||||||
|
|
||||||
|
const response = await runWithContext(
|
||||||
|
{ client: failingClient, isReadOnly: false },
|
||||||
|
() => handleListUsers({ pageSize: 10 })
|
||||||
|
);
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content[0]?.text).toContain("Users service failure");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registerMetadataTools registers all 4 metadata tools", () => {
|
||||||
|
const server = new McpServer({ name: "test-mcp", version: "1.0.0" });
|
||||||
|
registerMetadataTools(server);
|
||||||
|
expect(metadataTools).toHaveLength(4);
|
||||||
|
expect(metadataTools.map((t) => t.name)).toEqual([
|
||||||
|
"openproject_list_types",
|
||||||
|
"openproject_list_statuses",
|
||||||
|
"openproject_list_priorities",
|
||||||
|
"openproject_list_users",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const registeredTools = (
|
||||||
|
server as unknown as {
|
||||||
|
_registeredTools: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
)._registeredTools;
|
||||||
|
|
||||||
|
expect(registeredTools["openproject_list_types"]).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_list_statuses"]).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_list_priorities"]).toBeDefined();
|
||||||
|
expect(registeredTools["openproject_list_users"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user