first commit
This commit is contained in:
commit
2ffae91aa7
10 files changed
+891
No files matched your search
@@ -0,0 +1,9 @@
|
||||
QDRANT_URL=localhost
|
||||
QDRANT_PORT=6334
|
||||
# QDRANT_API_KEY=
|
||||
# QDRANT_TLS=false
|
||||
OPENAI_API_KEY=sk-...
|
||||
# EMBEDDING_BASE_URL=https://api.openai.com/v1
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
MCP_TOKEN=change-me
|
||||
PORT=8080
|
||||
@@ -0,0 +1,9 @@
|
||||
# Binary
|
||||
st-pierre
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,113 @@
|
||||
# St-Pierre - Knowledge MCP Server
|
||||
|
||||
Remote MCP server that stores and retrieves knowledge using Qdrant vector
|
||||
database and OpenAI embeddings.
|
||||
|
||||
## Tools
|
||||
|
||||
### `store_knowledge`
|
||||
Store text with optional metadata. Automatically generates embeddings via
|
||||
OpenAI and persists them in Qdrant.
|
||||
|
||||
Parameters:
|
||||
- `content` (string, required): Text content to store
|
||||
- `metadata` (object, optional): Arbitrary key-value pairs
|
||||
|
||||
### `search_knowledge`
|
||||
Semantic search across stored knowledge.
|
||||
|
||||
Parameters:
|
||||
- `query` (string, required): Search query
|
||||
- `limit` (number, optional, default 5): Max results
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Qdrant instance
|
||||
- OpenAI API key
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
QDRANT_URL=localhost # Required: Qdrant hostname
|
||||
QDRANT_PORT=6334 # Optional: Qdrant gRPC port (default: 6334)
|
||||
QDRANT_API_KEY= # Optional: Qdrant API key
|
||||
QDRANT_TLS=false # Optional: Enable TLS
|
||||
OPENAI_API_KEY=sk-... # Required: API key (OpenAI or DeepSeek)
|
||||
EMBEDDING_BASE_URL=https://api.openai.com/v1 # Optional: base URL (OpenAI by default)
|
||||
EMBEDDING_MODEL=text-embedding-3-small # Optional: embedding model
|
||||
MCP_TOKEN=my-secret-token # Required: Bearer token for auth
|
||||
PORT=8080 # Optional: HTTP port (default: 8080)
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Using OpenAI embeddings
|
||||
export QDRANT_URL=localhost
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export MCP_TOKEN=my-secret-token
|
||||
./st-pierre
|
||||
|
||||
# Using DeepSeek embeddings
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export EMBEDDING_BASE_URL=https://api.deepseek.com/v1
|
||||
export EMBEDDING_MODEL=deepseek-chat
|
||||
export MCP_TOKEN=my-secret-token
|
||||
./st-pierre
|
||||
```
|
||||
|
||||
### Nix
|
||||
|
||||
```bash
|
||||
# Build and run directly
|
||||
nix run github:didier/st-pierre -- --help
|
||||
|
||||
# Development shell
|
||||
nix develop
|
||||
```
|
||||
|
||||
### NixOS Module
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
{
|
||||
inputs.st-pierre.url = "github:didier/st-pierre";
|
||||
...
|
||||
}
|
||||
|
||||
# configuration.nix
|
||||
{ inputs, ... }: {
|
||||
imports = [ inputs.st-pierre.nixosModules.st-pierre ];
|
||||
|
||||
services.st-pierre = {
|
||||
enable = true;
|
||||
port = 8080;
|
||||
qdrantUrl = "localhost";
|
||||
openaiApiKeyFile = /run/secrets/openai-api-key;
|
||||
tokenFile = /run/secrets/mcp-token;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The NixOS module creates a dedicated `st-pierre` system user and a systemd
|
||||
service. Secrets (API keys, tokens) should be provided as files rather than
|
||||
inline strings.
|
||||
|
||||
## MCP Client Configuration
|
||||
|
||||
Add to your MCP client config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"knowledge": {
|
||||
"url": "http://your-server:8080/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer my-secret-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultModel = "text-embedding-3-small"
|
||||
const defaultBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
type embedder struct {
|
||||
apiKey string
|
||||
model string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newEmbedder(apiKey, model, baseURL string) *embedder {
|
||||
if model == "" {
|
||||
model = defaultModel
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
return &embedder{
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type embeddingRequest struct {
|
||||
Input string `json:"input"`
|
||||
Model string `json:"model"`
|
||||
EncodingFormat string `json:"encoding_format,omitempty"`
|
||||
}
|
||||
|
||||
type embeddingResponse struct {
|
||||
Data []struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (e *embedder) embedText(ctx context.Context, text string) ([]float32, error) {
|
||||
reqBody := embeddingRequest{
|
||||
Input: text,
|
||||
Model: e.model,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal embedding request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.baseURL+"/embeddings", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create embedding request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+e.apiKey)
|
||||
|
||||
resp, err := e.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embedding request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedding response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("embedding API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var embResp embeddingResponse
|
||||
if err := json.Unmarshal(respBody, &embResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal embedding response: %w", err)
|
||||
}
|
||||
|
||||
if len(embResp.Data) == 0 {
|
||||
return nil, fmt.Errorf("no embedding returned")
|
||||
}
|
||||
|
||||
return embResp.Data[0].Embedding, nil
|
||||
}
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1779560665,
|
||||
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
{
|
||||
description = "St-Pierre - MCP Knowledge Server";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
(flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
st-pierre = pkgs.buildGoModule {
|
||||
pname = "st-pierre";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
vendorHash = "sha256-pSBt5y3Mu8kYX9Vaq3jNU+k8748DLo/RGHV5PqiYelM=";
|
||||
};
|
||||
in
|
||||
{
|
||||
packages.default = st-pierre;
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [ go gopls gotools ];
|
||||
};
|
||||
}
|
||||
)) // {
|
||||
nixosModules.st-pierre = { config, pkgs, lib, ... }:
|
||||
let
|
||||
cfg = config.services.st-pierre;
|
||||
st-pierre-pkg = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
in
|
||||
{
|
||||
options.services.st-pierre = {
|
||||
enable = lib.mkEnableOption "St-Pierre knowledge MCP server";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8080;
|
||||
description = "HTTP port to listen on.";
|
||||
};
|
||||
|
||||
qdrantUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "localhost";
|
||||
description = "Qdrant server hostname.";
|
||||
};
|
||||
|
||||
qdrantPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 6334;
|
||||
description = "Qdrant gRPC port.";
|
||||
};
|
||||
|
||||
qdrantApiKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.path;
|
||||
default = null;
|
||||
description = "Path to a file containing the Qdrant API key.";
|
||||
};
|
||||
|
||||
qdrantTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable TLS for Qdrant connection.";
|
||||
};
|
||||
|
||||
openaiApiKeyFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Path to a file containing the API key (OpenAI or DeepSeek).";
|
||||
};
|
||||
|
||||
embeddingBaseUrl = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "https://api.openai.com/v1";
|
||||
description = "Base URL for the embeddings API.";
|
||||
};
|
||||
|
||||
embeddingModel = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "text-embedding-3-small";
|
||||
description = "Embedding model name.";
|
||||
};
|
||||
|
||||
tokenFile = lib.mkOption {
|
||||
type = lib.types.path;
|
||||
description = "Path to a file containing the MCP Bearer token.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.groups.st-pierre = { };
|
||||
|
||||
users.users.st-pierre = {
|
||||
createHome = true;
|
||||
isSystemUser = true;
|
||||
home = "/var/lib/st-pierre";
|
||||
group = "st-pierre";
|
||||
};
|
||||
|
||||
systemd.services.st-pierre = {
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
|
||||
serviceConfig = {
|
||||
User = "st-pierre";
|
||||
Group = "st-pierre";
|
||||
Restart = "always";
|
||||
WorkingDirectory = "/var/lib/st-pierre";
|
||||
ExecStart = "${st-pierre-pkg}/bin/st-pierre";
|
||||
EnvironmentFile = [ cfg.openaiApiKeyFile cfg.tokenFile ]
|
||||
++ lib.optional (cfg.qdrantApiKeyFile != null) cfg.qdrantApiKeyFile;
|
||||
};
|
||||
|
||||
environment = {
|
||||
PORT = builtins.toString cfg.port;
|
||||
QDRANT_URL = cfg.qdrantUrl;
|
||||
QDRANT_PORT = builtins.toString cfg.qdrantPort;
|
||||
QDRANT_TLS = if cfg.qdrantTls then "true" else "false";
|
||||
EMBEDDING_BASE_URL = cfg.embeddingBaseUrl;
|
||||
EMBEDDING_MODEL = cfg.embeddingModel;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module github.com/didier/st-pierre
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/mark3labs/mcp-go v0.54.1
|
||||
github.com/qdrant/go-client v1.18.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
|
||||
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qdrant/go-client v1.18.2 h1:7ViiXB/fB4vfzdUtEYZ7g2vSG+yMU6EMu8CaNWF1g1c=
|
||||
github.com/qdrant/go-client v1.18.2/go.mod h1:Xkfp+r89uNOgSbvilVAhCZ3wKI4G+hB/r9Zr2m4zifI=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/qdrant/go-client/qdrant"
|
||||
)
|
||||
|
||||
var version = "0.1.0"
|
||||
|
||||
func main() {
|
||||
qdrantURL := requireEnv("QDRANT_URL")
|
||||
openAIKey := requireEnv("OPENAI_API_KEY")
|
||||
token := requireEnv("MCP_TOKEN")
|
||||
embeddingModel := os.Getenv("EMBEDDING_MODEL")
|
||||
embeddingBaseURL := os.Getenv("EMBEDDING_BASE_URL")
|
||||
port := envOrDefault("PORT", "8080")
|
||||
|
||||
qdrantPort := 6334
|
||||
if p, err := strconv.Atoi(envOrDefault("QDRANT_PORT", "6334")); err == nil {
|
||||
qdrantPort = p
|
||||
}
|
||||
qdrantAPIKey := os.Getenv("QDRANT_API_KEY")
|
||||
qdrantTLS := os.Getenv("QDRANT_TLS") == "true"
|
||||
|
||||
qdrantClient, err := qdrant.NewClient(&qdrant.Config{
|
||||
Host: qdrantURL,
|
||||
Port: qdrantPort,
|
||||
APIKey: qdrantAPIKey,
|
||||
UseTLS: qdrantTLS,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create qdrant client: %v", err)
|
||||
}
|
||||
defer qdrantClient.Close()
|
||||
|
||||
emb := newEmbedder(openAIKey, embeddingModel, embeddingBaseURL)
|
||||
store := newQdrantStore(qdrantClient, emb)
|
||||
|
||||
if err := store.ensureCollection(context.Background()); err != nil {
|
||||
log.Fatalf("failed to ensure collection: %v", err)
|
||||
}
|
||||
|
||||
mcpServer := server.NewMCPServer(
|
||||
"knowledge-mcp",
|
||||
version,
|
||||
server.WithToolCapabilities(true),
|
||||
server.WithLogging(),
|
||||
)
|
||||
|
||||
registerTools(mcpServer, store)
|
||||
|
||||
streamableServer := server.NewStreamableHTTPServer(mcpServer)
|
||||
|
||||
authHandler := authMiddleware(token, streamableServer)
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: corsMiddleware(authHandler),
|
||||
}
|
||||
|
||||
log.Printf("MCP Knowledge Server starting on http://0.0.0.0:%s", port)
|
||||
|
||||
go func() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-sigChan
|
||||
log.Printf("received signal %v, shutting down...", sig)
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := streamableServer.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("shutdown error: %v", err)
|
||||
}
|
||||
qdrantClient.Close()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server stopped: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTools(mcpServer *server.MCPServer, store *qdrantStore) {
|
||||
mcpServer.AddTool(
|
||||
mcp.NewTool("store_knowledge",
|
||||
mcp.WithDescription("Store a piece of knowledge for later retrieval. Use this to persist useful information discovered during a session."),
|
||||
mcp.WithString("content",
|
||||
mcp.Required(),
|
||||
mcp.Description("The text content to store"),
|
||||
),
|
||||
mcp.WithObject("metadata",
|
||||
mcp.Description("Optional metadata as key-value pairs (e.g. {\"source\": \"docs\", \"category\": \"golang\"})"),
|
||||
),
|
||||
),
|
||||
handleStoreKnowledge(store),
|
||||
)
|
||||
|
||||
mcpServer.AddTool(
|
||||
mcp.NewTool("search_knowledge",
|
||||
mcp.WithDescription("Search previously stored knowledge using semantic search. Returns the most relevant knowledge entries for the given query."),
|
||||
mcp.WithString("query",
|
||||
mcp.Required(),
|
||||
mcp.Description("The search query"),
|
||||
),
|
||||
mcp.WithNumber("limit",
|
||||
mcp.Description("Maximum number of results to return (default: 5)"),
|
||||
),
|
||||
),
|
||||
handleSearchKnowledge(store),
|
||||
)
|
||||
}
|
||||
|
||||
func handleStoreKnowledge(store *qdrantStore) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, ok := request.Params.Arguments.(map[string]any)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("invalid arguments"), nil
|
||||
}
|
||||
|
||||
content, ok := args["content"].(string)
|
||||
if !ok || content == "" {
|
||||
return mcp.NewToolResultError("content is required and must be a non-empty string"), nil
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
if rawMeta, ok := args["metadata"]; ok && rawMeta != nil {
|
||||
metaMap, ok := rawMeta.(map[string]any)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("metadata must be a key-value object"), nil
|
||||
}
|
||||
metadata = metaMap
|
||||
}
|
||||
|
||||
result, err := store.store(ctx, content, metadata)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to store knowledge: %v", err)), nil
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(result, "", " ")
|
||||
return mcp.NewToolResultText(string(output)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleSearchKnowledge(store *qdrantStore) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
args, ok := request.Params.Arguments.(map[string]any)
|
||||
if !ok {
|
||||
return mcp.NewToolResultError("invalid arguments"), nil
|
||||
}
|
||||
|
||||
query, ok := args["query"].(string)
|
||||
if !ok || query == "" {
|
||||
return mcp.NewToolResultError("query is required and must be a non-empty string"), nil
|
||||
}
|
||||
|
||||
limit := uint64(5)
|
||||
if rawLimit := args["limit"]; rawLimit != nil {
|
||||
switch v := rawLimit.(type) {
|
||||
case float64:
|
||||
limit = uint64(v)
|
||||
case int:
|
||||
limit = uint64(v)
|
||||
case int64:
|
||||
limit = uint64(v)
|
||||
}
|
||||
}
|
||||
|
||||
results, err := store.search(ctx, query, limit)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("search failed: %v", err)), nil
|
||||
}
|
||||
|
||||
output, _ := json.MarshalIndent(results, "", " ")
|
||||
return mcp.NewToolResultText(string(output)), nil
|
||||
}
|
||||
}
|
||||
|
||||
func authMiddleware(token string, next http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/health" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") || strings.TrimPrefix(authHeader, "Bearer ") != token {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func corsMiddleware(next http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func requireEnv(key string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
log.Fatalf("environment variable %s is required", key)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func envOrDefault(key, defaultVal string) string {
|
||||
val := os.Getenv(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/qdrant/go-client/qdrant"
|
||||
)
|
||||
|
||||
const collectionName = "knowledge"
|
||||
|
||||
type qdrantStore struct {
|
||||
client *qdrant.Client
|
||||
embedder *embedder
|
||||
}
|
||||
|
||||
func newQdrantStore(client *qdrant.Client, embedder *embedder) *qdrantStore {
|
||||
return &qdrantStore{client: client, embedder: embedder}
|
||||
}
|
||||
|
||||
func (s *qdrantStore) ensureCollection(ctx context.Context) error {
|
||||
exists, err := s.client.CollectionExists(ctx, collectionName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check collection exists: %w", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = s.client.CreateCollection(ctx, &qdrant.CreateCollection{
|
||||
CollectionName: collectionName,
|
||||
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
|
||||
Size: 1536, // text-embedding-3-small dimension
|
||||
Distance: qdrant.Distance_Cosine,
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type storedKnowledge struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Score float32 `json:"score"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
func (s *qdrantStore) store(ctx context.Context, content string, metadata map[string]any) (*storedKnowledge, error) {
|
||||
vec, err := s.embedder.embedText(ctx, content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embed text: %w", err)
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
payload := map[string]*qdrant.Value{
|
||||
"content": qdrant.NewValueString(content),
|
||||
}
|
||||
|
||||
if metadata != nil {
|
||||
metaBytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
payload["_metadata"] = qdrant.NewValueString(string(metaBytes))
|
||||
}
|
||||
|
||||
idHash := sha256.Sum256([]byte(id))
|
||||
idNum := uint64(0)
|
||||
for i := 0; i < 8; i++ {
|
||||
idNum = (idNum << 8) | uint64(idHash[i])
|
||||
}
|
||||
|
||||
_, err = s.client.Upsert(ctx, &qdrant.UpsertPoints{
|
||||
CollectionName: collectionName,
|
||||
Points: []*qdrant.PointStruct{
|
||||
{
|
||||
Id: qdrant.NewIDNum(idNum),
|
||||
Vectors: qdrant.NewVectors(vec...),
|
||||
Payload: payload,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert point: %w", err)
|
||||
}
|
||||
|
||||
return &storedKnowledge{
|
||||
ID: id,
|
||||
Content: content,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *qdrantStore) search(ctx context.Context, query string, limit uint64) ([]searchResult, error) {
|
||||
vec, err := s.embedder.embedText(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("embed query: %w", err)
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
results, err := s.client.Query(ctx, &qdrant.QueryPoints{
|
||||
CollectionName: collectionName,
|
||||
Query: qdrant.NewQueryDense(vec),
|
||||
Limit: &limit,
|
||||
WithPayload: qdrant.NewWithPayload(true),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query points: %w", err)
|
||||
}
|
||||
|
||||
var searchResults []searchResult
|
||||
for _, pt := range results {
|
||||
r := searchResult{
|
||||
Score: pt.Score,
|
||||
}
|
||||
|
||||
if payload := pt.GetPayload(); payload != nil {
|
||||
if v, ok := payload["content"]; ok {
|
||||
r.ID = fmt.Sprintf("%d", pt.GetId().GetNum())
|
||||
r.Content = v.GetStringValue()
|
||||
}
|
||||
if v, ok := payload["_metadata"]; ok {
|
||||
var meta map[string]any
|
||||
if err := json.Unmarshal([]byte(v.GetStringValue()), &meta); err == nil {
|
||||
r.Metadata = meta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
searchResults = append(searchResults, r)
|
||||
}
|
||||
|
||||
return searchResults, nil
|
||||
}
|
||||
Reference in new issue
Block a user