frontmatter-mcp 0.2.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 @@
1
+ """Filesystem Frontmatter MCP Server."""
@@ -0,0 +1,97 @@
1
+ """Frontmatter read/write module."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import frontmatter
7
+
8
+
9
+ def parse_file(path: Path, base_dir: Path) -> dict[str, Any]:
10
+ """Parse frontmatter from a single file.
11
+
12
+ Args:
13
+ path: Absolute path to the file.
14
+ base_dir: Base directory for relative path calculation.
15
+
16
+ Returns:
17
+ Dictionary with 'path' (relative) and frontmatter properties.
18
+ """
19
+ post = frontmatter.load(path)
20
+ result: dict[str, Any] = {
21
+ "path": str(path.relative_to(base_dir)),
22
+ }
23
+ result.update(post.metadata)
24
+ return result
25
+
26
+
27
+ def parse_files(
28
+ paths: list[Path], base_dir: Path
29
+ ) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
30
+ """Parse frontmatter from multiple files.
31
+
32
+ Args:
33
+ paths: List of absolute paths to files.
34
+ base_dir: Base directory for relative path calculation.
35
+
36
+ Returns:
37
+ Tuple of (parsed records, warnings for failed files).
38
+ """
39
+ records: list[dict[str, Any]] = []
40
+ warnings: list[dict[str, str]] = []
41
+
42
+ for path in paths:
43
+ try:
44
+ record = parse_file(path, base_dir)
45
+ records.append(record)
46
+ except Exception as e:
47
+ warnings.append(
48
+ {
49
+ "path": str(path.relative_to(base_dir)),
50
+ "error": str(e),
51
+ }
52
+ )
53
+
54
+ return records, warnings
55
+
56
+
57
+ def update_file(
58
+ path: Path,
59
+ base_dir: Path,
60
+ set_values: dict[str, Any] | None = None,
61
+ unset: list[str] | None = None,
62
+ ) -> dict[str, Any]:
63
+ """Update frontmatter in a single file.
64
+
65
+ Args:
66
+ path: Absolute path to the file.
67
+ base_dir: Base directory for relative path calculation.
68
+ set_values: Properties to add or overwrite.
69
+ unset: Property names to remove.
70
+
71
+ Returns:
72
+ Dictionary with 'path' (relative) and 'frontmatter' (updated metadata).
73
+ """
74
+ post = frontmatter.load(path)
75
+
76
+ # Apply set values
77
+ if set_values:
78
+ for key, value in set_values.items():
79
+ # Skip if key is in unset (unset takes priority)
80
+ if unset and key in unset:
81
+ continue
82
+ post.metadata[key] = value
83
+
84
+ # Apply unset
85
+ if unset:
86
+ for key in unset:
87
+ post.metadata.pop(key, None)
88
+
89
+ # Only write if there were changes
90
+ if set_values or unset:
91
+ with open(path, "wb") as f:
92
+ frontmatter.dump(post, f)
93
+
94
+ return {
95
+ "path": str(path.relative_to(base_dir)),
96
+ "frontmatter": dict(post.metadata),
97
+ }
@@ -0,0 +1,74 @@
1
+ """DuckDB query execution module."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import duckdb
7
+ import pyarrow as pa
8
+
9
+
10
+ def _serialize_value(value: Any) -> str | None:
11
+ """Serialize a value to string for DuckDB.
12
+
13
+ Arrays are JSON-encoded, other values are converted to string.
14
+ None remains None.
15
+ """
16
+ if value is None:
17
+ return None
18
+ if isinstance(value, list):
19
+ return json.dumps(value, ensure_ascii=False)
20
+ return str(value)
21
+
22
+
23
+ def execute_query(records: list[dict[str, Any]], sql: str) -> dict[str, Any]:
24
+ """Execute DuckDB SQL query on frontmatter records.
25
+
26
+ All values are passed as strings to DuckDB. Use TRY_CAST in SQL
27
+ for type conversion. Arrays are JSON-encoded strings.
28
+
29
+ Args:
30
+ records: List of parsed frontmatter records.
31
+ sql: SQL query string. Must reference 'files' table.
32
+
33
+ Returns:
34
+ Dictionary with results, row_count, and columns.
35
+ """
36
+ if not records:
37
+ return {
38
+ "results": [],
39
+ "row_count": 0,
40
+ "columns": [],
41
+ }
42
+
43
+ # Collect all unique keys across all records
44
+ all_keys: set[str] = set()
45
+ for record in records:
46
+ all_keys.update(record.keys())
47
+
48
+ # Build columns dict with serialized string values
49
+ columns_data: dict[str, list[str | None]] = {key: [] for key in all_keys}
50
+ for record in records:
51
+ for key in all_keys:
52
+ columns_data[key].append(_serialize_value(record.get(key)))
53
+
54
+ # Create pyarrow table with explicit string type for all columns
55
+ schema = pa.schema([(key, pa.string()) for key in all_keys])
56
+ table = pa.table(columns_data, schema=schema)
57
+
58
+ # Create connection and register table
59
+ conn = duckdb.connect(":memory:")
60
+ conn.register("files", table)
61
+
62
+ # Execute query
63
+ result = conn.execute(sql)
64
+ columns = [desc[0] for desc in result.description]
65
+ rows = result.fetchall()
66
+
67
+ # Convert to list of dicts
68
+ results = [dict(zip(columns, row, strict=True)) for row in rows]
69
+
70
+ return {
71
+ "results": results,
72
+ "row_count": len(results),
73
+ "columns": columns,
74
+ }
@@ -0,0 +1,58 @@
1
+ """Schema inference module."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def infer_schema(
7
+ records: list[dict[str, Any]], max_samples: int = 5
8
+ ) -> dict[str, dict[str, Any]]:
9
+ """Collect schema information from parsed records.
10
+
11
+ Note: This does not perform type inference. All values are treated
12
+ as strings in DuckDB queries. This function provides metadata about
13
+ what properties exist and their sample values.
14
+
15
+ Args:
16
+ records: List of parsed frontmatter records.
17
+ max_samples: Maximum number of sample values to include.
18
+
19
+ Returns:
20
+ Schema dict with count, nullable, sample_values for each property.
21
+ """
22
+ schema: dict[str, dict[str, Any]] = {}
23
+ property_values: dict[str, list[Any]] = {}
24
+
25
+ for record in records:
26
+ for key, value in record.items():
27
+ if key == "path":
28
+ continue
29
+ if key not in property_values:
30
+ property_values[key] = []
31
+ property_values[key].append(value)
32
+
33
+ total_files = len(records)
34
+
35
+ for prop, values in property_values.items():
36
+ non_null_values = [v for v in values if v is not None]
37
+ count = len(non_null_values)
38
+
39
+ # Detect if values are arrays
40
+ is_array = any(isinstance(v, list) for v in non_null_values)
41
+
42
+ # Collect unique sample values
43
+ seen: set[str] = set()
44
+ samples: list[Any] = []
45
+ for v in non_null_values:
46
+ key = str(v)
47
+ if key not in seen and len(samples) < max_samples:
48
+ seen.add(key)
49
+ samples.append(v)
50
+
51
+ schema[prop] = {
52
+ "type": "array" if is_array else "string",
53
+ "count": count,
54
+ "nullable": count < total_files,
55
+ "sample_values": samples,
56
+ }
57
+
58
+ return schema
@@ -0,0 +1,512 @@
1
+ """MCP Server implementation using FastMCP."""
2
+
3
+ import glob as globmodule
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import frontmatter
9
+ from mcp.server.fastmcp import FastMCP
10
+
11
+ from frontmatter_mcp.frontmatter import parse_files, update_file
12
+ from frontmatter_mcp.query import execute_query
13
+ from frontmatter_mcp.schema import infer_schema
14
+
15
+ # Global base directory
16
+ _base_dir: Path | None = None
17
+
18
+ mcp = FastMCP("frontmatter-mcp")
19
+
20
+
21
+ def get_base_dir() -> Path:
22
+ """Get the configured base directory."""
23
+ if _base_dir is None:
24
+ raise RuntimeError("Base directory not configured. Use --base-dir argument.")
25
+ return _base_dir
26
+
27
+
28
+ def collect_files(glob_pattern: str) -> list[Path]:
29
+ """Collect files matching the glob pattern."""
30
+ base = get_base_dir()
31
+ pattern = str(base / glob_pattern)
32
+ matches = globmodule.glob(pattern, recursive=True)
33
+ return [Path(p) for p in matches if Path(p).is_file()]
34
+
35
+
36
+ @mcp.tool()
37
+ def query_inspect(glob: str) -> dict[str, Any]:
38
+ """Get frontmatter schema from files matching glob pattern.
39
+
40
+ Args:
41
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
42
+
43
+ Returns:
44
+ Dict with file_count, schema (type, count, nullable, sample_values).
45
+ """
46
+ base = get_base_dir()
47
+ paths = collect_files(glob)
48
+ records, warnings = parse_files(paths, base)
49
+ schema = infer_schema(records)
50
+
51
+ result: dict[str, Any] = {
52
+ "file_count": len(records),
53
+ "schema": schema,
54
+ }
55
+ if warnings:
56
+ result["warnings"] = warnings
57
+
58
+ return result
59
+
60
+
61
+ @mcp.tool()
62
+ def query(glob: str, sql: str) -> dict[str, Any]:
63
+ """Query frontmatter with DuckDB SQL.
64
+
65
+ Args:
66
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
67
+ sql: SQL query string. Reference 'files' table. Columns are frontmatter
68
+ properties plus 'path'.
69
+
70
+ Returns:
71
+ Dict with results array, row_count, and columns.
72
+ """
73
+ base = get_base_dir()
74
+ paths = collect_files(glob)
75
+ records, warnings = parse_files(paths, base)
76
+ query_result = execute_query(records, sql)
77
+
78
+ result: dict[str, Any] = {
79
+ "results": query_result["results"],
80
+ "row_count": query_result["row_count"],
81
+ "columns": query_result["columns"],
82
+ }
83
+ if warnings:
84
+ result["warnings"] = warnings
85
+
86
+ return result
87
+
88
+
89
+ @mcp.tool()
90
+ def update(
91
+ path: str,
92
+ set: dict[str, Any] | None = None,
93
+ unset: list[str] | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Update frontmatter properties in a single file.
96
+
97
+ Args:
98
+ path: File path relative to base directory.
99
+ set: Properties to add or overwrite. Values are applied as-is (null becomes
100
+ YAML null, empty string becomes empty value).
101
+ unset: Property names to remove completely.
102
+
103
+ Returns:
104
+ Dict with path and updated frontmatter.
105
+
106
+ Notes:
107
+ - If same key appears in both set and unset, unset takes priority.
108
+ - If file has no frontmatter, it will be created.
109
+ """
110
+ base = get_base_dir().resolve()
111
+ abs_path = (base / path).resolve()
112
+
113
+ # Security: ensure path is within base_dir
114
+ try:
115
+ abs_path.relative_to(base)
116
+ except ValueError as e:
117
+ raise ValueError(f"Path must be within base directory: {path}") from e
118
+
119
+ if not abs_path.exists():
120
+ raise FileNotFoundError(f"File not found: {path}")
121
+
122
+ return update_file(abs_path, base, set_values=set, unset=unset)
123
+
124
+
125
+ @mcp.tool()
126
+ def batch_update(
127
+ glob: str,
128
+ set: dict[str, Any] | None = None,
129
+ unset: list[str] | None = None,
130
+ ) -> dict[str, Any]:
131
+ """Update frontmatter properties in multiple files matching glob pattern.
132
+
133
+ Args:
134
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
135
+ set: Properties to add or overwrite in all matched files.
136
+ unset: Property names to remove from all matched files.
137
+
138
+ Returns:
139
+ Dict with updated_count, updated_files, and warnings.
140
+
141
+ Notes:
142
+ - If same key appears in both set and unset, unset takes priority.
143
+ - If a file has no frontmatter, it will be created.
144
+ - Errors in individual files are recorded in warnings, not raised.
145
+ """
146
+ base = get_base_dir().resolve()
147
+ paths = collect_files(glob)
148
+
149
+ updated_files: list[str] = []
150
+ warnings: list[str] = []
151
+
152
+ for file_path in paths:
153
+ abs_path = file_path.resolve()
154
+ try:
155
+ abs_path.relative_to(base)
156
+ except ValueError:
157
+ warnings.append(f"Skipped (outside base directory): {abs_path}")
158
+ continue
159
+
160
+ try:
161
+ update_result = update_file(abs_path, base, set_values=set, unset=unset)
162
+ updated_files.append(update_result["path"])
163
+ except Exception as e:
164
+ rel_path = abs_path.relative_to(base)
165
+ warnings.append(f"Failed to update {rel_path}: {e}")
166
+
167
+ response: dict[str, Any] = {
168
+ "updated_count": len(updated_files),
169
+ "updated_files": updated_files,
170
+ }
171
+ if warnings:
172
+ response["warnings"] = warnings
173
+
174
+ return response
175
+
176
+
177
+ @mcp.tool()
178
+ def batch_array_add(
179
+ glob: str,
180
+ property: str,
181
+ value: Any,
182
+ allow_duplicates: bool = False,
183
+ ) -> dict[str, Any]:
184
+ """Add a value to an array property in multiple files.
185
+
186
+ Args:
187
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
188
+ property: Name of the array property.
189
+ value: Value to add. If value is an array, it's added as a single element.
190
+ allow_duplicates: If False (default), skip files where value already exists.
191
+
192
+ Returns:
193
+ Dict with updated_count, updated_files, and warnings.
194
+
195
+ Notes:
196
+ - If property doesn't exist, it will be created with [value].
197
+ - If property is not an array, file is skipped with a warning.
198
+ - Files are only included in updated_files if actually modified.
199
+ """
200
+
201
+ base = get_base_dir().resolve()
202
+ paths = collect_files(glob)
203
+
204
+ updated_files: list[str] = []
205
+ warnings: list[str] = []
206
+
207
+ for file_path in paths:
208
+ abs_path = file_path.resolve()
209
+ try:
210
+ rel_path = str(abs_path.relative_to(base))
211
+ except ValueError:
212
+ warnings.append(f"Skipped (outside base directory): {abs_path}")
213
+ continue
214
+
215
+ try:
216
+ post = frontmatter.load(abs_path)
217
+ current = post.get(property)
218
+
219
+ # Property doesn't exist: create new array
220
+ if current is None:
221
+ post[property] = [value]
222
+ frontmatter.dump(post, abs_path)
223
+ updated_files.append(rel_path)
224
+ continue
225
+
226
+ # Property is not an array: skip with warning
227
+ if not isinstance(current, list):
228
+ warnings.append(f"Skipped {rel_path}: '{property}' is not an array")
229
+ continue
230
+
231
+ # Check for duplicates
232
+ if not allow_duplicates and value in current:
233
+ continue
234
+
235
+ # Add value
236
+ current.append(value)
237
+ frontmatter.dump(post, abs_path)
238
+ updated_files.append(rel_path)
239
+
240
+ except Exception as e:
241
+ warnings.append(f"Failed to update {rel_path}: {e}")
242
+
243
+ response: dict[str, Any] = {
244
+ "updated_count": len(updated_files),
245
+ "updated_files": updated_files,
246
+ }
247
+ if warnings:
248
+ response["warnings"] = warnings
249
+
250
+ return response
251
+
252
+
253
+ @mcp.tool()
254
+ def batch_array_remove(
255
+ glob: str,
256
+ property: str,
257
+ value: Any,
258
+ ) -> dict[str, Any]:
259
+ """Remove a value from an array property in multiple files.
260
+
261
+ Args:
262
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
263
+ property: Name of the array property.
264
+ value: Value to remove.
265
+
266
+ Returns:
267
+ Dict with updated_count, updated_files, and warnings.
268
+
269
+ Notes:
270
+ - If property doesn't exist, file is skipped.
271
+ - If value doesn't exist in array, file is skipped.
272
+ - If property is not an array, file is skipped with a warning.
273
+ - Files are only included in updated_files if actually modified.
274
+ """
275
+
276
+ base = get_base_dir().resolve()
277
+ paths = collect_files(glob)
278
+
279
+ updated_files: list[str] = []
280
+ warnings: list[str] = []
281
+
282
+ for file_path in paths:
283
+ abs_path = file_path.resolve()
284
+ try:
285
+ rel_path = str(abs_path.relative_to(base))
286
+ except ValueError:
287
+ warnings.append(f"Skipped (outside base directory): {abs_path}")
288
+ continue
289
+
290
+ try:
291
+ post = frontmatter.load(abs_path)
292
+ current = post.get(property)
293
+
294
+ # Property doesn't exist: skip
295
+ if current is None:
296
+ continue
297
+
298
+ # Property is not an array: skip with warning
299
+ if not isinstance(current, list):
300
+ warnings.append(f"Skipped {rel_path}: '{property}' is not an array")
301
+ continue
302
+
303
+ # Value doesn't exist: skip
304
+ if value not in current:
305
+ continue
306
+
307
+ # Remove value
308
+ current.remove(value)
309
+ frontmatter.dump(post, abs_path)
310
+ updated_files.append(rel_path)
311
+
312
+ except Exception as e:
313
+ warnings.append(f"Failed to update {rel_path}: {e}")
314
+
315
+ response: dict[str, Any] = {
316
+ "updated_count": len(updated_files),
317
+ "updated_files": updated_files,
318
+ }
319
+ if warnings:
320
+ response["warnings"] = warnings
321
+
322
+ return response
323
+
324
+
325
+ @mcp.tool()
326
+ def batch_array_replace(
327
+ glob: str,
328
+ property: str,
329
+ old_value: Any,
330
+ new_value: Any,
331
+ ) -> dict[str, Any]:
332
+ """Replace a value in an array property in multiple files.
333
+
334
+ Args:
335
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
336
+ property: Name of the array property.
337
+ old_value: Value to replace.
338
+ new_value: New value.
339
+
340
+ Returns:
341
+ Dict with updated_count, updated_files, and warnings.
342
+
343
+ Notes:
344
+ - If property doesn't exist, file is skipped.
345
+ - If old_value doesn't exist in array, file is skipped.
346
+ - If property is not an array, file is skipped with a warning.
347
+ - Files are only included in updated_files if actually modified.
348
+ """
349
+
350
+ base = get_base_dir().resolve()
351
+ paths = collect_files(glob)
352
+
353
+ updated_files: list[str] = []
354
+ warnings: list[str] = []
355
+
356
+ for file_path in paths:
357
+ abs_path = file_path.resolve()
358
+ try:
359
+ rel_path = str(abs_path.relative_to(base))
360
+ except ValueError:
361
+ warnings.append(f"Skipped (outside base directory): {abs_path}")
362
+ continue
363
+
364
+ try:
365
+ post = frontmatter.load(abs_path)
366
+ current = post.get(property)
367
+
368
+ # Property doesn't exist: skip
369
+ if current is None:
370
+ continue
371
+
372
+ # Property is not an array: skip with warning
373
+ if not isinstance(current, list):
374
+ warnings.append(f"Skipped {rel_path}: '{property}' is not an array")
375
+ continue
376
+
377
+ # Old value doesn't exist: skip
378
+ if old_value not in current:
379
+ continue
380
+
381
+ # Replace value
382
+ idx = current.index(old_value)
383
+ current[idx] = new_value
384
+ frontmatter.dump(post, abs_path)
385
+ updated_files.append(rel_path)
386
+
387
+ except Exception as e:
388
+ warnings.append(f"Failed to update {rel_path}: {e}")
389
+
390
+ response: dict[str, Any] = {
391
+ "updated_count": len(updated_files),
392
+ "updated_files": updated_files,
393
+ }
394
+ if warnings:
395
+ response["warnings"] = warnings
396
+
397
+ return response
398
+
399
+
400
+ @mcp.tool()
401
+ def batch_array_sort(
402
+ glob: str,
403
+ property: str,
404
+ reverse: bool = False,
405
+ ) -> dict[str, Any]:
406
+ """Sort an array property in multiple files.
407
+
408
+ Args:
409
+ glob: Glob pattern relative to base directory (e.g. "atoms/**/*.md").
410
+ property: Name of the array property.
411
+ reverse: If True, sort in descending order. Default is ascending.
412
+
413
+ Returns:
414
+ Dict with updated_count, updated_files, and warnings.
415
+
416
+ Notes:
417
+ - If property doesn't exist, file is skipped.
418
+ - If array is empty, file is skipped.
419
+ - If array is already sorted, file is skipped.
420
+ - If property is not an array, file is skipped with a warning.
421
+ - Files are only included in updated_files if actually modified.
422
+ """
423
+
424
+ base = get_base_dir().resolve()
425
+ paths = collect_files(glob)
426
+
427
+ updated_files: list[str] = []
428
+ warnings: list[str] = []
429
+
430
+ for file_path in paths:
431
+ abs_path = file_path.resolve()
432
+ try:
433
+ rel_path = str(abs_path.relative_to(base))
434
+ except ValueError:
435
+ warnings.append(f"Skipped (outside base directory): {abs_path}")
436
+ continue
437
+
438
+ try:
439
+ post = frontmatter.load(abs_path)
440
+ current = post.get(property)
441
+
442
+ # Property doesn't exist: skip
443
+ if current is None:
444
+ continue
445
+
446
+ # Property is not an array: skip with warning
447
+ if not isinstance(current, list):
448
+ warnings.append(f"Skipped {rel_path}: '{property}' is not an array")
449
+ continue
450
+
451
+ # Empty array or single element: skip (already sorted)
452
+ if len(current) <= 1:
453
+ continue
454
+
455
+ # Check if already sorted using pairwise comparison
456
+ if not reverse:
457
+ is_sorted = all(
458
+ current[i] <= current[i + 1] for i in range(len(current) - 1)
459
+ )
460
+ else:
461
+ is_sorted = all(
462
+ current[i] >= current[i + 1] for i in range(len(current) - 1)
463
+ )
464
+ if is_sorted:
465
+ continue
466
+
467
+ # Sort
468
+ post[property] = sorted(current, reverse=reverse)
469
+ frontmatter.dump(post, abs_path)
470
+ updated_files.append(rel_path)
471
+
472
+ except Exception as e:
473
+ warnings.append(f"Failed to update {rel_path}: {e}")
474
+
475
+ response: dict[str, Any] = {
476
+ "updated_count": len(updated_files),
477
+ "updated_files": updated_files,
478
+ }
479
+ if warnings:
480
+ response["warnings"] = warnings
481
+
482
+ return response
483
+
484
+
485
+ def main() -> None:
486
+ """Entry point for the MCP server."""
487
+ global _base_dir
488
+
489
+ # Parse --base-dir argument
490
+ args = sys.argv[1:]
491
+ if "--base-dir" not in args:
492
+ print("Error: --base-dir argument is required", file=sys.stderr)
493
+ print("Usage: frontmatter-mcp --base-dir /path", file=sys.stderr)
494
+ sys.exit(1)
495
+
496
+ base_dir_idx = args.index("--base-dir")
497
+ if base_dir_idx + 1 >= len(args):
498
+ print("Error: --base-dir requires a value", file=sys.stderr)
499
+ sys.exit(1)
500
+
501
+ base_dir_str = args[base_dir_idx + 1]
502
+ _base_dir = Path(base_dir_str).resolve()
503
+
504
+ if not _base_dir.is_dir():
505
+ print(f"Error: Base directory does not exist: {_base_dir}", file=sys.stderr)
506
+ sys.exit(1)
507
+
508
+ mcp.run()
509
+
510
+
511
+ if __name__ == "__main__":
512
+ main()
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.4
2
+ Name: frontmatter-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for querying Markdown frontmatter with DuckDB SQL
5
+ Project-URL: Homepage, https://github.com/kzmshx/frontmatter-mcp
6
+ Project-URL: Repository, https://github.com/kzmshx/frontmatter-mcp
7
+ Author: kzmshx
8
+ License: MIT License
9
+
10
+ Copyright (c) 2025 kzmshx
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Requires-Python: >=3.11
31
+ Requires-Dist: duckdb>=1.0.0
32
+ Requires-Dist: mcp>=1.0.0
33
+ Requires-Dist: pyarrow>=22.0.0
34
+ Requires-Dist: python-frontmatter>=1.0.0
35
+ Description-Content-Type: text/markdown
36
+
37
+ # frontmatter-mcp
38
+
39
+ An MCP server for querying Markdown frontmatter with DuckDB SQL.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ uv tool install git+https://github.com/kzmshx/frontmatter-mcp.git
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "frontmatter": {
53
+ "command": "frontmatter-mcp",
54
+ "args": ["--base-dir", "/path/to/markdown/directory"]
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## Tools
61
+
62
+ ### query_inspect
63
+
64
+ Get schema information from frontmatter across files.
65
+
66
+ | Parameter | Type | Description |
67
+ | --------- | ------ | --------------------------------------- |
68
+ | `glob` | string | Glob pattern relative to base directory |
69
+
70
+ **Example:**
71
+
72
+ ```json
73
+ // Input
74
+ { "glob": "**/*.md" }
75
+
76
+ // Output
77
+ {
78
+ "file_count": 186,
79
+ "schema": {
80
+ "date": { "type": "string", "count": 180, "nullable": true },
81
+ "tags": { "type": "array", "count": 150, "nullable": true }
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### query
87
+
88
+ Query frontmatter data with DuckDB SQL.
89
+
90
+ | Parameter | Type | Description |
91
+ | --------- | ------ | ------------------------------------------ |
92
+ | `glob` | string | Glob pattern relative to base directory |
93
+ | `sql` | string | DuckDB SQL query referencing `files` table |
94
+
95
+ **Example:**
96
+
97
+ ```json
98
+ // Input
99
+ {
100
+ "glob": "**/*.md",
101
+ "sql": "SELECT path, date FROM files WHERE date >= '2025-11-01' ORDER BY date DESC"
102
+ }
103
+
104
+ // Output
105
+ {
106
+ "columns": ["path", "date"],
107
+ "row_count": 24,
108
+ "results": [
109
+ {"path": "daily/2025-11-28.md", "date": "2025-11-28"},
110
+ {"path": "daily/2025-11-27.md", "date": "2025-11-27"}
111
+ ]
112
+ }
113
+ ```
114
+
115
+ ### update
116
+
117
+ Update frontmatter properties in a single file.
118
+
119
+ | Parameter | Type | Description |
120
+ | --------- | -------- | ------------------------------------ |
121
+ | `path` | string | File path relative to base directory |
122
+ | `set` | object | Properties to add or overwrite |
123
+ | `unset` | string[] | Property names to remove |
124
+
125
+ **Example:**
126
+
127
+ ```json
128
+ // Input
129
+ { "path": "notes/idea.md", "set": {"status": "published"} }
130
+
131
+ // Output
132
+ { "path": "notes/idea.md", "frontmatter": {"title": "Idea", "status": "published"} }
133
+ ```
134
+
135
+ ### batch_update
136
+
137
+ Update frontmatter properties in multiple files.
138
+
139
+ | Parameter | Type | Description |
140
+ | --------- | -------- | --------------------------------------- |
141
+ | `glob` | string | Glob pattern relative to base directory |
142
+ | `set` | object | Properties to add or overwrite |
143
+ | `unset` | string[] | Property names to remove |
144
+
145
+ **Example:**
146
+
147
+ ```json
148
+ // Input
149
+ { "glob": "drafts/*.md", "set": {"status": "review"} }
150
+
151
+ // Output
152
+ { "updated_count": 5, "updated_files": ["drafts/a.md", "drafts/b.md", ...] }
153
+ ```
154
+
155
+ ### batch_array_add
156
+
157
+ Add a value to an array property in multiple files.
158
+
159
+ | Parameter | Type | Description |
160
+ | ------------------ | ------ | ------------------------------------------ |
161
+ | `glob` | string | Glob pattern relative to base directory |
162
+ | `property` | string | Name of the array property |
163
+ | `value` | any | Value to add |
164
+ | `allow_duplicates` | bool | Allow duplicate values (default: false) |
165
+
166
+ **Example:**
167
+
168
+ ```json
169
+ // Input
170
+ { "glob": "**/*.md", "property": "tags", "value": "reviewed" }
171
+
172
+ // Output
173
+ { "updated_count": 42, "updated_files": ["a.md", "b.md", ...] }
174
+ ```
175
+
176
+ ### batch_array_remove
177
+
178
+ Remove a value from an array property in multiple files.
179
+
180
+ | Parameter | Type | Description |
181
+ | ---------- | ------ | --------------------------------------- |
182
+ | `glob` | string | Glob pattern relative to base directory |
183
+ | `property` | string | Name of the array property |
184
+ | `value` | any | Value to remove |
185
+
186
+ **Example:**
187
+
188
+ ```json
189
+ // Input
190
+ { "glob": "**/*.md", "property": "tags", "value": "draft" }
191
+
192
+ // Output
193
+ { "updated_count": 15, "updated_files": ["a.md", "b.md", ...] }
194
+ ```
195
+
196
+ ### batch_array_replace
197
+
198
+ Replace a value in an array property in multiple files.
199
+
200
+ | Parameter | Type | Description |
201
+ | ----------- | ------ | --------------------------------------- |
202
+ | `glob` | string | Glob pattern relative to base directory |
203
+ | `property` | string | Name of the array property |
204
+ | `old_value` | any | Value to replace |
205
+ | `new_value` | any | New value |
206
+
207
+ **Example:**
208
+
209
+ ```json
210
+ // Input
211
+ { "glob": "**/*.md", "property": "tags", "old_value": "draft", "new_value": "review" }
212
+
213
+ // Output
214
+ { "updated_count": 10, "updated_files": ["a.md", "b.md", ...] }
215
+ ```
216
+
217
+ ### batch_array_sort
218
+
219
+ Sort an array property in multiple files.
220
+
221
+ | Parameter | Type | Description |
222
+ | ---------- | ------ | --------------------------------------- |
223
+ | `glob` | string | Glob pattern relative to base directory |
224
+ | `property` | string | Name of the array property |
225
+ | `reverse` | bool | Sort in descending order (default: false)|
226
+
227
+ **Example:**
228
+
229
+ ```json
230
+ // Input
231
+ { "glob": "**/*.md", "property": "tags" }
232
+
233
+ // Output
234
+ { "updated_count": 20, "updated_files": ["a.md", "b.md", ...] }
235
+ ```
236
+
237
+ ## Technical Notes
238
+
239
+ ### All Values Are Strings
240
+
241
+ All frontmatter values are passed to DuckDB as strings. Use `TRY_CAST` in SQL for type conversion when needed.
242
+
243
+ ```sql
244
+ SELECT * FROM files
245
+ WHERE TRY_CAST(date AS DATE) >= '2025-11-01'
246
+ ```
247
+
248
+ ### Arrays Are JSON Strings
249
+
250
+ Arrays like `tags: [ai, python]` are stored as JSON strings `'["ai", "python"]'`. Use `from_json()` and `UNNEST` to expand them.
251
+
252
+ ```sql
253
+ SELECT path, tag
254
+ FROM files, UNNEST(from_json(tags, '[""]')) AS t(tag)
255
+ WHERE tag = 'ai'
256
+ ```
257
+
258
+ ### Templater Expression Support
259
+
260
+ Files containing Obsidian Templater expressions (e.g., `<% tp.date.now("YYYY-MM-DD") %>`) are handled gracefully. These expressions are treated as strings and naturally excluded by date filtering.
261
+
262
+ ## License
263
+
264
+ MIT
@@ -0,0 +1,10 @@
1
+ frontmatter_mcp/__init__.py,sha256=xwUmLf7DN4rwjk-xtCtMlO3LUI1mBYjhq3QGxkGcRSY,41
2
+ frontmatter_mcp/frontmatter.py,sha256=yBkhxrm5q9QJkFkopJTO4eWmyT5vwUZt14y-d7sHFwE,2552
3
+ frontmatter_mcp/query.py,sha256=uEVVscmrAUaS70O-ETxl45uEgYwPHtnSgYWPzJyjvH8,2108
4
+ frontmatter_mcp/schema.py,sha256=kw23ahynDa7hrFlzXSjDtqODlZU6-aw1sg-vF39MfSs,1786
5
+ frontmatter_mcp/server.py,sha256=5C4hxpCVfRFRDpmVVncCOhKSizpQ902QGM23NkggT-o,15056
6
+ frontmatter_mcp-0.2.0.dist-info/METADATA,sha256=yQgtyHuVjSNSACiyHnDNhx4NXHgd5M-1jz_FsrmMR0s,7707
7
+ frontmatter_mcp-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ frontmatter_mcp-0.2.0.dist-info/entry_points.txt,sha256=56y0yYs0A2wNqpKFSLeZPK5lo4WTseW56o-d7OSrf90,64
9
+ frontmatter_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=09zWP4vFnSQF9-F1Z8jtzfC6Fw9gOOjBU-1pFiXxPIo,1063
10
+ frontmatter_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ frontmatter-mcp = frontmatter_mcp.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 kzmshx
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.