├── .gitignore ├── .gitmodules ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── rustfmt.toml └── src ├── config.rs ├── lib.rs ├── main.rs ├── pinecone.rs └── router.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # RustRover 17 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 18 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 19 | # and can be added to the global gitignore or merged into this file. For a more nuclear 20 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 21 | .idea/ 22 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinecone-io/assistant-mcp/7e32e35c20fe3f79d2504d4371f28e1b1eb34009/.gitmodules -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "assistant-mcp" 3 | version = "0.1.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | mcp-spec = "0.1" 8 | mcp-server = "0.1" 9 | serde_json = "1.0.139" 10 | serde = { version = "1.0.197", features = ["derive"] } 11 | tokio = { version = "1.43.0", features = ["full"] } 12 | tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 13 | tracing = "0.1.41" 14 | thiserror = "1.0.58" 15 | reqwest = { version = "0.11.26", features = ["json"] } 16 | is-terminal = "0.4.12" 17 | 18 | [dev-dependencies] 19 | tokio-test = "0.4.4" 20 | mockito = "1.4.0" 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.85-slim AS builder 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y pkg-config libssl-dev && \ 7 | rm -rf /var/lib/apt/lists/* 8 | 9 | COPY Cargo.toml ./ 10 | 11 | # Create a dummy project for dependency caching https://stackoverflow.com/a/58474618/2318775 12 | RUN mkdir -p src && \ 13 | echo "fn main() {println!(\"Dummy build\");}" > src/main.rs && \ 14 | cargo build --release --lib || true && \ 15 | rm -rf src 16 | 17 | # Now copy the actual source code and build the application 18 | COPY src ./src/ 19 | RUN cargo build --release 20 | 21 | FROM debian:bookworm-slim AS release 22 | 23 | WORKDIR /app 24 | 25 | RUN apt-get update && \ 26 | apt-get install -y --no-install-recommends libssl3 ca-certificates && \ 27 | rm -rf /var/lib/apt/lists/* 28 | 29 | COPY --from=builder /app/target/release/assistant-mcp /app/assistant-mcp 30 | 31 | ENV RUST_LOG=info 32 | 33 | ENTRYPOINT ["/app/assistant-mcp"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Pinecone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pinecone Assistant MCP Server 2 | 3 | An MCP server implementation for retrieving information from Pinecone Assistant. 4 | 5 | ## Features 6 | 7 | - Retrieves information from Pinecone Assistant 8 | - Supports multiple results retrieval with a configurable number of results 9 | 10 | ## Prerequisites 11 | 12 | - Docker installed on your system 13 | - Pinecone API key - obtain from the [Pinecone Console](https://app.pinecone.io) 14 | - Pinecone Assistant API host - after creating an Assistant (e.g. in Pinecone Console), you can find the host in the Assistant details page 15 | 16 | ## Building with Docker 17 | 18 | To build the Docker image: 19 | 20 | ```sh 21 | docker build -t pinecone/assistant-mcp . 22 | ``` 23 | 24 | ## Running with Docker 25 | 26 | Run the server with your Pinecone API key: 27 | 28 | ```sh 29 | docker run -i --rm \ 30 | -e PINECONE_API_KEY= \ 31 | -e PINECONE_ASSISTANT_HOST= \ 32 | pinecone/assistant-mcp 33 | ``` 34 | 35 | ### Environment Variables 36 | 37 | - `PINECONE_API_KEY` (required): Your Pinecone API key 38 | - `PINECONE_ASSISTANT_HOST` (optional): Pinecone Assistant API host (default: https://prod-1-data.ke.pinecone.io) 39 | - `LOG_LEVEL` (optional): Logging level (default: info) 40 | 41 | ## Usage with Claude Desktop 42 | 43 | Add this to your `claude_desktop_config.json`: 44 | 45 | ```json 46 | { 47 | "mcpServers": { 48 | "pinecone-assistant": { 49 | "command": "docker", 50 | "args": [ 51 | "run", 52 | "-i", 53 | "--rm", 54 | "-e", 55 | "PINECONE_API_KEY", 56 | "-e", 57 | "PINECONE_ASSISTANT_HOST", 58 | "pinecone/assistant-mcp" 59 | ], 60 | "env": { 61 | "PINECONE_API_KEY": "", 62 | "PINECONE_ASSISTANT_HOST": "" 63 | } 64 | } 65 | } 66 | } 67 | ``` 68 | 69 | ## Building from Source 70 | 71 | If you prefer to build from source without Docker: 72 | 73 | 1. Make sure you have Rust installed (https://rustup.rs/) 74 | 2. Clone this repository 75 | 3. Run `cargo build --release` 76 | 4. The binary will be available at `target/release/assistant-mcp` 77 | 78 | ### Testing with the inspector 79 | ```sh 80 | export PINECONE_API_KEY= 81 | export PINECONE_ASSISTANT_HOST= 82 | # Run the inspector alone 83 | npx @modelcontextprotocol/inspector cargo run 84 | # Or run with Docker directly through the inspector 85 | npx @modelcontextprotocol/inspector -- docker run -i --rm -e PINECONE_API_KEY -e PINECONE_ASSISTANT_HOST pinecone/assistant-mcp 86 | ``` 87 | 88 | ## License 89 | 90 | This project is licensed under the terms specified in the LICENSE file. 91 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | tab_spaces = 4 3 | imports_granularity = "Crate" 4 | group_imports = "StdExternalCrate" 5 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Config { 5 | pub pinecone_api_key: String, 6 | pub pinecone_assistant_host: String, 7 | pub log_level: String, 8 | } 9 | 10 | impl Config { 11 | pub fn from_env() -> Self { 12 | const PINECONE_API_KEY: &str = "PINECONE_API_KEY"; 13 | const PINECONE_ASSISTANT_HOST: &str = "PINECONE_ASSISTANT_HOST"; 14 | const LOG_LEVEL: &str = "LOG_LEVEL"; 15 | 16 | let pinecone_api_key = env::var(PINECONE_API_KEY).expect(&format!( 17 | "Missing environment variable: {}", 18 | PINECONE_API_KEY 19 | )); 20 | 21 | let pinecone_assistant_host = env::var(PINECONE_ASSISTANT_HOST) 22 | .unwrap_or_else(|_| "https://prod-1-data.ke.pinecone.io".to_string()); 23 | 24 | let log_level = env::var(LOG_LEVEL).unwrap_or_else(|_| "info".to_string()); 25 | 26 | Self { 27 | pinecone_api_key, 28 | pinecone_assistant_host, 29 | log_level, 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod pinecone; 3 | pub mod router; 4 | 5 | pub use pinecone::PineconeClient; 6 | pub use router::PineconeAssistantRouter; 7 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use assistant_mcp::config::Config; 2 | use assistant_mcp::router::PineconeAssistantRouter; 3 | use is_terminal::IsTerminal; 4 | use mcp_server::router::RouterService; 5 | use mcp_server::{ByteTransport, Server, ServerError}; 6 | use thiserror::Error; 7 | use tokio::io::{stdin, stdout}; 8 | use tracing_subscriber::EnvFilter; 9 | 10 | #[derive(Error, Debug)] 11 | pub enum AppError { 12 | #[error("IO error: {0}")] 13 | Io(#[from] std::io::Error), 14 | 15 | #[error("MCP server error: {0}")] 16 | Server(#[from] ServerError), 17 | } 18 | 19 | #[tokio::main] 20 | async fn main() -> Result<(), AppError> { 21 | tracing_subscriber::fmt() 22 | .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| { 23 | "info,assistant_mcp=debug" 24 | .parse() 25 | .expect("Invalid default filter") 26 | })) 27 | .with_target(true) 28 | .with_thread_ids(true) 29 | .with_file(true) 30 | .with_line_number(true) 31 | .with_ansi(std::io::stderr().is_terminal()) 32 | .with_writer(std::io::stderr) 33 | .init(); 34 | 35 | tracing::info!("Starting Pinecone MCP server"); 36 | 37 | let config = Config::from_env(); 38 | tracing::info!("Configuration loaded successfully"); 39 | 40 | let router = RouterService(PineconeAssistantRouter::new(config)); 41 | let server = Server::new(router); 42 | let transport = ByteTransport::new(stdin(), stdout()); 43 | 44 | tracing::info!("Server initialized and ready to handle requests"); 45 | server.run(transport).await.map_err(AppError::from) 46 | } 47 | -------------------------------------------------------------------------------- /src/pinecone.rs: -------------------------------------------------------------------------------- 1 | use reqwest::{Client, Error as ReqwestError}; 2 | use serde::{Deserialize, Serialize}; 3 | use thiserror::Error; 4 | 5 | #[derive(Error, Debug)] 6 | pub enum PineconeError { 7 | #[error("HTTP request error: {0}")] 8 | Request(#[from] ReqwestError), 9 | 10 | #[error("API error: {status} - {message}")] 11 | Api { status: u16, message: String }, 12 | 13 | #[error("API error: {resource} not found")] 14 | NotFound { resource: String }, 15 | 16 | #[error("JSON deserialization error: {0}")] 17 | Json(#[from] serde_json::Error), 18 | } 19 | 20 | #[derive(Clone)] 21 | pub struct PineconeClient { 22 | client: Client, 23 | api_key: String, 24 | base_url: String, 25 | } 26 | 27 | #[derive(Debug, Serialize)] 28 | pub struct AssistantContext { 29 | pub query: String, 30 | 31 | #[serde(skip_serializing_if = "Option::is_none")] 32 | pub top_k: Option, 33 | } 34 | 35 | #[derive(Debug, Deserialize)] 36 | pub struct AssistantContextResponse { 37 | pub snippets: Vec, 38 | pub usage: serde_json::Value, 39 | } 40 | 41 | impl PineconeClient { 42 | pub fn new(api_key: String, base_url: String) -> Self { 43 | Self { 44 | client: Client::new(), 45 | api_key, 46 | base_url, 47 | } 48 | } 49 | 50 | pub async fn assistant_context( 51 | &self, 52 | assistant_name: &str, 53 | query: &str, 54 | top_k: Option, 55 | ) -> Result { 56 | let url = format!( 57 | "{}/assistant/chat/{}/context", 58 | self.base_url, assistant_name 59 | ); 60 | 61 | let request_body = AssistantContext { 62 | query: query.to_string(), 63 | top_k, 64 | }; 65 | 66 | let response = self 67 | .client 68 | .post(&url) 69 | .header("Api-Key", &self.api_key) 70 | .header("accept", "application/json") 71 | .header("Content-Type", "application/json") 72 | .header("X-Pinecone-API-Version", "2025-04") 73 | .json(&request_body) 74 | .send() 75 | .await?; 76 | 77 | let status = response.status(); 78 | if !status.is_success() { 79 | let error_text = response.text().await?; 80 | match status.as_u16() { 81 | 404 => { 82 | return Err(PineconeError::NotFound { 83 | resource: format!("assistant \"{assistant_name}\""), 84 | }); 85 | } 86 | s => { 87 | return Err(PineconeError::Api { 88 | status: s, 89 | message: error_text, 90 | }); 91 | } 92 | } 93 | } 94 | 95 | Ok(response.json::().await?) 96 | } 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::*; 102 | use mockito::Server; 103 | 104 | #[tokio::test] 105 | async fn test_somke() { 106 | let mut server = Server::new_async().await; 107 | let mock = server 108 | .mock("POST", "/assistant/chat/test-assistant/context") 109 | .with_status(200) 110 | .with_header("content-type", "application/json") 111 | .with_body(r#"{"snippets": [{"text": "snippet 1"}, {"text": "snippet 2"}], "usage": {"total_tokens": 100}}"#) 112 | .create(); 113 | 114 | let client = PineconeClient::new("test-api-key".to_string(), server.url()); 115 | 116 | let result = client 117 | .assistant_context("test-assistant", "test query", None) 118 | .await; 119 | 120 | mock.assert(); 121 | let response = result.unwrap(); 122 | assert_eq!(response.snippets[0]["text"], "snippet 1"); 123 | assert_eq!(response.snippets[1]["text"], "snippet 2"); 124 | } 125 | 126 | #[tokio::test] 127 | async fn test_query_assistant_error() { 128 | let mut server = Server::new_async().await; 129 | let mock = server 130 | .mock("POST", "/assistant/chat/test-assistant/context") 131 | .with_status(401) 132 | .with_header("content-type", "application/json") 133 | .with_body(r#"{"error": "Unauthorized"}"#) 134 | .create(); 135 | 136 | let client = PineconeClient::new("invalid-api-key".to_string(), server.url()); 137 | 138 | let result = client 139 | .assistant_context("test-assistant", "test query", None) 140 | .await; 141 | 142 | mock.assert(); 143 | assert!(result.is_err()); 144 | match result { 145 | Err(PineconeError::Api { status, .. }) => assert_eq!(status, 401), 146 | _ => panic!("Expected API error"), 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/router.rs: -------------------------------------------------------------------------------- 1 | use crate::config::Config; 2 | use crate::pinecone::{PineconeClient, PineconeError}; 3 | use mcp_server::router::CapabilitiesBuilder; 4 | use mcp_spec::content::Content; 5 | use mcp_spec::handler::{PromptError, ResourceError, ToolError}; 6 | use mcp_spec::prompt::Prompt; 7 | use mcp_spec::{protocol::ServerCapabilities, resource::Resource, tool::Tool}; 8 | use serde_json::Value; 9 | use std::future::Future; 10 | use std::pin::Pin; 11 | use thiserror::Error; 12 | 13 | const TOOL_ASSISTANT_CONTEXT: &'static str = "assistant_context"; 14 | 15 | const PARAM_ASSISTANT_NAME: &'static str = "assistant_name"; 16 | const PARAM_QUERY: &'static str = "query"; 17 | const PARAM_TOP_K: &'static str = "top_k"; 18 | 19 | #[derive(Error, Debug)] 20 | pub enum RouterError { 21 | #[error("Pinecone error: {0}")] 22 | Pinecone(#[from] PineconeError), 23 | 24 | #[error("Invalid parameters: {0}")] 25 | InvalidParameters(String), 26 | } 27 | 28 | impl From for ToolError { 29 | fn from(err: RouterError) -> Self { 30 | match err { 31 | RouterError::Pinecone(e) => ToolError::ExecutionError(e.to_string()), 32 | RouterError::InvalidParameters(msg) => ToolError::InvalidParameters(msg), 33 | } 34 | } 35 | } 36 | 37 | #[derive(Clone)] 38 | pub struct PineconeAssistantRouter { 39 | client: PineconeClient, 40 | tools: Vec, 41 | } 42 | 43 | impl PineconeAssistantRouter { 44 | pub fn new(config: Config) -> Self { 45 | tracing::info!( 46 | "Creating new PineconeAssistantRouter [Host: {}]", 47 | config.pinecone_assistant_host 48 | ); 49 | let client = PineconeClient::new(config.pinecone_api_key, config.pinecone_assistant_host); 50 | tracing::info!("Successfully initialized Pinecone client"); 51 | Self { 52 | client, 53 | tools: vec![Tool::new( 54 | TOOL_ASSISTANT_CONTEXT.to_string(), 55 | "Retrieves relevant document snippets from your Pinecone Assistant knowledge base. \ 56 | Returns an array of text snippets from the most relevant documents. \ 57 | You can use the 'top_k' parameter to control result count (default: 15). \ 58 | Recommended top_k: a few (5-8) for simple/narrow queries, 10-20 for complex/broad topics.".to_string(), 59 | serde_json::json!({ 60 | "type": "object", 61 | "properties": { 62 | PARAM_ASSISTANT_NAME: { 63 | "type": "string", 64 | "description": "Name of an existing Pinecone assistant" 65 | }, 66 | PARAM_QUERY: { 67 | "type": "string", 68 | "description": "The query to retrieve context for." 69 | }, 70 | PARAM_TOP_K: { 71 | "type": "integer", 72 | "description": "The number of context snippets to retrieve. Defaults to 15." 73 | } 74 | }, 75 | "required": [PARAM_ASSISTANT_NAME, PARAM_QUERY] 76 | }), 77 | )], 78 | } 79 | } 80 | 81 | async fn handle_assistant_context( 82 | &self, 83 | arguments: Value, 84 | ) -> Result, RouterError> { 85 | tracing::debug!("Processing {TOOL_ASSISTANT_CONTEXT} arguments"); 86 | let assistant_name = arguments[PARAM_ASSISTANT_NAME].as_str().ok_or_else(|| { 87 | RouterError::InvalidParameters(format!("{} must be a string", PARAM_ASSISTANT_NAME)) 88 | })?; 89 | let query = arguments[PARAM_QUERY].as_str().ok_or_else(|| { 90 | RouterError::InvalidParameters(format!("{} must be a string", PARAM_QUERY)) 91 | })?; 92 | let top_k = arguments[PARAM_TOP_K].as_u64().map(|v| v as u32); 93 | 94 | tracing::info!( 95 | "Making request to Pinecone API for assistant: {} with top_k: {:?}", 96 | assistant_name, 97 | top_k 98 | ); 99 | 100 | let response = self 101 | .client 102 | .assistant_context(assistant_name, query, top_k) 103 | .await?; 104 | 105 | tracing::info!("Successfully received response from Pinecone API"); 106 | Ok(response 107 | .snippets 108 | .iter() 109 | .map(|snippet| Content::text(snippet.to_string())) 110 | .collect()) 111 | } 112 | } 113 | 114 | impl mcp_server::Router for PineconeAssistantRouter { 115 | fn name(&self) -> String { 116 | "pinecone-assistant".to_string() 117 | } 118 | 119 | fn instructions(&self) -> String { 120 | format!( 121 | "This server connects to an existing Pinecone Assistant,\ 122 | a RAG system for retrieving relevant document snippets. \ 123 | Use the {TOOL_ASSISTANT_CONTEXT} tool to access contextual information from its knowledge base" 124 | ) 125 | } 126 | 127 | fn capabilities(&self) -> ServerCapabilities { 128 | tracing::debug!("Building server capabilities"); 129 | CapabilitiesBuilder::new().with_tools(true).build() 130 | } 131 | 132 | fn list_tools(&self) -> Vec { 133 | tracing::debug!("Listing available tools"); 134 | self.tools.clone() 135 | } 136 | 137 | fn call_tool( 138 | &self, 139 | tool_name: &str, 140 | arguments: Value, 141 | ) -> Pin, ToolError>> + Send + 'static>> { 142 | tracing::info!("Calling tool: {}", tool_name); 143 | let router = self.clone(); 144 | match tool_name { 145 | TOOL_ASSISTANT_CONTEXT => Box::pin(async move { 146 | router 147 | .handle_assistant_context(arguments) 148 | .await 149 | .map_err(Into::into) 150 | }), 151 | _ => { 152 | tracing::error!("Tool not found: {}", tool_name); 153 | let tool_name = tool_name.to_string(); 154 | Box::pin(async move { 155 | Err(ToolError::NotFound(format!("Tool {} not found", tool_name))) 156 | }) 157 | } 158 | } 159 | } 160 | 161 | fn list_resources(&self) -> Vec { 162 | vec![] 163 | } 164 | 165 | fn read_resource( 166 | &self, 167 | _uri: &str, 168 | ) -> Pin> + Send + 'static>> { 169 | Box::pin(async { 170 | Err(ResourceError::NotFound( 171 | "No resources available".to_string(), 172 | )) 173 | }) 174 | } 175 | 176 | fn list_prompts(&self) -> Vec { 177 | vec![] 178 | } 179 | 180 | fn get_prompt( 181 | &self, 182 | prompt_name: &str, 183 | ) -> Pin> + Send + 'static>> { 184 | let prompt_name = prompt_name.to_string(); 185 | Box::pin(async move { 186 | Err(PromptError::NotFound(format!( 187 | "Prompt {} not found", 188 | prompt_name 189 | ))) 190 | }) 191 | } 192 | } 193 | --------------------------------------------------------------------------------