obsidian-mcp-server 1.2.3 → 1.2.5

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/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,15 +41,22 @@ 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(
45
+ "Missing API key. To fix this:\n" +
46
+ "1. Install the 'Local REST API' plugin in Obsidian\n" +
47
+ "2. Enable the plugin in Obsidian Settings\n" +
48
+ "3. Copy your API key from Obsidian Settings > Local REST API\n" +
49
+ "4. Provide the API key in your configuration",
50
+ 40100 // Unauthorized
51
+ );
30
52
  }
31
53
 
32
54
  // Combine defaults with provided config
33
55
  this.config = {
34
56
  ...DEFAULT_OBSIDIAN_CONFIG,
35
- verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
57
+ verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
36
58
  apiKey: config.apiKey,
37
- timeout: config.timeout ?? 5000,
59
+ timeout: config.timeout ?? 5000, // 5 second default timeout
38
60
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
39
61
  maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
40
62
  };
@@ -69,8 +91,9 @@ export class ObsidianClient {
69
91
 
70
92
  if (!this.config.verifySSL) {
71
93
  console.warn(
72
- "WARNING: SSL verification is disabled. This is not recommended for production use.",
73
- process.env.NODE_ENV === 'production' ? "Consider enabling SSL verification." : "This is acceptable for local development only."
94
+ "WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
95
+ "Make sure you have configured the certificate as a trusted certificate authority.\n" +
96
+ "See Obsidian Settings > Local REST API > 'How to Access' for setup instructions."
74
97
  );
75
98
  }
76
99
 
@@ -106,12 +129,33 @@ export class ObsidianClient {
106
129
  // Prevent path traversal attacks
107
130
  const normalizedPath = filepath.replace(/\\/g, '/');
108
131
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
109
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
132
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
110
133
  }
111
134
 
112
135
  // Additional path validations
113
136
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
114
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
137
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
138
+ }
139
+ }
140
+
141
+ private getErrorCode(status: number): number {
142
+ switch (status) {
143
+ case 400: return 40000; // Bad request
144
+ case 401: return 40100; // Unauthorized
145
+ case 403: return 40300; // Forbidden
146
+ case 404: return 40400; // Not found
147
+ case 405: return 40500; // Method not allowed
148
+ case 409: return 40900; // Conflict
149
+ case 429: return 42900; // Too many requests
150
+ case 500: return 50000; // Internal server error
151
+ case 501: return 50100; // Not implemented
152
+ case 502: return 50200; // Bad gateway
153
+ case 503: return 50300; // Service unavailable
154
+ case 504: return 50400; // Gateway timeout
155
+ default:
156
+ if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
157
+ if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
158
+ return 50000;
115
159
  }
116
160
  }
117
161
 
