# Directory Structure
```
├── .gitignore
├── .roomodes
├── go.mod
├── go.sum
├── main.go
└── README.md
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
### Generated by gibo (https://github.com/simonwhitaker/gibo)
### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Go.gitignore
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
.roo/mcp.json
```
--------------------------------------------------------------------------------
/.roomodes:
--------------------------------------------------------------------------------
```
{
"customModes": [
{
"slug": "boomerang-mode",
"name": "Boomerang Mode",
"roleDefinition": "You are Roo, a strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes. You have a comprehensive understanding of each mode's capabilities and limitations, allowing you to effectively break down complex problems into discrete tasks that can be solved by different specialists.",
"customInstructions": "Your role is to coordinate complex workflows by delegating tasks to specialized modes. As an orchestrator, you should:\n\n1. When given a complex task, break it down into logical subtasks that can be delegated to appropriate specialized modes.\n\n2. For each subtask, use the `new_task` tool to delegate. Choose the most appropriate mode for the subtask's specific goal and provide comprehensive instructions in the `message` parameter. These instructions must include:\n * All necessary context from the parent task or previous subtasks required to complete the work.\n * A clearly defined scope, specifying exactly what the subtask should accomplish.\n * An explicit statement that the subtask should *only* perform the work outlined in these instructions and not deviate.\n * An instruction for the subtask to signal completion by using the `attempt_completion` tool, providing a concise yet thorough summary of the outcome in the `result` parameter, keeping in mind that this summary will be the source of truth used to keep track of what was completed on this project. \n * A statement that these specific instructions supersede any conflicting general instructions the subtask's mode might have.\n\n3. Track and manage the progress of all subtasks. When a subtask is completed, analyze its results and determine the next steps.\n\n4. Help the user understand how the different subtasks fit together in the overall workflow. Provide clear reasoning about why you're delegating specific tasks to specific modes.\n\n5. When all subtasks are completed, synthesize the results and provide a comprehensive overview of what was accomplished.\n\n6. Ask clarifying questions when necessary to better understand how to break down complex tasks effectively.\n\n7. Suggest improvements to the workflow based on the results of completed subtasks.\n\nUse subtasks to maintain clarity. If a request significantly shifts focus or requires a different expertise (mode), consider creating a subtask rather than overloading the current one.",
"groups": [],
"source": "global"
}
]
}
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Meilisearch Hybrid Search MCP Server
This MCP (Model Control Protocol) server provides a tool for performing hybrid searches on a Meilisearch index. It allows combining keyword-based search with semantic vector search.
## Environment Variables
Set the following environment variables before running the server:
```bash
export MEILI_HOST="http://your-meilisearch-instance:7700" # Meilisearch host URL
export MEILI_API_KEY="your_api_key" # Meilisearch API key (if required)
export MEILI_INDEX="your_index_name" # The name of the index to search in
export MEILI_EMBEDDER="your_embedder_name" # The name of the embedder configured in Meilisearch (e.g., 'default', 'myOpenai')
export MEILI_FILTERABLE_ATTRIBUTES="attr1,attr2" # Comma-separated filterable attributes for AI awareness (from index settings)
```
## Building and Running
Build the server:
```bash
go build -o meilisearch-hybrid-search-mcp .
# windows
GOOS=windows GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp.exe .
# linux
GOOS=linux GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp .
# mac
GOOS=macos GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp .
```
Run the server:
```bash
./meilisearch-hybrid-search-mcp
```
The server will listen on standard input/output.
## Available Tool: `hybrid_search`
This tool performs a hybrid search on the configured Meilisearch index.
**Description:** Hybrid search your documents in Meilisearch index.
**Arguments:**
* `keywords` (string, **required**): The search query keywords.
* `semantic_ratio` (number, optional, default: 0.5): Controls the balance between keyword and semantic search.
* `0.0`: Pure keyword search.
* `1.0`: Pure semantic search.
* `0.5`: Balanced keyword and semantic search.
* `filterable_attribute` (string, optional): The attribute name to filter results on (e.g., "genre", "author"). Requires `filter_word`.
* `filter_word` (string, optional): The value to filter the specified `filterable_attribute` by (e.g., "Drama", "Tolkien"). Requires `filterable_attribute`.
```
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
```go
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/meilisearch/meilisearch-go"
)
var (
hostFlag *string
apiKeyFlag *string
indexFlag *string
embedderFlag *string
)
func init() {
hostFlag = flag.String("host", "", "Meilisearch server host (e.g., http://localhost:7700)")
apiKeyFlag = flag.String("api-key", "", "Meilisearch API key")
indexFlag = flag.String("index", "", "Meilisearch index name")
embedderFlag = flag.String("embedder", "", "Embedder to use (e.g., openai)")
flag.Parse()
}
func newMeiliIndex() meilisearch.IndexManager {
meiliHost := *hostFlag
if meiliHost == "" {
meiliHost = os.Getenv("MEILI_HOST")
if meiliHost == "" {
fmt.Println("Error: Meilisearch host not provided. Use --host flag or set MEILI_HOST environment variable")
os.Exit(1)
}
}
meiliAPIKey := *apiKeyFlag
if meiliAPIKey == "" {
meiliAPIKey = os.Getenv("MEILI_API_KEY")
}
meiliIndex := *indexFlag
if meiliIndex == "" {
meiliIndex = os.Getenv("MEILI_INDEX")
if meiliIndex == "" {
fmt.Println("Error: Meilisearch index not provided. Use --index flag or set MEILI_INDEX environment variable")
os.Exit(1)
}
}
client := meilisearch.New(meiliHost, meilisearch.WithAPIKey(meiliAPIKey))
index := client.Index(meiliIndex)
return index
}
func main() {
s := server.NewMCPServer(
"Meilisearch Hybrid Search MCP Server",
"1.0.0",
server.WithResourceCapabilities(true, true),
server.WithLogging(),
)
index := newMeiliIndex()
settings, err := index.GetSettings()
if err != nil {
fmt.Printf("Error getting index settings: %v\n", err)
os.Exit(1)
}
filterableAttrs := settings.FilterableAttributes
filterableAttrDescription := "Attribute to filter on. Requires filter_word."
if len(filterableAttrs) > 0 {
availableAttrsStr := strings.Join(filterableAttrs, ", ")
filterableAttrDescription = fmt.Sprintf("Attribute to filter on (Available: %s). Requires filter_word.", availableAttrsStr)
}
searchTool := mcp.NewTool("hybrid_search",
mcp.WithDescription("Hybrid search your documents in Meilisearch index"),
mcp.WithString("keywords",
mcp.Required(),
mcp.Description("Placing the most contextually important keywords at the beginning leads to more relevant results. (Good example: 'v1.13 new features meilisearch', Bad example: 'new features of meilisearch v1.13')"),
),
mcp.WithNumber("semantic_ratio",
mcp.Required(),
mcp.Description("A value closer to 0 emphasizes keyword search, while closer to 1 emphasizes vector search. Default is 0.5. If the `_rankingScore` in results is low, try adjusting to 0.8 or 0.2 to find more relevant documents"),
mcp.DefaultNumber(0.5),
mcp.Min(0.0),
mcp.Max(1.0),
),
mcp.WithString("filterable_attribute",
mcp.Description(filterableAttrDescription),
),
mcp.WithString("filter_word",
mcp.Description("Word or value to filter the attribute by (e.g., 'Drama', 'Tolkien'). Requires filterable_attribute."),
),
mcp.WithNumber("ranking_score_threshold",
mcp.Description("Returns results with a ranking score bigger than this value. Default is 0.9."),
mcp.DefaultNumber(0.9),
mcp.Min(0.0),
mcp.Max(0.99),
),
)
s.AddTool(searchTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
keywordsValue, ok := request.Params.Arguments["keywords"]
if !ok {
return nil, errors.New("missing required argument: keywords")
}
keywords, ok := keywordsValue.(string)
if !ok {
return nil, errors.New("argument 'keywords' must be a string")
}
semanticRatioValue, ok := request.Params.Arguments["semantic_ratio"]
if !ok {
semanticRatioValue = 0.5
}
semanticRatio, ok := semanticRatioValue.(float64)
if !ok {
return nil, errors.New("argument 'semantic_ratio' must be a number")
}
rankingScoreThresholdValue, ok := request.Params.Arguments["ranking_score_threshold"]
if !ok {
rankingScoreThresholdValue = 0.5
}
rankingScoreThreshold, ok := rankingScoreThresholdValue.(float64)
if !ok {
return nil, errors.New("argument 'ranking_score_threshold' must be a number")
}
var filterAttribute, filterWord string
var filterExpr string
filterAttrValue, filterAttrOk := request.Params.Arguments["filterable_attribute"]
filterWordValue, filterWordOk := request.Params.Arguments["filter_word"]
if filterAttrOk && filterWordOk {
var attrOk, wordOk bool
filterAttribute, attrOk = filterAttrValue.(string)
filterWord, wordOk = filterWordValue.(string)
if attrOk && wordOk && filterAttribute != "" && filterWord != "" {
filterExpr = fmt.Sprintf(`%s = '%s'`, filterAttribute, filterWord)
}
}
meiliEmbedder := *embedderFlag
if meiliEmbedder == "" {
meiliEmbedder = os.Getenv("MEILI_EMBEDDER")
if meiliEmbedder == "" {
return nil, errors.New("embedder not provided. Use --embedder flag or set MEILI_EMBEDDER environment variable")
}
}
searchReq := &meilisearch.SearchRequest{
Query: keywords,
Hybrid: &meilisearch.SearchRequestHybrid{
SemanticRatio: semanticRatio,
Embedder: meiliEmbedder,
},
Filter: filterExpr,
ShowRankingScore: true,
RankingScoreThreshold: rankingScoreThreshold,
}
searchRes, err := index.Search(keywords, searchReq)
if err != nil {
return nil, fmt.Errorf("meilisearch search failed: %w", err)
}
if len(searchRes.Hits) == 0 {
return mcp.NewToolResultText("no results found - please try with fewer or different keywords"), nil
}
jsonResult, err := json.Marshal(searchRes.Hits)
if err != nil {
return nil, fmt.Errorf("failed to marshal search result to JSON: %w", err)
}
return mcp.NewToolResultText(string(jsonResult)), nil
})
if err := server.ServeStdio(s); err != nil {
fmt.Printf("Server error: %v\n", err)
}
}
```