tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>tep chatbot — compare backends</title>
6
+ <link rel="stylesheet" href="/style.css">
7
+ </head>
8
+ <body class="app">
9
+ <aside class="sidebar">
10
+ <div class="sidebar-head">
11
+ <strong>tep chatbot</strong>
12
+ <a href="/" class="back-link">← Back</a>
13
+ </div>
14
+ <div class="sidebar-section">Compare mode</div>
15
+ <p class="sidebar-blurb">
16
+ One prompt fanned out to N backends in parallel via
17
+ <code>Tep::Parallel</code> + fork. Each card below is one
18
+ backend's reply.
19
+ </p>
20
+ <div class="sidebar-foot">
21
+ <form method="post" action="/logout" class="logout-form">
22
+ <button type="submit">Log out</button>
23
+ </form>
24
+ </div>
25
+ </aside>
26
+
27
+ <main id="compare">
28
+ <header class="topbar">
29
+ <span>compare backends</span>
30
+ </header>
31
+ <form id="prompt-form" class="compare-prompt">
32
+ <textarea id="prompt-input" rows="3" placeholder="Ask the same thing of every backend…" autofocus></textarea>
33
+ <button type="submit" id="prompt-btn">Send to all</button>
34
+ </form>
35
+ <p id="compare-status" class="status"></p>
36
+ <div id="compare-grid" class="compare-grid"></div>
37
+ </main>
38
+
39
+ <script id="bootbackends" type="application/json"><%= @backends_json %></script>
40
+ <script src="/markdown.js"></script>
41
+ <script src="/compare.js"></script>
42
+ </body>
43
+ </html>
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>tep chatbot</title>
6
+ <link rel="stylesheet" href="/style.css">
7
+ </head>
8
+ <body class="app">
9
+ <aside class="sidebar">
10
+ <div class="sidebar-head">
11
+ <strong>tep chatbot</strong>
12
+ <button id="new-conv-btn" type="button">+ New</button>
13
+ </div>
14
+ <ol id="conv-list" class="conv-list"></ol>
15
+ <div class="sidebar-section"><a href="/compare" class="back-link">Compare backends →</a></div>
16
+ <div class="sidebar-foot">
17
+ <form method="post" action="/logout" class="logout-form">
18
+ <button type="submit">Log out</button>
19
+ </form>
20
+ </div>
21
+ </aside>
22
+
23
+ <main id="chat" data-conv-id="<%= @conv_id %>">
24
+ <header class="topbar">
25
+ <span class="model"><%= @model %></span>
26
+ <span class="backend"><%= @backend %></span>
27
+ </header>
28
+ <ol id="messages" class="messages"></ol>
29
+ <form id="composer" class="composer">
30
+ <textarea id="composer-input" rows="3" placeholder="Send a message…" autofocus></textarea>
31
+ <button type="submit" id="send-btn">Send</button>
32
+ </form>
33
+ <p id="status" class="status"></p>
34
+ </main>
35
+
36
+ <!-- Boot data: current conversation messages + sidebar conversations. -->
37
+ <script id="bootmsgs" type="application/json"><%= @messages_json %></script>
38
+ <script id="bootconvs" type="application/json"><%= @conversations_json %></script>
39
+ <script src="/markdown.js"></script>
40
+ <script src="/chat.js"></script>
41
+ </body>
42
+ </html>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>tep chatbot — login</title>
6
+ <link rel="stylesheet" href="/style.css">
7
+ </head>
8
+ <body class="auth-page">
9
+ <main class="auth-card">
10
+ <h1>Log in</h1>
11
+ <% if @error %>
12
+ <p class="error"><%= @error %></p>
13
+ <% end %>
14
+ <form method="post" action="/login">
15
+ <label>Password
16
+ <input type="password" name="password" autofocus required>
17
+ </label>
18
+ <button type="submit">Log in</button>
19
+ </form>
20
+ </main>
21
+ </body>
22
+ </html>
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>tep chatbot — first-boot setup</title>
6
+ <link rel="stylesheet" href="/style.css">
7
+ </head>
8
+ <body class="auth-page">
9
+ <main class="auth-card">
10
+ <h1>Set a password</h1>
11
+ <p class="hint">This is the first boot. Pick a password to protect this chatbot — you'll use it to log in on subsequent visits.</p>
12
+ <% if @error %>
13
+ <p class="error"><%= @error %></p>
14
+ <% end %>
15
+ <form method="post" action="/setup">
16
+ <label>Password (6+ characters)
17
+ <input type="password" name="password" autofocus required minlength="6">
18
+ </label>
19
+ <button type="submit">Set password</button>
20
+ </form>
21
+ </main>
22
+ </body>
23
+ </html>
@@ -0,0 +1,68 @@
1
+ # counter -- the smallest Tep::LiveView demo
2
+
3
+ A single shared integer counter, server-side. Open in two browsers,
4
+ click `+` in one, watch the other update.
5
+
6
+ ```
7
+ ┌──────────────────────────────┐
8
+ │ SHARED COUNTER │
9
+ │ │
10
+ │ 7 │
11
+ │ │
12
+ │ [ - ] [ + ] │
13
+ │ reset │
14
+ │ │
15
+ │ open this page in another │
16
+ │ tab to see live updates. │
17
+ └──────────────────────────────┘
18
+ ```
19
+
20
+ ## Run
21
+
22
+ ```sh
23
+ bin/tep build examples/counter/app.rb -o /tmp/counter
24
+ /tmp/counter -p 4567
25
+ # open http://127.0.0.1:4567/counter in two browsers
26
+ ```
27
+
28
+ ## What it shows
29
+
30
+ This is the smallest possible app that exercises three pieces of
31
+ the LiveView surface at once:
32
+
33
+ | Piece | What it does here |
34
+ |---|---|
35
+ | `Tep.live "/counter", CounterView` | One DSL call lowers to GET `/counter` (initial render + bootstrap JS) **and** WS `/counter/ws` (event dispatch + re-render). No manual `websocket` block. |
36
+ | `CounterView#topic` | Returns a stable string. Every WS connection that opens against this view subscribes to that topic automatically; `broadcast_render` fans out to all of them. |
37
+ | `broadcast_render` | After mutating the shared `COUNTER`, every subscriber sees the new HTML in <100ms via a single WS TEXT frame. The bootstrap JS in `Tep::LiveView.render_page` does `outerHTML = e.data` on `#tep-live-root`. |
38
+
39
+ ## Code
40
+
41
+ ~30 lines of Ruby for the view + handler; ~20 lines of inline CSS.
42
+ No JS to write -- click + re-render comes from the bootstrap
43
+ shell that `Tep.live` wires automatically. Clicks on any element
44
+ with `data-event="..."` send `{"event": <name>, "payload": ""}`
45
+ over the WS; the server's `handle_event` mutates state + calls
46
+ `broadcast_render`; every subscriber re-renders.
47
+
48
+ ## Shared state
49
+
50
+ The counter lives in a module-level `COUNTER = [0]` array
51
+ (single-element typed slot, because spinel doesn't track module-
52
+ level `@@cvar` writes reliably across method calls). Per-worker
53
+ scope: a multi-worker deployment would need
54
+ `Tep::Broadcast.enable_pg_backend` to route NOTIFYs through PG so
55
+ all workers see the same mutations. Left out here for demo
56
+ simplicity.
57
+
58
+ ## What this demo does NOT show
59
+
60
+ - **Per-user state.** Every browser sees the same number. For
61
+ per-user LiveView state, give the view per-instance ivars +
62
+ seed them in `mount(req)` from `req.identity` or `req.params`.
63
+ - **Authentication.** No `Tep.session_secret` / `Tep::Auth.install!`
64
+ here. The presence-aware four-battery surface lives in
65
+ [`examples/agentic_chat`](../agentic_chat).
66
+ - **History / persistence.** The counter resets to 0 on every
67
+ server restart. For persistent state, mutate a `Tep::SQLite`
68
+ table from `handle_event`.
@@ -0,0 +1,85 @@
1
+ # Counter -- minimal Tep::LiveView demo using Tep.live auto-wiring.
2
+ #
3
+ # A single shared integer counter. Every open browser is subscribed
4
+ # to the same topic; clicking + / - / reset mutates the shared
5
+ # state and broadcasts the re-rendered HTML to every subscriber.
6
+ # All connections see the new value in <100ms with no polling and
7
+ # no full-page reload.
8
+ #
9
+ # Run:
10
+ # bin/tep build examples/counter/app.rb -o /tmp/counter
11
+ # /tmp/counter -p 4567
12
+ # Open http://127.0.0.1:4567/counter in two browsers and click +.
13
+ require 'sinatra'
14
+
15
+ set :scheduler, :scheduled
16
+
17
+ # Single-element typed array as a shared int slot. (Spinel doesn't
18
+ # track module-level `@@cvar` writes reliably across method calls;
19
+ # an Array[Integer] gives us a typed shared slot that survives
20
+ # request boundaries.)
21
+ COUNTER = [0]
22
+
23
+ class CounterView < Tep::LiveView
24
+ # Topic binds every connected viewer to the same broadcast stream.
25
+ # On every event, broadcast_render fans the updated HTML out to
26
+ # all subscribers (each WS on this topic).
27
+ def topic
28
+ "counter:shared"
29
+ end
30
+
31
+ def render
32
+ "<div id='tep-live-root' class='counter'>" +
33
+ "<h1>shared counter</h1>" +
34
+ "<p class='value'>" + COUNTER[0].to_s + "</p>" +
35
+ "<div class='controls'>" +
36
+ "<button data-event='dec'>&minus;</button>" +
37
+ "<button data-event='inc'>+</button>" +
38
+ "</div>" +
39
+ "<button class='reset' data-event='reset'>reset</button>" +
40
+ "<p class='hint'>open this page in another tab to see live updates.</p>" +
41
+ "</div>" +
42
+ "<style>" + counter_css + "</style>"
43
+ end
44
+
45
+ def handle_event(event, payload, req)
46
+ if event == "inc"
47
+ COUNTER[0] = COUNTER[0] + 1
48
+ broadcast_render
49
+ elsif event == "dec"
50
+ COUNTER[0] = COUNTER[0] - 1
51
+ broadcast_render
52
+ elsif event == "reset"
53
+ COUNTER[0] = 0
54
+ broadcast_render
55
+ end
56
+ 0
57
+ end
58
+ end
59
+
60
+ def counter_css
61
+ "body{margin:0;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;" +
62
+ "background:#f6f7f9;color:#1a1a1a;" +
63
+ "display:flex;justify-content:center;align-items:center;min-height:100vh}" +
64
+ ".counter{background:#fff;padding:2rem 3rem;border-radius:8px;" +
65
+ "box-shadow:0 1px 4px rgba(0,0,0,.06);text-align:center;min-width:300px}" +
66
+ ".counter h1{margin:0 0 1rem;font-size:.85rem;text-transform:uppercase;" +
67
+ "letter-spacing:.1em;color:#666;font-weight:600}" +
68
+ ".counter .value{margin:0 0 1.5rem;font-size:4rem;font-weight:700;" +
69
+ "font-variant-numeric:tabular-nums;color:#1a1a1a}" +
70
+ ".counter .controls{display:flex;gap:.5rem;justify-content:center;margin-bottom:1rem}" +
71
+ ".counter button{padding:.6rem 1.4rem;border:1px solid #d0d3d8;background:#fafbfc;" +
72
+ "color:#1a1a1a;border-radius:4px;font:inherit;font-size:1.2rem;cursor:pointer}" +
73
+ ".counter button:hover{background:#1a1a1a;color:#fff;border-color:#1a1a1a}" +
74
+ ".counter button.reset{font-size:.8rem;padding:.3rem .8rem;color:#888;background:transparent}" +
75
+ ".counter button.reset:hover{background:#f0f1f3;color:#1a1a1a;border-color:#d0d3d8}" +
76
+ ".counter .hint{margin:1rem 0 0;font-size:.75rem;color:#888;font-style:italic}"
77
+ end
78
+
79
+ Tep.live "/counter", CounterView
80
+
81
+ get '/' do
82
+ res.set_status(302)
83
+ res.headers["Location"] = "/counter"
84
+ ""
85
+ end
@@ -0,0 +1,91 @@
1
+ # AGENTS.md
2
+
3
+ This file documents the agent-facing surface of this app for any
4
+ LLM / agent reading it. The convention: every tep app exposing
5
+ MCP ships an `AGENTS.md` at its repo root so a Claude Code (or
6
+ OpenCode / Gravity / etc.) session can read it once and know
7
+ how to drive the app safely.
8
+
9
+ ## What this app does
10
+
11
+ A mock training-run manager. Agents start named experiments with
12
+ hyperparameters, advance them one epoch at a time, list current
13
+ state, and cancel runs.
14
+
15
+ The training is simulated for demo purposes; the agent-facing
16
+ surface is the production shape.
17
+
18
+ ## How to discover
19
+
20
+ | URL | Purpose |
21
+ |---|---|
22
+ | `/mcp` (POST, JSON-RPC 2.0) | Primary surface. Speak MCP here. |
23
+ | `/llms.txt` (GET) | Plain-text catalog of tools + resources. |
24
+ | `/openapi.json` (GET) | OpenAPI 3.0.3 of the HTTP-direct surface. |
25
+
26
+ Inside `/mcp`: `initialize` returns server info + capabilities;
27
+ `tools/list` enumerates tools; `resources/list` enumerates
28
+ resources; `tools/call` and `resources/read` invoke them.
29
+
30
+ ## Tool catalog
31
+
32
+ | Tool | Caps required | Effect |
33
+ |---|---|---|
34
+ | `start_experiment(name, learning_rate, epochs)` | `:run_experiments` | Enqueue + auto-start a run. Returns the new id. |
35
+ | `step_experiment(id)` | (none) | Advance one epoch. Idempotent on `done` / `cancelled`. |
36
+ | `list_experiments()` | (none) | Snapshot of every experiment as `id=N name=... status=... epoch=K/N loss=L1,L2,...`. |
37
+ | `cancel_experiment(id)` | `:run_experiments` | Mark a run as `cancelled`. Reversible: re-call `start_experiment` to start a new run with the same name. |
38
+
39
+ ## Resource catalog
40
+
41
+ | Resource URI | mimeType | Effect |
42
+ |---|---|---|
43
+ | `experiments/all` | `text/plain` | Snapshot of every experiment. Same format as `list_experiments`. |
44
+ | `experiments/active` | `text/plain` | Only runs with `status=running`. |
45
+
46
+ Resources are read-only fetches. Use them for periodic state
47
+ polling between tool calls.
48
+
49
+ ## Invariants the app maintains
50
+
51
+ - Experiment ids are monotonically increasing (1, 2, 3, ...).
52
+ - Status transitions: `queued -> running -> {done | cancelled}`.
53
+ Never goes backward.
54
+ - `step_experiment` on `done` or `cancelled` is a no-op.
55
+ - `cancel_experiment` on `done` is allowed but flips status back
56
+ to `cancelled` -- avoid if you want to preserve "completed" runs.
57
+ - Loss series is append-only; no in-place edits.
58
+
59
+ ## How to drive efficiently
60
+
61
+ - **Start a batch**, then `step` each run round-robin until all
62
+ complete. Listing in a loop polls cheaply (`list_experiments`
63
+ is O(n)).
64
+ - **Compare runs** by reading `experiments/all` and parsing the
65
+ loss arrays per id. The format is stable; agents can string-
66
+ split safely.
67
+ - **Cancel early** when an experiment's loss curve is clearly
68
+ worse than alternatives. Don't wait for it to finish.
69
+
70
+ ## Authorization
71
+
72
+ For the demo this app accepts an `X-Demo-Cap-Run: 1` header as a
73
+ stand-in for the capability. A real deployment uses
74
+ `Tep::AuthOAuth2` to mint a JWT delegating the agent
75
+ `run_experiments` capability on behalf of a human user; the
76
+ agent passes that JWT as `Authorization: Bearer <token>` on
77
+ every `/mcp` POST. The tool body sees `req.identity` with
78
+ `acting_via` set to the delegation, exactly as the framework
79
+ documents.
80
+
81
+ ## Things you should NOT do
82
+
83
+ - **Don't restart runs to force a different seed.** This
84
+ particular app is deterministic; cancel + start is the same as
85
+ step + step.
86
+ - **Don't start more than ~10 concurrent runs.** State is in-
87
+ memory; the app has no backpressure or queuing.
88
+ - **Don't assume `loss` values are real ML output.** They're
89
+ synthetic for the demo. Use the shape of the API to drive
90
+ reasoning about agent loops; don't extrapolate to actual
91
+ hyperparameter search conclusions.
@@ -0,0 +1,99 @@
1
+ # experiments -- the MCP battery demo
2
+
3
+ A mock training-run manager driven by an MCP client. The full
4
+ agent-as-driver loop in ~200 lines of Ruby:
5
+
6
+ - 4 `mcp_tool`s (start / step / list / cancel)
7
+ - 2 `mcp_resource`s (all / active)
8
+ - Capability gating on the mutating tools (`:run_experiments`)
9
+ - Auto-published catalog at `/llms.txt`, `/openapi.json`, `/mcp`
10
+
11
+ The training is **simulated** -- no actual ML. State lives in
12
+ module-level arrays. A real runner would persist to SQLite via
13
+ the same tool/resource API; nothing in the agent-facing surface
14
+ would change.
15
+
16
+ ## Run
17
+
18
+ ```sh
19
+ bin/tep build examples/experiments/app.rb -o /tmp/experiments
20
+ /tmp/experiments -p 4567
21
+ ```
22
+
23
+ Open `http://127.0.0.1:4567/` in a browser for the landing
24
+ page (lists the tool + resource catalog with quick-start curl
25
+ recipes).
26
+
27
+ ## Drive it from Claude Code (or any MCP client)
28
+
29
+ Point your MCP client at `http://127.0.0.1:4567/mcp`. Three
30
+ methods cover everything:
31
+
32
+ ```
33
+ initialize -> handshake
34
+ tools/list -> discover the 4 tools
35
+ tools/call -> run one (e.g. start_experiment + step_experiment)
36
+ resources/list -> discover the 2 resources
37
+ resources/read -> read a resource by URI
38
+ ```
39
+
40
+ The client doesn't need to know any HTTP details beyond the
41
+ `/mcp` URL -- everything else (tool schemas, capability checks,
42
+ error reporting) flows through JSON-RPC.
43
+
44
+ ## Drive it from curl
45
+
46
+ The natural agent-driver shape but works for humans too:
47
+
48
+ ```sh
49
+ # Discover
50
+ curl http://127.0.0.1:4567/llms.txt
51
+ curl http://127.0.0.1:4567/openapi.json
52
+
53
+ # Start an experiment (capped -- demo accepts X-Demo-Cap-Run header
54
+ # as a stand-in for a real bearer-token-with-caps from a Tep::AuthOAuth2
55
+ # delegation flow)
56
+ curl -X POST http://127.0.0.1:4567/tools/start_experiment \
57
+ -H "Content-Type: application/json" \
58
+ -H "X-Demo-Cap-Run: 1" \
59
+ -d '{"name":"baseline","learning_rate":"1e-3","epochs":3}'
60
+ # -> started experiment id=1 (baseline)
61
+
62
+ # Advance an epoch
63
+ curl -X POST http://127.0.0.1:4567/tools/step_experiment \
64
+ -H "Content-Type: application/json" \
65
+ -d '{"id":1}'
66
+ # -> id=1 name=baseline lr=1e-3 status=running epoch=1/3 loss=0.90
67
+
68
+ # Snapshot
69
+ curl http://127.0.0.1:4567/resources/experiments/active
70
+ ```
71
+
72
+ ## What this demo exercises
73
+
74
+ | Surface | What it does here |
75
+ |---|---|
76
+ | **`mcp_tool`** | All 4 tools declare typed params (`String` / `Integer`), descriptions, and (for the mutating two) a `caps: [:run_experiments]` gate. Tool bodies return `Tep::MCP.text(...)` for success and `Tep::MCP.error(...)` for the not-found path. |
77
+ | **`mcp_resource`** | 2 read-only fetches. Bodies return `Tep::MCP.resource_text(uri, body)`; mimeType defaults to `text/plain`. |
78
+ | **`/mcp` JSON-RPC** | Translator-generated dispatcher routes `initialize` / `tools/list` / `tools/call` / `resources/list` / `resources/read` / `notifications/initialized`. |
79
+ | **`/llms.txt`** | Auto-published markdown catalog. Both tools + resources sections, with the MCP endpoint URL + OpenAPI link in the header. |
80
+ | **`/openapi.json`** | Auto-published OpenAPI 3.0.3 spec for the HTTP-direct surface. Non-MCP agents and Swagger UI consume this directly. |
81
+ | **Capability gating** | `start_experiment` and `cancel_experiment` require `:run_experiments`; the read paths don't. Anonymous callers get an MCP `isError:true` response with `missing capability: run_experiments`. |
82
+
83
+ ## What's NOT in this demo (intentionally)
84
+
85
+ - **Persistent state.** Module-level arrays only; counters reset
86
+ on restart. A real version would use `Tep::SQLite`.
87
+ - **Real training.** The "loss" series is synthetic. The point is
88
+ the agent-driver loop, not the ML.
89
+ - **Bearer-token auth.** The `X-Demo-Cap-Run` header shortcuts the
90
+ full `Tep::AuthOAuth2` + JWT flow that a production deployment
91
+ would use. The capability check itself (`req.identity.may?(...)`)
92
+ is real.
93
+ - **Streaming progress.** `tools/call` returns when the body
94
+ returns. Long-running experiments would benefit from MCP
95
+ `notifications/progress` over an SSE channel -- deferred
96
+ past chunk 5.4.
97
+
98
+ See `AGENTS.md` in this directory for the agent-facing surface
99
+ spec (the file convention agents look for at the repo root).