obsidian_fetch 1.2.0 → 1.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f05f834b38c590fcbccce689668144c14f442bf7ff08899e04f2feb1f807eb95
4
- data.tar.gz: cc9d23ced95dd85719d0f0666a3363929e40ff8b2983962cc58930e3d2793157
3
+ metadata.gz: c92c9699f97419d2dddd804dbd4a9f44796f2f51dbb5031973956e45965e8052
4
+ data.tar.gz: 93c8c207206f2d26dc7164d1b98630ba059d3144c484e1597a8aa94a96330058
5
5
  SHA512:
6
- metadata.gz: 366348161c4e8a2352c9a715787f9f86ebb96d0aee0878d83720a4da623d3ab378878e5dd4c0c86ba9dc1ef92421c27f162c40dcbbfadf0ea033c35d5cf6fc19
7
- data.tar.gz: 731c8660944590029ab2383da67fb2cfc8499bc89ef7bcde7460798b62651df76e7ec104f79165048a6d20a76760c93f3cf52c271a8cb84d5d4bcf4b5252d004
6
+ metadata.gz: 859f74ff1b6e17496fa5f2cd34f13db8e2e459ce81da72e2f3b1184f4013ac253cfdafb9d61520cd75b8f908d78ae559a6351720d19354a1dd5c4b96898f6124
7
+ data.tar.gz: 8a9ddd0bde00cc3746e6a129bd87ac2408258b9fa26dfc314ffd229071f9b89f94fe07ca5f503f8d8e91d3f94452eb0026bffb1023d340b8a4bb237175e8a2f5
data/CHANGELOG.md CHANGED
@@ -1,4 +1,44 @@
1
- ## [Unreleased]
1
+ ## [1.3.1] - 2026-10-28
2
+
3
+ - streamable-http transport: disabled the DNS rebinding protection for convenience in closed container networks.
4
+
5
+ ## [1.3.0] - 2026-10-28
6
+
7
+ - Dependencies: mcp 1.8.2 (constraint `~> 1.8`), puma 8.0.2, rack 3.2.7, simplecov 1.3.2. Required Ruby is 3.3.0 or later.
8
+ - list tool: stopped shuffling. Results are ordered by note name, compared case-insensitively with an ASCII tiebreak. Backlinks, link fallback lists and file enumerations are ordered the same way. When the result is truncated by limit, the truncated priority group shows its head and tail with a line reporting the omitted count.
9
+ - list tool: added the `limit` argument (1 to 200, default 20) and the total number of matches in the response.
10
+ - list tool: folder names are searched, matches are ranked by the distance from the file, and the response reports the breakdown of matches.
11
+ - read tool: a case-insensitive fuzzy match is accepted when no exact match exists, and case variants are suggested when an exact match exists.
12
+ - The initialize response advertises only the tools capability.
13
+
14
+ ## [1.2.0] - 2026-07-10
15
+
16
+ - Vault files are reloaded when they change.
17
+ - isError handling was added to the tools.
18
+ - Tests were restructured and coverage measurement was added.
19
+
20
+ ## [1.1.2] - 2026-04-28
21
+
22
+ - The Puma bind address was changed from 127.0.0.1 to 0.0.0.0 to allow external access.
23
+ - The `enable_json_response` option added in 1.1.1 was reverted.
24
+
25
+ ## [1.1.1] - 2026-04-28
26
+
27
+ - Added the `enable_json_response` option for mcp 0.14.0.
28
+
29
+ ## [1.1.0] - 2026-04-28
30
+
31
+ - Added the Streamable HTTP transport.
32
+ - README was made multilingual.
33
+ - The mcp gem was upgraded to 0.14.0.
34
+
35
+ ## [1.0.0] - 2025-08-20
36
+
37
+ - Switched to the official MCP Ruby SDK.
38
+ - Fixed the way tools are specified.
39
+ - read tool normalizes a path given as `name`.
40
+ - Display shows note names instead of paths.
41
+ - Refactored and extended the list tool.
2
42
 
3
43
  ## [0.1.0] - 2025-04-27
4
44
 
data/README.md CHANGED
@@ -48,7 +48,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
48
48
  ## Tools
49
49
 
