UNPKG

mcp-sequentialthinking-tools

Version:

Lightweight MCP sequential thinking scratchpad with optional tool-plan validation

414 lines (413 loc) 16.9 kB
#!/usr/bin/env node 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