Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@
# other, so comparing them proves nothing. Add an origin only if you serve an MCP web
# client from it. "*" trusts every origin and disables the check.
# MCP_ALLOWED_ORIGINS=https://console.example.com
# MCP_TOOL_DISCOVERY: "off" (default) lists every tool; "search" lists only search_tools
# and call_tool, so large catalogs stop costing model context on every turn. Use it for
# clients without their own tool search. Clients override it with X-MCP-Tool-Discovery.
# MCP_TOOL_DISCOVERY=off

# HTTP Client Configuration (for upstream API requests)
# The standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY variables route every upstream
Expand Down
4 changes: 4 additions & 0 deletions config/config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ models:
# # page's Origin and Host agree with each other, so comparing them proves
# # nothing). Add an origin only if you serve an MCP web client from it.
# allowed_origins: [] # env: MCP_ALLOWED_ORIGINS; "*" disables the check
# # off (default) lists every tool; search lists only search_tools and call_tool,
# # so large catalogs stop costing model context. Clients override it per session
# # with the X-MCP-Tool-Discovery header.
# tool_discovery: off # env: MCP_TOOL_DISCOVERY
# servers:
# github:
# url: https://api.githubcopilot.com/mcp
Expand Down
20 changes: 20 additions & 0 deletions config/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,23 @@ type MCPConfig struct {
// ("*") turns the check off and is logged as a warning at startup.
AllowedOrigins []string `yaml:"allowed_origins" env:"MCP_ALLOWED_ORIGINS"`

// ToolDiscovery selects how sessions see tools by default. "off" (the
// default) lists every visible tool. "search" lists only search_tools and
// call_tool, so large catalogs stop costing context on every model turn.
// Clients override it per session with the X-MCP-Tool-Discovery header.
ToolDiscovery string `yaml:"tool_discovery" env:"MCP_TOOL_DISCOVERY"`

// Servers maps stable server slugs to upstream definitions. Slugs become
// tool namespaces and URL segments, so they are restricted to [a-z0-9_-].
Servers map[string]MCPServerConfig `yaml:"servers"`
}

// MCP tool discovery modes accepted in MCPConfig.ToolDiscovery.
const (
MCPToolDiscoveryOff = "off"
MCPToolDiscoverySearch = "search"
)

// TrustAnyOrigin is the mcp.allowed_origins entry that trusts every browser
// origin. It exists for deployments that enforce their own origin checks in
// front of the gateway, and disables the gateway's DNS-rebinding defense.
Expand Down Expand Up @@ -224,6 +236,14 @@ func expandMCPServerEnv(server *MCPServerConfig) {
// invalid entries. It runs at load time so a bad declaration fails startup
// loudly instead of silently dropping the server.
func normalizeMCPConfig(cfg *MCPConfig) error {
switch mode := strings.ToLower(strings.TrimSpace(cfg.ToolDiscovery)); mode {
case "":
cfg.ToolDiscovery = MCPToolDiscoveryOff
case MCPToolDiscoveryOff, MCPToolDiscoverySearch:
cfg.ToolDiscovery = mode
default:
return fmt.Errorf("mcp.tool_discovery must be %q or %q, got %q", MCPToolDiscoveryOff, MCPToolDiscoverySearch, cfg.ToolDiscovery)
}
if len(cfg.AllowedOrigins) > 0 {
normalized := make([]string, 0, len(cfg.AllowedOrigins))
for _, raw := range cfg.AllowedOrigins {
Expand Down
25 changes: 25 additions & 0 deletions config/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,28 @@ func TestNormalizeMCPAllowedOrigins(t *testing.T) {
})
}
}

func TestNormalizeMCPToolDiscovery(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: MCPToolDiscoveryOff},
{input: "off", want: MCPToolDiscoveryOff},
{input: " Search ", want: MCPToolDiscoverySearch},
{input: "semantic", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
cfg := MCPConfig{ToolDiscovery: tt.input}
err := normalizeMCPConfig(&cfg)
if tt.wantErr {
require.ErrorContains(t, err, "mcp.tool_discovery")
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, cfg.ToolDiscovery)
})
}
}
1 change: 1 addition & 0 deletions docs/advanced/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ See [MCP Gateway](/features/mcp-gateway) for the full feature guide.
| `MCP_ENABLED` | Expose the MCP endpoints `/mcp` and `/mcp/{server}` | `true` |
| `MCP_SERVERS` | JSON object of upstream MCP servers, merged over the `mcp.servers` YAML map per name (entries are read-only in the dashboard) | _(none)_ |
| `MCP_ALLOWED_ORIGINS` | Comma-separated browser origins (`scheme://host[:port]`) allowed to call `/mcp`; the default trusts none, which is what blocks DNS rebinding. `*` disables the check | _(none)_ |
| `MCP_TOOL_DISCOVERY` | `off` lists every tool; `search` serves `search_tools` and `call_tool` instead, so large catalogs stop filling model context. Clients override it per session with `X-MCP-Tool-Discovery` | `off` |

#### Logging

Expand Down
48 changes: 48 additions & 0 deletions docs/features/mcp-gateway.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,54 @@ Three ways to narrow what a client sees:
- `/mcp/{slug}` — a single server with its **original** tool names.
- `X-MCP-Servers: github,jira` header — a comma-separated subset on `/mcp`.

## Search tools instead of listing them

Every tool in `tools/list` is sent to the model on every turn. With a few
large servers that can be tens of thousands of tokens. Search discovery
replaces the list with two tools:

- `search_tools` takes keywords (or an exact tool name) and returns the
matching tools with their descriptions and input schemas.
- `call_tool` runs a found tool by `name` with its `arguments`.

It is off by default. Turn it on for every client:

```yaml
mcp:
tool_discovery: search # env: MCP_TOOL_DISCOVERY; default: off
```

Or per client, with a header that overrides the default for that session:

```json
{
"mcpServers": {
"gomodel": {
"type": "http",
"url": "https://your-gateway/mcp",
"headers": {
"Authorization": "Bearer sk-your-gomodel-key",
"X-MCP-Tool-Discovery": "search"
}
}
}
}
```

Use it for clients that send every tool to the model, such as custom agents
and SDK loops. Leave it off for clients with their own tool search, such as
Claude Code: they already defer tools, and they get per-tool permission
prompts and read-only/destructive hints only when tools are listed directly.
Through `call_tool`, every call looks like the same tool to the client.

Search results and calls respect user paths and tool filters. Failures, such
as an unknown name or an unreachable server, come back as tool errors the
model can read and recover from. Usage entries and the request log record the
real tool name, not `call_tool`. If the catalog is only
large because of tools nobody uses, trimming it with
[tool filters](#choose-which-tools-each-server-exposes) or `X-MCP-Servers`
is simpler and keeps tools listed directly.

## Declare servers

Servers can be managed in the dashboard (**MCP Servers** page) or declared as
Expand Down
Loading
Loading