Add basic MCP server (#5279)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Alexey Palazhchenko <alexey.palazhchenko@ferretdb.io>
This commit is contained in:
authored and GitHub committed 2025-11-06 09:08:44 +04:00
1 parent 3b29cece0c
commit 487880fd19
18 files changed
+500 -4

No files matched your search

+14
View File
@@ -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:
+1
View File
@@ -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"
+13 -3
View File
@@ -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"
+11
View File
@@ -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)
+2
View File
@@ -148,6 +148,8 @@ func New(config *Config) (*FerretDB, error) {
TestRecordsDir: "",
DataAPIAddr: "",
MCPAddr: "",
})
if res == nil {
return nil, fmt.Errorf("failed to create FerretDB")
+2
View File
@@ -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
+4
View File
@@ -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=
+2
View File
@@ -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
+4
View File
@@ -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=
+2
View File
@@ -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)
+1 -1
View File
@@ -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,
+34
View File
@@ -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)
}
+136
View File
@@ -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) {
}
+141
View File
@@ -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
}
+16
View File
@@ -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
+77
View File
@@ -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
}
+39
View File
@@ -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
+1
View File
@@ -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<br />(set to empty value or `-` to disable) | `FERRETDB_LISTEN_DATA_API_ADDR` | |
| `--listen-mcp-addr` | Listen TCP address for HTTP MCP server<br />(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` | |