Skip to main content
Glama
messages.ts7.88 kB
// 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 }] }; } ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/craigashields/basecamp-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server