@@ -120,21 +164,64 @@ export class ObsidianClient {
120
164
  return await operation();
121
165
  } catch (error) {
122
166
  if (axios.isAxiosError(error)) {
123
- const axiosError = error as AxiosError<{ errorCode?: number; message?: string }>;
167
+ const axiosError = error as AxiosError<ApiError>;
124
168
  const response = axiosError.response;
125
169
  const errorData = response?.data;
126
- const code = errorData?.errorCode ?? response?.status ?? 500;
170
+
171
+ // Handle common connection errors with helpful messages
172
+ if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
173
+ throw new ObsidianError(
174
+ `SSL certificate verification failed. To fix this:\n` +
175
+ `1. Go to Obsidian Settings > Local REST API\n` +
176
+ `2. Under 'How to Access', copy the certificate\n` +
177
+ `3. Configure the certificate as a trusted certificate authority\n` +
178
+ `4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
179
+ `Original error: ${error.message}`,
180
+ 50001, // SSL error code
181
+ { code: error.code, config: { verifySSL: this.config.verifySSL } }
182
+ );
183
+ }
184
+
185
+ if (error.code === 'ECONNREFUSED') {
186
+ throw new ObsidianError(
187
+ `Connection refused. To fix this:\n` +
188
+ `1. Ensure Obsidian is running\n` +
189
+ `2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
190
+ `3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
191
+ `4. Make sure HTTPS is enabled in the plugin settings`,
192
+ 50002, // Connection refused
193
+ { code: error.code }
194
+ );
195
+ }
196
+
197
+ if (response?.status === 401) {
198
+ throw new ObsidianError(
199
+ `Authentication failed. To fix this:\n` +
200
+ `1. Go to Obsidian Settings > Local REST API\n` +
201
+ `2. Copy your API key from the settings\n` +
202
+ `3. Update your configuration with the new API key\n` +
203
+ `Note: The API key changes when you regenerate certificates`,
204
+ 40100, // Unauthorized
205
+ { code: error.code }
206
+ );
207
+ }
208
+
209
+ // For other errors, use API error code if available
210
+ const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
127
211
  const message = errorData?.message ?? axiosError.message ?? "Unknown error";
128
- throw new ObsidianError(message, code, errorData);
212
+ throw new ObsidianError(message, errorCode, errorData);
129
213
  }
130
- throw error;
214
+
215
+ if (error instanceof Error) {
216
+ throw new ObsidianError(error.message, 50000, error);
217
+ }
218
+
219
+ throw new ObsidianError("Unknown error occurred", 50000, error);
131
220
  }
132
221
  }
133
222
 
134
223
  async listFilesInVault(): Promise<ObsidianFile[]> {
135
224
  return this.safeRequest(async () => {
136
- const requestId = crypto.randomUUID();
137
- console.debug(`[${requestId}] Listing vault files`);
138
225
  const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
139
226
  return response.data.files;
140
227
  });
@@ -143,8 +230,6 @@ export class ObsidianClient {
143
230
  async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
144
231
  this.validateFilePath(dirpath);
145
232
  return this.safeRequest(async () => {
146
- const requestId = crypto.randomUUID();
147
- console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
148
233
  const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
149
234
  return response.data.files;
150
235
  });
@@ -153,23 +238,18 @@ export class ObsidianClient {
153
238
  async getFileContents(filepath: string): Promise<string> {
154
239
  this.validateFilePath(filepath);
155
240
  return this.safeRequest(async () => {
156
- const requestId = crypto.randomUUID();
157
- console.debug(`[${requestId}] Getting file contents: ${filepath}`);
158
241
  const response = await this.client.get<string>(`/vault/${filepath}`);
159
242
  return response.data;
160
243
  });
161
244
  }
162
245
 
163
- async search(query: string, contextLength: number = 100): Promise<SearchResult[]> {
246
+ async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
164
247
  return this.safeRequest(async () => {
165
- const requestId = crypto.randomUUID();
166
- console.debug(`[${requestId}] Performing simple search: ${query}`);
167
- const response = await this.client.post<SearchResult[]>("/search/simple/", undefined, {
168
- params: {
169
- query,
170
- contextLength
171
- }
172
- });
248
+ const response = await this.client.post<SimpleSearchResult[]>(
249
+ "/search/simple/",
250
+ null,
251
+ { params: { query, contextLength } }
252
+ );
173
253
  return response.data;
174
254
  });
175
255
  }
@@ -177,11 +257,9 @@ export class ObsidianClient {
177
257
  async appendContent(filepath: string, content: string): Promise<void> {
178
258
  this.validateFilePath(filepath);
179
259
  if (!content || typeof content !== 'string') {
180
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
260
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
181
261
  }
182
262
  return this.safeRequest(async () => {
183
- const requestId = crypto.randomUUID();
184
- console.debug(`[${requestId}] Appending content to: ${filepath}`);
185
263
  await this.client.post(
186
264
  `/vault/${filepath}`,
187
265
  content,
@@ -197,12 +275,10 @@ export class ObsidianClient {
197
275
  async updateContent(filepath: string, content: string): Promise<void> {
198
276
  this.validateFilePath(filepath);
199
277
  if (!content || typeof content !== 'string') {
200
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
278
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
201
279
  }
202
280
 
203
281
  return this.safeRequest(async () => {
204
- const requestId = crypto.randomUUID();
205
- console.debug(`[${requestId}] Updating content in: ${filepath}`);
206
282
  await this.client.put(
207
283
  `/vault/${filepath}`,
208
284
  content,
@@ -215,21 +291,167 @@ export class ObsidianClient {
215
291
  });
216
292
  }
217
293
 
218
- async searchJson(query: JsonLogicQuery): Promise<SearchResult[]> {
294
+ async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
219
295
  return this.safeRequest(async () => {
220
- const requestId = crypto.randomUUID();
221
- console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
222
- const response = await this.client.post<SearchResult[]>(
296
+ const isTagSearch = JSON.stringify(query).includes('"contains"') &&
297
+ JSON.stringify(query).includes('"#"');
298
+
299
+ const response = await this.client.post(
223
300
  "/search/",
224
301
  query,
225
302
  {
226
303
  headers: {
227
304
  "Content-Type": "application/vnd.olrapi.jsonlogic+json",
228
- "Accept": "application/json"
305
+ "Accept": "application/vnd.olrapi.note+json"
229
306
  }
230
307
  }
231
308
  );
309
+
310
+ return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
311
+ });
312
+ }
313
+
314
+ async getStatus(): Promise<ObsidianStatus> {
315
+ return this.safeRequest(async () => {
316
+ const response = await this.client.get<ObsidianStatus>("/");
317
+ return response.data;
318
+ });
319
+ }
320
+
321
+ async listCommands(): Promise<ObsidianCommand[]> {
322
+ return this.safeRequest(async () => {
323
+ const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
324
+ return response.data.commands;
325
+ });
326
+ }
327
+
328
+ async executeCommand(commandId: string): Promise<void> {
329
+ return this.safeRequest(async () => {
330
+ await this.client.post(`/commands/${commandId}/`);
331
+ });
332
+ }
333
+
334
+ async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
335
+ this.validateFilePath(filepath);
336
+ return this.safeRequest(async () => {
337
+ await this.client.post(`/open/${filepath}`, null, {
338
+ params: { newLeaf }
339
+ });
340
+ });
341
+ }
342
+
343
+ async getActiveFile(): Promise<NoteJson> {
344
+ return this.safeRequest(async () => {
345
+ const response = await this.client.get<NoteJson>("/active/", {
346
+ headers: {
347
+ "Accept": "application/vnd.olrapi.note+json"
348
+ }
349
+ });
350
+ return response.data;
351
+ });
352
+ }
353
+
354
+ async updateActiveFile(content: string): Promise<void> {
355
+ return this.safeRequest(async () => {
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
+ await this.client.delete("/active/");
367
+ });
368
+ }
369
+
370
+ async patchActiveFile(
371
+ operation: "append" | "prepend" | "replace",
372
+ targetType: "heading" | "block" | "frontmatter",
373
+ target: string,
374
+ content: string,
375
+ options?: {
376
+ delimiter?: string;
377
+ trimWhitespace?: boolean;
378
+ contentType?: "text/markdown" | "application/json";
379
+ }
380
+ ): Promise<void> {
381
+ return this.safeRequest(async () => {
382
+ const headers: Record<string, string> = {
383
+ "Operation": operation,
384
+ "Target-Type": targetType,
385
+ "Target": target,
386
+ "Content-Type": options?.contentType || "text/markdown"
387
+ };
388
+
389
+ if (options?.delimiter) {
390
+ headers["Target-Delimiter"] = options.delimiter;
391
+ }
392
+ if (options?.trimWhitespace !== undefined) {
393
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
394
+ }
395
+
396
+ await this.client.patch("/active/", content, { headers });
397
+ });
398
+ }
399
+
400
+ async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
401
+ return this.safeRequest(async () => {
402
+ const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
403
+ headers: {
404
+ "Accept": "application/vnd.olrapi.note+json"
405
+ }
406
+ });
232
407
  return response.data;
233
408
  });
234
409
  }
410
+
411
+ async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
412
+ return this.safeRequest(async () => {
413
+ await this.client.put(`/periodic/${period}/`, content, {
414
+ headers: {
415
+ "Content-Type": "text/markdown"
416
+ }
417
+ });
418
+ });
419
+ }
420
+
421
+ async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
422
+ return this.safeRequest(async () => {
423
+ await this.client.delete(`/periodic/${period}/`);
424
+ });
425
+ }
426
+
427
+ async patchPeriodicNote(
428
+ period: PeriodType["type"],
429
+ operation: "append" | "prepend" | "replace",
430
+ targetType: "heading" | "block" | "frontmatter",
431
+ target: string,
432
+ content: string,
433
+ options?: {
434
+ delimiter?: string;
435
+ trimWhitespace?: boolean;
436
+ contentType?: "text/markdown" | "application/json";
437
+ }
438
+ ): Promise<void> {
439
+ return this.safeRequest(async () => {
440
+ const headers: Record<string, string> = {
441
+ "Operation": operation,
442
+ "Target-Type": targetType,
443
+ "Target": target,
444
+ "Content-Type": options?.contentType || "text/markdown"
445
+ };
446
+
447
+ if (options?.delimiter) {
448
+ headers["Target-Delimiter"] = options.delimiter;
449
+ }
450
+ if (options?.trimWhitespace !== undefined) {
451
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
452
+ }
453
+
454
+ await this.client.patch(`/periodic/${period}/`, content, { headers });
455
+ });
456
+ }
235
457
  }
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 {