// src/tools/projects.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { bcRequestWithHeaders, parseNextPage } from "../lib/basecamp.js";
export function registerProjectsTools(server: McpServer) {
server.registerTool(
"list_projects",
{
title: "List Basecamp projects (rich)",
description:
"Returns id, name, status, timestamps, purpose, clients_enabled, bookmarked, and enabled dock tools. Supports pagination and JSON output.",
inputSchema: {
limit: z.number().int().positive().max(200).optional(),
page: z.number().int().positive().optional(),
include_archived: z.boolean().optional(),
include_dock: z.boolean().optional(),
dock_detail: z.boolean().optional(), // NEW: include raw dock
format: z.enum(["table", "json"]).optional(),
},
},
async (args) => {
const {
limit,
page,
include_archived,
include_dock,
dock_detail,
format,
} = args;
const wantDock = include_dock ?? true;
const fmt = format ?? "table";
const { data, headers } = await bcRequestWithHeaders<any[]>(
"GET",
"/projects.json",
undefined,
page ? { page } : undefined
);
let projects = Array.isArray(data) ? data : [];
if (!include_archived)
projects = projects.filter((p) => p.status === "active");
if (limit) projects = projects.slice(0, limit);
const rows = projects.map((p) => {
const item: any = {
id: p.id,
name: p.name,
status: p.status,
created_at: p.created_at,
updated_at: p.updated_at,
description: p.description ?? "",
purpose: p.purpose ?? "",
clients_enabled: Boolean(p.clients_enabled),
bookmarked: Boolean(p.bookmarked),
};
if (wantDock && Array.isArray(p.dock)) {
item.dock_tools = p.dock
.filter((d: any) => d?.enabled)
.map((d: any) => d.name);
if (dock_detail) item.dock = p.dock; // raw dock objects (includes todoset URL)
}
return item;
});
const nextPage = parseNextPage(headers.get("Link"));
if (fmt === "json") {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
page: page ?? 1,
nextPage,
count: rows.length,
projects: rows,
},
null,
2
),
},
],
};
}
const trunc = (s: string, n: number) =>
s && s.length > n ? s.slice(0, n - 1) + "…" : s || "";
const header = [
"ID".padEnd(12),
"STATUS".padEnd(9),
"CLIENTS".padEnd(8),
"BOOKMK".padEnd(6),
"CREATED".padEnd(10),
"UPDATED".padEnd(10),
"NAME".padEnd(28),
"PURPOSE".padEnd(8),
wantDock ? "DOCK" : "",
]
.filter(Boolean)
.join(" ");
const lines = [
`Page: ${page ?? 1}${nextPage ? ` | Next page: ${nextPage}` : ""}`,
"",
header,
"-".repeat(120),
...rows.map((r: any) =>
[
String(r.id).padEnd(12),
String(r.status ?? "").padEnd(9),
String(r.clients_enabled).padEnd(8),
String(r.bookmarked).padEnd(6),
(r.created_at ?? "").slice(0, 10).padEnd(10),
(r.updated_at ?? "").slice(0, 10).padEnd(10),
trunc(r.name ?? "", 28).padEnd(28),
trunc(r.purpose ?? "", 8).padEnd(8),
wantDock ? trunc((r.dock_tools ?? []).join(","), 28) : "",
]
.filter(Boolean)
.join(" ")
),
].join("\n");
return { content: [{ type: "text", text: lines }] };
}
);
}