service-ize
CI / Test + coverage (push) Successful in 27s

This commit is contained in:
2026-09-19 13:57:13 -09:00
parent 5aaf5a8d6c
commit 1f907cfe7b
7 changed files with 206 additions and 85 deletions
+17 -27
View File
@@ -1,9 +1,11 @@
(ns tbd.mcp.tools
"The MCP tools the server offers, in the shape plumcp's server options take as `:primitives`.
Like the HTTP handlers, a tool is a thin adapter over a group's business logic (tbd.<group>.core)
and storage; it has no rules of its own. Its arguments are described by the group's Malli schema
(tbd.<group>.schema), the same one the HTTP routes use, so shapes are defined once and every tool
gets its input schema and argument validation from them (see tbd.mcp.schema).
Like the HTTP handlers, a tool is a thin adapter over a group's business logic: pure functions
(tbd.<group>.core) or, for a group with storage, its service (tbd.<group>.service), which the
HTTP routes call too. A tool has no rules of its own and never touches a database. Its
arguments are described by the group's Malli schema (tbd.<group>.schema), the same one the HTTP
routes use, so shapes are defined once and every tool gets its input schema and argument
validation from them (see tbd.mcp.schema).
A tool function takes the coerced arguments and the request context, and returns a string,
number, boolean or vector of content, or throws: an exception becomes an error result the model
@@ -13,9 +15,8 @@
[tbd.math.schema :as math-schema]
[tbd.mcp.schema :refer [tool ctx-get]]
[tbd.oauth.middleware :as oauth-mw]
[tbd.todo.core :as todo]
[tbd.todo.db :as todo-db]
[tbd.todo.schema :as todo-schema]))
[tbd.todo.schema :as todo-schema]
[tbd.todo.service :as todo]))
(def todo-ds-key
"Where the todo database (a delay, as for the HTTP routes) is in the request's plumcp runtime
@@ -33,20 +34,12 @@
(defn- ->json [data] (slurp (m/encode "application/json" data)))
(defn- ok!
"The value of a todo.core result, or throws its error."
"The value of a service result, or throws its error (which reaches the model as an error result)."
[{:keys [ok error]}]
(or ok (throw (ex-info error {}))))
(if error (throw (ex-info error {})) ok))
(defn- now [] (System/currentTimeMillis))
;; Todos belong to the caller. Someone else's is reported exactly like a missing one.
(defn- todo-ds [ctx] @(ctx-get ctx todo-ds-key))
(defn- owner-id [ctx] (:id (current-user ctx)))
(defn- find-todo! [ctx id]
(or (todo-db/find-todo (todo-ds ctx) (owner-id ctx) id)
(throw (ex-info "todo not found" {:id id}))))
;; Todos belong to the caller, and tbd.todo.service scopes every operation to them.
(defn- todo-ds [ctx] (ctx-get ctx todo-ds-key))
(def primitives
{:tools
@@ -62,23 +55,20 @@
(format "%s (Gitea user %d)%s" login id (if admin? ", admin" "")))))
(tool "list_todos" "Lists your todos (JSON: id, title, done, created-at, updated-at), oldest first" [:map]
(fn [_ ctx] (->json (todo-db/list-todos (todo-ds ctx) (owner-id ctx)))))
(fn [_ ctx] (->json (ok! (todo/list-todos (todo-ds ctx) (current-user ctx))))))
(tool "get_todo" "Gets one of your todos by id (JSON)" todo-schema/IdParam
(fn [{:keys [id]} ctx] (->json (find-todo! ctx id))))
(fn [{:keys [id]} ctx] (->json (ok! (todo/find-todo (todo-ds ctx) (current-user ctx) id)))))
(tool "create_todo" "Creates a todo for you and returns it (JSON)" todo-schema/NewTodo
(fn [{:keys [title]} ctx]
(->json (todo-db/insert! (todo-ds ctx) (ok! (todo/new-todo (owner-id ctx) title (now)))))))
(fn [{:keys [title]} ctx] (->json (ok! (todo/create! (todo-ds ctx) (current-user ctx) title)))))
(tool "update_todo" "Changes the title and/or done flag of one of your todos and returns it (JSON)"
todo-schema/UpdateTodo
(fn [{:keys [id] :as input} ctx]
(let [changed (ok! (todo/apply-changes (find-todo! ctx id) (dissoc input :id) (now)))]
(->json (todo-db/update! (todo-ds ctx) (owner-id ctx) changed)))))
(->json (ok! (todo/update! (todo-ds ctx) (current-user ctx) id (dissoc input :id))))))
(tool "delete_todo" "Deletes one of your todos" todo-schema/IdParam
(fn [{:keys [id]} ctx]
(find-todo! ctx id)
(todo-db/delete! (todo-ds ctx) (owner-id ctx) id)
(ok! (todo/delete! (todo-ds ctx) (current-user ctx) id))
"deleted"))]})
+24 -31
View File
@@ -1,49 +1,42 @@
(ns tbd.todo.routes
(:require [tbd.http :refer [respond]]
[tbd.todo.core :as todo]
[tbd.todo.db :as db]
[tbd.todo.schema :as schema]))
[tbd.todo.schema :as schema]
[tbd.todo.service :as service]))
(def ^:private not-found (respond 404 {:error "todo not found"}))
;; Todos belong to the logged-in user (:login? true puts them on the request as :user); the
;; rules, and the scoping to that user, are tbd.todo.service's, shared with the MCP tools. These
;; handlers only turn a service result into a response.
(def ^:private error-status {:invalid 400 :not-found 404})
;; Todos belong to the logged-in user (:login? true puts them on the request). Someone else's
;; todo is answered exactly like a missing one, so ids can't be probed.
(defn- owner-id [req] (get-in req [:user :id]))
(defn- now [] (System/currentTimeMillis))
(defn- reply
"The response for a service `result`: `status` and `(shape value)` when it is ok, else the
error's status and message."
([status result] (reply status identity result))
([status shape {:keys [ok error kind]}]
(if error
(respond (error-status kind) {:error error})
(respond status (shape ok)))))
(defn- list-handler [ds]
(fn [req]
(respond 200 {:todos (db/list-todos @ds (owner-id req))})))
(fn [{:keys [user]}]
(reply 200 (fn [todos] {:todos todos}) (service/list-todos ds user))))
(defn- create-handler [ds]
(fn [{{{:keys [title]} :body} :parameters :as req}]
(let [{:keys [ok error]} (todo/new-todo (owner-id req) title (now))]
(if error
(respond 400 {:error error})
(respond 201 (db/insert! @ds ok))))))
(fn [{{{:keys [title]} :body} :parameters :keys [user]}]
(reply 201 (service/create! ds user title))))
(defn- get-handler [ds]
(fn [{{{:keys [id]} :path} :parameters :as req}]
(if-let [found (db/find-todo @ds (owner-id req) id)]
(respond 200 found)
not-found)))
(fn [{{{:keys [id]} :path} :parameters :keys [user]}]
(reply 200 (service/find-todo ds user id))))
(defn- patch-handler [ds]
(fn [{{{:keys [id]} :path changes :body} :parameters :as req}]
(let [owner (owner-id req)]
(if-let [found (db/find-todo @ds owner id)]
(let [{:keys [ok error]} (todo/apply-changes found changes (now))]
(if error
(respond 400 {:error error})
(respond 200 (db/update! @ds owner ok))))
not-found))))
(fn [{{{:keys [id]} :path changes :body} :parameters :keys [user]}]
(reply 200 (service/update! ds user id changes))))
(defn- delete-handler [ds]
(fn [{{{:keys [id]} :path} :parameters :as req}]
(if (db/delete! @ds (owner-id req) id)
(respond 200 {:deleted true})
not-found)))
(fn [{{{:keys [id]} :path} :parameters :keys [user]}]
(reply 200 (fn [_] {:deleted true}) (service/delete! ds user id))))
(defn routes
"`ds` is a delay of the todo database (see tbd.todo.db/open), shared with the MCP tools."
+2 -1
View File
@@ -3,7 +3,8 @@
the HTTP routes coerce and validate requests with it (tbd.todo.routes), and the MCP tools
derive their input schema and validate their arguments from it (tbd.mcp.tools). They describe
shape only (types, which keys may be missing); the rules on values (trimming, blank or too long
titles, an empty change) stay in tbd.todo.core, which both callers go through."
titles, an empty change) stay in tbd.todo.core, applied by tbd.todo.service, which both callers
go through."
(:require [malli.util :as mu]
[tbd.todo.core :as todo]))
+65
View File
@@ -0,0 +1,65 @@
(ns tbd.todo.service
"What a caller can do with todos: the one place that ties the rules (tbd.todo.core) to storage
(tbd.todo.db). Both front ends, the HTTP routes (tbd.todo.routes) and the MCP tools
(tbd.mcp.tools), call these and nothing lower, so ownership, not-found and validation behave the
same on both and are written (and tested) once. A front end only translates: its request into
arguments, and the result into a response.
`ds` is the delay of the todo database the app shares (opened on first use, so a caller who is
turned away earlier never opens it). `user` is the authenticated caller, {:id ...} at least
(however they proved who they are: a session or an access token); a todo is only ever reached
through its owner's id, and someone else's is reported exactly like a missing one.
Every fn returns {:ok value} or {:error message :kind kind}, where kind is :invalid (the
input is no good) or :not-found."
(:require [tbd.todo.core :as todo]
[tbd.todo.db :as db]))
(defn- now [] (System/currentTimeMillis))
(defn- owner-id [user] (:id user))
(def ^:private not-found {:error "todo not found" :kind :not-found})
(defn- invalid [{:keys [error]}] {:error error :kind :invalid})
(defn list-todos
"The user's todos, oldest first."
[ds user]
{:ok (db/list-todos @ds (owner-id user))})
(defn find-todo
"The user's todo with this id."
[ds user id]
(if-let [found (db/find-todo @ds (owner-id user) id)]
{:ok found}
not-found))
(defn create!
"Stores a new todo for the user and returns it."
[ds user title]
(let [{:keys [ok] :as built} (todo/new-todo (owner-id user) title (now))]
(if ok
{:ok (db/insert! @ds ok)}
(invalid built))))
(defn update!
"Applies `changes` (:title and/or :done) to the user's todo and returns it."
[ds user id changes]
(let [owner (owner-id user)]
(if-let [found (db/find-todo @ds owner id)]
(let [{:keys [ok] :as changed} (todo/apply-changes found changes (now))]
(if ok
;; nil only if it was deleted since the lookup.
(if-let [stored (db/update! @ds owner ok)]
{:ok stored}
not-found)
(invalid changed)))
not-found)))
(defn delete!
"Deletes the user's todo; {:ok true} once it is gone."
[ds user id]
(if (db/delete! @ds (owner-id user) id)
{:ok true}
not-found))