# Directory Structure
```
├── .gitignore
├── .roomodes
├── go.mod
├── go.sum
├── main.go
└── README.md
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | ### Generated by gibo (https://github.com/simonwhitaker/gibo)
2 | ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/macOS.gitignore
3 |
4 | # General
5 | .DS_Store
6 | .AppleDouble
7 | .LSOverride
8 |
9 | # Icon must end with two \r
10 | Icon
11 |
12 |
13 | # Thumbnails
14 | ._*
15 |
16 | # Files that might appear in the root of a volume
17 | .DocumentRevisions-V100
18 | .fseventsd
19 | .Spotlight-V100
20 | .TemporaryItems
21 | .Trashes
22 | .VolumeIcon.icns
23 | .com.apple.timemachine.donotpresent
24 |
25 | # Directories potentially created on remote AFP share
26 | .AppleDB
27 | .AppleDesktop
28 | Network Trash Folder
29 | Temporary Items
30 | .apdisk
31 | ### Generated by gibo (https://github.com/simonwhitaker/gibo)
32 | ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Global/Windows.gitignore
33 |
34 | # Windows thumbnail cache files
35 | Thumbs.db
36 | Thumbs.db:encryptable
37 | ehthumbs.db
38 | ehthumbs_vista.db
39 |
40 | # Dump file
41 | *.stackdump
42 |
43 | # Folder config file
44 | [Dd]esktop.ini
45 |
46 | # Recycle Bin used on file shares
47 | $RECYCLE.BIN/
48 |
49 | # Windows Installer files
50 | *.cab
51 | *.msi
52 | *.msix
53 | *.msm
54 | *.msp
55 |
56 | # Windows shortcuts
57 | *.lnk
58 | ### Generated by gibo (https://github.com/simonwhitaker/gibo)
59 | ### https://raw.github.com/github/gitignore/4488915eec0b3a45b5c63ead28f286819c0917de/Go.gitignore
60 |
61 | # If you prefer the allow list template instead of the deny list, see community template:
62 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
63 | #
64 | # Binaries for programs and plugins
65 | *.exe
66 | *.exe~
67 | *.dll
68 | *.so
69 | *.dylib
70 |
71 | # Test binary, built with `go test -c`
72 | *.test
73 |
74 | # Output of the go coverage tool, specifically when used with LiteIDE
75 | *.out
76 |
77 | # Dependency directories (remove the comment below to include it)
78 | # vendor/
79 |
80 | # Go workspace file
81 | go.work
82 |
83 | .roo/mcp.json
```
--------------------------------------------------------------------------------
/.roomodes:
--------------------------------------------------------------------------------
```
1 | {
2 | "customModes": [
3 | {
4 | "slug": "boomerang-mode",
5 | "name": "Boomerang Mode",
6 | "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.",
7 | "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.",
8 | "groups": [],
9 | "source": "global"
10 | }
11 | ]
12 | }
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Meilisearch Hybrid Search MCP Server
2 |
3 | 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.
4 |
5 | ## Environment Variables
6 |
7 | Set the following environment variables before running the server:
8 |
9 | ```bash
10 | export MEILI_HOST="http://your-meilisearch-instance:7700" # Meilisearch host URL
11 | export MEILI_API_KEY="your_api_key" # Meilisearch API key (if required)
12 | export MEILI_INDEX="your_index_name" # The name of the index to search in
13 | export MEILI_EMBEDDER="your_embedder_name" # The name of the embedder configured in Meilisearch (e.g., 'default', 'myOpenai')
14 | export MEILI_FILTERABLE_ATTRIBUTES="attr1,attr2" # Comma-separated filterable attributes for AI awareness (from index settings)
15 | ```
16 |
17 | ## Building and Running
18 |
19 | Build the server:
20 | ```bash
21 | go build -o meilisearch-hybrid-search-mcp .
22 |
23 | # windows
24 | GOOS=windows GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp.exe .
25 | # linux
26 | GOOS=linux GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp .
27 | # mac
28 | GOOS=macos GOARCH=amd64 go build -o meilisearch-hybrid-search-mcp .
29 | ```
30 |
31 | Run the server:
32 | ```bash
33 | ./meilisearch-hybrid-search-mcp
34 | ```
35 | The server will listen on standard input/output.
36 |
37 | ## Available Tool: `hybrid_search`
38 |
39 | This tool performs a hybrid search on the configured Meilisearch index.
40 |
41 | **Description:** Hybrid search your documents in Meilisearch index.
42 |
43 | **Arguments:**
44 |
45 | * `keywords` (string, **required**): The search query keywords.
46 | * `semantic_ratio` (number, optional, default: 0.5): Controls the balance between keyword and semantic search.
47 | * `0.0`: Pure keyword search.
48 | * `1.0`: Pure semantic search.
49 | * `0.5`: Balanced keyword and semantic search.
50 | * `filterable_attribute` (string, optional): The attribute name to filter results on (e.g., "genre", "author"). Requires `filter_word`.
51 | * `filter_word` (string, optional): The value to filter the specified `filterable_attribute` by (e.g., "Drama", "Tolkien"). Requires `filterable_attribute`.
```
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "flag"
8 | "fmt"
9 | "os"
10 | "strings"
11 |
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/mark3labs/mcp-go/server"
14 | "github.com/meilisearch/meilisearch-go"
15 | )
16 |
17 | var (
18 | hostFlag *string
19 | apiKeyFlag *string
20 | indexFlag *string
21 | embedderFlag *string
22 | )
23 |
24 | func init() {
25 | hostFlag = flag.String("host", "", "Meilisearch server host (e.g., http://localhost:7700)")
26 | apiKeyFlag = flag.String("api-key", "", "Meilisearch API key")
27 | indexFlag = flag.String("index", "", "Meilisearch index name")
28 | embedderFlag = flag.String("embedder", "", "Embedder to use (e.g., openai)")
29 | flag.Parse()
30 | }
31 |
32 | func newMeiliIndex() meilisearch.IndexManager {
33 | meiliHost := *hostFlag
34 | if meiliHost == "" {
35 | meiliHost = os.Getenv("MEILI_HOST")
36 | if meiliHost == "" {
37 | fmt.Println("Error: Meilisearch host not provided. Use --host flag or set MEILI_HOST environment variable")
38 | os.Exit(1)
39 | }
40 | }
41 |
42 | meiliAPIKey := *apiKeyFlag
43 | if meiliAPIKey == "" {
44 | meiliAPIKey = os.Getenv("MEILI_API_KEY")
45 | }
46 |
47 | meiliIndex := *indexFlag
48 | if meiliIndex == "" {
49 | meiliIndex = os.Getenv("MEILI_INDEX")
50 | if meiliIndex == "" {
51 | fmt.Println("Error: Meilisearch index not provided. Use --index flag or set MEILI_INDEX environment variable")
52 | os.Exit(1)
53 | }
54 | }
55 |
56 | client := meilisearch.New(meiliHost, meilisearch.WithAPIKey(meiliAPIKey))
57 | index := client.Index(meiliIndex)
58 | return index
59 | }
60 |
61 | func main() {
62 | s := server.NewMCPServer(
63 | "Meilisearch Hybrid Search MCP Server",
64 | "1.0.0",
65 | server.WithResourceCapabilities(true, true),
66 | server.WithLogging(),
67 | )
68 |
69 | index := newMeiliIndex()
70 |
71 | settings, err := index.GetSettings()
72 | if err != nil {
73 | fmt.Printf("Error getting index settings: %v\n", err)
74 | os.Exit(1)
75 | }
76 |
77 | filterableAttrs := settings.FilterableAttributes
78 |
79 | filterableAttrDescription := "Attribute to filter on. Requires filter_word."
80 | if len(filterableAttrs) > 0 {
81 | availableAttrsStr := strings.Join(filterableAttrs, ", ")
82 | filterableAttrDescription = fmt.Sprintf("Attribute to filter on (Available: %s). Requires filter_word.", availableAttrsStr)
83 | }
84 |
85 | searchTool := mcp.NewTool("hybrid_search",
86 | mcp.WithDescription("Hybrid search your documents in Meilisearch index"),
87 | mcp.WithString("keywords",
88 | mcp.Required(),
89 | 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')"),
90 | ),
91 | mcp.WithNumber("semantic_ratio",
92 | mcp.Required(),
93 | 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"),
94 | mcp.DefaultNumber(0.5),
95 | mcp.Min(0.0),
96 | mcp.Max(1.0),
97 | ),
98 | mcp.WithString("filterable_attribute",
99 | mcp.Description(filterableAttrDescription),
100 | ),
101 | mcp.WithString("filter_word",
102 | mcp.Description("Word or value to filter the attribute by (e.g., 'Drama', 'Tolkien'). Requires filterable_attribute."),
103 | ),
104 | mcp.WithNumber("ranking_score_threshold",
105 | mcp.Description("Returns results with a ranking score bigger than this value. Default is 0.9."),
106 | mcp.DefaultNumber(0.9),
107 | mcp.Min(0.0),
108 | mcp.Max(0.99),
109 | ),
110 | )
111 |
112 | s.AddTool(searchTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
113 | keywordsValue, ok := request.Params.Arguments["keywords"]
114 | if !ok {
115 | return nil, errors.New("missing required argument: keywords")
116 | }
117 | keywords, ok := keywordsValue.(string)
118 | if !ok {
119 | return nil, errors.New("argument 'keywords' must be a string")
120 | }
121 |
122 | semanticRatioValue, ok := request.Params.Arguments["semantic_ratio"]
123 | if !ok {
124 | semanticRatioValue = 0.5
125 | }
126 | semanticRatio, ok := semanticRatioValue.(float64)
127 | if !ok {
128 | return nil, errors.New("argument 'semantic_ratio' must be a number")
129 | }
130 | rankingScoreThresholdValue, ok := request.Params.Arguments["ranking_score_threshold"]
131 | if !ok {
132 | rankingScoreThresholdValue = 0.5
133 | }
134 | rankingScoreThreshold, ok := rankingScoreThresholdValue.(float64)
135 | if !ok {
136 | return nil, errors.New("argument 'ranking_score_threshold' must be a number")
137 | }
138 |
139 | var filterAttribute, filterWord string
140 | var filterExpr string
141 |
142 | filterAttrValue, filterAttrOk := request.Params.Arguments["filterable_attribute"]
143 | filterWordValue, filterWordOk := request.Params.Arguments["filter_word"]
144 |
145 | if filterAttrOk && filterWordOk {
146 | var attrOk, wordOk bool
147 | filterAttribute, attrOk = filterAttrValue.(string)
148 | filterWord, wordOk = filterWordValue.(string)
149 |
150 | if attrOk && wordOk && filterAttribute != "" && filterWord != "" {
151 | filterExpr = fmt.Sprintf(`%s = '%s'`, filterAttribute, filterWord)
152 | }
153 | }
154 | meiliEmbedder := *embedderFlag
155 | if meiliEmbedder == "" {
156 | meiliEmbedder = os.Getenv("MEILI_EMBEDDER")
157 | if meiliEmbedder == "" {
158 | return nil, errors.New("embedder not provided. Use --embedder flag or set MEILI_EMBEDDER environment variable")
159 | }
160 | }
161 |
162 | searchReq := &meilisearch.SearchRequest{
163 | Query: keywords,
164 | Hybrid: &meilisearch.SearchRequestHybrid{
165 | SemanticRatio: semanticRatio,
166 | Embedder: meiliEmbedder,
167 | },
168 | Filter: filterExpr,
169 | ShowRankingScore: true,
170 | RankingScoreThreshold: rankingScoreThreshold,
171 | }
172 |
173 | searchRes, err := index.Search(keywords, searchReq)
174 | if err != nil {
175 | return nil, fmt.Errorf("meilisearch search failed: %w", err)
176 | }
177 |
178 | if len(searchRes.Hits) == 0 {
179 | return mcp.NewToolResultText("no results found - please try with fewer or different keywords"), nil
180 | }
181 |
182 | jsonResult, err := json.Marshal(searchRes.Hits)
183 | if err != nil {
184 | return nil, fmt.Errorf("failed to marshal search result to JSON: %w", err)
185 | }
186 |
187 | return mcp.NewToolResultText(string(jsonResult)), nil
188 | })
189 |
190 | if err := server.ServeStdio(s); err != nil {
191 | fmt.Printf("Server error: %v\n", err)
192 | }
193 | }
194 |
```