UNPKG

@modelcontextprotocol/server-sequential-thinking

Version:

MCP server for sequential thinking and problem solving

227 lines (226 loc) • 10.4 kB
import chalk from "chalk"; /** * The widest the text inside a thought box gets (#5433). A longer line, or a * longer header, wraps onto further rows of this width. Without the cap every * row was padded to the longest line, so one long line and many short ones * made the box grow as their product: a 60 KB thought logged 400 million * characters. Capped, the box grows linearly with the thought. */ export const MAX_BOX_TEXT_WIDTH = 100; /** * The most rows of thought text a box draws (#5433). Each row is padded to * the box's width, so a thought of many short lines would still log about a * hundred times its own size; past this many rows the box ends with one row * saying how many lines it left out. Header rows (a long branchId) are not * counted. Only stderr is cut: the thought kept in thoughtHistory is whole, * and the tool result, which carries no thought text, is unchanged. */ export const MAX_BOX_ROWS = 1000; /** * `text` cut into rows of at most `width` UTF-16 code units, never between * the two halves of a surrogate pair, so an emoji is never split across rows. * An empty string is one empty row. */ function wrapRow(text, width) { const rows = []; let start = 0; do { let end = Math.min(start + width, text.length); const last = text.charCodeAt(end - 1); if (end < text.length && end - start > 1 && last >= 0xd800 && last <= 0xdbff) { end -= 1; } rows.push(text.slice(start, end)); start = end; } while (start < text.length); return rows; } /** * How many thoughts one server instance retains when MAX_THOUGHT_HISTORY is * unset (#2912). A server lives as long as its client's connection, so without * a cap a long-lived connection grows its history without bound. */ export const DEFAULT_MAX_THOUGHT_HISTORY = 1000; /** * The thought cap from MAX_THOUGHT_HISTORY: a positive integer, or the default * when the variable is unset or empty. Any other value is reported on stderr * and replaced by the default, so a typo cannot remove the bound. */ function readMaxThoughtHistory() { const raw = (process.env.MAX_THOUGHT_HISTORY ?? "").trim(); if (raw === "") { return DEFAULT_MAX_THOUGHT_HISTORY; } const value = Number(raw); if (/^\d+$/.test(raw) && Number.isSafeInteger(value) && value >= 1) { return value; } console.error(`MAX_THOUGHT_HISTORY must be a positive integer, got "${raw}"; using ${DEFAULT_MAX_THOUGHT_HISTORY}`); return DEFAULT_MAX_THOUGHT_HISTORY; } export class SequentialThinkingServer { // At most maxThoughtHistory thoughts, oldest first. Each branch holds the // retained thoughts recorded on it, so evicting the oldest thought also // removes it from its branch, and a branch left empty is dropped: the // branches together never hold more than the history does. thoughtHistory = []; // Null prototype, so any string branchId (even "constructor" or "__proto__") // is an ordinary own key rather than an inherited Object.prototype member. branches = Object.create(null); disableThoughtLogging; maxThoughtHistory; constructor() { this.disableThoughtLogging = (process.env.DISABLE_THOUGHT_LOGGING || "").toLowerCase() === "true"; this.maxThoughtHistory = readMaxThoughtHistory(); } formatThought(thoughtData) { const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId, } = thoughtData; // The label is coloured only after the header is measured, so the border // and the header's padding are sized from the visible text, not from // chalk's escape codes. A missing // revisesThought or branchId is left out of the header rather than printed // as "undefined". let label; let colour; let context = ""; if (isRevision) { label = "šŸ”„ Revision"; colour = chalk.yellow; if (revisesThought !== undefined) { context = ` (revising thought ${revisesThought})`; } } else if (branchFromThought) { label = "🌿 Branch"; colour = chalk.green; context = branchId !== undefined ? ` (from thought ${branchFromThought}, ID: ${branchId})` : ` (from thought ${branchFromThought})`; } else { label = "šŸ’­ Thought"; colour = chalk.blue; } const counts = ` ${thoughtNumber}/${totalThoughts}${context}`; const visibleHeader = `${label}${counts}`; // #5205: each line of the thought is a row of its own, and the box is as // wide as the longest of them (or the header). Any line ending (\r\n, \n // or a lone \r) ends a row: a \r left inside a row would send the cursor // back to the left border and draw the rest of the row over it. const lines = thought.split(/\r\n|\r|\n/); // A reduce rather than Math.max(...lengths): spreading one argument per // line would overflow the call stack on a thought with very many lines. // #5433: never wider than MAX_BOX_TEXT_WIDTH; longer lines wrap below. const wrapWidth = Math.min(MAX_BOX_TEXT_WIDTH, lines.reduce((widest, line) => Math.max(widest, line.length), visibleHeader.length)); // A header longer than the cap (a long branchId) wraps like a thought // line; only its first row, which starts with the label, is coloured. const headerTexts = wrapRow(visibleHeader, wrapWidth); // Rows are drawn up to MAX_BOX_ROWS; the lines left over are counted, not // drawn, so a huge thought costs no more than the cap. const texts = []; // Lines drawn in full; a line cut off by the cap counts as hidden. let whole = 0; for (const line of lines) { const wrapped = wrapRow(line, wrapWidth); const room = MAX_BOX_ROWS - texts.length; texts.push(...wrapped.slice(0, room)); if (wrapped.length > room) break; whole++; } const hidden = lines.length - whole; const summary = hidden > 0 ? `… ${hidden} more line${hidden === 1 ? "" : "s"} not shown` : ""; if (summary) texts.push(summary); // The summary row can be wider than every line it follows, so the box is // sized after it. Widening cannot change the wrapping above: below the cap // no line wrapped, and at the cap the summary (under 40 characters) fits. const width = Math.max(wrapWidth, summary.length); const border = "─".repeat(width + 4); // Every row, header rows included, is padded to the border's width by the // visible text's length. Each label's emoji is one surrogate pair, so its // length of 2 is also its display width in a terminal. const row = (text) => `│ ${text.padEnd(border.length - 2)} │`; const headerRows = headerTexts.map((text, i) => i === 0 ? row(text).replace(label, colour(label)) : row(text)); const rows = texts.map(row); return ` ā”Œ${border}┐ ${headerRows.join("\n")} ā”œ${border}┤ ${rows.join("\n")} ā””${border}ā”˜`; } processThought(input) { try { // Validation happens at the tool registration layer via Zod // Adjust totalThoughts if thoughtNumber exceeds it if (input.thoughtNumber > input.totalThoughts) { input.totalThoughts = input.thoughtNumber; } // Log before touching any state: drawing and writing the box are the // steps that can throw, so a failed call leaves thoughtHistory and // branches as they were. if (!this.disableThoughtLogging) { const formattedThought = this.formatThought(input); console.error(formattedThought); } this.thoughtHistory.push(input); if (input.branchFromThought && input.branchId) { if (!this.branches[input.branchId]) { this.branches[input.branchId] = []; } this.branches[input.branchId].push(input); } // One thought in, at most one out. Thoughts enter a branch in the order // they enter the history, so the evicted thought is its branch's first. if (this.thoughtHistory.length > this.maxThoughtHistory) { const [oldest] = this.thoughtHistory.splice(0, 1); if (oldest.branchFromThought && oldest.branchId) { const branch = this.branches[oldest.branchId]; branch.shift(); if (branch.length === 0) { delete this.branches[oldest.branchId]; } } } return { content: [ { type: "text", text: JSON.stringify({ thoughtNumber: input.thoughtNumber, totalThoughts: input.totalThoughts, nextThoughtNeeded: input.nextThoughtNeeded, // Both describe the retained thoughts: once the cap is // reached the length stays at MAX_THOUGHT_HISTORY, and a // branch whose thoughts were all evicted is no longer listed. branches: Object.keys(this.branches), thoughtHistoryLength: this.thoughtHistory.length, }, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: JSON.stringify({ error: error instanceof Error ? error.message : String(error), status: "failed", }, null, 2), }, ], isError: true, }; } } }