diff --git a/src/sequentialthinking/__tests__/lib.test.ts b/src/sequentialthinking/__tests__/lib.test.ts index a97e41f5a0..0e570e5b74 100644 --- a/src/sequentialthinking/__tests__/lib.test.ts +++ b/src/sequentialthinking/__tests__/lib.test.ts @@ -329,6 +329,190 @@ describe('SequentialThinkingServer', () => { expect(data.nextThoughtNeeded).toBe(false); }); + + it('should reject negative thoughtNumber', () => { + const input = { + thought: 'Test thought', + thoughtNumber: -1, + totalThoughts: 3, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('thoughtNumber must be >= 1'); + }); + + it('should reject zero thoughtNumber', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 0, + totalThoughts: 3, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('thoughtNumber must be >= 1'); + }); + + it('should reject negative totalThoughts', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: -5, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('totalThoughts must be >= 1'); + }); + + it('should reject zero totalThoughts', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 0, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('totalThoughts must be >= 1'); + }); + + it('should reject NaN thoughtNumber', () => { + const input = { + thought: 'Test thought', + thoughtNumber: NaN, + totalThoughts: 3, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('thoughtNumber must be a valid number'); + }); + + it('should reject Infinity totalThoughts', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: Infinity, + nextThoughtNeeded: true + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('totalThoughts must be a valid number'); + }); + + it('should reject non-boolean isRevision', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: 'true' + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('isRevision must be a boolean'); + }); + + it('should reject non-number revisesThought', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: '1' + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('revisesThought must be a number'); + }); + + it('should reject negative revisesThought', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: -1 + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('revisesThought must be >= 1'); + }); + + it('should reject non-number branchFromThought', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: '1', + branchId: 'branch-a' + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('branchFromThought must be a number'); + }); + + it('should reject non-string branchId', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: 123 + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('branchId must be a string'); + }); + + it('should reject empty branchId', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + branchFromThought: 1, + branchId: '' + }; + + const result = server.processThought(input); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('branchId cannot be empty'); + }); + + it('should accept valid optional fields', () => { + const input = { + thought: 'Test thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true, + isRevision: true, + revisesThought: 1, + branchFromThought: 1, + branchId: 'branch-a', + needsMoreThoughts: true + }; + + const result = server.processThought(input); + expect(result.isError).toBeUndefined(); + }); }); describe('processThought - response format', () => { @@ -436,4 +620,121 @@ describe('SequentialThinkingServer', () => { expect(result.isError).toBeUndefined(); }); }); + + describe('processThought - state isolation (concurrent requests)', () => { + it('should isolate state between different server instances', () => { + const server1 = new SequentialThinkingServer(); + const server2 = new SequentialThinkingServer(); + + const input1 = { + thought: 'Server 1 thought', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true + }; + + const input2 = { + thought: 'Server 2 thought', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true + }; + + server1.processThought(input1); + server2.processThought(input2); + + const result1 = server1.processThought({ + thought: 'Server 1 second thought', + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false + }); + + const result2 = server2.processThought({ + thought: 'Server 2 second thought', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true + }); + + const data1 = JSON.parse(result1.content[0].text); + const data2 = JSON.parse(result2.content[0].text); + + // Each server should track only its own history + expect(data1.thoughtHistoryLength).toBe(2); + expect(data2.thoughtHistoryLength).toBe(2); + }); + + it('should not leak branches between server instances', () => { + const server1 = new SequentialThinkingServer(); + const server2 = new SequentialThinkingServer(); + + server1.processThought({ + thought: 'Branch A', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + branchFromThought: 1, + branchId: 'branch-a' + }); + + const result2 = server2.processThought({ + thought: 'No branch', + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false + }); + + const data2 = JSON.parse(result2.content[0].text); + + // Server 2 should have no branches from server 1 + expect(data2.branches.length).toBe(0); + }); + + it('should handle concurrent interleaved requests without state pollution', () => { + const server1 = new SequentialThinkingServer(); + const server2 = new SequentialThinkingServer(); + + // Interleave requests as if they were concurrent + server1.processThought({ + thought: 'Conversation A - thought 1', + thoughtNumber: 1, + totalThoughts: 3, + nextThoughtNeeded: true + }); + + server2.processThought({ + thought: 'Conversation B - thought 1', + thoughtNumber: 1, + totalThoughts: 2, + nextThoughtNeeded: true + }); + + server1.processThought({ + thought: 'Conversation A - thought 2', + thoughtNumber: 2, + totalThoughts: 3, + nextThoughtNeeded: true + }); + + server2.processThought({ + thought: 'Conversation B - thought 2', + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false + }); + + const result1 = server1.processThought({ + thought: 'Conversation A - thought 3', + thoughtNumber: 3, + totalThoughts: 3, + nextThoughtNeeded: false + }); + + const data1 = JSON.parse(result1.content[0].text); + + // Server 1 should have exactly 3 thoughts, not 5 + expect(data1.thoughtHistoryLength).toBe(3); + }); + }); }); diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index 6b7472068a..b795ae2761 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -125,14 +125,14 @@ const server = new Server( } ); -const thinkingServer = new SequentialThinkingServer(); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [SEQUENTIAL_THINKING_TOOL], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "sequentialthinking") { + // Create new instance per request to avoid shared state + const thinkingServer = new SequentialThinkingServer(); return thinkingServer.processThought(request.params.arguments); } diff --git a/src/sequentialthinking/lib.ts b/src/sequentialthinking/lib.ts index c5ee9cad3c..4b9a4d0037 100644 --- a/src/sequentialthinking/lib.ts +++ b/src/sequentialthinking/lib.ts @@ -24,19 +24,77 @@ export class SequentialThinkingServer { private validateThoughtData(input: unknown): ThoughtData { const data = input as Record; - if (!data.thought || typeof data.thought !== 'string') { - throw new Error('Invalid thought: must be a string'); + // Validate required fields + if (typeof data.thought !== 'string' || data.thought.length === 0) { + throw new Error('Invalid thought: must be a non-empty string'); } - if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') { + + if (typeof data.thoughtNumber !== 'number') { throw new Error('Invalid thoughtNumber: must be a number'); } - if (!data.totalThoughts || typeof data.totalThoughts !== 'number') { + if (!Number.isFinite(data.thoughtNumber)) { + throw new Error('Invalid thoughtNumber: thoughtNumber must be a valid number'); + } + if (data.thoughtNumber < 1) { + throw new Error('Invalid thoughtNumber: thoughtNumber must be >= 1'); + } + + if (typeof data.totalThoughts !== 'number') { throw new Error('Invalid totalThoughts: must be a number'); } + if (!Number.isFinite(data.totalThoughts)) { + throw new Error('Invalid totalThoughts: totalThoughts must be a valid number'); + } + if (data.totalThoughts < 1) { + throw new Error('Invalid totalThoughts: totalThoughts must be >= 1'); + } + if (typeof data.nextThoughtNeeded !== 'boolean') { throw new Error('Invalid nextThoughtNeeded: must be a boolean'); } + // Validate optional fields if present + if (data.isRevision !== undefined && typeof data.isRevision !== 'boolean') { + throw new Error('Invalid isRevision: isRevision must be a boolean'); + } + + if (data.revisesThought !== undefined) { + if (typeof data.revisesThought !== 'number') { + throw new Error('Invalid revisesThought: revisesThought must be a number'); + } + if (!Number.isFinite(data.revisesThought)) { + throw new Error('Invalid revisesThought: revisesThought must be a valid number'); + } + if (data.revisesThought < 1) { + throw new Error('Invalid revisesThought: revisesThought must be >= 1'); + } + } + + if (data.branchFromThought !== undefined) { + if (typeof data.branchFromThought !== 'number') { + throw new Error('Invalid branchFromThought: branchFromThought must be a number'); + } + if (!Number.isFinite(data.branchFromThought)) { + throw new Error('Invalid branchFromThought: branchFromThought must be a valid number'); + } + if (data.branchFromThought < 1) { + throw new Error('Invalid branchFromThought: branchFromThought must be >= 1'); + } + } + + if (data.branchId !== undefined) { + if (typeof data.branchId !== 'string') { + throw new Error('Invalid branchId: branchId must be a string'); + } + if (data.branchId.length === 0) { + throw new Error('Invalid branchId: branchId cannot be empty'); + } + } + + if (data.needsMoreThoughts !== undefined && typeof data.needsMoreThoughts !== 'boolean') { + throw new Error('Invalid needsMoreThoughts: needsMoreThoughts must be a boolean'); + } + return { thought: data.thought, thoughtNumber: data.thoughtNumber,