obsidian-mcp-server 1.1.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/src/server.ts ADDED
@@ -0,0 +1,277 @@
1
+ import { config } from "dotenv";
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import {
5
+ Tool,
6
+ TextContent,
7
+ ImageContent,
8
+ EmbeddedResource,
9
+ ListToolsRequestSchema,
10
+ CallToolRequestSchema
11
+ } from "@modelcontextprotocol/sdk/types.js";
12
+ import { ObsidianClient } from "./obsidian.js";
13
+ import { ObsidianError, DEFAULT_RATE_LIMIT_CONFIG, RateLimitConfig } from "./types.js";
14
+ import type { ToolHandler } from "./types.js";
15
+ import {
16
+ ListFilesInVaultToolHandler,
17
+ ListFilesInDirToolHandler,
18
+ GetFileContentsToolHandler,
19
+ FindInFileToolHandler,
20
+ AppendContentToolHandler,
21
+ PatchContentToolHandler,
22
+ ComplexSearchToolHandler
23
+ } from "./tools.js";
24
+
25
+ // Load environment variables
26
+ config();
27
+
28
+ const API_KEY = process.env.OBSIDIAN_API_KEY;
29
+ if (!API_KEY) {
30
+ throw new Error("OBSIDIAN_API_KEY environment variable is required");
31
+ }
32
+
33
+ // Get rate limit config from environment or use defaults
34
+ const rateLimitConfig: RateLimitConfig = {
35
+ windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? String(DEFAULT_RATE_LIMIT_CONFIG.windowMs)),
36
+ maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS ?? String(DEFAULT_RATE_LIMIT_CONFIG.maxRequests))
37
+ };
38
+
39
+ // Request tracking for rate limiting
40
+ const requestCounts = new Map<string, { count: number; resetTime: number }>();
41
+
42
+ function checkRateLimit(toolName: string): boolean {
43
+ const now = Date.now();
44
+ const requestInfo = requestCounts.get(toolName);
45
+
46
+ if (!requestInfo || now > requestInfo.resetTime) {
47
+ // Reset counter for new window
48
+ requestCounts.set(toolName, {
49
+ count: 1,
50
+ resetTime: now + rateLimitConfig.windowMs
51
+ });
52
+ return true;
53
+ }
54
+
55
+ if (requestInfo.count >= rateLimitConfig.maxRequests) {
56
+ return false;
57
+ }
58
+
59
+ requestInfo.count++;
60
+ return true;
61
+ }
62
+
63
+ // Clean up expired rate limit entries periodically
64
+ const cleanupInterval = setInterval(() => {
65
+ const now = Date.now();
66
+ for (const [tool, info] of requestCounts.entries()) {
67
+ if (now > info.resetTime) {
68
+ requestCounts.delete(tool);
69
+ }
70
+ }
71
+ }, 60000); // Clean up every minute
72
+
73
+ // Initialize Obsidian client
74
+ const client = new ObsidianClient({
75
+ apiKey: API_KEY,
76
+ verifySSL: process.env.NODE_ENV === 'production' // Enable SSL verification in production
77
+ });
78
+
79
+ // Initialize tool handlers
80
+ type AnyToolHandler = ToolHandler<any>;
81
+ const toolHandlers = new Map<string, AnyToolHandler>();
82
+
83
+ const handlers: AnyToolHandler[] = [
84
+ new ListFilesInVaultToolHandler(client),
85
+ new ListFilesInDirToolHandler(client),
86
+ new GetFileContentsToolHandler(client),
87
+ new FindInFileToolHandler(client),
88
+ new AppendContentToolHandler(client),
89
+ new PatchContentToolHandler(client),
90
+ new ComplexSearchToolHandler(client)
91
+ ];
92
+
93
+ handlers.forEach(handler => toolHandlers.set(handler.name, handler));
94
+
95
+ // Create MCP server
96
+ const server = new Server(
97
+ {
98
+ name: "obsidian-mcp-server",
99
+ version: process.env.npm_package_version ?? "1.1.0" // Use version from package.json
100
+ },
101
+ {
102
+ capabilities: {
103
+ tools: {},
104
+ resources: {}
105
+ }
106
+ }
107
+ );
108
+
109
+ // Set up request handlers
110
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
111
+ const tools: Tool[] = [];
112
+ for (const handler of toolHandlers.values()) {
113
+ tools.push(handler.getToolDescription());
114
+ }
115
+ return { tools };
116
+ });
117
+
118
+ // Add validation helper
119
+ function validateToolArguments(args: unknown, schema: any): { valid: boolean; errors: string[] } {
120
+ if (typeof args !== 'object' || args === null) {
121
+ return { valid: false, errors: ['Arguments must be an object'] };
122
+ }
123
+
124
+ const errors: string[] = [];
125
+ const required = schema.required || [];
126
+
127
+ // Check required fields
128
+ for (const field of required) {
129
+ if (!(field in args)) {
130
+ errors.push(`Missing required field: ${field}`);
131
+ }
132
+ }
133
+
134
+ // Check field types
135
+ const properties = schema.properties || {};
136
+ for (const [key, value] of Object.entries(args)) {
137
+ const propSchema = properties[key];
138
+ if (!propSchema) {
139
+ errors.push(`Unknown field: ${key}`);
140
+ continue;
141
+ }
142
+
143
+ // Skip validation for undefined optional fields
144
+ if (value === undefined && !required.includes(key)) {
145
+ continue;
146
+ }
147
+
148
+ // Type validation
149
+ if (propSchema.type === 'string' && typeof value !== 'string') {
150
+ errors.push(`Field ${key} must be a string`);
151
+ } else if (propSchema.type === 'number' && typeof value !== 'number') {
152
+ errors.push(`Field ${key} must be a number`);
153
+ } else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
154
+ errors.push(`Field ${key} must be a boolean`);
155
+ } else if (propSchema.type === 'array' && !Array.isArray(value)) {
156
+ errors.push(`Field ${key} must be an array`);
157
+ }
158
+
159
+ // Enum validation
160
+ if (propSchema.enum && value !== undefined && !propSchema.enum.includes(value)) {
161
+ errors.push(`Field ${key} must be one of: ${propSchema.enum.join(', ')}`);
162
+ }
163
+
164
+ // Format validation for paths
165
+ if (propSchema.format === 'path' && typeof value === 'string') {
166
+ // Prevent path traversal
167
+ if (value.includes('../') || value.includes('..\\')) {
168
+ errors.push(`Field ${key} contains invalid path traversal`);
169
+ }
170
+ // Prevent absolute paths
171
+ if (value.startsWith('/') || /^[a-zA-Z]:/.test(value)) {
172
+ errors.push(`Field ${key} must be a relative path`);
173
+ }
174
+ }
175
+ }
176
+
177
+ return { valid: errors.length === 0, errors };
178
+ }
179
+
180
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
181
+ const { name, arguments: args } = request.params;
182
+
183
+ const handler = toolHandlers.get(name);
184
+ if (!handler) {
185
+ throw new ObsidianError(`Unknown tool: ${name}`, 404);
186
+ }
187
+
188
+ // Check rate limit
189
+ if (!checkRateLimit(name)) {
190
+ throw new ObsidianError(
191
+ `Rate limit exceeded for tool: ${name}. Please try again later.`,
192
+ 429
193
+ );
194
+ }
195
+
196
+ // Add timeout handling
197
+ const timeoutMs = parseInt(process.env.TOOL_TIMEOUT_MS ?? '60000'); // 60 second default timeout
198
+ const timeoutPromise = new Promise((_, reject) => {
199
+ setTimeout(() => {
200
+ reject(new ObsidianError(`Tool execution timed out after ${timeoutMs}ms`, 408));
201
+ }, timeoutMs);
202
+ });
203
+
204
+ try {
205
+ // Validate arguments against tool's schema
206
+ const toolDescription = handler.getToolDescription();
207
+ const validationResult = validateToolArguments(args, toolDescription.inputSchema);
208
+ if (!validationResult.valid) {
209
+ throw new ObsidianError(
210
+ `Invalid tool arguments: ${validationResult.errors.join(', ')}`,
211
+ 400
212
+ );
213
+ }
214
+
215
+ // Race between tool execution and timeout
216
+ const content = await Promise.race([
217
+ handler.runTool(args),
218
+ timeoutPromise
219
+ ]);
220
+ return { content };
221
+ } catch (error) {
222
+ if (error instanceof ObsidianError) {
223
+ // Check if the operation actually succeeded despite the error
224
+ if (error.code === 204) {
225
+ return {
226
+ content: [{
227
+ type: "text",
228
+ text: "Operation completed successfully"
229
+ }]
230
+ };
231
+ }
232
+ throw error;
233
+ }
234
+
235
+ // Enhanced error logging
236
+ console.error("Tool execution error:", {
237
+ name: error instanceof Error ? error.name : 'Unknown',
238
+ message: error instanceof Error ? error.message : String(error),
239
+ stack: error instanceof Error ? error.stack : undefined,
240
+ toolName: name,
241
+ args
242
+ });
243
+
244
+ if (error instanceof Error) {
245
+ throw new ObsidianError(
246
+ `Tool '${name}' execution failed: ${error.message}`,
247
+ 500,
248
+ { originalError: error.stack }
249
+ );
250
+ }
251
+
252
+ throw new ObsidianError(
253
+ "Tool execution failed with unknown error",
254
+ 500,
255
+ { error }
256
+ );
257
+ }
258
+ });
259
+
260
+ // Error handler
261
+ server.onerror = (error) => {
262
+ console.error("[MCP Error]", error);
263
+ };
264
+
265
+ // Handle shutdown
266
+ process.on("SIGINT", async () => {
267
+ clearInterval(cleanupInterval); // Clean up rate limit interval
268
+ await server.close();
269
+ process.exit(0);
270
+ });
271
+
272
+ // Export the run function
273
+ export async function run(): Promise<void> {
274
+ const transport = new StdioServerTransport();
275
+ await server.connect(transport);
276
+ console.error("Obsidian MCP server running on stdio");
277
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,429 @@
1
+ import { Tool, TextContent } from "@modelcontextprotocol/sdk/types.js";
2
+ import { ObsidianClient } from "./obsidian.js";
3
+ import { encoding_for_model } from "tiktoken";
4
+ import {
5
+ ToolHandler,
6
+ PatchContentArgs,
7
+ AppendContentArgs,
8
+ SearchArgs,
9
+ ComplexSearchArgs,
10
+ FileContentsArgs,
11
+ ListFilesArgs,
12
+ ObsidianError
13
+ } from "./types.js";
14
+
15
+ const TOOL_NAMES = {
16
+ LIST_FILES_IN_VAULT: "obsidian_list_files_in_vault",
17
+ LIST_FILES_IN_DIR: "obsidian_list_files_in_dir",
18
+ GET_FILE_CONTENTS: "obsidian_get_file_contents",
19
+ FIND_IN_FILE: "obsidian_find_in_file",
20
+ APPEND_CONTENT: "obsidian_append_content",
21
+ PATCH_CONTENT: "obsidian_patch_content",
22
+ COMPLEX_SEARCH: "obsidian_complex_search"
23
+ } as const;
24
+
25
+ // Load token limits from environment or use defaults
26
+ const MAX_TOKENS = parseInt(process.env.MAX_TOKENS ?? '20000');
27
+ const TRUNCATION_MESSAGE = "\n\n[Response truncated due to length]";
28
+
29
+ export abstract class BaseToolHandler<T = Record<string, unknown>> implements ToolHandler<T> {
30
+ private tokenizer = encoding_for_model("gpt-4"); // This is strictly for token counting, not for LLM inference
31
+ private isShuttingDown = false;
32
+
33
+ constructor(
34
+ public readonly name: string,
35
+ protected client: ObsidianClient
36
+ ) {
37
+ // Clean up tokenizer when process exits
38
+ const cleanup = () => {
39
+ if (!this.isShuttingDown) {
40
+ this.isShuttingDown = true;
41
+ if (this.tokenizer) {
42
+ this.tokenizer.free();
43
+ }
44
+ }
45
+ };
46
+
47
+ process.on('exit', cleanup);
48
+ process.on('SIGINT', cleanup);
49
+ process.on('SIGTERM', cleanup);
50
+ process.on('uncaughtException', cleanup);
51
+ }
52
+
53
+ protected countTokens(text: string): number {
54
+ return this.tokenizer.encode(text).length;
55
+ }
56
+
57
+ protected truncateToTokenLimit(text: string): string {
58
+ const tokens = this.tokenizer.encode(text);
59
+ if (tokens.length <= MAX_TOKENS) {
60
+ return text;
61
+ }
62
+
63
+ // Reserve tokens for truncation message
64
+ const messageTokens = this.tokenizer.encode(TRUNCATION_MESSAGE);
65
+ const availableTokens = MAX_TOKENS - messageTokens.length;
66
+
67
+ // Decode truncated tokens back to text
68
+ const truncatedText = this.tokenizer.decode(tokens.slice(0, availableTokens));
69
+ return truncatedText + TRUNCATION_MESSAGE;
70
+ }
71
+
72
+ abstract getToolDescription(): Tool;
73
+ abstract runTool(args: T): Promise<Array<TextContent>>;
74
+
75
+ protected createResponse(content: unknown): TextContent[] {
76
+ let text: string;
77
+
78
+ // Handle different content types
79
+ if (typeof content === 'string') {
80
+ text = content;
81
+ } else if (content instanceof Buffer) {
82
+ text = content.toString('utf-8');
83
+ } else if (Array.isArray(content) && content.every(item => typeof item === 'string')) {
84
+ text = content.join('\n');
85
+ } else if (content instanceof Error) {
86
+ text = `Error: ${content.message}\n${content.stack || ''}`;
87
+ } else {
88
+ try {
89
+ text = JSON.stringify(content, null, 2);
90
+ } catch (error) {
91
+ text = String(content);
92
+ }
93
+ }
94
+
95
+ // Count tokens and truncate if necessary
96
+ const originalTokenCount = this.countTokens(text);
97
+ const truncatedText = this.truncateToTokenLimit(text);
98
+ const finalTokenCount = this.countTokens(truncatedText);
99
+
100
+ if (originalTokenCount > MAX_TOKENS) {
101
+ console.debug(
102
+ `[${this.name}] Response truncated:`,
103
+ `original tokens=${originalTokenCount}`,
104
+ `truncated tokens=${finalTokenCount}`
105
+ );
106
+ }
107
+
108
+ return [{
109
+ type: "text",
110
+ text: truncatedText
111
+ }];
112
+ }
113
+
114
+ protected handleError(error: unknown): never {
115
+ if (error instanceof ObsidianError) {
116
+ throw error;
117
+ }
118
+ if (error instanceof Error) {
119
+ throw new ObsidianError(
120
+ `Tool '${this.name}' execution failed: ${error.message}`,
121
+ 500,
122
+ { originalError: error.stack }
123
+ );
124
+ }
125
+ throw new ObsidianError(
126
+ `Tool '${this.name}' execution failed with unknown error`,
127
+ 500,
128
+ { error }
129
+ );
130
+ }
131
+ }
132
+
133
+ export class ListFilesInVaultToolHandler extends BaseToolHandler<Record<string, never>> {
134
+ constructor(client: ObsidianClient) {
135
+ super(TOOL_NAMES.LIST_FILES_IN_VAULT, client);
136
+ }
137
+
138
+ getToolDescription(): Tool {
139
+ return {
140
+ name: this.name,
141
+ description: "Lists all files and directories in the root directory of your Obsidian vault.",
142
+ examples: [
143
+ {
144
+ description: "List all files in vault",
145
+ args: {}
146
+ }
147
+ ],
148
+ inputSchema: {
149
+ type: "object",
150
+ properties: {},
151
+ required: []
152
+ }
153
+ };
154
+ }
155
+
156
+ async runTool(): Promise<Array<TextContent>> {
157
+ try {
158
+ const files = await this.client.listFilesInVault();
159
+ return this.createResponse(files);
160
+ } catch (error) {
161
+ return this.handleError(error);
162
+ }
163
+ }
164
+ }
165
+
166
+ export class ListFilesInDirToolHandler extends BaseToolHandler<ListFilesArgs> {
167
+ constructor(client: ObsidianClient) {
168
+ super(TOOL_NAMES.LIST_FILES_IN_DIR, client);
169
+ }
170
+
171
+ getToolDescription(): Tool {
172
+ return {
173
+ name: this.name,
174
+ description: "Lists all files and directories that exist in a specific Obsidian directory.",
175
+ examples: [
176
+ {
177
+ description: "List files in Documents folder",
178
+ args: {
179
+ dirpath: "Documents"
180
+ }
181
+ }
182
+ ],
183
+ inputSchema: {
184
+ type: "object",
185
+ properties: {
186
+ dirpath: {
187
+ type: "string",
188
+ description: "Path to list files from (relative to your vault root). Note that empty directories will not be returned.",
189
+ format: "path"
190
+ }
191
+ },
192
+ required: ["dirpath"]
193
+ }
194
+ };
195
+ }
196
+
197
+ async runTool(args: ListFilesArgs): Promise<Array<TextContent>> {
198
+ try {
199
+ const files = await this.client.listFilesInDir(args.dirpath);
200
+ return this.createResponse(files);
201
+ } catch (error) {
202
+ return this.handleError(error);
203
+ }
204
+ }
205
+ }
206
+
207
+ export class GetFileContentsToolHandler extends BaseToolHandler<FileContentsArgs> {
208
+ constructor(client: ObsidianClient) {
209
+ super(TOOL_NAMES.GET_FILE_CONTENTS, client);
210
+ }
211
+
212
+ getToolDescription(): Tool {
213
+ return {
214
+ name: this.name,
215
+ description: "Return the content of a single file in your vault.",
216
+ inputSchema: {
217
+ type: "object",
218
+ properties: {
219
+ filepath: {
220
+ type: "string",
221
+ description: "Path to the relevant file (relative to your vault root).",
222
+ format: "path"
223
+ }
224
+ },
225
+ required: ["filepath"]
226
+ }
227
+ };
228
+ }
229
+
230
+ async runTool(args: FileContentsArgs): Promise<Array<TextContent>> {
231
+ try {
232
+ const content = await this.client.getFileContents(args.filepath);
233
+ return this.createResponse(content);
234
+ } catch (error) {
235
+ return this.handleError(error);
236
+ }
237
+ }
238
+ }
239
+
240
+ export class FindInFileToolHandler extends BaseToolHandler<SearchArgs> {
241
+ constructor(client: ObsidianClient) {
242
+ super(TOOL_NAMES.FIND_IN_FILE, client);
243
+ }
244
+
245
+ getToolDescription(): Tool {
246
+ return {
247
+ name: this.name,
248
+ description: "Simple search that returns filenames of documents matching a specified text query across all files in the vault.",
249
+ inputSchema: {
250
+ type: "object",
251
+ properties: {
252
+ query: {
253
+ type: "string",
254
+ description: "Text to search for in the vault."
255
+ },
256
+ contextLength: {
257
+ type: "integer",
258
+ description: "How much context to use for matching (default: 10)",
259
+ default: 10
260
+ }
261
+ },
262
+ required: ["query"]
263
+ }
264
+ };
265
+ }
266
+
267
+ async runTool(args: SearchArgs): Promise<Array<TextContent>> {
268
+ try {
269
+ const results = await this.client.search(args.query, args.contextLength);
270
+ // Extract only unique filenames from search results
271
+ const filenames = [...new Set(results.map(result => result.filename))].sort();
272
+ return this.createResponse(filenames);
273
+ } catch (error) {
274
+ return this.handleError(error);
275
+ }
276
+ }
277
+ }
278
+
279
+ export class AppendContentToolHandler extends BaseToolHandler<AppendContentArgs> {
280
+ constructor(client: ObsidianClient) {
281
+ super(TOOL_NAMES.APPEND_CONTENT, client);
282
+ }
283
+
284
+ getToolDescription(): Tool {
285
+ return {
286
+ name: this.name,
287
+ description: "Append content to a new or existing file in the vault.",
288
+ examples: [
289
+ {
290
+ description: "Append a new task",
291
+ args: {
292
+ filepath: "tasks.md",
293
+ content: "- [ ] New task to complete"
294
+ }
295
+ },
296
+ {
297
+ description: "Append meeting notes",
298
+ args: {
299
+ filepath: "meetings/2025-01-23.md",
300
+ content: "## Meeting Notes\n\n- Discussed project timeline\n- Assigned tasks"
301
+ }
302
+ }
303
+ ],
304
+ inputSchema: {
305
+ type: "object",
306
+ properties: {
307
+ filepath: {
308
+ type: "string",
309
+ description: "Path to the file (relative to vault root)",
310
+ format: "path"
311
+ },
312
+ content: {
313
+ type: "string",
314
+ description: "Content to append to the file"
315
+ }
316
+ },
317
+ required: ["filepath", "content"]
318
+ }
319
+ };
320
+ }
321
+
322
+ async runTool(args: AppendContentArgs): Promise<Array<TextContent>> {
323
+ try {
324
+ await this.client.appendContent(args.filepath, args.content);
325
+ return this.createResponse({ message: `Successfully appended content to ${args.filepath}` });
326
+ } catch (error) {
327
+ return this.handleError(error);
328
+ }
329
+ }
330
+ }
331
+
332
+ export class PatchContentToolHandler extends BaseToolHandler<PatchContentArgs> {
333
+ constructor(client: ObsidianClient) {
334
+ super(TOOL_NAMES.PATCH_CONTENT, client);
335
+ }
336
+
337
+ getToolDescription(): Tool {
338
+ return {
339
+ name: this.name,
340
+ description: "Update the entire content of an existing note or create a new one.",
341
+ examples: [
342
+ {
343
+ description: "Update a note's content",
344
+ args: {
345
+ filepath: "project.md",
346
+ content: "# Project Notes\n\nThis will replace the entire content of the note."
347
+ }
348
+ }
349
+ ],
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ filepath: {
354
+ type: "string",
355
+ description: "Path to the file (relative to vault root)",
356
+ format: "path"
357
+ },
358
+ content: {
359
+ type: "string",
360
+ description: "New content for the note (replaces existing content)"
361
+ }
362
+ },
363
+ required: ["filepath", "content"]
364
+ }
365
+ };
366
+ }
367
+
368
+ async runTool(args: PatchContentArgs): Promise<Array<TextContent>> {
369
+ try {
370
+ await this.client.updateContent(args.filepath, args.content);
371
+ return this.createResponse({ message: `Successfully updated content in ${args.filepath}` });
372
+ } catch (error) {
373
+ return this.handleError(error);
374
+ }
375
+ }
376
+ }
377
+
378
+ export class ComplexSearchToolHandler extends BaseToolHandler<ComplexSearchArgs> {
379
+ constructor(client: ObsidianClient) {
380
+ super(TOOL_NAMES.COMPLEX_SEARCH, client);
381
+ }
382
+
383
+ getToolDescription(): Tool {
384
+ return {
385
+ name: this.name,
386
+ description: "Complex search for documents using a JsonLogic query.",
387
+ examples: [
388
+ {
389
+ description: "Find all markdown files",
390
+ args: {
391
+ query: {
392
+ "glob": ["*.md", {"var": "path"}]
393
+ }
394
+ }
395
+ },
396
+ {
397
+ description: "Find files modified in last week",
398
+ args: {
399
+ query: {
400
+ ">=": [
401
+ {"var": "mtime"},
402
+ {"date": "-7 days"}
403
+ ]
404
+ }
405
+ }
406
+ }
407
+ ],
408
+ inputSchema: {
409
+ type: "object",
410
+ properties: {
411
+ query: {
412
+ type: "object",
413
+ description: "JsonLogic query object. Example: {\"glob\": [\"*.md\", {\"var\": \"path\"}]} matches all markdown files"
414
+ }
415
+ },
416
+ required: ["query"]
417
+ }
418
+ };
419
+ }
420
+
421
+ async runTool(args: ComplexSearchArgs): Promise<Array<TextContent>> {
422
+ try {
423
+ const results = await this.client.searchJson(args.query);
424
+ return this.createResponse(results);
425
+ } catch (error) {
426
+ return this.handleError(error);
427
+ }
428
+ }
429
+ }