50
50
  - **read**: Read a note from the Obsidian vault. If multiple notes with the same name are found, all will be shown.
51
- - **list**: Search for files with matching names partially.
51
+ - **list**: Search for notes whose name or containing folder names partially match. It returns 20 notes by default. The `limit` argument accepts an integer between 1 and 200, and the response states how many notes matched in total.
52
52
 
53
53
  ## Contributing
54
54
 
data/README_ja.md CHANGED
@@ -52,7 +52,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
52
52
  ## ツール
53
53
 
54
54
  - **read**: Obsidian Vaultからノートを取得します。同名のノートが複数存在する場合、すべて取得します。
55
- - **list**: ファイル名で検索します。部分一致での検索が可能です。
55
+ - **list**: ノート名とフォルダ名で検索します。部分一致での検索が可能です。既定では20件を返します。limit 引数には1から200の整数を受け付けます。応答にはマッチした総件数が含まれます。
56
56
 
57
57
  ## 貢献
58
58
 
data/lib/exe.rb CHANGED
@@ -101,26 +101,34 @@ class ReadTool < MCP::Tool
101
101
  end
102
102
 
103
103
  class ListTool < MCP::Tool
104
- description "Search for files with matching names partially."
104
+ description "Search for notes whose name or containing folder names partially match."
105
105
  input_schema(
106
106
  properties: {
107
- name: { type: "string", description: "Note name to search" }
107
+ name: { type: "string", description: "Note name to search" },
108
+ limit: { type: "integer", description: "Number of notes to return. Between 1 and #{ObsidianFetch::MAX_LIST_LIMIT}. Default: #{ObsidianFetch::MAX_LIST_SIZE}." }
108
109
  },
109
110
  required: ["name"]
110
111
  )
111
112
 
112
- def self.call(name:)
113
+ def self.call(name:, limit: ObsidianFetch::MAX_LIST_SIZE)
113
114
  return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
114
- result = ObsidianFetch.list_notes($manager.vault, name)
115
+ result = ObsidianFetch.list_notes($manager.vault, name, limit: limit)
115
116
  MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
116
117
  end
117
118
  end
118
119
 
119
120
  # MCPサーバーの初期化
121
+ #
122
+ # capabilities を明示的に指定しています。
123
+ # MCP::Server は capabilities を渡さない場合、default_capabilities を使います。
124
+ # default_capabilities には tools だけでなく prompts、resources、logging が含まれるため、
125
+ # リソースを持たないこのサーバーでもクライアントが resources/list を呼び出し、空の一覧が返ってしまいます。
126
+ # https://github.com/modelcontextprotocol/ruby-sdk/blob/v1.8.2/lib/mcp/server.rb
120
127
  server = MCP::Server.new(
121
128
  name: "obsidian-fetch",
122
129
  version: ObsidianFetch::VERSION,
123
130
  tools: [ReadTool, ListTool],
131
+ capabilities: { tools: { listChanged: true } },
124
132
  )
125
133
 
126
134
  # トランスポートを選択してサーバーを起動
@@ -128,7 +136,8 @@ case transport_type
128
136
  when 'streamable-http'
129
137
  require 'puma'
130
138
  require 'rack'
131
- transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
139
+ # 閉じたネットワークにおける利便性を優先し、DNSリバインディングへの保護を無効化する。
140
+ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true, dns_rebinding_protection: false)
132
141
  $stdout.sync = true
133
142
  $stderr.sync = true
134
143
  server_obj = Puma::Server.new(transport) do
@@ -8,7 +8,10 @@ module ObsidianFetch
8
8
  end
9
9
  end
10
10
 
11
+ # list ツールの既定の返却件数
11
12
  MAX_LIST_SIZE = 20
13
+ # list ツールで指定できる返却件数の上限
14
+ MAX_LIST_LIMIT = 200
12
15
 
13
16
  # ノート名を正規化する
14
17
  # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
