obsidian-mcp-server 1.2.3 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-mcp-server",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Model Context Protocol server for Obsidian integration with token-aware response handling",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -18,7 +18,7 @@
18
18
  "format": "prettier --write \"src/**/*.ts\""
19
19
  },
20
20
  "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.4.0",
21
+ "@modelcontextprotocol/sdk": "^1.4.1",
22
22
  "axios": "^1.7.9",
23
23
  "dotenv": "^16.4.7",
24
24
  "tiktoken": "^1.0.18",
@@ -29,7 +29,7 @@
29
29
  "@types/node": "^22.10.10",
30
30
  "@typescript-eslint/eslint-plugin": "^8.21.0",
31
31
  "@typescript-eslint/parser": "^8.21.0",
32
- "eslint": "^9.18.0",
32
+ "eslint": "^9.19.0",
33
33
  "eslint-config-prettier": "^10.0.1",
34
34
  "eslint-plugin-prettier": "^5.2.3",
35
35
  "prettier": "^3.4.2",
package/src/obsidian.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  import axios from "axios";
2
2
  import type { AxiosInstance, AxiosError, AxiosRequestConfig } from "axios";
3
- import { ObsidianConfig, ObsidianError, ObsidianFile, SearchResult, DEFAULT_OBSIDIAN_CONFIG, ObsidianServerConfig, JsonLogicQuery } from "./types.js";
3
+ import {
4
+ ObsidianConfig,
5
+ ObsidianError,
6
+ ObsidianFile,
7
+ SearchResult,
8
+ SimpleSearchResult,
9
+ SearchResponse,
10
+ DEFAULT_OBSIDIAN_CONFIG,
11
+ ObsidianServerConfig,
12
+ JsonLogicQuery,
13
+ ObsidianStatus,
14
+ ObsidianCommand,
15
+ NoteJson,
16
+ PeriodType,
17
+ ApiError
18
+ } from "./types.js";
4
19
  import { Agent } from "node:https";
5
20
  import { readFileSync } from "fs";
6
21
  import { fileURLToPath } from 'url';
@@ -26,7 +41,7 @@ export class ObsidianClient {
26
41
 
27
42
  constructor(config: ObsidianConfig) {
28
43
  if (!config.apiKey) {
29
- throw new ObsidianError("API key is required", 401);
44
+ throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
30
45
  }
31
46
 
32
47
  // Combine defaults with provided config
@@ -34,7 +49,7 @@ export class ObsidianClient {
34
49
  ...DEFAULT_OBSIDIAN_CONFIG,
35
50
  verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
36
51
  apiKey: config.apiKey,
37
- timeout: config.timeout ?? 5000,
52
+ timeout: config.timeout ?? 5000, // 5 second default timeout
38
53
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
39
54
  maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
40
55
  };
@@ -106,12 +121,39 @@ export class ObsidianClient {
106
121
  // Prevent path traversal attacks
107
122
  const normalizedPath = filepath.replace(/\\/g, '/');
108
123
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
109
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
124
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
110
125
  }
111
126
 
112
127
  // Additional path validations
113
128
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
114
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
129
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
130
+ }
131
+ }
132
+
133
+ private getErrorCode(status: number): number {
134
+ // Convert HTTP status codes to 5-digit error codes
135
+ switch (status) {
136
+ // Client errors (400-499)
137
+ case 400: return 40000; // Bad request
138
+ case 401: return 40100; // Unauthorized
139
+ case 403: return 40300; // Forbidden
140
+ case 404: return 40400; // Not found
141
+ case 405: return 40500; // Method not allowed
142
+ case 409: return 40900; // Conflict
143
+ case 429: return 42900; // Too many requests
144
+
145
+ // Server errors (500-599)
146
+ case 500: return 50000; // Internal server error
147
+ case 501: return 50100; // Not implemented
148
+ case 502: return 50200; // Bad gateway
149
+ case 503: return 50300; // Service unavailable
150
+ case 504: return 50400; // Gateway timeout
151
+
152
+ // Default cases
153
+ default:
154
+ if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
155
+ if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
156
+ return 50000; // Default to internal server error
115
157
  }
116
158
  }
117
159
 
