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.
- worklog_mcp/__init__.py +10 -0
- worklog_mcp/cli.py +111 -0
- worklog_mcp/logfile.py +492 -0
- worklog_mcp/py.typed +0 -0
- worklog_mcp/server.py +162 -0
- worklog_mcp-0.1.0.dist-info/METADATA +294 -0
- worklog_mcp-0.1.0.dist-info/RECORD +10 -0
- worklog_mcp-0.1.0.dist-info/WHEEL +4 -0
- worklog_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- worklog_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
worklog_mcp/__init__.py
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/worklog-mcp/)
|
|
47
|
+
[](https://pypi.org/project/worklog-mcp/)
|
|
48
|
+

|
|
49
|
+

|
|
50
|
+

|
|
51
|
+

|
|
52
|
+
[](https://modelcontextprotocol.io)
|
|
53
|
+
[](https://peps.python.org/pep-0561/)
|
|
54
|
+
|
|
55
|
+
[](https://github.com/feodor-ra/worklog-mcp/actions/workflows/ci.yml)
|
|
56
|
+
[](https://github.com/feodor-ra/worklog-mcp/actions/workflows/docs.yml)
|
|
57
|
+
[](https://coveralls.io/github/feodor-ra/worklog-mcp?branch=main)
|
|
58
|
+
[](https://feodor-ra.github.io/worklog-mcp/)
|
|
59
|
+

|
|
60
|
+

|
|
61
|
+

|
|
62
|
+
[](https://github.com/feodor-ra/worklog-mcp/issues)
|
|
63
|
+

|
|
64
|
+
[](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
|
+
[](https://github.com/astral-sh/uv)
|
|
288
|
+
[](https://github.com/astral-sh/ruff)
|
|
289
|
+
[](https://github.com/astral-sh/ty)
|
|
290
|
+
[](https://docs.pytest.org)
|
|
291
|
+
[](https://squidfunk.github.io/mkdocs-material/)
|
|
292
|
+
[](https://conventionalcommits.org)
|
|
293
|
+
[](https://semver.org)
|
|
294
|
+
[](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,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.
|