// src/tools/messages.ts
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import TurndownService from "turndown";
import {
bcRequest,
bcRequestWithHeaders,
parseNextPage,
} from "../lib/basecamp.js";
// HTML → Markdown (better for Chat rendering)
const td = new TurndownService({
headingStyle: "atx",
codeBlockStyle: "fenced",
});
function renderBody(
html: string,
render: "markdown" | "html" | "text" = "markdown"
): string {
const markdown = td.turndown(html || "");
if (render === "markdown") return markdown;
if (render === "html") return html || "";
// crude Markdown → plain-text
return markdown
.replace(/```[\s\S]*?```/g, "") // drop code blocks
.replace(/`([^`]+)`/g, "$1") // inline code
.replace(/!\[([^\]]*)]\([^)]*\)/g, "$1") // images
.replace(/\[([^\]]+)]\([^)]*\)/g, "$1") // links
.replace(/^[#>\-\*\d\.\s]+/gm, "") // headings/quotes/lists
.replace(/[*_~`]/g, "") // emphasis
.trim();
}
/** Resolve the project’s message board id from the dock */
async function getMessageBoardFromDock(
project_id: number
): Promise<{ id: number; url: string } | null> {
const project = await bcRequest<any>("GET", `/projects/${project_id}.json`);
const dock = Array.isArray(project?.dock) ? project.dock : [];
const board = dock.find((d: any) => {
const n = (d?.name || d?.app_name || "").toLowerCase();
return (
n === "message_board" || n === "message-board" || n === "messageboard"
);
});
if (!board) return null;
const href: string =
board.url || board.href || board.api_url || board.app_url || "";
const m = href.match(/\/message_boards\/(\d+)/);
return m ? { id: Number(m[1]), url: href } : null;
}
export function registerMessageTools(server: McpServer) {
// List messages; can output table, json, or full markdown (with bodies)
server.registerTool(
"list_messages",
{
title: "List messages",
description:
"Resolves the project's message board from the dock, then lists messages with pagination. Optionally include full content.",
inputSchema: {
project_id: z.number().int(),
page: z.number().int().positive().optional(),
limit: z.number().int().positive().max(50).optional(), // keep modest for rate limits
format: z.enum(["table", "json", "markdown"]).optional(), // default: table
include_body: z.boolean().optional(), // include full message content
render: z.enum(["markdown", "html", "text"]).optional(), // how to render body (default markdown)
},
},
async ({ project_id, page, limit, format, include_body, render }) => {
const fmt = format ?? "table";
const board = await getMessageBoardFromDock(project_id);
if (!board) {
return {
content: [
{
type: "text",
text: "No message board found in the project's dock.",
},
],
};
}
const { data, headers } = await bcRequestWithHeaders<any[]>(
"GET",
`/buckets/${project_id}/message_boards/${board.id}/messages.json`,
undefined,
page ? { page } : undefined
);
let messages = Array.isArray(data) ? data : [];
if (limit) messages = messages.slice(0, limit);
// If we want bodies, fetch each full message
let bodies: Record<number, string> = {};
if (include_body) {
const fulls = await Promise.all(
messages.map((m) =>
bcRequest<any>(
"GET",
`/buckets/${project_id}/messages/${m.id}.json`
).then((msg) => ({ id: m.id, html: msg.content || msg.body || "" }))
)
);
for (const { id, html } of fulls) {
bodies[id] = renderBody(html, render ?? "markdown");
}
}
const rows = messages.map((m) => ({
id: m.id,
subject: m.subject ?? m.title ?? "",
status: m.status,
created_at: m.created_at,
updated_at: m.updated_at,
comments_count: m.comments_count ?? m.comments?.count ?? 0,
url: m.url || m.app_url || m.web_url || m.html_url || "",
body: include_body ? bodies[m.id] : undefined,
}));
const nextPage = parseNextPage(headers.get("Link"));
if (fmt === "json") {
return {
content: [
{
type: "text",
text: JSON.stringify(
{
page: page ?? 1,
nextPage,
count: rows.length,
messages: rows,
},
null,
2
),
},
],
};
}
if (fmt === "markdown") {
// Full readable output with bodies
const md = [
`# Messages (page ${page ?? 1}${
nextPage ? ` → next ${nextPage}` : ""
})`,
"",
...rows.map((r) => {
const header = `## ${r.subject || "(no subject)"} _(ID: ${r.id})_`;
const meta =
`- Comments: ${r.comments_count ?? 0}\n` +
`- Created: ${(r.created_at ?? "").slice(0, 10)} | Updated: ${(
r.updated_at ?? ""
).slice(0, 10)}\n` +
(r.url ? `- Link: ${r.url}\n` : "");
const body = include_body && r.body ? `\n${r.body}\n` : "";
return [header, meta, body].join("\n");
}),
].join("\n");
return { content: [{ type: "text", text: md }] };
}
// Default table (no full bodies to avoid huge output)
const trunc = (s: string, n: number) =>
s && s.length > n ? s.slice(0, n - 1) + "…" : s || "";
const header = [
"ID".padEnd(12),
"SUBJECT".padEnd(40),
"CMTS".padEnd(4),
"CREATED".padEnd(10),
"UPDATED".padEnd(10),
"URL",
].join(" ");
const lines = [
`Page: ${page ?? 1}${nextPage ? ` | Next page: ${nextPage}` : ""}`,
"",
header,
"-".repeat(120),
...rows.map((r) =>
[
String(r.id).padEnd(12),
trunc(r.subject, 40).padEnd(40),
String(r.comments_count ?? 0).padEnd(4),
(r.created_at ?? "").slice(0, 10).padEnd(10),
(r.updated_at ?? "").slice(0, 10).padEnd(10),
trunc(r.url ?? "", 40),
].join(" ")
),
include_body
? '\n(Use format: "markdown" to see full message content.)'
: "",
].join("\n");
return { content: [{ type: "text", text: lines }] };
}
);
// Get a single message with rendered content
server.registerTool(
"get_message",
{
title: "Get a message",
description:
"Fetch a single message by ID; render body as markdown/html/text, or return raw JSON.",
inputSchema: {
project_id: z.number().int(),
message_id: z.number().int(),
render: z.enum(["markdown", "html", "text", "json"]).optional(), // default markdown
},
},
async ({ project_id, message_id, render }) => {
const msg = await bcRequest<any>(
"GET",
`/buckets/${project_id}/messages/${message_id}.json`
);
const subject = msg.subject ?? msg.title ?? "(no subject)";
const html = msg.content || msg.body || "";
const mode = render ?? "markdown";
if (mode === "json") {
return {
content: [{ type: "text", text: JSON.stringify(msg, null, 2) }],
};
}
const body = renderBody(html, mode as "markdown" | "html" | "text");
const output =
mode === "markdown"
? `# ${subject}\n\n${body}`
: `${subject}\n\n${body}`;
return { content: [{ type: "text", text: output }] };
}
);
}