mcp-sequentialthinking-tools
Version:
Lightweight MCP sequential thinking scratchpad with optional tool-plan validation
414 lines (413 loc) • 16.9 kB
JavaScript
import { ValibotJsonSchemaAdapter } from "@tmcp/adapter-valibot";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { McpServer } from "tmcp";
import { prompt } from "tmcp/utils";
import * as v from "valibot";
import process$1 from "node:process";
//#region src/schema.ts
const tool_reference_schema = v.union([v.pipe(v.string(), v.description("Tool name")), v.object({
name: v.pipe(v.string(), v.description("Tool name")),
description: v.optional(v.pipe(v.string(), v.description("Brief tool description")))
})]);
const tool_recommendation_schema = v.object({
tool_name: v.pipe(v.string(), v.description("Name of the tool being recommended")),
confidence: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1), v.description("Optional 0-1 confidence score"))),
rationale: v.optional(v.pipe(v.string(), v.description("Why this tool may help"))),
priority: v.optional(v.pipe(v.number(), v.minValue(1), v.description("Optional execution order hint"))),
suggested_inputs: v.optional(v.pipe(v.record(v.string(), v.unknown()), v.description("Optional suggested tool arguments"))),
alternatives: v.optional(v.pipe(v.array(v.string()), v.description("Alternative tool names")))
});
const sequential_thinking_schema = v.object({
session_id: v.optional(v.pipe(v.string(), v.description("Optional history bucket. Defaults to \"default\"."))),
thought: v.pipe(v.string(), v.description("Current thinking step")),
thought_number: v.pipe(v.number(), v.minValue(1), v.description("Current thought number")),
total_thoughts: v.pipe(v.number(), v.minValue(1), v.description("Current estimate of total thoughts needed")),
next_thought_needed: v.pipe(v.boolean(), v.description("Whether another thought is needed")),
is_revision: v.optional(v.pipe(v.boolean(), v.description("Whether this revises a thought"))),
revises_thought: v.optional(v.pipe(v.number(), v.minValue(1), v.description("Thought revised"))),
branch_from_thought: v.optional(v.pipe(v.number(), v.minValue(1), v.description("Thought number this branch starts from"))),
branch_id: v.optional(v.pipe(v.string(), v.description("Branch identifier"))),
needs_more_thoughts: v.optional(v.pipe(v.boolean(), v.description("Set when the estimate was too low"))),
available_tools: v.optional(v.pipe(v.array(tool_reference_schema), v.description("Optional tool names/descriptions used only to validate recommendations"))),
recommended_tools: v.optional(v.pipe(v.array(tool_recommendation_schema), v.description("Optional tool plan authored by the model; validated if available_tools is supplied"))),
remaining_steps: v.optional(v.pipe(v.array(v.string()), v.description("Optional high-level remaining steps")))
});
const get_history_schema = v.object({
session_id: v.optional(v.pipe(v.string(), v.description("History bucket to inspect"))),
branch_id: v.optional(v.pipe(v.string(), v.description("Optional branch filter"))),
limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(500), v.description("Maximum records to return; default 50")))
});
const clear_history_schema = v.object({
session_id: v.optional(v.pipe(v.string(), v.description("History bucket to clear"))),
all_sessions: v.optional(v.pipe(v.boolean(), v.description("Clear every history bucket")))
});
const guidance_prompt_schema = v.object({ problem: v.optional(v.pipe(v.string(), v.description("Optional problem to think through"))) });
//#endregion
//#region src/stdio.ts
var compatible_stdio_transport = class {
buffer = Buffer.alloc(0);
cleaners = /* @__PURE__ */ new Set();
mode = "headers";
session_info = {};
subscriptions = { resource: [] };
constructor(server) {
this.server = server;
this.cleaners.add(this.server.on("initialize", ({ capabilities, clientInfo: client_info }) => {
this.session_info.clientCapabilities = capabilities;
this.session_info.clientInfo = client_info;
}));
this.cleaners.add(this.server.on("send", ({ request }) => {
this.write(request);
}));
this.cleaners.add(this.server.on("broadcast", ({ request }) => {
if (request.method === "notifications/resources/updated" && !this.subscriptions.resource.includes(request.params.uri)) return;
this.write(request);
}));
this.cleaners.add(this.server.on("loglevelchange", ({ level }) => {
this.session_info.logLevel = level;
}));
this.cleaners.add(this.server.on("subscription", ({ uri, action }) => {
if (action === "remove") this.subscriptions.resource = this.subscriptions.resource.filter((item) => item !== uri);
else if (!this.subscriptions.resource.includes(uri)) this.subscriptions.resource.push(uri);
}));
}
listen(custom) {
process$1.stdin.on("data", (chunk) => {
this.buffer = Buffer.concat([this.buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
this.drain(custom);
});
process$1.stdin.on("end", () => this.close());
process$1.on("SIGINT", () => this.close());
process$1.on("SIGTERM", () => this.close());
}
async drain(custom) {
while (this.buffer.length > 0) {
const framed = this.read_headers_message();
if (framed === null) {
const lined = this.read_line_message();
if (lined === null) return;
this.mode = "lines";
await this.handle(lined, custom);
continue;
}
if (framed === void 0) return;
this.mode = "headers";
await this.handle(framed, custom);
}
}
read_headers_message() {
const header_end = this.buffer.indexOf("\r\n\r\n");
if (header_end === -1) return this.buffer.includes(10) ? null : void 0;
const content_length = this.buffer.subarray(0, header_end).toString("utf8").split("\r\n").map((line) => line.match(/^content-length:\s*(\d+)$/i)?.[1]).find(Boolean);
if (!content_length) return null;
const length = Number(content_length);
const body_start = header_end + 4;
const body_end = body_start + length;
if (this.buffer.length < body_end) return;
const body = this.buffer.subarray(body_start, body_end).toString("utf8");
this.buffer = this.buffer.subarray(body_end);
return JSON.parse(body);
}
read_line_message() {
const line_end = this.buffer.indexOf("\n");
if (line_end === -1) return null;
const raw = this.buffer.subarray(0, line_end).toString("utf8").trim();
this.buffer = this.buffer.subarray(line_end + 1);
if (!raw) return null;
return JSON.parse(raw);
}
async handle(message, custom) {
try {
const response = await this.server.receive(message, {
custom,
sessionInfo: this.session_info
});
if (response) this.write(response);
} catch (error) {
this.write({
jsonrpc: "2.0",
id: typeof message.id === "number" || typeof message.id === "string" ? message.id : null,
error: {
code: -32603,
message: error instanceof Error ? error.message : String(error)
}
});
}
}
write(message) {
const body = JSON.stringify(message);
if (this.mode === "lines") {
process$1.stdout.write(`${body}\n`);
return;
}
process$1.stdout.write(`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`);
}
close() {
for (const cleaner of this.cleaners) cleaner();
process$1.exit(0);
}
};
//#endregion
//#region src/security.ts
const injection_patterns = [
{
name: "ignore-instructions",
pattern: /\bignore\s+(all\s+)?(previous|prior|above|system|developer)\s+instructions?\b/i
},
{
name: "override-role",
pattern: /\b(system|developer)\s+(prompt|message|instruction)s?\b/i
},
{
name: "secret-exfiltration",
pattern: /\b(reveal|print|dump|exfiltrate|leak|show)\s+(the\s+)?(secret|secrets|token|tokens|api\s*key|password|credentials?)\b/i
},
{
name: "tool-coercion",
pattern: /\b(call|use|run|execute)\s+[^\n]{0,80}\b(tool|bash|shell|curl|wget)\b/i
},
{
name: "hidden-instruction",
pattern: /\bdo\s+not\s+(tell|mention|disclose|reveal)\b/i
}
];
const redaction = "[redacted: possible prompt-injection text]";
function scan_text(field, value) {
if (!value) return [];
return injection_patterns.filter(({ pattern }) => pattern.test(value)).map(({ name }) => ({
field,
pattern: name
}));
}
function sanitize_text(value) {
return injection_patterns.reduce((sanitized, { pattern }) => sanitized.replace(pattern, redaction), value);
}
function scan_record(record) {
return [
...scan_text("thought", record.thought),
...(record.remaining_steps ?? []).flatMap((step, index) => scan_text(`remaining_steps.${index}`, step)),
...(record.available_tools ?? []).flatMap((tool, index) => scan_tool_reference(tool, `available_tools.${index}`)),
...(record.recommended_tools ?? []).flatMap((tool, index) => scan_tool_recommendation(tool, `recommended_tools.${index}`))
];
}
function sanitize_record(record) {
return {
...record,
thought: sanitize_text(record.thought),
available_tools: record.available_tools?.map(sanitize_tool_reference),
recommended_tools: record.recommended_tools?.map(sanitize_tool_recommendation),
remaining_steps: record.remaining_steps?.map(sanitize_text)
};
}
function scan_tool_reference(tool, field) {
if (typeof tool === "string") return scan_text(field, tool);
return [...scan_text(`${field}.name`, tool.name), ...scan_text(`${field}.description`, tool.description)];
}
function scan_tool_recommendation(tool, field) {
return [
...scan_text(`${field}.tool_name`, tool.tool_name),
...scan_text(`${field}.rationale`, tool.rationale),
...(tool.alternatives ?? []).flatMap((alternative, index) => scan_text(`${field}.alternatives.${index}`, alternative))
];
}
function sanitize_tool_reference(tool) {
if (typeof tool === "string") return sanitize_text(tool);
return {
...tool,
name: sanitize_text(tool.name),
description: tool.description ? sanitize_text(tool.description) : void 0
};
}
function sanitize_tool_recommendation(tool) {
return {
...tool,
tool_name: sanitize_text(tool.tool_name),
rationale: tool.rationale ? sanitize_text(tool.rationale) : void 0,
alternatives: tool.alternatives?.map(sanitize_text)
};
}
//#endregion
//#region src/thinking.ts
const DEFAULT_SESSION = "default";
var thinking_store = class {
max_history_size;
sessions = /* @__PURE__ */ new Map();
constructor(options = {}) {
this.max_history_size = sanitize_limit(options.max_history_size, 1e3);
}
add(input) {
const session_id = normalize_session(input.session_id);
const total_thoughts = Math.max(input.total_thoughts, input.thought_number);
const raw_record = {
...input,
session_id,
total_thoughts,
created_at: (/* @__PURE__ */ new Date()).toISOString()
};
const security_warnings = scan_record(raw_record);
const record = sanitize_record(raw_record);
const invalid_recommendations = validate_recommendations(record);
if (invalid_recommendations.length > 0) return {
session_id,
thought_number: record.thought_number,
total_thoughts,
next_thought_needed: record.next_thought_needed,
needs_more_thoughts: record.needs_more_thoughts,
branches: this.branches(session_id),
history_length: this.history(session_id).length,
invalid_recommendations,
security_warnings: security_warnings.length ? security_warnings : void 0,
recommended_tools: record.recommended_tools,
remaining_steps: record.remaining_steps
};
const history = this.history(session_id);
history.push(record);
if (history.length > this.max_history_size) history.splice(0, history.length - this.max_history_size);
this.sessions.set(session_id, history);
return {
session_id,
thought_number: record.thought_number,
total_thoughts,
next_thought_needed: record.next_thought_needed,
needs_more_thoughts: record.needs_more_thoughts,
branches: this.branches(session_id),
history_length: history.length,
security_warnings: security_warnings.length ? security_warnings : void 0,
recommended_tools: record.recommended_tools,
remaining_steps: record.remaining_steps
};
}
get_history(input = {}) {
const session_id = normalize_session(input.session_id);
const limit = sanitize_limit(input.limit, 50);
let records = [...this.history(session_id)];
if (input.branch_id) records = records.filter((record) => record.branch_id === input.branch_id);
return {
session_id,
branches: this.branches(session_id),
history_length: this.history(session_id).length,
thoughts: records.slice(-limit)
};
}
clear(input = {}) {
if (input.all_sessions) {
const cleared_sessions = this.sessions.size;
const cleared_thoughts = [...this.sessions.values()].reduce((total, records) => total + records.length, 0);
this.sessions.clear();
return {
cleared_sessions,
cleared_thoughts
};
}
const session_id = normalize_session(input.session_id);
const cleared_thoughts = this.history(session_id).length;
this.sessions.delete(session_id);
return {
session_id,
cleared_sessions: 1,
cleared_thoughts
};
}
history(session_id) {
return this.sessions.get(session_id) ?? [];
}
branches(session_id) {
return [...new Set(this.history(session_id).map((record) => record.branch_id).filter((branch_id) => Boolean(branch_id)))];
}
};
function validate_recommendations(input) {
if (!input.recommended_tools?.length || !input.available_tools?.length) return [];
const available = new Set(input.available_tools.map(tool_name));
const issues = [];
input.recommended_tools.forEach((recommendation, index) => {
if (!available.has(recommendation.tool_name)) issues.push({
field: `recommended_tools.${index}.tool_name`,
message: `Unknown tool "${recommendation.tool_name}". Supply it in available_tools or remove the recommendation.`
});
recommendation.alternatives?.forEach((alternative, alt_index) => {
if (!available.has(alternative)) issues.push({
field: `recommended_tools.${index}.alternatives.${alt_index}`,
message: `Unknown alternative tool "${alternative}".`
});
});
});
return issues;
}
function tool_name(tool) {
return typeof tool === "string" ? tool : tool.name;
}
function normalize_session(session_id) {
const trimmed = session_id?.trim();
return trimmed ? trimmed : DEFAULT_SESSION;
}
function sanitize_limit(value, fallback) {
if (!Number.isFinite(value) || value === void 0) return fallback;
return Math.max(1, Math.floor(value));
}
//#endregion
//#region src/index.ts
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
const adapter = new ValibotJsonSchemaAdapter();
const server = new McpServer({
name: pkg.name,
version: pkg.version,
description: pkg.description ?? "Sequential thinking scratchpad and optional tool-plan validator."
}, {
adapter,
capabilities: {
tools: { listChanged: true },
prompts: { listChanged: true }
}
});
const thinking = new thinking_store({ max_history_size: parse_int_env("MAX_HISTORY_SIZE", 1e3) });
server.tool({
name: "sequentialthinking_tools",
description: "Record one step of sequential reasoning. Optionally include available_tools and recommended_tools; recommendations are validated, not generated, by this server.",
schema: sequential_thinking_schema
}, (input) => {
const result = thinking.add(input);
if (result.invalid_recommendations?.length) return json_tool(result, true);
return json_tool(result);
});
server.tool({
name: "get_thinking_history",
description: "Return recorded thoughts for a session. Use this to inspect or resume prior reasoning.",
schema: get_history_schema
}, (input) => json_tool(thinking.get_history(input)));
server.tool({
name: "clear_thinking_history",
description: "Clear recorded sequential-thinking history for one session or all sessions.",
schema: clear_history_schema
}, (input) => json_tool(thinking.clear(input)));
server.prompt({
name: "sequential-thinking-guidance",
description: "Use sequentialthinking_tools as a lightweight scratchpad without pretending it performs reasoning for you.",
schema: guidance_prompt_schema
}, ({ problem }) => prompt.message([
"Use sequentialthinking_tools only for problems that genuinely benefit from explicit multi-step reasoning.",
"Keep each thought short. Revise or branch when new evidence changes the plan.",
"If recommending tools, pass available_tools and recommended_tools so the server can validate names.",
"Do not claim the server chose the tools; the model authored the plan and the server tracked it.",
problem ? `Problem: ${problem}` : void 0
].filter(Boolean).join("\n")));
new compatible_stdio_transport(server).listen();
function json_tool(data, is_error = false) {
return {
content: [{
type: "text",
text: JSON.stringify(data, null, 2)
}],
isError: is_error
};
}
function parse_int_env(name, fallback) {
const value = Number.parseInt(process.env[name] ?? "", 10);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
//#endregion
export {};
//# sourceMappingURL=index.js.map