314 lines
10 KiB
JavaScript
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);
|