Skip to content

Commit a2e8ee2

Browse files
authored
Add heath check endpoints (#59)
* Add heath check endpoints * Add API endpoints for /health/servers and /health/servers/{server_name} * Refactor API and server code and move to Chi mux * Added health tracker and refactored pinging all servers code * Text tweak to search command flag text * Tweak upcoming command name
1 parent 219caf6 commit a2e8ee2

File tree

10 files changed

+987
-254
lines changed

10 files changed

+987
-254
lines changed

cmd/search.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ func NewSearchCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Comman
7575
&c.License,
7676
"license",
7777
"",
78-
"Optional, specify the license of the server package (e.g. MIT, Apache)",
78+
"Optional, specify a partial match for the license of the server package (e.g. MIT, Apache)",
7979
)
8080

8181
cobraCommand.Flags().StringVar(
@@ -89,14 +89,14 @@ func NewSearchCmd(baseCmd *cmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Comman
8989
&c.Tags,
9090
"tag",
9191
nil,
92-
"Optional, specify tags to filter the search results (can be repeated)",
92+
"Optional, specify a partial match for required tags (can be repeated)",
9393
)
9494

9595
cobraCommand.Flags().StringArrayVar(
9696
&c.Categories,
9797
"category",
9898
nil,
99-
"Optional, specify categories to filter the search results (can be repeated)",
99+
"Optional, specify a partial match for required categories (can be repeated)",
100100
)
101101

102102
return cobraCommand, nil

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Traditional agent frameworks often embed complex subprocess logic, brittle start
4848
|------------------------------------------------------------------|------------------------------------|---------------------------------------------|
4949
| `.mcpd.toml` | Version-controlled agent tool spec | Declarative IaC for tools |
5050
| `mcpd daemon` | Run everything locally | Run in container alongside your agentic app |
51-
| `mcpd config export-env` | Discover required vars | Populate CI/CD pipelines |
51+
| `mcpd config export` | Discover required vars | Populate CI/CD pipelines |
5252
| Secure secrets store | Local overrides per dev | Injected via Control Plane |
5353
| Same binary | Local builds | Cloud deployment |
5454
| [SDKs for Python](https://github.com/mozilla-ai/mcpd-sdk-python) | Iterate locally | Plug into prod orchestrators |

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ go 1.24.4
44

55
require (
66
github.com/BurntSushi/toml v1.5.0
7+
github.com/go-chi/chi/v5 v5.2.2
78
github.com/hashicorp/go-hclog v1.6.3
89
github.com/mark3labs/mcp-go v0.31.0
910
github.com/spf13/cobra v1.9.1
@@ -20,9 +21,11 @@ require (
2021
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2122
github.com/mattn/go-colorable v0.1.12 // indirect
2223
github.com/mattn/go-isatty v0.0.14 // indirect
24+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
2325
github.com/pmezard/go-difflib v1.0.0 // indirect
2426
github.com/russross/blackfriday/v2 v2.1.0 // indirect
2527
github.com/spf13/cast v1.7.1 // indirect
2628
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
27-
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 // indirect
29+
golang.org/x/sys v0.18.0 // indirect
30+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
2831
)

go.sum

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
99
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
1010
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
1111
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
12+
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
13+
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
1214
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
1315
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1416
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -19,6 +21,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
1921
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2022
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
2123
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
24+
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
25+
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
2226
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
2327
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
2428
github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
@@ -29,6 +33,8 @@ github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb
2933
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
3034
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
3135
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
36+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
37+
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
3238
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
3339
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
3440
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -51,9 +57,11 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w
5157
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
5258
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
5359
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
54-
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6 h1:nonptSpoQ4vQjyraW20DXPAglgQfVnM9ZC6MmNLMR60=
5560
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
56-
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
61+
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
62+
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
5763
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
64+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
65+
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5866
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
5967
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/daemon/daemon.go

Lines changed: 112 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package daemon
33
import (
44
"bufio"
55
"context"
6+
"errors"
67
"fmt"
78
"io"
89
"net"
@@ -31,8 +32,9 @@ type Daemon struct {
3132
apiServer *ApiServer
3233
logger hclog.Logger
3334
clientManager *ClientManager
35+
healthTracker *HealthTracker
3436
supportedRuntimes map[runtime.Runtime]struct{}
35-
cfgLoader config.Loader
37+
runtimeCfg []runtime.Server
3638
}
3739

3840
func NewDaemon(logger hclog.Logger, cfgLoader config.Loader, apiAddr string) (*Daemon, error) {
@@ -46,48 +48,36 @@ func NewDaemon(logger hclog.Logger, cfgLoader config.Loader, apiAddr string) (*D
4648
return nil, fmt.Errorf("invalid api address '%s': %w", apiAddr, err)
4749
}
4850

49-
clientManager := NewClientManager()
51+
// Load config.
52+
cfg, err := loadConfig(cfgLoader)
53+
if err != nil {
54+
return nil, err
55+
}
5056

51-
apiServer, err := NewApiServer(logger, clientManager, apiAddr)
57+
var serverNames []string
58+
for _, r := range cfg {
59+
serverNames = append(serverNames, r.Name)
60+
}
61+
62+
healthTracker := NewHealthTracker(serverNames)
63+
clientManager := NewClientManager()
64+
apiServer, err := NewApiServer(logger, clientManager, healthTracker, apiAddr)
5265
if err != nil {
5366
return nil, fmt.Errorf("failed to create daemon API server: %w", err)
5467
}
5568

5669
return &Daemon{
5770
logger: logger.Named("daemon"),
5871
clientManager: clientManager,
72+
healthTracker: healthTracker,
5973
apiServer: apiServer,
6074
supportedRuntimes: runtime.DefaultSupportedRuntimes(),
61-
cfgLoader: cfgLoader,
75+
runtimeCfg: cfg,
6276
}, nil
6377
}
6478

65-
func (d *Daemon) LoadConfig() ([]runtime.Server, error) {
66-
cfgPath := flags.ConfigFile
67-
cfg, err := d.cfgLoader.Load(cfgPath)
68-
if err != nil {
69-
return nil, err
70-
}
71-
72-
// Use the home directory to load the execution context config data (for now).
73-
home, err := os.UserHomeDir()
74-
if err != nil {
75-
return nil, fmt.Errorf("could not determine home directory: %w", err)
76-
}
77-
executionCtxPath := filepath.Join(home, ".mcpd", "secrets.dev.toml")
78-
execCtx, err := configcontext.LoadExecutionContextConfig(executionCtxPath)
79-
if err != nil {
80-
return nil, err
81-
}
82-
83-
return runtime.AggregateConfigs(cfg, execCtx)
84-
}
85-
8679
func (d *Daemon) StartAndManage(ctx context.Context) error {
87-
runtimeCfg, err := d.LoadConfig()
88-
if err != nil {
89-
return err
90-
}
80+
runtimeCfg := d.runtimeCfg
9181

9282
d.logger.Info(fmt.Sprintf("loaded config for %d daemon(s)", len(runtimeCfg)))
9383
fmt.Println(fmt.Sprintf("Attempting to start %d MCP server(s)", len(runtimeCfg)))
@@ -221,7 +211,7 @@ func (d *Daemon) launchServer(ctx context.Context, server runtime.Server, wg *sy
221211
initializeCtx,
222212
mcp.InitializeRequest{
223213
Params: mcp.InitializeParams{
224-
ProtocolVersion: "latest",
214+
ProtocolVersion: mcp.LATEST_PROTOCOL_VERSION,
225215
ClientInfo: mcp.Implementation{Name: cmd.AppName(), Version: cmd.Version()},
226216
},
227217
})
@@ -240,52 +230,6 @@ func (d *Daemon) launchServer(ctx context.Context, server runtime.Server, wg *sy
240230
return nil
241231
}
242232

243-
// parseAndLogMCPMessage parses a log line from the MCP server's stderr and logs it with the corresponding level.
244-
func parseAndLogMCPMessage(logger hclog.Logger, line string) {
245-
trimmed := strings.TrimSpace(line)
246-
if trimmed == "" {
247-
return
248-
}
249-
250-
// TODO: This format may change based on the runtime that spawned the MCP Server.
251-
// Attempt to parse the log format: LEVEL:LOGGER:MESSAGE.
252-
parts := strings.SplitN(trimmed, ":", 3)
253-
254-
if len(parts) < 2 {
255-
logger.Info(trimmed)
256-
return
257-
}
258-
259-
lvl := normalizeLogLevel(parts[0])
260-
message := parts[len(parts)-1]
261-
262-
if lvl == hclog.NoLevel {
263-
logger.Info(trimmed)
264-
return
265-
}
266-
267-
if lvl >= logger.GetLevel() {
268-
// The level is valid and at or above our logger's configured level.
269-
logger.Log(lvl, message)
270-
}
271-
272-
// Either no logging (off) or a level we're not configured to log at.
273-
return
274-
}
275-
276-
func normalizeLogLevel(level string) hclog.Level {
277-
level = strings.TrimSpace(strings.ToLower(level))
278-
279-
switch level {
280-
case "warning":
281-
return hclog.Warn // Normalize to warn
282-
case "fatal", "critical":
283-
return hclog.Error // Normalize to error
284-
default:
285-
return hclog.LevelFromString(level)
286-
}
287-
}
288-
289233
func (d *Daemon) healthCheckLoop(
290234
ctx context.Context,
291235
interval time.Duration,
@@ -320,13 +264,32 @@ func (d *Daemon) pingAllServers(ctx context.Context, timeout time.Duration) {
320264
pingCtx, cancel := context.WithTimeout(ctx, timeout)
321265
defer cancel()
322266

323-
if err := mcpClient.Ping(pingCtx); err != nil {
324-
d.logger.Error(fmt.Sprintf("Error pinging MCP server: '%s'", name), "error", err)
325-
return
267+
start := time.Now()
268+
err := mcpClient.Ping(pingCtx)
269+
duration := time.Since(start)
270+
271+
status := HealthStatusUnknown
272+
var latency *time.Duration
273+
274+
switch {
275+
case err == nil:
276+
status = HealthStatusOK
277+
latency = &duration
278+
d.logger.Debug("Ping successful", "server", name, "latency", duration)
279+
case errors.Is(err, context.DeadlineExceeded):
280+
status = HealthStatusTimeout
281+
d.logger.Error("Ping timed out", "server", name, "error", err)
282+
case errors.Is(err, context.Canceled):
283+
status = HealthStatusTimeout
284+
d.logger.Warn("Ping context canceled", "server", name)
285+
default:
286+
status = HealthStatusUnreachable
287+
d.logger.Error("Ping unreachable", "server", name, "error", err)
326288
}
327289

328-
// TODO: Store health state for servers, and expose HTTP API route for /heath
329-
d.logger.Debug("Ping successful", "server", name)
290+
if updateErr := d.healthTracker.Update(name, status, latency); updateErr != nil {
291+
d.logger.Error("Failed to record health", "server", name, "error", updateErr)
292+
}
330293
}(name, c)
331294
}
332295
}
@@ -354,3 +317,71 @@ func IsValidAddr(addr string) error {
354317

355318
return nil
356319
}
320+
321+
// parseAndLogMCPMessage parses a log line from the MCP server's stderr and logs it with the corresponding level.
322+
func parseAndLogMCPMessage(logger hclog.Logger, line string) {
323+
trimmed := strings.TrimSpace(line)
324+
if trimmed == "" {
325+
return
326+
}
327+
328+
// TODO: This format may change based on the runtime that spawned the MCP Server.
329+
// Attempt to parse the log format: LEVEL:LOGGER:MESSAGE.
330+
parts := strings.SplitN(trimmed, ":", 3)
331+
332+
if len(parts) < 2 {
333+
logger.Info(trimmed)
334+
return
335+
}
336+
337+
lvl := normalizeLogLevel(parts[0])
338+
message := parts[len(parts)-1]
339+
340+
if lvl == hclog.NoLevel {
341+
logger.Info(trimmed)
342+
return
343+
}
344+
345+
if lvl >= logger.GetLevel() {
346+
// The level is valid and at or above our logger's configured level.
347+
logger.Log(lvl, message)
348+
}
349+
350+
// Either no logging (off) or a level we're not configured to log at.
351+
return
352+
}
353+
354+
func normalizeLogLevel(level string) hclog.Level {
355+
level = strings.TrimSpace(strings.ToLower(level))
356+
357+
switch level {
358+
case "warning":
359+
return hclog.Warn // Normalize to warn
360+
case "fatal", "critical":
361+
return hclog.Error // Normalize to error
362+
default:
363+
return hclog.LevelFromString(level)
364+
}
365+
}
366+
367+
func loadConfig(cfgLoader config.Loader) ([]runtime.Server, error) {
368+
cfgPath := flags.ConfigFile
369+
370+
cfg, err := cfgLoader.Load(cfgPath)
371+
if err != nil {
372+
return nil, err
373+
}
374+
375+
// Use the home directory to load the execution context config data (for now).
376+
home, err := os.UserHomeDir()
377+
if err != nil {
378+
return nil, fmt.Errorf("could not determine home directory: %w", err)
379+
}
380+
executionCtxPath := filepath.Join(home, ".mcpd", "secrets.dev.toml")
381+
execCtx, err := configcontext.LoadExecutionContextConfig(executionCtxPath)
382+
if err != nil {
383+
return nil, err
384+
}
385+
386+
return runtime.AggregateConfigs(cfg, execCtx)
387+
}

internal/daemon/errors.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package daemon
2+
3+
import "errors"
4+
5+
var (
6+
ErrBadRequest = errors.New("bad request")
7+
ErrServerNotFound = errors.New("server not found")
8+
ErrToolsNotFound = errors.New("tools not found")
9+
ErrToolForbidden = errors.New("tool not allowed")
10+
ErrToolListFailed = errors.New("tool list failed")
11+
ErrToolCallFailed = errors.New("tool call failed")
12+
ErrToolCallFailedUnknown = errors.New("tool call failed (unknown error)")
13+
ErrHealthNotTracked = errors.New("server health is not being tracked")
14+
)

0 commit comments

Comments
 (0)