Skip to content

Commit a6faa80

Browse files
committed
Update reload logic and add documentation
* Added documentation * Implement equality checks for Server (ServerEntry and ServerExecutionContext) * Added 'UpdateTools' to MCPClientAccessor (ClientManager) * Update 'reload' logic for MCP servers to handle tool only changes etc. *
1 parent 6968ad0 commit a6faa80

File tree

10 files changed

+848
-50
lines changed

10 files changed

+848
-50
lines changed

docs/configuration.md

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,12 @@ You can provide this path in multiple ways:
2424
[[servers]]
2525
name = "fetch"
2626
package = "uvx::mcp-server-fetch@2025.4.7"
27+
tools = ["fetch"]
2728

2829
[[servers]]
2930
name = "time"
30-
package = "uvx::mcp-server-time@0.6.2"
31-
tools = ["get_current_time"]
31+
package = "uvx::mcp-server-time@2025.8.4"
32+
tools = ["get_current_time", "convert_time"]
3233
```
3334

3435
---
@@ -65,6 +66,150 @@ Options:
6566

6667
---
6768

69+
## Hot Reload
70+
71+
The `mcpd` daemon supports hot-reloading of MCP server configurations without requiring a full restart. This allows you to add, remove, or modify server configurations while keeping the daemon running.
72+
73+
Hot reload processes both:
74+
75+
- **Server configuration** (`--config-file`) e.g. `.mcpd.toml`
76+
- **Execution context** (`--runtime-file`) e.g. `secrets.dev.toml`
77+
78+
### SIGHUP Signal
79+
80+
Send a `SIGHUP` signal to the running daemon process to trigger a configuration reload:
81+
82+
```bash
83+
# Find the daemon process ID
84+
ps aux | grep mcpd
85+
86+
# Send reload signal (replace PID with actual process ID)
87+
kill -HUP <PID>
88+
```
89+
90+
### Reload Behavior
91+
92+
During a hot reload, the daemon intelligently categorizes changes and responds accordingly:
93+
94+
| Change Type | Action | Description |
95+
|-----------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
96+
| Unchanged servers | Preserve | Servers with identical configurations keep their existing connections, tools, and health status |
97+
| Removed servers | Stop | Servers no longer in the config file are gracefully shut down |
98+
| New servers | Start | Newly added servers are initialized and connected |
99+
| 'Tools-Only' changes | Update | When only the `tools` change, the daemon updates the allowed tools without restarting the server process |
100+
| Configuration changes | Restart | Servers with other configuration changes (package version, environment variables, arguments, execution context, etc.) are stopped and restarted with new settings |
101+
102+
### Example: 'Tools-Only' Update
103+
104+
Consider this server configuration:
105+
106+
```toml
107+
[[servers]]
108+
name = "github"
109+
package = "uvx::modelcontextprotocol/github-server@1.2.3"
110+
tools = ["create_repository", "get_repository"]
111+
```
112+
113+
If you modify only the tools list:
114+
115+
```toml
116+
[[servers]]
117+
name = "github"
118+
package = "uvx::modelcontextprotocol/github-server@1.2.3"
119+
tools = ["create_repository", "get_repository", "list_repositories"] # Additional tools
120+
```
121+
122+
The daemon will:
123+
124+
1. Detect that only the `tools` array changed
125+
2. Update the allowed tools list in-place
126+
3. Keep the existing server process and connections intact
127+
4. Log a message that tools for a server were updated (including the server name and list of tools)
128+
129+
### Example: Package Version Update
130+
131+
If you change the package version:
132+
133+
```toml
134+
[[servers]]
135+
name = "github"
136+
package = "uvx::modelcontextprotocol/github-server@1.3.0" # Version changed
137+
tools = ["create_repository", "get_repository", "list_repositories"]
138+
```
139+
140+
The daemon will:
141+
142+
1. Detect configuration changes beyond just tools
143+
2. Gracefully stop the existing server
144+
3. Start a new server with the updated configuration
145+
4. Log a message that the server is being restarted (including the server name)
146+
147+
### Execution Context and Environment Variables
148+
149+
!!! warning "Environment Variable Visibility"
150+
The `mcpd` process can only see environment variables that existed when it started.
151+
152+
If you export new environment variables in your shell after starting `mcpd`, you must restart the daemon for those variables to become available for shell expansion.
153+
154+
When the execution context file is reloaded, shell expansion of environment variables (`${VAR}` syntax)
155+
occurs using the environment available to the running `mcpd` process when it was started.
156+
157+
#### What Works During Hot Reload
158+
159+
Direct values are applied immediately:
160+
161+
```toml
162+
[servers.jira]
163+
args = ["--confluence-token=test123", "--confluence-url=http://jira-test.mozilla.ai"]
164+
[servers.mcp-discord.env]
165+
DISCORD_TOKEN = "qwerty123!1one"
166+
```
167+
168+
Shell expansion of existing environment variables works:
169+
170+
```toml
171+
[servers.myserver]
172+
args = ["--home=${HOME}", "--user=${USER}"] # These expand to current values
173+
[servers.myserver.env]
174+
CONFIG_PATH = "${HOME}/.config/myapp" # Expands using mcpd's environment
175+
```
176+
177+
#### What Requires an `mcpd` Restart
178+
179+
New environment variables added to the system after `mcpd` started won't be visible:
180+
181+
```toml
182+
[servers.myserver]
183+
args = ["--token=${NEW_TOKEN}"] # NEW_TOKEN added after mcpd started
184+
[servers.myserver.env]
185+
API_KEY = "${NEWLY_EXPORTED_VAR}" # Won't expand until mcpd restarts
186+
```
187+
188+
### Limitations
189+
190+
191+
Hot reload does **NOT** apply to:
192+
193+
- Daemon-level config settings (timeouts, CORS, etc.)
194+
- New environment variables added to the system
195+
196+
Both require `mcpd` to be restarted for changes to take effect
197+
198+
### Error Handling
199+
200+
The reload process maintains strict consistency - any error causes the daemon to exit:
201+
202+
- **Configuration errors**: Invalid configuration files or loading failures cause the daemon to exit
203+
- **Validation errors**: Invalid server configurations cause the daemon to exit
204+
- **Server operation failures**: Any failure to start, stop, or restart a server causes the daemon to exit
205+
206+
This ensures the daemon never runs in an inconsistent or partially-failed state, matching the behavior during initial startup where any server failure prevents the daemon from running.
207+
208+
!!! warning "Reload Failures"
209+
Unlike some systems that allow partial reloads, `mcpd` exits on any reload error to prevent inconsistent state. You'll need to fix the configuration and restart the daemon.
210+
211+
---
212+
68213
## Configuration Export
69214

70215
The `mcpd config export` command generates portable configuration files for deployment across different environments. It creates template variables using the naming pattern `MCPD__{SERVER_NAME}__{VARIABLE_NAME}`.
@@ -83,7 +228,7 @@ Environment variables and command-line arguments are both converted to template
83228

84229
In most cases, this is intentional, the same configuration value is being used in different ways. The collision results in a single template variable that can be used for both the environment variable and command-line argument.
85230

86-
#### Example collision
231+
#### Example Collision
87232

88233
```toml
89234
[[servers]]

