5e0417306b
Refresh called alive() with the manager lock held, so a slow health check blocked every other server. It now snapshots the candidates and asks outside the lock. A server that cannot be dialled was retried every minute forever, which for a misconfigured stdio block means re-exec'ing a process 1440 times a day. Dials now back off from one minute to thirty. An allow_private fetcher followed redirects. A LAN MCP endpoint could answer a POST with a redirect to 169.254.169.254 and the guard would go there, because allow_private is what turns the address check off. Redirects are refused outright on that door. The tool catalogue was trimmed by taking the first max_tools entries of whatever order the server sent, so the server chose which of its tools Maven proposed. Over the cap without allow_tools now contributes nothing: refusing is honest, silently keeping the server's pick is not. Descriptions are server-written text that lands in the router prompt and on /tools, so they are capped too. A server block with enabled false was skipped by validation, so a typo in a block written dark surfaced only on the day it was switched on. All blocks are shape-checked now. Configured static headers carry the bearer token a real remote server needs, and host_interval bounds how fast one endpoint is polled. Found in review of #70.
57 lines
2.0 KiB
Go
57 lines
2.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/kami/maven/internal/webfetch"
|
|
)
|
|
|
|
// WebfetchDoor builds the PosterFactory used in production: one guarded
|
|
// webfetch.Fetcher per url server, with that server's allow_private and the
|
|
// shared host lists and limits.
|
|
//
|
|
// One fetcher PER server is the point. allow_private is a hole in the
|
|
// private-address guard, and a hole punched for the Vikunja server on loopback
|
|
// must not become a hole for some public endpoint that happens to redirect at
|
|
// the LAN. Rate limiting is per fetcher too, which is the right shape here:
|
|
// separate servers are separate hosts.
|
|
//
|
|
// A server WITH allow_private also gets redirects switched off. Across servers
|
|
// the per-fetcher split holds the line; within the one server that has the
|
|
// flag it did not, because allow_private disables the dialer guard on every
|
|
// hop: http://localhost:9100/mcp answering 302 to
|
|
// http://169.254.169.254/latest/meta-data/ was followed, up to MaxRedirects. A
|
|
// local MCP endpoint has no business redirecting, so refusing costs nothing.
|
|
func WebfetchDoor(limits webfetch.Config) PosterFactory {
|
|
return func(cfg ServerConfig) (Poster, error) {
|
|
c := limits
|
|
c.AllowPrivate = cfg.AllowPrivate
|
|
if cfg.AllowPrivate {
|
|
c.MaxRedirects = -1 // negative ⇒ no redirects followed
|
|
}
|
|
if c.Timeout <= 0 && cfg.Timeout > 0 {
|
|
c.Timeout = cfg.Timeout
|
|
}
|
|
return fetcherPoster{webfetch.New(c)}, nil
|
|
}
|
|
}
|
|
|
|
// fetcherPoster adapts webfetch.Fetcher to Poster. It exists so this package
|
|
// does not have to know webfetch's Response type, and so a test can substitute
|
|
// a fake without a listener.
|
|
type fetcherPoster struct{ f *webfetch.Fetcher }
|
|
|
|
func (p fetcherPoster) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
|
resp, err := p.f.Post(ctx, rawURL, contentType, body, hdr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: post %s: %w", rawURL, err)
|
|
}
|
|
return &PostResponse{
|
|
Status: resp.Status,
|
|
ContentType: resp.ContentType,
|
|
Body: resp.Body,
|
|
Header: resp.Header,
|
|
}, nil
|
|
}
|