doccupine 0.0.119 → 0.0.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,11 +45,12 @@ doccupine config --reset # Re-prompt for configuration
45
45
 
46
46
  `watch` (default):
47
47
 
48
- | Flag | Description |
49
- | --------------- | -------------------------------------------------------------------- |
50
- | `--port <port>` | Port for the dev server (default: `3000`). Auto-increments if taken. |
51
- | `--verbose` | Show all Next.js output including compilation details |
52
- | `--reset` | Re-prompt for watch/output directories |
48
+ | Flag | Description |
49
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
50
+ | `--port <port>` | Port for the dev server (default: `3000`). Auto-increments if taken. |
51
+ | `--verbose` | Show all Next.js output including compilation details |
52
+ | `--reset` | Re-prompt for watch/output directories |
53
+ | `--package-manager <name>` | Package manager for the generated app: `pnpm` or `npm` (default: auto-detect). Overrides the `packageManager` field in `doccupine.json`. |
53
54
 
54
55
  `build`:
55
56
 
@@ -75,10 +76,13 @@ description: "Page description for SEO"
75
76
  category: "Getting Started"
76
77
  categoryOrder: 0 # Sort order for the category group
77
78
  order: 1 # Sort order within the category
79
+ navIcon: "book-open" # Lucide icon name shown next to the page in the sidebar
80
+ categoryIcon: "rocket" # Lucide icon name shown next to the category group
78
81
  name: "My Docs" # Override site name in title suffix
79
82
  icon: "https://..." # Page favicon URL
80
83
  image: "https://..." # OpenGraph image URL
81
84
  date: "2025-01-01" # Page date metadata
85
+ updated: "2025-02-01" # Last-modified date (JSON-LD dateModified)
82
86
  section: "API Reference" # Section this page belongs to
83
87
  sectionOrder: 1 # Sort order for the section in the tab bar
84
88
  ---
@@ -58,6 +58,7 @@ import { mcpIndexTemplate } from "../templates/services/mcp/index.js";
58
58
  import { mcpServerTemplate } from "../templates/services/mcp/server.js";
59
59
  import { mcpToolsTemplate } from "../templates/services/mcp/tools.js";
60
60
  import { mcpTypesTemplate } from "../templates/services/mcp/types.js";
61
+ import { vectorHelpersTemplate } from "../templates/services/mcp/vector.js";
61
62
  import { docsIndexStubTemplate } from "../templates/services/mcp/docsIndexStub.js";
62
63
  import { llmConfigTemplate } from "../templates/services/llm/config.js";
63
64
  import { llmFactoryTemplate } from "../templates/services/llm/factory.js";
