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.
- frontmatter_mcp/__init__.py +1 -0
- frontmatter_mcp/frontmatter.py +97 -0
- frontmatter_mcp/query.py +74 -0
- frontmatter_mcp/schema.py +58 -0
- frontmatter_mcp/server.py +512 -0
- frontmatter_mcp-0.2.0.dist-info/METADATA +264 -0
- frontmatter_mcp-0.2.0.dist-info/RECORD +10 -0
- frontmatter_mcp-0.2.0.dist-info/WHEEL +4 -0
- frontmatter_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- frontmatter_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
}
|
frontmatter_mcp/query.py
ADDED
|
@@ -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,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.
|