diff --git a/docker-compose.yml b/docker-compose.yml index 573f56e..90416a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,6 +158,22 @@ services: - opencontext-network restart: unless-stopped + # Mock Server for testing + open-context-mock-server: + build: + context: ./mcp-adapter + dockerfile: Dockerfile + image: opencontext/mcp-adapter:0.1.0 + container_name: open-context-mock-server + ports: + - "8001:8001" + command: ["npm", "run", "mock"] + environment: + - MOCK_SERVER_PORT=8001 + networks: + - opencontext-network + restart: unless-stopped + # Node.js MCP Adapter open-context-mcp-adapter: build: @@ -165,10 +181,19 @@ services: dockerfile: Dockerfile image: opencontext/mcp-adapter:0.1.0 container_name: open-context-mcp-adapter + ports: + - "3000:3000" + environment: + - OPENCONTEXT_CORE_URL=http://open-context-mock-server:8001 + - OPENCONTEXT_DEFAULT_TOP_K=5 + - OPENCONTEXT_DEFAULT_MAX_TOKENS=25000 + - MCP_SERVER_PORT=3000 + - MCP_MODE=http depends_on: - - open-context-core + - open-context-mock-server networks: - opencontext-network + command: ["node", "dist/index.js", "--transport", "http", "--port", "3000"] restart: unless-stopped volumes: diff --git a/mcp-adapter/.dockerignore b/mcp-adapter/.dockerignore index a98fdec..3b3d6a3 100644 --- a/mcp-adapter/.dockerignore +++ b/mcp-adapter/.dockerignore @@ -35,3 +35,10 @@ Thumbs.db # Docker Dockerfile .dockerignore + +# Build output +dist + +# Test coverage +.nyc_output + diff --git a/mcp-adapter/.gitignore b/mcp-adapter/.gitignore index a6c4445..e185053 100644 --- a/mcp-adapter/.gitignore +++ b/mcp-adapter/.gitignore @@ -34,4 +34,7 @@ logs *.log # Optional REPL history -.node_repl_history \ No newline at end of file +.node_repl_history + +# Build output +dist/ \ No newline at end of file diff --git a/mcp-adapter/Dockerfile b/mcp-adapter/Dockerfile index f6cf8db..8473139 100644 --- a/mcp-adapter/Dockerfile +++ b/mcp-adapter/Dockerfile @@ -7,14 +7,29 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies (only devDependencies for nodemon) -RUN npm install +# Install dependencies (including dev dependencies for build) +RUN npm ci # Copy source code COPY . . -# Expose port +# Build TypeScript +RUN npm run build + +# Remove dev dependencies to reduce image size +RUN npm prune --production + +# Create non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nodejs -u 1001 +USER nodejs + +# Expose port (if needed for health checks) EXPOSE 3000 -# Start the application -CMD ["node", "index.js"] +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "console.log('MCP Server health check passed')" || exit 1 + +# Start MCP server +CMD ["node", "dist/index.js"] diff --git a/mcp-adapter/index.js b/mcp-adapter/index.js deleted file mode 100644 index 5f316c1..0000000 --- a/mcp-adapter/index.js +++ /dev/null @@ -1,71 +0,0 @@ -const http = require('http'); - -// Simple MCP Adapter - Docker Health Check Only -const server = http.createServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.writeHead(200); - res.end(); - return; - } - - if (req.method === 'GET' && req.url === '/health') { - const health = { - status: 'healthy', - service: 'mcp-adapter', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - version: '1.0.0' - }; - - res.writeHead(200); - res.end(JSON.stringify(health, null, 2)); - } else if (req.method === 'GET' && req.url === '/') { - const info = { - service: 'OpenContext MCP Adapter', - version: '1.0.0', - status: 'running', - endpoints: { - health: '/health', - info: '/' - } - }; - - res.writeHead(200); - res.end(JSON.stringify(info, null, 2)); - } else { - res.writeHead(404); - res.end(JSON.stringify({ - error: 'Endpoint not found', - available: ['/', '/health'] - }, null, 2)); - } -}); - -const PORT = process.env.PORT || 3000; -server.listen(PORT, '0.0.0.0', () => { - console.log(`šŸš€ MCP Adapter running on port ${PORT}`); - console.log(`šŸ“Š Health check: http://localhost:${PORT}/health`); - console.log(`ā„¹ļø Info: http://localhost:${PORT}/`); -}); - -// Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM received, shutting down gracefully'); - server.close(() => { - console.log('Server closed'); - process.exit(0); - }); -}); - -process.on('SIGINT', () => { - console.log('SIGINT received, shutting down gracefully'); - server.close(() => { - console.log('Server closed'); - process.exit(0); - }); -}); diff --git a/mcp-adapter/index.ts b/mcp-adapter/index.ts new file mode 100644 index 0000000..ef99817 --- /dev/null +++ b/mcp-adapter/index.ts @@ -0,0 +1,343 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { findKnowledge, getContent } from "./lib/api.js"; +import { createServer } from "http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Command } from "commander"; +import { IncomingMessage } from "http"; + +// Environment variables for default values +const DEFAULT_TOP_K = parseInt(process.env.OPENCONTEXT_DEFAULT_TOP_K || '5', 10); +const DEFAULT_MAX_TOKENS = parseInt(process.env.OPENCONTEXT_DEFAULT_MAX_TOKENS || '25000', 10); + +// Parse CLI arguments using commander +const program = new Command() + .option("--transport ", "transport type", "stdio") + .option("--port ", "port for HTTP transport", "3000") + .allowUnknownOption() // let MCP Inspector / other wrappers pass through extra flags + .parse(process.argv); + +const cliOptions = program.opts<{ + transport: string; + port: string; +}>(); + +// Validate transport option +const allowedTransports = ["stdio", "http"]; +if (!allowedTransports.includes(cliOptions.transport)) { + console.error( + `Invalid --transport value: '${cliOptions.transport}'. Must be one of: stdio, http.` + ); + process.exit(1); +} + +// Transport configuration +const TRANSPORT_TYPE = (cliOptions.transport || "stdio") as "stdio" | "http"; + +// HTTP port configuration - CLI arguments first, environment variables second +const CLI_PORT = (() => { + const parsed = parseInt(cliOptions.port, 10); + if (!isNaN(parsed)) return parsed; + + // Use environment variable if CLI argument is not provided + const envPort = parseInt(process.env.MCP_SERVER_PORT || '3000', 10); + return isNaN(envPort) ? 3000 : envPort; +})(); + +function getClientIp(req: IncomingMessage): string | undefined { + // Check both possible header casings + const forwardedFor = req.headers["x-forwarded-for"] || req.headers["X-Forwarded-For"]; + + if (forwardedFor) { + // X-Forwarded-For can contain multiple IPs + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + const ipList = ips.split(",").map((ip) => ip.trim()); + + // Find the first public IP address + for (const ip of ipList) { + const plainIp = ip.replace(/^::ffff:/, ""); + if ( + !plainIp.startsWith("10.") && + !plainIp.startsWith("192.168.") && + !/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(plainIp) + ) { + return plainIp; + } + } + // If all are private, use the first one + return ipList[0].replace(/^::ffff:/, ""); + } + + // Fallback: use remote address, strip IPv6-mapped IPv4 + if (req.socket?.remoteAddress) { + return req.socket.remoteAddress.replace(/^::ffff:/, ""); + } + return undefined; +} + +function createServerInstance(clientIp?: string) { + const server = new McpServer( + { + name: "OpenContext MCP Adapter", + version: "1.0.0" + }, + { + instructions: "Use this server to access and search a secure, local knowledge base compiled from the user's private documents and technical specifications. This is the authoritative source of truth for answering questions about the user's specific codebase, internal architecture, or project-related documentation, as it contains proprietary and context-rich information not available on the public internet." + } + ); + + // OpenContext MCP: find_knowledge tool + server.registerTool( + "find_knowledge", + { + title: "Find Knowledge", + description: `This is the first-stage search tool for identifying the most relevant knowledge chunk ID (chunkId) based on the user's query. The primary purpose of this tool is to identify the best candidate for the subsequent get_content tool. + +Process: +It performs an initial search for a list of relevant knowledge chunk candidates based on the user's natural language question. +The LLM must analyze the returned list (which includes titles, snippets, and relevance scores) to select the single best item that most closely matches the user's intent. +The chunkId from the selected item must be used immediately to call the 'get_content tool'. + +Usage Rule: +This tool must always be called first when the user asks a question about their internal documents or specific projects.`, + inputSchema: { + query: z.string().describe("User's original natural language search query (e.g., 'Spring Security JWT filter implementation')"), + topK: z.number().optional().describe(`Maximum number of results to return (default: ${DEFAULT_TOP_K})`) + } + }, + async ({ query, topK = DEFAULT_TOP_K }) => { + const clientInfo = clientIp ? ` from ${clientIp}` : ""; + console.log(`[MCP] find_knowledge called${clientInfo} with query: "${query}", topK: ${topK}`); + + try { + const result = await findKnowledge(query, topK); + + if (result.error) { + console.error(`[MCP] find_knowledge error${clientInfo}: ${result.error.message}`); + return { + content: [{ + type: "text", + text: `Error occurred during search: ${result.error.message}` + }] + }; + } + + if (!result.results || result.results.length === 0) { + return { + content: [{ + type: "text", + text: "No knowledge chunks found related to the search query." + }] + }; + } + + const resultsText = result.results.map((item, index) => { + return `> **Result ${index + 1}: ${item.title}**\n> - **Relevance:** ${(item.relevanceScore * 100).toFixed(1)}%\n> - **Snippet:** ${item.snippet}\n> - **ChunkID:** \`${item.chunkId}\``; + }).join("\n\n"); + + const responseText = `Search complete. The following knowledge chunks were found. The LLM's next action is to select the best ChunkID and call the 'get_content' tool.\n\n${resultsText}`; + + console.log(`[MCP] find_knowledge returned ${result.results.length} results${clientInfo}`); + return { + content: [{ + type: "text", + text: responseText + }] + }; + + } catch (error) { + console.error(`[MCP] find_knowledge unexpected error${clientInfo}:`, error); + return { + content: [{ + type: "text", + text: `Unexpected error occurred during search processing: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + } + ); + + // OpenContext MCP: get_content tool + server.registerTool( + "get_content", + { + title: "Get Content", + description: `This is the second-stage tool that retrieves the full, original text content of a specific chunk using a \`chunkId\` obtained from the 'find_knowledge' tool. + +Prerequisite: +- You must call 'find_knowledge' first to get a valid \`chunkId\`. This tool cannot be used without it. + +Action: +- It takes a single \`chunkId\` as input. +- It returns the complete text content, which serves as the decisive context for the LLM to generate the final, detailed answer for the user.`, + inputSchema: { + chunkId: z.string().describe("The single, most relevant chunkId selected from the results of the 'find_knowledge' tool"), + maxTokens: z.number().optional().describe(`The maximum number of tokens for the returned content (default: ${DEFAULT_MAX_TOKENS})`) + } + }, + async ({ chunkId, maxTokens = DEFAULT_MAX_TOKENS }) => { + const clientInfo = clientIp ? ` from ${clientIp}` : ""; + console.log(`[MCP] get_content called${clientInfo} with chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + try { + const result = await getContent(chunkId, maxTokens); + + if (result.error) { + console.error(`[MCP] get_content error${clientInfo}: ${result.error.message}`); + return { + content: [{ + type: "text", + text: `Error occurred while retrieving content: ${result.error.message}` + }] + }; + } + + const tokenInfo = result.tokenInfo ? + `\n\n**Token Information:**\n- Tokenizer: ${result.tokenInfo.tokenizer}\n- Actual Token Count: ${result.tokenInfo.actualTokens}` : + ""; + + const responseText = result.content; + + console.log(`[MCP] get_content successfully returned content with ${result.tokenInfo?.actualTokens || 'unknown'} tokens${clientInfo}`); + return { + content: [{ + type: "text", + text: responseText + }] + }; + + } catch (error) { + console.error(`[MCP] get_content unexpected error${clientInfo}:`, error); + return { + content: [{ + type: "text", + text: `Unexpected error occurred during content processing: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + } + ); + + return server; +} + +async function main() { + const transportType = TRANSPORT_TYPE; + + if (transportType === "http") { + // Get initial port from environment or use default + const initialPort = CLI_PORT ?? 3000; + // Keep track of which port we end up using + let actualPort = initialPort; + + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "", `http://${req.headers.host}`).pathname; + + // Set CORS headers for all responses + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, MCP-Session-Id, mcp-session-id, MCP-Protocol-Version" + ); + res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id"); + + // Handle preflight OPTIONS requests + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + + try { + // Extract client IP address using socket remote address (most reliable) + const clientIp = getClientIp(req); + + // Create new server instance for each request + const requestServer = createServerInstance(clientIp); + + if (url === "/mcp") { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await requestServer.connect(transport); + await transport.handleRequest(req, res); + } else if (url === "/ping") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("pong"); + } else if (url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'OpenContext MCP Adapter', + mode: 'http', + timestamp: new Date().toISOString() + })); + } else if (url === "/info") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + name: "OpenContext MCP Adapter", + version: "1.0.0", + mode: "http", + port: actualPort, + defaultTopK: DEFAULT_TOP_K, + defaultMaxTokens: DEFAULT_MAX_TOKENS, + endpoints: { + health: '/health', + info: '/info', + mcp: '/mcp' + } + })); + } else { + res.writeHead(404); + res.end("Not found"); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } + }); + + // Function to attempt server listen with port fallback + const startServer = (port: number, maxAttempts = 10) => { + httpServer.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) { + console.warn(`Port ${port} is in use, trying port ${port + 1}...`); + startServer(port + 1, maxAttempts); + } else { + console.error(`Failed to start server: ${err.message}`); + process.exit(1); + } + }); + + httpServer.listen(port, () => { + actualPort = port; + console.log(`[Server] OpenContext MCP Server running on HTTP mode at http://localhost:${actualPort}/mcp`); + console.log(`[Server] Health check: http://localhost:${actualPort}/health`); + console.log(`[Server] Server info: http://localhost:${actualPort}/info`); + }); + }; + + // Start the server with initial port + startServer(initialPort); + } else { + // Stdio transport - this is already stateless by nature + const server = createServerInstance(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.log("[Server] OpenContext MCP Server running on stdio"); + } +} + +main().catch((error) => { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Fatal] Unhandled error in main(): ${errorMsg}`); + if (error instanceof Error && error.stack) { + console.error(`[Fatal] Stack trace:`, error.stack); + } + process.exit(1); +}); \ No newline at end of file diff --git a/mcp-adapter/lib/api.ts b/mcp-adapter/lib/api.ts new file mode 100644 index 0000000..bd485d8 --- /dev/null +++ b/mcp-adapter/lib/api.ts @@ -0,0 +1,168 @@ +import { + KnowledgeSearchResponse, + ContentResponse, + CommonResponse, + SearchApiResponse, + GetContentApiResponse +} from "./types.js"; + +const CORE_URL = process.env.OPENCONTEXT_CORE_URL || 'http://localhost:8080'; + +/** + * Implementation of OpenContext MCP's find_knowledge tool + * Calls GET /api/v1/search API to search for relevant knowledge chunks. + */ +export async function findKnowledge(query: string, topK: number = 5): Promise { + if (!query || query.trim().length === 0) { + console.warn("[findKnowledge] Empty query provided, returning empty results"); + return { results: [] }; + } + + const url = new URL(`${CORE_URL}/api/v1/search`); + url.searchParams.set("query", query.trim()); + if (topK) { + url.searchParams.set("topK", topK.toString()); + } + + console.log(`[findKnowledge] Searching for knowledge with query: "${query}", topK: ${topK}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Accept": "application/json", + "User-Agent": "OpenContext-MCP-Adapter/1.0.0" + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unable to read error response"); + console.error(`[findKnowledge] HTTP ${response.status} ${response.statusText}: ${errorText}`); + console.error(`[findKnowledge] Request URL: ${url.toString()}`); + return { + results: [], + error: { + code: "SEARCH_FAILED", + message: `Search failed with status ${response.status}: ${errorText}` + } + }; + } + + const result: CommonResponse = await response.json(); + + if (!result.success) { + console.error(`[findKnowledge] API returned error: ${result.message}`); + return { + results: [], + error: { + code: result.errorCode || "SEARCH_FAILED", + message: result.message || "Search failed" + } + }; + } + + console.log(`[findKnowledge] Found ${result.data?.results.length || 0} knowledge chunks`); + return { results: result.data?.results || [] }; + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[findKnowledge] Network/Parse error: ${errorMsg}`); + console.error(`[findKnowledge] Query: "${query}", URL: ${url.toString()}`); + return { + results: [], + error: { + code: "NETWORK_ERROR", + message: `Network error: ${errorMsg}` + } + }; + } +} + +/** + * Implementation of OpenContext MCP's get_content tool + * Calls POST /api/v1/get-content API to retrieve the full content of a specific chunk. + */ +export async function getContent(chunkId: string, maxTokens: number = 25000): Promise { + if (!chunkId || chunkId.trim().length === 0) { + console.warn("[getContent] Empty chunkId provided"); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "VALIDATION_FAILED", + message: "Chunk ID is required" + } + }; + } + + const url = `${CORE_URL}/api/v1/get-content`; + const body: { chunkId: string; maxTokens?: number } = { + chunkId: chunkId.trim() + }; + + if (maxTokens) { + body.maxTokens = maxTokens; + } + + console.log(`[getContent] Fetching content for chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "OpenContext-MCP-Adapter/1.0.0" + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unable to read error response"); + const errorMsg = `HTTP ${response.status} ${response.statusText}: ${errorText}`; + console.error(`[getContent] ${errorMsg}`); + console.error(`[getContent] Request body:`, JSON.stringify(body, null, 2)); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "CONTENT_FETCH_FAILED", + message: `Failed to fetch content. ${errorMsg}` + } + }; + } + + const result: CommonResponse = await response.json(); + + if (!result.success) { + console.error(`[getContent] API returned error: ${result.message}`); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: result.errorCode || "CONTENT_FETCH_FAILED", + message: result.message || "Content fetch failed" + } + }; + } + + console.log(`[getContent] Successfully fetched content with ${result.data?.tokenInfo?.actualTokens || 'unknown'} tokens`); + return { + content: result.data?.content || "", + tokenInfo: result.data?.tokenInfo || { tokenizer: "", actualTokens: 0 } + }; + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[getContent] Network/Parse error: ${errorMsg}`); + console.error(`[getContent] ChunkId: "${chunkId}"`); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "NETWORK_ERROR", + message: `Network error: ${errorMsg}` + } + }; + } +} \ No newline at end of file diff --git a/mcp-adapter/lib/types.ts b/mcp-adapter/lib/types.ts new file mode 100644 index 0000000..52c1281 --- /dev/null +++ b/mcp-adapter/lib/types.ts @@ -0,0 +1,64 @@ +/** + * Response type for OpenContext MCP's find_knowledge tool + */ +export interface KnowledgeSearchResult { + chunkId: string; // Unique identifier for the chunk (UUID) + title: string; // Title of the chunk + snippet: string; // Text extracted from the beginning of the chunk content with a certain length + relevanceScore: number; // Relevance score with the search query (0.0 ~ 1.0) +} + +/** + * Response structure for the find_knowledge tool + */ +export interface KnowledgeSearchResponse { + results: KnowledgeSearchResult[]; + error?: { + code: string; + message: string; + }; +} + +/** + * Response type for OpenContext MCP's get_content tool + */ +export interface ContentResponse { + content: string; // Original content of the requested chunk + tokenInfo: { + tokenizer: string; // Name of the tokenizer used to calculate token count + actualTokens: number; // Actual token count of the content + }; + error?: { + code: string; + message: string; + }; +} + +/** + * Standard API response structure for OpenContext Core + */ +export interface CommonResponse { + success: boolean; + data: T | null; + message: string; + errorCode: string | null; + timestamp: string; +} + +/** + * Search API response data from OpenContext Core + */ +export interface SearchApiResponse { + results: KnowledgeSearchResult[]; +} + +/** + * Content API response data from OpenContext Core + */ +export interface GetContentApiResponse { + content: string; + tokenInfo: { + tokenizer: string; + actualTokens: number; + }; +} \ No newline at end of file diff --git a/mcp-adapter/mock-server.ts b/mcp-adapter/mock-server.ts new file mode 100644 index 0000000..38ed388 --- /dev/null +++ b/mcp-adapter/mock-server.ts @@ -0,0 +1,312 @@ +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { URL } from 'url'; + +// Mock find_knowledge API - GET /api/v1/search +function handleSearch(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url || '', `http://${req.headers.host}`); + const query = url.searchParams.get('query'); + const topK = parseInt(url.searchParams.get('topK') || '5', 10); + + console.log(`[Mock Server] find_knowledge called with query: "${query}", topK: ${topK}`); + + // Simulate different responses based on query + let mockResults = []; + + if (query?.includes('Spring Security') || query?.includes('JWT')) { + mockResults = [ + { + chunkId: "spring-security-jwt-1", + title: "Spring Security JWT Filter Implementation", + snippet: "This guide covers implementing JWT authentication filters in Spring Security, including token validation and user authentication...", + relevanceScore: 0.95 + }, + { + chunkId: "spring-security-jwt-2", + title: "JWT Token Configuration in Spring Boot", + snippet: "Configure JWT token settings, expiration times, and signing keys in your Spring Boot application...", + relevanceScore: 0.88 + } + ]; + } else if (query?.includes('Elasticsearch') || query?.includes('ź²€ģƒ‰')) { + mockResults = [ + { + chunkId: "elasticsearch-search-1", + title: "Elasticsearch Search Configuration", + snippet: "Learn how to configure Elasticsearch for optimal search performance and relevance scoring...", + relevanceScore: 0.92 + } + ]; + } else { + // Default mock response + mockResults = [ + { + chunkId: "mock-chunk-1", + title: `Mock Result for: ${query}`, + snippet: "This is a mock snippet for testing purposes. The query was processed successfully by the mock server.", + relevanceScore: 0.85 + }, + { + chunkId: "mock-chunk-2", + title: "Another Mock Result", + snippet: "Additional mock data to test the topK parameter functionality.", + relevanceScore: 0.75 + } + ]; + } + + // Limit results based on topK parameter + const limitedResults = mockResults.slice(0, topK); + + const response = { + success: true, + data: { + results: limitedResults + }, + message: "Search completed successfully", + errorCode: null, + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); +} + +// Mock get_content API - POST /api/v1/get-content +function handleGetContent(req: IncomingMessage, res: ServerResponse) { + let body = ''; + + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const { chunkId, maxTokens = 25000 } = JSON.parse(body); + + console.log(`[Mock Server] get_content called with chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + // Simulate different content based on chunkId + let mockContent = ""; + + if (chunkId === "spring-security-jwt-1") { + mockContent = `# Spring Security JWT Filter Implementation + +## Overview +This comprehensive guide covers implementing JWT (JSON Web Token) authentication filters in Spring Security applications. + +## Key Components + +### 1. JWT Filter Class +\`\`\`java +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // JWT token extraction and validation logic + String token = extractTokenFromRequest(request); + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } +} +\`\`\` + +### 2. Token Provider +\`\`\`java +@Component +public class JwtTokenProvider { + @Value("\${jwt.secret}") + private String jwtSecret; + + public String generateToken(Authentication authentication) { + UserDetails userPrincipal = (UserDetails) authentication.getPrincipal(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + 86400000); // 24 hours + + return Jwts.builder() + .setSubject(userPrincipal.getUsername()) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(SignatureAlgorithm.HS512, jwtSecret) + .compact(); + } +} +\`\`\` + +## Configuration +Add the filter to your SecurityConfig: +\`\`\`java +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} +\`\`\` + +This implementation provides secure JWT-based authentication for your Spring Boot application.`; + + } else if (chunkId === "elasticsearch-search-1") { + mockContent = `# Elasticsearch Search Configuration + +## Search Optimization +Learn how to configure Elasticsearch for optimal search performance and relevance scoring. + +## Key Settings +- Index mapping optimization +- Analyzer configuration +- Relevance scoring tuning +- Query performance optimization + +This content demonstrates how to configure Elasticsearch for the best search experience.`; + + } else { + // Default mock content + mockContent = `# Mock Content for Testing + +## Chunk ID: ${chunkId} +This is mock content generated by the test server for testing purposes. + +## Content Details +- **Chunk ID**: ${chunkId} +- **Max Tokens**: ${maxTokens} +- **Generated At**: ${new Date().toISOString()} + +## Test Information +This mock content is designed to test the mcp-adapter functionality without requiring a real OpenContext Core server. + +You can use this to verify: +1. Tool parameter handling +2. Response formatting +3. Error handling +4. Token counting + +The content is structured to provide realistic test data that mimics actual knowledge base responses.`; + } + + // Simulate token counting (simple word count for demo) + const actualTokens = Math.min(mockContent.split(/\s+/).length, maxTokens); + + const response = { + success: true, + data: { + content: mockContent, + tokenInfo: { + tokenizer: "mock-tokenizer", + actualTokens: actualTokens + } + }, + message: "Content retrieved successfully", + errorCode: null, + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); + + } catch (error) { + console.error('[Mock Server] Error parsing request body:', error); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Invalid JSON in request body', + errorCode: 'INVALID_JSON', + timestamp: new Date().toISOString() + })); + } + }); +} + +// Health check endpoint +function handleHealth(req: IncomingMessage, res: ServerResponse) { + const response = { + status: 'healthy', + service: 'OpenContext Mock Server', + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); +} + +// CORS headers +function setCorsHeaders(res: ServerResponse) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +} + +// Main server logic +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + setCorsHeaders(res); + + // Handle preflight OPTIONS request + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + const url = new URL(req.url || '', `http://${req.headers.host}`); + const path = url.pathname; + + console.log(`[Mock Server] ${req.method} ${path}`); + + try { + if (req.method === 'GET' && path === '/api/v1/search') { + handleSearch(req, res); + } else if (req.method === 'POST' && path === '/api/v1/get-content') { + handleGetContent(req, res); + } else if (req.method === 'GET' && path === '/health') { + handleHealth(req, res); + } else { + // 404 handler + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Endpoint not found', + errorCode: 'NOT_FOUND', + timestamp: new Date().toISOString() + })); + } + } catch (error) { + console.error('[Mock Server] Error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Internal server error', + errorCode: 'INTERNAL_ERROR', + timestamp: new Date().toISOString() + })); + } +}); + +const PORT = parseInt(process.env.MOCK_SERVER_PORT || '8080', 10); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`šŸš€ OpenContext Mock Server running on http://0.0.0.0:${PORT}`); + console.log(`šŸ“– Available endpoints:`); + console.log(` GET /api/v1/search?query=&topK=`); + console.log(` POST /api/v1/get-content`); + console.log(` GET /health`); + console.log(`\nšŸ’” Test with: export OPENCONTEXT_CORE_URL=http://localhost:${PORT}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\nšŸ›‘ Shutting down mock server...'); + server.close(() => { + console.log('āœ… Mock server stopped'); + process.exit(0); + }); +}); diff --git a/mcp-adapter/package.json b/mcp-adapter/package.json index 0e0cb31..e6f7cad 100644 --- a/mcp-adapter/package.json +++ b/mcp-adapter/package.json @@ -2,17 +2,34 @@ "name": "mcp-adapter", "version": "1.0.0", "description": "OpenContext MCP Adapter", - "main": "index.js", + "main": "dist/index.js", + "type": "module", "scripts": { - "start": "node index.js", - "dev": "nodemon index.js" + "build": "tsc", + "start": "tsx index.ts", + "dev": "tsx index.ts", + "mock": "node dist/mock-server.js", + "clean": "rm -rf dist" }, - "keywords": ["mcp", "adapter"], - "author": "OpenContext Team", + "keywords": [ + "mcp", + "model-client-protocol", + "context-protocol", + "adapter", + "opencontext", + "rag" + ], + "author": "OpenContext Development Team", "license": "MIT", - "dependencies": {}, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.1", + "commander": "^14.0.0", + "zod": "^3.25.76" + }, "devDependencies": { - "nodemon": "^3.0.1" + "@types/node": "^24.0.0", + "tsx": "^4.20.3", + "typescript": "^5.9.2" }, "engines": { "node": ">=18.0.0" diff --git a/mcp-adapter/tsconfig.json b/mcp-adapter/tsconfig.json new file mode 100644 index 0000000..b8a4917 --- /dev/null +++ b/mcp-adapter/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "allowJs": true, + + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "**/*.ts", + "**/*.js" + ], + "exclude": [ + "node_modules", + "dist" + ] +}