@@ -138,6 +139,7 @@ export const appStructure = {
138
139
  "services/mcp/server.ts": mcpServerTemplate,
139
140
  "services/mcp/tools.ts": mcpToolsTemplate,
140
141
  "services/mcp/types.ts": mcpTypesTemplate,
142
+ "services/mcp/vector.ts": vectorHelpersTemplate,
141
143
  "services/mcp/docs-index.json": docsIndexStubTemplate,
142
144
  "services/llm/config.ts": llmConfigTemplate,
143
145
  "services/llm/factory.ts": llmFactoryTemplate,
@@ -1 +1 @@
1
- export declare const envExampleTemplate = "# Public Site URL\n# Used by sitemap.xml and robots.txt. Overrides `url` in config.json when set.\n# NEXT_PUBLIC_SITE_URL=https://docs.example.com\n\n# Password Protection (optional)\n# Set a shared password to gate the whole site behind a login screen. When set,\n# pages require the password, the content APIs (chat + search) return 401\n# without it, and the site is hidden from search engines and crawlers. Leave\n# unset (or remove) to keep the site public.\n# SITE_PASSWORD=choose-a-strong-shared-password\n\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default chat model\n# See available models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/models\n# Anthropic: https://docs.anthropic.com/claude/docs/models-overview\n# Google: https://ai.google.dev/models/gemini\n# LLM_CHAT_MODEL=\n\n# Optional: Override default embedding model\n# See available embedding models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/guides/embeddings\n# Google: https://ai.google.dev/gemini-api/docs/embeddings\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n";
1
+ export declare const envExampleTemplate = "# Public Site URL\n# Used by sitemap.xml and robots.txt. Overrides `url` in config.json when set.\n# NEXT_PUBLIC_SITE_URL=https://docs.example.com\n\n# Password Protection (optional)\n# Set a shared password to gate the whole site behind a login screen. When set,\n# pages require the password, the content APIs (chat + search) return 401\n# without it, and the site is hidden from search engines and crawlers. Leave\n# unset (or remove) to keep the site public.\n# SITE_PASSWORD=choose-a-strong-shared-password\n\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default chat model\n# See available models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/models\n# Anthropic: https://docs.anthropic.com/claude/docs/models-overview\n# Google: https://ai.google.dev/models/gemini\n# LLM_CHAT_MODEL=\n\n# Optional: Override default embedding model\n# See available embedding models at your provider's docs:\n# OpenAI: https://platform.openai.com/docs/guides/embeddings\n# Google: https://ai.google.dev/gemini-api/docs/embeddings\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n\n# Optional: Embedding dimensions for the prebuilt search index (default: 512)\n# Doc vectors are Matryoshka-truncated to this many dimensions and stored as\n# int8, which keeps services/mcp/docs-index.json small (~20x smaller than raw\n# floats) so large doc sets don't stall the AI chat on serverless cold starts.\n# Lower = smaller index, slightly lower recall. Values >= the model's native\n# dimension keep full precision. Rebuild after changing this.\n# LLM_EMBEDDING_DIMS=512\n";
@@ -34,4 +34,12 @@ GOOGLE_API_KEY=your_google_api_key_here
34
34
 
35
35
  # Optional: Set temperature (0-1, default: 0)
36
36
  # LLM_TEMPERATURE=0
37
+
38
+ # Optional: Embedding dimensions for the prebuilt search index (default: 512)
39
+ # Doc vectors are Matryoshka-truncated to this many dimensions and stored as
40
+ # int8, which keeps services/mcp/docs-index.json small (~20x smaller than raw
41
+ # floats) so large doc sets don't stall the AI chat on serverless cold starts.
42
+ # Lower = smaller index, slightly lower recall. Values >= the model's native
43
+ # dimension keep full precision. Rebuild after changing this.
44
+ # LLM_EMBEDDING_DIMS=512
37
45
  `;
@@ -1 +1 @@
1
- export declare const mcpMdxTemplate = "---\ntitle: \"Model Context Protocol\"\ndescription: \"Connect your Doccupine documentation to AI tools with an MCP server for enhanced AI-powered documentation search.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 9\n---\n\n# Model Context Protocol\n\nConnect your documentation to AI tools with a hosted MCP server.\n\nDoccupine automatically generates a Model Context Protocol (MCP) server from your documentation, making your content accessible to AI applications like Claude, Cursor, VS Code, and other MCP-compatible tools. Your MCP server exposes semantic search capabilities, allowing AI tools to query your documentation directly and provide accurate, context-aware answers.\n\n<Callout type=\"warning\">\n The MCP server requires the [AI Assistant](/ai-assistant) to be configured. Make sure you have set up your LLM provider and API keys before using the MCP server.\n</Callout>\n\n## About MCP servers\n\nThe Model Context Protocol (MCP) is an open protocol that creates standardized connections between AI applications and external services, like documentation. Doccupine generates an MCP server from your documentation, preparing your content for the broader AI ecosystem where any MCP client can connect to your documentation.\n\nYour MCP server exposes search and retrieval tools for AI applications to query your documentation. Your users must connect your MCP server to their preferred AI tools to access your documentation.\n\n### How MCP servers work\n\nWhen an AI tool has your documentation MCP server connected, the AI tool can search your documentation directly instead of making a generic web search in response to a user's prompt. Your MCP server provides access to all indexed content from your documentation site.\n\n- The LLM can proactively search your documentation while generating a response, not just when explicitly asked.\n- The LLM determines when to use the search tool based on the context of the conversation and the relevance of your documentation.\n- Each tool call happens during the generation process, so the LLM searches up-to-date information from your documentation to generate its response.\n\n### MCP compared to web search\n\nAI tools can search the web, but MCP provides distinct advantages for documentation.\n\n- **Direct source access**: Web search depends on what search engines have indexed, which may be stale or incomplete. MCP searches your current indexed documentation directly.\n- **Integrated workflow**: MCP allows the AI to search during response generation rather than performing a separate web search.\n- **Semantic search**: MCP uses vector embeddings for semantic similarity search, providing more relevant results than keyword-based web search.\n- **No search noise**: SEO and ranking algorithms influence web search results. MCP goes straight to your documentation content.\n\n## Access your MCP server\n\nDoccupine automatically generates an MCP server for your documentation and hosts it at your documentation URL with the `/api/mcp` path. For example, if your documentation is hosted at `https://example.com`, your MCP server is available at `https://example.com/api/mcp`.\n\nThe MCP server provides both a GET endpoint to discover available tools and a POST endpoint to execute tool calls.\n\n### Authentication\n\nYou can optionally protect your MCP server with an API key by setting the `DOCS_API_KEY` environment variable in your `.env` file:\n\n```bash\nDOCS_API_KEY=your-secret-key\n```\n\nWhen `DOCS_API_KEY` is set, all requests to `/api/mcp` must include an `Authorization` header with a Bearer token:\n\n```text\nAuthorization: Bearer your-secret-key\n```\n\nRequests without a valid token receive a `401 Unauthorized` response. When `DOCS_API_KEY` is not set, the MCP server is publicly accessible with no authentication required.\n\n<Callout type=\"note\">\n Authentication only applies to the `/api/mcp` endpoint. The `/api/rag` endpoint used by the built-in AI Assistant chat is not affected by this setting.\n</Callout>\n\n### API Endpoints\n\n#### GET /api/mcp\n\nReturns information about available tools and the current index status.\n\n**Response:**\n\n```json\n{\n \"tools\": [\n {\n \"name\": \"search_docs\",\n \"description\": \"Search through the documentation content using semantic search...\",\n \"inputSchema\": { ... }\n },\n ...\n ],\n \"index\": {\n \"ready\": true,\n \"chunkCount\": 150\n }\n}\n```\n\n#### POST /api/mcp\n\nExecutes an MCP tool call.\n\n**Request Body:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to deploy\",\n \"limit\": 6\n }\n}\n```\n\n**Response:**\n\n```json\n{\n \"content\": [\n {\n \"path\": \"app/deployment-and-hosting/page.tsx\",\n \"uri\": \"docs://deployment-and-hosting\",\n \"score\": \"0.892\",\n \"text\": \"Deploy your Doccupine site as a Next.js application...\"\n },\n ...\n ]\n}\n```\n\n## Available Tools\n\nYour MCP server exposes three tools for interacting with your documentation:\n\n### search_docs\n\nSearch through the documentation content using semantic search. Returns relevant chunks of documentation based on the query using vector embeddings and cosine similarity.\n\n**Parameters:**\n\n- `query` (required): The search query to find relevant documentation\n- `limit` (optional): Maximum number of results to return (default: 6)\n\n**Example:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to configure AI assistant\",\n \"limit\": 5\n }\n}\n```\n\n### get_doc\n\nGet the full content of a specific documentation page by its path.\n\n**Parameters:**\n\n- `path` (required): The file path to the documentation page (e.g., `app/getting-started/page.tsx`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"get_doc\",\n \"params\": {\n \"path\": \"app/deployment-and-hosting/page.tsx\"\n }\n}\n```\n\n### list_docs\n\nList all available documentation pages, optionally filtered by directory.\n\n**Parameters:**\n\n- `directory` (optional): Optional directory to filter results (e.g., `components`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"list_docs\",\n \"params\": {\n \"directory\": \"configuration\"\n }\n}\n```\n\n## How it works\n\nDoccupine's MCP server uses semantic search powered by vector embeddings to provide accurate, context-aware search results.\n\n### Indexing Process\n\nDoccupine runs this pipeline at build time and ships the resulting vectors with your site, so the server loads a ready-made index instead of re-embedding your docs on every cold start.\n\n1. **Document Discovery**: Doccupine scans your `app/` directory for all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files.\n2. **Content Extraction**: It extracts content from `const content =` declarations in your page files.\n3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.\n4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.\n5. **Index Building**: The embeddings are written to `services/mcp/docs-index.json`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.\n\n### Search Process\n\n1. **Query Embedding**: The search query is converted to a vector embedding using the same embedding model.\n2. **Similarity Calculation**: Cosine similarity is calculated between the query embedding and all document chunk embeddings.\n3. **Ranking**: Results are sorted by similarity score (highest first).\n4. **Response**: The top N results (based on the limit parameter) are returned with their paths, URIs, scores, and text content.\n\n<Callout type=\"note\">\n Embeddings are precomputed at build time and bundled with your site, so the server loads the ready-made index into memory on startup instead of re-embedding your docs on every cold start. It only embeds at runtime when the precomputed index is missing or was built with a different provider or model. When you update your documentation, rebuild or redeploy your site to regenerate the index with fresh content.\n</Callout>\n\n## Use your MCP server\n\nYour users must connect your MCP server to their preferred AI tools.\n\n1. Make your MCP server URL publicly available.\n2. Users copy your MCP server URL and add it to their tools.\n3. Users access your documentation through their tools.\n\nThese are some of the ways you can help your users connect to your MCP server:\n\n<Tabs>\n <TabContent title=\"Claude\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Claude.\n 1. Navigate to the [Connectors](https://claude.ai/settings/connectors) page in the Claude settings.\n 2. Select **Add custom connector**.\n 3. Add your MCP server name and URL.\n 4. Select **Add**.\n 5. When using Claude, select the attachments button (the plus icon).\n 6. Select your MCP server.\n </Step>\n </Steps>\n See the [Model Context Protocol documentation](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server#connecting-to-a-remote-mcp-server) for more details.\n </TabContent>\n <TabContent title=\"Claude Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the command to connect it to Claude Code.\n ```bash\n claude mcp add --transport http <name> <url>\n ```\n </Step>\n </Steps>\n See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"Cursor\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Cursor.\n 1. Use <kbd>Command</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> on Windows) to open the command palette.\n 2. Search for \"Open MCP settings\".\n 3. Select **Add custom MCP**. This opens the `mcp.json` file.\n 4. In `mcp.json`, configure your server:\n ```json\n {\n \"mcpServers\": {\n \"<your-mcp-server-name>\": {\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [Cursor documentation](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"VS Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to VS Code.\n 1. Create a `.vscode/mcp.json` file.\n 2. In `mcp.json`, configure your server:\n ```json\n {\n \"servers\": {\n \"<your-mcp-server-name>\": {\n \"type\": \"http\",\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details.\n </TabContent>\n</Tabs>\n\n## Requirements\n\nTo use the MCP server, you need to have the AI Assistant configured. The MCP server uses the same LLM configuration for generating embeddings.\n\n| Variable | Required | Description |\n| -------------- | -------- | ------------------------------------------------------------ |\n| `LLM_PROVIDER` | Yes | Your LLM provider (`openai`, `anthropic`, or `google`) |\n| `DOCS_API_KEY` | No | When set, requires Bearer token authentication on `/api/mcp` |\n\n<Callout type=\"warning\">\n The MCP server requires an LLM provider to be configured for generating embeddings. Make sure you have set up your AI Assistant with a valid API key before using the MCP server.\n</Callout>\n\nSee the [AI Assistant documentation](/ai-assistant) for configuration details.\n\n## Content indexing\n\nYour MCP server searches content extracted from your page files. The server automatically discovers and indexes all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files in your `app/` directory.\n\n### Content extraction\n\nThe server extracts content from `const content =` declarations in your page files. Make sure your documentation pages export a `content` constant with your markdown or MDX content.\n\n**Example:**\n\n```tsx\nexport const content = `\n# Getting Started\n\nWelcome to the documentation...\n`;\n```\n\n### Excluded directories\n\nThe following directories are automatically excluded from indexing:\n\n- `node_modules`\n- `.next`\n- `.git`\n- `api`\n\n## Troubleshooting\n\n### Index not building\n\nIf the index is not building, check:\n\n- Your LLM provider is configured correctly in your `.env` file\n- You have a valid API key set\n- Your documentation pages export a `content` constant\n\n### No search results\n\nIf searches return no results:\n\n- Verify that your documentation pages are in the `app/` directory\n- Check that your pages export a `content` constant\n- Ensure the index has been built (check the `index.ready` status via GET `/api/mcp`)\n\n### Slow search performance\n\nSearches use the precomputed in-memory index and are fast. The first search is only slow when the app has to embed your docs at runtime, which happens when the precomputed index is missing or was built with a different provider or model. If performance is consistently slow:\n\n- Confirm the build ran the doc-index precompute step (the `build` script runs it before `next build`) with a valid API key\n- Check your embedding API response times\n- Consider reducing the number of documentation pages\n- Verify your server has sufficient memory\n\n### Cloudflare blocking MCP requests\n\nIf you use Cloudflare as a proxy in front of your documentation site, Cloudflare's bot protection may block server-to-server requests from AI tools like Claude.ai. This can cause MCP connections to fail silently or return errors.\n\nThere are two ways to resolve this:\n\n**Option 1: Disable the Cloudflare proxy (simplest)**\n\nIn your Cloudflare DNS settings, click the orange cloud icon next to your domain record to switch it to \"DNS only\" (grey cloud). This disables Cloudflare's proxy and bot protection for your domain, allowing MCP requests to reach your server directly.\n\n**Option 2: Add a Cloudflare WAF exception (keeps your custom domain proxied)**\n\nIn Cloudflare dashboard:\n\n1. Go to **Security > WAF**.\n2. Click **Create rule**.\n3. Set it up as:\n - **Rule name**: Allow MCP API\n - **Field**: URI Path\n - **Operator**: starts with\n - **Value**: `/api/mcp`\n - **Action**: Skip -- then check all remaining custom rules, Rate limiting rules, and Bot Fight Mode / Super Bot Fight Mode.\n4. Deploy the rule and make sure it is ordered first (above other rules).\n\n<Callout type=\"warning\">\n Also check **Security > Bots** in your Cloudflare dashboard. If \"Bot Fight Mode\" or \"Super Bot Fight Mode\" is enabled, that is very likely what is blocking server-to-server requests from AI tools.\n</Callout>\n\n## Best practices\n\n- **Keep content up-to-date**: Rebuild or redeploy your site after updating documentation to regenerate the index with fresh content.\n- **Use descriptive queries**: More specific queries yield better semantic search results.\n- **Monitor index status**: Use the GET endpoint to check if your index is ready before performing searches.\n- **Optimize content structure**: Well-structured markdown with clear headings improves search relevance.";
1
+ export declare const mcpMdxTemplate = "---\ntitle: \"Model Context Protocol\"\ndescription: \"Connect your Doccupine documentation to AI tools with an MCP server for enhanced AI-powered documentation search.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 9\n---\n\n# Model Context Protocol\n\nConnect your documentation to AI tools with a hosted MCP server.\n\nDoccupine automatically generates a Model Context Protocol (MCP) server from your documentation, making your content accessible to AI applications like Claude, Cursor, VS Code, and other MCP-compatible tools. Your MCP server exposes semantic search capabilities, allowing AI tools to query your documentation directly and provide accurate, context-aware answers.\n\n<Callout type=\"warning\">\n The MCP server requires the [AI Assistant](/ai-assistant) to be configured. Make sure you have set up your LLM provider and API keys before using the MCP server.\n</Callout>\n\n## About MCP servers\n\nThe Model Context Protocol (MCP) is an open protocol that creates standardized connections between AI applications and external services, like documentation. Doccupine generates an MCP server from your documentation, preparing your content for the broader AI ecosystem where any MCP client can connect to your documentation.\n\nYour MCP server exposes search and retrieval tools for AI applications to query your documentation. Your users must connect your MCP server to their preferred AI tools to access your documentation.\n\n### How MCP servers work\n\nWhen an AI tool has your documentation MCP server connected, the AI tool can search your documentation directly instead of making a generic web search in response to a user's prompt. Your MCP server provides access to all indexed content from your documentation site.\n\n- The LLM can proactively search your documentation while generating a response, not just when explicitly asked.\n- The LLM determines when to use the search tool based on the context of the conversation and the relevance of your documentation.\n- Each tool call happens during the generation process, so the LLM searches up-to-date information from your documentation to generate its response.\n\n### MCP compared to web search\n\nAI tools can search the web, but MCP provides distinct advantages for documentation.\n\n- **Direct source access**: Web search depends on what search engines have indexed, which may be stale or incomplete. MCP searches your current indexed documentation directly.\n- **Integrated workflow**: MCP allows the AI to search during response generation rather than performing a separate web search.\n- **Semantic search**: MCP uses vector embeddings for semantic similarity search, providing more relevant results than keyword-based web search.\n- **No search noise**: SEO and ranking algorithms influence web search results. MCP goes straight to your documentation content.\n\n## Access your MCP server\n\nDoccupine automatically generates an MCP server for your documentation and hosts it at your documentation URL with the `/api/mcp` path. For example, if your documentation is hosted at `https://example.com`, your MCP server is available at `https://example.com/api/mcp`.\n\nThe MCP server provides both a GET endpoint to discover available tools and a POST endpoint to execute tool calls.\n\n### Authentication\n\nYou can optionally protect your MCP server with an API key by setting the `DOCS_API_KEY` environment variable in your `.env` file:\n\n```bash\nDOCS_API_KEY=your-secret-key\n```\n\nWhen `DOCS_API_KEY` is set, all requests to `/api/mcp` must include an `Authorization` header with a Bearer token:\n\n```text\nAuthorization: Bearer your-secret-key\n```\n\nRequests without a valid token receive a `401 Unauthorized` response. When `DOCS_API_KEY` is not set, the MCP server is publicly accessible with no authentication required.\n\n<Callout type=\"note\">\n Authentication only applies to the `/api/mcp` endpoint. The `/api/rag` endpoint used by the built-in AI Assistant chat is not affected by this setting.\n</Callout>\n\n### API Endpoints\n\n#### GET /api/mcp\n\nReturns information about available tools and the current index status.\n\n**Response:**\n\n```json\n{\n \"tools\": [\n {\n \"name\": \"search_docs\",\n \"description\": \"Search through the documentation content using semantic search...\",\n \"inputSchema\": { ... }\n },\n ...\n ],\n \"index\": {\n \"ready\": true,\n \"chunkCount\": 150\n }\n}\n```\n\n#### POST /api/mcp\n\nExecutes an MCP tool call.\n\n**Request Body:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to deploy\",\n \"limit\": 6\n }\n}\n```\n\n**Response:**\n\n```json\n{\n \"content\": [\n {\n \"path\": \"app/deployment-and-hosting/page.tsx\",\n \"uri\": \"docs://deployment-and-hosting\",\n \"score\": \"0.892\",\n \"text\": \"Deploy your Doccupine site as a Next.js application...\"\n },\n ...\n ]\n}\n```\n\n## Available Tools\n\nYour MCP server exposes three tools for interacting with your documentation:\n\n### search_docs\n\nSearch through the documentation content using semantic search. Returns relevant chunks of documentation based on the query using vector embeddings and cosine similarity.\n\n**Parameters:**\n\n- `query` (required): The search query to find relevant documentation\n- `limit` (optional): Maximum number of results to return (default: 6)\n\n**Example:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to configure AI assistant\",\n \"limit\": 5\n }\n}\n```\n\n### get_doc\n\nGet the full content of a specific documentation page by its path.\n\n**Parameters:**\n\n- `path` (required): The file path to the documentation page (e.g., `app/getting-started/page.tsx`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"get_doc\",\n \"params\": {\n \"path\": \"app/deployment-and-hosting/page.tsx\"\n }\n}\n```\n\n### list_docs\n\nList all available documentation pages, optionally filtered by directory.\n\n**Parameters:**\n\n- `directory` (optional): Optional directory to filter results (e.g., `components`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"list_docs\",\n \"params\": {\n \"directory\": \"configuration\"\n }\n}\n```\n\n## How it works\n\nDoccupine's MCP server uses semantic search powered by vector embeddings to provide accurate, context-aware search results.\n\n### Indexing Process\n\nDoccupine runs this pipeline at build time and ships the resulting vectors with your site, so the server loads a ready-made index instead of re-embedding your docs on every cold start.\n\n1. **Document Discovery**: Doccupine scans your `app/` directory for all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files.\n2. **Content Extraction**: It extracts content from `const content =` declarations in your page files.\n3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.\n4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.\n5. **Compaction**: Each vector is truncated to `LLM_EMBEDDING_DIMS` dimensions (default 512) and quantized to int8, keeping the index roughly 20x smaller than raw floats so large doc sets don't stall the chat on cold start.\n6. **Index Building**: The compact embeddings are written to `services/mcp/docs-index.json`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.\n\n### Search Process\n\n1. **Query Embedding**: The search query is converted to a vector embedding using the same embedding model.\n2. **Similarity Calculation**: Cosine similarity is calculated between the query embedding and all document chunk embeddings.\n3. **Ranking**: Results are sorted by similarity score (highest first).\n4. **Response**: The top N results (based on the limit parameter) are returned with their paths, URIs, scores, and text content.\n\n<Callout type=\"note\">\n Embeddings are precomputed at build time and bundled with your site, so the server loads the ready-made index into memory on startup instead of re-embedding your docs on every cold start. It only embeds at runtime when the precomputed index is missing or was built with a different provider or model. When you update your documentation, rebuild or redeploy your site to regenerate the index with fresh content.\n</Callout>\n\n## Use your MCP server\n\nYour users must connect your MCP server to their preferred AI tools.\n\n1. Make your MCP server URL publicly available.\n2. Users copy your MCP server URL and add it to their tools.\n3. Users access your documentation through their tools.\n\nThese are some of the ways you can help your users connect to your MCP server:\n\n<Tabs>\n <TabContent title=\"Claude\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Claude.\n 1. Navigate to the [Connectors](https://claude.ai/settings/connectors) page in the Claude settings.\n 2. Select **Add custom connector**.\n 3. Add your MCP server name and URL.\n 4. Select **Add**.\n 5. When using Claude, select the attachments button (the plus icon).\n 6. Select your MCP server.\n </Step>\n </Steps>\n See the [Model Context Protocol documentation](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server#connecting-to-a-remote-mcp-server) for more details.\n </TabContent>\n <TabContent title=\"Claude Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the command to connect it to Claude Code.\n ```bash\n claude mcp add --transport http <name> <url>\n ```\n </Step>\n </Steps>\n See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"Cursor\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Cursor.\n 1. Use <kbd>Command</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> on Windows) to open the command palette.\n 2. Search for \"Open MCP settings\".\n 3. Select **Add custom MCP**. This opens the `mcp.json` file.\n 4. In `mcp.json`, configure your server:\n ```json\n {\n \"mcpServers\": {\n \"<your-mcp-server-name>\": {\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [Cursor documentation](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"VS Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to VS Code.\n 1. Create a `.vscode/mcp.json` file.\n 2. In `mcp.json`, configure your server:\n ```json\n {\n \"servers\": {\n \"<your-mcp-server-name>\": {\n \"type\": \"http\",\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details.\n </TabContent>\n</Tabs>\n\n## Requirements\n\nTo use the MCP server, you need to have the AI Assistant configured. The MCP server uses the same LLM configuration for generating embeddings.\n\n| Variable | Required | Description |\n| -------------- | -------- | ------------------------------------------------------------ |\n| `LLM_PROVIDER` | Yes | Your LLM provider (`openai`, `anthropic`, or `google`) |\n| `DOCS_API_KEY` | No | When set, requires Bearer token authentication on `/api/mcp` |\n\n<Callout type=\"warning\">\n The MCP server requires an LLM provider to be configured for generating embeddings. Make sure you have set up your AI Assistant with a valid API key before using the MCP server.\n</Callout>\n\nSee the [AI Assistant documentation](/ai-assistant) for configuration details.\n\n## Content indexing\n\nYour MCP server searches content extracted from your page files. The server automatically discovers and indexes all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files in your `app/` directory.\n\n### Content extraction\n\nThe server extracts content from `const content =` declarations in your page files. Make sure your documentation pages export a `content` constant with your markdown or MDX content.\n\n**Example:**\n\n```tsx\nexport const content = `\n# Getting Started\n\nWelcome to the documentation...\n`;\n```\n\n### Excluded directories\n\nThe following directories are automatically excluded from indexing:\n\n- `node_modules`\n- `.next`\n- `.git`\n- `api`\n\n## Troubleshooting\n\n### Index not building\n\nIf the index is not building, check:\n\n- Your LLM provider is configured correctly in your `.env` file\n- You have a valid API key set\n- Your documentation pages export a `content` constant\n\n### No search results\n\nIf searches return no results:\n\n- Verify that your documentation pages are in the `app/` directory\n- Check that your pages export a `content` constant\n- Ensure the index has been built (check the `index.ready` status via GET `/api/mcp`)\n\n### Slow search performance\n\nSearches use the precomputed in-memory index and are fast. The first search is only slow when the app has to embed your docs at runtime, which happens when the precomputed index is missing or was built with a different provider or model. If performance is consistently slow:\n\n- Confirm the build ran the doc-index precompute step (the `build` script runs it before `next build`) with a valid API key\n- Check your embedding API response times\n- Consider reducing the number of documentation pages\n- Verify your server has sufficient memory\n\n### Cloudflare blocking MCP requests\n\nIf you use Cloudflare as a proxy in front of your documentation site, Cloudflare's bot protection may block server-to-server requests from AI tools like Claude.ai. This can cause MCP connections to fail silently or return errors.\n\nThere are two ways to resolve this:\n\n**Option 1: Disable the Cloudflare proxy (simplest)**\n\nIn your Cloudflare DNS settings, click the orange cloud icon next to your domain record to switch it to \"DNS only\" (grey cloud). This disables Cloudflare's proxy and bot protection for your domain, allowing MCP requests to reach your server directly.\n\n**Option 2: Add a Cloudflare WAF exception (keeps your custom domain proxied)**\n\nIn Cloudflare dashboard:\n\n1. Go to **Security > WAF**.\n2. Click **Create rule**.\n3. Set it up as:\n - **Rule name**: Allow MCP API\n - **Field**: URI Path\n - **Operator**: starts with\n - **Value**: `/api/mcp`\n - **Action**: Skip -- then check all remaining custom rules, Rate limiting rules, and Bot Fight Mode / Super Bot Fight Mode.\n4. Deploy the rule and make sure it is ordered first (above other rules).\n\n<Callout type=\"warning\">\n Also check **Security > Bots** in your Cloudflare dashboard. If \"Bot Fight Mode\" or \"Super Bot Fight Mode\" is enabled, that is very likely what is blocking server-to-server requests from AI tools.\n</Callout>\n\n## Best practices\n\n- **Keep content up-to-date**: Rebuild or redeploy your site after updating documentation to regenerate the index with fresh content.\n- **Use descriptive queries**: More specific queries yield better semantic search results.\n- **Monitor index status**: Use the GET endpoint to check if your index is ready before performing searches.\n- **Optimize content structure**: Well-structured markdown with clear headings improves search relevance.";
@@ -198,7 +198,8 @@ Doccupine runs this pipeline at build time and ships the resulting vectors with
198
198
  2. **Content Extraction**: It extracts content from \`const content =\` declarations in your page files.
199
199
  3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.
200
200
  4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.
201
- 5. **Index Building**: The embeddings are written to \`services/mcp/docs-index.json\`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.
201
+ 5. **Compaction**: Each vector is truncated to \`LLM_EMBEDDING_DIMS\` dimensions (default 512) and quantized to int8, keeping the index roughly 20x smaller than raw floats so large doc sets don't stall the chat on cold start.
202
+ 6. **Index Building**: The compact embeddings are written to \`services/mcp/docs-index.json\`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.
202
203
 
203
204
  ### Search Process
204
205
 
@@ -1 +1 @@
1
- export declare const buildDocsIndexScriptTemplate = "/**\n * Precompute document embeddings at build time.\n *\n * Runs before `next build` (wired into the \"build\" script in package.json).\n * Embeds every docs chunk once and writes services/mcp/docs-index.json, so the\n * running app loads vectors instead of re-embedding the whole doc set on every\n * serverless cold start (the main cause of slow first chats / proxy timeouts).\n *\n * Fails soft: without an API key, or on any embedding error, it leaves the\n * existing (possibly empty) index in place and exits 0 so the build proceeds.\n * The app then falls back to embedding on demand at runtime.\n */\nimport path from \"node:path\";\nimport { writeFileSync } from \"node:fs\";\n// @next/env is CommonJS; use a default import so the named export resolves\n// under tsx's ESM loader (a named import is not statically detected).\nimport nextEnv from \"@next/env\";\nimport { getAllDocsChunks } from \"../services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable } from \"../services/llm/config\";\nimport { createEmbeddings } from \"../services/llm/factory\";\n\n// This runs as a standalone process before `next build`, so it must load\n// .env / .env.local / .env.production itself (the same way Next does). Real\n// environment variables still take precedence over .env files.\nnextEnv.loadEnvConfig(process.cwd());\n\nconst OUTPUT = path.join(process.cwd(), \"services\", \"mcp\", \"docs-index.json\");\nconst BATCH_SIZE = 10;\n\nasync function main() {\n if (!isLLMAvailable()) {\n console.warn(\n \"[doccupine] No LLM API key set - skipping embedding precompute. \" +\n \"The chat will embed docs on demand at runtime.\",\n );\n return;\n }\n\n const chunks = await getAllDocsChunks();\n if (chunks.length === 0) {\n console.warn(\"[doccupine] No docs found to embed - skipping precompute.\");\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Embed in small batches to stay within provider token limits.\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = await embeddings.embedDocuments(\n texts.slice(i, i + BATCH_SIZE),\n );\n vectors.push(...batch);\n }\n\n const data = {\n provider: config.provider,\n embeddingModel: config.embeddingModel,\n chunks: chunks.map((c, i) => ({ ...c, embedding: vectors[i] })),\n };\n\n writeFileSync(OUTPUT, JSON.stringify(data));\n console.log(\n `[doccupine] Precomputed ${data.chunks.length} doc embeddings -> ${OUTPUT}`,\n );\n}\n\nmain()\n .then(() => process.exit(0))\n .catch((error) => {\n // Never fail the build on an embedding error - fall back to runtime embedding.\n console.warn(\n \"[doccupine] Embedding precompute failed; continuing build. \" +\n \"The chat will embed docs at runtime.\",\n error instanceof Error ? error.message : error,\n );\n process.exit(0);\n });\n";
1
+ export declare const buildDocsIndexScriptTemplate = "/**\n * Precompute document embeddings at build time.\n *\n * Runs before `next build` (wired into the \"build\" script in package.json).\n * Embeds every docs chunk once and writes services/mcp/docs-index.json, so the\n * running app loads vectors instead of re-embedding the whole doc set on every\n * serverless cold start (the main cause of slow first chats / proxy timeouts).\n *\n * Fails soft: without an API key, or on any embedding error, it leaves the\n * existing (possibly empty) index in place and exits 0 so the build proceeds.\n * The app then falls back to embedding on demand at runtime.\n */\nimport path from \"node:path\";\nimport { writeFileSync } from \"node:fs\";\n// @next/env is CommonJS; use a default import so the named export resolves\n// under tsx's ESM loader (a named import is not statically detected).\nimport nextEnv from \"@next/env\";\nimport { getAllDocsChunks } from \"../services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable } from \"../services/llm/config\";\nimport { createEmbeddings } from \"../services/llm/factory\";\nimport { reduceDims, quantizeInt8, encodeInt8 } from \"../services/mcp/vector\";\n\n// This runs as a standalone process before `next build`, so it must load\n// .env / .env.local / .env.production itself (the same way Next does). Real\n// environment variables still take precedence over .env files.\nnextEnv.loadEnvConfig(process.cwd());\n\nconst OUTPUT = path.join(process.cwd(), \"services\", \"mcp\", \"docs-index.json\");\nconst BATCH_SIZE = 10;\n\nasync function main() {\n if (!isLLMAvailable()) {\n console.warn(\n \"[doccupine] No LLM API key set - skipping embedding precompute. \" +\n \"The chat will embed docs on demand at runtime.\",\n );\n return;\n }\n\n const chunks = await getAllDocsChunks();\n if (chunks.length === 0) {\n console.warn(\"[doccupine] No docs found to embed - skipping precompute.\");\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Embed in small batches to stay within provider token limits, then reduce\n // each vector to config.embeddingDims and quantize to int8. Storing raw float\n // arrays as JSON balloons to 100MB+ on large doc sets, which OOMs / stalls the\n // serverless chat on cold start; int8 base64 keeps the index ~20x smaller.\n const texts = chunks.map((c) => c.text);\n const encoded: string[] = [];\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = await embeddings.embedDocuments(\n texts.slice(i, i + BATCH_SIZE),\n );\n for (const vector of batch) {\n encoded.push(\n encodeInt8(quantizeInt8(reduceDims(vector, config.embeddingDims))),\n );\n }\n }\n\n const data = {\n provider: config.provider,\n embeddingModel: config.embeddingModel,\n dims: config.embeddingDims,\n quantization: \"int8\",\n chunks: chunks.map((c, i) => ({ ...c, embedding: encoded[i] })),\n };\n\n writeFileSync(OUTPUT, JSON.stringify(data));\n console.log(\n `[doccupine] Precomputed ${data.chunks.length} doc embeddings -> ${OUTPUT}`,\n );\n}\n\nmain()\n .then(() => process.exit(0))\n .catch((error) => {\n // Never fail the build on an embedding error - fall back to runtime embedding.\n console.warn(\n \"[doccupine] Embedding precompute failed; continuing build. \" +\n \"The chat will embed docs at runtime.\",\n error instanceof Error ? error.message : error,\n );\n process.exit(0);\n });\n";
@@ -18,6 +18,7 @@ import nextEnv from "@next/env";
18
18
  import { getAllDocsChunks } from "../services/mcp/tools";
19
19
  import { getLLMConfig, isLLMAvailable } from "../services/llm/config";
20
20
  import { createEmbeddings } from "../services/llm/factory";
21
+ import { reduceDims, quantizeInt8, encodeInt8 } from "../services/mcp/vector";
21
22
 
22
23
  // This runs as a standalone process before \`next build\`, so it must load
23
24
  // .env / .env.local / .env.production itself (the same way Next does). Real
@@ -45,20 +46,29 @@ async function main() {
45
46
  const config = getLLMConfig();
46
47
  const embeddings = createEmbeddings(config);
47
48
 
48
- // Embed in small batches to stay within provider token limits.
49
+ // Embed in small batches to stay within provider token limits, then reduce
50
+ // each vector to config.embeddingDims and quantize to int8. Storing raw float
51
+ // arrays as JSON balloons to 100MB+ on large doc sets, which OOMs / stalls the
52
+ // serverless chat on cold start; int8 base64 keeps the index ~20x smaller.
49
53
  const texts = chunks.map((c) => c.text);
50
- const vectors: number[][] = [];
54
+ const encoded: string[] = [];
51
55
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
52
56
  const batch = await embeddings.embedDocuments(
53
57
  texts.slice(i, i + BATCH_SIZE),
54
58
  );
55
- vectors.push(...batch);
59
+ for (const vector of batch) {
60
+ encoded.push(
61
+ encodeInt8(quantizeInt8(reduceDims(vector, config.embeddingDims))),
62
+ );
63
+ }
56
64
  }
57
65
 
58
66
  const data = {
59
67
  provider: config.provider,
60
68
  embeddingModel: config.embeddingModel,
61
- chunks: chunks.map((c, i) => ({ ...c, embedding: vectors[i] })),
69
+ dims: config.embeddingDims,
70
+ quantization: "int8",
71
+ chunks: chunks.map((c, i) => ({ ...c, embedding: encoded[i] })),
62
72
  };
63
73
 
64
74
  writeFileSync(OUTPUT, JSON.stringify(data));
@@ -1 +1 @@
1
- export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"gemini-embedding-001\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function isLLMAvailable(): boolean {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n if (!keyName || !process.env[keyName]) return false;\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) return false;\n return true;\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
1
+ export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"gemini-embedding-001\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function isLLMAvailable(): boolean {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n if (!keyName || !process.env[keyName]) return false;\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) return false;\n return true;\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n // Vectors are Matryoshka-truncated to this many dimensions before being\n // stored as int8, keeping the prebuilt search index small. Must match between\n // build time and runtime; a mismatch forces a re-embed. Values >= the model's\n // native dimension keep full precision (default: 512).\n const rawDims = process.env.LLM_EMBEDDING_DIMS;\n let embeddingDims = 512;\n if (rawDims !== undefined && rawDims !== \"\") {\n const parsed = parseInt(rawDims, 10);\n if (Number.isFinite(parsed) && parsed >= 0) embeddingDims = parsed;\n }\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n embeddingDims,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
@@ -57,10 +57,21 @@ export function getLLMConfig(): LLMConfig {
57
57
  }
58
58
  validateAPIKeys(provider);
59
59
  const defaults = PROVIDER_DEFAULTS[provider];
60
+ // Vectors are Matryoshka-truncated to this many dimensions before being
61
+ // stored as int8, keeping the prebuilt search index small. Must match between
62
+ // build time and runtime; a mismatch forces a re-embed. Values >= the model's
63
+ // native dimension keep full precision (default: 512).
64
+ const rawDims = process.env.LLM_EMBEDDING_DIMS;
65
+ let embeddingDims = 512;
66
+ if (rawDims !== undefined && rawDims !== "") {
67
+ const parsed = parseInt(rawDims, 10);
68
+ if (Number.isFinite(parsed) && parsed >= 0) embeddingDims = parsed;
69
+ }
60
70
  return {
61
71
  provider,
62
72
  chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,
63
73
  embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,
74
+ embeddingDims,
64
75
  temperature: parseFloat(process.env.LLM_TEMPERATURE || "0"),
65
76
  };
66
77
  }
@@ -1 +1 @@
1
- export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n temperature: number;\n}\n\ninterface ProviderModels {\n chat: string;\n embedding: string;\n}\n\nexport type ProviderDefaults = Record<LLMProvider, ProviderModels>;\n";
1
+ export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n embeddingDims: number;\n temperature: number;\n}\n\ninterface ProviderModels {\n chat: string;\n embedding: string;\n}\n\nexport type ProviderDefaults = Record<LLMProvider, ProviderModels>;\n";
@@ -4,6 +4,7 @@ export interface LLMConfig {
4
4
  provider: LLMProvider;
5
5
  chatModel: string;
6
6
  embeddingModel: string;
7
+ embeddingDims: number;
7
8
  temperature: number;
8
9
  }
9
10
 
@@ -1 +1 @@
1
- export declare const docsIndexStubTemplate = "{\n \"provider\": null,\n \"embeddingModel\": null,\n \"chunks\": []\n}\n";
1
+ export declare const docsIndexStubTemplate = "{\n \"provider\": null,\n \"embeddingModel\": null,\n \"dims\": 0,\n \"quantization\": \"none\",\n \"chunks\": []\n}\n";
@@ -4,6 +4,8 @@
4
4
  export const docsIndexStubTemplate = `{
5
5
  "provider": null,
6
6
  "embeddingModel": null,
7
+ "dims": 0,
8
+ "quantization": "none",
7
9
  "chunks": []
8
10
  }
