#!/usr/bin/env bun
/**
* OpenProject MCP Server Setup
* Shared server configuration for both STDIO and HTTP transports
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { createClient, type OpenProjectClient } from './openproject-client.ts';
import logger from './logger.ts';
export interface ServerConfig {
name?: string;
version?: string;
}
// Helper to safely stringify responses
export function formatResponse(data: unknown): string {
return JSON.stringify(data, null, 2);
}
// Helper to create API links
export function createLink(type: string, id: number | string): string {
return `/api/v3/${type}/${id}`;
}
export async function resolveProjectId(client: OpenProjectClient, projectRef: number | string): Promise<number> {
if (typeof projectRef === 'number') {
return projectRef;
}
const numericId = Number(projectRef);
if (!Number.isNaN(numericId)) {
return numericId;
}
const project = await client.getProject(projectRef);
return project.id;
}
export function buildProjectMembershipFilter(projectId: number): string {
return JSON.stringify([
{
project: {
operator: '=',
values: [String(projectId)],
},
},
]);
}
export function extractResourceId(href: string, resource: string): number | null {
const regex = new RegExp(`/${resource}/(\\d+)(?:/|$)`);
const match = href.match(regex);
return match ? Number(match[1]) : null;
}
// Helper to wrap tool handlers with logging
function wrapToolHandler<T extends z.ZodTypeAny>(
client: OpenProjectClient,
toolName: string,
handler: (params: z.infer<T>) => Promise<any>
): (params: z.infer<T>) => Promise<any> {
return async (params: z.infer<T>) => {
const caller = `tool:${toolName}`;
// Log tool invocation
logger.logToolInvocation(caller, toolName, params);
// Set caller in client for API logging
client.setCaller(caller);
try {
const result = await handler(params);
// Log successful tool result
logger.logToolResult(caller, toolName, true, result);
return result;
} catch (error) {
// Log failed tool result
logger.logToolResult(caller, toolName, false, undefined, error as Error);
throw error;
}
};
}
export function setupMcpServer(config: ServerConfig = {}): { server: McpServer; initClient: () => Promise<OpenProjectClient> } {
const server = new McpServer({
name: config.name || 'openproject-mcp',
version: config.version || '1.0.0',
});
let client: OpenProjectClient;
const initClient = async (): Promise<OpenProjectClient> => {
client = createClient('system');
return client;
};
// ============== Root & Configuration Tools ==============
server.tool(
'get_api_root',
'Get the OpenProject API root information',
{},
async () => {
const toolName = 'get_api_root';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.getRoot();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_configuration',
'Get the OpenProject instance configuration',
{},
async () => {
const toolName = 'get_configuration';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.getConfiguration();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Project Tools ==============
server.tool(
'list_projects',
'List all projects accessible to the current user',
{
offset: z.number().optional().describe('Page offset for pagination (default: 0)'),
pageSize: z.number().optional().describe('Number of items per page (default: 20, max: 1000)'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
},
async (params) => {
const toolName = 'list_projects';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listProjects(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_project',
'Get details of a specific project',
{
id: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
},
async ({ id }) => {
const toolName = 'get_project';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getProject(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'create_project',
'Create a new project',
{
name: z.string().describe('Name of the project'),
identifier: z.string().optional().describe('Unique identifier (auto-generated if not provided)'),
description: z.string().optional().describe('Project description'),
public: z.boolean().optional().describe('Whether the project is public (default: false)'),
status: z.enum(['on_track', 'at_risk', 'off_track', 'not_set']).optional().describe('Project status'),
statusExplanation: z.string().optional().describe('Explanation for the project status'),
parentId: z.number().optional().describe('Parent project ID'),
},
async ({ name, identifier, description, public: isPublic, status, statusExplanation, parentId }) => {
const toolName = 'create_project';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { name, identifier, description, public: isPublic, status, statusExplanation, parentId });
client.setCaller(caller);
try {
const data: Parameters<typeof client.createProject>[0] = {
name,
identifier,
public: isPublic,
status,
};
if (description) data.description = { raw: description };
if (statusExplanation) data.statusExplanation = { raw: statusExplanation };
if (parentId) data.parent = { href: createLink('projects', parentId) };
const result = await client.createProject(data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'update_project',
'Update an existing project',
{
id: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
name: z.string().optional().describe('New name for the project'),
description: z.string().optional().describe('New project description'),
public: z.boolean().optional().describe('Whether the project is public'),
active: z.boolean().optional().describe('Whether the project is active'),
status: z.enum(['on_track', 'at_risk', 'off_track', 'not_set']).optional().describe('Project status'),
statusExplanation: z.string().optional().describe('Explanation for the project status'),
},
async ({ id, name, description, public: isPublic, active, status, statusExplanation }) => {
const toolName = 'update_project';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id, name, description, public: isPublic, active, status, statusExplanation });
client.setCaller(caller);
try {
const data: Parameters<typeof client.updateProject>[1] = {
name,
public: isPublic,
active,
status,
};
if (description !== undefined) data.description = { raw: description };
if (statusExplanation !== undefined) data.statusExplanation = { raw: statusExplanation };
const result = await client.updateProject(id, data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'delete_project',
'Delete a project (requires confirmation)',
{
id: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
},
async ({ id }) => {
const toolName = 'delete_project';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
await client.deleteProject(id);
return { content: [{ type: 'text', text: `Project ${id} deleted successfully` }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Work Package Tools ==============
server.tool(
'list_work_packages',
'List all work packages with optional filtering',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
groupBy: z.string().optional().describe('Group by attribute'),
query_id: z.number().optional().describe('Query ID to apply a saved query/filter'),
},
async (params) => {
const toolName = 'list_work_packages';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listWorkPackages(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_project_work_packages',
'List work packages in a specific project',
{
projectId: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
query_id: z.number().optional().describe('Query ID to apply a saved query/filter'),
},
async ({ projectId, ...params }) => {
const toolName = 'list_project_work_packages';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId, ...params });
client.setCaller(caller);
try {
const result = await client.listProjectWorkPackages(projectId, params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_work_package',
'Get details of a specific work package',
{
id: z.number().describe('Work package ID'),
},
async ({ id }) => {
const toolName = 'get_work_package';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getWorkPackage(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'create_work_package',
'Create a new work package in a project',
{
projectId: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
subject: z.string().describe('Subject/title of the work package'),
description: z.string().optional().describe('Detailed description (supports markdown)'),
typeId: z.number().optional().describe('Work package type ID'),
statusId: z.number().optional().describe('Status ID'),
priorityId: z.number().optional().describe('Priority ID'),
assigneeId: z.number().optional().describe('Assignee user ID'),
responsibleId: z.number().optional().describe('Responsible user ID'),
versionId: z.number().optional().describe('Version/milestone ID'),
parentId: z.number().optional().describe('Parent work package ID'),
startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'),
dueDate: z.string().optional().describe('Due date (YYYY-MM-DD)'),
estimatedTime: z.string().optional().describe('Estimated time (ISO 8601 duration, e.g., PT8H)'),
percentageDone: z.number().min(0).max(100).optional().describe('Completion percentage (0-100)'),
notify: z.boolean().optional().describe('Send notifications (default: true)'),
},
async ({ projectId, subject, description, typeId, statusId, priorityId, assigneeId, responsibleId, versionId, parentId, startDate, dueDate, estimatedTime, percentageDone, notify }) => {
const toolName = 'create_work_package';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId, subject, description, typeId, statusId, priorityId, assigneeId, responsibleId, versionId, parentId, startDate, dueDate, estimatedTime, percentageDone, notify });
client.setCaller(caller);
try {
const _links: NonNullable<Parameters<typeof client.createWorkPackage>[1]['_links']> = {};
if (typeId) _links.type = { href: createLink('types', typeId) };
if (statusId) _links.status = { href: createLink('statuses', statusId) };
if (priorityId) _links.priority = { href: createLink('priorities', priorityId) };
if (assigneeId) _links.assignee = { href: createLink('users', assigneeId) };
if (responsibleId) _links.responsible = { href: createLink('users', responsibleId) };
if (versionId) _links.version = { href: createLink('versions', versionId) };
if (parentId) _links.parent = { href: createLink('work_packages', parentId) };
const data: Parameters<typeof client.createWorkPackage>[1] = {
subject,
_links: Object.keys(_links).length > 0 ? _links : undefined,
startDate,
dueDate,
estimatedTime,
percentageDone,
};
if (description) data.description = { raw: description };
const result = await client.createWorkPackage(projectId, data, notify);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'update_work_package',
'Update an existing work package',
{
id: z.number().describe('Work package ID'),
lockVersion: z.number().describe('Current lock version (for optimistic locking)'),
subject: z.string().optional().describe('New subject/title'),
description: z.string().optional().describe('New description'),
typeId: z.number().optional().describe('New type ID'),
statusId: z.number().optional().describe('New status ID'),
priorityId: z.number().optional().describe('New priority ID'),
assigneeId: z.number().optional().describe('New assignee user ID'),
responsibleId: z.number().optional().describe('New responsible user ID'),
versionId: z.number().optional().describe('New version ID'),
parentId: z.number().optional().describe('New parent work package ID'),
startDate: z.string().optional().describe('New start date (YYYY-MM-DD)'),
dueDate: z.string().optional().describe('New due date (YYYY-MM-DD)'),
estimatedTime: z.string().optional().describe('New estimated time'),
percentageDone: z.number().min(0).max(100).optional().describe('New completion percentage'),
notify: z.boolean().optional().describe('Send notifications'),
},
async ({ id, lockVersion, subject, description, typeId, statusId, priorityId, assigneeId, responsibleId, versionId, parentId, startDate, dueDate, estimatedTime, percentageDone, notify }) => {
const toolName = 'update_work_package';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id, lockVersion, subject, description, typeId, statusId, priorityId, assigneeId, responsibleId, versionId, parentId, startDate, dueDate, estimatedTime, percentageDone, notify });
client.setCaller(caller);
try {
const _links: NonNullable<Parameters<typeof client.updateWorkPackage>[1]['_links']> = {};
if (typeId) _links.type = { href: createLink('types', typeId) };
if (statusId) _links.status = { href: createLink('statuses', statusId) };
if (priorityId) _links.priority = { href: createLink('priorities', priorityId) };
if (assigneeId) _links.assignee = { href: createLink('users', assigneeId) };
if (responsibleId) _links.responsible = { href: createLink('users', responsibleId) };
if (versionId) _links.version = { href: createLink('versions', versionId) };
if (parentId) _links.parent = { href: createLink('work_packages', parentId) };
const data: Parameters<typeof client.updateWorkPackage>[1] = {
lockVersion,
subject,
_links: Object.keys(_links).length > 0 ? _links : undefined,
startDate,
dueDate,
estimatedTime,
percentageDone,
};
if (description !== undefined) data.description = { raw: description };
const result = await client.updateWorkPackage(id, data, notify);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'delete_work_package',
'Delete a work package',
{
id: z.number().describe('Work package ID'),
},
async ({ id }) => {
const toolName = 'delete_work_package';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
await client.deleteWorkPackage(id);
return { content: [{ type: 'text', text: `Work package ${id} deleted successfully` }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_work_package_activities',
'List activities/journal entries for a work package',
{
id: z.number().describe('Work package ID'),
},
async ({ id }) => {
const toolName = 'list_work_package_activities';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.listWorkPackageActivities(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== User Tools ==============
server.tool(
'list_users',
'List all users (administrator only)',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
},
async (params) => {
const toolName = 'list_users';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const currentUser = await client.getCurrentUser();
if (!currentUser.admin) {
return {
content: [{ type: 'text', text: 'Error: list_users requires administrator privileges' }],
isError: true,
};
}
const result = await client.listUsers(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_user',
'Get details of a specific user',
{
id: z.union([z.number(), z.string()]).describe('User ID or "me" for current user'),
},
async ({ id }) => {
const toolName = 'get_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = id === 'me' ? await client.getCurrentUser() : await client.getUser(id);
return { content: [{ type: 'text', text: formatResponse(result) }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_current_user',
'Get the currently authenticated user',
{},
async () => {
const toolName = 'get_current_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.getCurrentUser();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'create_user',
'Create a new user (admin only)',
{
login: z.string().describe('Login username'),
email: z.string().email().describe('Email address'),
firstName: z.string().describe('First name'),
lastName: z.string().describe('Last name'),
admin: z.boolean().optional().describe('Whether user is admin'),
language: z.string().optional().describe('Preferred language'),
password: z.string().optional().describe('Initial password'),
},
async (params) => {
const toolName = 'create_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.createUser(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'update_user',
'Update an existing user',
{
id: z.number().describe('User ID'),
login: z.string().optional().describe('New login username'),
email: z.string().email().optional().describe('New email address'),
firstName: z.string().optional().describe('New first name'),
lastName: z.string().optional().describe('New last name'),
admin: z.boolean().optional().describe('Admin status'),
language: z.string().optional().describe('Preferred language'),
},
async ({ id, ...data }) => {
const toolName = 'update_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id, ...data });
client.setCaller(caller);
try {
const result = await client.updateUser(id, data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'delete_user',
'Delete a user (admin only)',
{
id: z.number().describe('User ID'),
},
async ({ id }) => {
const toolName = 'delete_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
await client.deleteUser(id);
return { content: [{ type: 'text', text: `User ${id} deleted successfully` }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'lock_user',
'Lock a user account',
{
id: z.number().describe('User ID'),
},
async ({ id }) => {
const toolName = 'lock_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.lockUser(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'unlock_user',
'Unlock a user account',
{
id: z.number().describe('User ID'),
},
async ({ id }) => {
const toolName = 'unlock_user';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.unlockUser(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Membership Tools ==============
server.tool(
'list_memberships',
'List project memberships (users/groups assigned to projects)',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
},
async (params) => {
const toolName = 'list_memberships';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listMemberships(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_project_members',
'List members who belong to a specific project',
{
projectId: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
},
async ({ projectId, offset, pageSize }) => {
const toolName = 'list_project_members';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId, offset, pageSize });
client.setCaller(caller);
try {
const resolvedProjectId = await resolveProjectId(client, projectId);
const filters = buildProjectMembershipFilter(resolvedProjectId);
const result = await client.listMemberships({ offset, pageSize, filters });
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_work_package_members',
'List members of the project that owns a work package',
{
workPackageId: z.number().describe('Work package ID'),
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
},
async ({ workPackageId, offset, pageSize }) => {
const toolName = 'list_work_package_members';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { workPackageId, offset, pageSize });
client.setCaller(caller);
try {
const workPackage = await client.getWorkPackage(workPackageId);
const projectHref = workPackage._links?.project?.href;
if (!projectHref) {
throw new Error(`Work package ${workPackageId} does not reference a project.`);
}
const projectId = extractResourceId(projectHref, 'projects');
if (projectId === null) {
throw new Error(`Unable to extract project ID from link: ${projectHref}`);
}
const filters = buildProjectMembershipFilter(projectId);
const memberships = await client.listMemberships({ offset, pageSize, filters });
const response = {
workPackageId,
projectId,
projectHref,
memberships,
};
return { content: [{ type: 'text', text: formatResponse(response) }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Type Tools ==============
server.tool(
'list_types',
'List all work package types',
{},
async () => {
const toolName = 'list_types';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.listTypes();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_type',
'Get details of a specific work package type',
{
id: z.number().describe('Type ID'),
},
async ({ id }) => {
const toolName = 'get_type';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getType(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_project_types',
'List types available in a specific project',
{
projectId: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
},
async ({ projectId }) => {
const toolName = 'list_project_types';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId });
client.setCaller(caller);
try {
const result = await client.listProjectTypes(projectId);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Status Tools ==============
server.tool(
'list_statuses',
'List all work package statuses',
{},
async () => {
const toolName = 'list_statuses';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.listStatuses();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_status',
'Get details of a specific status',
{
id: z.number().describe('Status ID'),
},
async ({ id }) => {
const toolName = 'get_status';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getStatus(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Priority Tools ==============
server.tool(
'list_priorities',
'List all priorities',
{},
async () => {
const toolName = 'list_priorities';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, {});
client.setCaller(caller);
try {
const result = await client.listPriorities();
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_priority',
'Get details of a specific priority',
{
id: z.number().describe('Priority ID'),
},
async ({ id }) => {
const toolName = 'get_priority';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getPriority(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Time Entry Tools ==============
server.tool(
'list_time_entries',
'List all time entries',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
sortBy: z.string().optional().describe('Sort criteria as JSON array'),
},
async (params) => {
const toolName = 'list_time_entries';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listTimeEntries(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_time_entry',
'Get details of a specific time entry',
{
id: z.number().describe('Time entry ID'),
},
async ({ id }) => {
const toolName = 'get_time_entry';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getTimeEntry(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'create_time_entry',
'Create a new time entry',
{
projectId: z.number().describe('Project ID'),
workPackageId: z.number().optional().describe('Work package ID'),
activityId: z.number().describe('Activity ID'),
hours: z.string().describe('Hours spent (ISO 8601 duration, e.g., PT8H30M)'),
spentOn: z.string().describe('Date spent on (YYYY-MM-DD)'),
comment: z.string().optional().describe('Comment for the time entry'),
},
async ({ projectId, workPackageId, activityId, hours, spentOn, comment }) => {
const toolName = 'create_time_entry';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId, workPackageId, activityId, hours, spentOn, comment });
client.setCaller(caller);
try {
const data: Parameters<typeof client.createTimeEntry>[0] = {
_links: {
project: { href: createLink('projects', projectId) },
activity: { href: createLink('time_entries/activities', activityId) },
},
hours,
spentOn,
};
if (workPackageId) data._links.workPackage = { href: createLink('work_packages', workPackageId) };
if (comment) data.comment = { raw: comment };
const result = await client.createTimeEntry(data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'update_time_entry',
'Update an existing time entry',
{
id: z.number().describe('Time entry ID'),
activityId: z.number().optional().describe('New activity ID'),
hours: z.string().optional().describe('New hours'),
spentOn: z.string().optional().describe('New date'),
comment: z.string().optional().describe('New comment'),
},
async ({ id, activityId, hours, spentOn, comment }) => {
const toolName = 'update_time_entry';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id, activityId, hours, spentOn, comment });
client.setCaller(caller);
try {
const data: Parameters<typeof client.updateTimeEntry>[1] = {
hours,
spentOn,
};
if (activityId) data._links = { activity: { href: createLink('time_entries/activities', activityId) } };
if (comment !== undefined) data.comment = { raw: comment };
const result = await client.updateTimeEntry(id, data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'delete_time_entry',
'Delete a time entry',
{
id: z.number().describe('Time entry ID'),
},
async ({ id }) => {
const toolName = 'delete_time_entry';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
await client.deleteTimeEntry(id);
return { content: [{ type: 'text', text: `Time entry ${id} deleted successfully` }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Version Tools ==============
server.tool(
'list_versions',
'List all versions/milestones',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
},
async (params) => {
const toolName = 'list_versions';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listVersions(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'get_version',
'Get details of a specific version',
{
id: z.number().describe('Version ID'),
},
async ({ id }) => {
const toolName = 'get_version';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getVersion(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'list_project_versions',
'List versions in a specific project',
{
projectId: z.union([z.number(), z.string()]).describe('Project ID or identifier'),
},
async ({ projectId }) => {
const toolName = 'list_project_versions';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { projectId });
client.setCaller(caller);
try {
const result = await client.listProjectVersions(projectId);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'create_version',
'Create a new version/milestone',
{
name: z.string().describe('Version name'),
projectId: z.number().describe('Defining project ID'),
description: z.string().optional().describe('Version description'),
startDate: z.string().optional().describe('Start date (YYYY-MM-DD)'),
endDate: z.string().optional().describe('End date (YYYY-MM-DD)'),
status: z.enum(['open', 'locked', 'closed']).optional().describe('Version status'),
sharing: z.enum(['none', 'descendants', 'hierarchy', 'tree', 'system']).optional().describe('Sharing scope'),
},
async ({ name, projectId, description, startDate, endDate, status, sharing }) => {
const toolName = 'create_version';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { name, projectId, description, startDate, endDate, status, sharing });
client.setCaller(caller);
try {
const data: Parameters<typeof client.createVersion>[0] = {
name,
_links: {
definingProject: { href: createLink('projects', projectId) },
},
startDate,
endDate,
status,
sharing,
};
if (description) data.description = { raw: description };
const result = await client.createVersion(data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'update_version',
'Update an existing version',
{
id: z.number().describe('Version ID'),
name: z.string().optional().describe('New version name'),
description: z.string().optional().describe('New description'),
startDate: z.string().optional().describe('New start date'),
endDate: z.string().optional().describe('New end date'),
status: z.enum(['open', 'locked', 'closed']).optional().describe('New status'),
sharing: z.enum(['none', 'descendants', 'hierarchy', 'tree', 'system']).optional().describe('New sharing scope'),
},
async ({ id, name, description, startDate, endDate, status, sharing }) => {
const toolName = 'update_version';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id, name, description, startDate, endDate, status, sharing });
client.setCaller(caller);
try {
const data: Parameters<typeof client.updateVersion>[1] = {
name,
startDate,
endDate,
status,
sharing,
};
if (description !== undefined) data.description = { raw: description };
const result = await client.updateVersion(id, data);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
server.tool(
'delete_version',
'Delete a version',
{
id: z.number().describe('Version ID'),
},
async ({ id }) => {
const toolName = 'delete_version';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
await client.deleteVersion(id);
return { content: [{ type: 'text', text: `Version ${id} deleted successfully` }] };
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Activity Tools ==============
server.tool(
'get_activity',
'Get details of a specific activity/journal entry',
{
id: z.number().describe('Activity ID'),
},
async ({ id }) => {
const toolName = 'get_activity';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, { id });
client.setCaller(caller);
try {
const result = await client.getActivity(id);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
// ============== Principal Tools ==============
server.tool(
'list_principals',
'List all principals (users, groups, placeholder users)',
{
offset: z.number().optional().describe('Page offset for pagination'),
pageSize: z.number().optional().describe('Number of items per page'),
filters: z.string().optional().describe('JSON filter expression'),
},
async (params) => {
const toolName = 'list_principals';
const caller = `tool:${toolName}`;
logger.logToolInvocation(caller, toolName, params);
client.setCaller(caller);
try {
const result = await client.listPrincipals(params);
const response = { content: [{ type: 'text', text: formatResponse(result) }] };
logger.logToolResult(caller, toolName, true, result);
return response;
} catch (error) {
logger.logToolResult(caller, toolName, false, undefined, error as Error);
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
}
}
);
return { server, initClient };
}