pkm-mcp-server 1.5.2 → 1.6.0

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.6.0] - 2026-03-23
10
+
11
+ ### Security
12
+ - **Fix command injection in capture-handler.sh** — replaced `eval` of Node.js output with safe per-variable stdout capture. Previously, crafted `title` or `content` values containing shell metacharacters (e.g., `$(...)`) could execute arbitrary commands.
13
+ - `handleTrash` and `handleMove` now catch ENOENT errors and return relative paths instead of leaking absolute vault paths in error messages
14
+
15
+ ### Changed
16
+ - Wikilink extraction in `handleLinks` and `handleSuggestLinks` now uses shared `extractWikilinks` from `graph.js` instead of duplicating the regex
17
+ - Extracted `addToBasenameMap`/`removeFromBasenameMap` helpers in `handlers.js` (replaces 3 inline copies)
18
+ - Named constant `SESSION_ID_DISPLAY_LEN` replaces magic number `8` for session ID truncation
19
+ - `@modelcontextprotocol/sdk` version range tightened from `^1.0.0` to `^1.27.0` to reflect actual minimum tested version
20
+
21
+ ### Fixed
22
+ - README hook configuration referenced deleted `stop-sweep.sh` (renamed to `stop-sweep.js` in v1.5.0)
23
+ - `vault_peek` tool description no longer claims "line numbers" in heading outline (removed in v1.1.0)
24
+ - CHANGELOG: added missing entries for v1.5.3, v1.5.2, v1.1.1; fixed all comparison links
25
+ - CLAUDE.md and sample-project/CLAUDE.md: added missing `note.md` template to template lists
26
+ - CONTRIBUTING.md: clarified that double quotes and semicolons are conventions, not lint-enforced
27
+ - CI workflow: added `permissions: {}` for least privilege; added `npm audit --omit=dev` step
28
+
29
+ ## [1.5.3] - 2026-03-21
30
+
31
+ ### Fixed
32
+ - Init wizard `patchMcpConfig()` now handles multi-line `MCP_CONFIG=` blocks correctly — previously only replaced the first line, leaving orphaned continuation lines
33
+
34
+ ## [1.5.2] - 2026-03-21
35
+
36
+ ### Fixed
37
+ - Hook scripts now auto-detect repo vs installed location — `capture-handler.sh` no longer hardcodes `node $SCRIPT_DIR/../index.js`, falling back to `npx pkm-mcp-server@latest` when `index.js` is not present
38
+
9
39
  ## [1.5.1] - 2026-03-21
10
40
 
11
41
  ### Fixed
@@ -109,6 +139,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
109
139
  ### Security
110
140
  - Resolved 7 transitive dependency vulnerabilities (hono, @hono/node-server, ajv, express-rate-limit, flatted, minimatch, qs)
111
141
 
142
+ ## [1.1.1] - 2026-02-24
143
+
144
+ ### Added
145
+ - Enum validation for task `status` and `priority` fields in `vault_write` and `vault_update_frontmatter`
146
+
112
147
  ## [1.1.0] - 2026-02-23
113
148
 
114
149
  ### Changed
@@ -158,8 +193,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
158
193
  - Atomic file creation in `vault_write` (`wx` flag) prevents race conditions
159
194
  - Error messages sanitized to prevent leaking absolute vault paths
160
195
 
