314 lines
10 KiB
JavaScript

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
// ============================================
// CONFIGURATION
// ============================================
const MCP_NAME = "basecamp";
const MCP_VERSION = "1.0.0";
// ============================================
// API CLIENT (OAuth 2.0)
// Basecamp 4 API uses: https://3.basecampapi.com/{account_id}/
// ============================================
class BasecampClient {
private accessToken: string;
private accountId: string;
private baseUrl: string;
private userAgent: string;
constructor(accessToken: string, accountId: string, appIdentity: string) {
this.accessToken = accessToken;
this.accountId = accountId;
this.baseUrl = `https://3.basecampapi.com/${accountId}`;
this.userAgent = appIdentity; // Required: "AppName (contact@email.com)"
}
async request(endpoint: string, options: RequestInit = {}) {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
"Authorization": `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
"User-Agent": this.userAgent,
...options.headers,
},
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Basecamp API error: ${response.status} - ${error}`);
}
const text = await response.text();
return text ? JSON.parse(text) : { success: true };
}
async get(endpoint: string) {
return this.request(endpoint, { method: "GET" });
}
async post(endpoint: string, data: any) {
return this.request(endpoint, {
method: "POST",
body: JSON.stringify(data),
});
}
async put(endpoint: string, data: any) {
return this.request(endpoint, {
method: "PUT",
body: JSON.stringify(data),
});
}
}
// ============================================
// TOOL DEFINITIONS
// ============================================
const tools = [
{
name: "list_projects",
description: "List all projects in the Basecamp account",
inputSchema: {
type: "object" as const,
properties: {
status: {
type: "string",
enum: ["active", "archived", "trashed"],
description: "Filter by project status (default: active)"
},
},
},
},
{
name: "get_project",
description: "Get details of a specific project including its dock (tools)",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
},
required: ["project_id"],
},
},
{
name: "list_todos",
description: "List to-dos from a to-do list in a project",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
todolist_id: { type: "number", description: "To-do list ID (required)" },
status: {
type: "string",
enum: ["active", "archived", "trashed"],
description: "Filter by status"
},
completed: { type: "boolean", description: "Filter by completion (true=completed, false=pending)" },
},
required: ["project_id", "todolist_id"],
},
},
{
name: "create_todo",
description: "Create a new to-do in a to-do list",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
todolist_id: { type: "number", description: "To-do list ID (required)" },
content: { type: "string", description: "To-do content/title (required)" },
description: { type: "string", description: "Rich text description (HTML)" },
assignee_ids: {
type: "array",
items: { type: "number" },
description: "Array of person IDs to assign"
},
due_on: { type: "string", description: "Due date (YYYY-MM-DD)" },
starts_on: { type: "string", description: "Start date (YYYY-MM-DD)" },
notify: { type: "boolean", description: "Notify assignees (default: false)" },
},
required: ["project_id", "todolist_id", "content"],
},
},
{
name: "complete_todo",
description: "Mark a to-do as complete",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
todo_id: { type: "number", description: "To-do ID (required)" },
},
required: ["project_id", "todo_id"],
},
},
{
name: "list_messages",
description: "List messages from a project's message board",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
message_board_id: { type: "number", description: "Message board ID (required, get from project dock)" },
},
required: ["project_id", "message_board_id"],
},
},
{
name: "create_message",
description: "Create a new message on a project's message board",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (required)" },
message_board_id: { type: "number", description: "Message board ID (required)" },
subject: { type: "string", description: "Message subject (required)" },
content: { type: "string", description: "Message content in HTML (required)" },
status: {
type: "string",
enum: ["active", "draft"],
description: "Post status (default: active)"
},
category_id: { type: "number", description: "Message type/category ID" },
},
required: ["project_id", "message_board_id", "subject", "content"],
},
},
{
name: "list_people",
description: "List all people in the Basecamp account or a specific project",
inputSchema: {
type: "object" as const,
properties: {
project_id: { type: "number", description: "Project ID (optional - if provided, lists project members only)" },
},
},
},
];
// ============================================
// TOOL HANDLERS
// ============================================
async function handleTool(client: BasecampClient, name: string, args: any) {
switch (name) {
case "list_projects": {
let endpoint = "/projects.json";
if (args.status === "archived") {
endpoint = "/projects/archive.json";
} else if (args.status === "trashed") {
endpoint = "/projects/trash.json";
}
return await client.get(endpoint);
}
case "get_project": {
const { project_id } = args;
return await client.get(`/projects/${project_id}.json`);
}
case "list_todos": {
const { project_id, todolist_id, completed } = args;
let endpoint = `/buckets/${project_id}/todolists/${todolist_id}/todos.json`;
if (completed === true) {
endpoint += "?completed=true";
}
return await client.get(endpoint);
}
case "create_todo": {
const { project_id, todolist_id, content, description, assignee_ids, due_on, starts_on, notify } = args;
const payload: any = { content };
if (description) payload.description = description;
if (assignee_ids) payload.assignee_ids = assignee_ids;
if (due_on) payload.due_on = due_on;
if (starts_on) payload.starts_on = starts_on;
if (notify !== undefined) payload.notify = notify;
return await client.post(`/buckets/${project_id}/todolists/${todolist_id}/todos.json`, payload);
}
case "complete_todo": {
const { project_id, todo_id } = args;
return await client.post(`/buckets/${project_id}/todos/${todo_id}/completion.json`, {});
}
case "list_messages": {
const { project_id, message_board_id } = args;
return await client.get(`/buckets/${project_id}/message_boards/${message_board_id}/messages.json`);
}
case "create_message": {
const { project_id, message_board_id, subject, content, status, category_id } = args;
const payload: any = { subject, content };
if (status) payload.status = status;
if (category_id) payload.category_id = category_id;
return await client.post(`/buckets/${project_id}/message_boards/${message_board_id}/messages.json`, payload);
}
case "list_people": {
const { project_id } = args;
if (project_id) {
return await client.get(`/projects/${project_id}/people.json`);
}
return await client.get("/people.json");
}
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// ============================================
// SERVER SETUP
// ============================================
async function main() {
const accessToken = process.env.BASECAMP_ACCESS_TOKEN;
const accountId = process.env.BASECAMP_ACCOUNT_ID;
const appIdentity = process.env.BASECAMP_APP_IDENTITY || "MCPServer (mcp@example.com)";
if (!accessToken) {
console.error("Error: BASECAMP_ACCESS_TOKEN environment variable required");
console.error("Obtain via OAuth 2.0 flow: https://github.com/basecamp/api/blob/master/sections/authentication.md");
process.exit(1);
}
if (!accountId) {
console.error("Error: BASECAMP_ACCOUNT_ID environment variable required");
console.error("Find your account ID in the Basecamp URL: https://3.basecamp.com/{ACCOUNT_ID}/");
process.exit(1);
}
const client = new BasecampClient(accessToken, accountId, appIdentity);
const server = new Server(
{ name: `${MCP_NAME}-mcp`, version: MCP_VERSION },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await handleTool(client, name, args || {});
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `Error: ${message}` }],
isError: true,
};
}
});
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`${MCP_NAME} MCP server running on stdio`);
}
main().catch(console.error);