first commit

This commit is contained in:
didier committed 2026-05-31 12:35:40 -04:00
commit 2ffae91aa7
10 files changed
+891

No files matched your search

+9
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
# Binary
st-pierre
# Environment
.env
# IDE
.idea/
.vscode/
+113
View File
@@ -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"
}
}
}
}
```
+96
View File
@@ -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
View File
@@ -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
}
+125
View File
@@ -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;
};
};
};
};
};
}
+22
View File
@@ -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
)
+68
View File
@@ -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=
+239
View File
@@ -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
}
+149
View File
@@ -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
}