Skip to main content

max / makenotwork

kberg: add BoundServer::wait, serve-config helpers, and test suite Adds the park-point a standalone MCP binary needs (BoundServer::wait), ServeConfig::granting/with_projection ergonomics, and 12 tests covering capability gating, compact projection, refusals, and an HTTP round-trip. First consumers are go-mcp and spag-mcp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 18:55 UTC
Commit: 81ffb5c9847ea253c36c9a2f49ea722d8733323e
Parent: 4bf6179
5 files changed, +371 insertions, -7 deletions
@@ -32,3 +32,4 @@ tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros
32 32 [dev-dependencies]
33 33 tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
34 34 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
35 + reqwest = { version = "0.12", features = ["json"] }
@@ -40,5 +40,5 @@ pub use tool::{
40 40 #[cfg(feature = "server")]
41 41 pub mod server;
42 42
43 - #[cfg(any(feature = "ollama"))]
43 + #[cfg(feature = "ollama")]
44 44 pub mod provider;
@@ -47,6 +47,23 @@ impl Default for ServeConfig {
47 47 }
48 48 }
49 49
50 + impl ServeConfig {
51 + /// Full surface, honoring exactly the given write-capability ids. The
52 + /// common shape for a standalone binary that knows which capabilities its
53 + /// driving session should hold.
54 + pub fn granting(ids: impl IntoIterator<Item = impl Into<String>>) -> Self {
55 + Self {
56 + projection: SurfaceProjection::Full,
57 + grants: Some(ids.into_iter().map(Into::into).collect()),
58 + }
59 + }
60 +
61 + pub fn with_projection(mut self, projection: SurfaceProjection) -> Self {
62 + self.projection = projection;
63 + self
64 + }
65 + }
66 +
50 67 /// Handle to a running MCP server.
51 68 ///
52 69 /// Dropping this does *not* stop the server — call [`BoundServer::shutdown`]
@@ -65,6 +82,20 @@ impl BoundServer {
65 82 pub fn shutdown(self) {
66 83 self.handle.abort();
67 84 }
85 +
86 + /// Block until the server task finishes.
87 + ///
88 + /// A standalone MCP binary calls this after [`serve`] to keep the process
89 + /// alive for as long as the server is listening. The task only ends on an
90 + /// unrecoverable listener error (or an external abort), so under normal
91 + /// operation this future never resolves — it is the binary's park point.
92 + pub async fn wait(self) -> Result<()> {
93 + match self.handle.await {
94 + Ok(io_result) => io_result.map_err(Error::from),
95 + Err(join_err) if join_err.is_cancelled() => Ok(()),
96 + Err(join_err) => Err(Error::Transport(join_err.to_string())),
97 + }
98 + }
68 99 }
69 100
70 101 #[derive(Clone)]
@@ -266,11 +266,10 @@ impl ToolRegistry {
266 266 let mut seen = HashSet::new();
267 267 let mut out = Vec::new();
268 268 for tool in self.tools.values() {
269 - if let ToolKind::Write(cap) = tool.kind() {
270 - if seen.insert(cap.id.clone()) {
269 + if let ToolKind::Write(cap) = tool.kind()
270 + && seen.insert(cap.id.clone()) {
271 271 out.push(cap);
272 272 }
273 - }
274 273 }
275 274 out
276 275 }
@@ -288,15 +287,163 @@ impl ToolRegistry {
288 287 .get(name)
289 288 .ok_or_else(|| Error::ToolNotFound(name.to_string()))?;
290 289
291 - if let (ToolKind::Write(cap), Some(grants)) = (tool.kind(), grants) {
292 - if !grants.contains(&cap.id) {
290 + if let (ToolKind::Write(cap), Some(grants)) = (tool.kind(), grants)
291 + && !grants.contains(&cap.id) {
293 292 return Err(Error::CapabilityDenied {
294 293 tool: name.to_string(),
295 294 capability: cap.id,
296 295 });
297 296 }
298 - }
299 297
300 298 tool.call(args).await
301 299 }
302 300 }
301 +
302 + #[cfg(test)]
303 + mod tests {
304 + use super::*;
305 + use serde_json::json;
306 +
307 + struct Echo;
308 + #[async_trait]
309 + impl Tool for Echo {
310 + fn name(&self) -> &str {
311 + "echo"
312 + }
313 + fn description(&self) -> &str {
314 + "read-only echo"
315 + }
316 + fn kind(&self) -> ToolKind {
317 + ToolKind::Read
318 + }
319 + fn small_model_safe(&self) -> bool {
320 + true
321 + }
322 + fn input_schema(&self) -> Value {
323 + json!({ "type": "object", "properties": {} })
324 + }
325 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
326 + Ok(ToolCallResult::text("ok"))
327 + }
328 + }
329 +
330 + struct Mutate;
331 + #[async_trait]
332 + impl Tool for Mutate {
333 + fn name(&self) -> &str {
334 + "mutate"
335 + }
336 + fn description(&self) -> &str {
337 + "write, gated on `test.write`"
338 + }
339 + fn kind(&self) -> ToolKind {
340 + ToolKind::Write(WriteCapability::new("test.write", "mutate test state"))
341 + }
342 + fn input_schema(&self) -> Value {
343 + json!({ "type": "object", "properties": {} })
344 + }
345 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
346 + Ok(ToolCallResult::text("mutated"))
347 + }
348 + }
349 +
350 + fn registry() -> ToolRegistry {
351 + let mut r = ToolRegistry::new();
352 + r.register(Echo);
353 + r.register(Mutate);
354 + r.register(Refusal::new(
355 + "delete_everything",
356 + "not offered",
357 + "destructive bulk ops are never exposed",
358 + ));
359 + r
360 + }
361 +
362 + fn grants(ids: &[&str]) -> HashSet<String> {
363 + ids.iter().map(|s| s.to_string()).collect()
364 + }
365 +
366 + #[tokio::test]
367 + async fn read_is_callable_without_grants() {
368 + let r = registry();
369 + let out = r.call("echo", json!({}), Some(&HashSet::new())).await.unwrap();
370 + assert!(!out.is_error);
371 + }
372 +
373 + #[tokio::test]
374 + async fn write_denied_without_grant() {
375 + let r = registry();
376 + let err = r.call("mutate", json!({}), Some(&HashSet::new())).await.unwrap_err();
377 + match err {
378 + Error::CapabilityDenied { capability, .. } => assert_eq!(capability, "test.write"),
379 + other => panic!("expected CapabilityDenied, got {other:?}"),
380 + }
381 + }
382 +
383 + #[tokio::test]
384 + async fn write_allowed_with_grant() {
385 + let r = registry();
386 + let out = r
387 + .call("mutate", json!({}), Some(&grants(&["test.write"])))
388 + .await
389 + .unwrap();
390 + assert_eq!(out.content.len(), 1);
391 + assert!(!out.is_error);
392 + }
393 +
394 + #[tokio::test]
395 + async fn none_grants_bypasses_the_check() {
396 + let r = registry();
397 + let out = r.call("mutate", json!({}), None).await.unwrap();
398 + assert!(!out.is_error);
399 + }
400 +
401 + #[tokio::test]
402 + async fn unknown_tool_is_not_found() {
403 + let r = registry();
404 + let err = r.call("nope", json!({}), None).await.unwrap_err();
405 + assert!(matches!(err, Error::ToolNotFound(_)));
406 + }
407 +
408 + #[tokio::test]
409 + async fn refusal_surfaces_in_specs_and_errors_on_call() {
410 + let r = registry();
411 + let spec = r
412 + .specs()
413 + .into_iter()
414 + .find(|s| s.name == "delete_everything")
415 + .expect("refusal appears in tools/list");
416 + assert!(spec.refusal_reason.is_some());
417 +
418 + let err = r.call("delete_everything", json!({}), None).await.unwrap_err();
419 + assert!(matches!(err, Error::Refused { .. }));
420 + }
421 +
422 + #[test]
423 + fn compact_projection_filters_to_small_model_safe() {
424 + let r = registry();
425 + let compact: Vec<_> = r
426 + .specs_projected(SurfaceProjection::Compact)
427 + .into_iter()
428 + .map(|s| s.name)
429 + .collect();
430 + assert!(compact.contains(&"echo".to_string()));
431 + assert!(!compact.contains(&"mutate".to_string()));
432 + }
433 +
434 + #[test]
435 + fn write_capabilities_are_deduped() {
436 + let r = registry();
437 + let caps = r.write_capabilities();
438 + assert_eq!(caps.len(), 1);
439 + assert_eq!(caps[0].id, "test.write");
440 + }
441 +
442 + #[test]
443 + #[should_panic(expected = "duplicate tool registration")]
444 + fn duplicate_registration_panics() {
445 + let mut r = ToolRegistry::new();
446 + r.register(Echo);
447 + r.register(Echo);
448 + }
449 + }
@@ -0,0 +1,185 @@
1 + //! End-to-end tests for the Streamable HTTP MCP server: drive a real bound
2 + //! server over HTTP with a plain `reqwest` client (no `Agent`), exercising
3 + //! `initialize`, `tools/list` (both projections), and `tools/call` across the
4 + //! read / write-granted / write-denied / refusal / not-found paths.
5 +
6 + #![cfg(feature = "server")]
7 +
8 + use std::collections::HashSet;
9 +
10 + use async_trait::async_trait;
11 + use kberg::server::{self, ServeConfig};
12 + use kberg::{
13 + Refusal, Result, SurfaceProjection, Tool, ToolCallResult, ToolKind, ToolRegistry,
14 + WriteCapability,
15 + };
16 + use serde_json::{Value, json};
17 +
18 + struct Ping;
19 + #[async_trait]
20 + impl Tool for Ping {
21 + fn name(&self) -> &str {
22 + "ping"
23 + }
24 + fn description(&self) -> &str {
25 + "read-only ping"
26 + }
27 + fn kind(&self) -> ToolKind {
28 + ToolKind::Read
29 + }
30 + fn small_model_safe(&self) -> bool {
31 + true
32 + }
33 + fn input_schema(&self) -> Value {
34 + json!({ "type": "object", "properties": {} })
35 + }
36 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
37 + Ok(ToolCallResult::text("pong"))
38 + }
39 + }
40 +
41 + struct Save;
42 + #[async_trait]
43 + impl Tool for Save {
44 + fn name(&self) -> &str {
45 + "save"
46 + }
47 + fn description(&self) -> &str {
48 + "write, gated on `demo.save`"
49 + }
50 + fn kind(&self) -> ToolKind {
51 + ToolKind::Write(WriteCapability::new("demo.save", "persist a value"))
52 + }
53 + fn input_schema(&self) -> Value {
54 + json!({ "type": "object", "properties": { "v": { "type": "string" } } })
55 + }
56 + async fn call(&self, args: Value) -> Result<ToolCallResult> {
57 + let v = args.get("v").and_then(Value::as_str).unwrap_or("");
58 + Ok(ToolCallResult::text(format!("saved {v}")))
59 + }
60 + }
61 +
62 + fn registry() -> ToolRegistry {
63 + let mut r = ToolRegistry::new();
64 + r.register(Ping);
65 + r.register(Save);
66 + r.register(Refusal::new("nuke", "not offered", "never exposed"));
67 + r
68 + }
69 +
70 + async fn rpc(client: &reqwest::Client, url: &str, method: &str, params: Value) -> Value {
71 + client
72 + .post(url)
73 + .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }))
74 + .send()
75 + .await
76 + .unwrap()
77 + .json()
78 + .await
79 + .unwrap()
80 + }
81 +
82 + #[tokio::test]
83 + async fn full_surface_round_trip() {
84 + let bound = server::serve(
85 + registry(),
86 + "127.0.0.1:0",
87 + ServeConfig::granting(["demo.save"]),
88 + )
89 + .await
90 + .unwrap();
91 + let url = bound.url();
92 + let client = reqwest::Client::new();
93 +
94 + // initialize
95 + let init = rpc(&client, &url, "initialize", Value::Null).await;
96 + assert_eq!(init["result"]["serverInfo"]["name"], "kberg");
97 + assert!(init["result"]["protocolVersion"].is_string());
98 +
99 + // tools/list — full surface has all three, with kind/refusal extensions
100 + let list = rpc(&client, &url, "tools/list", Value::Null).await;
101 + let tools = list["result"]["tools"].as_array().unwrap();
102 + assert_eq!(tools.len(), 3);
103 + let save = tools.iter().find(|t| t["name"] == "save").unwrap();
104 + assert_eq!(save["kburgKind"], "write");
105 + assert_eq!(save["kburgCapability"]["id"], "demo.save");
106 + let nuke = tools.iter().find(|t| t["name"] == "nuke").unwrap();
107 + assert!(nuke["kburgRefusalReason"].is_string());
108 +
109 + // tools/call — read works
110 + let ping = rpc(&client, &url, "tools/call", json!({ "name": "ping", "arguments": {} })).await;
111 + assert_eq!(ping["result"]["content"][0]["text"], "pong");
112 +
113 + // tools/call — granted write works
114 + let save = rpc(
115 + &client,
116 + &url,
117 + "tools/call",
118 + json!({ "name": "save", "arguments": { "v": "x" } }),
119 + )
120 + .await;
121 + assert_eq!(save["result"]["content"][0]["text"], "saved x");
122 +
123 + // tools/call — refusal returns a JSON-RPC error, not a tool result
124 + let nuke = rpc(&client, &url, "tools/call", json!({ "name": "nuke", "arguments": {} })).await;
125 + assert!(nuke["error"].is_object());
126 +
127 + // tools/call — unknown tool errors
128 + let miss = rpc(&client, &url, "tools/call", json!({ "name": "ghost", "arguments": {} })).await;
129 + assert!(miss["error"].is_object());
130 +
131 + bound.shutdown();
132 + }
133 +
134 + #[tokio::test]
135 + async fn ungranted_write_is_denied_over_the_wire() {
136 + // Server started with an empty grant set: `save` must be refused.
137 + let bound = server::serve(
138 + registry(),
139 + "127.0.0.1:0",
140 + ServeConfig::granting(Vec::<String>::new()),
141 + )
142 + .await
143 + .unwrap();
144 + let url = bound.url();
145 + let client = reqwest::Client::new();
146 +
147 + let save = rpc(
148 + &client,
149 + &url,
150 + "tools/call",
151 + json!({ "name": "save", "arguments": { "v": "x" } }),
152 + )
153 + .await;
154 + let msg = save["error"]["message"].as_str().unwrap();
155 + assert!(msg.contains("demo.save"), "denial names the capability: {msg}");
156 + assert!(msg.contains("do not retry"), "denial tells small models not to retry: {msg}");
157 +
158 + bound.shutdown();
159 + }
160 +
161 + #[tokio::test]
162 + async fn compact_projection_hides_write_tools() {
163 + let bound = server::serve(
164 + registry(),
165 + "127.0.0.1:0",
166 + ServeConfig::granting(["demo.save"]).with_projection(SurfaceProjection::Compact),
167 + )
168 + .await
169 + .unwrap();
170 + let url = bound.url();
171 + let client = reqwest::Client::new();
172 +
173 + let list = rpc(&client, &url, "tools/list", Value::Null).await;
174 + let names: HashSet<String> = list["result"]["tools"]
175 + .as_array()
176 + .unwrap()
177 + .iter()
178 + .map(|t| t["name"].as_str().unwrap().to_string())
179 + .collect();
180 + assert!(names.contains("ping"));
181 + assert!(!names.contains("save"));
182 + assert!(!names.contains("nuke"));
183 +
184 + bound.shutdown();
185 + }