Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"WebFetch(domain:www.redmine.org)",
"Bash(npm run build:*)",
"Bash(npm run lint:*)",
"Bash(npm test:*)",
"WebSearch",
"Bash(dir:*)",
"Bash(findstr:*)",
"Bash(mkdir:*)"
],
"deny": [],
"ask": []
}
}
69 changes: 69 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Build and Development Commands

```bash
# Install dependencies
npm install

# Build TypeScript to dist/
npm run build

# Run in development mode (with watch)
npm run dev

# Start production server
npm start

# Run tests
npm test

# Run linting
npm run lint
```

## Environment Variables

Required environment variables for development and testing:
- `REDMINE_API_KEY`: API key from Redmine user settings
- `REDMINE_HOST`: Redmine server URL (e.g., `https://redmine.example.com`)

## Architecture Overview

This is an MCP (Model Context Protocol) server that integrates with Redmine's REST API. The server exposes Redmine functionality as MCP tools that can be called by LLMs.

### Core Flow

1. **Entry Point** (`src/index.ts`): Bootstraps the server via `runServer()`
2. **Server Setup** (`src/handlers/index.ts`): Creates the MCP server, registers all tools, and handles tool execution routing
3. **Tool Execution**: Incoming tool calls are routed through a handler map to resource-specific handlers

### Module Structure

**Tools** (`src/tools/`): Define MCP tool schemas with names, descriptions, and input validation. Each file exports `*_TOOL` constants (e.g., `ISSUE_LIST_TOOL`, `PROJECT_CREATE_TOOL`). Tools are automatically discovered and registered.

**Handlers** (`src/handlers/`): Implement tool logic. Each resource has a `create*Handlers()` function that returns an object mapping tool names to handler functions. Handlers receive a `HandlerContext` with the Redmine client, config, and logger.

**Client** (`src/lib/client/`): Wraps Redmine REST API calls. `BaseClient` provides `performRequest()` for HTTP operations and `encodeQueryParams()` for query string building. Resource-specific clients extend `BaseClient`.

**Types** (`src/lib/types/`): TypeScript types and Zod schemas for each resource domain (issues, projects, time_entries, users). Each has:
- `types.ts`: TypeScript interfaces
- `schema.ts`: Zod validation schemas
- `index.ts`: Re-exports

**Formatters** (`src/formatters/`): Convert Redmine API responses to human-readable text for MCP tool responses.

**Config** (`src/lib/config.ts`): Loads and validates environment variables using Zod.

### Adding a New Tool

1. Add tool definition in `src/tools/<resource>.ts` (export `*_TOOL` constant)
2. Add export in `src/tools/index.ts`
3. Implement handler in `src/handlers/<resource>.ts`
4. If new resource: create client in `src/lib/client/`, types in `src/lib/types/`, formatter in `src/formatters/`

### Testing

Tests use Jest with `node-fetch` mocks. Test files are located in `__tests__/` directories adjacent to the code they test. Only GET operations are included in tests for data safety.
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

110 changes: 110 additions & 0 deletions src/formatters/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type {
RedmineAttachment,
RedmineUploadResponse,
} from "../lib/types/attachments/index.js";

