From 2ffae91aa7324e2941199fc6bb9c6fafe0cd8bc4 Mon Sep 17 00:00:00 2001 From: didier amyot Date: Sun, 31 May 2026 12:35:40 -0400 Subject: [PATCH] first commit --- .env.example | 9 ++ .gitignore | 9 ++ README.md | 113 ++++++++++++++++++++++++ embed.go | 96 +++++++++++++++++++++ flake.lock | 61 +++++++++++++ flake.nix | 125 +++++++++++++++++++++++++++ go.mod | 22 +++++ go.sum | 68 +++++++++++++++ main.go | 239 +++++++++++++++++++++++++++++++++++++++++++++++++++ qdrant.go | 149 ++++++++++++++++++++++++++++++++ 10 files changed, 891 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 embed.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 qdrant.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d0ef16b --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0913e80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Binary +st-pierre + +# Environment +.env + +# IDE +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ad571e7 --- /dev/null +++ b/README.md @@ -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" + } + } + } +} +``` diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..abda7d6 --- /dev/null +++ b/embed.go @@ -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 +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..083c58f --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..140797d --- /dev/null +++ b/flake.nix @@ -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; + }; + }; + }; + }; + }; +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a62471c --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aea0325 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d520649 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/qdrant.go b/qdrant.go new file mode 100644 index 0000000..45ec1cd --- /dev/null +++ b/qdrant.go @@ -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 +}