161
- [Unreleased]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.2.1...HEAD
196
+ [Unreleased]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.6.0...HEAD
197
+ [1.6.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.5.3...v1.6.0
198
+ [1.5.3]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.5.2...v1.5.3
199
+ [1.5.2]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.5.1...v1.5.2
200
+ [1.5.1]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.5.0...v1.5.1
201
+ [1.5.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.4.2...v1.5.0
202
+ [1.4.2]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.4.1...v1.4.2
203
+ [1.4.1]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.4.0...v1.4.1
204
+ [1.4.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.3.3...v1.4.0
205
+ [1.3.3]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.3.2...v1.3.3
206
+ [1.3.2]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.3.1...v1.3.2
207
+ [1.3.1]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.3.0...v1.3.1
208
+ [1.3.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.2.1...v1.3.0
162
209
  [1.2.1]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.2.0...v1.2.1
163
- [1.2.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.1.0...v1.2.0
210
+ [1.2.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.1.1...v1.2.0
211
+ [1.1.1]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.1.0...v1.1.1
164
212
  [1.1.0]: https://github.com/AdrianV101/Obsidian-MCP/compare/v1.0.0...v1.1.0
165
213
  [1.0.0]: https://github.com/AdrianV101/Obsidian-MCP/releases/tag/v1.0.0
package/README.md CHANGED
@@ -190,7 +190,7 @@ Add to your `~/.claude/settings.json` (alongside the `mcpServers` block):
190
190
  "hooks": [
191
191
  {
192
192
  "type": "command",
193
- "command": "VAULT_PATH=\"/path/to/your/vault\" /path/to/Obsidian-MCP/hooks/stop-sweep.sh",
193
+ "command": "VAULT_PATH=\"/path/to/your/vault\" node /path/to/Obsidian-MCP/hooks/stop-sweep.js",
194
194
  "async": true,
195
195
  "timeout": 10
196
196
  }
@@ -219,7 +219,7 @@ Replace `/path/to/your/vault` with your Obsidian vault path and `/path/to/Obsidi
219
219
  | Hook | Event | What it does |
220
220
  |------|-------|--------------|
221
221
  | `session-start.js` | SessionStart | Loads project context (index, devlog, active tasks) at session start |
222
- | `stop-sweep.sh` | Stop | Scans each exchange for PKM-worthy decisions/tasks, appends to daily captures |
222
+ | `stop-sweep.js` | Stop | PKM librarian: creates structured, graph-linked vault notes from the latest exchange |
223
223
  | `capture-handler.sh` | PostToolUse | Creates structured vault notes when `vault_capture` is called |
224
224
 
225
225
  See [hooks/README.md](hooks/README.md) for architecture details and troubleshooting.
package/handlers.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  FORCE_HARD_CAP,
24
24
  CHUNK_SIZE,
25
25
  } from "./helpers.js";
26
- import { exploreNeighborhood, formatNeighborhood, findFilesLinkingTo, rewriteWikilinks } from "./graph.js";
26
+ import { exploreNeighborhood, formatNeighborhood, findFilesLinkingTo, rewriteWikilinks, extractWikilinks } from "./graph.js";
27
27
  import { getAllMarkdownFiles, extractFrontmatter } from "./utils.js";
28
28
 
29
29
  /**
@@ -63,6 +63,26 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
63
63
  return resolvePath(resolvedFolder);
64
64
  };
65
65
 
66
+ const SESSION_ID_DISPLAY_LEN = 8;
67
+
68
+ function addToBasenameMap(relativePath) {
69
+ const bn = path.basename(relativePath, ".md").toLowerCase();
70
+ if (!basenameMap.has(bn)) basenameMap.set(bn, []);
71
+ basenameMap.get(bn).push(relativePath);
72
+ allFilesSet.add(relativePath);
73
+ }
74
+
75
+ function removeFromBasenameMap(relativePath) {
76
+ allFilesSet.delete(relativePath);
77
+ const bn = path.basename(relativePath, ".md").toLowerCase();
78
+ const entries = basenameMap.get(bn);
79
+ if (entries) {
80
+ const idx = entries.indexOf(relativePath);
81
+ if (idx !== -1) entries.splice(idx, 1);
82
+ if (entries.length === 0) basenameMap.delete(bn);
83
+ }
84
+ }
85
+
66
86
  async function handleRead(args) {
67
87
  const filePath = resolveFile(args.path);
68
88
  const content = await fs.readFile(filePath, "utf-8");
@@ -193,12 +213,7 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
193
213
  }
194
214
 
195
215
  // Update basename map with the new file
196
- const newBasename = path.basename(outputPath, ".md").toLowerCase();
197
- if (!basenameMap.has(newBasename)) {
198
- basenameMap.set(newBasename, []);
199
- }
200
- basenameMap.get(newBasename).push(outputPath);
201
- allFilesSet.add(outputPath);
216
+ addToBasenameMap(outputPath);
202
217
 
203
218
  const fm = validation.frontmatter;
204
219
  const createdStr = fm.created instanceof Date
@@ -403,11 +418,7 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
403
418
  const result = { outgoing: [], incoming: [] };
404
419
 
405
420
  if (args.direction !== "incoming") {
406
- const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
407
- let match;
408
- while ((match = linkRegex.exec(content)) !== null) {
409
- result.outgoing.push(match[1]);
410
- }
421
+ result.outgoing = extractWikilinks(content);
411
422
  }
412
423
 
413
424
  if (args.direction !== "outgoing") {
@@ -580,20 +591,20 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
580
591
 
581
592
  if (entries.length === 0) {
582
593
  return {
583
- content: [{ type: "text", text: `No activity entries found. (current session: ${sessionId.slice(0, 8)})` }]
594
+ content: [{ type: "text", text: `No activity entries found. (current session: ${sessionId.slice(0, SESSION_ID_DISPLAY_LEN)})` }]
584
595
  };
585
596
  }
586
597
 
587
598
  const formatted = entries.map(e => {
588
599
  const ts = e.timestamp.replace("T", " ").slice(0, 19);
589
- const sessionShort = e.session_id.slice(0, 8);
600
+ const sessionShort = e.session_id.slice(0, SESSION_ID_DISPLAY_LEN);
590
601
  return `[${ts}] [${sessionShort}] ${e.tool_name}\n${e.args_json}`;
591
602
  }).join("\n\n");
592
603
 
593
604
  return {
594
605
  content: [{
595
606
  type: "text",
596
- text: `Activity log (${entries.length} entr${entries.length === 1 ? "y" : "ies"}, current session: ${sessionId.slice(0, 8)}):\n\n${formatted}`
607
+ text: `Activity log (${entries.length} entr${entries.length === 1 ? "y" : "ies"}, current session: ${sessionId.slice(0, SESSION_ID_DISPLAY_LEN)}):\n\n${formatted}`
597
608
  }]
598
609
  };
599
610
  }
@@ -651,13 +662,9 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
651
662
  }
652
663
  if (!body) throw new Error("No content to analyze");
653
664
 
654
- const linkRegex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
655
- const linkedNames = new Set();
656
- let match;
657
- while ((match = linkRegex.exec(inputText)) !== null) {
658
- const target = match[1];
659
- linkedNames.add(path.basename(target, ".md").toLowerCase());
660
- }
665
+ const linkedNames = new Set(
666
+ extractWikilinks(inputText).map(t => path.basename(t, ".md").toLowerCase())
667
+ );
661
668
 
662
669
  const excludeFiles = new Set();
663
670
  if (sourcePath) excludeFiles.add(sourcePath);
@@ -696,7 +703,12 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
696
703
  const filePath = resolvePath(resolvedRelative);
697
704
 
698
705
  // Verify file exists
699
- await fs.access(filePath);
706
+ try {
707
+ await fs.access(filePath);
708
+ } catch (e) {
709
+ if (e.code === "ENOENT") throw new Error(`ENOENT: File not found: ${resolvedRelative}`, { cause: e });
710
+ throw e;
711
+ }
700
712
 
701
713
  // Find incoming links for warning output
702
714
  const allFilesList = Array.from(allFilesSet);
@@ -725,14 +737,7 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
725
737
  await fs.rename(filePath, trashAbsolute);
726
738
 
727
739
  // Update in-memory basename map
728
- allFilesSet.delete(resolvedRelative);
729
- const oldBasename = path.basename(resolvedRelative, ".md").toLowerCase();
730
- const entries = basenameMap.get(oldBasename);
731
- if (entries) {
732
- const idx = entries.indexOf(resolvedRelative);
733
- if (idx !== -1) entries.splice(idx, 1);
734
- if (entries.length === 0) basenameMap.delete(oldBasename);
735
- }
740
+ removeFromBasenameMap(resolvedRelative);
736
741
 
737
742
  // Build output
738
743
  let text = `Trashed ${resolvedRelative} → ${trashRelative}`;
@@ -754,7 +759,12 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
754
759
  const newAbsolute = resolvePath(newRelative);
755
760
 
756
761
  // Verify source exists
757
- await fs.access(oldAbsolute);
762
+ try {
763
+ await fs.access(oldAbsolute);
764
+ } catch (e) {
765
+ if (e.code === "ENOENT") throw new Error(`ENOENT: File not found: ${oldRelative}`, { cause: e });
766
+ throw e;
767
+ }
758
768
 
759
769
  // Verify destination does NOT exist
760
770
  try {
@@ -775,23 +785,11 @@ export async function createHandlers({ vaultPath, templateRegistry, semanticInde
775
785
  await fs.rename(oldAbsolute, newAbsolute);
776
786
 
777
787
  // Update basename map: remove old, add new
778
- allFilesSet.delete(oldRelative);
779
- const oldBasename = path.basename(oldRelative, ".md").toLowerCase();
780
- const oldEntries = basenameMap.get(oldBasename);
781
- if (oldEntries) {
782
- const idx = oldEntries.indexOf(oldRelative);
783
- if (idx !== -1) oldEntries.splice(idx, 1);
784
- if (oldEntries.length === 0) basenameMap.delete(oldBasename);
785
- }
786
-
787
- allFilesSet.add(newRelative);
788
- const newBasename = path.basename(newRelative, ".md").toLowerCase();
789
- if (!basenameMap.has(newBasename)) {
790
- basenameMap.set(newBasename, []);
791
- }
792
- basenameMap.get(newBasename).push(newRelative);
788
+ removeFromBasenameMap(oldRelative);
789
+ addToBasenameMap(newRelative);
793
790
 
794
791
  // Determine new link target — use full path if basename is now ambiguous
792
+ const newBasename = path.basename(newRelative, ".md").toLowerCase();
795
793
  const newEntries = basenameMap.get(newBasename);
796
794
  const isAmbiguous = newEntries && newEntries.length > 1;
797
795
  const newLinkTarget = isAmbiguous
@@ -15,19 +15,23 @@ trap cleanup EXIT
15
15
  # Read hook input from stdin
16
16
  INPUT=$(cat)
17
17
 
18
- # Extract tool_input fields (buffer all stdin before parsing)
19
- eval "$(echo "$INPUT" | node -e "
20
- let b='';
21
- process.stdin.on('data',c=>b+=c);
22
- process.stdin.on('end',()=>{
23
- const j=JSON.parse(b);
24
- const ti=j.tool_input||{};
25
- console.log('TOOL_INPUT='+JSON.stringify(JSON.stringify(ti)));
26
- console.log('CAPTURE_TYPE='+JSON.stringify(ti.type||''));
27
- console.log('CAPTURE_TITLE='+JSON.stringify(ti.title||''));
28
- console.log('CAPTURE_CONTENT='+JSON.stringify(ti.content||''));
29
- })
30
- ")"
18
+ # Extract tool_input fields safely (no eval stdout capture only)
19
+ TOOL_INPUT=$(echo "$INPUT" | node -e "
20
+ let b=''; process.stdin.on('data',c=>b+=c);
21
+ process.stdin.on('end',()=>{ process.stdout.write(JSON.stringify(JSON.parse(b).tool_input||{})); })
22
+ ")
23
+ CAPTURE_TYPE=$(echo "$INPUT" | node -e "
24
+ let b=''; process.stdin.on('data',c=>b+=c);
25
+ process.stdin.on('end',()=>{ process.stdout.write((JSON.parse(b).tool_input||{}).type||''); })
26
+ ")
27
+ CAPTURE_TITLE=$(echo "$INPUT" | node -e "
28
+ let b=''; process.stdin.on('data',c=>b+=c);
29
+ process.stdin.on('end',()=>{ process.stdout.write((JSON.parse(b).tool_input||{}).title||''); })
30
+ ")
31
+ CAPTURE_CONTENT=$(echo "$INPUT" | node -e "
32
+ let b=''; process.stdin.on('data',c=>b+=c);
33
+ process.stdin.on('end',()=>{ process.stdout.write((JSON.parse(b).tool_input||{}).content||''); })
34
+ ")
31
35
 
32
36
  # Skip if missing required fields
33
37
  if [ -z "$CAPTURE_TYPE" ] || [ -z "$CAPTURE_TITLE" ] || [ -z "$CAPTURE_CONTENT" ]; then
package/index.js CHANGED
@@ -73,7 +73,7 @@ export async function startServer() {
73
73
  },
74
74
  {
75
75
  name: "vault_peek",
76
- description: "Inspect a file's metadata and structure without reading full content. Returns file size, frontmatter, heading outline with line numbers and approximate section sizes, and a brief preview. Use this to plan which sections to read from large files.",
76
+ description: "Inspect a file's metadata and structure without reading full content. Returns file size, frontmatter, heading outline with approximate section sizes, and a brief preview. Use this to plan which sections to read from large files.",
77
77
  inputSchema: {
78
78
  type: "object",
79
79
  properties: {
package/init.js CHANGED
@@ -208,8 +208,29 @@ export function patchMcpConfig(scriptContent, installType) {
208
208
  const argsJson = JSON.stringify(installType.args);
209
209
  const replacement = `MCP_CONFIG='{"mcpServers":{"obsidian-pkm":{"command":"${installType.command}","args":${argsJson},"env":{"VAULT_PATH":"'"$VAULT_PATH"'"}}}}'`;
210
210
  const lines = scriptContent.split("\n");
211
- const patched = lines.map(line => line.startsWith("MCP_CONFIG=") ? replacement : line);
212
- return patched.join("\n");
211
+ const result = [];
212
+ let i = 0;
213
+
214
+ while (i < lines.length) {
215
+ if (lines[i].startsWith("MCP_CONFIG=")) {
216
+ result.push(replacement);
217
+ // Multi-line command substitution: contains $( but closing ) is on a later line
218
+ if (lines[i].includes("$(") && !lines[i].trimEnd().endsWith(")")) {
219
+ i++;
220
+ while (i < lines.length && !lines[i].trimEnd().endsWith(")")) {
221
+ i++;
222
+ }
223
+ i++; // skip the closing line
224
+ } else {
225
+ i++;
226
+ }
227
+ } else {
228
+ result.push(lines[i]);
229
+ i++;
230
+ }
231
+ }
232
+
233
+ return result.join("\n");
213
234
  }
214
235
 
215
236
  const PKM_HOOK_BASENAMES = new Set(["session-start.js", "stop-sweep.js", "capture-handler.sh"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pkm-mcp-server",
3
- "version": "1.5.2",
3
+ "version": "1.6.0",
4
4
  "description": "MCP server for Obsidian vault integration with Claude Code — 19 tools for notes, search, and graph traversal",
5
5
  "main": "cli.js",
6
6
  "exports": {
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@inquirer/prompts": "^8.3.2",
56
- "@modelcontextprotocol/sdk": "^1.0.0",
56
+ "@modelcontextprotocol/sdk": "^1.27.0",
57
57
  "better-sqlite3": "^12.6.2",
58
58
  "js-yaml": "^4.1.0",
59
59
  "sqlite-vec": "^0.1.7"
@@ -185,6 +185,7 @@ Use these with `vault_write({ template: "name", path: "...", frontmatter: { tags
185
185
  | `moc` | Maps of Content (index/hub notes) |
186
186
  | `daily-note` | Daily notes |
187
187
  | `task` | Structured task notes with status, priority, due date |
188
+ | `note` | Minimal generic notes |
188
189
 
189
190
  ## Session End
190
191