internal/api/servers_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"context"
5+
"fmt"
56
"testing"
67

78
"github.com/mark3labs/mcp-go/client"
@@ -48,6 +49,14 @@ func (m *mockMCPClientAccessor) List() []string {
4849
return names
4950
}
5051

52+
func (m *mockMCPClientAccessor) UpdateTools(name string, tools []string) error {
53+
if _, ok := m.clients[name]; !ok {
54+
return fmt.Errorf("server '%s' not found", name)
55+
}
56+
m.tools[name] = tools
57+
return nil
58+
}
59+
5160
func (m *mockMCPClientAccessor) Remove(name string) {
5261
delete(m.clients, name)
5362
delete(m.tools, name)

internal/config/types.go

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"slices"
45
"strings"
56

67
"github.com/mozilla-ai/mcpd/v2/internal/context"
@@ -118,29 +119,24 @@ type serverKey struct {
118119
Package string // NOTE: without version
119120
}
120121

121-
func (e *ServerEntry) PackageVersion() string {
122+
// argEntry represents a parsed command line argument.
123+
type argEntry struct {
124+
key string
125+
value string
126+
}
127+
128+
func (s *ServerEntry) PackageVersion() string {
122129
versionDelim := "@"
123-
pkg := stripPrefix(e.Package)
130+
pkg := stripPrefix(s.Package)
124131

125132
if idx := strings.LastIndex(pkg, versionDelim); idx != -1 {
126133
return pkg[idx+len(versionDelim):]
127134
}
128135
return pkg
129136
}
130137

131-
func (e *ServerEntry) PackageName() string {
132-
return stripPrefix(stripVersion(e.Package))
133-
}
134-
135-
// argEntry represents a parsed command line argument.
136-
type argEntry struct {
137-
key string
138-
value string
139-
}
140-
141-
// hasValue is used to determine if an argEntry is a bool flag or contains a value.
142-
func (e *argEntry) hasValue() bool {
143-
return strings.TrimSpace(e.value) != ""
138+
func (s *ServerEntry) PackageName() string {
139+
return stripPrefix(stripVersion(s.Package))
144140
}
145141

146142
func (e *argEntry) String() string {
@@ -152,13 +148,102 @@ func (e *argEntry) String() string {
152148

153149
// RequiredArguments returns all required CLI arguments, including positional, value-based and boolean flags.
154150
// NOTE: The order of these arguments matters, so positional arguments appear first.
155-
func (e *ServerEntry) RequiredArguments() []string {
156-
out := make([]string, 0, len(e.RequiredPositionalArgs)+len(e.RequiredValueArgs)+len(e.RequiredBoolArgs))
151+
func (s *ServerEntry) RequiredArguments() []string {
152+
out := make([]string, 0, len(s.RequiredPositionalArgs)+len(s.RequiredValueArgs)+len(s.RequiredBoolArgs))
157153

158154
// Add positional args first.
159-
out = append(out, e.RequiredPositionalArgs...)
160-
out = append(out, e.RequiredValueArgs...)
161-
out = append(out, e.RequiredBoolArgs...)
155+
out = append(out, s.RequiredPositionalArgs...)
156+
out = append(out, s.RequiredValueArgs...)
157+
out = append(out, s.RequiredBoolArgs...)
162158

163159
return out
164160
}
161+
162+
// Equal compares two ServerEntry instances for equality.
163+
// Returns true if all fields are equal.
164+
// RequiredPositionalArgs order matters (positional), all other slices are order-independent.
165+
func (s *ServerEntry) Equal(other *ServerEntry) bool {
166+
if other == nil {
167+
return false
168+
}
169+
170+
// Compare basic fields.
171+
if s.Name != other.Name {
172+
return false
173+
}
174+
175+
if s.Package != other.Package {
176+
return false
177+
}
178+
179+
// RequiredPositionalArgs order matters since they're positional.
180+
if !slices.Equal(s.RequiredPositionalArgs, other.RequiredPositionalArgs) {
181+
return false
182+
}
183+
184+
// All other slices are flags, so order doesn't matter.
185+
// NOTE: We are assuming that tools are always already normalized, ready for comparison.
186+
if !equalStringSlicesUnordered(s.Tools, other.Tools) {
187+
return false
188+
}
189+
190+
if !equalStringSlicesUnordered(s.RequiredEnvVars, other.RequiredEnvVars) {
191+
return false
192+
}
193+
194+
if !equalStringSlicesUnordered(s.RequiredValueArgs, other.RequiredValueArgs) {
195+
return false
196+
}
197+
198+
if !equalStringSlicesUnordered(s.RequiredBoolArgs, other.RequiredBoolArgs) {
199+
return false
200+
}
201+
202+
return true
203+
}
204+
205+
// EqualExceptTools compares this server with another and returns true if only the Tools field differs.
206+
// All other configuration fields must be identical for this to return true.
207+
func (s *ServerEntry) EqualExceptTools(other *ServerEntry) bool {
208+
if other == nil {
209+
return false
210+
}
211+
212+
// Create copies with identical Tools to compare everything else.
213+
a := s
214+
b := other
215+
216+
// Temporarily set tools to be identical for comparison.
217+
bTools := b.Tools
218+
b.Tools = a.Tools
219+
220+
// If everything else is equal, then only tools differ.
221+
equalIgnoringTools := a.Equal(b)
222+
223+
// Restore original tools.
224+
b.Tools = bTools
225+
226+
// Return true only if everything else is equal AND tools actually differ.
227+
// NOTE: We are assuming that tools are always already normalized, ready for comparison.
228+
return equalIgnoringTools && !equalStringSlicesUnordered(s.Tools, other.Tools)
229+
}
230+
231+
// equalStringSlicesUnordered compares two string slices for equality, ignoring order.
232+
func equalStringSlicesUnordered(a []string, b []string) bool {
233+
if len(a) != len(b) {
234+
return false
235+
}
236+
237+
x := slices.Clone(a)
238+
y := slices.Clone(b)
239+
240+
slices.Sort(x)
241+
slices.Sort(y)
242+
243+
return slices.Equal(x, y)
244+
}
245+
246+
// hasValue is used to determine if an argEntry is a bool flag or contains a value.
247+
func (e *argEntry) hasValue() bool {
248+
return strings.TrimSpace(e.value) != ""
249+
}

0 commit comments

Comments
 (0)