@@ -120,14 +162,28 @@ export class ObsidianClient {
120
162
  return await operation();
121
163
  } catch (error) {
122
164
  if (axios.isAxiosError(error)) {
123
- const axiosError = error as AxiosError<{ errorCode?: number; message?: string }>;
165
+ const axiosError = error as AxiosError<ApiError>;
124
166
  const response = axiosError.response;
125
167
  const errorData = response?.data;
126
- const code = errorData?.errorCode ?? response?.status ?? 500;
127
- const message = errorData?.message ?? axiosError.message ?? "Unknown error";
128
- throw new ObsidianError(message, code, errorData);
168
+
169
+ // If the API returns a proper 5-digit error code, use it
170
+ // Otherwise, convert HTTP status to 5-digit code
171
+ const errorCode = errorData?.errorCode ??
172
+ this.getErrorCode(response?.status ?? 500);
173
+
174
+ const message = errorData?.message ??
175
+ axiosError.message ??
176
+ "Unknown error";
177
+
178
+ throw new ObsidianError(message, errorCode, errorData);
129
179
  }
130
- throw error;
180
+
181
+ // For non-Axios errors, use a generic server error code
182
+ if (error instanceof Error) {
183
+ throw new ObsidianError(error.message, 50000, error);
184
+ }
185
+
186
+ throw new ObsidianError("Unknown error occurred", 50000, error);
131
187
  }
132
188
  }
133
189
 
@@ -160,16 +216,17 @@ export class ObsidianClient {
160
216
  });
161
217
  }
162
218
 
163
- async search(query: string, contextLength: number = 100): Promise<SearchResult[]> {
219
+ async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
164
220
  return this.safeRequest(async () => {
165
221
  const requestId = crypto.randomUUID();
166
222
  console.debug(`[${requestId}] Performing simple search: ${query}`);
167
- const response = await this.client.post<SearchResult[]>("/search/simple/", undefined, {
168
- params: {
169
- query,
170
- contextLength
223
+ const response = await this.client.post<SimpleSearchResult[]>(
224
+ "/search/simple/",
225
+ null,
226
+ {
227
+ params: { query, contextLength }
171
228
  }
172
- });
229
+ );
173
230
  return response.data;
174
231
  });
175
232
  }
@@ -177,7 +234,7 @@ export class ObsidianClient {
177
234
  async appendContent(filepath: string, content: string): Promise<void> {
178
235
  this.validateFilePath(filepath);
179
236
  if (!content || typeof content !== 'string') {
180
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
237
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
181
238
  }
182
239
  return this.safeRequest(async () => {
183
240
  const requestId = crypto.randomUUID();
@@ -197,7 +254,7 @@ export class ObsidianClient {
197
254
  async updateContent(filepath: string, content: string): Promise<void> {
198
255
  this.validateFilePath(filepath);
199
256
  if (!content || typeof content !== 'string') {
200
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
257
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
201
258
  }
202
259
 
203
260
  return this.safeRequest(async () => {
@@ -215,21 +272,187 @@ export class ObsidianClient {
215
272
  });
216
273
  }
217
274
 
218
- async searchJson(query: JsonLogicQuery): Promise<SearchResult[]> {
275
+ async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
219
276
  return this.safeRequest(async () => {
220
277
  const requestId = crypto.randomUUID();
221
278
  console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
222
- const response = await this.client.post<SearchResult[]>(
279
+
280
+ // Check if this is a tag-based search
281
+ const isTagSearch = JSON.stringify(query).includes('"contains"') &&
282
+ JSON.stringify(query).includes('"#"');
283
+
284
+ const response = await this.client.post(
223
285
  "/search/",
224
286
  query,
225
287
  {
226
288
  headers: {
227
289
  "Content-Type": "application/vnd.olrapi.jsonlogic+json",
228
- "Accept": "application/json"
290
+ "Accept": "application/vnd.olrapi.note+json"
229
291
  }
230
292
  }
231
293
  );
294
+
295
+ if (isTagSearch) {
296
+ return response.data as SimpleSearchResult[];
297
+ }
298
+ return response.data as SearchResult[];
299
+ });
300
+ }
301
+
302
+ async getStatus(): Promise<ObsidianStatus> {
303
+ return this.safeRequest(async () => {
304
+ const requestId = crypto.randomUUID();
305
+ console.debug(`[${requestId}] Getting server status`);
306
+ const response = await this.client.get<ObsidianStatus>("/");
232
307
  return response.data;
233
308
  });
234
309
  }
310
+
311
+ async listCommands(): Promise<ObsidianCommand[]> {
312
+ return this.safeRequest(async () => {
313
+ const requestId = crypto.randomUUID();
314
+ console.debug(`[${requestId}] Listing available commands`);
315
+ const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
316
+ return response.data.commands;
317
+ });
318
+ }
319
+
320
+ async executeCommand(commandId: string): Promise<void> {
321
+ return this.safeRequest(async () => {
322
+ const requestId = crypto.randomUUID();
323
+ console.debug(`[${requestId}] Executing command: ${commandId}`);
324
+ await this.client.post(`/commands/${commandId}/`);
325
+ });
326
+ }
327
+
328
+ async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
329
+ this.validateFilePath(filepath);
330
+ return this.safeRequest(async () => {
331
+ const requestId = crypto.randomUUID();
332
+ console.debug(`[${requestId}] Opening file: ${filepath}`);
333
+ await this.client.post(`/open/${filepath}`, null, {
334
+ params: { newLeaf }
335
+ });
336
+ });
337
+ }
338
+
339
+ async getActiveFile(): Promise<NoteJson> {
340
+ return this.safeRequest(async () => {
341
+ const requestId = crypto.randomUUID();
342
+ console.debug(`[${requestId}] Getting active file`);
343
+ const response = await this.client.get<NoteJson>("/active/", {
344
+ headers: {
345
+ "Accept": "application/vnd.olrapi.note+json"
346
+ }
347
+ });
348
+ return response.data;
349
+ });
350
+ }
351
+
352
+ async updateActiveFile(content: string): Promise<void> {
353
+ return this.safeRequest(async () => {
354
+ const requestId = crypto.randomUUID();
355
+ console.debug(`[${requestId}] Updating active file`);
356
+ await this.client.put("/active/", content, {
357
+ headers: {
358
+ "Content-Type": "text/markdown"
359
+ }
360
+ });
361
+ });
362
+ }
363
+
364
+ async deleteActiveFile(): Promise<void> {
365
+ return this.safeRequest(async () => {
366
+ const requestId = crypto.randomUUID();
367
+ console.debug(`[${requestId}] Deleting active file`);
368
+ await this.client.delete("/active/");
369
+ });
370
+ }
371
+
372
+ async patchActiveFile(operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
373
+ delimiter?: string;
374
+ trimWhitespace?: boolean;
375
+ contentType?: "text/markdown" | "application/json";
376
+ }): Promise<void> {
377
+ return this.safeRequest(async () => {
378
+ const requestId = crypto.randomUUID();
379
+ console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
380
+
381
+ const headers: Record<string, string> = {
382
+ "Operation": operation,
383
+ "Target-Type": targetType,
384
+ "Target": target,
385
+ "Content-Type": options?.contentType || "text/markdown"
386
+ };
387
+
388
+ if (options?.delimiter) {
389
+ headers["Target-Delimiter"] = options.delimiter;
390
+ }
391
+ if (options?.trimWhitespace !== undefined) {
392
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
393
+ }
394
+
395
+ await this.client.patch("/active/", content, { headers });
396
+ });
397
+ }
398
+
399
+ async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
400
+ return this.safeRequest(async () => {
401
+ const requestId = crypto.randomUUID();
402
+ console.debug(`[${requestId}] Getting ${period} note`);
403
+ const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
404
+ headers: {
405
+ "Accept": "application/vnd.olrapi.note+json"
406
+ }
407
+ });
408
+ return response.data;
409
+ });
410
+ }
411
+
412
+ async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
413
+ return this.safeRequest(async () => {
414
+ const requestId = crypto.randomUUID();
415
+ console.debug(`[${requestId}] Updating ${period} note`);
416
+ await this.client.put(`/periodic/${period}/`, content, {
417
+ headers: {
418
+ "Content-Type": "text/markdown"
419
+ }
420
+ });
421
+ });
422
+ }
423
+
424
+ async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
425
+ return this.safeRequest(async () => {
426
+ const requestId = crypto.randomUUID();
427
+ console.debug(`[${requestId}] Deleting ${period} note`);
428
+ await this.client.delete(`/periodic/${period}/`);
429
+ });
430
+ }
431
+
432
+ async patchPeriodicNote(period: PeriodType["type"], operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
433
+ delimiter?: string;
434
+ trimWhitespace?: boolean;
435
+ contentType?: "text/markdown" | "application/json";
436
+ }): Promise<void> {
437
+ return this.safeRequest(async () => {
438
+ const requestId = crypto.randomUUID();
439
+ console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
440
+
441
+ const headers: Record<string, string> = {
442
+ "Operation": operation,
443
+ "Target-Type": targetType,
444
+ "Target": target,
445
+ "Content-Type": options?.contentType || "text/markdown"
446
+ };
447
+
448
+ if (options?.delimiter) {
449
+ headers["Target-Delimiter"] = options.delimiter;
450
+ }
451
+ if (options?.trimWhitespace !== undefined) {
452
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
453
+ }
454
+
455
+ await this.client.patch(`/periodic/${period}/`, content, { headers });
456
+ });
457
+ }
235
458
  }
package/src/properties.ts CHANGED
@@ -82,7 +82,8 @@ export class PropertyManager {
82
82
  */
83
83
  mergeProperties(
84
84
  existing: ObsidianProperties,
85
- updates: Partial<ObsidianProperties>
85
+ updates: Partial<ObsidianProperties>,
86
+ replace: boolean = false
86
87
  ): ObsidianProperties {
87
88
  const merged = { ...existing };
88
89
 
@@ -92,11 +93,11 @@ export class PropertyManager {
92
93
 
93
94
  const currentValue = merged[key as keyof ObsidianProperties];
94
95
 
95
- // Special handling for arrays - merge rather than replace
96
+ // Handle arrays based on replace flag
96
97
  if (Array.isArray(value) && Array.isArray(currentValue)) {
97
- merged[key as keyof ObsidianProperties] = [
98
- ...new Set([...currentValue, ...value])
99
- ] as any;
98
+ merged[key as keyof ObsidianProperties] = replace ?
99
+ value :
100
+ [...new Set([...currentValue, ...value])] as any;
100
101
  }
101
102
  // Special handling for custom object - deep merge
102
103
  else if (key === 'custom' && typeof value === 'object' && value !== null) {
@@ -144,7 +145,8 @@ export class PropertyManager {
144
145
  */
145
146
  async updateProperties(
146
147
  filepath: string,
147
- newProperties: Partial<ObsidianProperties>
148
+ newProperties: Partial<ObsidianProperties>,
149
+ replace: boolean = false
148
150
  ): Promise<PropertyManagerResult> {
149
151
  try {
150
152
  // Validate new properties
@@ -162,7 +164,7 @@ export class PropertyManager {
162
164
  const existingProperties = this.parseProperties(content);
163
165
 
164
166
  // Merge properties
165
- const mergedProperties = this.mergeProperties(existingProperties, newProperties);
167
+ const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
166
168
 
167
169
  // Generate new frontmatter
168
170
  const newFrontmatter = this.generateProperties(mergedProperties);
@@ -16,6 +16,7 @@ interface GetPropertiesArgs {
16
16
  interface UpdatePropertiesArgs {
17
17
  filepath: string;
18
18
  properties: Partial<ObsidianProperties>;
19
+ replace?: boolean;
19
20
  }
20
21
 
21
22
  export class GetPropertiesToolHandler extends BaseToolHandler<GetPropertiesArgs> {
@@ -79,7 +80,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
79
80
  getToolDescription(): Tool {
80
81
  return {
81
82
  name: this.name,
82
- description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically manages timestamps (created by Obsidian, modified by MCP server). Existing properties not included in the update are preserved.",
83
+ description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically manages timestamps. Valid property types:\n- type: Any string value\n- status: ['draft', 'in-progress', 'review', 'complete']\n- tags: Array of strings starting with '#'\n- Other fields: title, author, version, platform, repository (URI), dependencies, sources, urls (URI), papers, custom (object)",
83
84
  examples: [
84
85
  {
85
86
  description: "Update basic metadata",
@@ -93,14 +94,14 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
93
94
  }
94
95
  },
95
96
  {
96
- description: "Update tags and status",
97
+ description: "Update tags and status with replace",
97
98
  args: {
98
99
  filepath: "docs/feature.md",
99
100
  properties: {
100
101
  tags: ["#feature", "#in-development", "#high-priority"],
101
- status: ["in-progress"],
102
- version: "2.0.0"
103
- }
102
+ status: ["in-progress"]
103
+ },
104
+ replace: true
104
105
  }
105
106
  },
106
107
  {
@@ -131,23 +132,9 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
131
132
  properties: {
132
133
  title: { type: "string" },
133
134
  author: { type: "string" },
134
- // Note: created and modified timestamps are managed automatically
135
135
  type: {
136
136
  type: "array",
137
- items: {
138
- type: "string",
139
- enum: [
140
- "concept",
141
- "architecture",
142
- "specification",
143
- "protocol",
144
- "api",
145
- "research",
146
- "implementation",
147
- "guide",
148
- "reference"
149
- ]
150
- }
137
+ items: { type: "string" }
151
138
  },
152
139
  tags: {
153
140
  type: "array",
@@ -185,6 +172,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
185
172
  }
186
173
  },
187
174
  additionalProperties: false
175
+ },
176
+ replace: {
177
+ type: "boolean",
178
+ description: "If true, arrays will be replaced instead of merged"
188
179
  }
189
180
  },
190
181
  required: ["filepath", "properties"]
@@ -196,7 +187,8 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
196
187
  try {
197
188
  const result = await this.propertyManager.updateProperties(
198
189
  args.filepath,
199
- args.properties
190
+ args.properties,
191
+ args.replace
200
192
  );
201
193
  return this.createResponse(result);
202
194
  } catch (error) {
@@ -1,17 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  // Define validation schemas
4
- export const PropertyTypeEnum = z.enum([
5
- "concept",
6
- "architecture",
7
- "specification",
8
- "protocol",
9
- "api",
10
- "research",
11
- "implementation",
12
- "guide",
13
- "reference"
14
- ]);
4
+ // Allow any string for type to be more flexible
5
+ export const PropertyType = z.string();
15
6
 
16
7
  export const StatusEnum = z.enum([
17
8
  "draft",
@@ -29,7 +20,7 @@ export const ObsidianPropertiesSchema = z.object({
29
20
  author: z.string().optional(),
30
21
 
31
22
  // Classification
32
- type: z.array(PropertyTypeEnum).optional(),
23
+ type: z.array(PropertyType).optional(),
33
24
 
34
25
  // Organization
35
26
  tags: z.array(z.string().startsWith("#")).optional(),
@@ -57,7 +48,7 @@ export const PropertyUpdateSchema = z.object({
57
48
  author: z.string().optional(),
58
49
 
59
50
  // Classification
60
- type: z.array(PropertyTypeEnum).optional(),
51
+ type: z.array(PropertyType).optional(),
61
52
 
62
53
  // Organization
63
54
  tags: z.array(z.string().startsWith("#")).optional(),
@@ -85,6 +76,7 @@ export interface PropertyOperation {
85
76
  operation: 'get' | 'update' | 'patch';
86
77
  filepath: string;
87
78
  properties?: Partial<ObsidianProperties>;
79
+ replace?: boolean; // Add replace flag
88
80
  }
89
81
 
90
82
  export interface ValidationResult {
@@ -0,0 +1,76 @@
1
+ import { Resource, TextContent } from "@modelcontextprotocol/sdk/types.js";
2
+ import { ObsidianClient } from "./obsidian.js";
3
+ import { TagResponse, SearchMatch, SimpleSearchResult, SearchResponse } from "./types.js";
4
+
5
+ export class TagResource {
6
+ private static readonly TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
7
+
8
+ constructor(private client: ObsidianClient) {}
9
+
10
+ getResourceDescription(): Resource {
11
+ return {
12
+ uri: "obsidian://tags",
13
+ name: "Obsidian Tags",
14
+ description: "List of all tags used across the Obsidian vault with their usage counts",
15
+ mimeType: "application/json"
16
+ };
17
+ }
18
+
19
+ async getContent(): Promise<TextContent[]> {
20
+ try {
21
+ // Search for files containing #
22
+ const query = {
23
+ "contains": [{ "var": "content" }, "#"]
24
+ };
25
+
26
+ const results = await this.client.searchJson(query);
27
+
28
+ // Process results to extract tags
29
+ const tagMap = new Map<string, Set<string>>();
30
+ let totalOccurrences = 0;
31
+ let scannedFiles = 0;
32
+
33
+ results.forEach((result: SearchResponse) => {
34
+ scannedFiles++;
35
+ if ('matches' in result) {
36
+ result.matches.forEach((match: SearchMatch) => {
37
+ const tags = match.context.match(TagResource.TAG_PATTERN);
38
+ if (tags) {
39
+ tags.forEach((tag: string) => {
40
+ if (!tagMap.has(tag)) {
41
+ tagMap.set(tag, new Set());
42
+ }
43
+ tagMap.get(tag)!.add(result.filename);
44
+ totalOccurrences++;
45
+ });
46
+ }
47
+ });
48
+ }
49
+ });
50
+
51
+ // Convert to sorted response format
52
+ const response: TagResponse = {
53
+ tags: Array.from(tagMap.entries())
54
+ .map(([name, files]) => ({
55
+ name,
56
+ count: files.size,
57
+ files: Array.from(files).sort()
58
+ }))
59
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
60
+ metadata: {
61
+ totalOccurrences,
62
+ uniqueTags: tagMap.size,
63
+ scannedFiles
64
+ }
65
+ };
66
+
67
+ return [{
68
+ type: "text",
69
+ text: JSON.stringify(response, null, 2)
70
+ }];
71
+ } catch (error) {
72
+ console.error("Failed to fetch tags:", error);
73
+ throw error;
74
+ }
75
+ }
76
+ }