list_messages
Retrieve and display message board posts from a Basecamp project with pagination options and customizable content formats.
Instructions
Resolves the project's message board from the dock, then lists messages with pagination. Optionally include full content.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| project_id | Yes | ||
| page | No | ||
| limit | No | ||
| format | No | ||
| include_body | No | ||
| render | No |
Implementation Reference
- src/tools/messages.ts:73-201 (handler)The main execution logic for the 'list_messages' tool. Resolves project message board, fetches paginated messages, optionally includes rendered bodies, and formats output as table (default), JSON, or 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 }] }; }
- src/tools/messages.ts:64-71 (schema)Zod input schema defining parameters: project_id (required), optional page, limit, format (table/json/markdown), include_body, render (markdown/html/text).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) },
- src/tools/messages.ts:58-203 (registration)Tool registration via server.registerTool in registerMessageTools function, including name, metadata, schema, and handler reference. This function is called from the main MCP server setup.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 }] }; } );
- src/tools/messages.ts:36-54 (helper)Helper function to resolve the message board ID and URL from the project's dock array.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; }
- src/tools/messages.ts:17-33 (helper)Helper function to convert HTML message bodies to Markdown (using Turndown), HTML, or plain text.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(); }