@@ -16,6 +19,10 @@ module ObsidianFetch
16
19
  note_name.gsub(/[\[\]\#\^|]/, '')
17
20
  end
18
21
 
22
+ PATH_CORRECTION_PREFACE = <<~EOS
23
+ Presumably a path was specified. The process was automatically renamed and processed.
24
+ EOS
25
+
19
26
  # --- ツールロジック ---
20
27
  def self.read_note(vault, name)
21
28
  name = ObsidianFetch.normalize_note_name(name)
@@ -27,47 +34,55 @@ module ObsidianFetch
27
34
  fixed_name = File.basename(name, '.md')
28
35
  file_pathes = vault.notes[fixed_name]
29
36
  if file_pathes.nil?
37
+ # 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
38
+ candidates = case_variant_candidates(vault, fixed_name)
39
+ return fuzzy_match_response(vault, fixed_name, candidates, PATH_CORRECTION_PREFACE) unless candidates.empty?
40
+
30
41
  # もしも名前で見つからなければ、リンクにも存在しないか確認する
31
42
  link_pathes = vault.links_by_file_name[fixed_name]
32
43
  if link_pathes.nil?
33
44
  return Result.new(text: note_not_found(name), error: true)
34
45
  else
35
46
  # リンク先のノートが見つかった場合は、prefaceを追加する
36
- preface = <<~EOS
37
- Presumably a path was specified. The process was automatically renamed and processed.
38
- EOS
39
- return Result.new(text: list_links(fixed_name, preface, vault), error: true)
47
+ return Result.new(text: list_links(fixed_name, PATH_CORRECTION_PREFACE, vault), error: true)
40
48
  end
41
49
  else
42
50
  # ノート名が見つかった場合は、prefaceを追加する
43
- preface = <<~EOS
44
- Presumably a path was specified. The process was automatically renamed and processed.
45
- EOS
46
- return Result.new(text: open_file(fixed_name, file_pathes, preface, vault))
51
+ return Result.new(text: PATH_CORRECTION_PREFACE + case_variant_suggestion(vault, fixed_name) + open_file(fixed_name, file_pathes, "", vault))
47
52
  end
48
53
  end
49
54
 
50
55
  # 名前のノートが存在しない場合
51
56
  if file_pathes.nil?
57
+ # 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
58
+ candidates = case_variant_candidates(vault, name)
59
+ return fuzzy_match_response(vault, name, candidates, preface) unless candidates.empty?
60
+
52
61
  return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil?
53
62
  return Result.new(text: list_links(name, preface, vault), error: true)
54
63
  end
55
64
 
56
- Result.new(text: open_file(name, file_pathes, preface, vault))
65
+ Result.new(text: case_variant_suggestion(vault, name) + open_file(name, file_pathes, preface, vault))
57
66
  end
58
67
 
59
- def self.list_notes(vault, name)
68
+ def self.list_notes(vault, name, limit: MAX_LIST_SIZE)
69
+ unless limit.is_a?(Integer) && limit >= 1 && limit <= MAX_LIST_LIMIT
70
+ return Result.new(text: <<~EOS, error: true)
71
+ The limit must be an integer between 1 and #{MAX_LIST_LIMIT}.
72
+ EOS
73
+ end
74
+
60
75
  name = ObsidianFetch.normalize_note_name(name)
61
76
  split_name = name.split(/[\t \u3000]+/)
62
77
  # 空白文字で検索された場合は失敗した旨を返す
63
- # 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
78
+ # 仮に全ノートからランダムに limit 件返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
64
79
  if split_name.empty?
65
80
  return Result.new(text: <<~EOS, error: true)
66
81
  It cannot be listed in a blank string.
67
82
  EOS
68
83
  end
69
- matched_notes = vault.notes.select do |note_name, _file_pathes|
70
- split_name.map { |name_part| note_name.include?(name_part) }.all?
84
+ matched_notes = vault.notes.filter_map do |note_name, file_pathes|
85
+ match_note(vault, note_name, file_pathes, split_name)
71
86
  end
72
87
  # 名前で検索したが見つからない場合
73
88
  if matched_notes.empty?
@@ -79,18 +94,103 @@ module ObsidianFetch
79
94
  return Result.new(text: <<~EOS, error: true)
80
95
  Note not found: #{name}
81
96
  However, I found other notes linked to this note.
82
- #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
97
+ #{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
83
98
  EOS
84
99
  end
85
- # マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ
86
- preface = "Notes matching '#{name}' are as follows.\n"
87
- if matched_notes.size > MAX_LIST_SIZE
88
- matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h
89
- preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface
100
+ # マッチしたノートの数が limit より多い場合は、優先順位の順に枠を埋める
101
+ # truncate されるグループは先頭と末尾を出す
102
+ total = matched_notes.size
103
+ by_name = matched_notes.count { |entry| entry[:matched_by_name] }
104
+ tiers = matched_notes.group_by { |entry| entry[:distance] }.keys.sort.map do |distance|
105
+ matched_notes.select { |entry| entry[:distance] == distance }.sort_by { |entry| [entry[:note_name].downcase, entry[:note_name]] }
106
+ end
107
+
108
+ segments = []
109
+ remaining = limit
110
+ tiers.each do |tier|
111
+ break if remaining.zero?
112
+
113
+ if tier.size <= remaining
114
+ segments << {entries: tier, omitted_after: 0}
115
+ remaining -= tier.size
116
+ elsif remaining < 3
117
+ segments << {entries: tier.first(remaining), omitted_after: tier.size - remaining}
118
+ remaining = 0
119
+ else
120
+ head_size = (remaining * 2) / 3
121
+ tail_size = remaining - head_size
122
+ segments << {entries: tier.first(head_size), omitted_after: tier.size - remaining}
123
+ segments << {entries: tier.last(tail_size), omitted_after: 0}
124
+ remaining = 0
125
+ end
126
+ end
127
+
128
+ shown = segments.sum { |segment| segment[:entries].size }
129
+ preface = "#{total} notes matched '#{name}'"
130
+ preface += " (#{by_name} by note name, #{total - by_name} by folder name)" if total > by_name
131
+ preface += ".\n"
132
+ if shown < total
133
+ preface += "I will show you #{shown} of them. To see the rest, narrow the search or raise the limit.\n"
90
134
  end
91
- # マッチした名前のリストで返す
92
- list = matched_notes.keys.shuffle.map { |note_name| "- #{note_name}" }.join("\n")
93
- Result.new(text: preface + list)
135
+ preface += "Notes matching '#{name}' are as follows.\n"
136
+ lines = []
137
+ segments.each do |segment|
138
+ lines += segment[:entries].map do |entry|
139
+ if entry[:matched_by_name]
140
+ "- #{entry[:note_name]}"
141
+ else
142
+ "- #{entry[:note_name]} (#{entry[:display_folder]})"
143
+ end
144
+ end
145
+ lines << "... (#{segment[:omitted_after]} notes omitted) ..." if segment[:omitted_after].positive?
146
+ end
147
+ Result.new(text: preface + lines.join("\n") + "\n")
148
+ end
149
+
150
+ # ノート1件について検索語のマッチを判定し、マッチしていれば情報を返す
151
+ # 距離はファイルからの距離で、ノート名は距離0、直近の親フォルダは距離1、その上が距離2とする
152
+ # 各語の最も近いマッチ位置のうち、最も遠いものがそのノートの距離になる
153
+ def self.match_note(vault, note_name, file_pathes, words)
154
+ lowered_words = words.map(&:downcase)
155
+ best = nil
156
+
157
+ file_pathes.each do |file_path|
158
+ vault_name, rel_path = relative_path(vault, file_path)
159
+ folders = rel_path.split('/')[0...-1]
160
+
161
+ distances = lowered_words.map do |word|
162
+ distance = note_name.downcase.include?(word) ? 0 : nil
163
+ folders.each_with_index do |folder, index|
164
+ next unless folder.downcase.include?(word)
165
+ candidate = folders.size - index
166
+ distance = candidate if distance.nil? || candidate < distance
167
+ end
168
+ distance
169
+ end
170
+ next if distances.any?(&:nil?)
171
+
172
+ display_folder = folders.join('/')
173
+ display_folder = File.join(vault_name, display_folder) if vault.vault_pathes.size > 1 && vault_name
174
+
175
+ entry = {
176
+ note_name: note_name,
177
+ distance: distances.max,
178
+ matched_by_name: distances.max.zero?,
179
+ display_folder: display_folder,
180
+ }
181
+ best = entry if best.nil? || entry[:distance] < best[:distance]
182
+ end
183
+
184
+ best
185
+ end
186
+
187
+ # 保管庫ルートからの相対パスと、その保管庫名を返す
188
+ def self.relative_path(vault, file_path)
189
+ vault.vault_pathes.each do |vault_path|
190
+ pattern = /\A#{Regexp.escape(vault_path)}\/+/
191
+ return [File.basename(vault_path), file_path.sub(pattern, '')] if file_path.match?(pattern)
192
+ end
193
+ [nil, File.basename(file_path)]
94
194
  end
95
195
 
96
196
  # --- ヘルパーメソッド ---
@@ -100,25 +200,62 @@ module ObsidianFetch
100
200
  EOS
101
201
  end
102
202
 
203
+ # 大文字小文字は揃えて比較し、同値は原名の ASCII 順でタイブレークする
204
+ def self.sort_case_insensitive(values)
205
+ values.sort_by { |value| [value.downcase, value] }
206
+ end
207
+
208
+ # 完全一致を除いた、大文字小文字の差異だけのキーをノート名順で返す
209
+ def self.case_variant_candidates(vault, target)
210
+ sort_case_insensitive(vault.notes.keys.reject { |note_name| note_name == target }.select { |note_name| note_name.downcase == target.downcase })
211
+ end
212
+
213
+ # 完全一致を読むときに、揺らぎマッチしたノート名のサジェストを組み立てる
214
+ def self.case_variant_suggestion(vault, target)
215
+ candidates = case_variant_candidates(vault, target)
216
+ return "" if candidates.empty?
217
+
218
+ if candidates.size == 1
219
+ "There is also a note with the same name in different capitalization:\n- #{candidates.first}\n\n"
220
+ else
221
+ "There are also notes with the same name in different capitalization:\n" +
222
+ candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n\n"
223
+ end
224
+ end
225
+
226
+ # 揺らぎマッチの応答を組み立てる
227
+ # 候補が1件のときはその内容を読み、複数のときは候補名の一覧を返す
228
+ def self.fuzzy_match_response(vault, target, candidates, preface)
229
+ if candidates.size == 1
230
+ note_name = candidates.first
231
+ message = "There is no note named '#{target}'. The following note has the same name with different capitalization:\n- #{note_name}\n\n"
232
+ Result.new(text: preface + message + open_file(note_name, vault.notes[note_name], "", vault), error: false)
233
+ else
234
+ message = "There is no note named '#{target}'. The following notes have the same name with different capitalization:\n" +
235
+ candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n"
236
+ Result.new(text: preface + message, error: false)
237
+ end
238
+ end
239
+
103
240
  def self.list_links(name, preface, vault)
104
241
  <<~EOS
105
242
  #{preface}
106
243
  Note not found: #{name}
107
244
  However, I found other notes linked to this note.
108
- #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
245
+ #{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
109
246
  EOS
110
247
  end
111
248
 
112
249
  def self.open_file(name, file_pathes, preface, vault)
113
250
  # 複数のファイルがある場合は、---とファイル名で区切って返す
114
- result = file_pathes.map do |file_path|
251
+ result = sort_case_insensitive(file_pathes).map do |file_path|
115
252
  content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
116
253
  link_notes = if vault.links_by_file_path[file_path].nil?
117
254
  ""
118
255
  else
119
256
  <<~EOS
120
257
  This note is linked by the following notes:
121
- #{(vault.links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
258
+ #{sort_case_insensitive(vault.links_by_file_path[file_path].map { |linked_path| File.basename(linked_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
122
259
  EOS
123
260
  end
124
261
  metadata = <<~EOS
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ObsidianFetch
4
- VERSION = "1.2.0"
4
+ VERSION = "1.3.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: obsidian_fetch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - sou7
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.14.0
18
+ version: '1.8'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.14.0
25
+ version: '1.8'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rack
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -86,7 +86,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
88
88
  - !ruby/object:Gem::Version
89
- version: 3.1.0
89
+ version: 3.3.0
90
90
  required_rubygems_version: !ruby/object:Gem::Requirement
91
91
  requirements:
92
92
  - - ">="