Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
331 lines (297 loc) · 12 KB
/
Copy pathmain.go
File metadata and controls
331 lines (297 loc) · 12 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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
// Command example is a whole MCP server on mcpkit: the Streamable HTTP
// transport, the handshake, a catalogue of markdown served as resources and
// prompts, one tool, api-key authentication, rate limiting, an audit record per
// call and the RFC 9728 metadata document — on net/http, with no config file
// and no web framework.
//
// go run ./example -api-key demo-token
// curl -s localhost:8075/mcp -H 'Authorization: Bearer demo-token' \
// -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
package main
//go:generate go tool zenrpc
import (
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"flag"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/vmkteam/mcpkit"
"github.com/vmkteam/mcpkit/audit"
"github.com/vmkteam/mcpkit/auth"
"github.com/vmkteam/mcpkit/doc"
"github.com/vmkteam/mcpkit/mcp"
"github.com/vmkteam/mcpkit/mcptool"
"github.com/vmkteam/mcpkit/ratelimit"
"github.com/vmkteam/embedlog"
"github.com/vmkteam/zenrpc/v2"
)
// The documents this server serves. A real service ships its own tree; what it
// says is the service's business, which is why the library carries no texts of
// its own.
//
//go:embed md
var docFS embed.FS
const uriScheme = "docs://"
func main() {
addr := flag.String("addr", ":8075", "listen address")
apiKey := flag.String("api-key", "", "plaintext api key; empty means no authentication")
issuer := flag.String("issuer", "", "OIDC issuer for the protected-resource document; empty means no document")
baseURL := flag.String("base-url", "http://localhost:8075", "public base URL of this server")
flag.Parse()
log := embedlog.NewLogger(true, false)
docs, err := doc.Load(docFS, "md", doc.Options{URIScheme: uriScheme})
if err != nil {
log.Errorf("load docs: %v", err)
return
}
h, stop := newMCP(log, docs, *apiKey, localHosts(*addr))
defer stop()
mux := http.NewServeMux()
mux.Handle("/mcp", h)
mux.Handle("/.well-known/oauth-protected-resource", auth.ProtectedResource{
Resource: *baseURL + "/mcp",
Issuer: *issuer,
}.Handler())
// This file gets copied into services, so the timeouts are here rather than
// left to the zero value: without WriteTimeout and IdleTimeout a stuck
// client holds a connection until the process dies.
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 2 * time.Minute,
}
ctx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stopSignals()
go func() {
log.Print(ctx, "mcp server", "addr", *addr, "auth", *apiKey != "")
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Errorf("listen: %v", err)
stopSignals()
}
}()
<-ctx.Done()
// In-flight calls finish; a tool call cut in half writes an audit record
// that says nothing about what happened to the work.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Errorf("shutdown: %v", err)
}
}
// newMCP assembles the endpoint: the namespaces, authentication, the limiter
// and the audit, in the order they have to be in. The returned function stops
// the limiter's background work.
//
// It is separate from main so that a test can build exactly what production
// runs — see main_test.go. An example nobody can run against is an example
// nobody can check.
func newMCP(log embedlog.Logger, docs *doc.Library, apiKey string, hosts []string) (http.Handler, func()) {
// One record per call, masked. The library does not know what this server is
// about, so what goes beside the core fields is the service's to decide.
auditor := audit.NewWriter(log, audit.Options{})
tools := mcptool.NewRegistry(helloTool{}).With(mcptool.WithCallHook(nil,
func(ctx context.Context, name string, res mcp.ToolCallResult, elapsed time.Duration) {
decision := audit.DecisionAllow
if res.IsError {
decision = audit.DecisionDeny
}
subject := ""
if p, ok := auth.PrincipalFromContext(ctx); ok {
subject = p.UserID
}
auditor.Write(ctx, audit.Record{
Subject: subject, Tool: name, Source: "example",
Decision: decision, BytesOut: res.Size(), Duration: elapsed,
})
}))
// No AllowCORS: zenrpc reads that option inside its own ServeHTTP, and the
// transport calls zsrv.Do() directly — so the option would switch nothing on
// while claiming a browser client works. This server has none; a browser
// request is refused by the Origin check instead.
zsrv := zenrpc.NewServer(zenrpc.Options{})
deps := mcpkit.InitDeps{
// Name and Version are the identity; the rest is what a client has to
// show a human. All of it is self-reported — display and logging, never
// a security decision.
Info: mcp.ServerInfo{
Name: "mcpkit-example",
Version: "0.1.0",
Title: "mcpkit example",
Description: "One tool, one document, one prompt — the shape of a service built on mcpkit.",
WebsiteURL: "https://github.com/vmkteam/mcpkit",
},
// Declare exactly the namespaces registered below. A capability the
// server announces is one it promises to answer: declaring prompts
// without registering them sends the client to a -32601.
Capabilities: mcp.Capabilities{
Tools: &mcp.ToolsCapability{},
Resources: &mcp.ResourcesCapability{},
Prompts: &mcp.PromptsCapability{},
},
Instructions: "An example server. It has one tool, it says hello, and it writes down that it did.",
}
zsrv.RegisterAll(map[string]zenrpc.Invoker{
// Both eras on one endpoint: initialize for the clients that still open
// with a handshake, server/discover for the ones that no longer do.
"": mcpkit.NewInitService(deps),
mcpkit.NamespaceServer: mcpkit.NewDiscoverService(deps),
mcpkit.NamespaceResources: mcpkit.NewResourcesService(docs),
mcpkit.NamespacePrompts: mcpkit.NewPromptsService(docs),
mcpkit.NamespaceTools: ToolsService{registry: tools},
})
keys := auth.NewStoreWithOptions(apiKeys(apiKey), auth.StoreOptions{Realm: "example-mcp"})
limiter := ratelimit.New(ratelimit.Config{
PerUserRPM: 60,
PerUserConcurrent: 4,
GlobalConcurrent: 16,
CostBudgetPerHour: 10 * time.Minute,
})
// Authentication on the outside, so the limiter sees Principal.UserID; the
// other order buckets every caller as anonymous.
//
// AllowedHosts is spelt out rather than left empty because this file gets
// copied into services and this is the one server that runs with nothing in
// front of it. On a developer's loopback there is no nginx to check the
// Host, and a page at evil.com whose name has just been re-resolved to
// 127.0.0.1 reaches this port as a same-origin request — carrying no Origin
// at all, so the origin check above never sees it. A deployment behind a
// proxy lists its own name here or leaves the list empty.
var h http.Handler = mcpkit.NewServerWithOptions(zsrv, log, mcpkit.Options{
AllowedHosts: hosts,
})
h = limiter.Middleware(h, log)
h = keys.Middleware(h, log)
return h, limiter.Stop
}
// localHosts is the set of Host values a browser can reach a loopback server
// under. It is derived from the listen address rather than hard-coded so that
// -addr and the check cannot disagree.
//
// A real deployment lists the name it is published under instead.
func localHosts(addr string) []string {
_, port, err := net.SplitHostPort(addr)
if err != nil || port == "" {
return nil // an address we cannot read is not one to guess about
}
return []string{
net.JoinHostPort("localhost", port),
net.JoinHostPort("127.0.0.1", port),
net.JoinHostPort("::1", port),
}
}
func apiKeys(plaintext string) []auth.Key {
if plaintext == "" {
return nil // empty store — no authentication, which is a dev-only mode
}
sum := sha256.Sum256([]byte(plaintext))
return []auth.Key{{
UserID: "demo",
KeyHash: hex.EncodeToString(sum[:]),
Groups: []string{"demo-users"},
}}
}
// ToolsService is what stays in a service once the registry is the library's:
// the zenrpc namespace, and nothing else.
type ToolsService struct {
zenrpc.Service
registry *mcptool.Registry
}
// List answers tools/list with the tools this caller may see.
//
// RPCError is what makes an invalid cursor a -32602 rather than the -32603
// zenrpc gives any plain error: the registry does not know what zenrpc is, so
// the translation happens here, where the namespace does.
//
//zenrpc:cursor nextCursor from the previous page; empty for the first
func (s ToolsService) List(ctx context.Context, cursor string) (mcp.ToolList, error) {
list, err := s.registry.List(ctx, cursor)
return list, mcpkit.RPCError("tools.list", err)
}
// Call dispatches tools/call to the named tool.
//
//zenrpc:name name of the tool to invoke
//zenrpc:arguments tool-specific arguments
func (s ToolsService) Call(ctx context.Context, name string, arguments map[string]any) (mcp.ToolCallResult, error) {
return s.registry.Call(ctx, name, arguments)
}
// helloArgs is what the hello tool takes. The json tags are the whole
// description of it: the schema is reflected from this struct.
type helloArgs struct {
Who string `json:"who,omitempty" jsonschema_description:"Who to greet; defaults to the caller."`
}
// helloResult is what the tool answers with. Declaring the shape is what lets a
// client validate the answer instead of parsing a string and hoping.
type helloResult struct {
Greeting string `json:"greeting" jsonschema_description:"The greeting, addressed to whoever asked."`
}
// Both schemas are reflected once, at startup — tools/list is on the request
// path, and reflection there would be paid for on every call.
var (
helloSchema = mcp.SchemaFor(helloArgs{})
helloOutput = mcp.SchemaFor(helloResult{})
)
// helloTool is the domain of this server, such as it is.
type helloTool struct{}
// Name is the identity of the tool and never changes.
func (helloTool) Name() string { return "hello" }
// Describe is asked on every tools/list. This one is visible to everybody; a
// real tool answers false for a caller whose role does not include it.
func (helloTool) Describe(context.Context) (mcp.Tool, bool) {
return mcp.Tool{
Name: "hello",
Title: "Say hello",
Description: "Greets whoever is asking. Read " + uriScheme + "hello.md for what this server is.",
InputSchema: helloSchema,
OutputSchema: helloOutput,
Annotations: &mcp.ToolAnnotations{
Title: "Say hello",
ReadOnlyHint: new(true),
// Spelt out rather than left unset: the spec default for these two is
// true, and a read-only tool that says nothing is treated as a tool
// that may destroy something and reach the open world.
DestructiveHint: new(false),
OpenWorldHint: new(false),
},
}, true
}
// Call does the work. A refusal carries the schema as its hint: an error is the
// documentation a caller reads at the moment they need it.
func (helloTool) Call(ctx context.Context, args map[string]any) mcp.ToolCallResult {
var a helloArgs
if err := mcp.DecodeArgs(args, &a); err != nil {
return mcptool.ErrorResult(mcptool.Error{
Code: "E_BAD_ARGS",
Message: err.Error(),
Hint: helloSchema, // the schema is already json.RawMessage
})
}
if a.Who == "" {
if p, ok := auth.PrincipalFromContext(ctx); ok {
a.Who = p.UserID
}
}
if a.Who == "" {
a.Who = "stranger"
}
// A tool that does work upstream says what it cost and where it went: the
// budget is spent by it, and app_mcp_ratelimit_charge_seconds is broken
// down by the label. The label names a target from the service's own
// catalogue, never a value from the request. A greeting costs next to
// nothing; the call is here to show where a real tool puts it.
start := time.Now()
greeting := "hello, " + a.Who
ratelimit.ChargeFor(ctx, "greeter", time.Since(start))
// A struct rather than a map: the same type the output schema was reflected
// from, so the answer cannot drift from what the tool promised.
return mcptool.OKResult(helloResult{Greeting: greeting})
}