worklog-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,10 @@
1
+ """An MCP server that records finished work in a directory of log files.
2
+
3
+ The public surface is small on purpose: build a :class:`Worklog` to use the
4
+ logic directly, or :func:`build_server` to expose it over MCP.
5
+ """
6
+
7
+ from worklog_mcp.logfile import AppendResult, CheckResult, Worklog, WorklogError
8
+ from worklog_mcp.server import build_server
9
+
10
+ __all__ = ["AppendResult", "CheckResult", "Worklog", "WorklogError", "build_server"]
worklog_mcp/cli.py ADDED
@@ -0,0 +1,111 @@
1
+ """Command line: serve the MCP server, or check the setup from a terminal.
2
+
3
+ Both commands take the same two options, and each option can also be given as
4
+ an environment variable — which is what makes the directory configurable
5
+ without ever writing it into a client's config file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from worklog_mcp.logfile import Worklog, WorklogError
17
+ from worklog_mcp.server import build_server
18
+
19
+ EXIT_ERROR = 1
20
+ EXIT_NO_DIR = 2
21
+ EXIT_NO_FILE = 3
22
+ EXIT_NO_CHECKLIST = 4
23
+
24
+ EXIT_CODES = {"no_dir": EXIT_NO_DIR, "no_file": EXIT_NO_FILE, "no_checklist": EXIT_NO_CHECKLIST}
25
+
26
+ DirectoryOption = Annotated[
27
+ str | None,
28
+ typer.Option(
29
+ "--dir",
30
+ envvar="WORKLOG_DIR",
31
+ help="Directory holding the log files. Required, as an option or as the variable.",
32
+ ),
33
+ ]
34
+ PatternOption = Annotated[
35
+ str | None,
36
+ typer.Option(
37
+ "--pattern",
38
+ envvar="WORKLOG_FILE_PATTERN",
39
+ help="Regular expression a file name must match. Omit to accept every file.",
40
+ ),
41
+ ]
42
+
43
+ app = typer.Typer(
44
+ name="worklog-mcp",
45
+ help="Append what you did to the checklist of the newest file in a directory.",
46
+ no_args_is_help=True,
47
+ add_completion=False,
48
+ )
49
+
50
+
51
+ def _directory(raw: str | None) -> Path | None:
52
+ """Turn the option's raw value into a directory, treating blank as unset.
53
+
54
+ An empty string is what a client's config produces when a variable it
55
+ interpolates is not set. It must not become ``Path(".")``, which is a real
56
+ directory and would put the log file somewhere surprising.
57
+
58
+ Returns:
59
+ Path | None: The directory, or ``None`` when nothing was configured.
60
+ """
61
+ if raw is None or not raw.strip():
62
+ return None
63
+ return Path(raw).expanduser()
64
+
65
+
66
+ def _pattern(raw: str | None) -> re.Pattern[str] | None:
67
+ """Compile the file-name pattern.
68
+
69
+ Returns:
70
+ re.Pattern[str] | None: The compiled pattern, or ``None`` when the
71
+ option was omitted.
72
+
73
+ Raises:
74
+ BadParameter: The expression does not compile.
75
+ """
76
+ if raw is None:
77
+ return None
78
+ try:
79
+ return re.compile(raw)
80
+ except re.error as error:
81
+ message = f"not a valid regular expression: {error}"
82
+ raise typer.BadParameter(message) from error
83
+
84
+
85
+ @app.command()
86
+ def serve(directory: DirectoryOption = None, pattern: PatternOption = None) -> None:
87
+ """Run the MCP server on stdio. This is the command an MCP client starts."""
88
+ build_server(_directory(directory), _pattern(pattern)).run(transport="stdio")
89
+
90
+
91
+ @app.command()
92
+ def check(directory: DirectoryOption = None, pattern: PatternOption = None) -> None:
93
+ """Report which log file would be written to, and how many items it holds.
94
+
95
+ Raises:
96
+ Exit: With `1` when the file cannot be read, `2` when no directory is
97
+ configured, `3` when it holds no log file and `4` when that file
98
+ has no checklist.
99
+ """
100
+ worklog = Worklog(directory=_directory(directory), pattern=_pattern(pattern))
101
+ try:
102
+ result = worklog.check()
103
+ except WorklogError as error:
104
+ typer.echo(f"worklog-mcp: {error}", err=True)
105
+ raise typer.Exit(EXIT_ERROR) from error
106
+
107
+ if result.status != "ok":
108
+ typer.echo(f"worklog-mcp: {result.message}", err=True)
109
+ raise typer.Exit(EXIT_CODES[result.status])
110
+
111
+ typer.echo(f'ok: file="{result.file}" items={result.items}')
worklog_mcp/logfile.py ADDED
@@ -0,0 +1,492 @@
1
+ """Finding the log file, finding its checklist, and appending one item to it.
2
+
3
+ This module is the whole behaviour of the package; ``server`` and ``cli`` are
4
+ thin shells over it. Nothing here knows about MCP, and nothing here ever puts a
5
+ filesystem path into a result: callers get the file's *name* and the line that
6
+ was written, never the directory they live in.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ CHECKLIST_ITEM = re.compile(r"^(\s*)([-*+]) \[[ xX]\]\s*")
20
+ LIST_ITEM = re.compile(r"^(\s*)(?:[-*+]|\d+[.)])\s")
21
+ FENCE = re.compile(r"^\s*(```|~~~)")
22
+ LEADING_MARKER = re.compile(r"^(?:[-*+]\s+)?(?:\[[ xX]\]\s*)?")
23
+
24
+ AppendStatus = Literal["written", "created", "duplicate", "dry_run", "empty", "no_dir", "no_file"]
25
+ """Outcome of an append: what happened, or why nothing happened."""
26
+
27
+ CheckStatus = Literal["ok", "no_dir", "no_file", "no_checklist"]
28
+ """Outcome of a check: the setup works, or the first thing that is missing."""
29
+
30
+ MissingStatus = Literal["no_dir", "no_file"]
31
+ """The two ways reaching the log file can come up empty."""
32
+
33
+ NO_DIR_MESSAGE = "the worklog directory is not configured or does not exist"
34
+
35
+ NEW_MARKER = "-"
36
+ """Bullet used when a checklist has to be started; an existing one is copied instead."""
37
+
38
+
39
+ class WorklogError(RuntimeError):
40
+ """The log file was found but could not be read or written.
41
+
42
+ Raised for a file that is not valid UTF-8 and for a failed write. Every
43
+ other outcome is a status on the result model, not an exception.
44
+ """
45
+
46
+
47
+ class AppendResult(BaseModel):
48
+ """What ``Worklog.append`` did."""
49
+
50
+ status: AppendStatus = Field(
51
+ description=(
52
+ "What happened. 'written': the item was appended to an existing checklist. 'created': the "
53
+ "file had no checklist, so one was started at its end with this item. 'duplicate': the "
54
+ "checklist already had it, nothing was written. 'dry_run': nothing was written because "
55
+ "dry_run was set. 'empty': the description was blank. 'no_dir', 'no_file': the directory "
56
+ "or the log file is missing — report it and stop, never create either."
57
+ ),
58
+ )
59
+ file: str | None = Field(
60
+ default=None,
61
+ description="Name of the log file, without its directory. Null when no file was reached.",
62
+ )
63
+ line: str | None = Field(
64
+ default=None,
65
+ description="The exact line written, or that would have been written. Null when nothing was written.",
66
+ )
67
+ message: str = Field(description="One sentence describing the outcome, safe to repeat to the user.")
68
+
69
+
70
+ class CheckResult(BaseModel):
71
+ """What ``Worklog.check`` found."""
72
+
73
+ status: CheckStatus = Field(
74
+ description=(
75
+ "'ok': a log file with a checklist was found. 'no_checklist': the file is there but has no "
76
+ "checklist yet, which the next append would start. 'no_dir', 'no_file': the directory or "
77
+ "the log file itself is missing."
78
+ ),
79
+ )
80
+ file: str | None = Field(
81
+ default=None,
82
+ description="Name of the log file, without its directory. Null when no file was reached.",
83
+ )
84
+ items: int | None = Field(
85
+ default=None,
86
+ description="How many items the checklist already holds. Null unless the status is 'ok'.",
87
+ )
88
+ message: str = Field(description="One sentence describing the outcome, safe to repeat to the user.")
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class _Checklist:
93
+ """Boundaries and shape of the checklist an item will be appended to."""
94
+
95
+ start: int
96
+ end: int
97
+ indent: str
98
+ marker: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class _Document:
103
+ """A log file split into lines, remembering how to put it back together."""
104
+
105
+ path: Path
106
+ lines: list[str]
107
+ newline: str
108
+ trailing: str
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class _Missing:
113
+ """Why the log file could not be reached, in a form both results accept."""
114
+
115
+ status: MissingStatus
116
+ message: str
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class Worklog:
121
+ """A directory of log files, plus an optional filter on their names.
122
+
123
+ Attributes:
124
+ directory: Where the log files live; ``None`` when it was never
125
+ configured, which every operation reports as ``no_dir``.
126
+ pattern: Applied to a candidate's file name with ``search``; when it is
127
+ ``None`` every non-hidden file in the directory is a candidate.
128
+ """
129
+
130
+ directory: Path | None
131
+ pattern: re.Pattern[str] | None = None
132
+
133
+ def append(self, description: str, *, dry_run: bool = False) -> AppendResult:
134
+ """Append one done item to the checklist of the newest log file.
135
+
136
+ The item is written as ``[x]``, reusing the indent and the bullet
137
+ marker of the checklist's first item. An item whose text is already in
138
+ the checklist is not written again, so retrying a workflow never
139
+ doubles a line.
140
+
141
+ A log file with no checklist at all gets one started at its end — the
142
+ file itself is never created, but an empty day is not a reason to
143
+ refuse the entry.
144
+
145
+ Args:
146
+ description: What was done, as one sentence. Leading list and
147
+ checkbox markers are stripped, and whitespace is collapsed.
148
+ dry_run: Report the line that would be written without touching
149
+ the file.
150
+
151
+ Returns:
152
+ AppendResult: The outcome, the file's name and the line involved.
153
+
154
+ Raises:
155
+ WorklogError: The file is not valid UTF-8, or the write failed.
156
+ """
157
+ text = _normalize(description)
158
+ if not text:
159
+ return AppendResult(status="empty", message="the description is empty, so there is nothing to write")
160
+
161
+ located = self._locate()
162
+ if isinstance(located, _Missing):
163
+ return AppendResult(status=located.status, message=located.message)
164
+ document, checklist = located
165
+
166
+ name = document.path.name
167
+ if checklist is None:
168
+ return self._start(document, text, dry_run=dry_run)
169
+
170
+ line = f"{checklist.indent}{checklist.marker} [x] {text}"
171
+ block = document.lines[checklist.start : checklist.end + 1]
172
+ if any(CHECKLIST_ITEM.match(item) and _item_text(item) == text for item in block):
173
+ return AppendResult(
174
+ status="duplicate",
175
+ file=name,
176
+ line=line,
177
+ message=f'"{name}" already has this item, so nothing was written',
178
+ )
179
+
180
+ if dry_run:
181
+ return AppendResult(
182
+ status="dry_run",
183
+ file=name,
184
+ line=line,
185
+ message=f'one item would be appended to the checklist in "{name}"',
186
+ )
187
+
188
+ document.lines.insert(checklist.end + 1, line)
189
+ _write_atomic(document)
190
+ return AppendResult(
191
+ status="written",
192
+ file=name,
193
+ line=line,
194
+ message=f'one item was appended to the checklist in "{name}"',
195
+ )
196
+
197
+ @staticmethod
198
+ def _start(document: _Document, text: str, *, dry_run: bool) -> AppendResult:
199
+ """Start a checklist at the end of a log file that has none.
200
+
201
+ Returns:
202
+ AppendResult: ``created``, or ``dry_run`` when nothing was written.
203
+ """
204
+ name = document.path.name
205
+ line = f"{NEW_MARKER} [x] {text}"
206
+ if dry_run:
207
+ return AppendResult(
208
+ status="dry_run",
209
+ file=name,
210
+ line=line,
211
+ message=f'"{name}" has no checklist; one would be started with this item',
212
+ )
213
+
214
+ # A list glued to the paragraph above it is not a list, so the blank
215
+ # line is part of writing a checklist, not cosmetics.
216
+ if document.lines and document.lines[-1].strip():
217
+ document.lines.append("")
218
+ document.lines.append(line)
219
+ _write_atomic(document)
220
+ return AppendResult(
221
+ status="created",
222
+ file=name,
223
+ line=line,
224
+ message=f'"{name}" had no checklist, so one was started with this item',
225
+ )
226
+
227
+ def check(self) -> CheckResult:
228
+ """Report whether a log file with a checklist can be found right now.
229
+
230
+ Reads nothing back to the caller beyond the file's name and how many
231
+ items its checklist already holds — it is a setup probe, not a way to
232
+ look at the file.
233
+
234
+ Returns:
235
+ CheckResult: ``ok`` plus the file name and item count, or the first
236
+ thing that is missing. Unlike :meth:`append`, this never starts
237
+ a checklist — it reports ``no_checklist`` and leaves the file
238
+ alone.
239
+
240
+ Raises:
241
+ WorklogError: The file is not valid UTF-8.
242
+ """
243
+ located = self._locate()
244
+ if isinstance(located, _Missing):
245
+ return CheckResult(status=located.status, message=located.message)
246
+ document, checklist = located
247
+
248
+ name = document.path.name
249
+ if checklist is None:
250
+ return CheckResult(
251
+ status="no_checklist",
252
+ file=name,
253
+ message=f'"{name}" has no checklist yet; the next append will start one',
254
+ )
255
+
256
+ block = document.lines[checklist.start : checklist.end + 1]
257
+ items = sum(1 for line in block if CHECKLIST_ITEM.match(line))
258
+ return CheckResult(status="ok", file=name, items=items, message=f'"{name}" has a checklist with {items} items')
259
+
260
+ def _locate(self) -> tuple[_Document, _Checklist | None] | _Missing:
261
+ """Find the newest log file, and the first checklist in it if it has one.
262
+
263
+ Returns:
264
+ tuple[_Document, _Checklist | None] | _Missing: The file and its
265
+ checklist — ``None`` when the file has none — or which
266
+ prerequisite is absent.
267
+ """
268
+ if self.directory is None or not self.directory.is_dir():
269
+ return _Missing(status="no_dir", message=NO_DIR_MESSAGE)
270
+
271
+ path = _latest_file(self.directory, self.pattern)
272
+ if path is None:
273
+ missing = (
274
+ "no file in the worklog directory matches the configured pattern"
275
+ if self.pattern is not None
276
+ else "the worklog directory holds no files"
277
+ )
278
+ return _Missing(status="no_file", message=missing)
279
+
280
+ document = _read(path)
281
+ return document, _find_checklist(document.lines)
282
+
283
+
284
+ def _created_at(path: Path) -> float:
285
+ """Creation time where the platform records one, last modification otherwise.
286
+
287
+ Returns:
288
+ float: Seconds since the epoch, for ordering candidates only.
289
+ """
290
+ stat = path.stat()
291
+ # float() because st_birthtime is untyped: the platform may not have it.
292
+ return float(getattr(stat, "st_birthtime", stat.st_mtime))
293
+
294
+
295
+ def _latest_file(directory: Path, pattern: re.Pattern[str] | None) -> Path | None:
296
+ """Newest matching file in the directory itself, ignoring subdirectories.
297
+
298
+ Today's date is never computed: the file for today is the one created last,
299
+ so the newest file is the right one — and work finished after midnight
300
+ lands where its author expects to find it.
301
+
302
+ Args:
303
+ directory: Searched without recursion; hidden files are skipped.
304
+ pattern: Matched against the file name with ``search``, or ``None`` to
305
+ accept every candidate.
306
+
307
+ Returns:
308
+ Path | None: The newest candidate, or ``None`` when there is none.
309
+ """
310
+ candidates = [item for item in directory.iterdir() if item.is_file() and not item.name.startswith(".")]
311
+ if pattern is not None:
312
+ candidates = [item for item in candidates if pattern.search(item.name)]
313
+ if not candidates:
314
+ return None
315
+ return max(candidates, key=_created_at)
316
+
317
+
318
+ def _read(path: Path) -> _Document:
319
+ """Read a log file, remembering its line ending and whether it ends in one.
320
+
321
+ Returns:
322
+ _Document: The file's lines and how to write them back.
323
+
324
+ Raises:
325
+ WorklogError: The file is not valid UTF-8.
326
+ """
327
+ try:
328
+ # newline="" keeps CRLF intact; the default would translate it away and
329
+ # the file would silently come back with its line endings rewritten.
330
+ text = path.read_text(encoding="utf-8", newline="")
331
+ except UnicodeDecodeError as error:
332
+ message = f'"{path.name}" is not valid UTF-8'
333
+ raise WorklogError(message) from error
334
+ newline = "\r\n" if "\r\n" in text else "\n"
335
+ trailing = newline if text.endswith("\n") else ""
336
+ return _Document(path=path, lines=text.splitlines(), newline=newline, trailing=trailing)
337
+
338
+
339
+ def _write_atomic(document: _Document) -> None:
340
+ """Replace the file in one step, through a temporary file beside it.
341
+
342
+ Editors watch these files, so none of them may ever observe a truncated
343
+ one. A failure at any point leaves the original untouched and removes the
344
+ temporary file.
345
+
346
+ Raises:
347
+ WorklogError: The temporary file could not be created, written or
348
+ moved into place.
349
+ """
350
+ text = document.newline.join(document.lines) + document.trailing
351
+ try:
352
+ handle = tempfile.NamedTemporaryFile( # ruff: ignore[open-file-with-context-handler] # closed below, then renamed over the original
353
+ "w",
354
+ encoding="utf-8",
355
+ dir=document.path.parent,
356
+ prefix=".worklog-",
357
+ suffix=".tmp",
358
+ delete=False,
359
+ newline="",
360
+ )
361
+ except OSError as error:
362
+ raise WorklogError(_write_failed(document.path, error)) from error
363
+
364
+ temporary = Path(handle.name)
365
+ try:
366
+ with handle:
367
+ handle.write(text)
368
+ temporary.replace(document.path)
369
+ except OSError as error:
370
+ temporary.unlink(missing_ok=True)
371
+ raise WorklogError(_write_failed(document.path, error)) from error
372
+
373
+
374
+ def _write_failed(path: Path, error: OSError) -> str:
375
+ """Phrase a write failure without naming the directory.
376
+
377
+ Returns:
378
+ str: The message carried by the raised :class:`WorklogError`.
379
+ """
380
+ return f'cannot write "{path.name}": {error.strerror}'
381
+
382
+
383
+ def _skip_frontmatter(lines: list[str]) -> int:
384
+ """Index of the first line after a YAML frontmatter block.
385
+
386
+ Returns:
387
+ int: Where the document's body starts; ``0`` when there is no
388
+ frontmatter.
389
+ """
390
+ if not lines or lines[0].rstrip() != "---":
391
+ return 0
392
+ for index in range(1, len(lines)):
393
+ if lines[index].rstrip() in {"---", "..."}:
394
+ return index + 1
395
+ return 0
396
+
397
+
398
+ def _toggle_fence(fence: str | None, token: str, line: str) -> str | None:
399
+ """Track whether the document is currently inside a fenced code block.
400
+
401
+ A fence is closed only by its own token, so backticks inside a tilde block
402
+ leave it open.
403
+
404
+ Returns:
405
+ str | None: The token holding the block open, or ``None`` outside one.
406
+ """
407
+ if fence is None:
408
+ return token
409
+ if line.strip().startswith(fence):
410
+ return None
411
+ return fence
412
+
413
+
414
+ def _find_checklist(lines: list[str]) -> _Checklist | None:
415
+ """Locate the FIRST checklist in the document.
416
+
417
+ A file may hold several checklists; the first one from the top wins and the
418
+ rest are left alone. Fenced code blocks are skipped, so a markdown example
419
+ inside triple backticks is not mistaken for a checklist.
420
+
421
+ Returns:
422
+ _Checklist | None: Boundaries and shape of the block, or ``None`` when
423
+ the document has no checklist.
424
+ """
425
+ index = _skip_frontmatter(lines)
426
+ fence: str | None = None
427
+
428
+ while index < len(lines):
429
+ line = lines[index]
430
+ opening = FENCE.match(line)
431
+ if opening:
432
+ fence = _toggle_fence(fence, opening.group(1), line)
433
+ elif fence is None:
434
+ item = CHECKLIST_ITEM.match(line)
435
+ if item:
436
+ indent, marker = item.group(1), item.group(2)
437
+ end = _block_end(lines, index, indent)
438
+ return _Checklist(start=index, end=end, indent=indent, marker=marker)
439
+ index += 1
440
+
441
+ return None
442
+
443
+
444
+ def _block_end(lines: list[str], start: int, indent: str) -> int:
445
+ """Index of the last line belonging to the checklist that starts at ``start``.
446
+
447
+ The block runs to the last sibling or nested item, keeping the blank lines
448
+ and continuation lines inside it, and stops at the first line that returns
449
+ to a shallower level or opens a code fence.
450
+
451
+ Returns:
452
+ int: Index of that last line; the new item goes right after it.
453
+ """
454
+ end = start
455
+ index = start + 1
456
+ base = len(indent)
457
+ while index < len(lines):
458
+ line = lines[index]
459
+ if not line.strip():
460
+ index += 1
461
+ continue
462
+ if FENCE.match(line):
463
+ break
464
+ current = len(line) - len(line.lstrip())
465
+ if LIST_ITEM.match(line) and current >= base:
466
+ end = index
467
+ elif current > base:
468
+ end = index # a continuation line of the item above
469
+ else:
470
+ break
471
+ index += 1
472
+ return end
473
+
474
+
475
+ def _item_text(line: str) -> str:
476
+ """Text of a checklist item, without its marker and with collapsed spaces.
477
+
478
+ Returns:
479
+ str: The comparable form used to recognise a duplicate.
480
+ """
481
+ return " ".join(CHECKLIST_ITEM.sub("", line, count=1).split())
482
+
483
+
484
+ def _normalize(raw: str) -> str:
485
+ """Reduce a description to the single line that will be written.
486
+
487
+ Returns:
488
+ str: One line without a leading list or checkbox marker; empty when
489
+ nothing is left to write.
490
+ """
491
+ description = " ".join(raw.split())
492
+ return LEADING_MARKER.sub("", description, count=1).strip()
worklog_mcp/py.typed ADDED
File without changes
worklog_mcp/server.py ADDED
@@ -0,0 +1,162 @@
1
+ """The MCP server: two tools over one :class:`~worklog_mcp.logfile.Worklog`.
2
+
3
+ The directory and the file-name pattern are fixed when the server starts, so a
4
+ client never passes them — and never sees them.
5
+
6
+ Every field of both tool descriptors is spelled out below rather than inferred,
7
+ because the descriptor is the only thing a model has to go on. The descriptions
8
+ in particular are part of the wire protocol: they are written as instructions to
9
+ a model — when to call, what comes back, what the tool will not do — not as
10
+ notes to a maintainer.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Annotated
18
+
19
+ from mcp.server import MCPServer
20
+ from mcp.types import ToolAnnotations
21
+ from pydantic import Field
22
+
23
+ from worklog_mcp.logfile import AppendResult, CheckResult, Worklog
24
+
25
+ SERVER_NAME = "worklog"
26
+
27
+ DOCS_URL = "https://feodor-ra.github.io/worklog-mcp/"
28
+
29
+ APPEND_DESCRIPTION = """\
30
+ Record ONE finished piece of work in the user's log for today.
31
+
32
+ Call this the moment a task is actually done — a fix landed, a change was
33
+ pushed, a question was answered — and once per piece of work. Do not call it to
34
+ plan something, to take a note, or to save a partial result.
35
+
36
+ The item is appended as a completed entry to the checklist of the newest file in
37
+ the log directory. That directory is configured when the server starts: you do
38
+ not choose it, you cannot pass it, and you will not be told what it is.
39
+
40
+ Write `description` as one plain sentence in the past tense, in the language the
41
+ user writes their log in, naming what changed and — for a bug — why it happened.
42
+ Leave out issue keys, links and branch names unless the user asks for them.
43
+
44
+ The call is idempotent: repeating the same sentence returns `duplicate` and
45
+ writes nothing, so retrying after an error is safe.
46
+
47
+ If today's file has no checklist yet, one is started at the end of it and you
48
+ get `created` instead of `written` — say so when you report back, since the
49
+ file gained a list it did not have.
50
+
51
+ The log file itself is never created. If the directory or the file is missing
52
+ you get `no_dir` or `no_file`: report that to the user in one line and stop — do
53
+ not create a file, do not look for the log yourself, and do not ask the user for
54
+ the path.
55
+
56
+ Returns the status, the log file's NAME (never its path) and the exact line
57
+ written. Set `dry_run` to see that line without writing it."""
58
+
59
+ CHECK_DESCRIPTION = """\
60
+ Check that a log file with a checklist can be found right now.
61
+
62
+ Use it to diagnose an `append` that came back `no_dir` or `no_file`: it tells
63
+ you whether the server is misconfigured or the user has simply not started
64
+ today's log yet. It takes no arguments and changes nothing — a file with no
65
+ checklist is reported as `no_checklist` here, not started, which only `append`
66
+ does.
67
+
68
+ Returns the log file's NAME and how many items its checklist already holds. It
69
+ is not a way to read the log — the items themselves are never returned, and no
70
+ other tool returns them either. Do not call it before every append; the append
71
+ reports the same conditions by itself."""
72
+
73
+
74
+ def build_server(directory: Path | None, pattern: re.Pattern[str] | None = None) -> MCPServer:
75
+ """Create the MCP server for one worklog directory.
76
+
77
+ The directory is not validated here. A server that refused to start on a
78
+ missing directory would show up in a client as a broken connection with no
79
+ tools; instead every call reports ``no_dir``, which a caller can act on.
80
+
81
+ Args:
82
+ directory: Where the log files live, or ``None`` when it was never
83
+ configured.
84
+ pattern: Optional filter on candidate file names.
85
+
86
+ Returns:
87
+ MCPServer: A server exposing the ``append`` and ``check`` tools.
88
+ """
89
+ worklog = Worklog(directory=directory, pattern=pattern)
90
+ mcp = MCPServer(SERVER_NAME)
91
+
92
+ @mcp.tool(
93
+ name="append",
94
+ title="Append to today's work log",
95
+ description=APPEND_DESCRIPTION,
96
+ annotations=ToolAnnotations(
97
+ title="Append to today's work log",
98
+ read_only_hint=False,
99
+ # It only ever adds a line; no item is edited, reordered or removed.
100
+ destructive_hint=False,
101
+ # The same description twice leaves one line, not two.
102
+ idempotent_hint=True,
103
+ # One local directory, fixed at startup — no network, no discovery.
104
+ open_world_hint=False,
105
+ ),
106
+ meta={"docs": f"{DOCS_URL}reference/tools/"},
107
+ structured_output=True,
108
+ )
109
+ def append(
110
+ description: Annotated[
111
+ str,
112
+ Field(
113
+ description=(
114
+ "What was done, as ONE sentence in the past tense, in the user's own language. "
115
+ "Name the change and, for a bug, its cause. No issue keys, links or branch names. "
116
+ "Line breaks are collapsed and a leading '- ' or '- [ ]' marker is stripped."
117
+ ),
118
+ min_length=1,
119
+ ),
120
+ ],
121
+ *,
122
+ dry_run: Annotated[
123
+ bool,
124
+ Field(
125
+ description=(
126
+ "Return the line that would be written without touching the file. "
127
+ "Use only when the user asked to preview the entry."
128
+ ),
129
+ ),
130
+ ] = False,
131
+ ) -> AppendResult:
132
+ """Append one done item; see :data:`APPEND_DESCRIPTION` for the model-facing text.
133
+
134
+ Returns:
135
+ AppendResult: The outcome, the log file's name and the line.
136
+ """
137
+ return worklog.append(description, dry_run=dry_run)
138
+
139
+ @mcp.tool(
140
+ name="check",
141
+ title="Check the work log setup",
142
+ description=CHECK_DESCRIPTION,
143
+ annotations=ToolAnnotations(
144
+ title="Check the work log setup",
145
+ # Reads one file and returns a count; writes nothing.
146
+ read_only_hint=True,
147
+ destructive_hint=False,
148
+ idempotent_hint=True,
149
+ open_world_hint=False,
150
+ ),
151
+ meta={"docs": f"{DOCS_URL}reference/tools/"},
152
+ structured_output=True,
153
+ )
154
+ def check() -> CheckResult:
155
+ """Probe the setup; see :data:`CHECK_DESCRIPTION` for the model-facing text.
156
+
157
+ Returns:
158
+ CheckResult: Whether the setup works, or the first thing missing.
159
+ """
160
+ return worklog.check()
161
+
162
+ return mcp
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.4
2
+ Name: worklog-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server that appends what you did to the checklist of the newest log file in a directory
5
+ Keywords: mcp,model-context-protocol,worklog,markdown,checklist
6
+ Author: Fedor Ratschew
7
+ Author-email: Fedor Ratschew <feodor.ra@me.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: Natural Language :: English
16
+ Classifier: Natural Language :: Russian
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Operating System :: MacOS
19
+ Classifier: Operating System :: Microsoft :: Windows
20
+ Classifier: Operating System :: POSIX :: Linux
21
+ Classifier: Programming Language :: Python
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Programming Language :: Python :: Implementation :: CPython
27
+ Classifier: Topic :: Office/Business
28
+ Classifier: Topic :: Software Development :: Documentation
29
+ Classifier: Topic :: Text Editors :: Text Processing
30
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
31
+ Classifier: Topic :: Utilities
32
+ Classifier: Typing :: Typed
33
+ Requires-Dist: mcp~=2.1
34
+ Requires-Dist: typer~=0.27
35
+ Requires-Python: >=3.13
36
+ Project-URL: Homepage, https://feodor-ra.github.io/worklog-mcp/
37
+ Project-URL: Documentation, https://feodor-ra.github.io/worklog-mcp/
38
+ Project-URL: Repository, https://github.com/feodor-ra/worklog-mcp
39
+ Project-URL: Issues, https://github.com/feodor-ra/worklog-mcp/issues
40
+ Description-Content-Type: text/markdown
41
+
42
+ # worklog-mcp
43
+
44
+ 🇺🇸 **English** · 🇷🇺 [Русский](README.ru.md)
45
+
46
+ [![PyPI - Version](https://img.shields.io/pypi/v/worklog-mcp)](https://pypi.org/project/worklog-mcp/)
47
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/worklog-mcp)](https://pypi.org/project/worklog-mcp/)
48
+ ![PyPI - Status](https://img.shields.io/pypi/status/worklog-mcp)
49
+ ![PyPI - Wheel](https://img.shields.io/pypi/wheel/worklog-mcp)
50
+ ![PyPI - Downloads](https://img.shields.io/pypi/dm/worklog-mcp)
51
+ ![PyPI - Format](https://img.shields.io/pypi/format/worklog-mcp)
52
+ [![MCP](https://img.shields.io/badge/MCP-server-blue)](https://modelcontextprotocol.io)
53
+ [![Typed](https://img.shields.io/badge/typing-py.typed-261230)](https://peps.python.org/pep-0561/)
54
+
55
+ [![CI](https://github.com/feodor-ra/worklog-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/feodor-ra/worklog-mcp/actions/workflows/ci.yml)
56
+ [![Docs](https://github.com/feodor-ra/worklog-mcp/actions/workflows/docs.yml/badge.svg)](https://github.com/feodor-ra/worklog-mcp/actions/workflows/docs.yml)
57
+ [![Coverage Status](https://coveralls.io/repos/github/feodor-ra/worklog-mcp/badge.svg?branch=main)](https://coveralls.io/github/feodor-ra/worklog-mcp?branch=main)
58
+ [![Documentation](https://img.shields.io/badge/docs-mkdocs-blue)](https://feodor-ra.github.io/worklog-mcp/)
59
+ ![GitHub Release](https://img.shields.io/github/v/release/feodor-ra/worklog-mcp)
60
+ ![GitHub last commit](https://img.shields.io/github/last-commit/feodor-ra/worklog-mcp)
61
+ ![GitHub commit activity](https://img.shields.io/github/commit-activity/m/feodor-ra/worklog-mcp)
62
+ [![GitHub issues](https://img.shields.io/github/issues/feodor-ra/worklog-mcp)](https://github.com/feodor-ra/worklog-mcp/issues)
63
+ ![GitHub Repo stars](https://img.shields.io/github/stars/feodor-ra/worklog-mcp?style=flat)
64
+ [![License: MIT](https://img.shields.io/github/license/feodor-ra/worklog-mcp)](LICENSE)
65
+
66
+ **An MCP server that records finished work in your daily log.** Point it at a directory of log files; it appends one done item to the checklist of the newest one.
67
+
68
+ If you keep a note per day with a checklist of what got done, the assistant that did the work can write the line itself. The directory is configured once, when the server starts, so it never travels through the conversation — the model sees the file's name and the line it wrote, and nothing else.
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "worklog": {
74
+ "command": "worklog-mcp",
75
+ "args": ["serve", "--dir", "/path/to/your/notes"]
76
+ }
77
+ }
78
+ }
79
+ ```
80
+
81
+ That is the whole setup. The assistant now has `append` and `check`.
82
+
83
+ ## Install
84
+
85
+ ```bash
86
+ uv tool install worklog-mcp
87
+ ```
88
+
89
+ Requires Python **3.13+**. No editor, vault format or note-taking app is assumed: a log file is any text file with a markdown checklist in it.
90
+
91
+ ## Add it to your agent
92
+
93
+ <details open>
94
+ <summary><b>Claude Code</b></summary>
95
+
96
+ ```bash
97
+ claude mcp add worklog -- worklog-mcp serve --dir /path/to/notes
98
+ ```
99
+
100
+ Add `--scope user` to make it available in every project.
101
+ </details>
102
+
103
+ <details>
104
+ <summary><b>Claude Desktop</b> · <code>claude_desktop_config.json</code></summary>
105
+
106
+ ```json
107
+ {
108
+ "mcpServers": {
109
+ "worklog": {
110
+ "command": "worklog-mcp",
111
+ "args": ["serve", "--dir", "/path/to/notes"]
112
+ }
113
+ }
114
+ }
115
+ ```
116
+ </details>
117
+
118
+ <details>
119
+ <summary><b>Cursor</b> · <code>~/.cursor/mcp.json</code></summary>
120
+
121
+ ```json
122
+ {
123
+ "mcpServers": {
124
+ "worklog": {
125
+ "command": "worklog-mcp",
126
+ "args": ["serve", "--dir", "/path/to/notes"]
127
+ }
128
+ }
129
+ }
130
+ ```
131
+ </details>
132
+
133
+ <details>
134
+ <summary><b>VS Code</b> · <code>.vscode/mcp.json</code> — note the <code>servers</code> key</summary>
135
+
136
+ ```json
137
+ {
138
+ "servers": {
139
+ "worklog": {
140
+ "type": "stdio",
141
+ "command": "worklog-mcp",
142
+ "args": ["serve", "--dir", "/path/to/notes"]
143
+ }
144
+ }
145
+ }
146
+ ```
147
+ </details>
148
+
149
+ <details>
150
+ <summary><b>Windsurf</b> · <code>~/.codeium/windsurf/mcp_config.json</code></summary>
151
+
152
+ ```json
153
+ {
154
+ "mcpServers": {
155
+ "worklog": {
156
+ "command": "worklog-mcp",
157
+ "args": ["serve", "--dir", "/path/to/notes"]
158
+ }
159
+ }
160
+ }
161
+ ```
162
+ </details>
163
+
164
+ <details>
165
+ <summary><b>Zed</b> · <code>settings.json</code> — <code>context_servers</code></summary>
166
+
167
+ ```json
168
+ {
169
+ "context_servers": {
170
+ "worklog": {
171
+ "command": "worklog-mcp",
172
+ "args": ["serve", "--dir", "/path/to/notes"],
173
+ "env": {}
174
+ }
175
+ }
176
+ }
177
+ ```
178
+ </details>
179
+
180
+ <details>
181
+ <summary><b>Cline</b> · <code>cline_mcp_settings.json</code></summary>
182
+
183
+ ```json
184
+ {
185
+ "mcpServers": {
186
+ "worklog": {
187
+ "command": "worklog-mcp",
188
+ "args": ["serve", "--dir", "/path/to/notes"]
189
+ }
190
+ }
191
+ }
192
+ ```
193
+ </details>
194
+
195
+ <details>
196
+ <summary><b>Codex CLI</b> · <code>~/.codex/config.toml</code></summary>
197
+
198
+ ```toml
199
+ [mcp_servers.worklog]
200
+ command = "worklog-mcp"
201
+ args = ["serve", "--dir", "/path/to/notes"]
202
+ ```
203
+
204
+ Or: `codex mcp add worklog -- worklog-mcp serve --dir /path/to/notes`
205
+ </details>
206
+
207
+ <details>
208
+ <summary><b>Gemini CLI</b> · <code>~/.gemini/settings.json</code></summary>
209
+
210
+ ```json
211
+ {
212
+ "mcpServers": {
213
+ "worklog": {
214
+ "command": "worklog-mcp",
215
+ "args": ["serve", "--dir", "/path/to/notes"]
216
+ }
217
+ }
218
+ }
219
+ ```
220
+ </details>
221
+
222
+ <details>
223
+ <summary><b>OpenCode</b> · <code>opencode.json</code></summary>
224
+
225
+ ```json
226
+ {
227
+ "mcp": {
228
+ "worklog": {
229
+ "type": "local",
230
+ "command": ["worklog-mcp", "serve", "--dir", "/path/to/notes"]
231
+ }
232
+ }
233
+ }
234
+ ```
235
+ </details>
236
+
237
+ ## How to use it
238
+
239
+ **1. Keep the path out of the config file, if you like.** Both options read an environment variable — `WORKLOG_DIR` and `WORKLOG_FILE_PATTERN` — so the snippet can carry no path at all:
240
+
241
+ ```json
242
+ {
243
+ "mcpServers": {
244
+ "worklog": {
245
+ "command": "worklog-mcp",
246
+ "args": ["serve"],
247
+ "env": { "WORKLOG_DIR": "/path/to/notes" }
248
+ }
249
+ }
250
+ }
251
+ ```
252
+
253
+ **2. Narrow down which files count, if the directory holds more than logs.** `--pattern` is a regular expression matched against the file name:
254
+
255
+ ```bash
256
+ worklog-mcp serve --dir ~/notes --pattern '^\d{4}-\d{2}-\d{2}\.md$'
257
+ ```
258
+
259
+ Without a pattern, every non-hidden file in the directory is a candidate, and the newest one wins.
260
+
261
+ **3. Check the setup from a terminal** whenever a call reports something missing:
262
+
263
+ ```bash
264
+ worklog-mcp check --dir ~/notes
265
+ # ok: file="2026-09-06.md" items=3
266
+ ```
267
+
268
+ Exit codes are `0` ok, `2` no directory, `3` no log file, `4` no checklist.
269
+
270
+ ## Why it's cool
271
+
272
+ - **The path stays out of the conversation.** It is a launch argument, read by the local process. Results carry a file name, never a directory.
273
+ - **It never creates a log file.** A missing file or directory is reported and the call stops, so the assistant cannot reorganize your notes. The one thing it does add is a checklist, when today's file has none yet.
274
+ - **Repeating a call is safe.** An item whose text is already in the checklist is not written twice, so a retried workflow does not double a line.
275
+ - **Your formatting survives.** The new item reuses the indent and bullet marker of the checklist's first item, and the file keeps its line endings; the write is atomic, so an editor watching the file never sees it truncated.
276
+
277
+ ## Documentation
278
+
279
+ 📖 **[Full documentation](https://feodor-ra.github.io/worklog-mcp/)** — configuration, ready-made client snippets, exactly how the file and the checklist are picked, the tool contracts and an API reference. Available in English and Russian.
280
+
281
+ ## License
282
+
283
+ MIT — see [LICENSE](LICENSE).
284
+
285
+ ---
286
+
287
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
288
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
289
+ [![ty](https://img.shields.io/badge/types-ty-261230)](https://github.com/astral-sh/ty)
290
+ [![pytest](https://img.shields.io/badge/tested%20with-pytest-0A9EDC?logo=pytest&logoColor=white)](https://docs.pytest.org)
291
+ [![Material for MkDocs](https://img.shields.io/badge/docs-Material%20for%20MkDocs-526CFE?logo=materialformkdocs&logoColor=white)](https://squidfunk.github.io/mkdocs-material/)
292
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
293
+ [![Semantic Versions](https://img.shields.io/badge/SemVer-2.0.0-3F4551)](https://semver.org)
294
+ [![Keep a Changelog](https://img.shields.io/badge/Keep%20a%20Changelog-1.1.0-E05735?logo=keepachangelog&logoColor=white)](https://keepachangelog.com)
@@ -0,0 +1,10 @@
1
+ worklog_mcp/__init__.py,sha256=FbHyuFbiXMjadCZduOYb4fI6A6eyMQ2xeZX5Gi3cPfo,429
2
+ worklog_mcp/cli.py,sha256=sbwRCM6Tkr3NPDZQxVukwrOV-km4dy02dFjxa1Zk7kQ,3458
3
+ worklog_mcp/logfile.py,sha256=NgiG_Oe1Tg-ARaTfaSSBeNvmWu4dIdvJF-0ccqxhmRs,17771
4
+ worklog_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ worklog_mcp/server.py,sha256=BvSPxZhh5xlIgC7s8gt2KuI5mJeHtUYvt7IdfEuk7Fs,6386
6
+ worklog_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=jk6A0Q6YhMNLgQ5cwUN53lUvDCp1ybOGMVJXtu1BYww,1071
7
+ worklog_mcp-0.1.0.dist-info/WHEEL,sha256=Kot-FOXwz2no6rGIg4et79Z7m6vcKPAXr2Y6fTFpZuI,81
8
+ worklog_mcp-0.1.0.dist-info/entry_points.txt,sha256=VOoKVlAAObGv1R7U2KcIBV2C6QRiAjvyANMHPgnJQPk,53
9
+ worklog_mcp-0.1.0.dist-info/METADATA,sha256=v2YAEHtZzp-ATpDrYG-ytswlaiZ0GZXdpqGQfVbsJqg,10223
10
+ worklog_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.10
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ worklog-mcp = worklog_mcp.cli:app
3
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fedor Ratschew
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.