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` | |