+17
-27
@@ -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
@@ -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."
|
||||
|
||||
@@ -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]))
|
||||
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user