[Markdown] Tools for working with agents.
1
;;; rail-tools.el --- RAIL MCP tools for RAIL.org -*- lexical-binding: t; -*-
2
3
;; Copyright (C) 2025
4
5
;; This file is NOT part of GNU Emacs.
6
7
;;; Commentary:
8
9
;; RAIL means "Rolling Action Item List".
10
;;
11
;; This file registers dedicated Emacs MCP tools for the RAIL skill so the
12
;; agent does not run raw `eval-elisp' snippets for each capture, status
13
;; change, log, result, or verification. Each operation becomes a named tool.
14
;;
15
;; RAIL.org is a flat stream. Each action item is a top-level heading, newest
16
;; first, tagged from a vocabulary that the file itself declares. The file
17
;; holds no container heading.
18
;;
19
;; The tools carry no project vocabulary and no project workflow. The stream
20
;; file owns both. The `#+TODO:' line declares the status keywords. The
21
;; `#+TAGS:' lines declare the tag vocabulary, grouped into axes. The tools
22
;; read both from the file, so one tool file serves every project.
23
;;
24
;; Why dedicated tools instead of `eval-elisp'?
25
;;
26
;; The `eval-elisp' tool routes its argument through
27
;; `mcp-server-security-safe-eval', whose form walker blocks or prompts for
28
;; "dangerous" functions such as `find-file-noselect', `write-file', and
29
;; `save-buffer'. With `mcp-server-security-prompt-for-permissions' set to t,
30
;; every RAIL snippet triggers a minibuffer prompt.
31
;;
32
;; A registered MCP tool runs through `mcp-server-tools-call', which calls the
33
;; handler function directly and does NOT pass through the form walker. The
34
;; handlers below therefore run without the repeated security prompt. Each
35
;; tool also carries MCP `annotations' so the MCP client can auto-approve the
36
;; read-only tools.
37
;;
38
;; The tools operate only on the file "RAIL.org". They find that file
39
;; under `rail-project-root', or under a caller-supplied project ROOT that
40
;; overrides it. They never touch any other file.
41
;;
42
;; `rail-project-root' comes from an upward search for the stream file. The
43
;; search starts at this file's own directory, then at `default-directory'. It
44
;; assumes no directory layout, so this file needs no absolute path and it
45
;; works on every machine.
46
;;
47
;; Install the tools once per Emacs session. Load this file, and the tools
48
;; register themselves. With `mcp-server-emacs-tools-enabled' set to `all',
49
;; which is the default, they appear in the MCP tool list at once.
50
;;
51
;; The MCP framework is a soft dependency. When the framework is absent, for
52
;; example in a batch test run, this file still loads and every handler stays
53
;; callable. Run the test suite with the run-tests.sh script beside this file.
54
55
;;; Code:
56
57
(require 'cl-lib)
58
(require 'org)
59
(require 'org-id)
60
(require 'json)
61
(require 'subr-x)
62
63
;; Load the MCP tool framework when it is available. When it is absent, for
64
;; example in a batch test run, define the two symbols the registrations below
65
;; need and discard each registration. The handler functions stay callable, so
66
;; the test suite runs on any machine without the framework.
67
(defconst rail-mcp-available (require 'mcp-server-tools nil t)
68
"Non-nil when the Emacs MCP tool framework is available.")
69
70
(unless rail-mcp-available
71
;; Define plain functions, never a struct. A stub struct would clobber the
72
;; real slot layout if the framework loads later in the same session.
73
(defun make-mcp-server-tool (&rest _args)
74
"Return nil. The MCP framework is absent."
75
nil)
76
(defun mcp-server-register-tool (_tool)
77
"Discard _TOOL. The MCP framework is absent."
78
nil))
79
80
(defvar rail-stream-file-name "RAIL.org"
81
"Name of the Org file that RAIL manages.")
82
83
(defvar rail-tools-path
84
(let ((file (or load-file-name buffer-file-name)))
85
(and file (expand-file-name file)))
86
"Absolute path of this file, or nil when the path is unknown.")
87
88
(defun rail-locate-root (start)
89
"Return the closest directory at or above START that holds the stream file.
90
The stream file is `rail-stream-file-name'. Return nil when no
91
ancestor directory holds that file."
92
(let ((dir (and start (locate-dominating-file
93
(file-name-as-directory (expand-file-name start))
94
rail-stream-file-name))))
95
(and dir (expand-file-name (file-name-as-directory dir)))))
96
97
(defvar rail-project-root
98
(or (rail-locate-root (and rail-tools-path
99
(file-name-directory rail-tools-path)))
100
(rail-locate-root default-directory)
101
(expand-file-name default-directory))
102
"Default project directory that holds the RAIL stream file.
103
The value comes from an upward search for `rail-stream-file-name',
104
first from this file's own directory, then from `default-directory'.
105
The search makes no assumption about the depth of this file in the
106
project. Set this variable to override the search, or pass a `root'
107
argument to any tool.")
108
109
;;; Helpers
110
111
(defun rail-tools--file (args)
112
"Return the absolute path of the stream file for ARGS.
113
ARGS may hold a `root' string that names the project directory. When
114
`root' is absent, use `rail-project-root'. Signal an error when the
115
selected root is not a directory."
116
(let ((root (or (alist-get 'root args) rail-project-root)))
117
(unless (and (stringp root) (> (length root) 0))
118
(error "No project root: pass `root' or set `rail-project-root'"))
119
(let ((dir (expand-file-name root)))
120
(unless (file-directory-p dir)
121
(error "Not a directory: %s" dir))
122
(expand-file-name rail-stream-file-name dir))))
123
124
(defun rail-tools--buffer (file)
125
"Return an org-mode buffer visiting FILE, creating it as needed."
126
(let ((buf (find-file-noselect file)))
127
(with-current-buffer buf
128
(unless (derived-mode-p 'org-mode)
129
(org-mode)))
130
buf))
131
132
(defun rail-tools--goto-id (id)
133
"Move point to the heading with Org ID in the current buffer.
134
Signal an error when ID is not found."
135
(let ((marker (org-id-find id 'marker)))
136
(unless marker
137
(error "Org ID not found: %s" id))
138
(goto-char marker)))
139
140
(defun rail-tools--fill-body ()
141
"Wrap the body of the entry at point to 72 columns.
142
Fill every paragraph after the metadata (SCHEDULED line, property
143
drawer) up to the next heading. Use `org-fill-paragraph' so Org
144
list items and other structure fill correctly. Point must be on
145
the entry heading."
146
(let ((fill-column 72))
147
(org-back-to-heading t)
148
(let ((end (save-excursion (org-end-of-subtree t t) (point-marker))))
149
;; Move past the heading and all metadata (planning line,
150
;; property drawer, logbook) to the first line of body text.
151
(org-end-of-meta-data t)
152
;; Fill each body line. `org-fill-paragraph' fills the whole
153
;; element and is idempotent, so stepping one line at a time is
154
;; safe and does not overshoot a trailing paragraph.
155
(while (< (point) end)
156
(unless (looking-at-p "^[ \t]*$")
157
(org-fill-paragraph))
158
(forward-line 1))
159
(set-marker end nil))))
160
161
(defun rail-tools--nonblank (value)
162
"Return VALUE trimmed when it is a non-blank string, else nil."
163
(and (stringp value)
164
(let ((trimmed (string-trim value)))
165
(and (> (length trimmed) 0) trimmed))))
166
167
(defconst rail-tools--closing-labels
168
'((done . "CLOSING NOTE")
169
(blocked . "BLOCKED NOTE")
170
(cancelled . "CANCELLED NOTE"))
171
"Map a closing purpose to its `:LOGBOOK:' note label.
172
The label mirrors Org's own `CLOSING NOTE' heading for the `done'
173
purpose, so a RAIL closing note reads like a native Org log note.")
174
175
(defun rail-tools--drawer-insert (line &optional nofill)
176
"Insert LINE as the newest item in the entry's `:LOGBOOK:' drawer.
177
Create the drawer directly after the metadata when it is absent. LINE
178
is one already-formatted list item without its leading dash. Wrap the
179
inserted item to 72 columns, unless NOFILL is non-nil, in which case
180
keep it on one line. This never edits an existing item or the body.
181
Point must be on the entry heading."
182
(let ((fill-column 72))
183
(org-back-to-heading t)
184
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker))))
185
(org-back-to-heading t)
186
(let ((drawer-start
187
(save-excursion
188
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
189
(line-beginning-position)))))
190
(unless drawer-start
191
(org-end-of-meta-data t)
192
(insert ":LOGBOOK:\n:END:\n")
193
(setq drawer-start
194
(save-excursion
195
(org-back-to-heading t)
196
(re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
197
(line-beginning-position))))
198
(goto-char drawer-start)
199
(forward-line 1)
200
(let ((item-start (point)))
201
(insert (format "- %s\n" line))
202
(unless nofill
203
(save-excursion
204
(goto-char item-start)
205
(org-fill-paragraph)))))
206
(set-marker subtree-end nil))))
207
208
(defun rail-tools--append-log (note)
209
"Append NOTE as a timestamped item to the entry's `:LOGBOOK:' drawer.
210
Insert the newest item first and wrap it to 72 columns. This is
211
append-only. It never edits an existing item or the body. Point must
212
be on the entry heading."
213
(let ((ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
214
(rail-tools--drawer-insert (format "%s %s" ts note))))
215
216
(defun rail-tools--closing-note-text (commit tests &optional model notes)
217
"Compose the closing-note text for a completed item.
218
COMMIT is a commit hash. TESTS is a short recap such as \"215 pass\".
219
MODEL names the agent that did the work, and NOTES adds a free-text
220
tail after a semicolon. Both are optional. Return one line, because
221
the reader reads one line."
222
(let ((model (rail-tools--nonblank model))
223
(notes (rail-tools--nonblank notes)))
224
(concat (if model (format "model=%s " model) "")
225
(format "commit=%s tests=%s" commit tests)
226
(if notes (format "; %s" notes) ""))))
227
228
(defun rail-tools--append-closing-note (purpose text)
229
"Write a closing note into the entry's `:LOGBOOK:' drawer.
230
PURPOSE is `done', `blocked', or `cancelled'. TEXT is the note body.
231
The note reads `LABEL [timestamp] :: TEXT', with LABEL from
232
`rail-tools--closing-labels'. It lands in the drawer next to the
233
state timestamp, and it replaces the old body-line evidence. When the
234
entry already carries a note of the same PURPOSE, replace that note in
235
place, so a re-run does not stack a second note. Point must be on the
236
entry heading."
237
(let ((label (or (cdr (assq purpose rail-tools--closing-labels))
238
(error "Unknown closing purpose: %s" purpose)))
239
(ts (format-time-string "[%Y-%m-%d %a %H:%M]")))
240
(org-back-to-heading t)
241
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
242
(line (format "%s %s :: %s" label ts text)))
243
(org-back-to-heading t)
244
(if (re-search-forward
245
(format "^[ \t]*- %s \\[[^]]*\\] ::.*$" (regexp-quote label))
246
subtree-end t)
247
;; Replace an existing note of this purpose in place. Keep the
248
;; note on one line, because the reader reads one line.
249
(replace-match (format "- %s" line) t t)
250
(rail-tools--drawer-insert line 'nofill))
251
(set-marker subtree-end nil))))
252
253
(defun rail-tools--closing-note-of (purpose)
254
"Return the text of the newest closing note of PURPOSE at point.
255
PURPOSE is `done', `blocked', or `cancelled'. Return nil when the
256
entry carries no such note. Point must be on the entry heading."
257
(let ((label (cdr (assq purpose rail-tools--closing-labels))))
258
(org-back-to-heading t)
259
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
260
(org-back-to-heading t)
261
(when (re-search-forward
262
(format "^[ \t]*- %s \\[[^]]*\\] :: \\(.*\\)$"
263
(regexp-quote label))
264
subtree-end t)
265
(string-trim (match-string-no-properties 1))))))
266
267
(defmacro rail-tools--json (&rest body)
268
"Evaluate BODY and return its value as a JSON string.
269
Catch any error and return a JSON object with an `error' field."
270
(declare (indent 0))
271
`(condition-case err
272
(json-encode (progn ,@body))
273
(error (json-encode `((error . ,(error-message-string err)))))))
274
275
;;; Tag vocabulary
276
277
;; The vocabulary is not hardcoded. Each stream file declares it with
278
;; `#+TAGS:' group-tag lines, for example:
279
;;
280
;; #+TAGS: [ Kind : feat fix chore ]
281
;; #+TAGS: [ Scope : core web ]
282
;;
283
;; Org parses those lines into `org-current-tag-alist'. The functions below
284
;; read that alist, so the vocabulary follows the file, not this code. A file
285
;; with no `#+TAGS:' line accepts any tag.
286
287
(defun rail-tools--tag-axes ()
288
"Return the tag vocabulary of the current buffer, grouped by axis.
289
Read the group tags that Org parsed from the `#+TAGS:' lines into
290
`org-current-tag-alist'. Return an alist that maps each axis symbol to
291
its list of tag strings. Return nil when the file declares no axis, and
292
then the file accepts any tag."
293
(let ((axes '()) (current nil))
294
(dolist (entry org-current-tag-alist)
295
(pcase entry
296
(`(:startgrouptag) (setq current nil))
297
(`(:endgrouptag)
298
(when current
299
(push (cons (intern (downcase (car current)))
300
(nreverse (cdr current)))
301
axes))
302
(setq current nil))
303
(`(:grouptags))
304
(`(,(and tag (pred stringp)) . ,_)
305
(if current
306
(setcdr current (cons tag (cdr current)))
307
;; The first tag in a group is the axis name.
308
(setq current (cons tag '()))))))
309
(nreverse axes)))
310
311
(defun rail-tools--all-tags ()
312
"Return every tag the current buffer declares, as one flat list.
313
Return nil when the file declares no vocabulary."
314
(apply #'append (mapcar #'cdr (rail-tools--tag-axes))))
315
316
(defun rail-tools--check-tags (tags)
317
"Signal an error when TAGS holds a tag outside the file vocabulary.
318
TAGS is a list of strings. When the file declares no vocabulary, accept
319
any tag. Return TAGS unchanged when valid."
320
(let ((allowed (rail-tools--all-tags)))
321
(when allowed
322
(dolist (tag tags)
323
(unless (member tag allowed)
324
(error "Unknown tag `%s'; allowed: %s"
325
tag (string-join allowed ", "))))))
326
tags)
327
328
(defun rail-tools--goto-stream-top ()
329
"Move point to the insertion place for a new item.
330
That place is the start of the first top-level heading, after the
331
file preamble. When no heading exists, move to the end of the
332
preamble."
333
(goto-char (point-min))
334
(if (re-search-forward "^\\* " nil t)
335
(goto-char (line-beginning-position))
336
(goto-char (point-max))))
337
338
;;; inspect (read-only)
339
340
(defun rail-tools--inspect-handler (args)
341
"Report the TODO sequence and the tag vocabulary for RAIL.org.
342
Read both from the stream file, so the report mirrors the file."
343
(rail-tools--json
344
(let ((file (rail-tools--file args)))
345
(with-current-buffer (rail-tools--buffer file)
346
(org-with-wide-buffer
347
`((file . ,file)
348
(todo_keywords . ,(vconcat org-todo-keywords-1))
349
(tags . ,(mapcar (lambda (axis)
350
(cons (car axis) (vconcat (cdr axis))))
351
(rail-tools--tag-axes)))))))))
352
353
(mcp-server-register-tool
354
(make-mcp-server-tool
355
:name "rail-inspect"
356
:title "RAIL Inspect"
357
:description "Inspect RAIL.org: return its TODO keyword sequence and the tag vocabulary that the file declares, grouped by axis. Read-only."
358
:input-schema '((type . "object")
359
(properties . ((root . ((type . "string")
360
(description . "Absolute path to the project directory containing RAIL.org")))))
361
(required . []))
362
:function #'rail-tools--inspect-handler
363
:annotations '((readOnlyHint . t)
364
(destructiveHint . :false)
365
(idempotentHint . t)
366
(openWorldHint . :false))))
367
368
;;; list (read-only)
369
370
(defun rail-tools--list-handler (args)
371
"List the top-level action items in RAIL.org, newest first.
372
ARGS keys: `root', `state' (optional), `tag' (optional). When STATE is
373
given, return only items with that TODO keyword. When TAG is given,
374
return only items that carry that tag. Each row has `id', `title',
375
`state', `scheduled', and `tags'."
376
(rail-tools--json
377
(let ((file (rail-tools--file args))
378
(state (alist-get 'state args))
379
(tag (alist-get 'tag args)))
380
(with-current-buffer (rail-tools--buffer file)
381
(org-with-wide-buffer
382
(goto-char (point-min))
383
(let ((rows '()))
384
(while (re-search-forward "^\\* " nil t)
385
(let ((todo (org-get-todo-state))
386
(tags (org-get-tags nil t)))
387
(when (and (or (null state) (equal state todo))
388
(or (null tag) (member tag tags)))
389
(push `((id . ,(org-id-get))
390
(title . ,(org-get-heading t t t t))
391
(state . ,todo)
392
(scheduled . ,(org-entry-get nil "SCHEDULED"))
393
(tags . ,(vconcat tags)))
394
rows))))
395
;; The file is newest-first, so reverse to keep that order.
396
(vconcat (nreverse rows))))))))
397
398
(mcp-server-register-tool
399
(make-mcp-server-tool
400
:name "rail-list"
401
:title "RAIL List"
402
:description "List the top-level action items in RAIL.org, newest first, with each entry's Org ID, title, TODO state, SCHEDULED time, and tags. Pass an optional `state' or `tag' to filter. Read-only."
403
:input-schema '((type . "object")
404
(properties . ((root . ((type . "string")
405
(description . "Absolute path to the project directory")))
406
(state . ((type . "string")
407
(description . "Optional TODO keyword filter, for example TODO or IN-PROGRESS")))
408
(tag . ((type . "string")
409
(description . "Optional tag filter, for example web or major")))))
410
(required . []))
411
:function #'rail-tools--list-handler
412
:annotations '((readOnlyHint . t)
413
(destructiveHint . :false)
414
(idempotentHint . t)
415
(openWorldHint . :false))))
416
417
;;; capture
418
419
(defun rail-tools--capture-handler (args)
420
"Capture a TODO entry at the top of the RAIL.org stream.
421
ARGS keys: `root', `title', `body' (optional), `tags' (optional array).
422
Insert the entry as a top-level heading directly below the file
423
preamble, so the newest item is first. Record the capture time as an
424
inactive SCHEDULED timestamp, apply TAGS from the file vocabulary,
425
assign an Org ID, and wrap the body to 72 columns."
426
(rail-tools--json
427
(let* ((file (rail-tools--file args))
428
(title (or (alist-get 'title args) (error "Missing `title'")))
429
(body (or (alist-get 'body args) ""))
430
(raw-tags (append (alist-get 'tags args) nil))
431
(captured-at (format-time-string "[%Y-%m-%d %a %H:%M]")))
432
(with-current-buffer (rail-tools--buffer file)
433
(org-with-wide-buffer
434
;; Validate inside the buffer, because the vocabulary lives here.
435
(let ((tags (rail-tools--check-tags raw-tags)))
436
(rail-tools--goto-stream-top)
437
(let ((start (point)))
438
(insert (format "* TODO %s\nSCHEDULED: %s\n" title captured-at))
439
(unless (string-empty-p body)
440
(insert body "\n"))
441
(goto-char start)
442
(when tags
443
(org-set-tags tags))
444
(let ((id (org-id-get-create)))
445
(rail-tools--fill-body)
446
(when (buffer-modified-p) (save-buffer))
447
(goto-char (org-id-find id 'marker))
448
`((id . ,id)
449
(file . ,file)
450
(heading . ,(org-get-heading t t t t))
451
(tags . ,(vconcat (org-get-tags nil t))))))))))))
452
453
(mcp-server-register-tool
454
(make-mcp-server-tool
455
:name "rail-capture"
456
:title "RAIL Capture"
457
:description "Capture a TODO action item as a top-level heading at the top of RAIL.org, so the newest item comes first. Applies tags that the file vocabulary permits, records the capture time as an inactive SCHEDULED timestamp, wraps the body to 72 columns, and assigns an Org ID."
458
:input-schema '((type . "object")
459
(properties . ((root . ((type . "string")
460
(description . "Absolute path to the project directory")))
461
(title . ((type . "string")
462
(description . "Imperative title under 60 chars")))
463
(body . ((type . "string")
464
(description . "Full request text, verbatim")))
465
(tags . ((type . "array")
466
(items . ((type . "string")))
467
(description . "Tags from the vocabulary that the file declares in its #+TAGS: lines. Run rail-inspect to read the axes and their allowed tags.")))))
468
(required . ["title"]))
469
:function #'rail-tools--capture-handler
470
:annotations '((readOnlyHint . :false)
471
(destructiveHint . :false)
472
(idempotentHint . :false)
473
(openWorldHint . :false))))
474
475
;;; set-status
476
477
(defun rail-tools--set-status-handler (args)
478
"Change the TODO keyword of an entry.
479
ARGS keys: `root', `id', `state'. STATE must be one keyword from the
480
file's own #+TODO sequence, and must not be DONE (use rail-complete)."
481
(rail-tools--json
482
(let ((file (rail-tools--file args))
483
(id (or (alist-get 'id args) (error "Missing `id'")))
484
(state (or (alist-get 'state args) (error "Missing `state'"))))
485
(when (string-equal state "DONE")
486
(error "Use rail-complete for DONE, not rail-set-status"))
487
(with-current-buffer (rail-tools--buffer file)
488
(rail-tools--goto-id id)
489
(org-todo state)
490
(when (buffer-modified-p) (save-buffer))
491
`((id . ,id)
492
(state . ,(org-get-todo-state)))))))
493
494
(mcp-server-register-tool
495
(make-mcp-server-tool
496
:name "rail-set-status"
497
:title "RAIL Set Status"
498
:description "Set the TODO keyword of a RAIL entry to any open keyword from the file's own #+TODO sequence. Does not accept DONE; use rail-complete for that."
499
:input-schema '((type . "object")
500
(properties . ((root . ((type . "string")))
501
(id . ((type . "string")
502
(description . "Org ID of the entry")))
503
(state . ((type . "string")
504
(description . "TODO keyword from the file's #+TODO sequence")))))
505
(required . ["id" "state"]))
506
:function #'rail-tools--set-status-handler
507
:annotations '((readOnlyHint . :false)
508
(destructiveHint . :false)
509
(idempotentHint . t)
510
(openWorldHint . :false))))
511
512
;;; log
513
514
(defun rail-tools--log-handler (args)
515
"Append a timestamped note to an entry's `:LOGBOOK:' drawer.
516
ARGS keys: `root', `id', `note'. Append-only progress feedback from an
517
agentic session. Never edits an existing note or the item body."
518
(rail-tools--json
519
(let ((file (rail-tools--file args))
520
(id (or (alist-get 'id args) (error "Missing `id'")))
521
(note (or (alist-get 'note args) (error "Missing `note'"))))
522
(with-current-buffer (rail-tools--buffer file)
523
(rail-tools--goto-id id)
524
(rail-tools--append-log note)
525
(when (buffer-modified-p) (save-buffer))
526
`((id . ,id)
527
(state . ,(org-get-todo-state)))))))
528
529
(mcp-server-register-tool
530
(make-mcp-server-tool
531
:name "rail-log"
532
:title "RAIL Log"
533
:description "Append a timestamped progress note to a RAIL entry's `:LOGBOOK:' drawer. The drawer is append-only. The tool never rewrites an earlier note, and never rewrites the item body. Newest note first, wrapped to 72 columns."
534
:input-schema '((type . "object")
535
(properties . ((root . ((type . "string")))
536
(id . ((type . "string")
537
(description . "Org ID of the entry")))
538
(note . ((type . "string")
539
(description . "Progress note to append")))))
540
(required . ["id" "note"]))
541
:function #'rail-tools--log-handler
542
:annotations '((readOnlyHint . :false)
543
(destructiveHint . :false)
544
(idempotentHint . :false)
545
(openWorldHint . :false))))
546
547
;;; set-result
548
549
(defun rail-tools--set-result-handler (args)
550
"Write the DONE closing note for an entry without changing its state.
551
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
552
`notes' (optional). COMMIT is a commit hash. TESTS is a short recap
553
such as \"215 pass\". Compose the note text and write it as a
554
`CLOSING NOTE' item in the `:LOGBOOK:' drawer, replacing an earlier
555
one. Use this to pre-stage the closing evidence before rail-complete."
556
(rail-tools--json
557
(let ((file (rail-tools--file args))
558
(id (or (alist-get 'id args) (error "Missing `id'")))
559
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
560
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
561
(model (alist-get 'model args))
562
(notes (alist-get 'notes args)))
563
(with-current-buffer (rail-tools--buffer file)
564
(rail-tools--goto-id id)
565
(rail-tools--append-closing-note
566
'done (rail-tools--closing-note-text commit tests model notes))
567
(when (buffer-modified-p) (save-buffer))
568
(rail-tools--goto-id id)
569
`((id . ,id)
570
(result . ,(rail-tools--result-text)))))))
571
572
(mcp-server-register-tool
573
(make-mcp-server-tool
574
:name "rail-set-result"
575
:title "RAIL Set Result"
576
:description "Write the DONE closing note for a RAIL entry into the :LOGBOOK: drawer, recording the commit hash, a short test recap, and optionally the model that did the work and a free-text note. Replaces an earlier closing note. Use it to pre-stage the closing evidence; rail-complete writes the same note when it closes the item."
577
:input-schema '((type . "object")
578
(properties . ((root . ((type . "string")))
579
(id . ((type . "string")
580
(description . "Org ID of the entry")))
581
(commit . ((type . "string")
582
(description . "Commit hash")))
583
(tests . ((type . "string")
584
(description . "Short test recap, for example \"215 pass\"")))
585
(model . ((type . "string")
586
(description . "Optional model or agent that did the work, for example the agent name")))
587
(notes . ((type . "string")
588
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
589
(required . ["id" "commit" "tests"]))
590
:function #'rail-tools--set-result-handler
591
:annotations '((readOnlyHint . :false)
592
(destructiveHint . :false)
593
(idempotentHint . t)
594
(openWorldHint . :false))))
595
596
;;; check (checklist for sub-tasks)
597
598
(defconst rail-tools--checklist-header "Checklist [/]:"
599
"Header line that introduces an item's checkbox list.
600
The `[/]' cookie tracks completed items against the total.")
601
602
(defun rail-tools--checklist-add (item)
603
"Add ITEM as an unchecked checkbox to the entry at point.
604
Create the checklist block when it does not exist. Wrap ITEM to 72
605
columns and refresh the `[/]' cookie. Point must be on the heading."
606
(let ((fill-column 72))
607
(org-back-to-heading t)
608
(let* ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker)))
609
(line (format "- [ ] %s\n" item))
610
insert-at)
611
(org-back-to-heading t)
612
(if (re-search-forward "^Checklist \\[[0-9]*/[0-9]*\\]:[ \t]*$"
613
subtree-end t)
614
;; Existing block: step past the trailing checkbox items.
615
(progn (forward-line 1)
616
(while (looking-at-p "^- \\[.\\] \\|^ ") (forward-line 1))
617
(setq insert-at (point))
618
(insert line))
619
;; No block: append one at the end of the body.
620
(goto-char subtree-end)
621
(skip-chars-backward "\n")
622
(insert "\n\n" rail-tools--checklist-header "\n")
623
(setq insert-at (point))
624
(insert line))
625
(save-excursion (goto-char insert-at) (org-fill-paragraph))
626
(org-update-checkbox-count)
627
(set-marker subtree-end nil))))
628
629
(defun rail-tools--checklist-toggle (item)
630
"Toggle the checkbox whose text matches ITEM in the entry at point.
631
Signal an error when no item matches. Refresh the `[/]' cookie.
632
Point must be on the heading."
633
(org-back-to-heading t)
634
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point-marker))))
635
(org-back-to-heading t)
636
(if (re-search-forward (concat "^- \\[.\\] " (regexp-quote item))
637
subtree-end t)
638
(progn (beginning-of-line) (org-toggle-checkbox)
639
(org-update-checkbox-count))
640
(set-marker subtree-end nil)
641
(error "No checklist item matches: %s" item))
642
(set-marker subtree-end nil)))
643
644
(defun rail-tools--checklist-items ()
645
"Return the checklist items of the entry at point.
646
Each item is an alist with `done' and `text'. Point must be on the
647
heading."
648
(org-back-to-heading t)
649
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
650
(items '()))
651
(org-back-to-heading t)
652
(while (re-search-forward "^- \\[\\(.\\)\\] \\(.*\\)$" subtree-end t)
653
(push `((done . ,(if (string-equal (match-string 1) " ") :json-false t))
654
(text . ,(string-trim (match-string-no-properties 2))))
655
items))
656
(vconcat (nreverse items))))
657
658
(defun rail-tools--check-handler (args)
659
"Manage the checklist of an item, for splitting a complex task.
660
ARGS keys: `root', `id', `action' (add|toggle|list), `item'.
661
`add' appends an unchecked item. `toggle' flips a matching item.
662
`list' returns the items. A `[/]' cookie tracks progress."
663
(rail-tools--json
664
(let ((file (rail-tools--file args))
665
(id (or (alist-get 'id args) (error "Missing `id'")))
666
(action (or (alist-get 'action args) (error "Missing `action'")))
667
(item (alist-get 'item args)))
668
(with-current-buffer (rail-tools--buffer file)
669
(rail-tools--goto-id id)
670
(cond
671
((string-equal action "add")
672
(unless item (error "`add' needs an `item'"))
673
(rail-tools--checklist-add item))
674
((string-equal action "toggle")
675
(unless item (error "`toggle' needs an `item'"))
676
(rail-tools--checklist-toggle item))
677
((string-equal action "list") nil)
678
(t (error "Unknown action `%s'; use add, toggle, or list" action)))
679
(when (buffer-modified-p) (save-buffer))
680
(rail-tools--goto-id id)
681
`((id . ,id)
682
(items . ,(rail-tools--checklist-items)))))))
683
684
(mcp-server-register-tool
685
(make-mcp-server-tool
686
:name "rail-check"
687
:title "RAIL Checklist"
688
:description "Manage an item's checkbox list to split a complex task into sub-tasks with their own done state. Actions: add an unchecked item, toggle a matching item, or list items. A [/] cookie on the checklist header tracks progress. The items stay inside the one request. They are not separate stream entries."
689
:input-schema '((type . "object")
690
(properties . ((root . ((type . "string")))
691
(id . ((type . "string")
692
(description . "Org ID of the item")))
693
(action . ((type . "string")
694
(description . "add, toggle, or list")))
695
(item . ((type . "string")
696
(description . "Item text for add or toggle")))))
697
(required . ["id" "action"]))
698
:function #'rail-tools--check-handler
699
:annotations '((readOnlyHint . :false)
700
(destructiveHint . :false)
701
(idempotentHint . :false)
702
(openWorldHint . :false))))
703
704
;;; show (read-only)
705
706
(defun rail-tools--body-text ()
707
"Return the plain body text of the entry at point.
708
Read from the first line after the metadata up to the first of: a
709
`Checklist [' line or the end of the subtree. The closing note lives
710
in the `:LOGBOOK:' drawer, not the body, so it never appears here.
711
Return the trimmed string. Point must be on the entry heading."
712
(org-back-to-heading t)
713
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point))))
714
(org-back-to-heading t)
715
(org-end-of-meta-data t)
716
(let ((body-start (point))
717
(body-end subtree-end))
718
(save-excursion
719
(goto-char body-start)
720
(when (re-search-forward "^Checklist \\[" subtree-end t)
721
(setq body-end (line-beginning-position))))
722
(string-trim
723
(buffer-substring-no-properties body-start body-end)))))
724
725
(defun rail-tools--logbook-items ()
726
"Return the `:LOGBOOK:' drawer item lines of the entry at point.
727
Each item is a string, in the order stored (newest first). Return an
728
empty vector when there is no drawer. Point must be on the heading."
729
(org-back-to-heading t)
730
(let ((subtree-end (save-excursion (org-end-of-subtree t t) (point)))
731
(items '()))
732
(org-back-to-heading t)
733
(when (re-search-forward "^[ \t]*:LOGBOOK:[ \t]*$" subtree-end t)
734
(forward-line 1)
735
(while (and (< (point) subtree-end)
736
(not (looking-at-p "^[ \t]*:END:[ \t]*$")))
737
(when (looking-at "^[ \t]*- \\(.*\\)$")
738
(push (string-trim (match-string-no-properties 1)) items))
739
(forward-line 1)))
740
(vconcat (nreverse items))))
741
742
(defun rail-tools--result-text ()
743
"Return the text of the entry's DONE closing note.
744
Read the newest `CLOSING NOTE' item from the `:LOGBOOK:' drawer.
745
Return nil when there is no such note, so it encodes as JSON null.
746
Point must be on the heading."
747
(rail-tools--closing-note-of 'done))
748
749
(defun rail-tools--show-handler (args)
750
"Return the full content of an entry.
751
ARGS keys: `root', `id'. Report the heading, state, tags, scheduled
752
and closed timestamps, body text, logbook notes, checklist items, and
753
the DONE closing note. Read-only."
754
(rail-tools--json
755
(let ((file (rail-tools--file args))
756
(id (or (alist-get 'id args) (error "Missing `id'"))))
757
(with-current-buffer (rail-tools--buffer file)
758
(org-with-wide-buffer
759
(rail-tools--goto-id id)
760
`((id . ,id)
761
(heading . ,(org-get-heading t t t t))
762
(state . ,(org-get-todo-state))
763
(tags . ,(vconcat (org-get-tags nil t)))
764
(scheduled . ,(org-entry-get nil "SCHEDULED"))
765
(closed . ,(org-entry-get nil "CLOSED"))
766
(body . ,(rail-tools--body-text))
767
(logbook . ,(rail-tools--logbook-items))
768
(checklist . ,(rail-tools--checklist-items))
769
(result . ,(rail-tools--result-text))))))))
770
771
(mcp-server-register-tool
772
(make-mcp-server-tool
773
:name "rail-show"
774
:title "RAIL Show"
775
:description "Return the full content of a RAIL entry: heading, state, tags, scheduled and closed timestamps, body text, logbook notes, checklist items, and the DONE closing note. Read-only."
776
:input-schema '((type . "object")
777
(properties . ((root . ((type . "string")))
778
(id . ((type . "string")
779
(description . "Org ID of the entry")))))
780
(required . ["id"]))
781
:function #'rail-tools--show-handler
782
:annotations '((readOnlyHint . t)
783
(destructiveHint . :false)
784
(idempotentHint . t)
785
(openWorldHint . :false))))
786
787
;;; retag
788
789
(defun rail-tools--retag-handler (args)
790
"Replace the tags on an entry with a validated set.
791
ARGS keys: `root', `id', `tags' (array). Validate TAGS against the
792
file vocabulary, then set them, keeping the file's default tag
793
alignment."
794
(rail-tools--json
795
(let ((file (rail-tools--file args))
796
(id (or (alist-get 'id args) (error "Missing `id'")))
797
(raw-tags (append (alist-get 'tags args) nil)))
798
(with-current-buffer (rail-tools--buffer file)
799
(let ((tags (rail-tools--check-tags raw-tags)))
800
(rail-tools--goto-id id)
801
(org-set-tags tags)
802
(when (buffer-modified-p) (save-buffer))
803
`((id . ,id)
804
(tags . ,(vconcat (org-get-tags nil t)))))))))
805
806
(mcp-server-register-tool
807
(make-mcp-server-tool
808
:name "rail-retag"
809
:title "RAIL Retag"
810
:description "Replace the tags on a RAIL entry with a validated set from the file vocabulary. Use this tool to re-tag an entry as its shape changes. The tool keeps the file's default tag alignment."
811
:input-schema '((type . "object")
812
(properties . ((root . ((type . "string")))
813
(id . ((type . "string")
814
(description . "Org ID of the entry")))
815
(tags . ((type . "array")
816
(items . ((type . "string")))
817
(description . "Tags from the file vocabulary")))))
818
(required . ["id" "tags"]))
819
:function #'rail-tools--retag-handler
820
:annotations '((readOnlyHint . :false)
821
(destructiveHint . :false)
822
(idempotentHint . :false)
823
(openWorldHint . :false))))
824
825
;;; cancel and block
826
827
(defun rail-tools--transition-with-reason (id keyword purpose reason)
828
"Transition entry ID to KEYWORD and record REASON as a closing note.
829
Signal an error when REASON is missing or blank. Set the TODO keyword
830
first, so the state timestamp lands, then write REASON as a PURPOSE
831
closing note in the `:LOGBOOK:' drawer. PURPOSE is `blocked' or
832
`cancelled'. The closing note replaces the old `- PURPOSE ::' body
833
line, so the decision is never silent."
834
(when (or (null reason) (string-empty-p (string-trim reason)))
835
(error "A reason is required"))
836
(rail-tools--goto-id id)
837
(org-todo keyword)
838
(rail-tools--goto-id id)
839
(rail-tools--append-closing-note purpose (string-trim reason))
840
(when (buffer-modified-p) (save-buffer)))
841
842
(defun rail-tools--cancel-handler (args)
843
"Set an entry to CANCELLED with a required reason.
844
ARGS keys: `root', `id', `reason'. Record REASON as a `CANCELLED NOTE'
845
closing note in the `:LOGBOOK:' drawer, so the decision is never
846
silent."
847
(rail-tools--json
848
(let ((file (rail-tools--file args))
849
(id (or (alist-get 'id args) (error "Missing `id'")))
850
(reason (alist-get 'reason args)))
851
(with-current-buffer (rail-tools--buffer file)
852
(rail-tools--transition-with-reason id "CANCELLED" 'cancelled reason)
853
(rail-tools--goto-id id)
854
`((id . ,id)
855
(state . ,(org-get-todo-state)))))))
856
857
(mcp-server-register-tool
858
(make-mcp-server-tool
859
:name "rail-cancel"
860
:title "RAIL Cancel"
861
:description "Set a RAIL entry to CANCELLED and record a required reason as a CANCELLED NOTE closing note in the :LOGBOOK: drawer, so the decision is never silent."
862
:input-schema '((type . "object")
863
(properties . ((root . ((type . "string")))
864
(id . ((type . "string")
865
(description . "Org ID of the entry")))
866
(reason . ((type . "string")
867
(description . "Reason for cancelling the entry")))))
868
(required . ["id" "reason"]))
869
:function #'rail-tools--cancel-handler
870
:annotations '((readOnlyHint . :false)
871
(destructiveHint . :false)
872
(idempotentHint . :false)
873
(openWorldHint . :false))))
874
875
(defun rail-tools--block-handler (args)
876
"Set an entry to BLOCKED with a required reason.
877
ARGS keys: `root', `id', `reason'. Record REASON as a `BLOCKED NOTE'
878
closing note in the `:LOGBOOK:' drawer, so the blocker is never
879
silent."
880
(rail-tools--json
881
(let ((file (rail-tools--file args))
882
(id (or (alist-get 'id args) (error "Missing `id'")))
883
(reason (alist-get 'reason args)))
884
(with-current-buffer (rail-tools--buffer file)
885
(rail-tools--transition-with-reason id "BLOCKED" 'blocked reason)
886
(rail-tools--goto-id id)
887
`((id . ,id)
888
(state . ,(org-get-todo-state)))))))
889
890
(mcp-server-register-tool
891
(make-mcp-server-tool
892
:name "rail-block"
893
:title "RAIL Block"
894
:description "Set a RAIL entry to BLOCKED and record a required reason as a BLOCKED NOTE closing note in the :LOGBOOK: drawer, so the blocker is never silent."
895
:input-schema '((type . "object")
896
(properties . ((root . ((type . "string")))
897
(id . ((type . "string")
898
(description . "Org ID of the entry")))
899
(reason . ((type . "string")
900
(description . "Reason for blocking the entry")))))
901
(required . ["id" "reason"]))
902
:function #'rail-tools--block-handler
903
:annotations '((readOnlyHint . :false)
904
(destructiveHint . :false)
905
(idempotentHint . :false)
906
(openWorldHint . :false))))
907
908
;;; complete
909
910
(defun rail-tools--complete-handler (args)
911
"Set an entry to DONE with its closing note and confirm CLOSED.
912
ARGS keys: `root', `id', `commit', `tests', `model' (optional),
913
`notes' (optional). Transition to DONE, then write the closing note
914
into the `:LOGBOOK:' drawer next to the CLOSED timestamp. Requires
915
`org-log-done' to be `time' so the normal Org transition inserts
916
CLOSED; this handler never writes CLOSED."
917
(rail-tools--json
918
(let ((file (rail-tools--file args))
919
(id (or (alist-get 'id args) (error "Missing `id'")))
920
(commit (or (alist-get 'commit args) (error "Missing `commit'")))
921
(tests (or (alist-get 'tests args) (error "Missing `tests'")))
922
(model (alist-get 'model args))
923
(notes (alist-get 'notes args)))
924
(with-current-buffer (rail-tools--buffer file)
925
(hack-local-variables)
926
(unless (eq org-log-done 'time)
927
(error "org-log-done is not set to time"))
928
(rail-tools--goto-id id)
929
(org-todo "DONE")
930
(rail-tools--goto-id id)
931
(rail-tools--append-closing-note
932
'done (rail-tools--closing-note-text commit tests model notes))
933
(when (buffer-modified-p) (save-buffer))
934
(let ((closed (org-entry-get nil "CLOSED")))
935
(unless closed
936
(error "Org did not record a CLOSED timestamp"))
937
`((id . ,id)
938
(state . ,(org-get-todo-state))
939
(result . ,(rail-tools--result-text))
940
(closed . ,closed)))))))
941
942
(mcp-server-register-tool
943
(make-mcp-server-tool
944
:name "rail-complete"
945
:title "RAIL Complete"
946
:description "Set a RAIL entry to DONE. The tool writes the closing note as a CLOSING NOTE item in the :LOGBOOK: drawer. The note holds the commit hash, a short test recap, and optionally the agent that did the work and a free-text note. The tool then confirms that Org inserted a CLOSED timestamp. The file must set org-log-done to time. The tool never writes the timestamp itself. The entry stays in place, and there is no refile step."
947
:input-schema '((type . "object")
948
(properties . ((root . ((type . "string")))
949
(id . ((type . "string")))
950
(commit . ((type . "string")
951
(description . "Commit hash for the completed work")))
952
(tests . ((type . "string")
953
(description . "Short test recap, for example \"215 pass\"")))
954
(model . ((type . "string")
955
(description . "Optional model or agent that did the work, for example the agent name")))
956
(notes . ((type . "string")
957
(description . "Optional free-text tail appended after a semicolon, for example a root cause")))))
958
(required . ["id" "commit" "tests"]))
959
:function #'rail-tools--complete-handler
960
:annotations '((readOnlyHint . :false)
961
(destructiveHint . :false)
962
(idempotentHint . t)
963
(openWorldHint . :false))))
964
965
;;; verify (read-only)
966
967
(defun rail-tools--verify-handler (args)
968
"Return the current state of the entry with `id'.
969
ARGS keys: `root', `id'. Read-only."
970
(rail-tools--json
971
(let ((file (rail-tools--file args))
972
(id (or (alist-get 'id args) (error "Missing `id'"))))
973
(with-current-buffer (rail-tools--buffer file)
974
(org-with-wide-buffer
975
(rail-tools--goto-id id)
976
`((id . ,id)
977
(heading . ,(org-get-heading t t t t))
978
(state . ,(org-get-todo-state))
979
(closed . ,(org-entry-get nil "CLOSED"))
980
(tags . ,(vconcat (org-get-tags nil t)))))))))
981
982
(mcp-server-register-tool
983
(make-mcp-server-tool
984
:name "rail-verify"
985
:title "RAIL Verify"
986
:description "Return the heading, TODO state, CLOSED timestamp, and tags of a RAIL entry. Read-only."
987
:input-schema '((type . "object")
988
(properties . ((root . ((type . "string")))
989
(id . ((type . "string")))))
990
(required . ["id"]))
991
:function #'rail-tools--verify-handler
992
:annotations '((readOnlyHint . t)
993
(destructiveHint . :false)
994
(idempotentHint . t)
995
(openWorldHint . :false))))
996
997
(provide 'rail-tools)
998
999
;;; rail-tools.el ends here
1000