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:
18 files changed
+500
-4
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -148,6 +148,8 @@ func New(config *Config) (*FerretDB, error) {
|
||||
TestRecordsDir: "",
|
||||
|
||||
DataAPIAddr: "",
|
||||
|
||||
MCPAddr: "",
|
||||
})
|
||||
if res == nil {
|
||||
return nil, fmt.Errorf("failed to create FerretDB")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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` | |
|
||||
|
||||
Reference in new issue
Block a user