mirror of
https://github.com/modelcontextprotocol/servers.git
synced 2026-04-25 23:35:19 +02:00
Merge branch 'main' into convert-everything-to-modern-api
This commit is contained in:
11
.github/workflows/claude.yml
vendored
11
.github/workflows/claude.yml
vendored
@@ -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.
|
|
||||||
|
|||||||
14
.github/workflows/typescript.yml
vendored
14
.github/workflows/typescript.yml
vendored
@@ -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]
|
||||||
|
|||||||
@@ -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 xAI’s 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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"**/__tests__/**",
|
"**/__tests__/**",
|
||||||
"**/*.test.ts",
|
"**/*.test.ts",
|
||||||
"**/*.spec.ts"
|
"**/*.spec.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./**/*.ts"
|
"./**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"**/*.test.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user