max / makenotwork
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 | + | ®istry, | |
| 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(¶ms.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 | + | } |