diff --git a/devguide.md b/devguide.md index 7b85eb7..56b062c 100644 --- a/devguide.md +++ b/devguide.md @@ -2,7 +2,7 @@ Notes to self and contributors on how to develop and release Temba. -This repo is a monorepo containing the workspaces `packages/cli`, `packages/temba`, `docs`, and `examples`. +This repo is a monorepo containing the workspaces `packages/cli`, `packages/temba`, `packages/mcp`, `docs`, and `examples`. > [!IMPORTANT] > As this is a monorepo, all commands are always called from the monorepo root folder, @@ -13,7 +13,7 @@ This repo is a monorepo containing the workspaces `packages/cli`, `packages/temb You can run these commands directly from the root: ```bash -npm test # Runs tests for the Temba library +npm test # Runs tests for the Temba library and MCP package npm run lint # Runs linting for the Temba library ``` @@ -23,6 +23,12 @@ Or combine them in one go: npm run check ``` +To run only the MCP package tests: + +```bash +npm test -w packages/mcp +``` + ## MongoDB E2E testing To also run the integration tests against a real MongoDB, you need a local MongoDB @@ -113,4 +119,28 @@ Write your release notes. Commit and push the remaining changes in your feature branch. -Merge the PR to `main`. \ No newline at end of file +Merge the PR to `main`. + +## Publishing the MCP package + +The MCP package is versioned independently from Temba, the CLI, examples, and docs. Do not include it in the shared `./publish.sh` release flow. + +To publish a new MCP version from the root folder: + +```bash +./publish-mcp.sh [patch|minor|major] +``` + +Use `--dry-run` to inspect the release steps without changing the version or publishing: + +```bash +./publish-mcp.sh patch --dry-run +``` + +The script bumps only `packages/mcp/package.json`, updates `packages/mcp/version.js`, publishes only the `packages/mcp` workspace, then commits the MCP package version and lockfile changes. + +For the first npm publish of an already prepared version, publish the workspace directly instead of bumping again: + +```bash +npm publish -w packages/mcp +``` diff --git a/docs/docs/getting-started.md b/docs/docs/getting-started.md index ed0bffd..7c2f2b4 100644 --- a/docs/docs/getting-started.md +++ b/docs/docs/getting-started.md @@ -64,6 +64,10 @@ server.start() ✅ Server listening on port 8362 ``` +### Temba Docs MCP + +If you use AI tools while building with Temba, point them at the [Temba Docs MCP](/docs/mcp) so they can use the latest documentation and current best practices. + ### Configuration To opt-out or customize Temba's workings, pass a `config` object to the `create` function. Check out the individual feature pages in the sidebar, or the [config settings overview](/docs/overview#config-settings-overview). diff --git a/docs/docs/mcp.md b/docs/docs/mcp.md new file mode 100644 index 0000000..c4e9eea --- /dev/null +++ b/docs/docs/mcp.md @@ -0,0 +1,35 @@ +--- +id: mcp +title: Temba Docs MCP +sidebar_position: 2 +--- + +# Temba Docs MCP + +You can ensure your AI tools have current Temba knowledge through the Temba Docs MCP (Model Context Protocol) server. This provides real-time access to the latest documentation, helping AI tools avoid outdated recommendations and ensuring they understand current best practices. + +Unlike AI models trained on static data, the MCP server provides access to the latest Temba documentation. The server is free and open-source. + +# Installation + +The setup process varies depending on your AI development tool. You may see some tools refer to MCP servers as connectors, adapters, extensions, or plugins. + +- [ChatGPT](https://platform.openai.com/docs/mcp#test-and-connect-your-mcp-server) +- [Claude.ai / Claude Desktop](https://support.anthropic.com/en/articles/10168395-setting-up-integrations-on-claude-ai#h_cda40ecb32) +- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code/mcp) +- [Claude Code GitHub Action](https://github.com/anthropics/claude-code-action?tab=readme-ov-file#using-custom-mcp-configuration) +- [Codex CLI](https://developers.openai.com/codex/mcp) +- [Cursor](https://docs.cursor.com/context/mcp) +- [Gemini CLI](https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md) +- [GitHub Copilot Coding Agent](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp) +- [Google Antigravity](https://antigravity.google/) +- [Opencode AI](https://opencode.ai/) +- [Raycast](https://manual.raycast.com/model-context-protocol) +- [Visual Studio Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) +- [Warp](https://docs.warp.dev/knowledge-and-collaboration/mcp) +- [Windsurf](https://docs.windsurf.com/windsurf/cascade/mcp#mcp-config-json) +- [Zed](https://zed.dev/docs/ai/mcp) + +### Usage + +Once configured, you can ask your AI tool questions about Temba, and it will retrieve information directly from the latest docs. Coding agents will be able to consult the latest documentation when performing coding tasks, and chatbots will be able to accurately answer questions about Temba features, APIs, and best practices. diff --git a/docs/plugins/docusaurus-plugin-ai-docs/README.md b/docs/plugins/docusaurus-plugin-ai-docs/README.md index b7b0226..7112073 100644 --- a/docs/plugins/docusaurus-plugin-ai-docs/README.md +++ b/docs/plugins/docusaurus-plugin-ai-docs/README.md @@ -10,6 +10,9 @@ This plugin implements the "Third Audience" pattern for AI agents and LLM crawle 2. **Auto-Discovery**: Injects `` tags in every HTML page's `` pointing to the corresponding Markdown file. - Example: `` +3. **Search Index**: Generates a static JSON index of the documentation for client-side or external search tooling. + - Example: `https://temba.bouwe.io/search_index.json` + ## How It Works The plugin uses two key mechanisms: @@ -19,6 +22,7 @@ During the `postBuild` lifecycle hook, the plugin: - Scans the `docs/` directory for all `.md` and `.mdx` files - Extracts document IDs from frontmatter to determine URL routes - Copies each Markdown file to the build output directory at the correct path +- Generates `search_index.json` with each page's title, URL, keywords, and Markdown content - Injects `` tags directly into the generated HTML files ### 2. URL Matching @@ -52,6 +56,7 @@ After building, your HTML pages will include meta tags like this: And the corresponding Markdown file will be accessible: - HTML: `https://temba.bouwe.io/docs/documentation` - Markdown: `https://temba.bouwe.io/docs/documentation.md` +- Search index: `https://temba.bouwe.io/search_index.json` ## Testing @@ -73,6 +78,9 @@ curl http://localhost:4444/docs/api/functions/create.md # Test meta tag injection curl http://localhost:4444/docs/getting-started.html | grep "text/markdown" curl http://localhost:4444/docs/api/functions/create.html | grep "text/markdown" + +# Test search index generation +curl http://localhost:4444/search_index.json ``` Expected output: @@ -88,6 +96,16 @@ title: Documentation # Meta tags + +# Search index +[ + { + "title": "Getting Started", + "url": "/docs/getting-started", + "keywords": [], + "content": "# Getting Started\n\nPrerequisites you need to have:\n..." + } +] ``` ## Benefits diff --git a/docs/plugins/docusaurus-plugin-ai-docs/index.js b/docs/plugins/docusaurus-plugin-ai-docs/index.js index 1405012..333c6c4 100644 --- a/docs/plugins/docusaurus-plugin-ai-docs/index.js +++ b/docs/plugins/docusaurus-plugin-ai-docs/index.js @@ -1,6 +1,103 @@ const fs = require('fs-extra'); const path = require('path'); -const glob = require('glob'); + +async function findMarkdownFiles(directory, rootDirectory = directory) { + const entries = await fs.readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + files.push(...await findMarkdownFiles(entryPath, rootDirectory)); + continue; + } + + if (entry.isFile() && /\.(md|mdx)$/.test(entry.name)) { + files.push(path.relative(rootDirectory, entryPath)); + } + } + + return files.sort(); +} + +function parseFrontmatter(content) { + const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); + + if (!match) { + return { data: {}, body: content }; + } + + const data = {}; + const frontmatter = match[1]; + + for (const line of frontmatter.split('\n')) { + const fieldMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + + if (fieldMatch) { + data[fieldMatch[1]] = fieldMatch[2].trim(); + } + } + + return { + data, + body: content.slice(match[0].length), + }; +} + +function stripQuotes(value) { + return value.replace(/^['"]|['"]$/g, ''); +} + +function parseKeywords(value) { + if (!value) { + return []; + } + + const trimmedValue = value.trim(); + + if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) { + return trimmedValue + .slice(1, -1) + .split(',') + .map(keyword => stripQuotes(keyword.trim())) + .filter(Boolean); + } + + return [stripQuotes(trimmedValue)].filter(Boolean); +} + +function deriveTitle(file, body, frontmatterTitle) { + if (frontmatterTitle) { + return stripQuotes(frontmatterTitle); + } + + const headingMatch = body.match(/^#\s+(.+)$/m); + + if (headingMatch) { + return headingMatch[1].trim(); + } + + const basename = path.basename(file, path.extname(file)); + + return basename + .split(/[-_]/) + .filter(Boolean) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function resolveRoute(file, docId, routeMap) { + const route = routeMap[docId]; + + if (route) { + return route; + } + + const relativePath = file.replace(/\.(md|mdx)$/, '').replace(/\/index$/, ''); + + return `/docs/${relativePath}`; +} /** * Docusaurus Plugin: AI Documentation @@ -8,6 +105,7 @@ const glob = require('glob'); * This plugin implements the "Third Audience" pattern for AI agents: * 1. Copies markdown files to build output directory matching URL structure * 2. Injects meta tags in HTML to make markdown discoverable + * 3. Generates a static search_index.json for the full documentation */ module.exports = function (context, options) { return { @@ -21,12 +119,8 @@ module.exports = function (context, options) { const docsPath = path.join(context.siteDir, 'docs'); - // Find all markdown files in the docs directory - const markdownFiles = glob.sync('**/*.{md,mdx}', { - cwd: docsPath, - absolute: false, - ignore: ['api/index.md'], - }); + const markdownFiles = (await findMarkdownFiles(docsPath)) + .filter(file => file !== 'api/index.md'); console.log(`[AI Docs Plugin] Found ${markdownFiles.length} markdown files`); @@ -44,6 +138,7 @@ module.exports = function (context, options) { // Map to store doc routes for HTML injection const docRoutes = []; + const searchIndex = []; // Copy each markdown file to match its URL structure for (const file of markdownFiles) { @@ -51,19 +146,10 @@ module.exports = function (context, options) { try { const content = await fs.readFile(sourcePath, 'utf-8'); + const { data: frontmatter, body } = parseFrontmatter(content); - // Extract the doc ID from frontmatter - const idMatch = content.match(/^---\s*\n[\s\S]*?id:\s*(.+?)\s*\n[\s\S]*?---/m); - const docId = idMatch ? idMatch[1].trim() : path.basename(file, path.extname(file)); - - // Determine the route - let route = routeMap[docId]; - - if (!route) { - // Fallback: construct from file path - const relativePath = file.replace(/\.(md|mdx)$/, '').replace(/\/index$/, ''); - route = `/docs/${relativePath}`; - } + const docId = frontmatter.id || path.basename(file, path.extname(file)); + const route = resolveRoute(file, docId, routeMap); // Remove leading slash and create the destination path const routePath = route.replace(/^\//, ''); @@ -78,11 +164,23 @@ module.exports = function (context, options) { // Store route for HTML injection docRoutes.push({ route, routePath }); + searchIndex.push({ + title: deriveTitle(file, body, frontmatter.title), + url: route, + keywords: parseKeywords(frontmatter.keywords || frontmatter.tags), + content: body.trim(), + }); } catch (error) { console.error(`[AI Docs Plugin] Error processing ${file}:`, error.message); } } + searchIndex.sort((first, second) => first.url.localeCompare(second.url)); + + const searchIndexPath = path.join(outDir, 'search_index.json'); + await fs.writeFile(searchIndexPath, `${JSON.stringify(searchIndex, null, 2)}\n`, 'utf-8'); + console.log(`[AI Docs Plugin] Generated search index: search_index.json (${searchIndex.length} entries)`); + // Now inject meta tags into HTML files console.log('[AI Docs Plugin] Injecting meta tags into HTML files...'); diff --git a/docs/sidebars.ts b/docs/sidebars.ts index fea183f..7d5924c 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -6,6 +6,7 @@ const typedocSidebar = require('./docs/api/typedoc-sidebar.cjs') const sidebars: SidebarsConfig = { tembaSidebar: [ 'getting-started', + 'mcp', 'overview', 'examples', { diff --git a/package-lock.json b/package-lock.json index 64dbd1c..fde18b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ } }, "docs": { - "version": "0.70.0", + "version": "0.71.0", "dependencies": { "@docusaurus/core": "3.9.2", "@docusaurus/preset-classic": "3.9.2", @@ -3895,6 +3895,18 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -4428,6 +4440,378 @@ "react": ">=16" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "dev": true, @@ -6956,7 +7340,6 @@ }, "node_modules/cookie-signature": { "version": "1.2.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.6.0" @@ -7066,6 +7449,23 @@ "version": "1.0.3", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "8.3.6", "license": "MIT", @@ -8439,6 +8839,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "license": "MIT", @@ -8512,6 +8933,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/content-disposition": { "version": "0.5.4", "license": "MIT", @@ -9490,6 +9929,15 @@ "react-is": "^16.7.0" } }, + "node_modules/hono": { + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hpack.js": { "version": "2.1.6", "license": "MIT", @@ -9829,6 +10277,15 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.3.0", "license": "MIT", @@ -10057,6 +10514,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regexp": { "version": "1.0.0", "license": "MIT", @@ -10169,6 +10632,15 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -10205,6 +10677,12 @@ "version": "1.0.0", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "dev": true, @@ -12946,7 +13424,6 @@ }, "node_modules/once": { "version": "1.4.0", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -13323,6 +13800,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "7.0.0", "license": "MIT", @@ -15662,6 +16148,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rtlcss": { "version": "4.3.0", "license": "MIT", @@ -16602,6 +17114,10 @@ "resolved": "packages/cli", "link": true }, + "node_modules/temba-mcp": { + "resolved": "packages/mcp", + "link": true + }, "node_modules/terser": { "version": "5.46.0", "license": "BSD-2-Clause", @@ -18137,7 +18653,6 @@ }, "node_modules/wrappy": { "version": "1.0.2", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -18251,6 +18766,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "license": "MIT", @@ -18269,6 +18802,20 @@ "temba": "cli.js" } }, + "packages/mcp": { + "name": "temba-mcp", + "version": "0.4.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "4.4.3" + }, + "bin": { + "temba-mcp": "src/cli.js" + }, + "devDependencies": { + "vitest": "^4.0.18" + } + }, "packages/temba": { "version": "0.71.0", "license": "ISC", diff --git a/package.json b/package.json index dd6e5b5..7fa6c8c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "docs" ], "scripts": { - "test": "npm test -w packages/temba", + "test": "npm run test --workspaces --if-present", "test:mongodb": "npm run test:mongodb -w packages/temba", "test:watch": "npm run test:watch -w packages/temba", "lint": "npm run lint -w packages/temba", diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000..d3e204f --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,3 @@ +# Temba Docs MCP + +You can ensure your AI tools have current Temba knowledge through the Temba Docs MCP (Model Context Protocol) server. This provides real-time access to the latest documentation, helping AI tools avoid outdated recommendations and ensuring they understand current best practices. diff --git a/packages/mcp/nus.config.js b/packages/mcp/nus.config.js new file mode 100644 index 0000000..e310659 --- /dev/null +++ b/packages/mcp/nus.config.js @@ -0,0 +1,5 @@ +export default { + minAge: 1440, + tool: 'npm', + overrides: {}, +} diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000..0194701 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,25 @@ +{ + "name": "temba-mcp", + "version": "0.4.0", + "description": "MCP for Temba documentation", + "author": "Bouwe (https://bouwe.io)", + "scripts": { + "test": "vitest run", + "test:watch": "vitest --watch", + "update": "npx -y jelmerro/nus" + }, + "bin": { + "temba-mcp": "./src/cli.js" + }, + "type": "module", + "files": [ + "src" + ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "4.4.3" + }, + "devDependencies": { + "vitest": "^4.0.18" + } +} diff --git a/packages/mcp/src/cli.js b/packages/mcp/src/cli.js new file mode 100755 index 0000000..c0be97a --- /dev/null +++ b/packages/mcp/src/cli.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { startMcpServer } from './mcp.js' +import { searchDocs } from './searchDocs.js' + +const args = process.argv.slice(2) +const isDebug = args.includes('--debug') +const queryIndex = args.findIndex((a) => a === '-q' || a === '--query') + +if (isDebug) { + console.error('✨ Temba Docs MCP running in DEBUG mode') +} + +// Check for the testing flag +if (queryIndex !== -1 && args[queryIndex + 1]) { + const query = args[queryIndex + 1] + + try { + // 1. Fetch from the exact same remote location the MCP server uses + const response = await fetch('https://temba.bouwe.io/search_index.json') + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + const index = await response.json() + + // 2. Execute logic + const results = searchDocs(query, index) + + // 3. Output raw JSON (Agent-fidelity) + process.stdout.write(JSON.stringify(results, null, 2)) + process.exit(0) + } catch (err) { + console.error('❌ Failed to fetch remote index for test:', err.message) + process.exit(1) + } +} + +startMcpServer({ debug: isDebug }).catch(console.error) diff --git a/packages/mcp/src/log.js b/packages/mcp/src/log.js new file mode 100644 index 0000000..5e50023 --- /dev/null +++ b/packages/mcp/src/log.js @@ -0,0 +1,14 @@ +import fs from 'fs' +import path from 'path' + +const LOG_FILE = path.join(process.cwd(), 'temba-mcp.log') + +export const createLogger = (debug = false) => { + return (message) => { + if (debug) { + const timestamp = new Date().toISOString() + const entry = `[${timestamp}] ${message}\n` + fs.appendFileSync(LOG_FILE, entry) + } + } +} diff --git a/packages/mcp/src/mcp.js b/packages/mcp/src/mcp.js new file mode 100644 index 0000000..8966de9 --- /dev/null +++ b/packages/mcp/src/mcp.js @@ -0,0 +1,86 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { z } from 'zod' +import { createLogger } from './log.js' +import { searchDocs } from './searchDocs.js' +import { version } from './version.js' + +let index = [] +let lastFetched = 0 +const CACHE_TTL = 3600000 // 1 hour in milliseconds +const searchIndexUrl = 'https://temba.bouwe.io/search_index.json' + +async function ensureFreshIndex(log) { + if (Date.now() - lastFetched < CACHE_TTL && index.length > 0) return + + try { + const response = await fetch(searchIndexUrl) + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`) + } + + const contentType = response.headers.get('content-type') || '' + if (!contentType.includes('application/json')) { + throw new Error(`Expected JSON, received ${contentType || 'unknown content type'}`) + } + + index = await response.json() + lastFetched = Date.now() + } catch (e) { + log(`Refresh failed, using stale index: ${e.message}`) + } +} + +export const startMcpServer = async ({ debug = false } = {}) => { + const server = new McpServer({ + name: 'temba-docs-mcp', + version, + }) + + const log = createLogger(debug) + + // Register the tool + server.tool( + 'search_docs', + 'Search the library documentation', + { query: z.string() }, + async ({ query }) => { + await ensureFreshIndex(log) + + if (index.length === 0) { + log('No documentation index available to search.') + return { + content: [ + { + type: 'text', + text: 'Documentation is currently unavailable. Please try again later.', + }, + ], + } + } + + const results = searchDocs(query, index).slice(0, 5) // Limit to top 5 results + + log(`Query: "${query}" | Results: ${results.length}`) + + // Return a friendly message instead of an empty result to avoid LLM confusion. + if (results.length === 0) { + return { + content: [ + { type: 'text', text: `No Temba documentation found for your query "${query}".` }, + ], + } + } + + return { + content: results.map((page) => ({ + type: 'text', + text: `Title: ${page.title}\nURL: ${page.url}\n\nContent:\n${page.content}`, + })), + } + }, + ) + + const transport = new StdioServerTransport() + await server.connect(transport) +} diff --git a/packages/mcp/src/searchDocs.js b/packages/mcp/src/searchDocs.js new file mode 100644 index 0000000..452d8a9 --- /dev/null +++ b/packages/mcp/src/searchDocs.js @@ -0,0 +1,67 @@ +const stopWords = new Set([ + 'a', + 'an', + 'the', + 'is', + 'i', + 'do', + 'how', + 'to', + 'and', + 'or', + 'of', + 'in', + 'on', + 'for', + 'with', + 'by', + 'as', + 'at', + 'from', + 'that', + 'this', + 'it', + 'are', + 'was', + 'were', + 'be', + 'been', +]) + +export const searchDocs = (query, index) => { + const lowerQuery = query?.toLowerCase().trim() + if (!lowerQuery) return [] + + const tokens = lowerQuery.split(/\s+/).filter((token) => !stopWords.has(token)) + + // Early return if the user only searched for stop words + if (tokens.length === 0) return [] + + return ( + index + .map((page) => { + const content = (page.content || '').toLowerCase() + const title = (page.title || '').toLowerCase() + const keywords = (page.keywords || []).map((k) => k.toLowerCase()) + + // Count how many tokens are found in this page + let score = 0 + tokens.forEach((token) => { + if ( + content.includes(token) || + title.includes(token) || + keywords.some((k) => k.includes(token)) + ) { + score += 1 + } + }) + + return { page, score } + }) + // Filter out pages that didn't match at least one token + .filter((item) => item.score > 0) + // Sort by most matches first + .sort((a, b) => b.score - a.score) + .map((item) => item.page) + ) +} diff --git a/packages/mcp/src/version.js b/packages/mcp/src/version.js new file mode 100644 index 0000000..d249a39 --- /dev/null +++ b/packages/mcp/src/version.js @@ -0,0 +1 @@ +export const version = '0.1.3' diff --git a/packages/mcp/test/searchDocs.test.js b/packages/mcp/test/searchDocs.test.js new file mode 100644 index 0000000..ced58e5 --- /dev/null +++ b/packages/mcp/test/searchDocs.test.js @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'vitest' +import { searchDocs } from '../src/searchDocs' + +const helloDocument = { + title: 'Hello World', + content: "Let's talk about greeting our great planet.", + keywords: ['howdy', 'earth'], +} + +const scotlandDocument = { + title: 'The most beautiful country in the world', + content: 'Scotland is so great, with its mountains, beaches and whiskies.', + keywords: ['bagpipes', 'whisky', 'howdy'], +} + +const search_index = [helloDocument, scotlandDocument] + +describe('searchDocs', () => { + describe('Finding no documents', () => { + test('Returns no results for an empty index', () => { + expect(searchDocs('anything', [])).toEqual([]) + }) + test('Returns no results for an empty query', () => { + expect(searchDocs('', search_index)).toEqual([]) + }) + test('Finds no results', () => { + expect(searchDocs('goodbye', search_index)).toEqual([]) + }) + }) + + describe('Finding 1 document', () => { + test('Finds by title', () => { + expect(searchDocs('hello', search_index)).toEqual([helloDocument]) + }) + test('Finds by content (partial)', () => { + // Testing partial token match instead of full sentence + expect(searchDocs('greeting', search_index)).toEqual([helloDocument]) + }) + test('Finds by keyword', () => { + expect(searchDocs('earth', search_index)).toEqual([helloDocument]) + }) + test('Finds by trimming query whitespace', () => { + expect(searchDocs(' hello ', search_index)).toEqual([helloDocument]) + }) + }) + + describe('Finding multiple documents', () => { + test('Finds 2 results by title token', () => { + // Using 'world' which appears in both titles + const result = searchDocs('world', search_index) + expect(result).toEqual(expect.arrayContaining([helloDocument, scotlandDocument])) + }) + test('Finds 2 results by content token', () => { + const result = searchDocs('great', search_index) + expect(result).toEqual(expect.arrayContaining([helloDocument, scotlandDocument])) + }) + test('Finds 2 results by keyword', () => { + const result = searchDocs('howdy', search_index) + expect(result).toEqual(expect.arrayContaining([helloDocument, scotlandDocument])) + }) + }) + + describe('Finding partial matches', () => { + test('Finds partial matches by title', () => { + expect(searchDocs('greet', search_index)).toEqual([helloDocument]) + }) + test('Finds partial matches by content', () => { + expect(searchDocs('mountain', search_index)).toEqual([scotlandDocument]) + }) + test('Finds partial matches by keyword', () => { + expect(searchDocs('bagpipe', search_index)).toEqual([scotlandDocument]) + }) + }) + + describe('Edge Cases and Ranking', () => { + test('Ranks documents with more matches higher', () => { + // hello matches 'world', 'great' (2 matches) + // scotland matches 'great' (1 match) + const result = searchDocs('world great', search_index) + expect(result[0].title).toBe('Hello World') + }) + + test('Handles pages with missing fields gracefully', () => { + const brokenDoc = { title: 'Broken' } // Missing content and keywords + const index = [brokenDoc] + expect(() => searchDocs('broken', index)).not.toThrow() + expect(searchDocs('broken', index)).toEqual([brokenDoc]) + }) + }) + + describe('Stop words filtering', () => { + test('Filters out stop words from the query', () => { + // "the" and "is" are stop words. "world" is the only active token. + // Both docs contain "world" in their title or content. + const result = searchDocs('the world is', search_index) + expect(result).toEqual(expect.arrayContaining([helloDocument, scotlandDocument])) + }) + + test('Returns no results if query only contains stop words', () => { + // Should return [] because no tokens remain after filtering + expect(searchDocs('the is a', search_index)).toEqual([]) + }) + + test('Still finds relevant docs when stop words are present', () => { + // "planet" is the keyword. "in the" is noise. + expect(searchDocs('planet in the', search_index)).toEqual([helloDocument]) + }) + }) +}) diff --git a/packages/mcp/version.js b/packages/mcp/version.js new file mode 100644 index 0000000..02968f1 --- /dev/null +++ b/packages/mcp/version.js @@ -0,0 +1 @@ +export const version = '0.4.0' diff --git a/publish-mcp.sh b/publish-mcp.sh new file mode 100755 index 0000000..6c820bc --- /dev/null +++ b/publish-mcp.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +TYPE=$1 +DRY_RUN=false + +if [[ " $* " =~ " --dry-run " ]]; then + DRY_RUN=true + echo "DRY RUN MODE ENABLED" +fi + +if [ "$TYPE" != "major" ] && [ "$TYPE" != "minor" ] && [ "$TYPE" != "patch" ]; then + echo "Usage: ./publish-mcp.sh [major|minor|patch] [--dry-run]" + exit 1 +fi + +if [ "$DRY_RUN" = false ] && [ -n "$(git status --porcelain)" ]; then + echo "Error: Commit all changes before publishing" + exit 1 +fi + +if [ "$DRY_RUN" = false ]; then + echo "Checking NPM login status..." + if npm whoami &> /dev/null; then + echo "Logged in as $(npm whoami)" + else + echo "Not logged in to NPM." + npm login + + if [ $? -ne 0 ]; then + echo "Login failed or was cancelled. Exiting." + exit 1 + fi + fi +else + echo "[DRY RUN] Would check NPM login status" +fi + +CURRENT_VERSION=$(node -p "require('./packages/mcp/package.json').version") +NEXT_VERSION=$(node -p "const [ma, mi, pa] = '$CURRENT_VERSION'.split('.').map(Number); '$TYPE' === 'major' ? \`\${ma+1}.0.0\` : '$TYPE' === 'minor' ? \`\${ma}.\${mi+1}.0\` : \`\${ma}.\${mi}.\${pa+1}\`") + +echo "Releasing temba-mcp $NEXT_VERSION (from $CURRENT_VERSION)..." + +run_cmd() { + if [ "$DRY_RUN" = true ]; then + echo "[DRY RUN] Would execute: $*" + else + "$@" + fi +} + +run_cmd npm version "$TYPE" -w packages/mcp --no-git-tag-version +run_cmd bash -c "echo \"export const version = '$NEXT_VERSION'\" > packages/mcp/version.js" +run_cmd npm publish -w packages/mcp + +echo "Finalizing Git..." +run_cmd git add packages/mcp/package.json packages/mcp/version.js package-lock.json +run_cmd git commit -m "temba-mcp $NEXT_VERSION" + +if [ "$DRY_RUN" = false ]; then + echo "Done. temba-mcp $NEXT_VERSION published." +else + echo "Dry run complete. No changes were made." +fi