Skip to main content

max / makenotwork

kberg: initial MCP bridge crate for LLM/desktop-app tool routing v1 primitives: Tool + ToolRegistry with Read/Write(WriteCapability) classification, Streamable HTTP MCP server (JSON-RPC 2.0), provider-agnostic Agent + InferenceProvider, Ollama reference provider, Refusal, SurfaceProjection::Compact, and an end-to-end toy example. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 15:59 UTC
Commit: 344486ca4bb2cc6708f7e0125f3d602a634a25fa
Parent: e65630c
13 files changed, +1847 insertions, -0 deletions
@@ -0,0 +1,1682 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "aho-corasick"
7 + version = "1.1.4"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10 + dependencies = [
11 + "memchr",
12 + ]
13 +
14 + [[package]]
15 + name = "async-trait"
16 + version = "0.1.89"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
19 + dependencies = [
20 + "proc-macro2",
21 + "quote",
22 + "syn",
23 + ]
24 +
25 + [[package]]
26 + name = "atomic-waker"
27 + version = "1.1.2"
28 + source = "registry+https://github.com/rust-lang/crates.io-index"
29 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
30 +
31 + [[package]]
32 + name = "axum"
33 + version = "0.8.9"
34 + source = "registry+https://github.com/rust-lang/crates.io-index"
35 + checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
36 + dependencies = [
37 + "axum-core",
38 + "bytes",
39 + "form_urlencoded",
40 + "futures-util",
41 + "http",
42 + "http-body",
43 + "http-body-util",
44 + "hyper",
45 + "hyper-util",
46 + "itoa",
47 + "matchit",
48 + "memchr",
49 + "mime",
50 + "percent-encoding",
51 + "pin-project-lite",
52 + "serde_core",
53 + "serde_json",
54 + "serde_path_to_error",
55 + "serde_urlencoded",
56 + "sync_wrapper",
57 + "tokio",
58 + "tower",
59 + "tower-layer",
60 + "tower-service",
61 + "tracing",
62 + ]
63 +
64 + [[package]]
65 + name = "axum-core"
66 + version = "0.5.6"
67 + source = "registry+https://github.com/rust-lang/crates.io-index"
68 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
69 + dependencies = [
70 + "bytes",
71 + "futures-core",
72 + "http",
73 + "http-body",
74 + "http-body-util",
75 + "mime",
76 + "pin-project-lite",
77 + "sync_wrapper",
78 + "tower-layer",
79 + "tower-service",
80 + "tracing",
81 + ]
82 +
83 + [[package]]
84 + name = "base64"
85 + version = "0.22.1"
86 + source = "registry+https://github.com/rust-lang/crates.io-index"
87 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
88 +
89 + [[package]]
90 + name = "bitflags"
91 + version = "2.13.0"
92 + source = "registry+https://github.com/rust-lang/crates.io-index"
93 + checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
94 +
95 + [[package]]
96 + name = "bumpalo"
97 + version = "3.20.3"
98 + source = "registry+https://github.com/rust-lang/crates.io-index"
99 + checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
100 +
101 + [[package]]
102 + name = "bytes"
103 + version = "1.12.0"
104 + source = "registry+https://github.com/rust-lang/crates.io-index"
105 + checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
106 +
107 + [[package]]
108 + name = "cc"
109 + version = "1.2.66"
110 + source = "registry+https://github.com/rust-lang/crates.io-index"
111 + checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
112 + dependencies = [
113 + "find-msvc-tools",
114 + "shlex",
115 + ]
116 +
117 + [[package]]
118 + name = "cfg-if"
119 + version = "1.0.4"
120 + source = "registry+https://github.com/rust-lang/crates.io-index"
121 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
122 +
123 + [[package]]
124 + name = "core-foundation"
125 + version = "0.9.4"
126 + source = "registry+https://github.com/rust-lang/crates.io-index"
127 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
128 + dependencies = [
129 + "core-foundation-sys",
130 + "libc",
131 + ]
132 +
133 + [[package]]
134 + name = "core-foundation"
135 + version = "0.10.1"
136 + source = "registry+https://github.com/rust-lang/crates.io-index"
137 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
138 + dependencies = [
139 + "core-foundation-sys",
140 + "libc",
141 + ]
142 +
143 + [[package]]
144 + name = "core-foundation-sys"
145 + version = "0.8.7"
146 + source = "registry+https://github.com/rust-lang/crates.io-index"
147 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
148 +
149 + [[package]]
150 + name = "displaydoc"
151 + version = "0.2.6"
152 + source = "registry+https://github.com/rust-lang/crates.io-index"
153 + checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
154 + dependencies = [
155 + "proc-macro2",
156 + "quote",
157 + "syn",
158 + ]
159 +
160 + [[package]]
161 + name = "encoding_rs"
162 + version = "0.8.35"
163 + source = "registry+https://github.com/rust-lang/crates.io-index"
164 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
165 + dependencies = [
166 + "cfg-if",
167 + ]
168 +
169 + [[package]]
170 + name = "equivalent"
171 + version = "1.0.2"
172 + source = "registry+https://github.com/rust-lang/crates.io-index"
173 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
174 +
175 + [[package]]
176 + name = "errno"
177 + version = "0.3.14"
178 + source = "registry+https://github.com/rust-lang/crates.io-index"
179 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
180 + dependencies = [
181 + "libc",
182 + "windows-sys 0.61.2",
183 + ]
184 +
185 + [[package]]
186 + name = "fastrand"
187 + version = "2.4.1"
188 + source = "registry+https://github.com/rust-lang/crates.io-index"
189 + checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
190 +
191 + [[package]]
192 + name = "find-msvc-tools"
193 + version = "0.1.9"
194 + source = "registry+https://github.com/rust-lang/crates.io-index"
195 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
196 +
197 + [[package]]
198 + name = "fnv"
199 + version = "1.0.7"
200 + source = "registry+https://github.com/rust-lang/crates.io-index"
201 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
202 +
203 + [[package]]
204 + name = "foreign-types"
205 + version = "0.3.2"
206 + source = "registry+https://github.com/rust-lang/crates.io-index"
207 + checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
208 + dependencies = [
209 + "foreign-types-shared",
210 + ]
211 +
212 + [[package]]
213 + name = "foreign-types-shared"
214 + version = "0.1.1"
215 + source = "registry+https://github.com/rust-lang/crates.io-index"
216 + checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
217 +
218 + [[package]]
219 + name = "form_urlencoded"
220 + version = "1.2.2"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
223 + dependencies = [
224 + "percent-encoding",
225 + ]
226 +
227 + [[package]]
228 + name = "futures-channel"
229 + version = "0.3.32"
230 + source = "registry+https://github.com/rust-lang/crates.io-index"
231 + checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
232 + dependencies = [
233 + "futures-core",
234 + ]
235 +
236 + [[package]]
237 + name = "futures-core"
238 + version = "0.3.32"
239 + source = "registry+https://github.com/rust-lang/crates.io-index"
240 + checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
241 +
242 + [[package]]
243 + name = "futures-sink"
244 + version = "0.3.32"
245 + source = "registry+https://github.com/rust-lang/crates.io-index"
246 + checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
247 +
248 + [[package]]
249 + name = "futures-task"
250 + version = "0.3.32"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
253 +
254 + [[package]]
255 + name = "futures-util"
256 + version = "0.3.32"
257 + source = "registry+https://github.com/rust-lang/crates.io-index"
258 + checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
259 + dependencies = [
260 + "futures-core",
261 + "futures-task",
262 + "pin-project-lite",
263 + "slab",
264 + ]
265 +
266 + [[package]]
267 + name = "getrandom"
268 + version = "0.2.17"
269 + source = "registry+https://github.com/rust-lang/crates.io-index"
270 + checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
271 + dependencies = [
272 + "cfg-if",
273 + "libc",
274 + "wasi",
275 + ]
276 +
277 + [[package]]
278 + name = "getrandom"
279 + version = "0.4.3"
280 + source = "registry+https://github.com/rust-lang/crates.io-index"
281 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
282 + dependencies = [
283 + "cfg-if",
284 + "libc",
285 + "r-efi",
286 + ]
287 +
288 + [[package]]
289 + name = "h2"
290 + version = "0.4.15"
291 + source = "registry+https://github.com/rust-lang/crates.io-index"
292 + checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
293 + dependencies = [
294 + "atomic-waker",
295 + "bytes",
296 + "fnv",
297 + "futures-core",
298 + "futures-sink",
299 + "http",
300 + "indexmap",
301 + "slab",
302 + "tokio",
303 + "tokio-util",
304 + "tracing",
305 + ]
306 +
307 + [[package]]
308 + name = "hashbrown"
309 + version = "0.17.1"
310 + source = "registry+https://github.com/rust-lang/crates.io-index"
311 + checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
312 +
313 + [[package]]
314 + name = "http"
315 + version = "1.4.2"
316 + source = "registry+https://github.com/rust-lang/crates.io-index"
317 + checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
318 + dependencies = [
319 + "bytes",
320 + "itoa",
321 + ]
322 +
323 + [[package]]
324 + name = "http-body"
325 + version = "1.0.1"
326 + source = "registry+https://github.com/rust-lang/crates.io-index"
327 + checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
328 + dependencies = [
329 + "bytes",
330 + "http",
331 + ]
332 +
333 + [[package]]
334 + name = "http-body-util"
335 + version = "0.1.3"
336 + source = "registry+https://github.com/rust-lang/crates.io-index"
337 + checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
338 + dependencies = [
339 + "bytes",
340 + "futures-core",
341 + "http",
342 + "http-body",
343 + "pin-project-lite",
344 + ]
345 +
346 + [[package]]
347 + name = "httparse"
348 + version = "1.10.1"
349 + source = "registry+https://github.com/rust-lang/crates.io-index"
350 + checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
351 +
352 + [[package]]
353 + name = "httpdate"
354 + version = "1.0.3"
355 + source = "registry+https://github.com/rust-lang/crates.io-index"
356 + checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
357 +
358 + [[package]]
359 + name = "hyper"
360 + version = "1.10.1"
361 + source = "registry+https://github.com/rust-lang/crates.io-index"
362 + checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
363 + dependencies = [
364 + "atomic-waker",
365 + "bytes",
366 + "futures-channel",
367 + "futures-core",
368 + "h2",
369 + "http",
370 + "http-body",
371 + "httparse",
372 + "httpdate",
373 + "itoa",
374 + "pin-project-lite",
375 + "smallvec",
376 + "tokio",
377 + "want",
378 + ]
379 +
380 + [[package]]
381 + name = "hyper-rustls"
382 + version = "0.27.9"
383 + source = "registry+https://github.com/rust-lang/crates.io-index"
384 + checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
385 + dependencies = [
386 + "http",
387 + "hyper",
388 + "hyper-util",
389 + "rustls",
390 + "tokio",
391 + "tokio-rustls",
392 + "tower-service",
393 + ]
394 +
395 + [[package]]
396 + name = "hyper-tls"
397 + version = "0.6.0"
398 + source = "registry+https://github.com/rust-lang/crates.io-index"
399 + checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
400 + dependencies = [
401 + "bytes",
402 + "http-body-util",
403 + "hyper",
404 + "hyper-util",
405 + "native-tls",
406 + "tokio",
407 + "tokio-native-tls",
408 + "tower-service",
409 + ]
410 +
411 + [[package]]
412 + name = "hyper-util"
413 + version = "0.1.20"
414 + source = "registry+https://github.com/rust-lang/crates.io-index"
415 + checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
416 + dependencies = [
417 + "base64",
418 + "bytes",
419 + "futures-channel",
420 + "futures-util",
421 + "http",
422 + "http-body",
423 + "hyper",
424 + "ipnet",
425 + "libc",
426 + "percent-encoding",
427 + "pin-project-lite",
428 + "socket2",
429 + "system-configuration",
430 + "tokio",
431 + "tower-service",
432 + "tracing",
433 + "windows-registry",
434 + ]
435 +
436 + [[package]]
437 + name = "icu_collections"
438 + version = "2.2.0"
439 + source = "registry+https://github.com/rust-lang/crates.io-index"
440 + checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
441 + dependencies = [
442 + "displaydoc",
443 + "potential_utf",
444 + "utf8_iter",
445 + "yoke",
446 + "zerofrom",
447 + "zerovec",
448 + ]
449 +
450 + [[package]]
451 + name = "icu_locale_core"
452 + version = "2.2.0"
453 + source = "registry+https://github.com/rust-lang/crates.io-index"
454 + checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
455 + dependencies = [
456 + "displaydoc",
457 + "litemap",
458 + "tinystr",
459 + "writeable",
460 + "zerovec",
461 + ]
462 +
463 + [[package]]
464 + name = "icu_normalizer"
465 + version = "2.2.0"
466 + source = "registry+https://github.com/rust-lang/crates.io-index"
467 + checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
468 + dependencies = [
469 + "icu_collections",
470 + "icu_normalizer_data",
471 + "icu_properties",
472 + "icu_provider",
473 + "smallvec",
474 + "zerovec",
475 + ]
476 +
477 + [[package]]
478 + name = "icu_normalizer_data"
479 + version = "2.2.0"
480 + source = "registry+https://github.com/rust-lang/crates.io-index"
481 + checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
482 +
483 + [[package]]
484 + name = "icu_properties"
485 + version = "2.2.0"
486 + source = "registry+https://github.com/rust-lang/crates.io-index"
487 + checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
488 + dependencies = [
489 + "icu_collections",
490 + "icu_locale_core",
491 + "icu_properties_data",
492 + "icu_provider",
493 + "zerotrie",
494 + "zerovec",
495 + ]
496 +
497 + [[package]]
498 + name = "icu_properties_data"
499 + version = "2.2.0"
500 + source = "registry+https://github.com/rust-lang/crates.io-index"
Lines truncated
@@ -0,0 +1,34 @@
1 + [package]
2 + name = "kberg"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "MCP bridge for connecting local LLMs to desktop apps. Provider-agnostic agent loop over a Streamable HTTP MCP server."
6 + license = "MIT"
7 + readme = "README.md"
8 + keywords = ["mcp", "llm", "ollama", "tools", "agent"]
9 + categories = ["api-bindings", "development-tools"]
10 +
11 + [features]
12 + default = ["server", "ollama"]
13 + server = ["dep:axum", "dep:tokio", "dep:tower", "dep:tower-http"]
14 + ollama = ["dep:reqwest", "dep:tokio"]
15 +
16 + [dependencies]
17 + serde = { version = "1", features = ["derive"] }
18 + serde_json = "1"
19 + thiserror = "2"
20 + tracing = "0.1"
21 + async-trait = "0.1"
22 + uuid = { version = "1", features = ["v4", "serde"] }
23 +
24 + axum = { version = "0.8", optional = true }
25 + tower = { version = "0.5", optional = true }
26 + tower-http = { version = "0.6", optional = true, features = ["trace"] }
27 +
28 + reqwest = { version = "0.12", optional = true, features = ["json"] }
29 +
30 + tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
31 +
32 + [dev-dependencies]
33 + tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time"] }
34 + tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -0,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
@@ -0,0 +1,75 @@
1 + # Kberg
2 +
3 + MCP bridge for connecting local LLMs to desktop apps.
4 +
5 + Named after Königsberg — the city whose seven bridges seeded graph theory (Euler, 1736). The library is a bridge in that sense: it routes tool-call edges between an LLM node and a set of app nodes.
6 +
7 + ## Ships
8 +
9 + - `Tool` trait + `ToolRegistry` — the app-side surface.
10 + - Streamable HTTP MCP server (JSON-RPC 2.0) — how any MCP-speaking client reaches your app.
11 + - Provider-agnostic `Agent` + `InferenceProvider` trait — the tool-use loop is decoupled from the LLM backend.
12 + - `provider::ollama::OllamaProvider` — reference implementation for a local Ollama server.
13 +
14 + ## Scope
15 +
16 + Kberg is a *bridge*, not a model manager. You bring the inference endpoint (a local Ollama, LM Studio, vLLM, or a hosted API); Kberg wires it to the app-side tool surface. Future providers (OpenAI, Anthropic, OpenAI-compatible) drop in behind `InferenceProvider` without touching the agent loop.
17 +
18 + ## Guardrails
19 +
20 + Every tool is classified when it's registered:
21 +
22 + | Kind | Meaning |
23 + |---|---|
24 + | `Read` | No state change. Freely callable subject to app-level connection auth. |
25 + | `Write(WriteCapability)` | Mutates app state. Bound to a named capability, granted per session. |
26 +
27 + Writes are the finite, app-declared vocabulary of permissions the LLM can be given — webhook-style. A driver session holds a `HashSet<String>` of granted capability IDs; any write tool call whose capability is not in the set is refused with a stable, model-visible error.
28 +
29 + Capability IDs are a public contract the app owns. Renaming means a new capability + a deprecated old one — you do not silently reassign an existing id.
30 +
31 + ## v1 primitive matrix
32 +
33 + | Primitive | Status | Purpose |
34 + |---|---|---|
35 + | `Tool` + `ToolRegistry` | shipped | register app functions as MCP tools |
36 + | `ToolKind::Read / Write(capability)` | shipped | webhook-style write permissions |
37 + | Streamable HTTP MCP server | shipped | speak MCP over local HTTP |
38 + | `InferenceProvider` + `Agent` | shipped | provider-agnostic tool-use loop |
39 + | Ollama provider | shipped | drive tools from a local Ollama model |
40 + | Hard refusals (`Refusal`) | shipped | registered "not offered" tools with stable reason |
41 + | Compact surface projection | shipped | `SurfaceProjection::Compact` filters to `small_model_safe` tools |
42 + | Handles | planned | opaque, wire-safe entity IDs (never leak SHA-256 / row PKs to the model) |
43 + | Preview / commit | planned | two-step writes: dry-run returns a preview, commit executes |
44 + | Scope | planned | first-class scope arg (`book`, `library`, `inbox`) so small models don't re-specify it |
45 + | Human-confirm gating | planned | writes above app-defined thresholds return `pending_human_confirm(ticket)` |
46 + | Audit log | planned | every call recorded, especially writes |
47 + | Sidecar aggregator | planned | one driver, N apps, namespaced tool ids |
48 + | Additional providers | planned | OpenAI, Anthropic, generic OpenAI-compatible |
49 +
50 + ## Layout
51 +
52 + ```
53 + src/
54 + lib.rs
55 + error.rs
56 + tool.rs # Tool, ToolKind, WriteCapability, ToolRegistry, Refusal, SurfaceProjection
57 + agent.rs # Agent, InferenceProvider, Message, RunOutcome
58 + server/ # feature = "server"
59 + mod.rs
60 + protocol.rs
61 + provider/ # provider adapters (feature-gated)
62 + mod.rs
63 + ollama.rs # feature = "ollama"
64 + examples/
65 + toy.rs # end-to-end: register tools, serve, drive via Ollama
66 + ```
67 +
68 + ## Example
69 +
70 + ```
71 + ollama pull qwen2.5
72 + cargo run --example toy
73 + ```
74 +
75 + Override the model with `KBERG_MODEL=llama3.2 cargo run --example toy`.
@@ -0,0 +1,150 @@
1 + //! End-to-end example:
2 + //!
3 + //! - Register two tiny tools (`add` = Read, `remember_project` = Write) into a
4 + //! `ToolRegistry`.
5 + //! - Spin up an MCP server on a random localhost port so external clients
6 + //! (Claude Desktop, etc.) could connect.
7 + //! - Drive the same registry in-process via an `Agent<OllamaProvider>`, with
8 + //! the `remember_project` capability granted to the session.
9 + //!
10 + //! Run with a tool-calling model installed:
11 + //!
12 + //! ```
13 + //! ollama pull qwen2.5
14 + //! cargo run --example toy
15 + //! ```
16 + //!
17 + //! Override the model with `KBERG_MODEL=llama3.2 cargo run --example toy`.
18 +
19 + use std::collections::HashSet;
20 +
21 + use async_trait::async_trait;
22 + use kberg::{
23 + Agent, Error, Tool, ToolCallResult, ToolKind, ToolRegistry, WriteCapability,
24 + provider::ollama::OllamaProvider, server,
25 + };
26 + use serde_json::{Value, json};
27 +
28 + struct Add;
29 +
30 + #[async_trait]
31 + impl Tool for Add {
32 + fn name(&self) -> &str {
33 + "add"
34 + }
35 + fn description(&self) -> &str {
36 + "Add two integers and return the sum."
37 + }
38 + fn kind(&self) -> ToolKind {
39 + ToolKind::Read
40 + }
41 + fn small_model_safe(&self) -> bool {
42 + true
43 + }
44 + fn input_schema(&self) -> Value {
45 + json!({
46 + "type": "object",
47 + "properties": {
48 + "a": { "type": "integer" },
49 + "b": { "type": "integer" }
50 + },
51 + "required": ["a", "b"]
52 + })
53 + }
54 + async fn call(&self, args: Value) -> Result<ToolCallResult, Error> {
55 + let a = args.get("a").and_then(Value::as_i64).ok_or_else(|| Error::InvalidArgs {
56 + tool: "add".into(),
57 + message: "missing integer `a`".into(),
58 + })?;
59 + let b = args.get("b").and_then(Value::as_i64).ok_or_else(|| Error::InvalidArgs {
60 + tool: "add".into(),
61 + message: "missing integer `b`".into(),
62 + })?;
63 + Ok(ToolCallResult::text((a + b).to_string()))
64 + }
65 + }
66 +
67 + struct RememberProject;
68 +
69 + #[async_trait]
70 + impl Tool for RememberProject {
71 + fn name(&self) -> &str {
72 + "remember_project"
73 + }
74 + fn description(&self) -> &str {
75 + "Record a new project the user is working on."
76 + }
77 + fn kind(&self) -> ToolKind {
78 + ToolKind::Write(WriteCapability::new(
79 + "remember_project",
80 + "record new projects the user says they're working on",
81 + ))
82 + }
83 + fn input_schema(&self) -> Value {
84 + json!({
85 + "type": "object",
86 + "properties": { "name": { "type": "string" } },
87 + "required": ["name"]
88 + })
89 + }
90 + async fn call(&self, args: Value) -> Result<ToolCallResult, Error> {
91 + let name = args.get("name").and_then(Value::as_str).unwrap_or("<unnamed>");
92 + Ok(ToolCallResult::text(format!("recorded project `{name}`")))
93 + }
94 + }
95 +
96 + #[tokio::main]
97 + async fn main() -> Result<(), Box<dyn std::error::Error>> {
98 + tracing_subscriber::fmt()
99 + .with_env_filter(
100 + tracing_subscriber::EnvFilter::try_from_default_env()
101 + .unwrap_or_else(|_| "info,kberg=debug".into()),
102 + )
103 + .init();
104 +
105 + let mut registry = ToolRegistry::new();
106 + registry.register(Add);
107 + registry.register(RememberProject);
108 +
109 + // Bring up an MCP server so external clients could reach the same tools.
110 + let bound = server::serve(
111 + registry.clone(),
112 + "127.0.0.1:0",
113 + server::ServeConfig {
114 + projection: kberg::SurfaceProjection::Full,
115 + grants: Some(HashSet::from(["remember_project".to_string()])),
116 + },
117 + )
118 + .await?;
119 + println!("MCP server: {}", bound.url());
120 + println!("registered write capabilities: {:?}", registry.write_capabilities());
121 +
122 + let model = std::env::var("KBERG_MODEL").unwrap_or_else(|_| "qwen2.5".to_string());
123 + let provider = OllamaProvider::new(&model);
124 + let agent = Agent::new(provider)
125 + .with_grants(HashSet::from(["remember_project".to_string()]));
126 +
127 + let prompt = "What is 17 + 25? Also, remember that I'm working on a project called Kberg.";
128 +
129 + println!("\n> {prompt}\n");
130 + let outcome = agent
131 + .run(
132 + &registry,
133 + Some(
134 + "You are a terse assistant. Prefer tools over guessing. \
135 + When you have the answer, reply directly and briefly.",
136 + ),
137 + prompt,
138 + )
139 + .await?;
140 +
141 + println!(
142 + "--- final ({} step{}) ---",
143 + outcome.steps,
144 + if outcome.steps == 1 { "" } else { "s" }
145 + );
146 + println!("{}", outcome.final_message);
147 +
148 + bound.shutdown();
149 + Ok(())
150 + }
@@ -0,0 +1,214 @@
1 + //! Provider-agnostic agent loop.
2 + //!
3 + //! The [`Agent`] runs the tool-use loop: send `messages + tools` to an
4 + //! [`InferenceProvider`], dispatch any returned tool calls against a
5 + //! [`ToolRegistry`], feed the results back, repeat until the provider returns
6 + //! a plain assistant message or the step budget is exhausted.
7 + //!
8 + //! Providers (Ollama, and later OpenAI / Anthropic / OpenAI-compatible) only
9 + //! need to implement the small [`InferenceProvider`] trait; the loop is
10 + //! reused across all of them.
11 +
12 + use std::collections::HashSet;
13 +
14 + use async_trait::async_trait;
15 + use serde::{Deserialize, Serialize};
16 + use serde_json::Value;
17 +
18 + use crate::error::{Error, Result};
19 + use crate::tool::{SurfaceProjection, ToolRegistry, ToolSpec};
20 +
21 + /// A single turn in the conversation. Provider-neutral.
22 + #[derive(Debug, Clone, Serialize, Deserialize)]
23 + pub struct Message {
24 + pub role: Role,
25 + #[serde(default, skip_serializing_if = "Option::is_none")]
26 + pub content: Option<String>,
27 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
28 + pub tool_calls: Vec<ToolCall>,
29 + /// When `role == Tool`, the name of the tool that produced this result.
30 + #[serde(default, skip_serializing_if = "Option::is_none")]
31 + pub tool_name: Option<String>,
32 + }
33 +
34 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35 + #[serde(rename_all = "lowercase")]
36 + pub enum Role {
37 + System,
38 + User,
39 + Assistant,
40 + Tool,
41 + }
42 +
43 + /// A tool invocation requested by the model.
44 + #[derive(Debug, Clone, Serialize, Deserialize)]
45 + pub struct ToolCall {
46 + /// Provider-supplied ID, if any. Empty string if the provider doesn't emit one.
47 + #[serde(default)]
48 + pub id: String,
49 + pub name: String,
50 + #[serde(default)]
51 + pub arguments: Value,
52 + }
53 +
54 + impl Message {
55 + pub fn system(text: impl Into<String>) -> Self {
56 + Self {
57 + role: Role::System,
58 + content: Some(text.into()),
59 + tool_calls: Vec::new(),
60 + tool_name: None,
61 + }
62 + }
63 + pub fn user(text: impl Into<String>) -> Self {
64 + Self {
65 + role: Role::User,
66 + content: Some(text.into()),
67 + tool_calls: Vec::new(),
68 + tool_name: None,
69 + }
70 + }
71 + pub fn tool(name: impl Into<String>, content: impl Into<String>) -> Self {
72 + Self {
73 + role: Role::Tool,
74 + content: Some(content.into()),
75 + tool_calls: Vec::new(),
76 + tool_name: Some(name.into()),
77 + }
78 + }
79 + }
80 +
81 + /// Provider abstraction: given a conversation and a tool surface, return the
82 + /// next assistant message. Whether the message carries tool calls or a plain
83 + /// text turn is up to the model.
84 + #[async_trait]
85 + pub trait InferenceProvider: Send + Sync {
86 + async fn chat(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Message>;
87 + }
88 +
89 + /// Configuration for a [`Agent`] run.
90 + pub struct AgentConfig {
91 + pub max_steps: usize,
92 + pub projection: SurfaceProjection,
93 + /// Which write capabilities are granted for this session. `None` bypasses
94 + /// the check entirely — appropriate only for fully-trusted callers.
95 + pub grants: Option<HashSet<String>>,
96 + }
97 +
98 + impl Default for AgentConfig {
99 + fn default() -> Self {
100 + Self {
101 + max_steps: 8,
102 + projection: SurfaceProjection::Full,
103 + grants: Some(HashSet::new()),
104 + }
105 + }
106 + }
107 +
108 + pub struct Agent<P: InferenceProvider> {
109 + provider: P,
110 + config: AgentConfig,
111 + }
112 +
113 + impl<P: InferenceProvider> Agent<P> {
114 + pub fn new(provider: P) -> Self {
115 + Self {
116 + provider,
117 + config: AgentConfig::default(),
118 + }
119 + }
120 +
121 + pub fn with_max_steps(mut self, n: usize) -> Self {
122 + self.config.max_steps = n;
123 + self
124 + }
125 +
126 + pub fn with_projection(mut self, projection: SurfaceProjection) -> Self {
127 + self.config.projection = projection;
128 + self
129 + }
130 +
131 + pub fn with_grants(mut self, grants: HashSet<String>) -> Self {
132 + self.config.grants = Some(grants);
133 + self
134 + }
135 +
136 + /// Bypass capability checks. Only appropriate for fully-trusted callers.
137 + pub fn without_grant_checks(mut self) -> Self {
138 + self.config.grants = None;
139 + self
140 + }
141 +
142 + /// Run the loop. Returns the final assistant message plus the transcript.
143 + pub async fn run(
144 + &self,
145 + registry: &ToolRegistry,
146 + system: Option<&str>,
147 + user: &str,
148 + ) -> Result<RunOutcome> {
149 + let tools = registry.specs_projected(self.config.projection);
150 +
151 + let mut messages: Vec<Message> = Vec::new();
152 + if let Some(s) = system {
153 + messages.push(Message::system(s));
154 + }
155 + messages.push(Message::user(user));
156 +
157 + for step in 0..self.config.max_steps {
158 + let assistant = self.provider.chat(&messages, &tools).await?;
159 + let has_tool_calls = !assistant.tool_calls.is_empty();
160 + messages.push(assistant.clone());
161 +
162 + if !has_tool_calls {
163 + return Ok(RunOutcome {
164 + final_message: assistant.content.unwrap_or_default(),
165 + transcript: messages,
166 + steps: step + 1,
167 + });
168 + }
169 +
170 + for call in assistant.tool_calls {
171 + let result = registry
172 + .call(&call.name, call.arguments, self.config.grants.as_ref())
173 + .await;
174 + let content = match result {
175 + Ok(r) => tool_result_to_text(&r),
176 + Err(e) => format!("[error] {e}"),
177 + };
178 + messages.push(Message::tool(&call.name, content));
179 + }
180 + }
181 +
182 + Err(Error::Protocol(format!(
183 + "agent exceeded max_steps ({})",
184 + self.config.max_steps
185 + )))
186 + }
187 + }
188 +
189 + fn tool_result_to_text(result: &crate::tool::ToolCallResult) -> String {
190 + use crate::tool::ContentPart;
191 + let mut out = String::new();
192 + for part in &result.content {
193 + match part {
194 + ContentPart::Text { text } => {
195 + if !out.is_empty() {
196 + out.push('\n');
197 + }
198 + out.push_str(text);
199 + }
200 + }
201 + }
202 + if result.is_error {
203 + format!("[error] {out}")
204 + } else {
205 + out
206 + }
207 + }
208 +
209 + /// Outcome of a completed agent run.
210 + pub struct RunOutcome {
211 + pub final_message: String,
212 + pub transcript: Vec<Message>,
213 + pub steps: usize,
214 + }
@@ -0,0 +1,41 @@
1 + use thiserror::Error;
2 +
3 + pub type Result<T> = std::result::Result<T, Error>;
4 +
5 + #[derive(Debug, Error)]
6 + pub enum Error {
7 + #[error("tool not found: {0}")]
8 + ToolNotFound(String),
9 +
10 + #[error("invalid arguments for tool `{tool}`: {message}")]
11 + InvalidArgs { tool: String, message: String },
12 +
13 + #[error("tool `{tool}` failed: {message}")]
14 + ToolFailed { tool: String, message: String },
15 +
16 + /// Registered refusal — the tool exists in the surface but is not offered.
17 + /// The `reason` is stable text the model can act on.
18 + #[error("tool `{tool}` is not offered: {reason}")]
19 + Refused { tool: String, reason: String },
20 +
21 + /// Write tool called without a grant for its capability. Includes a stable
22 + /// suffix so small models learn not to retry.
23 + #[error("tool `{tool}` requires capability `{capability}` which was not granted; do not retry")]
24 + CapabilityDenied { tool: String, capability: String },
25 +
26 + #[error("protocol error: {0}")]
27 + Protocol(String),
28 +
29 + #[error("transport error: {0}")]
30 + Transport(String),
31 +
32 + #[error(transparent)]
33 + Json(#[from] serde_json::Error),
34 +
35 + #[error(transparent)]
36 + Io(#[from] std::io::Error),
37 +
38 + #[cfg(feature = "ollama")]
39 + #[error(transparent)]
40 + Http(#[from] reqwest::Error),
41 + }
@@ -0,0 +1,44 @@
1 + //! Kberg — MCP bridge for connecting local LLMs to desktop apps.
2 + //!
3 + //! Named after Königsberg, the city whose seven bridges founded graph theory
4 + //! (Euler, 1736). The library is a bridge in that same sense: it routes
5 + //! tool-call edges between an LLM node and a set of app nodes.
6 + //!
7 + //! # Shape
8 + //!
9 + //! - [`Tool`] + [`ToolRegistry`] — app-side surface.
10 + //! - [`server`] — Streamable HTTP MCP server (JSON-RPC 2.0). Any MCP-speaking
11 + //! client can reach a Kberg-served app.
12 + //! - [`agent::Agent`] + [`agent::InferenceProvider`] — provider-agnostic
13 + //! agent loop. Bring your own inference endpoint; Kberg does not manage
14 + //! models or runtimes.
15 + //! - [`provider::ollama`] — reference `InferenceProvider` for a local Ollama
16 + //! server. Future providers (OpenAI, Anthropic, OpenAI-compatible) drop in
17 + //! without touching the agent loop.
18 + //!
19 + //! # Guardrails
20 + //!
21 + //! - Every [`Tool`] declares [`ToolKind::Read`] or [`ToolKind::Write`] with a
22 + //! named capability. Writes are the finite, app-declared vocabulary of
23 + //! permissions the LLM can be granted — webhook-style.
24 + //! - [`SurfaceProjection::Compact`] exposes only tools the app has annotated
25 + //! as small-model safe.
26 + //! - Hard refusals ([`Refusal`]) appear in `tools/list` with a stable reason
27 + //! so models don't invent plausible names.
28 +
29 + pub mod agent;
30 + mod error;
31 + pub mod tool;
32 +
33 + pub use agent::{Agent, AgentConfig, InferenceProvider, Message, Role, RunOutcome, ToolCall};
34 + pub use error::{Error, Result};
35 + pub use tool::{
36 + ContentPart, Refusal, SurfaceProjection, Tool, ToolCallResult, ToolKind, ToolRegistry,
37 + ToolSpec, WriteCapability,
38 + };
39 +
40 + #[cfg(feature = "server")]
41 + pub mod server;
42 +
43 + #[cfg(any(feature = "ollama"))]
44 + pub mod provider;
@@ -0,0 +1,6 @@
1 + //! Concrete [`InferenceProvider`](crate::agent::InferenceProvider)
2 + //! implementations. Each provider lives behind its own feature flag so users
3 + //! only pull in the HTTP clients they need.
4 +
5 + #[cfg(feature = "ollama")]
6 + pub mod ollama;
@@ -0,0 +1,186 @@
1 + //! Ollama [`InferenceProvider`].
2 + //!
3 + //! Talks to a local Ollama server over its `/api/chat` endpoint and
4 + //! translates the neutral [`Message`] / [`ToolSpec`] types to and from
5 + //! Ollama's OpenAI-shaped wire format.
6 +
7 + use async_trait::async_trait;
8 + use serde::{Deserialize, Serialize};
9 + use serde_json::{Value, json};
10 +
11 + use crate::agent::{InferenceProvider, Message, Role, ToolCall};
12 + use crate::error::Result;
13 + use crate::tool::ToolSpec;
14 +
15 + const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434";
16 +
17 + pub struct OllamaProvider {
18 + client: reqwest::Client,
19 + base_url: String,
20 + model: String,
21 + }
22 +
23 + impl OllamaProvider {
24 + pub fn new(model: impl Into<String>) -> Self {
25 + Self {
26 + client: reqwest::Client::new(),
27 + base_url: DEFAULT_OLLAMA_URL.to_string(),
28 + model: model.into(),
29 + }
30 + }
31 +
32 + pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
33 + self.base_url = url.into();
34 + self
35 + }
36 + }
37 +
38 + #[async_trait]
39 + impl InferenceProvider for OllamaProvider {
40 + async fn chat(&self, messages: &[Message], tools: &[ToolSpec]) -> Result<Message> {
41 + let wire_messages: Vec<WireMessage> = messages.iter().map(WireMessage::from_neutral).collect();
42 + let wire_tools: Vec<WireTool> = tools
43 + .iter()
44 + .map(|s| WireTool {
45 + r#type: "function",
46 + function: WireFunction {
47 + name: s.name.clone(),
48 + description: s.description.clone(),
49 + parameters: s.input_schema.clone(),
50 + },
51 + })
52 + .collect();
53 +
54 + let req = ChatRequest {
55 + model: &self.model,
56 + messages: &wire_messages,
57 + tools: &wire_tools,
58 + stream: false,
59 + };
60 +
61 + let resp: ChatResponse = self
62 + .client
63 + .post(format!("{}/api/chat", self.base_url))
64 + .json(&req)
65 + .send()
66 + .await?
67 + .error_for_status()?
68 + .json()
69 + .await?;
70 +
71 + Ok(resp.message.into_neutral())
72 + }
73 + }
74 +
75 + // ---- wire types ----
76 +
77 + #[derive(Debug, Serialize)]
78 + struct ChatRequest<'a> {
79 + model: &'a str,
80 + messages: &'a [WireMessage],
81 + tools: &'a [WireTool],
82 + stream: bool,
83 + }
84 +
85 + #[derive(Debug, Deserialize)]
86 + struct ChatResponse {
87 + message: WireMessage,
88 + }
89 +
90 + #[derive(Debug, Serialize, Deserialize)]
91 + struct WireMessage {
92 + role: String,
93 + #[serde(default, skip_serializing_if = "Option::is_none")]
94 + content: Option<String>,
95 + #[serde(default, skip_serializing_if = "Option::is_none")]
96 + tool_calls: Option<Vec<WireToolCall>>,
97 + #[serde(default, skip_serializing_if = "Option::is_none")]
98 + tool_name: Option<String>,
99 + }
100 +
101 + impl WireMessage {
102 + fn from_neutral(m: &Message) -> Self {
103 + let role = match m.role {
104 + Role::System => "system",
105 + Role::User => "user",
106 + Role::Assistant => "assistant",
107 + Role::Tool => "tool",
108 + }
109 + .to_string();
110 + let tool_calls = if m.tool_calls.is_empty() {
111 + None
112 + } else {
113 + Some(
114 + m.tool_calls
115 + .iter()
116 + .map(|c| WireToolCall {
117 + function: WireToolCallFunction {
118 + name: c.name.clone(),
119 + arguments: c.arguments.clone(),
120 + },
121 + })
122 + .collect(),
123 + )
124 + };
125 + Self {
126 + role,
127 + content: m.content.clone(),
128 + tool_calls,
129 + tool_name: m.tool_name.clone(),
130 + }
131 + }
132 +
133 + fn into_neutral(self) -> Message {
134 + let role = match self.role.as_str() {
135 + "system" => Role::System,
136 + "user" => Role::User,
137 + "tool" => Role::Tool,
138 + _ => Role::Assistant,
139 + };
140 + let tool_calls = self
141 + .tool_calls
142 + .unwrap_or_default()
143 + .into_iter()
144 + .map(|c| ToolCall {
145 + id: String::new(),
146 + name: c.function.name,
147 + arguments: c.function.arguments,
148 + })
149 + .collect();
150 + Message {
151 + role,
152 + content: self.content,
153 + tool_calls,
154 + tool_name: self.tool_name,
155 + }
156 + }
157 + }
158 +
159 + #[derive(Debug, Serialize, Deserialize)]
160 + struct WireToolCall {
161 + function: WireToolCallFunction,
162 + }
163 +
164 + #[derive(Debug, Serialize, Deserialize)]
165 + struct WireToolCallFunction {
166 + name: String,
167 + #[serde(default = "empty_object")]
168 + arguments: Value,
169 + }
170 +
171 + fn empty_object() -> Value {
172 + json!({})
173 + }
174 +
175 + #[derive(Debug, Serialize)]
176 + struct WireTool {
177 + r#type: &'static str,
178 + function: WireFunction,
179 + }
180 +
181 + #[derive(Debug, Serialize)]
182 + struct WireFunction {
183 + name: String,
184 + description: String,
185 + parameters: Value,
186 + }
@@ -0,0 +1,173 @@
1 + //! Streamable HTTP MCP server.
2 + //!
3 + //! Exposes a single POST `/mcp` endpoint speaking JSON-RPC 2.0. Handles
4 + //! `initialize`, `tools/list`, and `tools/call`. No SSE for MVP.
5 + //!
6 + //! Configuration is passed at startup via [`ServeConfig`]:
7 + //! - `projection` — Full or Compact surface exposed to clients on this port.
8 + //! - `grants` — which write capabilities are honored; unmatched writes return
9 + //! `CapabilityDenied`. `None` bypasses the check (fully-trusted context).
10 +
11 + use std::collections::HashSet;
12 + use std::net::SocketAddr;
13 + use std::sync::Arc;
14 +
15 + use axum::{
16 + Json, Router,
17 + extract::State,
18 + http::StatusCode,
19 + response::IntoResponse,
20 + routing::post,
21 + };
22 + use serde_json::{Value, json};
23 + use tokio::net::TcpListener;
24 + use tokio::task::JoinHandle;
25 +
26 + use crate::error::{Error, Result};
27 + use crate::tool::{SurfaceProjection, ToolRegistry};
28 +
29 + pub mod protocol;
30 +
31 + use protocol::{
32 + CallToolParams, InitializeResult, JsonRpcRequest, JsonRpcResponse, MCP_PROTOCOL_VERSION,
33 + ServerCapabilities, ServerInfo, ToolsCapability, codes,
34 + };
35 +
36 + pub struct ServeConfig {
37 + pub projection: SurfaceProjection,
38 + pub grants: Option<HashSet<String>>,
39 + }
40 +
41 + impl Default for ServeConfig {
42 + fn default() -> Self {
43 + Self {
44 + projection: SurfaceProjection::Full,
45 + grants: Some(HashSet::new()),
46 + }
47 + }
48 + }
49 +
50 + /// Handle to a running MCP server.
51 + ///
52 + /// Dropping this does *not* stop the server — call [`BoundServer::shutdown`]
53 + /// or abort the task explicitly.
54 + pub struct BoundServer {
55 + pub addr: SocketAddr,
56 + handle: JoinHandle<std::io::Result<()>>,
57 + }
58 +
59 + impl BoundServer {
60 + /// Full URL clients POST to, e.g. `http://127.0.0.1:54321/mcp`.
61 + pub fn url(&self) -> String {
62 + format!("http://{}/mcp", self.addr)
63 + }
64 +
65 + pub fn shutdown(self) {
66 + self.handle.abort();
67 + }
68 + }
69 +
70 + #[derive(Clone)]
71 + struct AppState {
72 + registry: ToolRegistry,
73 + config: Arc<ServeConfig>,
74 + }
75 +
76 + /// Start an MCP server. Returns once the listener is bound; the server runs
77 + /// in a background tokio task.
78 + pub async fn serve(
79 + registry: ToolRegistry,
80 + bind: &str,
81 + config: ServeConfig,
82 + ) -> Result<BoundServer> {
83 + let listener = TcpListener::bind(bind).await?;
84 + let addr = listener.local_addr()?;
85 +
86 + let state = AppState {
87 + registry,
88 + config: Arc::new(config),
89 + };
90 +
91 + let app = Router::new()
92 + .route("/mcp", post(handle_rpc))
93 + .with_state(state);
94 +
95 + let handle = tokio::spawn(async move { axum::serve(listener, app).await });
96 +
97 + tracing::info!(%addr, "kberg server listening");
98 + Ok(BoundServer { addr, handle })
99 + }
100 +
101 + async fn handle_rpc(State(state): State<AppState>, Json(req): Json<Value>) -> impl IntoResponse {
102 + let req: JsonRpcRequest = match serde_json::from_value(req) {
103 + Ok(r) => r,
104 + Err(e) => {
105 + return (
106 + StatusCode::BAD_REQUEST,
107 + Json(JsonRpcResponse::err(
108 + Value::Null,
109 + codes::PARSE_ERROR,
110 + e.to_string(),
111 + )),
112 + )
113 + .into_response();
114 + }
115 + };
116 +
117 + // Notifications (no `id`) get a 202 with no body.
118 + let Some(id) = req.id.clone() else {
119 + return StatusCode::ACCEPTED.into_response();
120 + };
121 +
122 + let response = match req.method.as_str() {
123 + "initialize" => JsonRpcResponse::ok(id, initialize_result()),
124 + "tools/list" => {
125 + let specs = state.registry.specs_projected(state.config.projection);
126 + JsonRpcResponse::ok(id, json!({ "tools": specs }))
127 + }
128 + "tools/call" => match serde_json::from_value::<CallToolParams>(req.params.unwrap_or(Value::Null)) {
129 + Ok(params) => {
130 + let result = state
131 + .registry
132 + .call(&params.name, params.arguments, state.config.grants.as_ref())
133 + .await;
134 + match result {
135 + Ok(r) => JsonRpcResponse::ok(id, serde_json::to_value(r).unwrap()),
136 + Err(e) => rpc_err_from(id, e),
137 + }
138 + }
139 + Err(e) => JsonRpcResponse::err(id, codes::INVALID_PARAMS, e.to_string()),
140 + },
141 + other => JsonRpcResponse::err(
142 + id,
143 + codes::METHOD_NOT_FOUND,
144 + format!("unknown method: {other}"),
145 + ),
146 + };
147 +
148 + (StatusCode::OK, Json(response)).into_response()
149 + }
150 +
151 + fn rpc_err_from(id: Value, e: Error) -> JsonRpcResponse {
152 + let (code, message) = match &e {
153 + Error::ToolNotFound(_) => (codes::METHOD_NOT_FOUND, e.to_string()),
154 + Error::InvalidArgs { .. } => (codes::INVALID_PARAMS, e.to_string()),
155 + Error::CapabilityDenied { .. } | Error::Refused { .. } => (codes::INVALID_PARAMS, e.to_string()),
156 + _ => (codes::INTERNAL_ERROR, e.to_string()),
157 + };
158 + JsonRpcResponse::err(id, code, message)
159 + }
160 +
161 + fn initialize_result() -> Value {
162 + serde_json::to_value(InitializeResult {
163 + protocol_version: MCP_PROTOCOL_VERSION,
164 + capabilities: ServerCapabilities {
165 + tools: ToolsCapability { list_changed: false },
166 + },
167 + server_info: ServerInfo {
168 + name: "kberg".to_string(),
169 + version: env!("CARGO_PKG_VERSION").to_string(),
170 + },
171 + })
172 + .unwrap()
173 + }
@@ -0,0 +1,101 @@
1 + //! MCP JSON-RPC 2.0 protocol types for the Streamable HTTP transport.
2 + //!
3 + //! Only the subset needed for `initialize`, `tools/list`, and `tools/call`.
4 +
5 + use serde::{Deserialize, Serialize};
6 + use serde_json::Value;
7 +
8 + pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
9 +
10 + #[derive(Debug, Deserialize)]
11 + pub struct JsonRpcRequest {
12 + #[allow(dead_code)]
13 + pub jsonrpc: String,
14 + #[serde(default)]
15 + pub id: Option<Value>,
16 + pub method: String,
17 + #[serde(default)]
18 + pub params: Option<Value>,
19 + }
20 +
21 + #[derive(Debug, Serialize)]
22 + pub struct JsonRpcResponse {
23 + pub jsonrpc: &'static str,
24 + pub id: Value,
25 + #[serde(skip_serializing_if = "Option::is_none")]
26 + pub result: Option<Value>,
27 + #[serde(skip_serializing_if = "Option::is_none")]
28 + pub error: Option<JsonRpcError>,
29 + }
30 +
31 + impl JsonRpcResponse {
32 + pub fn ok(id: Value, result: Value) -> Self {
33 + Self {
34 + jsonrpc: "2.0",
35 + id,
36 + result: Some(result),
37 + error: None,
38 + }
39 + }
40 +
41 + pub fn err(id: Value, code: i32, message: impl Into<String>) -> Self {
42 + Self {
43 + jsonrpc: "2.0",
44 + id,
45 + result: None,
46 + error: Some(JsonRpcError {
47 + code,
48 + message: message.into(),
49 + data: None,
50 + }),
51 + }
52 + }
53 + }
54 +
55 + #[derive(Debug, Serialize)]
56 + pub struct JsonRpcError {
57 + pub code: i32,
58 + pub message: String,
59 + #[serde(skip_serializing_if = "Option::is_none")]
60 + pub data: Option<Value>,
61 + }
62 +
63 + pub mod codes {
64 + pub const PARSE_ERROR: i32 = -32700;
65 + pub const METHOD_NOT_FOUND: i32 = -32601;
66 + pub const INVALID_PARAMS: i32 = -32602;
67 + pub const INTERNAL_ERROR: i32 = -32603;
68 + }
69 +
70 + #[derive(Debug, Serialize)]
71 + pub struct InitializeResult {
72 + #[serde(rename = "protocolVersion")]
73 + pub protocol_version: &'static str,
74 + pub capabilities: ServerCapabilities,
75 + #[serde(rename = "serverInfo")]
76 + pub server_info: ServerInfo,
77 + }
78 +
79 + #[derive(Debug, Serialize)]
80 + pub struct ServerCapabilities {
81 + pub tools: ToolsCapability,
82 + }
83 +
84 + #[derive(Debug, Serialize)]
85 + pub struct ToolsCapability {
86 + #[serde(rename = "listChanged")]
87 + pub list_changed: bool,
88 + }
89 +
90 + #[derive(Debug, Serialize)]
91 + pub struct ServerInfo {
92 + pub name: String,
93 + pub version: String,
94 + }
95 +
96 + #[derive(Debug, Deserialize)]
97 + pub struct CallToolParams {
98 + pub name: String,
99 + #[serde(default)]
100 + pub arguments: Value,
101 + }
@@ -0,0 +1,302 @@
1 + use std::collections::{HashMap, HashSet};
2 + use std::sync::Arc;
3 +
4 + use async_trait::async_trait;
5 + use serde::{Deserialize, Serialize};
6 + use serde_json::Value;
7 +
8 + use crate::error::{Error, Result};
9 +
10 + /// A single tool exposed to an MCP client.
11 + ///
12 + /// Implementors must declare `kind()` — either `Read` or `Write(capability)`.
13 + /// There is no default, because forgetting the classification is the sort of
14 + /// hazard we can prevent at compile time.
15 + #[async_trait]
16 + pub trait Tool: Send + Sync + 'static {
17 + fn name(&self) -> &str;
18 + fn description(&self) -> &str;
19 +
20 + /// Read or Write. Writes carry a capability that must be granted per session.
21 + fn kind(&self) -> ToolKind;
22 +
23 + /// JSON Schema for the tool's arguments. Should describe a single object.
24 + fn input_schema(&self) -> Value;
25 +
26 + async fn call(&self, args: Value) -> Result<ToolCallResult>;
27 +
28 + /// If this tool is a hard refusal, return a stable, model-visible reason.
29 + /// Registered refusals appear in `tools/list` so external clients can see
30 + /// what the app has chosen not to offer, and the model gets a clear signal
31 + /// instead of inventing tool names.
32 + fn refusal_reason(&self) -> Option<&str> {
33 + None
34 + }
35 +
36 + /// Opt-in flag for the compact projection used by small models. Defaults
37 + /// to `false` — conservative: apps opt tools into the compact surface only
38 + /// after verifying a small model can call them reliably.
39 + fn small_model_safe(&self) -> bool {
40 + false
41 + }
42 + }
43 +
44 + /// Classification of a tool's side-effect profile.
45 + #[derive(Debug, Clone, PartialEq, Eq)]
46 + pub enum ToolKind {
47 + /// No state change. Freely callable subject to app-level connection auth.
48 + Read,
49 + /// Mutates app state. Requires the caller to hold the named capability.
50 + Write(WriteCapability),
51 + }
52 +
53 + impl ToolKind {
54 + pub fn is_write(&self) -> bool {
55 + matches!(self, ToolKind::Write(_))
56 + }
57 +
58 + pub fn capability_id(&self) -> Option<&str> {
59 + match self {
60 + ToolKind::Write(cap) => Some(&cap.id),
61 + _ => None,
62 + }
63 + }
64 + }
65 +
66 + /// A named, pre-declared write capability.
67 + ///
68 + /// `id` is a stable public contract owned by the app — treat it like an API
69 + /// surface identifier. If you rename a capability, you introduce a new one and
70 + /// deprecate the old, you do not silently reassign an existing id.
71 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72 + pub struct WriteCapability {
73 + pub id: String,
74 + pub description: String,
75 + }
76 +
77 + impl WriteCapability {
78 + pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
79 + Self {
80 + id: id.into(),
81 + description: description.into(),
82 + }
83 + }
84 + }
85 +
86 + /// Result of a single tool invocation.
87 + ///
88 + /// Shape mirrors MCP's `CallToolResult`: a list of content parts plus an
89 + /// `is_error` flag. MVP carries only text content; media/resource parts land
90 + /// alongside audiofiles integration.
91 + #[derive(Debug, Clone, Serialize, Deserialize)]
92 + pub struct ToolCallResult {
93 + pub content: Vec<ContentPart>,
94 + #[serde(default, rename = "isError")]
95 + pub is_error: bool,
96 + }
97 +
98 + #[derive(Debug, Clone, Serialize, Deserialize)]
99 + #[serde(tag = "type", rename_all = "snake_case")]
100 + pub enum ContentPart {
101 + Text { text: String },
102 + }
103 +
104 + impl ToolCallResult {
105 + pub fn text(text: impl Into<String>) -> Self {
106 + Self {
107 + content: vec![ContentPart::Text { text: text.into() }],
108 + is_error: false,
109 + }
110 + }
111 +
112 + pub fn error(text: impl Into<String>) -> Self {
113 + Self {
114 + content: vec![ContentPart::Text { text: text.into() }],
115 + is_error: true,
116 + }
117 + }
118 + }
119 +
120 + /// Serializable description of a tool for `tools/list` responses and for
121 + /// translating to provider-native schemas (Ollama, OpenAI, Anthropic).
122 + #[derive(Debug, Clone, Serialize, Deserialize)]
123 + pub struct ToolSpec {
124 + pub name: String,
125 + pub description: String,
126 + #[serde(rename = "inputSchema")]
127 + pub input_schema: Value,
128 +
129 + /// `"read"` or `"write"`. Extension over the MCP base spec; clients that
130 + /// don't understand this can ignore it.
131 + #[serde(rename = "kburgKind")]
132 + pub kind: &'static str,
133 +
134 + /// Only set when `kind == "write"`. Extension.
135 + #[serde(rename = "kburgCapability", skip_serializing_if = "Option::is_none")]
136 + pub capability: Option<WriteCapability>,
137 +
138 + /// Set when this tool is a registered refusal. Extension.
139 + #[serde(rename = "kburgRefusalReason", skip_serializing_if = "Option::is_none")]
140 + pub refusal_reason: Option<String>,
141 + }
142 +
143 + /// Which projection of the registry to expose to a given client.
144 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
145 + pub enum SurfaceProjection {
146 + /// All registered tools.
147 + Full,
148 + /// Only tools annotated `small_model_safe`.
149 + Compact,
150 + }
151 +
152 + impl SurfaceProjection {
153 + fn includes(&self, small_model_safe: bool) -> bool {
154 + match self {
155 + SurfaceProjection::Full => true,
156 + SurfaceProjection::Compact => small_model_safe,
157 + }
158 + }
159 + }
160 +
161 + /// A registered "hard refusal" — appears in `tools/list` and returns a stable
162 + /// error on invocation, preventing the model from inventing plausible names.
163 + pub struct Refusal {
164 + name: String,
165 + description: String,
166 + reason: String,
167 + }
168 +
169 + impl Refusal {
170 + pub fn new(
171 + name: impl Into<String>,
172 + description: impl Into<String>,
173 + reason: impl Into<String>,
174 + ) -> Self {
175 + Self {
176 + name: name.into(),
177 + description: description.into(),
178 + reason: reason.into(),
179 + }
180 + }
181 + }
182 +
183 + #[async_trait]
184 + impl Tool for Refusal {
185 + fn name(&self) -> &str {
186 + &self.name
187 + }
188 + fn description(&self) -> &str {
189 + &self.description
190 + }
191 + fn kind(&self) -> ToolKind {
192 + ToolKind::Read
193 + }
194 + fn input_schema(&self) -> Value {
195 + serde_json::json!({ "type": "object", "properties": {} })
196 + }
197 + fn refusal_reason(&self) -> Option<&str> {
198 + Some(&self.reason)
199 + }
200 + async fn call(&self, _args: Value) -> Result<ToolCallResult> {
201 + Err(Error::Refused {
202 + tool: self.name.clone(),
203 + reason: self.reason.clone(),
204 + })
205 + }
206 + }
207 +
208 + /// Registry of tools available to clients.
209 + ///
210 + /// Cheaply cloneable — the underlying map lives behind `Arc` so the server
211 + /// and any agents can share the same registry without copying.
212 + #[derive(Clone, Default)]
213 + pub struct ToolRegistry {
214 + tools: Arc<HashMap<String, Arc<dyn Tool>>>,
215 + }
216 +
217 + impl ToolRegistry {
218 + pub fn new() -> Self {
219 + Self::default()
220 + }
221 +
222 + /// Register a tool. Panics on duplicate names or if called after cloning
223 + /// (register all tools before serving).
224 + pub fn register<T: Tool>(&mut self, tool: T) {
225 + let name = tool.name().to_string();
226 + let map = Arc::get_mut(&mut self.tools)
227 + .expect("ToolRegistry::register called after cloning; register all tools before serving");
228 + if map.contains_key(&name) {
229 + panic!("duplicate tool registration: {name}");
230 + }
231 + map.insert(name, Arc::new(tool));
232 + }
233 +
234 + pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
235 + self.tools.get(name).cloned()
236 + }
237 +
238 + /// All specs, unfiltered. Equivalent to `specs_projected(Full)`.
239 + pub fn specs(&self) -> Vec<ToolSpec> {
240 + self.specs_projected(SurfaceProjection::Full)
241 + }
242 +
243 + pub fn specs_projected(&self, projection: SurfaceProjection) -> Vec<ToolSpec> {
244 + self.tools
245 + .values()
246 + .filter(|t| projection.includes(t.small_model_safe()))
247 + .map(|t| ToolSpec {
248 + name: t.name().to_string(),
249 + description: t.description().to_string(),
250 + input_schema: t.input_schema(),
251 + kind: match t.kind() {
252 + ToolKind::Read => "read",
253 + ToolKind::Write(_) => "write",
254 + },
255 + capability: match t.kind() {
256 + ToolKind::Write(cap) => Some(cap),
257 + _ => None,
258 + },
259 + refusal_reason: t.refusal_reason().map(str::to_string),
260 + })
261 + .collect()
262 + }
263 +
264 + /// Distinct write capabilities declared by registered tools.
265 + pub fn write_capabilities(&self) -> Vec<WriteCapability> {
266 + let mut seen = HashSet::new();
267 + let mut out = Vec::new();
268 + for tool in self.tools.values() {
269 + if let ToolKind::Write(cap) = tool.kind() {
270 + if seen.insert(cap.id.clone()) {
271 + out.push(cap);
272 + }
273 + }
274 + }
275 + out
276 + }
277 +
278 + /// Call a tool, enforcing the granted-capabilities set. `grants` may be
279 + /// `None` to bypass the check (e.g., for a fully-trusted in-process
280 + /// caller); pass `Some(set)` from any surface exposed to an LLM.
281 + pub async fn call(
282 + &self,
283 + name: &str,
284 + args: Value,
285 + grants: Option<&HashSet<String>>,
286 + ) -> Result<ToolCallResult> {
287 + let tool = self
288 + .get(name)
289 + .ok_or_else(|| Error::ToolNotFound(name.to_string()))?;
290 +
291 + if let (ToolKind::Write(cap), Some(grants)) = (tool.kind(), grants) {
292 + if !grants.contains(&cap.id) {
293 + return Err(Error::CapabilityDenied {
294 + tool: name.to_string(),
295 + capability: cap.id,
296 + });
297 + }
298 + }
299 +
300 + tool.call(args).await
301 + }
302 + }