9
11
  `;
@@ -1 +1 @@
1
- export declare const mcpIndexTemplate = "export * from \"@/services/mcp/types\";\nexport * from \"@/services/mcp/tools\";\nexport * from \"@/services/mcp/server\";\n";
1
+ export declare const mcpIndexTemplate = "export * from \"@/services/mcp/types\";\nexport * from \"@/services/mcp/tools\";\nexport * from \"@/services/mcp/vector\";\nexport * from \"@/services/mcp/server\";\n";
@@ -1,4 +1,5 @@
1
1
  export const mcpIndexTemplate = `export * from "@/services/mcp/types";
2
2
  export * from "@/services/mcp/tools";
3
+ export * from "@/services/mcp/vector";
3
4
  export * from "@/services/mcp/server";
4
5
  `;
@@ -1 +1 @@
1
- export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty or was built with a different provider/model than the current\n * config - query and document vectors must come from the same embedding model.\n */\nfunction loadPrecomputedIndex():\n (DocsChunk & { embedding: number[] })[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n chunks?: (DocsChunk & { embedding: number[] })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel\n ) {\n return null;\n }\n return parsed.chunks;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
1
+ export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport {\n reduceDims,\n quantizeInt8,\n decodeInt8,\n cosineFloatInt8,\n} from \"@/services/mcp/vector\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/** A doc chunk with its stored int8-quantized embedding. */\ntype IndexedChunk = DocsChunk & { embedding: Int8Array };\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: IndexedChunk[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty, was built with a different provider/model/dimension than the\n * current config, or is not int8-quantized - query and document vectors must\n * come from the same embedding model and the same transform (dims + int8).\n * A null return makes the caller fall back to embedding on demand at runtime.\n */\nfunction loadPrecomputedIndex(): IndexedChunk[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n dims?: number;\n quantization?: string;\n chunks?: (DocsChunk & { embedding: string })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n // Guard on the exact transform: the same dims value at build and query time\n // guarantees reduceDims produces matching-length vectors on both sides.\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel ||\n parsed.dims !== config.embeddingDims ||\n parsed.quantization !== \"int8\"\n ) {\n return null;\n }\n const decoded: IndexedChunk[] = [];\n let expectedLen = -1;\n for (const c of parsed.chunks) {\n const embedding = decodeInt8(c.embedding);\n // Reject a corrupt index rather than scoring against ragged vectors.\n if (expectedLen === -1) expectedLen = embedding.length;\n else if (embedding.length !== expectedLen) return null;\n decoded.push({\n id: c.id,\n text: c.text,\n path: c.path,\n uri: c.uri,\n embedding,\n });\n }\n return decoded;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits.\n // Reduce + quantize to the same int8 representation as the precomputed\n // index so searchDocs scores identically on either path.\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const built: IndexedChunk[] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n for (let j = 0; j < batchVectors.length; j++) {\n built.push({\n ...chunks[i + j],\n embedding: quantizeInt8(\n reduceDims(batchVectors[j], config.embeddingDims),\n ),\n });\n }\n }\n\n docsIndex.chunks = built;\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n // Reduce the query vector with the exact same transform used at index time so\n // dimensions line up. Scoring runs directly against the int8 vectors - cosine\n // is scale-invariant, so no dequantization is needed.\n const rawQueryVector = await getEmbeddings().embedQuery(query);\n const queryVector = reduceDims(rawQueryVector, getLLMConfig().embeddingDims);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineFloatInt8(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
@@ -9,8 +9,17 @@ import {
9
9
  DOCS_TOOLS,
10
10
  } from "@/services/mcp/tools";
11
11
  import { getLLMConfig, isLLMAvailable, createEmbeddings } from "@/services/llm";
12
+ import {
13
+ reduceDims,
14
+ quantizeInt8,
15
+ decodeInt8,
16
+ cosineFloatInt8,
17
+ } from "@/services/mcp/vector";
12
18
  import type { DocsChunk } from "@/services/mcp/types";
13
19
 
20
+ /** A doc chunk with its stored int8-quantized embedding. */
21
+ type IndexedChunk = DocsChunk & { embedding: Int8Array };
22
+
14
23
  /**
15
24
  * In-memory cache for document embeddings.
16
25
  * Built once at server startup since docs are static.
@@ -18,7 +27,7 @@ import type { DocsChunk } from "@/services/mcp/types";
18
27
  let docsIndex: {
19
28
  ready: boolean;
20
29
  building: boolean;
21
- chunks: (DocsChunk & { embedding: number[] })[];
30
+ chunks: IndexedChunk[];
22
31
  } = {
23
32
  ready: false,
24
33
  building: false,
@@ -28,24 +37,6 @@ let docsIndex: {
28
37
  /** Resolves when the initial index build completes */
29
38
  let indexReady: Promise<void> | null = null;
30
39
 
31
- /**
32
- * Cosine similarity between two vectors
33
- */
34
- function cosineSim(a: number[], b: number[]): number {
35
- let dot = 0,
36
- na = 0,
37
- nb = 0;
38
- for (let i = 0; i < a.length; i++) {
39
- const x = a[i];
40
- const y = b[i];
41
- dot += x * y;
42
- na += x * x;
43
- nb += y * y;
44
- }
45
- if (na === 0 || nb === 0) return 0;
46
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
47
- }
48
-
49
40
  /**
50
41
  * Absolute path to the embeddings index precomputed at build time by
51
42
  * scripts/build-docs-index.mts. Bundled into serverless functions via
@@ -60,26 +51,48 @@ const INDEX_FILE = path.join(
60
51
 
61
52
  /**
62
53
  * Load embeddings precomputed at build time. Returns null when the file is
63
- * missing/empty or was built with a different provider/model than the current
64
- * config - query and document vectors must come from the same embedding model.
54
+ * missing/empty, was built with a different provider/model/dimension than the
55
+ * current config, or is not int8-quantized - query and document vectors must
56
+ * come from the same embedding model and the same transform (dims + int8).
57
+ * A null return makes the caller fall back to embedding on demand at runtime.
65
58
  */
66
- function loadPrecomputedIndex():
67
- (DocsChunk & { embedding: number[] })[] | null {
59
+ function loadPrecomputedIndex(): IndexedChunk[] | null {
68
60
  try {
69
61
  const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, "utf8")) as {
70
62
  provider?: string;
71
63
  embeddingModel?: string;
72
- chunks?: (DocsChunk & { embedding: number[] })[];
64
+ dims?: number;
65
+ quantization?: string;
66
+ chunks?: (DocsChunk & { embedding: string })[];
73
67
  };
74
68
  if (!parsed.chunks || parsed.chunks.length === 0) return null;
75
69
  const config = getLLMConfig();
70
+ // Guard on the exact transform: the same dims value at build and query time
71
+ // guarantees reduceDims produces matching-length vectors on both sides.
76
72
  if (
77
73
  parsed.provider !== config.provider ||
78
- parsed.embeddingModel !== config.embeddingModel
74
+ parsed.embeddingModel !== config.embeddingModel ||
75
+ parsed.dims !== config.embeddingDims ||
76
+ parsed.quantization !== "int8"
79
77
  ) {
80
78
  return null;
81
79
  }
82
- return parsed.chunks;
80
+ const decoded: IndexedChunk[] = [];
81
+ let expectedLen = -1;
82
+ for (const c of parsed.chunks) {
83
+ const embedding = decodeInt8(c.embedding);
84
+ // Reject a corrupt index rather than scoring against ragged vectors.
85
+ if (expectedLen === -1) expectedLen = embedding.length;
86
+ else if (embedding.length !== expectedLen) return null;
87
+ decoded.push({
88
+ id: c.id,
89
+ text: c.text,
90
+ path: c.path,
91
+ uri: c.uri,
92
+ embedding,
93
+ });
94
+ }
95
+ return decoded;
83
96
  } catch {
84
97
  return null;
85
98
  }
@@ -116,21 +129,27 @@ async function buildDocsIndex(force = false): Promise<void> {
116
129
  const config = getLLMConfig();
117
130
  const embeddings = createEmbeddings(config);
118
131
 
119
- // Process embeddings in small batches to avoid exceeding token limits
132
+ // Process embeddings in small batches to avoid exceeding token limits.
133
+ // Reduce + quantize to the same int8 representation as the precomputed
134
+ // index so searchDocs scores identically on either path.
120
135
  const BATCH_SIZE = 10;
121
136
  const texts = chunks.map((c) => c.text);
122
- const vectors: number[][] = [];
137
+ const built: IndexedChunk[] = [];
123
138
 
124
139
  for (let i = 0; i < texts.length; i += BATCH_SIZE) {
125
140
  const batch = texts.slice(i, i + BATCH_SIZE);
126
141
  const batchVectors = await embeddings.embedDocuments(batch);
127
- vectors.push(...batchVectors);
142
+ for (let j = 0; j < batchVectors.length; j++) {
143
+ built.push({
144
+ ...chunks[i + j],
145
+ embedding: quantizeInt8(
146
+ reduceDims(batchVectors[j], config.embeddingDims),
147
+ ),
148
+ });
149
+ }
128
150
  }
129
151
 
130
- docsIndex.chunks = chunks.map((c, i) => ({
131
- ...c,
132
- embedding: vectors[i],
133
- }));
152
+ docsIndex.chunks = built;
134
153
  docsIndex.ready = true;
135
154
  } catch (error) {
136
155
  // Reset so the next call to ensureDocsIndex retries
@@ -186,12 +205,16 @@ export async function searchDocs(
186
205
  ): Promise<{ chunk: DocsChunk; score: number }[]> {
187
206
  await ensureDocsIndex();
188
207
 
189
- const queryVector = await getEmbeddings().embedQuery(query);
208
+ // Reduce the query vector with the exact same transform used at index time so
209
+ // dimensions line up. Scoring runs directly against the int8 vectors - cosine
210
+ // is scale-invariant, so no dequantization is needed.
211
+ const rawQueryVector = await getEmbeddings().embedQuery(query);
212
+ const queryVector = reduceDims(rawQueryVector, getLLMConfig().embeddingDims);
190
213
 
191
214
  const scored = docsIndex.chunks
192
215
  .map((c) => ({
193
216
  chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },
194
- score: cosineSim(queryVector, c.embedding),
217
+ score: cosineFloatInt8(queryVector, c.embedding),
195
218
  }))
196
219
  .sort((a, b) => b.score - a.score)
197
220
  .slice(0, limit);
@@ -0,0 +1 @@
1
+ export declare const vectorHelpersTemplate = "/**\n * Shared vector helpers for the docs embedding index.\n *\n * Both the build-time indexer (scripts/build-docs-index.mts) and the runtime\n * search path (services/mcp/server.ts) import these so query vectors and stored\n * vectors get the EXACT same transform - any mismatch would silently corrupt\n * ranking. Vectors are Matryoshka-truncated to a smaller dimension and stored\n * as int8, which shrinks each vector ~40x (and the overall index ~20x once\n * chunk text is counted) versus raw JSON floats. Large doc sets used to produce\n * 100MB+ indexes that OOM'd or timed out serverless cold starts (the chat would\n * connect, then idle forever).\n */\n\n/** L2-normalize a vector in place and return it. */\nexport function l2normalize(vector: number[]): number[] {\n let norm = 0;\n for (let i = 0; i < vector.length; i++) norm += vector[i] * vector[i];\n norm = Math.sqrt(norm);\n if (norm === 0) return vector;\n for (let i = 0; i < vector.length; i++) vector[i] = vector[i] / norm;\n return vector;\n}\n\n/**\n * Matryoshka dimension reduction: keep the first `dims` components and\n * renormalize. text-embedding-3-* and gemini-embedding-001 are MRL-trained, so\n * a renormalized prefix is a valid lower-dimension embedding. When `dims` is\n * <= 0 or >= the vector length, the vector is returned normalized at full\n * length. Operates on a copy so the caller's vector is left untouched.\n */\nexport function reduceDims(vector: number[], dims: number): number[] {\n const target = dims > 0 && dims < vector.length ? dims : vector.length;\n return l2normalize(vector.slice(0, target));\n}\n\n/**\n * Quantize a float vector to int8 using a per-vector max-abs scale. Cosine\n * similarity is scale-invariant, so the scale factor never has to be stored:\n * cosine(query, scale * q) === cosine(query, q). Callers score directly against\n * the int8 array via cosineFloatInt8, so there is no dequantization step.\n */\nexport function quantizeInt8(vector: number[]): Int8Array {\n let maxAbs = 0;\n for (let i = 0; i < vector.length; i++) {\n const a = Math.abs(vector[i]);\n if (a > maxAbs) maxAbs = a;\n }\n const scale = maxAbs > 0 ? 127 / maxAbs : 0;\n const q = new Int8Array(vector.length);\n for (let i = 0; i < vector.length; i++) {\n // Clamp before assignment: Int8Array wraps out-of-range values (200 -> -56)\n // instead of clamping, which would flip a component's sign.\n let s = Math.round(vector[i] * scale);\n if (s > 127) s = 127;\n else if (s < -127) s = -127;\n q[i] = s;\n }\n return q;\n}\n\n/** Encode an int8 vector as base64 for compact JSON storage. */\nexport function encodeInt8(q: Int8Array): string {\n return Buffer.from(q.buffer, q.byteOffset, q.byteLength).toString(\"base64\");\n}\n\n/** Decode a base64 int8 vector produced by encodeInt8. */\nexport function decodeInt8(b64: string): Int8Array {\n const buf = Buffer.from(b64, \"base64\");\n return new Int8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n}\n\n/**\n * Cosine similarity between a float query vector and an int8 stored vector.\n * Both must share the same length. The int8 scale cancels out in the\n * normalization, so scoring against the raw int8 array is exact up to the\n * quantization rounding error (negligible for top-K retrieval).\n */\nexport function cosineFloatInt8(query: number[], stored: Int8Array): number {\n const len = Math.min(query.length, stored.length);\n let dot = 0;\n let na = 0;\n let nb = 0;\n for (let i = 0; i < len; i++) {\n const x = query[i];\n const y = stored[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n";
@@ -0,0 +1,93 @@
1
+ export const vectorHelpersTemplate = `/**
2
+ * Shared vector helpers for the docs embedding index.
3
+ *
4
+ * Both the build-time indexer (scripts/build-docs-index.mts) and the runtime
5
+ * search path (services/mcp/server.ts) import these so query vectors and stored
6
+ * vectors get the EXACT same transform - any mismatch would silently corrupt
7
+ * ranking. Vectors are Matryoshka-truncated to a smaller dimension and stored
8
+ * as int8, which shrinks each vector ~40x (and the overall index ~20x once
9
+ * chunk text is counted) versus raw JSON floats. Large doc sets used to produce
10
+ * 100MB+ indexes that OOM'd or timed out serverless cold starts (the chat would
11
+ * connect, then idle forever).
12
+ */
13
+
14
+ /** L2-normalize a vector in place and return it. */
15
+ export function l2normalize(vector: number[]): number[] {
16
+ let norm = 0;
17
+ for (let i = 0; i < vector.length; i++) norm += vector[i] * vector[i];
18
+ norm = Math.sqrt(norm);
19
+ if (norm === 0) return vector;
20
+ for (let i = 0; i < vector.length; i++) vector[i] = vector[i] / norm;
21
+ return vector;
22
+ }
23
+
24
+ /**
25
+ * Matryoshka dimension reduction: keep the first \`dims\` components and
26
+ * renormalize. text-embedding-3-* and gemini-embedding-001 are MRL-trained, so
27
+ * a renormalized prefix is a valid lower-dimension embedding. When \`dims\` is
28
+ * <= 0 or >= the vector length, the vector is returned normalized at full
29
+ * length. Operates on a copy so the caller's vector is left untouched.
30
+ */
31
+ export function reduceDims(vector: number[], dims: number): number[] {
32
+ const target = dims > 0 && dims < vector.length ? dims : vector.length;
33
+ return l2normalize(vector.slice(0, target));
34
+ }
35
+
36
+ /**
37
+ * Quantize a float vector to int8 using a per-vector max-abs scale. Cosine
38
+ * similarity is scale-invariant, so the scale factor never has to be stored:
39
+ * cosine(query, scale * q) === cosine(query, q). Callers score directly against
40
+ * the int8 array via cosineFloatInt8, so there is no dequantization step.
41
+ */
42
+ export function quantizeInt8(vector: number[]): Int8Array {
43
+ let maxAbs = 0;
44
+ for (let i = 0; i < vector.length; i++) {
45
+ const a = Math.abs(vector[i]);
46
+ if (a > maxAbs) maxAbs = a;
47
+ }
48
+ const scale = maxAbs > 0 ? 127 / maxAbs : 0;
49
+ const q = new Int8Array(vector.length);
50
+ for (let i = 0; i < vector.length; i++) {
51
+ // Clamp before assignment: Int8Array wraps out-of-range values (200 -> -56)
52
+ // instead of clamping, which would flip a component's sign.
53
+ let s = Math.round(vector[i] * scale);
54
+ if (s > 127) s = 127;
55
+ else if (s < -127) s = -127;
56
+ q[i] = s;
57
+ }
58
+ return q;
59
+ }
60
+
61
+ /** Encode an int8 vector as base64 for compact JSON storage. */
62
+ export function encodeInt8(q: Int8Array): string {
63
+ return Buffer.from(q.buffer, q.byteOffset, q.byteLength).toString("base64");
64
+ }
65
+
66
+ /** Decode a base64 int8 vector produced by encodeInt8. */
67
+ export function decodeInt8(b64: string): Int8Array {
68
+ const buf = Buffer.from(b64, "base64");
69
+ return new Int8Array(buf.buffer, buf.byteOffset, buf.byteLength);
70
+ }
71
+
72
+ /**
73
+ * Cosine similarity between a float query vector and an int8 stored vector.
74
+ * Both must share the same length. The int8 scale cancels out in the
75
+ * normalization, so scoring against the raw int8 array is exact up to the
76
+ * quantization rounding error (negligible for top-K retrieval).
77
+ */
78
+ export function cosineFloatInt8(query: number[], stored: Int8Array): number {
79
+ const len = Math.min(query.length, stored.length);
80
+ let dot = 0;
81
+ let na = 0;
82
+ let nb = 0;
83
+ for (let i = 0; i < len; i++) {
84
+ const x = query[i];
85
+ const y = stored[i];
86
+ dot += x * y;
87
+ na += x * x;
88
+ nb += y * y;
89
+ }
90
+ if (na === 0 || nb === 0) return 0;
91
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
92
+ }
93
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.119",
3
+ "version": "0.0.120",
4
4
  "description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {