diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f6db6516..92910a94 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -123,6 +123,20 @@ jobs: run: go generate -x working-directory: tools + # install.sh starts ollama service, stop it since we want to run with `bin/task ollama` with environment variables + - name: Setup Ollama + run: | + curl -fsSL https://ollama.com/install.sh | sudo -E sh + sudo systemctl stop ollama + + - name: Start Ollama in the background + run: | + bin/task ollama & + + - name: Fetch model for Ollama + run: | + bin/task ollama-model + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: diff --git a/.golangci.yml b/.golangci.yml index 299b5ea6..c91a63dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -53,6 +53,7 @@ linters-settings: - "!**/internal/documentdb/**.go" - "!**/internal/driver/*.go" - "!**/internal/handler/**.go" + - "!**/internal/mcp/*.go" - "!**/internal/mongoerrors/*.go" - "!**/internal/util/logging/*.go" - "!**/internal/util/scram/*.go" diff --git a/Taskfile.yml b/Taskfile.yml index 133e532f..56685317 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -19,6 +19,8 @@ vars: SERVICES: jaeger mongodb mongodb-secure postgres yugabytedb LLM: qwen3:0.6b LLM_CONTEXT: 40960 # https://ollama.com/library/qwen3:0.6b + MCPHOST_CONFIG: build/mcphost.json + MCPHOST_ARGS: "" tasks: # invoked when `task` is run without arguments @@ -322,6 +324,7 @@ tasks: --no-auth --listen-addr=:27017 --listen-data-api-addr=127.0.0.1:8899 + --listen-mcp-addr=127.0.0.1:8081 --proxy-addr=127.0.0.1:37017 --postgresql-url='postgres://username:password@127.0.0.1:5432/postgres' --otel-traces-url=http://127.0.0.1:4318/v1/traces @@ -336,6 +339,7 @@ tasks: --mode={{.MODE}} --listen-addr=:27017 --listen-data-api-addr=127.0.0.1:8899 + --listen-mcp-addr=127.0.0.1:8081 --proxy-addr=127.0.0.1:47017 --postgresql-url='postgres://username:password@127.0.0.1:5432/postgres' --otel-traces-url=http://127.0.0.1:4318/v1/traces @@ -352,6 +356,7 @@ tasks: --no-auth --listen-addr=:27017 --listen-data-api-addr=127.0.0.1:8899 + --listen-mcp-addr=127.0.0.1:8081 --proxy-addr=127.0.0.1:37017 --postgresql-url='postgres://username:password@127.0.0.1:5433/yugabyte' --otel-traces-url=http://127.0.0.1:4318/v1/traces @@ -366,6 +371,7 @@ tasks: --mode={{.MODE}} --listen-addr=:27017 --listen-data-api-addr=127.0.0.1:8899 + --listen-mcp-addr=127.0.0.1:8081 --proxy-addr=127.0.0.1:47017 --postgresql-url='postgres://username:password@127.0.0.1:5433/yugabyte' --otel-traces-url=http://127.0.0.1:4318/v1/traces @@ -409,18 +415,22 @@ tasks: - bin/pkgsite{{exeExt}} -http=127.0.0.1:6060 -open ollama: - desc: "Run Ollama server" + desc: "Run local Ollama server" cmds: - ollama serve env: OLLAMA_CONTEXT_LENGTH: "{{.LLM_CONTEXT}}" OLLAMA_DEBUG: 2 + ollama-model: + desc: "Pull Ollama model" + cmds: + - ollama pull {{.LLM}} + mcphost: desc: "Run MCPHost for local Ollama server" cmds: - # TODO https://github.com/FerretDB/FerretDB/issues/5277 - # - bin/mcphost --config build/mcphost.json --model ollama:{{.LLM}} + - bin/mcphost --config={{.MCPHOST_CONFIG}} --model=ollama:{{.LLM}} --temperature=0.0 {{.MCPHOST_ARGS}} psql: desc: "Run psql" diff --git a/cmd/ferretdb/main.go b/cmd/ferretdb/main.go index d573d55d..c77e75b3 100644 --- a/cmd/ferretdb/main.go +++ b/cmd/ferretdb/main.go @@ -74,6 +74,7 @@ var cli struct { TLSKeyFile string `default:"" help:"TLS key file path."` TLSCaFile string `default:"" help:"TLS CA file path."` DataAPIAddr string `default:"" help:"Listen TCP address for HTTP Data API."` + MCPAddr string `default:"" help:"Listen TCP address for HTTP MCP server."` } `embed:"" prefix:"listen-" group:"Interfaces"` Proxy struct { @@ -253,6 +254,7 @@ func checkFlags(logger *slog.Logger) { &cli.Listen.Unix, &cli.Listen.TLS, &cli.Listen.DataAPIAddr, + &cli.Listen.MCPAddr, &cli.DebugAddr, &cli.OTel.Traces.URL, } { @@ -274,6 +276,13 @@ func checkFlags(logger *slog.Logger) { if !cli.Auth { logger.WarnContext(ctx, "Authentication is disabled; the server will accept any connection") } + + if cli.Auth && cli.Listen.MCPAddr != "" { + logger.Log( + ctx, logging.LevelFatal, + "MCP server does not support authentication; please disable authentication or MCP server", + ) + } } // dumpMetrics dumps all Prometheus metrics to stderr. @@ -489,6 +498,8 @@ func run() { TestRecordsDir: cli.Dev.RecordsDir, DataAPIAddr: cli.Listen.DataAPIAddr, + + MCPAddr: cli.Listen.MCPAddr, }) if res == nil { os.Exit(1) diff --git a/ferretdb/ferretdb.go b/ferretdb/ferretdb.go index da60da85..d7bdd9a7 100644 --- a/ferretdb/ferretdb.go +++ b/ferretdb/ferretdb.go @@ -148,6 +148,8 @@ func New(config *Config) (*FerretDB, error) { TestRecordsDir: "", DataAPIAddr: "", + + MCPAddr: "", }) if res == nil { return nil, fmt.Errorf("failed to create FerretDB") diff --git a/go.mod b/go.mod index 919e1d14..2018dd2a 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 github.com/jackc/pgx/v5 v5.7.6 + github.com/modelcontextprotocol/go-sdk v0.2.0 github.com/oapi-codegen/runtime v1.1.2 github.com/pmezard/go-difflib v1.0.0 github.com/prometheus/client_golang v1.23.2 @@ -53,6 +54,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index 4525fd4c..41ed887c 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modelcontextprotocol/go-sdk v0.2.0 h1:PESNYOmyM1c369tRkzXLY5hHrazj8x9CY1Xu0fLCryM= +github.com/modelcontextprotocol/go-sdk v0.2.0/go.mod h1:0sL9zUKKs2FTTkeCCVnKqbLJTw5TScefPAzojjU459E= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= @@ -91,6 +93,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +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= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/integration/go.mod b/integration/go.mod index 40147870..5e0d0a6f 100644 --- a/integration/go.mod +++ b/integration/go.mod @@ -41,6 +41,7 @@ require ( github.com/jackc/pgx/v5 v5.7.6 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect + github.com/modelcontextprotocol/go-sdk v0.2.0 // indirect github.com/montanaflynn/stats v0.7.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect @@ -50,6 +51,7 @@ require ( github.com/prometheus/procfs v0.16.1 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver/v2 v2.3.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/integration/go.sum b/integration/go.sum index 82991603..52023827 100644 --- a/integration/go.sum +++ b/integration/go.sum @@ -57,6 +57,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modelcontextprotocol/go-sdk v0.2.0 h1:PESNYOmyM1c369tRkzXLY5hHrazj8x9CY1Xu0fLCryM= +github.com/modelcontextprotocol/go-sdk v0.2.0/go.mod h1:0sL9zUKKs2FTTkeCCVnKqbLJTw5TScefPAzojjU459E= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -87,6 +89,8 @@ github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +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= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= diff --git a/internal/dataapi/dataapi_test.go b/internal/dataapi/dataapi_test.go index 65575810..40ea03fa 100644 --- a/internal/dataapi/dataapi_test.go +++ b/internal/dataapi/dataapi_test.go @@ -416,6 +416,8 @@ func setupDataAPI(tb testing.TB, auth bool) (addr string, dbName string) { TestRecordsDir: "", DataAPIAddr: "127.0.0.1:0", + + MCPAddr: "", }) require.NotNil(tb, res) diff --git a/internal/handler/commands.go b/internal/handler/commands.go index 5c424742..35732d31 100644 --- a/internal/handler/commands.go +++ b/internal/handler/commands.go @@ -247,7 +247,7 @@ func (h *Handler) initCommands() { }, "listDatabases": { handler: h.msgListDatabases, - Help: "Returns a summary of all the databases.", + Help: "Returns a summary of all databases.", }, "listIndexes": { handler: h.msgListIndexes, diff --git a/internal/mcp/listdatabases.go b/internal/mcp/listdatabases.go new file mode 100644 index 00000000..3248eb5b --- /dev/null +++ b/internal/mcp/listdatabases.go @@ -0,0 +1,34 @@ +// Copyright 2021 FerretDB Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + + "github.com/FerretDB/wire/wirebson" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// listDatabases returns a list of databases. +func (s *server) listDatabases(ctx context.Context, _ *mcp.ServerSession, _ *mcp.CallToolParamsFor[any]) (*mcp.CallToolResult, error) { //nolint:lll // for readability + // log MCP tool request for debug level + // TODO https://github.com/FerretDB/FerretDB/issues/5277 + req := wirebson.MustDocument( + "listDatabases", int32(1), + "$db", "admin", + ) + + return s.handle(ctx, req) +} diff --git a/internal/mcp/listener.go b/internal/mcp/listener.go new file mode 100644 index 00000000..d2f609af --- /dev/null +++ b/internal/mcp/listener.go @@ -0,0 +1,136 @@ +// Copyright 2021 FerretDB Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + + "github.com/AlekSi/lazyerrors" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/prometheus/client_golang/prometheus" + + "github.com/FerretDB/FerretDB/v2/build/version" + "github.com/FerretDB/FerretDB/v2/internal/clientconn/conninfo" + "github.com/FerretDB/FerretDB/v2/internal/handler/middleware" + "github.com/FerretDB/FerretDB/v2/internal/util/ctxutil" + "github.com/FerretDB/FerretDB/v2/internal/util/logging" +) + +// Listener represents MCP listener. +type Listener struct { + opts *ListenOpts + lis net.Listener + srv *server +} + +// ListenOpts represents [Listen] options. +type ListenOpts struct { //nolint:vet // for readability + L *slog.Logger + M *middleware.Middleware + TCPAddr string + + // TODO https://github.com/FerretDB/FerretDB/issues/5309 + // Auth bool +} + +// Listen creates a new MCP handler and starts listener on the given TCP address. +// [Listener.Run] must be called on the returned value. +func Listen(opts *ListenOpts) (*Listener, error) { + lis, err := net.Listen("tcp", opts.TCPAddr) + if err != nil { + return nil, lazyerrors.Error(err) + } + + return &Listener{ + opts: opts, + lis: lis, + srv: newServer(opts.M), + }, nil +} + +// Run runs MCP handler until ctx is canceled. +// +// It exits when handler is stopped and listener closed. +func (lis *Listener) Run(ctx context.Context) { + s := mcp.NewServer(&mcp.Implementation{Name: "FerretDB", Version: version.Get().Version}, nil) + lis.srv.addTools(s) + + mcpHandler := mcp.NewStreamableHTTPHandler(func(req *http.Request) *mcp.Server { return s }, nil) + srvHandler := http.NewServeMux() + + // TODO https://github.com/FerretDB/FerretDB/issues/5309 + srvHandler.Handle("/mcp", connInfoMiddleware(mcpHandler)) + + srv := &http.Server{ + Handler: srvHandler, + ErrorLog: slog.NewLogLogger(lis.opts.L.Handler(), slog.LevelError), + BaseContext: func(net.Listener) context.Context { + return ctx + }, + } + + lis.opts.L.InfoContext(ctx, fmt.Sprintf("Starting MCP server on http://%s/mcp", lis.lis.Addr())) + + go func() { + if err := srv.Serve(lis.lis); !errors.Is(err, http.ErrServerClosed) { + lis.opts.L.LogAttrs(ctx, logging.LevelDPanic, "Serve exited with unexpected error", logging.Error(err)) + } + }() + + <-ctx.Done() + + // ctx is already canceled, but we want to inherit its values + shutdownCtx, shutdownCancel := ctxutil.WithDelay(ctx) + defer shutdownCancel(nil) + + if err := srv.Shutdown(shutdownCtx); err != nil { + lis.opts.L.LogAttrs(ctx, logging.LevelDPanic, "Shutdown exited with unexpected error", logging.Error(err)) + } + + if err := srv.Close(); err != nil { + lis.opts.L.LogAttrs(ctx, logging.LevelDPanic, "Close exited with unexpected error", logging.Error(err)) + } + + lis.opts.L.InfoContext(ctx, "MCP server stopped") +} + +// connInfoMiddleware returns a handler function that creates a new [*conninfo.ConnInfo], +// calls the next handler, and closes the connection info after the request is done. +func connInfoMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + connInfo := conninfo.New() + defer connInfo.Close() + next.ServeHTTP(w, r.WithContext(conninfo.Ctx(r.Context(), connInfo))) + }) +} + +// Addr returns TCP listener's address. +// It can be used to determine an actually used port, if it was zero. +func (lis *Listener) Addr() net.Addr { + return lis.lis.Addr() +} + +// Describe implements [prometheus.Collector]. +func (lis *Listener) Describe(ch chan<- *prometheus.Desc) { +} + +// Collect implements [prometheus.Collector]. +func (lis *Listener) Collect(ch chan<- prometheus.Metric) { +} diff --git a/internal/mcp/listener_test.go b/internal/mcp/listener_test.go new file mode 100644 index 00000000..811618b2 --- /dev/null +++ b/internal/mcp/listener_test.go @@ -0,0 +1,141 @@ +// Copyright 2021 FerretDB Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/FerretDB/FerretDB/v2/internal/handler/middleware" + "github.com/FerretDB/FerretDB/v2/internal/util/setup" + "github.com/FerretDB/FerretDB/v2/internal/util/state" + "github.com/FerretDB/FerretDB/v2/internal/util/testutil" +) + +func TestListener(t *testing.T) { + t.Parallel() + + ctx := t.Context() + configF := setupMCP(t, ctx) + + res := askMCPHost(t, ctx, configF, "list databases") + t.Log(res) + // [ ferretdb__listDatabases + // ] List + // {"databases":[],"totalSize":{"$numberInt":"19967123"},"ok":{"$numberDouble":"1 + // .0"}} + res = strings.ReplaceAll(res, " ", "") + res = strings.ReplaceAll(res, "\n", "") + require.Contains(t, res, "ferretdb__listDatabases") + require.Contains(t, res, `{"databases":[`) + require.Contains(t, res, `],"totalSize":{`) + require.Contains(t, res, `},"ok":{"$numberDouble":"1.0"}`) +} + +// askMCPHost sends query to MCP host in non-interactive mode with +// the given config file and prompt and returns the output. +// Non-interactive mode without streaming is used for the ease of testing. +func askMCPHost(tb testing.TB, ctx context.Context, configF, prompt string) string { + tb.Helper() + + // we probably should do something better with quoting + config := `MCPHOST_CONFIG=` + configF + args := fmt.Sprintf(`MCPHOST_ARGS=--stream=false --compact --prompt=%q`, prompt) + cmd := exec.CommandContext(ctx, filepath.Join(testutil.BinDir, "task"), "mcphost", config, args) + + res, err := cmd.CombinedOutput() + require.NoError(tb, err, string(res)) + + return string(res) +} + +// setupMCP sets up a new MCP listener and returns config file path +// that mcp host can use. +func setupMCP(tb testing.TB, ctx context.Context) string { + tb.Helper() + + sp, err := state.NewProvider("") + require.NoError(tb, err) + + //exhaustruct:enforce + res := setup.Setup(tb.Context(), &setup.SetupOpts{ + Logger: testutil.Logger(tb), + StateProvider: sp, + Metrics: middleware.NewMetrics(), + + PostgreSQLURL: testutil.PostgreSQLURL(tb), + Auth: false, + ReplSetName: "", + SessionCleanupInterval: 0, + + ProxyAddr: "", + ProxyTLSCertFile: "", + ProxyTLSKeyFile: "", + ProxyTLSCAFile: "", + + TCPAddr: "127.0.0.1:0", + UnixAddr: "", + TLSAddr: "", + TLSCertFile: "", + TLSKeyFile: "", + TLSCAFile: "", + Mode: middleware.NormalMode, + TestRecordsDir: "", + + DataAPIAddr: "", + + MCPAddr: "127.0.0.1:0", + }) + require.NotNil(tb, res) + + ctx, cancel := context.WithCancel(testutil.Ctx(tb)) + + runDone := make(chan struct{}) + + go func() { + defer close(runDone) + res.Run(ctx) + }() + + // ensure that all listener's and handler's logs are written before test ends + tb.Cleanup(func() { + cancel() + <-runDone + }) + + config := fmt.Sprintf(`{ + "mcpServers": { + "FerretDB": { + "type": "remote", + "url": "http://%s/mcp" + } + } + }`, + res.MCPListener.Addr(), + ) + + configF := filepath.Join(tb.TempDir(), "mcphost.json") + err = os.WriteFile(configF, []byte(config), 0o666) + require.NoError(tb, err) + + return configF +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 00000000..23226d24 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,16 @@ +// Copyright 2021 FerretDB Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package mcp provides a MCP server implementation. +package mcp diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 00000000..de4dbffb --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,77 @@ +// Copyright 2021 FerretDB Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "errors" + + "github.com/FerretDB/wire/wirebson" + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/FerretDB/FerretDB/v2/internal/handler/middleware" +) + +// server handles MCP request. +type server struct { + m *middleware.Middleware +} + +// newServer creates a new server with the given parameter. +func newServer(m *middleware.Middleware) *server { + return &server{ + m: m, + } +} + +// addTools adds available MCP tools for the given mcp server. +func (s *server) addTools(srv *mcp.Server) { + listDatabasesTool := &mcp.Tool{ + Name: "listDatabases", + Description: "Returns a summary of all databases.", + } + mcp.AddTool(srv, listDatabasesTool, s.listDatabases) +} + +// handle sends the request document to the middleware and returns result used by MCP tool. +// +// Log MCP tool result for debug level. +// TODO https://github.com/FerretDB/FerretDB/issues/5277 +func (s *server) handle(ctx context.Context, reqDoc *wirebson.Document) (*mcp.CallToolResult, error) { + req, err := middleware.RequestDoc(reqDoc) + if err != nil { + return nil, err + } + + resp := s.m.Handle(ctx, req) + if resp == nil { + return nil, errors.New("internal error") + } + + doc, err := resp.DocumentRaw().DecodeDeep() + if doc == nil { + return nil, err + } + + json, err := doc.MarshalJSON() + if err != nil { + return nil, err + } + + return &mcp.CallToolResultFor[any]{ + Content: []mcp.Content{&mcp.TextContent{Text: string(json)}}, + IsError: !resp.OK(), + }, nil +} diff --git a/internal/util/setup/setup.go b/internal/util/setup/setup.go index 7fe4c4cc..446ebf70 100644 --- a/internal/util/setup/setup.go +++ b/internal/util/setup/setup.go @@ -28,6 +28,7 @@ import ( "github.com/FerretDB/FerretDB/v2/internal/handler" "github.com/FerretDB/FerretDB/v2/internal/handler/middleware" "github.com/FerretDB/FerretDB/v2/internal/handlers/proxy" + "github.com/FerretDB/FerretDB/v2/internal/mcp" "github.com/FerretDB/FerretDB/v2/internal/util/logging" "github.com/FerretDB/FerretDB/v2/internal/util/must" "github.com/FerretDB/FerretDB/v2/internal/util/state" @@ -65,6 +66,9 @@ type SetupOpts struct { // DataAPI listener DataAPIAddr string // empty value disables Data API listener + + // MCPAddr listener + MCPAddr string // empty value disables MCP listener } // SetupResult represents [Setup] result. @@ -74,6 +78,7 @@ type SetupResult struct { m *middleware.Middleware WireListener *clientconn.Listener DataAPIListener *dataapi.Listener + MCPListener *mcp.Listener } // Setup creates and sets up: @@ -82,6 +87,7 @@ type SetupResult struct { // - middleware ([*middleware.Middleware]); // - wire protocol listener ([*clientconn.Listener]); // - Data API listener ([*dataapi.Listener]); +// - MCP listener ([*mcp.Listener]); // - unregistered Prometheus collector for the above components. // // It does not change the global state or creates components that are different in tests. @@ -184,6 +190,7 @@ func Setup(ctx context.Context, opts *SetupOpts) *SetupResult { } if opts.DataAPIAddr != "" { + //exhaustruct:enforce res.DataAPIListener, err = dataapi.Listen(&dataapi.ListenOpts{ L: logging.WithName(opts.Logger, "dataapi"), M: res.m, @@ -198,6 +205,21 @@ func Setup(ctx context.Context, opts *SetupOpts) *SetupResult { } } + if opts.MCPAddr != "" { + //exhaustruct:enforce + res.MCPListener, err = mcp.Listen(&mcp.ListenOpts{ + L: logging.WithName(opts.Logger, "mcp"), + M: res.m, + TCPAddr: opts.MCPAddr, + }) + if err != nil { + opts.Logger.LogAttrs(ctx, logging.LevelDPanic, "Failed to construct MCP listener", logging.Error(err)) + res.Run(exitCtx) + + return nil + } + } + return &res } @@ -259,6 +281,15 @@ func (sr *SetupResult) runListeners(ctx context.Context) { }() } + if sr.MCPListener != nil { + wg.Add(1) + + go func() { + defer wg.Done() + sr.MCPListener.Run(ctx) + }() + } + wg.Wait() } @@ -315,6 +346,10 @@ func (sr *SetupResult) Describe(ch chan<- *prometheus.Desc) { if sr.DataAPIListener != nil { sr.DataAPIListener.Describe(ch) } + + if sr.MCPListener != nil { + sr.MCPListener.Describe(ch) + } } // Collect implements [prometheus.Collector]. @@ -336,6 +371,10 @@ func (sr *SetupResult) Collect(ch chan<- prometheus.Metric) { if sr.DataAPIListener != nil { sr.DataAPIListener.Collect(ch) } + + if sr.MCPListener != nil { + sr.MCPListener.Collect(ch) + } } // check interfaces diff --git a/website/docs/configuration/flags.md b/website/docs/configuration/flags.md index 179fc35d..95ecf31d 100644 --- a/website/docs/configuration/flags.md +++ b/website/docs/configuration/flags.md @@ -54,6 +54,7 @@ Additionally: | `--listen-tls-key-file` | TLS key file path | `FERRETDB_LISTEN_TLS_KEY_FILE` | | | `--listen-tls-ca-file` | TLS CA file path | `FERRETDB_LISTEN_TLS_CA_FILE` | | | `--listen-data-api-addr` | Listen TCP address for HTTP Data API
(set to empty value or `-` to disable) | `FERRETDB_LISTEN_DATA_API_ADDR` | | +| `--listen-mcp-addr` | Listen TCP address for HTTP MCP server
(set to empty value or `-` to disable) | `FERRETDB_LISTEN_MCP_ADDR` | | | `--proxy-addr` | Proxy address for non-normal [operation mode](operation-modes.md) | `FERRETDB_PROXY_ADDR` | | | `--proxy-tls-cert-file` | Proxy TLS cert file path | `FERRETDB_PROXY_TLS_CERT_FILE` | | | `--proxy-tls-key-file` | Proxy TLS key file path | `FERRETDB_PROXY_TLS_KEY_FILE` | |