@modelcontextprotocol/server-sequential-thinking
Version:
MCP server for sequential thinking and problem solving
227 lines (226 loc) ⢠10.4 kB
JavaScript
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,
};
}
}
}