From e48d899843cc9627bffc8fd526506c8be5aafe14 Mon Sep 17 00:00:00 2001 From: Hao-Yu-la Date: Tue, 1 Sep 2026 15:06:09 +0800 Subject: [PATCH 1/4] feat(knowledgebase): add list docs tool --- server/mcp_server_knowledgebase/README.md | 417 ++++++++++++------ server/mcp_server_knowledgebase/README_zh.md | 405 ++++++++++++----- .../src/mcp_server_knowledgebase/models.py | 9 + .../src/mcp_server_knowledgebase/server.py | 50 +++ 4 files changed, 653 insertions(+), 228 deletions(-) diff --git a/server/mcp_server_knowledgebase/README.md b/server/mcp_server_knowledgebase/README.md index ab230ac6..14512c91 100644 --- a/server/mcp_server_knowledgebase/README.md +++ b/server/mcp_server_knowledgebase/README.md @@ -1,237 +1,402 @@ # Viking Knowledge Base MCP Server -This MCP server provides a tool to interact with the VolcEngine Viking Knowledge Base Service, allowing you to search and retrieve knowledge from your collections, meanwhile, -allowing you to add doc to your collections and get doc processing info by doc_id. +[简体中文](README_zh.md) + +## Overview + +Viking Knowledge Base MCP Server lets MCP clients such as Claude Desktop, +Cursor, Cline, and Trae interact with VolcEngine Viking Knowledge Base. It +supports managing and inspecting collections and documents, and searching for +relevant knowledge chunks. ## Features -- Search knowledge based on queries with customizable parameters +- Add a document to a knowledge base collection by URL +- Get document information and processing status +- List documents with cursor pagination +- Get collection information and build status +- List collections in the configured project +- Search a collection with optional document filtering +- Authenticate with either a Viking API key or VolcEngine AK/SK credentials +- Run over stdio or stateless Streamable HTTP + +## Prerequisites -## Setup +- Python 3.10 or later +- A Viking Knowledge Base API key, or VolcEngine AK/SK credentials -### Prerequisites +## Installation -- Python 3.10 or higher -- A Viking Knowledge Base API key or VolcEngine AK/SK credentials +Install from PyPI: -### Installation +```bash +pip install mcp-server-knowledgebase +``` -1. Install the package: +Or install it as a persistent command-line tool with `uv`: ```bash -pip install -e . +uv tool install mcp-server-knowledgebase ``` -Or with uv (recommended): +For local development, clone the repository and install this package in +editable mode: ```bash +git clone https://github.com/volcengine/mcp-server.git +cd mcp-server/server/mcp_server_knowledgebase uv pip install -e . ``` -### Configuration +## Configuration -The server requires at least one authentication method: +### Authentication + +Configure at least one authentication method: - API key: set `VIKING_API_KEY`. Requests use `Authorization: Bearer `. -- AK/SK: set both `VOLCENGINE_ACCESS_KEY` and `VOLCENGINE_SECRET_KEY`. - Requests use VolcEngine SignerV4 authentication. +- AK/SK: set both `VOLCENGINE_ACCESS_KEY` and + `VOLCENGINE_SECRET_KEY`. Requests use VolcEngine SignerV4 authentication. + +When both methods are configured, `VIKING_API_KEY` takes precedence. If no API +key is set, the access key and secret key must be configured together. + +### Environment variables + +| Environment variable | Description | Default | +|---|---|---| +| `VIKING_API_KEY` | Viking Knowledge Base API key; takes precedence when configured | - | +| `VOLCENGINE_ACCESS_KEY` | VolcEngine access key | - | +| `VOLCENGINE_SECRET_KEY` | VolcEngine secret key | - | +| `KNOWLEDGE_BASE_PROJECT` | Viking Knowledge Base project | `default` | +| `KNOWLEDGE_BASE_REGION` | VolcEngine region used for AK/SK signing | `cn-north-1` | +| `KNOWLEDGE_BASE_TIMEOUT` | Upstream request timeout in seconds | `30` | +| `MCP_SERVER_HOST` | Streamable HTTP bind host | `127.0.0.1` | +| `MCP_SERVER_PORT` | Streamable HTTP port; falls back to `PORT` | `8000` | +| `STREAMABLE_HTTP_PATH` | Streamable HTTP endpoint path | `/mcp` | + +## Running the server -When both methods are configured, `VIKING_API_KEY` takes precedence and AK/SK -is ignored. When no API key is configured, AK and SK must be provided together. -The server rejects configurations with no usable authentication method. +Run with the default stdio transport: -Optional environment variables: -- `KNOWLEDGE_BASE_PROJECT`: Viking Knowledge Base project name (default: `default`) -- `KNOWLEDGE_BASE_REGION`: Viking Knowledge Base region (default: `cn-north-1`) -- `MCP_SERVER_HOST`: Streamable HTTP bind host (default: `127.0.0.1`) -- `MCP_SERVER_PORT`: Streamable HTTP port; falls back to `PORT` (default: `8000`) -- `STREAMABLE_HTTP_PATH`: Streamable HTTP endpoint path (default: `/mcp`) -- `KNOWLEDGE_BASE_TIMEOUT`: Upstream request timeout in seconds (default: `30`) +```bash +mcp-server-knowledgebase +``` -## Usage +Run the published package without installing it persistently: -### Running the Server +```bash +uvx --from mcp-server-knowledgebase mcp-server-knowledgebase +``` -The server supports stdio for local integrations and stateless Streamable HTTP -for remote deployments: +Or run the module directly from a source checkout: ```bash python -m mcp_server_knowledgebase.server --transport stdio ``` -Or: +Run with stateless Streamable HTTP: ```bash -python -m mcp_server_knowledgebase.server --transport streamable-http +mcp-server-knowledgebase --transport streamable-http ``` -The Streamable HTTP endpoint is `http://127.0.0.1:8000/mcp` by default. -Set `MCP_SERVER_HOST=0.0.0.0` when running behind a trusted gateway. +The default endpoint is `http://127.0.0.1:8000/mcp`. Set +`MCP_SERVER_HOST=0.0.0.0` only when deploying behind a trusted gateway. + +### MCP protocol and deployment security -### MCP protocol compatibility +The server uses MCP Python SDK 2.x and supports protocol revision `2026-07-28`, +including stateless `server/discover` negotiation. The SDK also handles older +handshake-based clients. Legacy HTTP+SSE is not exposed because it is +deprecated by the `2026-07-28` specification. -This server uses MCP Python SDK 2.x and speaks protocol revision `2026-07-28`. -Modern clients use the stateless per-request protocol and `server/discover`; -the same process also supports older handshake-based clients automatically. -Legacy HTTP+SSE is intentionally not exposed because it is deprecated by the -`2026-07-28` specification. +Streamable HTTP does not turn the configured Viking API key or VolcEngine +AK/SK into client authentication. Protect remote deployments with an +authentication gateway or MCP-compatible OAuth, and never expose service +credentials to callers. -The HTTP endpoint does not turn the configured API key or VolcEngine AK/SK into -client authentication. Protect remote deployments with an authentication -gateway or MCP-compatible OAuth, and never expose the service credentials to -callers. +## Available tools -### Available Tools +- [`add_doc`](https://www.volcengine.com/docs/84313/1254624): Add a document by URL +- [`get_doc`](https://www.volcengine.com/docs/84313/1254615): Get document information and processing status +- [`list_docs`](https://docs.volcengine.com/docs/84313/2477871?lang=zh): List documents with cursor pagination +- [`get_collection`](https://www.volcengine.com/docs/84313/1254602): Get collection information +- [`list_collections`](https://www.volcengine.com/docs/84313/1254596): List collections in the configured project +- [`search_knowledge`](https://www.volcengine.com/docs/84313/1350012): Search knowledge in a collection -#### add_doc +### `add_doc` -Add a document to a collection in your project. +Add a supported document to a collection by URL. ```python add_doc( - collection_name="collection_name", + collection_name="product_docs", add_type="url", - doc_id="mcp_server_auto_gen_doc_id_xxxxxxx", - doc_name="doc_xxxx", + doc_id="product_guide_2026", + doc_name="Product Guide", doc_type="pdf", - url="http://xxxxx.pdf" + url="https://example.com/product-guide.pdf", ) ``` Parameters: -- `collection_name` (required): the name of the collection you want to add document . -- `add_type` (required): the type of the document to add. so far only support "url" now. -- `doc_id` (required): you should generate a unique doc_id based on user's given url and timestamp, the doc_id can only use English letters, numbers, and underscores , and must start with an English letter. It cannot be empty. Length requirement: [1, 128], you can use a format like "mcp_server_auto_gen_doc_id_xxxxxxx". -- `doc_name` (required): the name of the document to add. You can generate a unique doc_name based on the user-provided URL and timestamp. The length of doc_name must be between 1 and 256; for example, "mcp_server_auto_gen_doc_name_xxxxxxx". -- `doc_type` (required): the type of the document to add. for structured document, we support xlsx, csv,jsonl, for unstructured document, wu support txt, doc, docx, pdf, markdown, faq.xlsx, pptx". you should judge the doc_type based on user's given url and judge if we support this doc type. if supported, assign this parameter. -- `url` (required): the url of the document to add. user should give a valid url, we will add the doc to the collection. -#### get_doc +- `collection_name` (required): Target collection name. +- `add_type` (required): Currently only `"url"` is supported. +- `doc_id` (required): Unique ID containing only letters, numbers, and + underscores. It must start with a letter and contain 1–128 characters. +- `doc_name` (required): Document name containing 1–256 characters. +- `doc_type` (required): One of `xlsx`, `csv`, `jsonl`, `txt`, `doc`, `docx`, + `pdf`, `markdown`, `faq.xlsx`, or `pptx`. +- `url` (required): Accessible URL of the document. + +Example result: + +```json +{ + "collection_name": "product_docs", + "doc_id": "product_guide_2026" +} +``` + +### `get_doc` -Get information about document by collection_name and doc_id . +Get a document's metadata and processing status. ```python get_doc( - collection_name="collection_name", - doc_id="mcp_server_auto_gen_doc_id_xxxxxxx", + collection_name="product_docs", + doc_id="product_guide_2026", ) ``` Parameters: -- `collection_name` (required): the name of the collection you want to get information . -- `doc_id` (required): the doc_id of document user want to get information . -#### get_collection +- `collection_name` (required): Collection containing the document. +- `doc_id` (required): Document ID. + +Example result: + +```json +{ + "collection_name": "product_docs", + "doc_id": "product_guide_2026", + "doc_name": "Product Guide", + "doc_type": "pdf", + "url": "https://example.com/product-guide.pdf", + "add_type": "url", + "create_time": 1788220800, + "update_time": 1788220860, + "point_num": 53, + "status": { + "process_status": 0, + "failed_code": null + } +} +``` + +`process_status` values: `0` completed, `1` failed, `2` or `3` queued, `5` +deleting, and `6` processing. Fields not returned by Viking are `null`; +additional upstream document fields are preserved. -Get information about a viking knowledge base collection from your project . +### `list_docs` + +List documents in a collection using cursor pagination. ```python -get_collection( - collection_name="collection_name", +list_docs( + collection_name="product_docs", + limit=2, + next_token=None, ) ``` Parameters: -- `collection_name` (required): the name of the collection you want to get information . +- `collection_name` (required): Collection whose documents will be listed. +- `limit` (optional): Number of documents to return, from 1 to 100. Defaults + to `100`. +- `next_token` (optional): Opaque cursor returned by the previous call. Omit + it for the first page. An empty cursor in the result means all documents + have been returned. + +Example result: + +```json +{ + "collection_name": "product_docs", + "total_num": 3, + "count": 2, + "doc_list": [ + { + "collection_name": "product_docs", + "doc_id": "product_guide_2026", + "doc_name": "Product Guide", + "doc_type": "pdf", + "url": "https://example.com/product-guide.pdf", + "add_type": "url", + "create_time": 1788220800, + "update_time": 1788220860, + "point_num": 53, + "status": { + "process_status": 0 + }, + "brief_summary": "An introduction to the product.", + "total_tokens": 345 + } + ], + "has_more": true, + "next_token": "opaque-cursor-for-next-page" +} +``` + +`total_num` is `null` when Viking does not provide it. Document entries +preserve additional upstream fields such as summaries and token counts. -#### list_collections +### `get_collection` -List all knowledge base collections of the globally configured project . +Get information and build status for a collection. ```python -list_collections( -) +get_collection(collection_name="product_docs") ``` +Parameters: + +- `collection_name` (required): Collection name. + +Example result: + +```json +{ + "collection_name": "product_docs", + "description": "Product manuals and release notes", + "status": 1 +} +``` -#### search_knowledge +Collection status values: `-1` pending build, `0` building, `1` completed, +`2` failed, and `3` changing. -Search for knowledge in the configured collection based on a query. +### `list_collections` + +List all collections in the globally configured project. + +```python +list_collections() +``` + +This tool has no parameters. + +Example result: + +```json +{ + "collection_list": [ + { + "collection_name": "product_docs", + "description": "Product manuals and release notes" + }, + { + "collection_name": "support_faq", + "description": "Frequently asked support questions" + } + ] +} +``` + +### `search_knowledge` + +Search for relevant chunks in a collection. An optional document filter can +include or exclude matching document field values. ```python search_knowledge( - query="How to reset my password?", + query="How do I reset my password?", + collection_name="support_faq", limit=3, - collection_name="collection_name", - doc_filter=None, + doc_filter={ + "op": "must", + "field": "doc_id", + "conds": ["account_guide"], + }, ) ``` Parameters: -- `query` (required): The search query string -- `limit` (optional): Maximum number of results to return, from 1 to 100 (default: 3) -- `collection_name` (required): Knowledge Base collection name to search -- `doc_filter` (optional): the filter is used to filter search results(default: None), which is structured as a JSON object with the following key components: - - `op` (string, required): specifies the query operator that defines the filtering logic. Valid values are 'must' and 'must_not', 'must' means results must satisfy the condition (inclusion filter),'must_not' means results must not satisfy the condition (exclusion filter). - - `field` (string, required): indicates the specific document field to apply the filter on (e.g., "doc_id"). - - `conds` (array, required): contains the concrete values used for filtering. The data type of elements in the array depends on the field. -Each result contains the chunk `id` and `content`, plus the source document's -`doc_id` and `doc_name`. The metadata fields are `null` when Viking does not -provide them. A non-null `doc_id` can be passed directly to `get_doc`. +- `query` (required): Search query. +- `collection_name` (required): Collection to search. +- `limit` (optional): Maximum number of chunks to return, from 1 to 100. + Defaults to `3`. +- `doc_filter` (optional): Object with the following fields: + - `op`: `"must"` to include matches or `"must_not"` to exclude them. + - `field`: Document field to filter, such as `"doc_id"`. + - `conds`: Non-empty list of values to match. + +Example result: + +```json +{ + "result_list": [ + { + "id": "chunk_001", + "content": "Open Account Settings and select Reset Password.", + "doc_id": "account_guide", + "doc_name": "Account Guide" + } + ] +} +``` + +`doc_id` and `doc_name` are `null` when Viking does not provide document +metadata. A non-null `doc_id` can be passed directly to `get_doc`. -## MCP Integration +## MCP client configuration -To add this server to your MCP configuration, add the following to your MCP settings file: +Example stdio configuration using `uvx` and a Viking API key: ```json { "mcpServers": { "knowledgebase": { "command": "uvx", - "args": [ - "--from", - "mcp-server-knowledgebase>=0.2.0", - "mcp-server-knowledgebase" + "args": [ + "--from", + "mcp-server-knowledgebase>=0.2.0", + "mcp-server-knowledgebase" ], "env": { "VIKING_API_KEY": "your-viking-api-key", "KNOWLEDGE_BASE_PROJECT": "your-project-name", - "KNOWLEDGE_BASE_REGION": "your-region" + "KNOWLEDGE_BASE_REGION": "cn-north-1" } } } } ``` -You may alternatively or additionally configure both -`VOLCENGINE_ACCESS_KEY` and `VOLCENGINE_SECRET_KEY`. If all three variables are -set, `VIKING_API_KEY` takes precedence. +You can instead configure both `VOLCENGINE_ACCESS_KEY` and +`VOLCENGINE_SECRET_KEY`. If all three credentials are present, +`VIKING_API_KEY` takes precedence. ## Troubleshooting -### Common Issues - -1. **Authentication Errors** - - Verify your API key or AK/SK credentials are correct - - Ensure at least one authentication method is configured - - Check that you have the necessary permissions for the collection - -2. **Connection Timeouts** - - Check your network connection to the VolcEngine API - - Verify the host configuration is correct - -3. **Empty Results** - - Verify the collection name is correct - - Try broadening your search query - -### Logging - -The server uses Python's logging module with INFO level by default. You can see detailed logs in the console when running the server. - -## Contributing - -Contributions to improve the Viking Knowledge Base MCP Server are welcome. Please follow these steps: - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Submit a pull request - -Please ensure your code follows the project's coding standards and includes appropriate tests. +1. Authentication errors + - Verify the API key or AK/SK credentials. + - When using AK/SK, configure both values together. + - Check that the credentials can access the configured project and collection. +2. Connection timeouts + - Check network connectivity to the VolcEngine API. + - Adjust `KNOWLEDGE_BASE_TIMEOUT` when necessary. +3. Empty results + - Verify the project and collection names. + - For search, try a broader query or remove the document filter. + - For document listing, continue only with the exact `next_token` returned + by the previous call. ## License -volcengine/mcp-server is licensed under the [MIT License](https://github.com/volcengine/mcp-server/blob/main/LICENSE). +This project is licensed under the [MIT License](https://github.com/volcengine/mcp-server/blob/main/LICENSE). diff --git a/server/mcp_server_knowledgebase/README_zh.md b/server/mcp_server_knowledgebase/README_zh.md index 25c12a2e..f2b4884d 100644 --- a/server/mcp_server_knowledgebase/README_zh.md +++ b/server/mcp_server_knowledgebase/README_zh.md @@ -1,191 +1,392 @@ # Viking Knowledge Base MCP Server -## 产品描述 +[English](README.md) -Viking Knowledge Base MCP Server 是一个模型上下文协议(Model Context Protocol)服务器,为MCP客户端(如Claude Desktop)提供与火山引擎知识库KnowledgeBase服务交互的能力。知识库MCP Server支持获取用户账号下的所有知识库列表,并在指定的知识库中检索结果。同时支持您以url上传的方式将文档上传到您的知识库,也支持查看文档和知识库的状态信息。 +## 产品介绍 -## 分类 -其他 +Viking Knowledge Base MCP Server 让 Claude Desktop、Cursor、Cline、Trae +等 MCP 客户端能够与火山引擎 Viking 知识库交互,支持管理和查看知识库及 +文档,并从知识库中检索相关知识切片。 ## 功能 -- 获取用户账号下的所有知识库列表 -- 在指定的知识库中检索结果 -- 以url上传的方式将文档上传到您的知识库 -- 查看文档的处理状态 -- 查看知识库的状态 +- 通过 URL 向知识库添加文档 +- 获取文档信息和处理状态 +- 使用游标分页获取文档列表 +- 获取知识库信息和构建状态 +- 获取已配置 Project 下的知识库列表 +- 在知识库中检索,并支持可选的文档过滤条件 +- 支持 Viking API Key 或火山引擎 AK/SK 鉴权 +- 支持 stdio 和无状态 Streamable HTTP 传输 -## 使用指南 +## 前置条件 -### 前置准备 -- Python 3.10+ -- UV -- 知识库 API Key 或火山引擎 AK/SK +- Python 3.10 或更高版本 +- Viking 知识库 API Key,或火山引擎 AK/SK 凭证 + +## 安装 + +从 PyPI 安装: -### 安装 -克隆仓库: ```bash -git clone git@github.com:volcengine/mcp-server.git +pip install mcp-server-knowledgebase ``` -### 使用方法 -启动服务器: +也可以使用 `uv` 将其安装为持久可用的命令行工具: -#### UV ```bash -cd mcp-server/server/mcp_server_knowledgebase -uv run mcp-server-knowledgebase +uv tool install mcp-server-knowledgebase +``` + +本地开发时,克隆仓库并以 editable 模式安装当前软件包: -# 使用无状态 Streamable HTTP 模式启动(默认为 stdio) -uv run mcp-server-knowledgebase -t streamable-http +```bash +git clone https://github.com/volcengine/mcp-server.git +cd mcp-server/server/mcp_server_knowledgebase +uv pip install -e . ``` -Streamable HTTP 默认地址为 `http://127.0.0.1:8000/mcp`。在可信网关后部署时, -可设置 `MCP_SERVER_HOST=0.0.0.0`。 +## 配置 + +### 鉴权 -Server 使用 MCP Python SDK 2.x,支持 `2026-07-28` 协议修订版及 -`server/discover` 无状态协商,同时由 SDK 自动兼容旧版握手客户端。 -旧 HTTP+SSE 已被新协议弃用,因此本 Server 不再提供 SSE 启动模式。 +至少配置一种鉴权方式: -Streamable HTTP 本身不会把知识库 API Key 或火山引擎 AK/SK 转换成 MCP 调用方认证。 -远程部署必须放在认证网关之后或接入兼容 MCP 的 OAuth,且不得向调用方暴露服务凭证。 +- API Key:设置 `VIKING_API_KEY`,请求使用 + `Authorization: Bearer `。 +- AK/SK:同时设置 `VOLCENGINE_ACCESS_KEY` 和 + `VOLCENGINE_SECRET_KEY`,请求使用火山引擎 SignerV4 鉴权。 + +同时配置两种方式时,优先使用 `VIKING_API_KEY`。未配置 API Key 时, +Access Key 和 Secret Key 必须成对配置。 + +### 环境变量 -使用客户端与服务器交互: +| 环境变量 | 描述 | 默认值 | +|---|---|---| +| `VIKING_API_KEY` | Viking 知识库 API Key;配置后优先使用 | - | +| `VOLCENGINE_ACCESS_KEY` | 火山引擎 Access Key | - | +| `VOLCENGINE_SECRET_KEY` | 火山引擎 Secret Key | - | +| `KNOWLEDGE_BASE_PROJECT` | Viking 知识库所属 Project | `default` | +| `KNOWLEDGE_BASE_REGION` | AK/SK 签名使用的火山引擎地域 | `cn-north-1` | +| `KNOWLEDGE_BASE_TIMEOUT` | 上游请求超时时间,单位为秒 | `30` | +| `MCP_SERVER_HOST` | Streamable HTTP 监听地址 | `127.0.0.1` | +| `MCP_SERVER_PORT` | Streamable HTTP 端口;未设置时读取 `PORT` | `8000` | +| `STREAMABLE_HTTP_PATH` | Streamable HTTP 端点路径 | `/mcp` | + +## 启动服务器 + +使用默认的 stdio 传输方式启动: + +```bash +mcp-server-knowledgebase ``` -Trae | Cursor | Claude Desktop | Cline | ... + +无需持久安装即可直接运行已发布的软件包: + +```bash +uvx --from mcp-server-knowledgebase mcp-server-knowledgebase ``` -## 配置 +也可以在源码目录中直接运行模块: -### 环境变量 +```bash +python -m mcp_server_knowledgebase.server --transport stdio +``` -鉴权至少需要配置一种方式:配置 `VIKING_API_KEY`,或同时配置 -`VOLCENGINE_ACCESS_KEY` 和 `VOLCENGINE_SECRET_KEY`。API Key 模式会通过 -`Authorization: Bearer ` 请求头鉴权;AK/SK 模式继续使用 -SignerV4。两种方式可以同时配置,此时 `VIKING_API_KEY` 优先,AK/SK 会被 -忽略。未配置 API Key 时,AK 和 SK 必须同时提供;没有可用鉴权方式时服务将 -启动失败。 +使用无状态 Streamable HTTP 启动: -以下环境变量可用于配置MCP服务器: +```bash +mcp-server-knowledgebase --transport streamable-http +``` + +默认端点为 `http://127.0.0.1:8000/mcp`。仅在可信网关后部署时设置 +`MCP_SERVER_HOST=0.0.0.0`。 -| 环境变量 | 描述 | 默认值 | -|--------------------------|-----------------|-------| -| `VIKING_API_KEY` | 知识库 API Key(配置时优先使用) | - | -| `VOLCENGINE_ACCESS_KEY` | 火山引擎账号ACCESSKEY | - | -| `VOLCENGINE_SECRET_KEY` | 火山引擎账号SECRETKEY | - | -| `KNOWLEDGE_BASE_PROJECT` | 知识库所属项目 | `default` | -| `KNOWLEDGE_BASE_REGION` | 知识库区域 | cn-north-1 | -| `MCP_SERVER_HOST` | Streamable HTTP 监听地址 | `127.0.0.1` | -| `MCP_SERVER_PORT` | Streamable HTTP 端口(兼容 `PORT`) | `8000` | -| `STREAMABLE_HTTP_PATH` | Streamable HTTP 路径 | `/mcp` | -| `KNOWLEDGE_BASE_TIMEOUT` | 上游请求超时(秒) | `30` | +### MCP 协议与部署安全 +服务器使用 MCP Python SDK 2.x,支持 `2026-07-28` 协议修订版,包括无状态 +`server/discover` 协商。SDK 同时兼容基于旧版握手的客户端。由于 HTTP+SSE +已被 `2026-07-28` 规范弃用,本服务器不再提供该传输方式。 + +Streamable HTTP 不会自动把已配置的 Viking API Key 或火山引擎 AK/SK +转换成 MCP 调用方鉴权。远程部署时必须使用认证网关或兼容 MCP 的 OAuth, +并且不要向调用方暴露服务凭证。 ## 可用工具 -Knowledge Base MCP Server 提供以下功能 +- [`add_doc`](https://www.volcengine.com/docs/84313/1254624):通过 URL 添加文档 +- [`get_doc`](https://www.volcengine.com/docs/84313/1254615):获取文档信息和处理状态 +- [`list_docs`](https://docs.volcengine.com/docs/84313/2477871?lang=zh):使用游标分页获取文档列表 +- [`get_collection`](https://www.volcengine.com/docs/84313/1254602):获取知识库信息 +- [`list_collections`](https://www.volcengine.com/docs/84313/1254596):获取已配置 Project 下的知识库列表 +- [`search_knowledge`](https://www.volcengine.com/docs/84313/1350012):在知识库中检索知识 -- `add_doc`: [上传文档(目前仅支持url)](https://www.volcengine.com/docs/84313/1254624) -- `get_doc`: [获取指定文档的状态](https://www.volcengine.com/docs/84313/1254615) -- `get_collection`: [获取知识库的详细信息](https://www.volcengine.com/docs/84313/1254602) -- `list_colletions`: [获取指定账户和Project下的知识库列表](https://www.volcengine.com/docs/84313/1254596) -- `search_knowledge`: [在指定知识库中进行搜索](https://www.volcengine.com/docs/84313/1350012) +### `add_doc` -#### add_doc +通过 URL 向知识库添加支持的文档。 ```python add_doc( - collection_name="collection_name", + collection_name="product_docs", add_type="url", - doc_id="mcp_server_auto_gen_doc_id_xxxxxxx", - doc_name="doc_xxxx", + doc_id="product_guide_2026", + doc_name="Product Guide", doc_type="pdf", - url="http://xxxxx.pdf" + url="https://example.com/product-guide.pdf", ) ``` -Parameters: -- `collection_name` (必须): 要上传文档的知识库名称 -- `add_type` (必须): 上传文档的添加类型, mcp server目前仅支持url -- `doc_id` (必须): url上传方式需要指定doc_id -- `doc_name` (必须): url 上传方式需要指定doc_name. -- `doc_type` (必须): 要添加的文档的类型。对于结构化文档,支持 xlsx、csv 和 jsonl;对于非结构化文档,我们支持 txt、doc、docx、pdf、markdown、faq.xlsx 和 pptx -- `url` (必须): 待添加文档的 URL +参数: + +- `collection_name`(必填):目标知识库名称。 +- `add_type`(必填):目前仅支持 `"url"`。 +- `doc_id`(必填):仅包含英文字母、数字和下划线的唯一 ID,必须以英文字母 + 开头,长度为 1–128 个字符。 +- `doc_name`(必填):文档名称,长度为 1–256 个字符。 +- `doc_type`(必填):可选值为 `xlsx`、`csv`、`jsonl`、`txt`、`doc`、 + `docx`、`pdf`、`markdown`、`faq.xlsx` 或 `pptx`。 +- `url`(必填):可访问的文档 URL。 + +返回示例: + +```json +{ + "collection_name": "product_docs", + "doc_id": "product_guide_2026" +} +``` + +### `get_doc` -#### get_doc +获取文档元数据和处理状态。 ```python get_doc( - collection_name="collection_name", - doc_id="mcp_server_auto_gen_doc_id_xxxxxxx", + collection_name="product_docs", + doc_id="product_guide_2026", ) ``` -Parameters: -- `collection_name` (必须): 要获取信息的文档所属的知识库 -- `doc_id` (必须): 要获取信息的文档ID +参数: + +- `collection_name`(必填):文档所属的知识库。 +- `doc_id`(必填):文档 ID。 + +返回示例: + +```json +{ + "collection_name": "product_docs", + "doc_id": "product_guide_2026", + "doc_name": "Product Guide", + "doc_type": "pdf", + "url": "https://example.com/product-guide.pdf", + "add_type": "url", + "create_time": 1788220800, + "update_time": 1788220860, + "point_num": 53, + "status": { + "process_status": 0, + "failed_code": null + } +} +``` + +`process_status` 状态值:`0` 表示处理完成,`1` 表示处理失败,`2` 或 `3` +表示排队中,`5` 表示删除中,`6` 表示处理中。Viking 未返回的字段为 `null`; +上游返回的其他文档字段也会保留。 + +### `list_docs` -#### get_collection +使用游标分页获取知识库中的文档列表。 ```python -get_collection( - collection_name="collection_name", +list_docs( + collection_name="product_docs", + limit=2, + next_token=None, ) ``` -Parameters: -- `collection_name` (必须): 要获取信息的知识库名称 +参数: +- `collection_name`(必填):要获取文档列表的知识库。 +- `limit`(可选):单次返回的文档数量,范围为 1–100,默认值为 `100`。 +- `next_token`(可选):上一次调用返回的不透明游标。首次请求不传;返回值中的 + 游标为空表示文档已全部返回。 -#### list_collections +返回示例: + +```json +{ + "collection_name": "product_docs", + "total_num": 3, + "count": 2, + "doc_list": [ + { + "collection_name": "product_docs", + "doc_id": "product_guide_2026", + "doc_name": "Product Guide", + "doc_type": "pdf", + "url": "https://example.com/product-guide.pdf", + "add_type": "url", + "create_time": 1788220800, + "update_time": 1788220860, + "point_num": 53, + "status": { + "process_status": 0 + }, + "brief_summary": "An introduction to the product.", + "total_tokens": 345 + } + ], + "has_more": true, + "next_token": "opaque-cursor-for-next-page" +} +``` + +Viking 未提供 `total_num` 时,该字段为 `null`。文档条目会保留摘要、token 数 +等上游扩展字段。 + +### `get_collection` + +获取知识库信息和构建状态。 + +```python +get_collection(collection_name="product_docs") +``` + +参数: + +- `collection_name`(必填):知识库名称。 + +返回示例: + +```json +{ + "collection_name": "product_docs", + "description": "Product manuals and release notes", + "status": 1 +} +``` + +知识库状态值:`-1` 表示待构建,`0` 表示构建中,`1` 表示构建完成,`2` +表示构建失败,`3` 表示变更中。 + +### `list_collections` + +获取全局配置 Project 下的所有知识库。 ```python list_collections() ``` -#### search_knowledge +此工具没有参数。 + +返回示例: + +```json +{ + "collection_list": [ + { + "collection_name": "product_docs", + "description": "Product manuals and release notes" + }, + { + "collection_name": "support_faq", + "description": "Frequently asked support questions" + } + ] +} +``` + +### `search_knowledge` + +在知识库中检索相关切片。可选的文档过滤条件可以包含或排除匹配的文档字段值。 ```python search_knowledge( - query="How to reset my password?", + query="How do I reset my password?", + collection_name="support_faq", limit=3, - collection_name="collection_name" + doc_filter={ + "op": "must", + "field": "doc_id", + "conds": ["account_guide"], + }, ) ``` -Parameters: -- `query` (必须): 搜索查询字符串 -- `limit` (可选): 返回的最大结果数,范围 1–100(默认值:3) -- `collection_name` (必须): 要搜索的知识库名称 +参数: + +- `query`(必填):检索问题。 +- `collection_name`(必填):要检索的知识库。 +- `limit`(可选):返回的最大切片数量,范围为 1–100,默认值为 `3`。 +- `doc_filter`(可选):包含以下字段的对象: + - `op`:`"must"` 表示包含匹配结果,`"must_not"` 表示排除匹配结果。 + - `field`:要过滤的文档字段,例如 `"doc_id"`。 + - `conds`:用于匹配的非空值列表。 -每条结果包含分块的 `id`、`content`,以及来源文档的 `doc_id` 和 -`doc_name`;Viking 未提供文档元数据时,这两个字段为 `null`。非空的 +返回示例: + +```json +{ + "result_list": [ + { + "id": "chunk_001", + "content": "Open Account Settings and select Reset Password.", + "doc_id": "account_guide", + "doc_name": "Account Guide" + } + ] +} +``` + +Viking 未提供文档元数据时,`doc_id` 和 `doc_name` 为 `null`。非空的 `doc_id` 可以直接传给 `get_doc`。 +## MCP 客户端配置 + +下面是使用 `uvx` 和 Viking API Key 的 stdio 配置示例: -### uvx 启动 ```json { "mcpServers": { "knowledgebase": { "command": "uvx", - "args": [ - "--from", - "mcp-server-knowledgebase>=0.2.0", - "mcp-server-knowledgebase" + "args": [ + "--from", + "mcp-server-knowledgebase>=0.2.0", + "mcp-server-knowledgebase" ], "env": { "VIKING_API_KEY": "your-viking-api-key", "KNOWLEDGE_BASE_PROJECT": "your-project-name", - "KNOWLEDGE_BASE_REGION": "your-region" + "KNOWLEDGE_BASE_REGION": "cn-north-1" } } } } ``` -也可以额外或改为同时配置 `VOLCENGINE_ACCESS_KEY` 和 -`VOLCENGINE_SECRET_KEY`。三个变量均配置时,优先使用 `VIKING_API_KEY`。 +也可以改为同时配置 `VOLCENGINE_ACCESS_KEY` 和 +`VOLCENGINE_SECRET_KEY`。三种凭证均存在时,优先使用 `VIKING_API_KEY`。 + +## 故障排查 + +1. 鉴权错误 + - 检查 API Key 或 AK/SK 凭证。 + - 使用 AK/SK 时,必须同时配置两个值。 + - 检查凭证是否有权访问已配置的 Project 和知识库。 +2. 连接超时 + - 检查到火山引擎 API 的网络连接。 + - 必要时调整 `KNOWLEDGE_BASE_TIMEOUT`。 +3. 返回结果为空 + - 检查 Project 和知识库名称。 + - 检索时尝试扩大问题范围或移除文档过滤条件。 + - 获取文档列表时,只能继续使用上一次调用原样返回的 `next_token`。 + +## 许可证 -## 证书 -volcengine/mcp-server is licensed under the [MIT License](https://github.com/volcengine/mcp-server/blob/main/LICENSE). +本项目使用 [MIT License](https://github.com/volcengine/mcp-server/blob/main/LICENSE)。 diff --git a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py index b184567b..f7fca3ab 100644 --- a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py +++ b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py @@ -40,6 +40,15 @@ class DocumentInfo(BaseModel): status: Optional[DocumentStatus] = None +class ListDocumentsResult(BaseModel): + collection_name: str + total_num: Optional[int] = None + count: int + doc_list: list[DocumentInfo] + has_more: bool + next_token: Optional[str] = None + + class CollectionInfoResult(BaseModel): collection_name: str description: str diff --git a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py index ba58f8dc..878be672 100644 --- a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py +++ b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py @@ -19,6 +19,7 @@ DocFilter, DocumentInfo, ListCollectionsResult, + ListDocumentsResult, SearchChunk, SearchKnowledgeResult, ) @@ -37,6 +38,7 @@ get_collections_path = "/api/knowledge/collection/info" doc_add_path = "/api/knowledge/doc/add" doc_info_path = "/api/knowledge/doc/info" +list_docs_path = "/api/knowledge/doc/v2/list" # Create MCP server mcp = MCPServer( @@ -223,6 +225,54 @@ async def get_doc( raise ToolError(str(e)) from e +@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) +async def list_docs( + collection_name: Annotated[str, Field(min_length=1)], + limit: Annotated[int, Field(ge=1, le=100)] = 100, + next_token: Optional[str] = None, +) -> ListDocumentsResult: + """List documents in a knowledge base collection using cursor pagination. + + Args: + collection_name: The knowledge base collection to list documents from. + limit: The maximum number of documents to return, from 1 to 100. + next_token: The opaque cursor returned by the previous request. Omit it + to retrieve the first page. + + Returns: + The documents in the current page and the cursor for the next page. + An empty next_token indicates that all documents have been returned. + """ + try: + if not collection_name: + raise ValueError("Collection name cannot be empty.") + + request_params = { + "collection_name": collection_name, + "project": config.project, + "limit": limit, + } + if next_token is not None: + request_params["next_token"] = next_token + + result = await _request_knowledgebase(list_docs_path, request_params) + if result["code"] != 0: + logger.error(f"Error in list_docs: {result['message']}") + raise ToolError(result["message"]) + + list_data = result.get("data") + if not list_data: + raise ValueError(f"Collection {collection_name} has no document list data.") + + return ListDocumentsResult.model_validate(list_data) + + except ToolError: + raise + except Exception as e: + logger.error(f"Error in list_docs: {str(e)}") + raise ToolError(str(e)) from e + + @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) async def get_collection( collection_name: str, From 313103d00c0cd61749218d38ae61cb8e86cbb9f1 Mon Sep 17 00:00:00 2001 From: Hao-Yu-la Date: Tue, 1 Sep 2026 17:54:19 +0800 Subject: [PATCH 2/4] fix(knowledgebase): accept numeric failed codes --- .../src/mcp_server_knowledgebase/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py index f7fca3ab..0a291162 100644 --- a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py +++ b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/models.py @@ -1,4 +1,4 @@ -from typing import Any, Literal, Optional +from typing import Any, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field @@ -20,7 +20,7 @@ class DocumentStatus(BaseModel): model_config = ConfigDict(extra="allow") process_status: Optional[int] = None - failed_code: Optional[str] = None + failed_code: Optional[Union[int, str]] = None class DocumentInfo(BaseModel): From 0b8b02576cc1ce0b8c153196ec88351cba815237 Mon Sep 17 00:00:00 2001 From: Hao-Yu-la Date: Thu, 3 Sep 2026 12:01:37 +0800 Subject: [PATCH 3/4] fix(knowledgebase): centralize API error handling --- server/mcp_server_knowledgebase/README.md | 2 +- server/mcp_server_knowledgebase/README_zh.md | 2 +- .../mcp_server_knowledgebase/pyproject.toml | 2 +- .../src/mcp_server_knowledgebase/server.py | 278 +++++++----------- .../tests/test_server.py | 98 ++++++ server/mcp_server_knowledgebase/uv.lock | 2 +- 6 files changed, 205 insertions(+), 179 deletions(-) create mode 100644 server/mcp_server_knowledgebase/tests/test_server.py diff --git a/server/mcp_server_knowledgebase/README.md b/server/mcp_server_knowledgebase/README.md index 14512c91..f011786b 100644 --- a/server/mcp_server_knowledgebase/README.md +++ b/server/mcp_server_knowledgebase/README.md @@ -365,7 +365,7 @@ Example stdio configuration using `uvx` and a Viking API key: "command": "uvx", "args": [ "--from", - "mcp-server-knowledgebase>=0.2.0", + "mcp-server-knowledgebase>=0.2.1", "mcp-server-knowledgebase" ], "env": { diff --git a/server/mcp_server_knowledgebase/README_zh.md b/server/mcp_server_knowledgebase/README_zh.md index f2b4884d..9cdf5e70 100644 --- a/server/mcp_server_knowledgebase/README_zh.md +++ b/server/mcp_server_knowledgebase/README_zh.md @@ -357,7 +357,7 @@ Viking 未提供文档元数据时,`doc_id` 和 `doc_name` 为 `null`。非空 "command": "uvx", "args": [ "--from", - "mcp-server-knowledgebase>=0.2.0", + "mcp-server-knowledgebase>=0.2.1", "mcp-server-knowledgebase" ], "env": { diff --git a/server/mcp_server_knowledgebase/pyproject.toml b/server/mcp_server_knowledgebase/pyproject.toml index 1f2b159c..6eb5309d 100644 --- a/server/mcp_server_knowledgebase/pyproject.toml +++ b/server/mcp_server_knowledgebase/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-knowledgebase" -version = "0.2.0" +version = "0.2.1" description = "MCP server for Viking Knowledge Base Service" readme = "README.md" requires-python = ">=3.10" diff --git a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py index 878be672..43820f08 100644 --- a/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py +++ b/server/mcp_server_knowledgebase/src/mcp_server_knowledgebase/server.py @@ -43,7 +43,7 @@ # Create MCP server mcp = MCPServer( "Knowledgebase MCP Server", - version="0.2.0", + version="0.2.1", cache_hints={"tools/list": CacheHint(ttl_ms=300_000, scope="public")}, ) @@ -85,6 +85,26 @@ async def _request_knowledgebase(path: str, data: Dict[str, Any]) -> Dict[str, A return await response.json() +async def _call_kb(path: str, params: Dict[str, Any], tool_name: str) -> Any: + """Call a Knowledge Base API and return its non-empty response data.""" + try: + result = await _request_knowledgebase(path, params) + if result["code"] != 0: + raise ToolError(result["message"]) + + data = result.get("data") + if not data: + raise ValueError(f"{tool_name} returned no data") + + return data + except ToolError as e: + logger.error(f"Error in {tool_name}: {str(e)}") + raise + except Exception as e: + logger.error(f"Error in {tool_name}: {str(e)}") + raise ToolError(str(e)) from e + + @mcp.tool( annotations=ToolAnnotations( readOnlyHint=False, @@ -134,36 +154,18 @@ async def add_doc( doc_id: the doc_id of document user added to collection. """ - try: - if not collection_name: - raise ValueError("Collection name cannot be empty.") - - request_params = { - "collection_name": collection_name, - "project": config.project, - "add_type": add_type, - "doc_id": doc_id, - "doc_name": doc_name, - "doc_type": doc_type, - "url": url, - } - - result = await _request_knowledgebase(doc_add_path, request_params) - if result['code'] != 0: - logger.error(f"Error in add_doc: {result['message']}") - raise ToolError(result['message']) - - doc_add_data = result['data'] - if not doc_add_data: - raise ValueError(f"doc {doc_id} has no data.") - - return AddDocumentResult(collection_name=collection_name, doc_id=doc_id) + request_params = { + "collection_name": collection_name, + "project": config.project, + "add_type": add_type, + "doc_id": doc_id, + "doc_name": doc_name, + "doc_type": doc_type, + "url": url, + } - except ToolError: - raise - except Exception as e: - logger.error(f"Error in add_doc: {str(e)}") - raise ToolError(str(e)) from e + await _call_kb(doc_add_path, request_params, "add_doc") + return AddDocumentResult(collection_name=collection_name, doc_id=doc_id) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) @@ -197,32 +199,14 @@ async def get_doc( - failed_code: the status message of the document. """ - try: - if not collection_name: - raise ValueError("Collection name cannot be empty.") - - request_params = { - "collection_name": collection_name, - "project": config.project, - "doc_id": doc_id, - } - - result = await _request_knowledgebase(doc_info_path, request_params) - if result['code'] != 0: - logger.error(f"Error in get_doc: {result['message']}") - raise ToolError(result['message']) - - doc_info_data = result['data'] - if not doc_info_data: - raise ValueError(f"doc {doc_id} not found.") - - return DocumentInfo.model_validate(doc_info_data) + request_params = { + "collection_name": collection_name, + "project": config.project, + "doc_id": doc_id, + } - except ToolError: - raise - except Exception as e: - logger.error(f"Error in get_doc: {str(e)}") - raise ToolError(str(e)) from e + doc_info_data = await _call_kb(doc_info_path, request_params, "get_doc") + return DocumentInfo.model_validate(doc_info_data) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) @@ -243,34 +227,16 @@ async def list_docs( The documents in the current page and the cursor for the next page. An empty next_token indicates that all documents have been returned. """ - try: - if not collection_name: - raise ValueError("Collection name cannot be empty.") - - request_params = { - "collection_name": collection_name, - "project": config.project, - "limit": limit, - } - if next_token is not None: - request_params["next_token"] = next_token - - result = await _request_knowledgebase(list_docs_path, request_params) - if result["code"] != 0: - logger.error(f"Error in list_docs: {result['message']}") - raise ToolError(result["message"]) - - list_data = result.get("data") - if not list_data: - raise ValueError(f"Collection {collection_name} has no document list data.") - - return ListDocumentsResult.model_validate(list_data) + request_params = { + "collection_name": collection_name, + "project": config.project, + "limit": limit, + } + if next_token is not None: + request_params["next_token"] = next_token - except ToolError: - raise - except Exception as e: - logger.error(f"Error in list_docs: {str(e)}") - raise ToolError(str(e)) from e + list_data = await _call_kb(list_docs_path, request_params, "list_docs") + return ListDocumentsResult.model_validate(list_data) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) @@ -296,35 +262,19 @@ async def get_collection( """ - try: - if not collection_name: - raise ValueError("Collection name cannot be empty.") - - request_params = { - "name": collection_name, - "project": config.project, - } - - result = await _request_knowledgebase(get_collections_path, request_params) - if result['code'] != 0: - logger.error(f"Error in search_knowledge: {result['message']}") - raise ToolError(result['message']) - - collection_info = result['data'] - if not collection_info: - raise ValueError(f"Collection {collection_name} not found.") - - return CollectionInfoResult( - collection_name=collection_info["collection_name"], - description=collection_info["description"], - status=collection_info["pipeline_list"][0]["index_list"][0]["status"], - ) + request_params = { + "name": collection_name, + "project": config.project, + } - except ToolError: - raise - except Exception as e: - logger.error(f"Error in get_collection: {str(e)}") - raise ToolError(str(e)) from e + collection_info = await _call_kb( + get_collections_path, request_params, "get_collection" + ) + return CollectionInfoResult( + collection_name=collection_info["collection_name"], + description=collection_info["description"], + status=collection_info["pipeline_list"][0]["index_list"][0]["status"], + ) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) @@ -340,35 +290,25 @@ async def list_collections() -> ListCollectionsResult: """ - try: - request_params = { - "project": config.project, - } - - result = await _request_knowledgebase(list_collections_path, request_params) - if result['code'] != 0: - logger.error(f"Error in list_collections: {result['message']}") - raise ToolError(result['message']) - - collections = result['data']['collection_list'] - - collection_list = [] + request_params = { + "project": config.project, + } - for collection in collections: - collection_list.append( - CollectionSummary( - collection_name=collection["collection_name"], - description=collection["description"], - ) + list_data = await _call_kb( + list_collections_path, request_params, "list_collections" + ) + collections = list_data["collection_list"] + + collection_list = [] + for collection in collections: + collection_list.append( + CollectionSummary( + collection_name=collection["collection_name"], + description=collection["description"], ) + ) - return ListCollectionsResult(collection_list=collection_list) - - except ToolError: - raise - except Exception as e: - logger.error(f"Error in list_collections: {str(e)}") - raise ToolError(str(e)) from e + return ListCollectionsResult(collection_list=collection_list) @mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True)) @@ -402,49 +342,37 @@ async def search_knowledge( doc_name: the name of the document containing the chunk, when available. """ - try: - if not collection_name: - raise ValueError("Collection name cannot be empty.") - - request_params = { - "query": query, - "limit": limit, - "name": collection_name, - "project": config.project, + request_params = { + "query": query, + "limit": limit, + "name": collection_name, + "project": config.project, + } + + if doc_filter: + request_params['query_param'] = { + "doc_filter": doc_filter.model_dump(mode="json"), } - if doc_filter: - request_params['query_param'] = { - "doc_filter": doc_filter.model_dump(mode="json"), - } - - result = await _request_knowledgebase(search_knowledge_path, request_params) - if result['code'] != 0: - logger.error(f"Error in search_knowledge: {result['message']}") - raise ToolError(result['message']) - - chunks = result['data'].get('result_list', []) - - search_result = [] - - for chunk in chunks: - raw_doc_info = chunk.get("doc_info") - doc_info = raw_doc_info if isinstance(raw_doc_info, dict) else {} - search_result.append( - SearchChunk( - id=chunk["id"], - content=chunk["content"], - doc_id=doc_info.get("doc_id"), - doc_name=doc_info.get("doc_name"), - ) + search_data = await _call_kb( + search_knowledge_path, request_params, "search_knowledge" + ) + chunks = search_data.get('result_list', []) + + search_result = [] + for chunk in chunks: + raw_doc_info = chunk.get("doc_info") + doc_info = raw_doc_info if isinstance(raw_doc_info, dict) else {} + search_result.append( + SearchChunk( + id=chunk["id"], + content=chunk["content"], + doc_id=doc_info.get("doc_id"), + doc_name=doc_info.get("doc_name"), ) + ) - return SearchKnowledgeResult(result_list=search_result) - except ToolError: - raise - except Exception as e: - logger.error(f"Error in search_knowledge: {str(e)}") - raise ToolError(str(e)) from e + return SearchKnowledgeResult(result_list=search_result) def main(): diff --git a/server/mcp_server_knowledgebase/tests/test_server.py b/server/mcp_server_knowledgebase/tests/test_server.py new file mode 100644 index 00000000..3ebed425 --- /dev/null +++ b/server/mcp_server_knowledgebase/tests/test_server.py @@ -0,0 +1,98 @@ +import asyncio +import logging +import os + +import pytest +from mcp.server.mcpserver.exceptions import ToolError + +os.environ.setdefault("VIKING_API_KEY", "test-api-key") + +from mcp_server_knowledgebase import server + + +def test_call_kb_returns_response_data(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: + assert path == "/test/path" + assert params == {"project": "test-project"} + return {"code": 0, "message": "success", "data": {"value": 1}} + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + result = asyncio.run( + server._call_kb("/test/path", {"project": "test-project"}, "test_tool") + ) + + assert result == {"value": 1} + + +@pytest.mark.parametrize( + ("response", "expected_message"), + [ + ({"code": 1001, "message": "permission denied", "data": None}, "permission denied"), + ( + {"code": 0, "message": "success", "data": None}, + "get_collection returned no data", + ), + ], +) +def test_call_kb_reports_failures_with_the_calling_tool_name( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + response: dict[str, object], + expected_message: str, +) -> None: + async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: + return response + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with caplog.at_level(logging.ERROR, logger=server.__name__): + with pytest.raises(ToolError, match=expected_message): + asyncio.run(server._call_kb("/test/path", {}, "get_collection")) + + assert "Error in get_collection:" in caplog.text + + +def test_call_kb_wraps_request_exceptions(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: + raise TimeoutError("upstream timed out") + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with pytest.raises(ToolError, match="upstream timed out"): + asyncio.run(server._call_kb("/test/path", {}, "list_docs")) + + +def test_call_kb_preserves_tool_errors_from_the_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: + raise ToolError("request rejected") + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with pytest.raises(ToolError, match="request rejected"): + asyncio.run(server._call_kb("/test/path", {}, "get_doc")) + + +def test_get_collection_identifies_itself_to_the_shared_call_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_call( + path: str, params: dict[str, object], tool_name: str + ) -> dict[str, object]: + assert path == server.get_collections_path + assert params == {"name": "product-docs", "project": server.config.project} + assert tool_name == "get_collection" + return { + "collection_name": "product-docs", + "description": "Product documentation", + "pipeline_list": [{"index_list": [{"status": 1}]}], + } + + monkeypatch.setattr(server, "_call_kb", fake_call) + + result = asyncio.run(server.get_collection("product-docs")) + + assert result.collection_name == "product-docs" + assert result.status == 1 diff --git a/server/mcp_server_knowledgebase/uv.lock b/server/mcp_server_knowledgebase/uv.lock index de998ccd..34fb06b6 100644 --- a/server/mcp_server_knowledgebase/uv.lock +++ b/server/mcp_server_knowledgebase/uv.lock @@ -756,7 +756,7 @@ cli = [ [[package]] name = "mcp-server-knowledgebase" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 5d2a1e4c97a794cf054356fa19e8d86dcf079b39 Mon Sep 17 00:00:00 2001 From: Hao-Yu-la Date: Thu, 3 Sep 2026 16:11:17 +0800 Subject: [PATCH 4/4] test(knowledgebase): cover document tool behavior --- .../tests/test_server.py | 98 ------- .../tests/test_tools.py | 254 ++++++++++++++++++ 2 files changed, 254 insertions(+), 98 deletions(-) delete mode 100644 server/mcp_server_knowledgebase/tests/test_server.py create mode 100644 server/mcp_server_knowledgebase/tests/test_tools.py diff --git a/server/mcp_server_knowledgebase/tests/test_server.py b/server/mcp_server_knowledgebase/tests/test_server.py deleted file mode 100644 index 3ebed425..00000000 --- a/server/mcp_server_knowledgebase/tests/test_server.py +++ /dev/null @@ -1,98 +0,0 @@ -import asyncio -import logging -import os - -import pytest -from mcp.server.mcpserver.exceptions import ToolError - -os.environ.setdefault("VIKING_API_KEY", "test-api-key") - -from mcp_server_knowledgebase import server - - -def test_call_kb_returns_response_data(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: - assert path == "/test/path" - assert params == {"project": "test-project"} - return {"code": 0, "message": "success", "data": {"value": 1}} - - monkeypatch.setattr(server, "_request_knowledgebase", fake_request) - - result = asyncio.run( - server._call_kb("/test/path", {"project": "test-project"}, "test_tool") - ) - - assert result == {"value": 1} - - -@pytest.mark.parametrize( - ("response", "expected_message"), - [ - ({"code": 1001, "message": "permission denied", "data": None}, "permission denied"), - ( - {"code": 0, "message": "success", "data": None}, - "get_collection returned no data", - ), - ], -) -def test_call_kb_reports_failures_with_the_calling_tool_name( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, - response: dict[str, object], - expected_message: str, -) -> None: - async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: - return response - - monkeypatch.setattr(server, "_request_knowledgebase", fake_request) - - with caplog.at_level(logging.ERROR, logger=server.__name__): - with pytest.raises(ToolError, match=expected_message): - asyncio.run(server._call_kb("/test/path", {}, "get_collection")) - - assert "Error in get_collection:" in caplog.text - - -def test_call_kb_wraps_request_exceptions(monkeypatch: pytest.MonkeyPatch) -> None: - async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: - raise TimeoutError("upstream timed out") - - monkeypatch.setattr(server, "_request_knowledgebase", fake_request) - - with pytest.raises(ToolError, match="upstream timed out"): - asyncio.run(server._call_kb("/test/path", {}, "list_docs")) - - -def test_call_kb_preserves_tool_errors_from_the_request( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_request(path: str, params: dict[str, object]) -> dict[str, object]: - raise ToolError("request rejected") - - monkeypatch.setattr(server, "_request_knowledgebase", fake_request) - - with pytest.raises(ToolError, match="request rejected"): - asyncio.run(server._call_kb("/test/path", {}, "get_doc")) - - -def test_get_collection_identifies_itself_to_the_shared_call_helper( - monkeypatch: pytest.MonkeyPatch, -) -> None: - async def fake_call( - path: str, params: dict[str, object], tool_name: str - ) -> dict[str, object]: - assert path == server.get_collections_path - assert params == {"name": "product-docs", "project": server.config.project} - assert tool_name == "get_collection" - return { - "collection_name": "product-docs", - "description": "Product documentation", - "pipeline_list": [{"index_list": [{"status": 1}]}], - } - - monkeypatch.setattr(server, "_call_kb", fake_call) - - result = asyncio.run(server.get_collection("product-docs")) - - assert result.collection_name == "product-docs" - assert result.status == 1 diff --git a/server/mcp_server_knowledgebase/tests/test_tools.py b/server/mcp_server_knowledgebase/tests/test_tools.py new file mode 100644 index 00000000..26026e3b --- /dev/null +++ b/server/mcp_server_knowledgebase/tests/test_tools.py @@ -0,0 +1,254 @@ +import asyncio +import logging +import os +from typing import Any + +import pytest +from mcp.server.mcpserver.exceptions import ToolError + +os.environ.setdefault("VIKING_API_KEY", "test-api-key") +os.environ["KNOWLEDGE_BASE_PROJECT"] = "default" + +from mcp_server_knowledgebase import server + + +def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + result = asyncio.run(server.mcp.call_tool(name, arguments)) + assert result.is_error is False + assert result.structured_content is not None + return result.structured_content + + +def empty_list_response(**overrides: Any) -> dict[str, Any]: + data = { + "collection_name": "product-docs", + "total_num": 0, + "count": 0, + "doc_list": [], + "has_more": False, + "next_token": None, + } + data.update(overrides) + return {"code": 0, "message": "success", "data": data} + + +def test_list_docs_first_page_omits_next_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[tuple[str, dict[str, Any]]] = [] + + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + requests.append((path, params)) + return empty_list_response() + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + result = call_tool("list_docs", {"collection_name": "product-docs"}) + + assert requests == [ + ( + "/api/knowledge/doc/v2/list", + { + "collection_name": "product-docs", + "project": "default", + "limit": 100, + }, + ) + ] + assert result["count"] == 0 + + +def test_list_docs_next_page_sends_and_returns_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[tuple[str, dict[str, Any]]] = [] + + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + requests.append((path, params)) + return empty_list_response(has_more=True, next_token="page-3") + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + result = call_tool( + "list_docs", + {"collection_name": "product-docs", "limit": 25, "next_token": "page-2"}, + ) + + assert requests[0][1]["next_token"] == "page-2" + assert requests[0][1]["limit"] == 25 + assert result["has_more"] is True + assert result["next_token"] == "page-3" + + +@pytest.mark.parametrize("limit", [1, 100]) +def test_list_docs_accepts_limit_boundaries( + monkeypatch: pytest.MonkeyPatch, limit: int +) -> None: + received_limit: int | None = None + + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + nonlocal received_limit + received_limit = params["limit"] + return empty_list_response() + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + call_tool("list_docs", {"collection_name": "product-docs", "limit": limit}) + + assert received_limit == limit + + +@pytest.mark.parametrize("limit", [0, 101]) +def test_list_docs_rejects_out_of_range_limits_before_request( + monkeypatch: pytest.MonkeyPatch, limit: int +) -> None: + request_was_sent = False + + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + nonlocal request_was_sent + request_was_sent = True + return empty_list_response() + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with pytest.raises(ToolError, match="limit"): + asyncio.run( + server.mcp.call_tool( + "list_docs", {"collection_name": "product-docs", "limit": limit} + ) + ) + + assert request_was_sent is False + + +@pytest.mark.parametrize( + ("response", "expected_message"), + [ + ( + {"code": 1001, "message": "permission denied", "data": None}, + "permission denied", + ), + ({"code": 0, "message": "success", "data": None}, "returned no data"), + ], +) +def test_list_docs_reports_invalid_upstream_responses( + monkeypatch: pytest.MonkeyPatch, + response: dict[str, Any], + expected_message: str, +) -> None: + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + return response + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with pytest.raises(ToolError, match=expected_message): + asyncio.run( + server.mcp.call_tool("list_docs", {"collection_name": "product-docs"}) + ) + + +def test_list_docs_wraps_transport_errors(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + raise TimeoutError("upstream timed out") + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with pytest.raises(ToolError, match="upstream timed out"): + asyncio.run( + server.mcp.call_tool("list_docs", {"collection_name": "product-docs"}) + ) + + +def test_list_docs_preserves_optional_and_extension_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + return empty_list_response( + total_num=None, + count=1, + doc_list=[ + { + "collection_name": "product-docs", + "doc_id": "guide-1", + "doc_name": "Product Guide", + "doc_type": "pdf", + "url": "https://example.com/guide.pdf", + "add_type": "url", + "create_time": 1788220800, + "update_time": 1788220860, + "point_num": 53, + "status": {"process_status": 1, "failed_code": 7001}, + "brief_summary": "A short guide.", + "total_tokens": 345, + } + ], + ) + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + result = call_tool("list_docs", {"collection_name": "product-docs"}) + document = result["doc_list"][0] + + assert result["total_num"] is None + assert document["status"]["failed_code"] == 7001 + assert document["brief_summary"] == "A short guide." + assert document["total_tokens"] == 345 + + +def test_get_doc_accepts_numeric_failed_code_and_preserves_extensions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + requests: list[tuple[str, dict[str, Any]]] = [] + + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + requests.append((path, params)) + return { + "code": 0, + "message": "success", + "data": { + "collection_name": "product-docs", + "doc_id": "guide-1", + "status": {"process_status": 1, "failed_code": 7001}, + "brief_summary": "A short guide.", + }, + } + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + result = call_tool( + "get_doc", {"collection_name": "product-docs", "doc_id": "guide-1"} + ) + + assert requests == [ + ( + "/api/knowledge/doc/info", + { + "collection_name": "product-docs", + "project": "default", + "doc_id": "guide-1", + }, + ) + ] + assert result["status"]["failed_code"] == 7001 + assert result["brief_summary"] == "A short guide." + + +def test_get_collection_logs_errors_under_its_own_name( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + async def fake_request(path: str, params: dict[str, Any]) -> dict[str, Any]: + return {"code": 1001, "message": "permission denied", "data": None} + + monkeypatch.setattr(server, "_request_knowledgebase", fake_request) + + with caplog.at_level(logging.ERROR, logger=server.__name__): + with pytest.raises(ToolError, match="permission denied"): + asyncio.run( + server.mcp.call_tool( + "get_collection", {"collection_name": "product-docs"} + ) + ) + + assert "Error in get_collection: permission denied" in caplog.text + assert "Error in search_knowledge" not in caplog.text