Merge branch 'main' into convert-everything-to-modern-api

This commit is contained in:
Cliff Hall
2025-11-22 14:07:50 -05:00
committed by GitHub
11 changed files with 823 additions and 1010 deletions

View File

@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code - name: Run Claude Code
id: claude id: claude
uses: anthropics/claude-code-action@beta uses: anthropics/claude-code-action@v1
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -43,9 +43,6 @@ jobs:
# Trigger when assigned to an issue # Trigger when assigned to an issue
assignee_trigger: "claude" assignee_trigger: "claude"
# Allow Claude to run bash claude_args: |
# This should be safe given the repo is already public --allowedTools Bash
allowed_tools: "Bash" --system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block."
custom_instructions: |
If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block.

View File

@@ -41,21 +41,9 @@ jobs:
working-directory: src/${{ matrix.package }} working-directory: src/${{ matrix.package }}
run: npm ci run: npm ci
- name: Check if tests exist
id: check-tests
working-directory: src/${{ matrix.package }}
run: |
if npm run test --silent 2>/dev/null; then
echo "has-tests=true" >> $GITHUB_OUTPUT
else
echo "has-tests=false" >> $GITHUB_OUTPUT
fi
continue-on-error: true
- name: Run tests - name: Run tests
if: steps.check-tests.outputs.has-tests == 'true'
working-directory: src/${{ matrix.package }} working-directory: src/${{ matrix.package }}
run: npm test run: npm test --if-present
build: build:
needs: [detect-packages, test] needs: [detect-packages, test]

View File

@@ -854,6 +854,7 @@ A growing set of community-developed and maintained servers demonstrates various
- **[GraphQL](https://github.com/drestrepom/mcp_graphql)** - Comprehensive GraphQL API integration that automatically exposes each GraphQL query as a separate tool. - **[GraphQL](https://github.com/drestrepom/mcp_graphql)** - Comprehensive GraphQL API integration that automatically exposes each GraphQL query as a separate tool.
- **[GraphQL Schema](https://github.com/hannesj/mcp-graphql-schema)** - Allow LLMs to explore large GraphQL schemas without bloating the context. - **[GraphQL Schema](https://github.com/hannesj/mcp-graphql-schema)** - Allow LLMs to explore large GraphQL schemas without bloating the context.
- **[Graylog](https://github.com/Pranavj17/mcp-server-graylog)** - Search Graylog logs by absolute/relative timestamps, filter by streams, and debug production issues directly from Claude Desktop. - **[Graylog](https://github.com/Pranavj17/mcp-server-graylog)** - Search Graylog logs by absolute/relative timestamps, filter by streams, and debug production issues directly from Claude Desktop.
- **[Grok-MCP](https://github.com/merterbak/Grok-MCP)** - MCP server for xAIs API featuring the latest Grok models, image analysis & generation, and web search.
- **[gx-mcp-server](https://github.com/davidf9999/gx-mcp-server)** - Expose Great Expectations data validation and quality checks as MCP tools for AI agents. - **[gx-mcp-server](https://github.com/davidf9999/gx-mcp-server)** - Expose Great Expectations data validation and quality checks as MCP tools for AI agents.
- **[HackMD](https://github.com/yuna0x0/hackmd-mcp)** (by yuna0x0) - An MCP server for HackMD, a collaborative markdown editor. It allows users to create, read, and update documents in HackMD using the Model Context Protocol. - **[HackMD](https://github.com/yuna0x0/hackmd-mcp)** (by yuna0x0) - An MCP server for HackMD, a collaborative markdown editor. It allows users to create, read, and update documents in HackMD using the Model Context Protocol.
- **[HAProxy](https://github.com/tuannvm/haproxy-mcp-server)** - A Model Context Protocol (MCP) server for HAProxy implemented in Go, leveraging HAProxy Runtime API. - **[HAProxy](https://github.com/tuannvm/haproxy-mcp-server)** - A Model Context Protocol (MCP) server for HAProxy implemented in Go, leveraging HAProxy Runtime API.

View File

@@ -1,11 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { import {
CallToolRequestSchema, CallToolResult,
ListToolsRequestSchema,
ToolSchema,
RootsListChangedNotificationSchema, RootsListChangedNotificationSchema,
type Root, type Root,
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
@@ -13,7 +11,6 @@ import fs from "fs/promises";
import { createReadStream } from "fs"; import { createReadStream } from "fs";
import path from "path"; import path from "path";
import { z } from "zod"; import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { minimatch } from "minimatch"; import { minimatch } from "minimatch";
import { normalizePath, expandHome } from './path-utils.js'; import { normalizePath, expandHome } from './path-utils.js';
import { getValidRootDirectories } from './roots-utils.js'; import { getValidRootDirectories } from './roots-utils.js';
@@ -143,20 +140,12 @@ const GetFileInfoArgsSchema = z.object({
path: z.string(), path: z.string(),
}); });
const ToolInputSchema = ToolSchema.shape.inputSchema;
type ToolInput = z.infer<typeof ToolInputSchema>;
// Server setup // Server setup
const server = new Server( const server = new McpServer(
{ {
name: "secure-filesystem-server", name: "secure-filesystem-server",
version: "0.2.0", version: "0.2.0",
}, }
{
capabilities: {
tools: {},
},
},
); );
// Reads a file as a stream of buffers, concatenates them, and then encodes // Reads a file as a stream of buffers, concatenates them, and then encodes
@@ -177,17 +166,46 @@ async function readFileAsBase64Stream(filePath: string): Promise<string> {
}); });
} }
// Tool handlers // Tool registrations
server.setRequestHandler(ListToolsRequestSchema, async () => {
// read_file (deprecated) and read_text_file
const readTextFileHandler = async (args: z.infer<typeof ReadTextFileArgsSchema>) => {
const validPath = await validatePath(args.path);
if (args.head && args.tail) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
let content: string;
if (args.tail) {
content = await tailFile(validPath, args.tail);
} else if (args.head) {
content = await headFile(validPath, args.head);
} else {
content = await readFileContent(validPath);
}
return { return {
tools: [ content: [{ type: "text" as const, text: content }],
structuredContent: { content }
};
};
server.registerTool(
"read_file",
{ {
name: "read_file", title: "Read File (Deprecated)",
description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.", description: "Read the complete contents of a file as text. DEPRECATED: Use read_text_file instead.",
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, inputSchema: ReadTextFileArgsSchema.shape,
outputSchema: { content: z.string() }
}, },
readTextFileHandler
);
server.registerTool(
"read_text_file",
{ {
name: "read_text_file", title: "Read Text File",
description: description:
"Read the complete contents of a file from the file system as text. " + "Read the complete contents of a file from the file system as text. " +
"Handles various text encodings and provides detailed error messages " + "Handles various text encodings and provides detailed error messages " +
@@ -196,167 +214,36 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
"the first N lines of a file, or the 'tail' parameter to read only " + "the first N lines of a file, or the 'tail' parameter to read only " +
"the last N lines of a file. Operates on the file as text regardless of extension. " + "the last N lines of a file. Operates on the file as text regardless of extension. " +
"Only works within allowed directories.", "Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadTextFileArgsSchema) as ToolInput, inputSchema: {
path: z.string(),
tail: z.number().optional().describe("If provided, returns only the last N lines of the file"),
head: z.number().optional().describe("If provided, returns only the first N lines of the file")
}, },
outputSchema: { content: z.string() }
},
readTextFileHandler
);
server.registerTool(
"read_media_file",
{ {
name: "read_media_file", title: "Read Media File",
description: description:
"Read an image or audio file. Returns the base64 encoded data and MIME type. " + "Read an image or audio file. Returns the base64 encoded data and MIME type. " +
"Only works within allowed directories.", "Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadMediaFileArgsSchema) as ToolInput,
},
{
name: "read_multiple_files",
description:
"Read the contents of multiple files simultaneously. This is more " +
"efficient than reading files one by one when you need to analyze " +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
"the entire operation. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ReadMultipleFilesArgsSchema) as ToolInput,
},
{
name: "write_file",
description:
"Create a new file or completely overwrite an existing file with new content. " +
"Use with caution as it will overwrite existing files without warning. " +
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: zodToJsonSchema(WriteFileArgsSchema) as ToolInput,
},
{
name: "edit_file",
description:
"Make line-based edits to a text file. Each edit replaces exact line sequences " +
"with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: zodToJsonSchema(EditFileArgsSchema) as ToolInput,
},
{
name: "create_directory",
description:
"Create a new directory or ensure a directory exists. Can create multiple " +
"nested directories in one operation. If the directory already exists, " +
"this operation will succeed silently. Perfect for setting up directory " +
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) as ToolInput,
},
{
name: "list_directory",
description:
"Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) as ToolInput,
},
{
name: "list_directory_with_sizes",
description:
"Get a detailed listing of all files and directories in a specified path, including sizes. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is useful for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: zodToJsonSchema(ListDirectoryWithSizesArgsSchema) as ToolInput,
},
{
name: "directory_tree",
description:
"Get a recursive tree view of files and directories as a JSON structure. " +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
"Files have no children array, while directories always have a children array (which may be empty). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) as ToolInput,
},
{
name: "move_file",
description:
"Move or rename files and directories. Can move files between directories " +
"and rename them in a single operation. If the destination exists, the " +
"operation will fail. Works across different directories and can be used " +
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
inputSchema: zodToJsonSchema(MoveFileArgsSchema) as ToolInput,
},
{
name: "search_files",
description:
"Recursively search for files and directories matching a pattern. " +
"The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: zodToJsonSchema(SearchFilesArgsSchema) as ToolInput,
},
{
name: "get_file_info",
description:
"Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) as ToolInput,
},
{
name: "list_allowed_directories",
description:
"Returns the list of directories that this server is allowed to access. " +
"Subdirectories within these allowed directories are also accessible. " +
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: { inputSchema: {
type: "object", path: z.string()
properties: {},
required: [],
}, },
outputSchema: {
content: z.array(z.object({
type: z.enum(["image", "audio", "blob"]),
data: z.string(),
mimeType: z.string()
}))
}
}, },
], async (args: z.infer<typeof ReadMediaFileArgsSchema>) => {
}; const validPath = await validatePath(args.path);
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
switch (name) {
case "read_file":
case "read_text_file": {
const parsed = ReadTextFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_text_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
if (parsed.data.head && parsed.data.tail) {
throw new Error("Cannot specify both head and tail parameters simultaneously");
}
if (parsed.data.tail) {
// Use memory-efficient tail implementation for large files
const tailContent = await tailFile(validPath, parsed.data.tail);
return {
content: [{ type: "text", text: tailContent }],
};
}
if (parsed.data.head) {
// Use memory-efficient head implementation for large files
const headContent = await headFile(validPath, parsed.data.head);
return {
content: [{ type: "text", text: headContent }],
};
}
const content = await readFileContent(validPath);
return {
content: [{ type: "text", text: content }],
};
}
case "read_media_file": {
const parsed = ReadMediaFileArgsSchema.safeParse(args);
if (!parsed.success) {
throw new Error(`Invalid arguments for read_media_file: ${parsed.error}`);
}
const validPath = await validatePath(parsed.data.path);
const extension = path.extname(validPath).toLowerCase(); const extension = path.extname(validPath).toLowerCase();
const mimeTypes: Record<string, string> = { const mimeTypes: Record<string, string> = {
".png": "image/png", ".png": "image/png",
@@ -373,23 +260,41 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}; };
const mimeType = mimeTypes[extension] || "application/octet-stream"; const mimeType = mimeTypes[extension] || "application/octet-stream";
const data = await readFileAsBase64Stream(validPath); const data = await readFileAsBase64Stream(validPath);
const type = mimeType.startsWith("image/") const type = mimeType.startsWith("image/")
? "image" ? "image"
: mimeType.startsWith("audio/") : mimeType.startsWith("audio/")
? "audio" ? "audio"
// Fallback for other binary types, not officially supported by the spec but has been used for some time
: "blob"; : "blob";
const contentItem = { type: type as 'image' | 'audio' | 'blob', data, mimeType };
return { return {
content: [{ type, data, mimeType }], content: [contentItem],
}; structuredContent: { content: [contentItem] }
} as unknown as CallToolResult;
} }
);
case "read_multiple_files": { server.registerTool(
const parsed = ReadMultipleFilesArgsSchema.safeParse(args); "read_multiple_files",
if (!parsed.success) { {
throw new Error(`Invalid arguments for read_multiple_files: ${parsed.error}`); title: "Read Multiple Files",
} description:
"Read the contents of multiple files simultaneously. This is more " +
"efficient than reading files one by one when you need to analyze " +
"or compare multiple files. Each file's content is returned with its " +
"path as a reference. Failed reads for individual files won't stop " +
"the entire operation. Only works within allowed directories.",
inputSchema: {
paths: z.array(z.string())
.min(1)
.describe("Array of file paths to read. Each path must be a string pointing to a valid file within allowed directories.")
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ReadMultipleFilesArgsSchema>) => {
const results = await Promise.all( const results = await Promise.all(
parsed.data.paths.map(async (filePath: string) => { args.paths.map(async (filePath: string) => {
try { try {
const validPath = await validatePath(filePath); const validPath = await validatePath(filePath);
const content = await readFileContent(validPath); const content = await readFileContent(validPath);
@@ -400,68 +305,136 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
} }
}), }),
); );
const text = results.join("\n---\n");
return { return {
content: [{ type: "text", text: results.join("\n---\n") }], content: [{ type: "text" as const, text }],
structuredContent: { content: text }
}; };
} }
);
case "write_file": { server.registerTool(
const parsed = WriteFileArgsSchema.safeParse(args); "write_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for write_file: ${parsed.error}`); title: "Write File",
} description:
const validPath = await validatePath(parsed.data.path); "Create a new file or completely overwrite an existing file with new content. " +
await writeFileContent(validPath, parsed.data.content); "Use with caution as it will overwrite existing files without warning. " +
"Handles text content with proper encoding. Only works within allowed directories.",
inputSchema: {
path: z.string(),
content: z.string()
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof WriteFileArgsSchema>) => {
const validPath = await validatePath(args.path);
await writeFileContent(validPath, args.content);
const text = `Successfully wrote to ${args.path}`;
return { return {
content: [{ type: "text", text: `Successfully wrote to ${parsed.data.path}` }], content: [{ type: "text" as const, text }],
structuredContent: { content: text }
}; };
} }
);
case "edit_file": { server.registerTool(
const parsed = EditFileArgsSchema.safeParse(args); "edit_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for edit_file: ${parsed.error}`); title: "Edit File",
} description:
const validPath = await validatePath(parsed.data.path); "Make line-based edits to a text file. Each edit replaces exact line sequences " +
const result = await applyFileEdits(validPath, parsed.data.edits, parsed.data.dryRun); "with new content. Returns a git-style diff showing the changes made. " +
"Only works within allowed directories.",
inputSchema: {
path: z.string(),
edits: z.array(z.object({
oldText: z.string().describe("Text to search for - must match exactly"),
newText: z.string().describe("Text to replace with")
})),
dryRun: z.boolean().default(false).describe("Preview changes using git-style diff format")
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof EditFileArgsSchema>) => {
const validPath = await validatePath(args.path);
const result = await applyFileEdits(validPath, args.edits, args.dryRun);
return { return {
content: [{ type: "text", text: result }], content: [{ type: "text" as const, text: result }],
structuredContent: { content: result }
}; };
} }
);
case "create_directory": { server.registerTool(
const parsed = CreateDirectoryArgsSchema.safeParse(args); "create_directory",
if (!parsed.success) { {
throw new Error(`Invalid arguments for create_directory: ${parsed.error}`); title: "Create Directory",
} description:
const validPath = await validatePath(parsed.data.path); "Create a new directory or ensure a directory exists. Can create multiple " +
"nested directories in one operation. If the directory already exists, " +
"this operation will succeed silently. Perfect for setting up directory " +
"structures for projects or ensuring required paths exist. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof CreateDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
await fs.mkdir(validPath, { recursive: true }); await fs.mkdir(validPath, { recursive: true });
const text = `Successfully created directory ${args.path}`;
return { return {
content: [{ type: "text", text: `Successfully created directory ${parsed.data.path}` }], content: [{ type: "text" as const, text }],
structuredContent: { content: text }
}; };
} }
);
case "list_directory": { server.registerTool(
const parsed = ListDirectoryArgsSchema.safeParse(args); "list_directory",
if (!parsed.success) { {
throw new Error(`Invalid arguments for list_directory: ${parsed.error}`); title: "List Directory",
} description:
const validPath = await validatePath(parsed.data.path); "Get a detailed listing of all files and directories in a specified path. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is essential for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ListDirectoryArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true }); const entries = await fs.readdir(validPath, { withFileTypes: true });
const formatted = entries const formatted = entries
.map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`) .map((entry) => `${entry.isDirectory() ? "[DIR]" : "[FILE]"} ${entry.name}`)
.join("\n"); .join("\n");
return { return {
content: [{ type: "text", text: formatted }], content: [{ type: "text" as const, text: formatted }],
structuredContent: { content: formatted }
}; };
} }
);
case "list_directory_with_sizes": { server.registerTool(
const parsed = ListDirectoryWithSizesArgsSchema.safeParse(args); "list_directory_with_sizes",
if (!parsed.success) { {
throw new Error(`Invalid arguments for list_directory_with_sizes: ${parsed.error}`); title: "List Directory with Sizes",
} description:
const validPath = await validatePath(parsed.data.path); "Get a detailed listing of all files and directories in a specified path, including sizes. " +
"Results clearly distinguish between files and directories with [FILE] and [DIR] " +
"prefixes. This tool is useful for understanding directory structure and " +
"finding specific files within a directory. Only works within allowed directories.",
inputSchema: {
path: z.string(),
sortBy: z.enum(["name", "size"]).optional().default("name").describe("Sort entries by name or size")
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof ListDirectoryWithSizesArgsSchema>) => {
const validPath = await validatePath(args.path);
const entries = await fs.readdir(validPath, { withFileTypes: true }); const entries = await fs.readdir(validPath, { withFileTypes: true });
// Get detailed information for each entry // Get detailed information for each entry
@@ -489,7 +462,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Sort entries based on sortBy parameter // Sort entries based on sortBy parameter
const sortedEntries = [...detailedEntries].sort((a, b) => { const sortedEntries = [...detailedEntries].sort((a, b) => {
if (parsed.data.sortBy === 'size') { if (args.sortBy === 'size') {
return b.size - a.size; // Descending by size return b.size - a.size; // Descending by size
} }
// Default sort by name // Default sort by name
@@ -514,43 +487,54 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
`Combined size: ${formatSize(totalSize)}` `Combined size: ${formatSize(totalSize)}`
]; ];
const text = [...formattedEntries, ...summary].join("\n");
const contentBlock = { type: "text" as const, text };
return { return {
content: [{ content: [contentBlock],
type: "text", structuredContent: { content: [contentBlock] }
text: [...formattedEntries, ...summary].join("\n")
}],
}; };
} }
);
case "directory_tree": { server.registerTool(
const parsed = DirectoryTreeArgsSchema.safeParse(args); "directory_tree",
if (!parsed.success) { {
throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`); title: "Directory Tree",
} description:
"Get a recursive tree view of files and directories as a JSON structure. " +
"Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " +
"Files have no children array, while directories always have a children array (which may be empty). " +
"The output is formatted with 2-space indentation for readability. Only works within allowed directories.",
inputSchema: {
path: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
interface TreeEntry { interface TreeEntry {
name: string; name: string;
type: 'file' | 'directory'; type: 'file' | 'directory';
children?: TreeEntry[]; children?: TreeEntry[];
} }
const rootPath = parsed.data.path; const rootPath = args.path;
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> { async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
const validPath = await validatePath(currentPath); const validPath = await validatePath(currentPath);
const entries = await fs.readdir(validPath, {withFileTypes: true}); const entries = await fs.readdir(validPath, { withFileTypes: true });
const result: TreeEntry[] = []; const result: TreeEntry[] = [];
for (const entry of entries) { for (const entry of entries) {
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name)); const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
const shouldExclude = excludePatterns.some(pattern => { const shouldExclude = excludePatterns.some(pattern => {
if (pattern.includes('*')) { if (pattern.includes('*')) {
return minimatch(relativePath, pattern, {dot: true}); return minimatch(relativePath, pattern, { dot: true });
} }
// For files: match exact name or as part of path // For files: match exact name or as part of path
// For directories: match as directory path // For directories: match as directory path
return minimatch(relativePath, pattern, {dot: true}) || return minimatch(relativePath, pattern, { dot: true }) ||
minimatch(relativePath, `**/${pattern}`, {dot: true}) || minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
minimatch(relativePath, `**/${pattern}/**`, {dot: true}); minimatch(relativePath, `**/${pattern}/**`, { dot: true });
}); });
if (shouldExclude) if (shouldExclude)
continue; continue;
@@ -571,74 +555,119 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return result; return result;
} }
const treeData = await buildTree(rootPath, parsed.data.excludePatterns); const treeData = await buildTree(rootPath, args.excludePatterns);
const text = JSON.stringify(treeData, null, 2);
const contentBlock = { type: "text" as const, text };
return { return {
content: [{ content: [contentBlock],
type: "text", structuredContent: { content: [contentBlock] }
text: JSON.stringify(treeData, null, 2)
}],
}; };
} }
);
case "move_file": { server.registerTool(
const parsed = MoveFileArgsSchema.safeParse(args); "move_file",
if (!parsed.success) { {
throw new Error(`Invalid arguments for move_file: ${parsed.error}`); title: "Move File",
} description:
const validSourcePath = await validatePath(parsed.data.source); "Move or rename files and directories. Can move files between directories " +
const validDestPath = await validatePath(parsed.data.destination); "and rename them in a single operation. If the destination exists, the " +
"operation will fail. Works across different directories and can be used " +
"for simple renaming within the same directory. Both source and destination must be within allowed directories.",
inputSchema: {
source: z.string(),
destination: z.string()
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof MoveFileArgsSchema>) => {
const validSourcePath = await validatePath(args.source);
const validDestPath = await validatePath(args.destination);
await fs.rename(validSourcePath, validDestPath); await fs.rename(validSourcePath, validDestPath);
const text = `Successfully moved ${args.source} to ${args.destination}`;
const contentBlock = { type: "text" as const, text };
return { return {
content: [{ type: "text", text: `Successfully moved ${parsed.data.source} to ${parsed.data.destination}` }], content: [contentBlock],
structuredContent: { content: [contentBlock] }
}; };
} }
);
case "search_files": { server.registerTool(
const parsed = SearchFilesArgsSchema.safeParse(args); "search_files",
if (!parsed.success) { {
throw new Error(`Invalid arguments for search_files: ${parsed.error}`); title: "Search Files",
} description:
const validPath = await validatePath(parsed.data.path); "Recursively search for files and directories matching a pattern. " +
const results = await searchFilesWithValidation(validPath, parsed.data.pattern, allowedDirectories, { excludePatterns: parsed.data.excludePatterns }); "The patterns should be glob-style patterns that match paths relative to the working directory. " +
"Use pattern like '*.ext' to match files in current directory, and '**/*.ext' to match files in all subdirectories. " +
"Returns full paths to all matching items. Great for finding files when you don't know their exact location. " +
"Only searches within allowed directories.",
inputSchema: {
path: z.string(),
pattern: z.string(),
excludePatterns: z.array(z.string()).optional().default([])
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof SearchFilesArgsSchema>) => {
const validPath = await validatePath(args.path);
const results = await searchFilesWithValidation(validPath, args.pattern, allowedDirectories, { excludePatterns: args.excludePatterns });
const text = results.length > 0 ? results.join("\n") : "No matches found";
return { return {
content: [{ type: "text", text: results.length > 0 ? results.join("\n") : "No matches found" }], content: [{ type: "text" as const, text }],
structuredContent: { content: text }
}; };
} }
);
case "get_file_info": { server.registerTool(
const parsed = GetFileInfoArgsSchema.safeParse(args); "get_file_info",
if (!parsed.success) { {
throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`); title: "Get File Info",
} description:
const validPath = await validatePath(parsed.data.path); "Retrieve detailed metadata about a file or directory. Returns comprehensive " +
"information including size, creation time, last modified time, permissions, " +
"and type. This tool is perfect for understanding file characteristics " +
"without reading the actual content. Only works within allowed directories.",
inputSchema: {
path: z.string()
},
outputSchema: { content: z.string() }
},
async (args: z.infer<typeof GetFileInfoArgsSchema>) => {
const validPath = await validatePath(args.path);
const info = await getFileStats(validPath); const info = await getFileStats(validPath);
return { const text = Object.entries(info)
content: [{ type: "text", text: Object.entries(info)
.map(([key, value]) => `${key}: ${value}`) .map(([key, value]) => `${key}: ${value}`)
.join("\n") }], .join("\n");
};
}
case "list_allowed_directories": {
return { return {
content: [{ content: [{ type: "text" as const, text }],
type: "text", structuredContent: { content: text }
text: `Allowed directories:\n${allowedDirectories.join('\n')}`
}],
}; };
} }
);
default: server.registerTool(
throw new Error(`Unknown tool: ${name}`); "list_allowed_directories",
} {
} catch (error) { title: "List Allowed Directories",
const errorMessage = error instanceof Error ? error.message : String(error); description:
"Returns the list of directories that this server is allowed to access. " +
"Subdirectories within these allowed directories are also accessible. " +
"Use this to understand which directories and their nested paths are available " +
"before trying to access files.",
inputSchema: {},
outputSchema: { content: z.string() }
},
async () => {
const text = `Allowed directories:\n${allowedDirectories.join('\n')}`;
return { return {
content: [{ type: "text", text: `Error: ${errorMessage}` }], content: [{ type: "text" as const, text }],
isError: true, structuredContent: { content: text }
}; };
} }
}); );
// Updates allowed directories based on MCP client roots // Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
@@ -653,10 +682,10 @@ async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
} }
// Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. // Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots.
server.setNotificationHandler(RootsListChangedNotificationSchema, async () => { server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
try { try {
// Request the updated roots list from the client // Request the updated roots list from the client
const response = await server.listRoots(); const response = await server.server.listRoots();
if (response && 'roots' in response) { if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots); await updateAllowedDirectoriesFromRoots(response.roots);
} }
@@ -666,12 +695,12 @@ server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
}); });
// Handles post-initialization setup, specifically checking for and fetching MCP roots. // Handles post-initialization setup, specifically checking for and fetching MCP roots.
server.oninitialized = async () => { server.server.oninitialized = async () => {
const clientCapabilities = server.getClientCapabilities(); const clientCapabilities = server.server.getClientCapabilities();
if (clientCapabilities?.roots) { if (clientCapabilities?.roots) {
try { try {
const response = await server.listRoots(); const response = await server.server.listRoots();
if (response && 'roots' in response) { if (response && 'roots' in response) {
await updateAllowedDirectoriesFromRoots(response.roots); await updateAllowedDirectoriesFromRoots(response.roots);
} else { } else {

View File

@@ -12,6 +12,7 @@
"exclude": [ "exclude": [
"**/__tests__/**", "**/__tests__/**",
"**/*.test.ts", "**/*.test.ts",
"**/*.spec.ts" "**/*.spec.ts",
"vitest.config.ts"
] ]
} }

View File

@@ -1,11 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { import { z } from "zod";
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -226,243 +223,235 @@ export class KnowledgeGraphManager {
let knowledgeGraphManager: KnowledgeGraphManager; let knowledgeGraphManager: KnowledgeGraphManager;
// Zod schemas for entities and relations
const EntitySchema = z.object({
name: z.string().describe("The name of the entity"),
entityType: z.string().describe("The type of the entity"),
observations: z.array(z.string()).describe("An array of observation contents associated with the entity")
});
const RelationSchema = z.object({
from: z.string().describe("The name of the entity where the relation starts"),
to: z.string().describe("The name of the entity where the relation ends"),
relationType: z.string().describe("The type of the relation")
});
// The server instance and tools exposed to Claude // The server instance and tools exposed to Claude
const server = new Server({ const server = new McpServer({
name: "memory-server", name: "memory-server",
version: "0.6.3", version: "0.6.3",
}, { });
capabilities: {
tools: {},
},
},);
server.setRequestHandler(ListToolsRequestSchema, async () => { // Register create_entities tool
return { server.registerTool(
tools: [ "create_entities",
{ {
name: "create_entities", title: "Create Entities",
description: "Create multiple new entities in the knowledge graph", description: "Create multiple new entities in the knowledge graph",
inputSchema: { inputSchema: {
type: "object", entities: z.array(EntitySchema)
properties: {
entities: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string", description: "The name of the entity" },
entityType: { type: "string", description: "The type of the entity" },
observations: {
type: "array",
items: { type: "string" },
description: "An array of observation contents associated with the entity"
},
},
required: ["name", "entityType", "observations"],
additionalProperties: false,
},
},
},
required: ["entities"],
additionalProperties: false,
}, },
outputSchema: {
entities: z.array(EntitySchema)
}
}, },
async ({ entities }) => {
const result = await knowledgeGraphManager.createEntities(entities);
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
structuredContent: { entities: result }
};
}
);
// Register create_relations tool
server.registerTool(
"create_relations",
{ {
name: "create_relations", title: "Create Relations",
description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice",
inputSchema: { inputSchema: {
type: "object", relations: z.array(RelationSchema)
properties: {
relations: {
type: "array",
items: {
type: "object",
properties: {
from: { type: "string", description: "The name of the entity where the relation starts" },
to: { type: "string", description: "The name of the entity where the relation ends" },
relationType: { type: "string", description: "The type of the relation" },
},
required: ["from", "to", "relationType"],
additionalProperties: false,
},
},
},
required: ["relations"],
additionalProperties: false,
}, },
outputSchema: {
relations: z.array(RelationSchema)
}
}, },
async ({ relations }) => {
const result = await knowledgeGraphManager.createRelations(relations);
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
structuredContent: { relations: result }
};
}
);
// Register add_observations tool
server.registerTool(
"add_observations",
{ {
name: "add_observations", title: "Add Observations",
description: "Add new observations to existing entities in the knowledge graph", description: "Add new observations to existing entities in the knowledge graph",
inputSchema: { inputSchema: {
type: "object", observations: z.array(z.object({
properties: { entityName: z.string().describe("The name of the entity to add the observations to"),
observations: { contents: z.array(z.string()).describe("An array of observation contents to add")
type: "array", }))
items: {
type: "object",
properties: {
entityName: { type: "string", description: "The name of the entity to add the observations to" },
contents: {
type: "array",
items: { type: "string" },
description: "An array of observation contents to add"
},
},
required: ["entityName", "contents"],
additionalProperties: false,
},
},
},
required: ["observations"],
additionalProperties: false,
}, },
outputSchema: {
results: z.array(z.object({
entityName: z.string(),
addedObservations: z.array(z.string())
}))
}
}, },
async ({ observations }) => {
const result = await knowledgeGraphManager.addObservations(observations);
return {
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
structuredContent: { results: result }
};
}
);
// Register delete_entities tool
server.registerTool(
"delete_entities",
{ {
name: "delete_entities", title: "Delete Entities",
description: "Delete multiple entities and their associated relations from the knowledge graph", description: "Delete multiple entities and their associated relations from the knowledge graph",
inputSchema: { inputSchema: {
type: "object", entityNames: z.array(z.string()).describe("An array of entity names to delete")
properties: {
entityNames: {
type: "array",
items: { type: "string" },
description: "An array of entity names to delete"
},
},
required: ["entityNames"],
additionalProperties: false,
}, },
outputSchema: {
success: z.boolean(),
message: z.string()
}
}, },
async ({ entityNames }) => {
await knowledgeGraphManager.deleteEntities(entityNames);
return {
content: [{ type: "text" as const, text: "Entities deleted successfully" }],
structuredContent: { success: true, message: "Entities deleted successfully" }
};
}
);
// Register delete_observations tool
server.registerTool(
"delete_observations",
{ {
name: "delete_observations", title: "Delete Observations",
description: "Delete specific observations from entities in the knowledge graph", description: "Delete specific observations from entities in the knowledge graph",
inputSchema: { inputSchema: {
type: "object", deletions: z.array(z.object({
properties: { entityName: z.string().describe("The name of the entity containing the observations"),
deletions: { observations: z.array(z.string()).describe("An array of observations to delete")
type: "array", }))
items: {
type: "object",
properties: {
entityName: { type: "string", description: "The name of the entity containing the observations" },
observations: {
type: "array",
items: { type: "string" },
description: "An array of observations to delete"
},
},
required: ["entityName", "observations"],
additionalProperties: false,
},
},
},
required: ["deletions"],
additionalProperties: false,
}, },
outputSchema: {
success: z.boolean(),
message: z.string()
}
}, },
async ({ deletions }) => {
await knowledgeGraphManager.deleteObservations(deletions);
return {
content: [{ type: "text" as const, text: "Observations deleted successfully" }],
structuredContent: { success: true, message: "Observations deleted successfully" }
};
}
);
// Register delete_relations tool
server.registerTool(
"delete_relations",
{ {
name: "delete_relations", title: "Delete Relations",
description: "Delete multiple relations from the knowledge graph", description: "Delete multiple relations from the knowledge graph",
inputSchema: { inputSchema: {
type: "object", relations: z.array(RelationSchema).describe("An array of relations to delete")
properties: {
relations: {
type: "array",
items: {
type: "object",
properties: {
from: { type: "string", description: "The name of the entity where the relation starts" },
to: { type: "string", description: "The name of the entity where the relation ends" },
relationType: { type: "string", description: "The type of the relation" },
},
required: ["from", "to", "relationType"],
additionalProperties: false,
},
description: "An array of relations to delete"
},
},
required: ["relations"],
additionalProperties: false,
}, },
outputSchema: {
success: z.boolean(),
message: z.string()
}
}, },
async ({ relations }) => {
await knowledgeGraphManager.deleteRelations(relations);
return {
content: [{ type: "text" as const, text: "Relations deleted successfully" }],
structuredContent: { success: true, message: "Relations deleted successfully" }
};
}
);
// Register read_graph tool
server.registerTool(
"read_graph",
{ {
name: "read_graph", title: "Read Graph",
description: "Read the entire knowledge graph", description: "Read the entire knowledge graph",
inputSchema: { inputSchema: {},
type: "object", outputSchema: {
properties: {}, entities: z.array(EntitySchema),
additionalProperties: false, relations: z.array(RelationSchema)
}, }
}, },
async () => {
const graph = await knowledgeGraphManager.readGraph();
return {
content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
structuredContent: { ...graph }
};
}
);
// Register search_nodes tool
server.registerTool(
"search_nodes",
{ {
name: "search_nodes", title: "Search Nodes",
description: "Search for nodes in the knowledge graph based on a query", description: "Search for nodes in the knowledge graph based on a query",
inputSchema: { inputSchema: {
type: "object", query: z.string().describe("The search query to match against entity names, types, and observation content")
properties: {
query: { type: "string", description: "The search query to match against entity names, types, and observation content" },
},
required: ["query"],
additionalProperties: false,
}, },
outputSchema: {
entities: z.array(EntitySchema),
relations: z.array(RelationSchema)
}
}, },
async ({ query }) => {
const graph = await knowledgeGraphManager.searchNodes(query);
return {
content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
structuredContent: { ...graph }
};
}
);
// Register open_nodes tool
server.registerTool(
"open_nodes",
{ {
name: "open_nodes", title: "Open Nodes",
description: "Open specific nodes in the knowledge graph by their names", description: "Open specific nodes in the knowledge graph by their names",
inputSchema: { inputSchema: {
type: "object", names: z.array(z.string()).describe("An array of entity names to retrieve")
properties: {
names: {
type: "array",
items: { type: "string" },
description: "An array of entity names to retrieve",
}, },
outputSchema: {
entities: z.array(EntitySchema),
relations: z.array(RelationSchema)
}
}, },
required: ["names"], async ({ names }) => {
additionalProperties: false, const graph = await knowledgeGraphManager.openNodes(names);
}, return {
}, content: [{ type: "text" as const, text: JSON.stringify(graph, null, 2) }],
], structuredContent: { ...graph }
}; };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "read_graph") {
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.readGraph(), null, 2) }] };
} }
);
if (!args) {
throw new Error(`No arguments provided for tool: ${name}`);
}
switch (name) {
case "create_entities":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.createEntities(args.entities as Entity[]), null, 2) }] };
case "create_relations":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.createRelations(args.relations as Relation[]), null, 2) }] };
case "add_observations":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.addObservations(args.observations as { entityName: string; contents: string[] }[]), null, 2) }] };
case "delete_entities":
await knowledgeGraphManager.deleteEntities(args.entityNames as string[]);
return { content: [{ type: "text", text: "Entities deleted successfully" }] };
case "delete_observations":
await knowledgeGraphManager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[]);
return { content: [{ type: "text", text: "Observations deleted successfully" }] };
case "delete_relations":
await knowledgeGraphManager.deleteRelations(args.relations as Relation[]);
return { content: [{ type: "text", text: "Relations deleted successfully" }] };
case "search_nodes":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query as string), null, 2) }] };
case "open_nodes":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names as string[]), null, 2) }] };
default:
throw new Error(`Unknown tool: ${name}`);
}
});
async function main() { async function main() {
// Initialize memory file path with backward compatibility // Initialize memory file path with backward compatibility

View File

@@ -6,6 +6,9 @@
}, },
"include": [ "include": [
"./**/*.ts" "./**/*.ts"
],
"exclude": [
"**/*.test.ts",
"vitest.config.ts"
] ]
} }

View File

@@ -22,107 +22,8 @@ describe('SequentialThinkingServer', () => {
server = new SequentialThinkingServer(); server = new SequentialThinkingServer();
}); });
describe('processThought - validation', () => { // Note: Input validation tests removed - validation now happens at the tool
it('should reject input with missing thought', () => { // registration layer via Zod schemas before processThought is called
const input = {
thoughtNumber: 1,
totalThoughts: 3,
nextThoughtNeeded: true
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid thought');
});
it('should reject input with non-string thought', () => {
const input = {
thought: 123,
thoughtNumber: 1,
totalThoughts: 3,
nextThoughtNeeded: true
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid thought');
});
it('should reject input with missing thoughtNumber', () => {
const input = {
thought: 'Test thought',
totalThoughts: 3,
nextThoughtNeeded: true
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid thoughtNumber');
});
it('should reject input with non-number 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('Invalid thoughtNumber');
});
it('should reject input with missing totalThoughts', () => {
const input = {
thought: 'Test thought',
thoughtNumber: 1,
nextThoughtNeeded: true
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid totalThoughts');
});
it('should reject input with non-number totalThoughts', () => {
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('Invalid totalThoughts');
});
it('should reject input with missing nextThoughtNeeded', () => {
const input = {
thought: 'Test thought',
thoughtNumber: 1,
totalThoughts: 3
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid nextThoughtNeeded');
});
it('should reject input with non-boolean nextThoughtNeeded', () => {
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('Invalid nextThoughtNeeded');
});
});
describe('processThought - valid inputs', () => { describe('processThought - valid inputs', () => {
it('should accept valid basic thought', () => { it('should accept valid basic thought', () => {
@@ -275,19 +176,6 @@ describe('SequentialThinkingServer', () => {
}); });
describe('processThought - edge cases', () => { describe('processThought - edge cases', () => {
it('should reject empty thought string', () => {
const input = {
thought: '',
thoughtNumber: 1,
totalThoughts: 1,
nextThoughtNeeded: false
};
const result = server.processThought(input);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid thought');
});
it('should handle very long thought strings', () => { it('should handle very long thought strings', () => {
const input = { const input = {
thought: 'a'.repeat(10000), thought: 'a'.repeat(10000),
@@ -349,25 +237,6 @@ describe('SequentialThinkingServer', () => {
expect(result.content[0]).toHaveProperty('text'); expect(result.content[0]).toHaveProperty('text');
}); });
it('should return correct error structure on failure', () => {
const input = {
thought: 'Test',
thoughtNumber: 1,
totalThoughts: 1
// missing nextThoughtNeeded
};
const result = server.processThought(input);
expect(result).toHaveProperty('isError', true);
expect(result).toHaveProperty('content');
expect(Array.isArray(result.content)).toBe(true);
const errorData = JSON.parse(result.content[0].text);
expect(errorData).toHaveProperty('error');
expect(errorData).toHaveProperty('status', 'failed');
});
it('should return valid JSON in response', () => { it('should return valid JSON in response', () => {
const input = { const input = {
thought: 'Test thought', thought: 'Test thought',

View File

@@ -1,16 +1,21 @@
#!/usr/bin/env node #!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { import { z } from "zod";
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { SequentialThinkingServer } from './lib.js'; import { SequentialThinkingServer } from './lib.js';
const SEQUENTIAL_THINKING_TOOL: Tool = { const server = new McpServer({
name: "sequentialthinking", name: "sequential-thinking-server",
version: "0.2.0",
});
const thinkingServer = new SequentialThinkingServer();
server.registerTool(
"sequentialthinking",
{
title: "Sequential Thinking",
description: `A detailed tool for dynamic and reflective problem-solving through thoughts. description: `A detailed tool for dynamic and reflective problem-solving through thoughts.
This tool helps analyze problems through a flexible thinking process that can adapt and evolve. This tool helps analyze problems through a flexible thinking process that can adapt and evolve.
Each thought can build on, question, or revise previous insights as understanding deepens. Each thought can build on, question, or revise previous insights as understanding deepens.
@@ -37,13 +42,13 @@ Key features:
Parameters explained: Parameters explained:
- thought: Your current thinking step, which can include: - thought: Your current thinking step, which can include:
* Regular analytical steps * Regular analytical steps
* Revisions of previous thoughts * Revisions of previous thoughts
* Questions about previous decisions * Questions about previous decisions
* Realizations about needing more analysis * Realizations about needing more analysis
* Changes in approach * Changes in approach
* Hypothesis generation * Hypothesis generation
* Hypothesis verification * Hypothesis verification
- nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end - nextThoughtNeeded: True if you need more thinking, even if at what seemed like the end
- thoughtNumber: Current number in sequence (can go beyond initial total if needed) - thoughtNumber: Current number in sequence (can go beyond initial total if needed)
- totalThoughts: Current estimate of thoughts needed (can be adjusted up/down) - totalThoughts: Current estimate of thoughts needed (can be adjusted up/down)
@@ -66,84 +71,40 @@ You should:
10. Provide a single, ideally correct answer as the final output 10. Provide a single, ideally correct answer as the final output
11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`, 11. Only set next_thought_needed to false when truly done and a satisfactory answer is reached`,
inputSchema: { inputSchema: {
type: "object", thought: z.string().describe("Your current thinking step"),
properties: { nextThoughtNeeded: z.boolean().describe("Whether another thought step is needed"),
thought: { thoughtNumber: z.number().int().min(1).describe("Current thought number (numeric value, e.g., 1, 2, 3)"),
type: "string", totalThoughts: z.number().int().min(1).describe("Estimated total thoughts needed (numeric value, e.g., 5, 10)"),
description: "Your current thinking step" isRevision: z.boolean().optional().describe("Whether this revises previous thinking"),
revisesThought: z.number().int().min(1).optional().describe("Which thought is being reconsidered"),
branchFromThought: z.number().int().min(1).optional().describe("Branching point thought number"),
branchId: z.string().optional().describe("Branch identifier"),
needsMoreThoughts: z.boolean().optional().describe("If more thoughts are needed")
}, },
nextThoughtNeeded: { outputSchema: {
type: "boolean", thoughtNumber: z.number(),
description: "Whether another thought step is needed" totalThoughts: z.number(),
nextThoughtNeeded: z.boolean(),
branches: z.array(z.string()),
thoughtHistoryLength: z.number()
}, },
thoughtNumber: {
type: "integer",
description: "Current thought number (numeric value, e.g., 1, 2, 3)",
minimum: 1
}, },
totalThoughts: { async (args) => {
type: "integer", const result = thinkingServer.processThought(args);
description: "Estimated total thoughts needed (numeric value, e.g., 5, 10)",
minimum: 1
},
isRevision: {
type: "boolean",
description: "Whether this revises previous thinking"
},
revisesThought: {
type: "integer",
description: "Which thought is being reconsidered",
minimum: 1
},
branchFromThought: {
type: "integer",
description: "Branching point thought number",
minimum: 1
},
branchId: {
type: "string",
description: "Branch identifier"
},
needsMoreThoughts: {
type: "boolean",
description: "If more thoughts are needed"
}
},
required: ["thought", "nextThoughtNeeded", "thoughtNumber", "totalThoughts"]
}
};
const server = new Server( if (result.isError) {
{ return result;
name: "sequential-thinking-server",
version: "0.2.0",
},
{
capabilities: {
tools: {},
},
} }
);
const thinkingServer = new SequentialThinkingServer(); // Parse the JSON response to get structured content
const parsedContent = JSON.parse(result.content[0].text);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [SEQUENTIAL_THINKING_TOOL],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "sequentialthinking") {
return thinkingServer.processThought(request.params.arguments);
}
return { return {
content: [{ content: result.content,
type: "text", structuredContent: parsedContent
text: `Unknown tool: ${request.params.name}`
}],
isError: true
}; };
}); }
);
async function runServer() { async function runServer() {
const transport = new StdioServerTransport(); const transport = new StdioServerTransport();

View File

@@ -21,35 +21,6 @@ export class SequentialThinkingServer {
this.disableThoughtLogging = (process.env.DISABLE_THOUGHT_LOGGING || "").toLowerCase() === "true"; this.disableThoughtLogging = (process.env.DISABLE_THOUGHT_LOGGING || "").toLowerCase() === "true";
} }
private validateThoughtData(input: unknown): ThoughtData {
const data = input as Record<string, unknown>;
if (!data.thought || typeof data.thought !== 'string') {
throw new Error('Invalid thought: must be a string');
}
if (!data.thoughtNumber || typeof data.thoughtNumber !== 'number') {
throw new Error('Invalid thoughtNumber: must be a number');
}
if (!data.totalThoughts || typeof data.totalThoughts !== 'number') {
throw new Error('Invalid totalThoughts: must be a number');
}
if (typeof data.nextThoughtNeeded !== 'boolean') {
throw new Error('Invalid nextThoughtNeeded: must be a boolean');
}
return {
thought: data.thought,
thoughtNumber: data.thoughtNumber,
totalThoughts: data.totalThoughts,
nextThoughtNeeded: data.nextThoughtNeeded,
isRevision: data.isRevision as boolean | undefined,
revisesThought: data.revisesThought as number | undefined,
branchFromThought: data.branchFromThought as number | undefined,
branchId: data.branchId as string | undefined,
needsMoreThoughts: data.needsMoreThoughts as boolean | undefined,
};
}
private formatThought(thoughtData: ThoughtData): string { private formatThought(thoughtData: ThoughtData): string {
const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } = thoughtData; const { thoughtNumber, totalThoughts, thought, isRevision, revisesThought, branchFromThought, branchId } = thoughtData;
@@ -78,35 +49,35 @@ export class SequentialThinkingServer {
${border}`; ${border}`;
} }
public processThought(input: unknown): { content: Array<{ type: string; text: string }>; isError?: boolean } { public processThought(input: ThoughtData): { content: Array<{ type: "text"; text: string }>; isError?: boolean } {
try { try {
const validatedInput = this.validateThoughtData(input); // Validation happens at the tool registration layer via Zod
// Adjust totalThoughts if thoughtNumber exceeds it
if (validatedInput.thoughtNumber > validatedInput.totalThoughts) { if (input.thoughtNumber > input.totalThoughts) {
validatedInput.totalThoughts = validatedInput.thoughtNumber; input.totalThoughts = input.thoughtNumber;
} }
this.thoughtHistory.push(validatedInput); this.thoughtHistory.push(input);
if (validatedInput.branchFromThought && validatedInput.branchId) { if (input.branchFromThought && input.branchId) {
if (!this.branches[validatedInput.branchId]) { if (!this.branches[input.branchId]) {
this.branches[validatedInput.branchId] = []; this.branches[input.branchId] = [];
} }
this.branches[validatedInput.branchId].push(validatedInput); this.branches[input.branchId].push(input);
} }
if (!this.disableThoughtLogging) { if (!this.disableThoughtLogging) {
const formattedThought = this.formatThought(validatedInput); const formattedThought = this.formatThought(input);
console.error(formattedThought); console.error(formattedThought);
} }
return { return {
content: [{ content: [{
type: "text", type: "text" as const,
text: JSON.stringify({ text: JSON.stringify({
thoughtNumber: validatedInput.thoughtNumber, thoughtNumber: input.thoughtNumber,
totalThoughts: validatedInput.totalThoughts, totalThoughts: input.totalThoughts,
nextThoughtNeeded: validatedInput.nextThoughtNeeded, nextThoughtNeeded: input.nextThoughtNeeded,
branches: Object.keys(this.branches), branches: Object.keys(this.branches),
thoughtHistoryLength: this.thoughtHistory.length thoughtHistoryLength: this.thoughtHistory.length
}, null, 2) }, null, 2)
@@ -115,7 +86,7 @@ export class SequentialThinkingServer {
} catch (error) { } catch (error) {
return { return {
content: [{ content: [{
type: "text", type: "text" as const,
text: JSON.stringify({ text: JSON.stringify({
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
status: 'failed' status: 'failed'

View File

@@ -2,9 +2,13 @@
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": ".", "rootDir": "."
"moduleResolution": "NodeNext",
"module": "NodeNext"
}, },
"include": ["./**/*.ts"] "include": [
"./**/*.ts"
],
"exclude": [
"**/*.test.ts",
"vitest.config.ts"
]
} }