commit 2ffae91aa7324e2941199fc6bb9c6fafe0cd8bc4 Author: didier amyot Date: Sun May 31 12:35:40 2026 -0400 first commit 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 +}