diff --git a/.changeset/sixty-regions-tickle.md b/.changeset/sixty-regions-tickle.md new file mode 100644 index 000000000..a03a66926 --- /dev/null +++ b/.changeset/sixty-regions-tickle.md @@ -0,0 +1,5 @@ +--- +"@sei-js/mcp-server": patch +--- + +Added main docs search tool diff --git a/docs/mcp-server/context.mdx b/docs/mcp-server/context.mdx index 53a93b8bb..8abf1a293 100644 --- a/docs/mcp-server/context.mdx +++ b/docs/mcp-server/context.mdx @@ -1,14 +1,25 @@ --- title: "Documentation Context" -description: "How the MCP server reads @sei-js documentation to provide intelligent assistance" +description: "How the MCP server provides intelligent assistance through comprehensive documentation search" icon: "book-open" --- -The Sei MCP Server includes intelligent documentation search that reads the entire @sei-js ecosystem documentation to provide context-aware responses and code generation. +The Sei MCP Server includes intelligent documentation search that provides access to both the main Sei documentation and the entire @sei-js ecosystem documentation for context-aware responses and code generation. ## Documentation Access -The MCP server can search and reference documentation for all @sei-js packages: +The MCP server provides two search tools: + +### Main Sei Documentation (`search_docs`) +Search the official Sei documentation for: +- General chain information and network details +- Ecosystem providers and integrations +- User onboarding guides and tutorials +- Bridging and cross-chain operations +- DeFi protocols and marketplace information + +### Sei-JS Package Documentation (`search_sei_js_docs`) +Search and reference documentation for all @sei-js packages: - **@sei-js/precompiles** - Precompile contract reference and integration examples - **@sei-js/sei-global-wallet** - EIP-6963 wallet standard implementation diff --git a/docs/mcp-server/introduction.mdx b/docs/mcp-server/introduction.mdx index e48a22bae..60852e475 100644 --- a/docs/mcp-server/introduction.mdx +++ b/docs/mcp-server/introduction.mdx @@ -70,7 +70,7 @@ The Model Context Protocol is an open standard that connects AI systems with cus - **Real-time blockchain data access** - Get current balances, transaction history, and network status directly from Sei - **Secure by default** - Runs in read-only mode until you explicitly enable wallet tools - **Full execution and write operations** - Deploy contracts, execute transactions, and interact with smart contracts (with wallet configured) -- **Up-to-date documentation access** - Search and understand the latest Sei documentation and guides +- **Up-to-date documentation access** - Search both main Sei docs and Sei-JS package documentation for comprehensive guidance - **Specialized blockchain capabilities** - Access Sei-specific features like precompiles and native token operations The Sei MCP Server leverages this protocol to bring comprehensive blockchain functionality directly to your AI assistant, enabling natural language interactions with the Sei network. @@ -91,13 +91,14 @@ The Sei MCP Server leverages this protocol to bring comprehensive blockchain fun - Access 16+ blockchain tools covering token management, NFT operations, smart contract interactions, and network monitoring. + Access 29 blockchain tools covering token management, NFT operations, smart contract interactions, network monitoring, and documentation search. ## Example Queries ```text +"How do I bridge tokens to Sei from Ethereum?" "How do I query staking my delegations using Viem?" "Generate a wallet connection component using Sei Global Wallet" "Help me set up a new Sei project with the latest patterns" diff --git a/docs/mcp-server/tools.mdx b/docs/mcp-server/tools.mdx index 2eb4d9b94..3949f2499 100644 --- a/docs/mcp-server/tools.mdx +++ b/docs/mcp-server/tools.mdx @@ -1,10 +1,10 @@ --- title: "Available Tools" -description: "Complete reference of 28 MCP tools for blockchain operations" +description: "Complete reference of 29 MCP tools for blockchain operations" icon: "wrench" --- -The Sei MCP Server provides 28 tools for blockchain operations. **By default, wallet tools are disabled** and only read-only tools are available. [Enable wallet tools](/mcp-server/setup#wallet-connection) to unlock transaction capabilities. +The Sei MCP Server provides 29 tools for blockchain operations. **By default, wallet tools are disabled** and only read-only tools are available. [Enable wallet tools](/mcp-server/setup#wallet-connection) to unlock transaction capabilities. **Wallet Tools Disabled by Default** @@ -113,6 +113,7 @@ These tools require [wallet configuration](/mcp-server/setup#wallet-connection) | Tool | Purpose | Example Usage | |------|---------|---------------| +| `search_docs` | Search the main Sei docs for general chain information, ecosystem providers, and user onboarding guides | "How do I bridge tokens to Sei?" | | `search_sei_js_docs` | Search Sei-JS documentation | "How do I use precompiles with Viem?" | ## Enabling Wallet Tools diff --git a/packages/mcp-server/src/docs/index.ts b/packages/mcp-server/src/docs/index.ts new file mode 100644 index 000000000..506c2ecd4 --- /dev/null +++ b/packages/mcp-server/src/docs/index.ts @@ -0,0 +1 @@ +export { createDocsSearchTool } from './server.js'; diff --git a/packages/mcp-server/src/docs/server.ts b/packages/mcp-server/src/docs/server.ts new file mode 100644 index 000000000..cc0d057a6 --- /dev/null +++ b/packages/mcp-server/src/docs/server.ts @@ -0,0 +1,67 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import type { SeiSearchResponse } from '../mintlify/types'; + +const DOCS_SEARCH_URL = 'https://docs.sei-apis.io/search'; + +export const createDocsSearchTool = async (server: McpServer) => { + server.tool( + 'search_docs', + 'Search the main Sei docs for general chain information, ecosystem providers, and user onboarding guides. Useful for all queries for up-to-date information about Sei.', + { + query: z.string() + }, + async ({ query }) => { + try { + const results = await searchDocs(query); + const content = results.map((result) => { + const { title, content, link } = result; + const text = `Title: ${title}\nContent: ${content}\nLink: ${link}`; + return { + type: 'text' as const, + text + }; + }); + return { + content + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error searching docs: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + } + ); +}; + +const searchDocs = async (query: string): Promise => { + const url = `${DOCS_SEARCH_URL}?q=${encodeURIComponent(query)}`; + + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json() as SeiSearchResponse[]; + + if (!data || data.length === 0) { + throw new Error('No results found'); + } + + return data; + } catch (error) { + if (error instanceof Error) { + throw new Error(`Search failed: ${error.message}`); + } + + throw error; + } +}; diff --git a/packages/mcp-server/src/mintlify/search.ts b/packages/mcp-server/src/mintlify/search.ts index f5032cfcc..4cdc283a2 100644 --- a/packages/mcp-server/src/mintlify/search.ts +++ b/packages/mcp-server/src/mintlify/search.ts @@ -4,7 +4,7 @@ import { TrieveSDK } from 'trieve-ts-sdk'; import { z } from 'zod'; import { DEFAULT_BASE_URL, SERVER_URL, SUBDOMAIN } from './config.js'; -import type { MintlifySearchConfig, SeiJSSearchResult, TrieveResponse, TrieveSearchResult } from './types.js'; +import type { MintlifySearchConfig, SeiSearchResponse, TrieveResponse, TrieveSearchResult } from './types.js'; import { formatErr, throwOnAxiosError } from './utils.js'; /** @@ -28,7 +28,7 @@ const fetchMintlifyConfig = async (subdomain: string): Promise => { +const searchSeiJSDocs = async (query: string, config: MintlifySearchConfig): Promise => { const trieve = new TrieveSDK({ apiKey: config.trieveApiKey, datasetId: config.trieveDatasetId, @@ -65,7 +65,7 @@ export const createSeiJSDocsSearchTool = async (server: McpServer): Promise { // Restore console.error console.error = originalConsoleError; }); + + test('should return false if there is a non-Error object thrown', async () => { + // Import mocked modules + const { readContract } = await import('../../../core/services/contracts.js'); + const { utils } = await import('../../../core/services/utils.js'); + + // Configure mocks for this test + (readContract as jest.Mock).mockImplementation(() => { + throw 'String error message'; // Non-Error object + }); + (utils.validateAddress as jest.Mock).mockImplementation((address) => address as `0x${string}`); + + // Mock console.error to avoid cluttering test output + const originalConsoleError = console.error; + console.error = () => {}; + + // Call the function + const result = await isNFTOwner(VALID_TOKEN_ADDRESS, VALID_OWNER_ADDRESS, 1n); + + // Verify results + expect(result).toBe(false); + + // Restore console.error + console.error = originalConsoleError; + }); }); describe('getERC721Balance', () => { diff --git a/packages/mcp-server/src/tests/core/tools.test.ts b/packages/mcp-server/src/tests/core/tools.test.ts index dec096775..a029547fe 100644 --- a/packages/mcp-server/src/tests/core/tools.test.ts +++ b/packages/mcp-server/src/tests/core/tools.test.ts @@ -20,8 +20,13 @@ import { getPrivateKeyAsHex, isWalletEnabled, getWalletMode } from '../../core/c import { registerEVMTools } from '../../core/tools.js'; import * as services from '../../core/services/index.js'; import { getWalletProvider } from '../../core/wallet/index.js'; +import { createDocsSearchTool } from '../../docs/index.js'; import { createMockServer, setupBalanceMocks, setupTransactionMocks, testToolError, testToolSuccess, verifyErrorResponse, verifySuccessResponse, type Tool } from './helpers/tool-test-helpers.js'; +// Mock fetch globally +const mockFetch = jest.fn(); +global.fetch = mockFetch as jest.MockedFunction; + // Mock all service functions jest.mock('../../core/services/index.js'); jest.mock('../../core/chains.js'); @@ -40,7 +45,7 @@ describe('EVM Tools', () => { let server: McpServer; let registeredTools: Map; - beforeEach(() => { + beforeEach(async () => { // Create fresh mock server for each test const mockServerResult = createMockServer(); server = mockServerResult.server; @@ -70,6 +75,12 @@ describe('EVM Tools', () => { // Register tools after mocks are set up registerEVMTools(server); + // Register docs search tool + await createDocsSearchTool(server); + + // Reset fetch mock + mockFetch.mockReset(); + // Mock formatJson function // Create a type for the helpers object to avoid read-only property error type ServiceHelpers = typeof services.helpers; @@ -2919,5 +2930,214 @@ describe('EVM Tools', () => { expect(response.content[0].text).toContain('Address does not own this NFT'); }); + + // Documentation Search Tests + describe('Documentation Search Tools', () => { + test('search_docs - successful search with results', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + const mockResults = [ + { + title: 'Getting Started', + content: 'Learn how to get started with Sei', + link: 'https://docs.sei.io/getting-started' + }, + { + title: 'Bridging Tokens', + content: 'How to bridge tokens to Sei', + link: 'https://docs.sei.io/bridging' + } + ]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResults) + } as unknown as Response); + + const response = await testToolSuccess(tool, { query: 'bridging tokens' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://docs.sei-apis.io/search?q=bridging%20tokens' + ); + + expect(response).toHaveProperty('content'); + expect(response.content).toHaveLength(2); + expect(response.content[0]).toEqual({ + type: 'text', + text: 'Title: Getting Started\nContent: Learn how to get started with Sei\nLink: https://docs.sei.io/getting-started' + }); + expect(response.content[1]).toEqual({ + type: 'text', + text: 'Title: Bridging Tokens\nContent: How to bridge tokens to Sei\nLink: https://docs.sei.io/bridging' + }); + }); + + test('search_docs - HTTP error response', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404 + } as unknown as Response); + + const response = await tool.handler({ query: 'test query' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Search failed: HTTP error! status: 404'); + }); + + test('search_docs - empty results', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce([]) + } as unknown as Response); + + const response = await tool.handler({ query: 'nonexistent' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Search failed: No results found'); + }); + + test('search_docs - null results', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(null) + } as unknown as Response); + + const response = await tool.handler({ query: 'test' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Search failed: No results found'); + }); + + test('search_docs - network error', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const response = await tool.handler({ query: 'test' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Search failed: Network error'); + }); + + test('search_docs - unknown error type', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockRejectedValueOnce('Unknown error'); + + const response = await tool.handler({ query: 'test' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Unknown error'); + }); + + test('search_docs - JSON parsing error', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockRejectedValueOnce(new Error('Invalid JSON')) + } as unknown as Response); + + const response = await tool.handler({ query: 'test' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: Search failed: Invalid JSON'); + }); + + test('search_docs - non-Error object thrown', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + // Mock fetch to throw a non-Error object that will pass through searchDocs + mockFetch.mockRejectedValueOnce({ message: 'Custom error object' }); + + const response = await tool.handler({ query: 'test' }); + + expect(response).toHaveProperty('isError', true); + expect(response.content[0].text).toContain('Error searching docs: [object Object]'); + }); + + test('search_docs - properly encode query parameters', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + const mockResults = [{ + title: 'Test Result', + content: 'Test content', + link: 'https://docs.sei.io/test' + }]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResults) + } as unknown as Response); + + await tool.handler({ query: 'special chars: @#$%^&*()' }); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://docs.sei-apis.io/search?q=special%20chars%3A%20%40%23%24%25%5E%26*()' + ); + }); + + test('search_docs - single result', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + const mockResults = [{ + title: 'Single Result', + content: 'Single content', + link: 'https://docs.sei.io/single' + }]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResults) + } as unknown as Response); + + const response = await testToolSuccess(tool, { query: 'single' }); + + expect(response.content).toHaveLength(1); + expect(response.content[0]).toEqual({ + type: 'text', + text: 'Title: Single Result\nContent: Single content\nLink: https://docs.sei.io/single' + }); + }); + + test('search_docs - results with special characters', async () => { + const tool = checkToolExists('search_docs'); + if (!tool) return; + + const mockResults = [{ + title: 'Special & Characters', + content: 'Content with "quotes" and & symbols', + link: 'https://docs.sei.io/special' + }]; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResults) + } as unknown as Response); + + const response = await testToolSuccess(tool, { query: 'special' }); + + expect(response.content[0]).toEqual({ + type: 'text', + text: 'Title: Special & Characters\nContent: Content with "quotes" and & symbols\nLink: https://docs.sei.io/special' + }); + }); + }); }); });