/**
* Escape XML special characters
*/
function escapeXml(unsafe: string | null | undefined): string {
if (unsafe === null || unsafe === undefined) {
return "";
}
return unsafe
.replace(/[&]/g, "&amp;")
.replace(/[<]/g, "&lt;")
.replace(/[>]/g, "&gt;")
.replace(/["]/g, "&quot;")
.replace(/[']/g, "&apos;");
}

/**
* Format file size to human readable format
*/
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}

/**
* Format a single attachment
*/
export function formatAttachment(attachment: RedmineAttachment): string {
return `<attachment>
<id>${attachment.id}</id>
<filename>${escapeXml(attachment.filename)}</filename>
<filesize>${formatFileSize(attachment.filesize)}</filesize>
<content_type>${escapeXml(attachment.content_type)}</content_type>
${attachment.description ? `<description>${escapeXml(attachment.description)}</description>` : ""}
<author id="${attachment.author.id}">${escapeXml(attachment.author.name)}</author>
<created_on>${attachment.created_on}</created_on>
<content_url>${escapeXml(attachment.content_url)}</content_url>
${attachment.thumbnail_url ? `<thumbnail_url>${escapeXml(attachment.thumbnail_url)}</thumbnail_url>` : ""}
</attachment>`;
}

/**
* Format list of attachments
*/
export function formatAttachments(attachments: RedmineAttachment[]): string {
if (!attachments || attachments.length === 0) {
return `<?xml version="1.0" encoding="UTF-8"?>
<attachments type="array" count="0" />`;
}

const formatted = attachments.map(formatAttachment).join("\n");

return `<?xml version="1.0" encoding="UTF-8"?>
<attachments type="array" count="${attachments.length}">
${formatted}
</attachments>`;
}

/**
* Format upload response
*/
export function formatUploadResponse(
response: RedmineUploadResponse,
filename: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<upload>
<status>success</status>
<message>File "${escapeXml(filename)}" uploaded successfully</message>
<token>${escapeXml(response.upload.token)}</token>
<usage>Use this token with create_issue or update_issue to attach the file:
{
"uploads": [
{
"token": "${escapeXml(response.upload.token)}",
"filename": "${escapeXml(filename)}",
"content_type": "application/octet-stream"
}
]
}
</usage>
</upload>`;
}

/**
* Format download response
*/
export function formatDownloadResponse(
attachment: RedmineAttachment,
base64Content: string
): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<download>
<status>success</status>
<attachment>
<id>${attachment.id}</id>
<filename>${escapeXml(attachment.filename)}</filename>
<filesize>${formatFileSize(attachment.filesize)}</filesize>
<content_type>${escapeXml(attachment.content_type)}</content_type>
</attachment>
<content_base64>${base64Content}</content_base64>
</download>`;
}
50 changes: 50 additions & 0 deletions src/formatters/enumerations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { RedmineEnumeration } from "../lib/types/index.js";

/**
* Format issue priorities list
*/
export function formatIssuePriorities(priorities: RedmineEnumeration[]): string {
return formatEnumerations(priorities, "issue_priorities", "priority");
}

/**
* Format time entry activities list
*/
export function formatTimeEntryActivities(activities: RedmineEnumeration[]): string {
return formatEnumerations(activities, "time_entry_activities", "activity");
}

/**
* Format document categories list
*/
export function formatDocumentCategories(categories: RedmineEnumeration[]): string {
return formatEnumerations(categories, "document_categories", "category");
}

/**
* Generic enumeration formatter
*/
function formatEnumerations(
items: RedmineEnumeration[],
rootTag: string,
itemTag: string
): string {
if (items.length === 0) {
return `<${rootTag}>No items found</${rootTag}>`;
}

const lines = items.map((item) => {
return ` <${itemTag} id="${item.id}" is_default="${item.is_default}">${escapeXml(item.name)}</${itemTag}>`;
});

return `<${rootTag} count="${items.length}">\n${lines.join("\n")}\n</${rootTag}>`;
}

function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
43 changes: 42 additions & 1 deletion src/formatters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,45 @@ export {
formatUsers,
formatUserResult,
formatUserDeleted,
} from "./users.js";
} from "./users.js";

export { formatIssueStatuses } from "./issue_statuses.js";

export { formatTrackers } from "./trackers.js";

export {
formatIssuePriorities,
formatTimeEntryActivities,
formatDocumentCategories,
} from "./enumerations.js";

export {
formatVersions,
formatVersion,
formatVersionResult,
formatVersionDeleted,
} from "./versions.js";

export {
formatMemberships,
formatMembership,
formatMembershipResult,
formatMembershipDeleted,
formatMembershipUpdated,
} from "./memberships.js";

export { formatRoles, formatRole } from "./roles.js";

export {
formatIssueCategories,
formatIssueCategory,
formatIssueCategoryResult,
formatIssueCategoryDeleted,
} from "./issue_categories.js";

export {
formatAttachment,
formatAttachments,
formatUploadResponse,
formatDownloadResponse,
} from "./attachments.js";
Loading