forked from daltoniam/switchboard
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmcp.go
More file actions
287 lines (249 loc) · 9.81 KB
/
Copy pathmcp.go
File metadata and controls
287 lines (249 loc) · 9.81 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
package mcp
import (
"context"
"encoding/json"
"errors"
"fmt"
"path"
"strconv"
"time"
)
// ErrNotConfigured is returned when an integration is used before being configured.
var ErrNotConfigured = errors.New("integration not configured")
// ErrUnhealthy is returned when an integration cannot reach its upstream API.
var ErrUnhealthy = errors.New("integration unhealthy")
// RetryableError signals that an operation failed with a transient error (5xx, 429)
// and should be retried. The server layer retries these automatically with backoff.
// Adapters return this from their HTTP helpers; non-retryable errors (4xx) use plain errors.
type RetryableError struct {
StatusCode int
Err error
RetryAfter time.Duration // server-suggested wait; 0 means use default backoff
}
func (e *RetryableError) Error() string {
return fmt.Sprintf("retryable (%d): %s", e.StatusCode, e.Err)
}
func (e *RetryableError) Unwrap() error { return e.Err }
// IsRetryable reports whether err (or any error in its chain) is a RetryableError.
func IsRetryable(err error) bool {
var re *RetryableError
return errors.As(err, &re)
}
const maxRetryAfter = 60 * time.Second
// ParseRetryAfter parses a Retry-After header value (integer seconds) into a Duration.
// Returns 0 for empty, non-numeric, or non-positive values. Caps at 60s.
// Does not handle HTTP-date format (RFC 7231 §7.1.3) — all known upstream APIs use
// integer seconds.
func ParseRetryAfter(header string) time.Duration {
if header == "" {
return 0
}
secs, err := strconv.Atoi(header)
if err != nil || secs <= 0 {
return 0
}
d := time.Duration(secs) * time.Second
if d > maxRetryAfter {
return maxRetryAfter
}
return d
}
// Credentials holds key-value credential pairs for an integration.
type Credentials map[string]string
// IntegrationConfig stores the enabled state and credentials for a single integration.
type IntegrationConfig struct {
Enabled bool `json:"enabled"`
Credentials Credentials `json:"credentials"`
ToolGlobs []string `json:"tool_globs,omitempty"`
}
// ToolAllowed reports whether toolName is permitted by the integration's tool glob
// restrictions. An empty ToolGlobs slice means all tools are permitted.
// Multiple globs are OR'd: the tool is allowed if any glob matches.
// Invalid patterns are skipped (use ValidateToolGlobs to catch them at config time).
func (ic *IntegrationConfig) ToolAllowed(toolName string) bool {
if len(ic.ToolGlobs) == 0 {
return true
}
for _, pattern := range ic.ToolGlobs {
matched, err := path.Match(pattern, toolName)
if err != nil {
continue
}
if matched {
return true
}
}
return false
}
// ValidateToolGlobs checks that all tool glob patterns are syntactically valid.
// Returns an error naming the first invalid pattern.
func ValidateToolGlobs(globs []string) error {
for _, pattern := range globs {
if _, err := path.Match(pattern, ""); err != nil {
return fmt.Errorf("invalid tool glob pattern %q: %w", pattern, err)
}
}
return nil
}
// WasmModuleConfig describes a WASM module to load as an integration.
type WasmModuleConfig struct {
Path string `json:"path"`
Credentials Credentials `json:"credentials,omitempty"`
}
// Config is the top-level configuration containing all integrations.
type Config struct {
Integrations map[string]*IntegrationConfig `json:"integrations"`
WasmModules []WasmModuleConfig `json:"wasm_modules,omitempty"`
}
// ToolDefinition describes an API operation an integration exposes.
// These are used by the search tool to let the AI discover available operations.
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]string `json:"parameters"` // param name -> description
Required []string `json:"required,omitempty"`
}
// ToolResult is the output of executing a tool.
type ToolResult struct {
Data string `json:"data,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// JSONResult marshals v to JSON and returns it as a ToolResult.
func JSONResult(v any) (*ToolResult, error) {
data, err := json.Marshal(v)
if err != nil {
return &ToolResult{Data: err.Error(), IsError: true}, nil
}
return &ToolResult{Data: string(data)}, nil
}
// RawResult wraps already-serialized JSON bytes as a ToolResult.
// Passing nil is equivalent to passing an empty slice — returns an empty, non-error result.
func RawResult(data []byte) (*ToolResult, error) {
return &ToolResult{Data: string(data)}, nil
}
// ErrResult converts an error to a ToolResult.
// Retryable errors are propagated as Go errors for the server retry loop.
// Non-retryable errors become ToolResult with IsError=true.
func ErrResult(err error) (*ToolResult, error) {
if err == nil {
return nil, nil
}
if IsRetryable(err) {
return nil, err
}
return &ToolResult{Data: err.Error(), IsError: true}, nil
}
// HealthStatus represents the health of an integration.
type HealthStatus struct {
Name string `json:"name"`
Healthy bool `json:"healthy"`
Error string `json:"error,omitempty"`
}
// --- Port Interfaces (the hexagonal boundaries) ---
// Integration is the primary port that all integration adapters implement.
// The domain defines what it needs; adapter packages satisfy it.
type Integration interface {
// Name returns the lowercase integration identifier (e.g., "github", "datadog").
Name() string
// Configure initializes the integration with credentials.
Configure(ctx context.Context, creds Credentials) error
// Tools returns the tool definitions this integration provides.
// Used by the search tool for progressive discovery.
Tools() []ToolDefinition
// Execute runs a named tool with the given arguments and returns the result.
Execute(ctx context.Context, toolName string, args map[string]any) (*ToolResult, error)
// Healthy returns true if the integration can reach its upstream API.
Healthy(ctx context.Context) bool
}
// FieldCompactionIntegration is an optional interface that integrations can implement
// to declare field compaction specs for tool responses. The server applies
// field compaction automatically after Execute, reducing context usage for LLM consumers.
type FieldCompactionIntegration interface {
// CompactSpec returns pre-parsed field compaction specs for a tool.
// Returns false if the tool has no specs (skip compaction).
// Adapters should parse specs once at init time via ParseCompactSpecs.
CompactSpec(toolName string) ([]CompactField, bool)
}
// PlainTextCredentials is an optional interface that integrations can implement
// to declare which credential keys should be rendered as plain text inputs
// instead of password fields in the web UI.
type PlainTextCredentials interface {
PlainTextKeys() []string
}
// PlaceholderHints is an optional interface that integrations can implement
// to provide custom placeholder text for credential input fields in the web UI.
type PlaceholderHints interface {
Placeholders() map[string]string
}
// OptionalCredentials is an optional interface that integrations can implement
// to declare which credential keys are not required, so the web UI can label them.
type OptionalCredentials interface {
OptionalKeys() []string
}
// ConfigService manages loading and saving configuration.
type ConfigService interface {
Load() error
Save() error
Get() *Config
Update(cfg *Config) error
GetIntegration(name string) (*IntegrationConfig, bool)
SetIntegration(name string, ic *IntegrationConfig) error
EnabledIntegrations() []string
DefaultCredentialKeys(name string) []string
}
// Registry holds all registered integrations and provides lookup.
type Registry interface {
Register(i Integration) error
Get(name string) (Integration, bool)
All() []Integration
Names() []string
}
// Services aggregates all port interfaces — the dependency injection container.
type Services struct {
Config ConfigService
Registry Registry
Browser BrowserService // nil if playwright driver is not installed
Metrics *Metrics // nil until initialized; callers must nil-check
}
// BrowserService manages browser lifecycle for web automation.
// Pass via integration constructors — never exposed as MCP tools.
type BrowserService interface {
NewSession(ctx context.Context) (BrowserSession, error)
Close() error
}
// BrowserCookie represents a browser cookie for injection into a BrowserSession.
type BrowserCookie struct {
Name string
Value string
Domain string
Path string
Secure bool
HTTPOnly bool
Expires *time.Time
}
// BrowserSession is an isolated browser context (own cookies, local storage).
// One session per integration; pages within the same session share auth state.
// AddCookies should be called before navigating any pages — cookies injected
// after the first navigation may not apply to already-loaded page contexts.
type BrowserSession interface {
AddCookies(ctx context.Context, cookies []BrowserCookie) error
NewPage(ctx context.Context) (BrowserPage, error)
Close() error
}
// BrowserPage is a single browser tab.
// Note: context.Context parameters are accepted for API consistency and future-proofing,
// but the underlying playwright-go driver does not support context cancellation.
// Long-running calls (Navigate, WaitForSelector) will not be interrupted by ctx.Done().
type BrowserPage interface {
Navigate(ctx context.Context, url string) error
Fill(ctx context.Context, selector, value string) error
Click(ctx context.Context, selector string) error
SelectOption(ctx context.Context, selector, value string) error
InnerText(ctx context.Context, selector string) (string, error)
InnerHTML(ctx context.Context, selector string) (string, error)
Content(ctx context.Context) (string, error)
WaitForSelector(ctx context.Context, selector string) error
Screenshot(ctx context.Context) ([]byte, error)
Evaluate(ctx context.Context, expression string, args ...any) (any, error)
Close() error
}