@probelabs/probe 0.6.0-rc56

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.
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import {
5
+ CallToolRequestSchema,
6
+ ErrorCode,
7
+ ListToolsRequestSchema,
8
+ McpError,
9
+ } from '@modelcontextprotocol/sdk/types.js';
10
+ import { exec } from 'child_process';
11
+ import { promisify } from 'util';
12
+ import path from 'path';
13
+ import fs from 'fs-extra';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ // Import from parent package
17
+ import { search, query, extract, getBinaryPath, setBinaryPath } from '../index.js';
18
+
19
+ // Parse command-line arguments
20
+ function parseArgs(): { timeout?: number } {
21
+ const args = process.argv.slice(2);
22
+ const config: { timeout?: number } = {};
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ if ((args[i] === '--timeout' || args[i] === '-t') && i + 1 < args.length) {
26
+ const timeout = parseInt(args[i + 1], 10);
27
+ if (!isNaN(timeout) && timeout > 0) {
28
+ config.timeout = timeout;
29
+ console.error(`Timeout set to ${timeout} seconds`);
30
+ } else {
31
+ console.error(`Invalid timeout value: ${args[i + 1]}. Using default.`);
32
+ }
33
+ i++; // Skip the next argument
34
+ } else if (args[i] === '--help' || args[i] === '-h') {
35
+ console.log(`
36
+ Probe MCP Server
37
+
38
+ Usage:
39
+ probe mcp [options]
40
+
41
+ Options:
42
+ --timeout, -t <seconds> Set timeout for search operations (default: 30)
43
+ --help, -h Show this help message
44
+ `);
45
+ process.exit(0);
46
+ }
47
+ }
48
+
49
+ return config;
50
+ }
51
+
52
+ const cliConfig = parseArgs();
53
+
54
+ const execAsync = promisify(exec);
55
+
56
+ // Get the package.json to determine the version
57
+ const __filename = fileURLToPath(import.meta.url);
58
+ const __dirname = path.dirname(__filename);
59
+
60
+ // Try multiple possible locations for package.json
61
+ let packageVersion = '0.0.0';
62
+ const possiblePaths = [
63
+ path.resolve(__dirname, '..', 'package.json'), // When installed from npm: build/../package.json
64
+ path.resolve(__dirname, '..', '..', 'package.json') // In development: src/../package.json
65
+ ];
66
+
67
+ for (const packageJsonPath of possiblePaths) {
68
+ try {
69
+ if (fs.existsSync(packageJsonPath)) {
70
+ console.log(`Found package.json at: ${packageJsonPath}`);
71
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
72
+ if (packageJson.version) {
73
+ packageVersion = packageJson.version;
74
+ console.log(`Using version from package.json: ${packageVersion}`);
75
+ break;
76
+ }
77
+ }
78
+ } catch (error) {
79
+ console.error(`Error reading package.json at ${packageJsonPath}:`, error);
80
+ }
81
+ }
82
+
83
+ // If we still have 0.0.0, try to get version from npm package
84
+ if (packageVersion === '0.0.0') {
85
+ try {
86
+ // Try to get version from the package name itself
87
+ const result = await execAsync('npm list -g @probelabs/probe --json');
88
+ const npmList = JSON.parse(result.stdout);
89
+ if (npmList.dependencies && npmList.dependencies['@probelabs/probe']) {
90
+ packageVersion = npmList.dependencies['@probelabs/probe'].version;
91
+ console.log(`Using version from npm list: ${packageVersion}`);
92
+ }
93
+ } catch (error) {
94
+ console.error('Error getting version from npm:', error);
95
+ }
96
+ }
97
+
98
+ import { existsSync } from 'fs';
99
+
100
+ // Get the path to the bin directory
101
+ const binDir = path.resolve(__dirname, '..', 'bin');
102
+ console.log(`Bin directory: ${binDir}`);
103
+
104
+ // The @probelabs/probe package now handles binary path management internally
105
+ // We don't need to manage the binary path in the MCP server anymore
106
+
107
+ interface SearchCodeArgs {
108
+ path: string;
109
+ query: string | string[];
110
+ filesOnly?: boolean;
111
+ ignore?: string[];
112
+ excludeFilenames?: boolean;
113
+ exact?: boolean;
114
+ maxResults?: number;
115
+ maxTokens?: number;
116
+ allowTests?: boolean;
117
+ session?: string;
118
+ timeout?: number;
119
+ noGitignore?: boolean;
120
+ }
121
+
122
+ interface QueryCodeArgs {
123
+ path: string;
124
+ pattern: string;
125
+ language?: string;
126
+ ignore?: string[];
127
+ allowTests?: boolean;
128
+ maxResults?: number;
129
+ format?: 'markdown' | 'plain' | 'json' | 'color';
130
+ timeout?: number;
131
+ noGitignore?: boolean;
132
+ }
133
+
134
+ interface ExtractCodeArgs {
135
+ path: string;
136
+ files: string[];
137
+ allowTests?: boolean;
138
+ contextLines?: number;
139
+ format?: 'markdown' | 'plain' | 'json';
140
+ timeout?: number;
141
+ noGitignore?: boolean;
142
+ }
143
+
144
+ class ProbeServer {
145
+ private server: Server;
146
+ private defaultTimeout: number;
147
+
148
+ constructor(timeout: number = 30) {
149
+ this.defaultTimeout = timeout;
150
+ this.server = new Server(
151
+ {
152
+ name: '@probelabs/probe',
153
+ version: packageVersion,
154
+ },
155
+ {
156
+ capabilities: {
157
+ tools: {},
158
+ },
159
+ }
160
+ );
161
+
162
+ this.setupToolHandlers();
163
+
164
+ // Error handling
165
+ this.server.onerror = (error) => console.error('[MCP Error]', error);
166
+ process.on('SIGINT', async () => {
167
+ await this.server.close();
168
+ process.exit(0);
169
+ });
170
+ }
171
+
172
+ private setupToolHandlers() {
173
+ // Use the tool descriptions defined at the top of the file
174
+
175
+ this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
176
+ tools: [
177
+ {
178
+ name: 'search_code',
179
+ description: "Search code in the repository using ElasticSearch. Use this tool first for any code-related questions.",
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ path: {
184
+ type: 'string',
185
+ description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
186
+ },
187
+ query: {
188
+ type: 'string',
189
+ description: 'Elastic search query. Supports logical operators (AND, OR, NOT), and grouping with parentheses. Examples: "config", "(term1 OR term2) AND term3". Use quotes for exact matches, like function or type names.',
190
+ },
191
+ filesOnly: {
192
+ type: 'boolean',
193
+ description: 'Skip AST parsing and just output unique files',
194
+ },
195
+ ignore: {
196
+ type: 'array',
197
+ items: { type: 'string' },
198
+ description: 'Custom patterns to ignore (in addition to .gitignore and common patterns)'
199
+ },
200
+ excludeFilenames: {
201
+ type: 'boolean',
202
+ description: 'Exclude filenames from being used for matching'
203
+ },
204
+ exact: {
205
+ type: 'boolean',
206
+ description: 'Perform exact search without tokenization (case-insensitive)'
207
+ },
208
+ allowTests: {
209
+ type: 'boolean',
210
+ description: 'Allow test files and test code blocks in results (disabled by default)'
211
+ },
212
+ session: {
213
+ type: 'string',
214
+ description: 'Session identifier for caching. Set to "new" if unknown, or want to reset cache. Re-use session ID returned from previous searches',
215
+ default: "new",
216
+ },
217
+ timeout: {
218
+ type: 'number',
219
+ description: 'Timeout for the search operation in seconds (default: 30)',
220
+ },
221
+ noGitignore: {
222
+ type: 'boolean',
223
+ description: 'Skip .gitignore files (will use PROBE_NO_GITIGNORE environment variable if not set)',
224
+ }
225
+ },
226
+ required: ['path', 'query']
227
+ },
228
+ },
229
+ {
230
+ name: 'query_code',
231
+ description: "Search code using ast-grep structural pattern matching. Use this tool to find specific code structures like functions, classes, or methods.",
232
+ inputSchema: {
233
+ type: 'object',
234
+ properties: {
235
+ path: {
236
+ type: 'string',
237
+ description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
238
+ },
239
+ pattern: {
240
+ type: 'string',
241
+ description: 'The ast-grep pattern to search for. Examples: "fn $NAME($$$PARAMS) $$$BODY" for Rust functions, "def $NAME($$$PARAMS): $$$BODY" for Python functions.',
242
+ },
243
+ language: {
244
+ type: 'string',
245
+ description: 'The programming language to search in. If not specified, the tool will try to infer the language from file extensions. Supported languages: rust, javascript, typescript, python, go, c, cpp, java, ruby, php, swift, csharp.',
246
+ },
247
+ ignore: {
248
+ type: 'array',
249
+ items: { type: 'string' },
250
+ description: 'Custom patterns to ignore (in addition to common patterns)',
251
+ },
252
+ maxResults: {
253
+ type: 'number',
254
+ description: 'Maximum number of results to return'
255
+ },
256
+ format: {
257
+ type: 'string',
258
+ enum: ['markdown', 'plain', 'json', 'color'],
259
+ description: 'Output format for the query results'
260
+ },
261
+ timeout: {
262
+ type: 'number',
263
+ description: 'Timeout for the query operation in seconds (default: 30)',
264
+ },
265
+ noGitignore: {
266
+ type: 'boolean',
267
+ description: 'Skip .gitignore files (will use PROBE_NO_GITIGNORE environment variable if not set)',
268
+ }
269
+ },
270
+ required: ['path', 'pattern']
271
+ },
272
+ },
273
+ {
274
+ name: 'extract_code',
275
+ description: "Extract code blocks from files based on line number, or symbol name. Fetch full file when line number is not provided.",
276
+ inputSchema: {
277
+ type: 'object',
278
+ properties: {
279
+ path: {
280
+ type: 'string',
281
+ description: 'Absolute path to the directory to search in (e.g., "/Users/username/projects/myproject").',
282
+ },
283
+ files: {
284
+ type: 'array',
285
+ items: { type: 'string' },
286
+ description: 'Files and lines or sybmbols to extract from: /path/to/file.rs:10, /path/to/file.rs#func_name Path should be absolute.',
287
+ },
288
+ allowTests: {
289
+ type: 'boolean',
290
+ description: 'Allow test files and test code blocks in results (disabled by default)',
291
+ },
292
+ contextLines: {
293
+ type: 'number',
294
+ description: 'Number of context lines to include before and after the extracted block when AST parsing fails to find a suitable node',
295
+ default: 0
296
+ },
297
+ format: {
298
+ type: 'string',
299
+ enum: ['markdown', 'plain', 'json'],
300
+ description: 'Output format for the extracted code',
301
+ default: 'markdown'
302
+ },
303
+ timeout: {
304
+ type: 'number',
305
+ description: 'Timeout for the extract operation in seconds (default: 30)',
306
+ },
307
+ noGitignore: {
308
+ type: 'boolean',
309
+ description: 'Skip .gitignore files (will use PROBE_NO_GITIGNORE environment variable if not set)',
310
+ }
311
+ },
312
+ required: ['path', 'files'],
313
+ },
314
+ },
315
+ ],
316
+ }));
317
+
318
+ this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
319
+ if (request.params.name !== 'search_code' && request.params.name !== 'query_code' && request.params.name !== 'extract_code' &&
320
+ request.params.name !== 'probe' && request.params.name !== 'query' && request.params.name !== 'extract') {
321
+ throw new McpError(
322
+ ErrorCode.MethodNotFound,
323
+ `Unknown tool: ${request.params.name}`
324
+ );
325
+ }
326
+
327
+ try {
328
+ let result: string;
329
+
330
+ // Log the incoming request for debugging
331
+ console.error(`Received request for tool: ${request.params.name}`);
332
+ console.error(`Request arguments: ${JSON.stringify(request.params.arguments)}`);
333
+
334
+ // Handle both new tool names and legacy tool names
335
+ if (request.params.name === 'search_code' || request.params.name === 'probe') {
336
+ // Ensure arguments is an object
337
+ if (!request.params.arguments || typeof request.params.arguments !== 'object') {
338
+ throw new Error("Arguments must be an object");
339
+ }
340
+
341
+ const args = request.params.arguments as unknown as SearchCodeArgs;
342
+
343
+ // Validate required fields
344
+ if (!args.path) {
345
+ throw new Error("Path is required in arguments");
346
+ }
347
+ if (!args.query) {
348
+ throw new Error("Query is required in arguments");
349
+ }
350
+
351
+ result = await this.executeCodeSearch(args);
352
+ } else if (request.params.name === 'query_code' || request.params.name === 'query') {
353
+ const args = request.params.arguments as unknown as QueryCodeArgs;
354
+ result = await this.executeCodeQuery(args);
355
+ } else { // extract_code or extract
356
+ const args = request.params.arguments as unknown as ExtractCodeArgs;
357
+ result = await this.executeCodeExtract(args);
358
+ }
359
+
360
+ return {
361
+ content: [
362
+ {
363
+ type: 'text',
364
+ text: result,
365
+ },
366
+ ],
367
+ };
368
+ } catch (error) {
369
+ console.error(`Error executing ${request.params.name}:`, error);
370
+ return {
371
+ content: [
372
+ {
373
+ type: 'text',
374
+ text: `Error executing ${request.params.name}: ${error instanceof Error ? error.message : String(error)}`,
375
+ },
376
+ ],
377
+ isError: true,
378
+ };
379
+ }
380
+ });
381
+ }
382
+
383
+ private async executeCodeSearch(args: SearchCodeArgs): Promise<string> {
384
+ try {
385
+ // Ensure path is included in the options and is a non-empty string
386
+ if (!args.path || typeof args.path !== 'string' || args.path.trim() === '') {
387
+ throw new Error("Path is required and must be a non-empty string");
388
+ }
389
+
390
+ // Ensure query is included in the options
391
+ if (!args.query) {
392
+ throw new Error("Query is required");
393
+ }
394
+
395
+ // Log the arguments we received for debugging
396
+ console.error(`Received search arguments: path=${args.path}, query=${JSON.stringify(args.query)}`);
397
+
398
+ // Create a clean options object with only the essential properties first
399
+ const options: any = {
400
+ path: args.path.trim(), // Ensure path is trimmed
401
+ query: args.query
402
+ };
403
+
404
+ // Add optional parameters only if they exist
405
+ if (args.filesOnly !== undefined) options.filesOnly = args.filesOnly;
406
+ if (args.ignore !== undefined) options.ignore = args.ignore;
407
+ if (args.excludeFilenames !== undefined) options.excludeFilenames = args.excludeFilenames;
408
+ if (args.exact !== undefined) options.exact = args.exact;
409
+ if (args.maxResults !== undefined) options.maxResults = args.maxResults;
410
+ if (args.maxTokens !== undefined) options.maxTokens = args.maxTokens;
411
+ if (args.allowTests !== undefined) options.allowTests = args.allowTests;
412
+ // Use noGitignore from args, or fall back to PROBE_NO_GITIGNORE environment variable
413
+ if (args.noGitignore !== undefined) {
414
+ options.noGitignore = args.noGitignore;
415
+ } else if (process.env.PROBE_NO_GITIGNORE) {
416
+ options.noGitignore = process.env.PROBE_NO_GITIGNORE === 'true';
417
+ }
418
+ if (args.session !== undefined && args.session.trim() !== '') {
419
+ options.session = args.session;
420
+ } else {
421
+ options.session = "new";
422
+ }
423
+ // Use timeout from args, or fall back to instance default
424
+ if (args.timeout !== undefined) {
425
+ options.timeout = args.timeout;
426
+ } else if (this.defaultTimeout !== undefined) {
427
+ options.timeout = this.defaultTimeout;
428
+ }
429
+
430
+ console.error("Executing search with options:", JSON.stringify(options, null, 2));
431
+
432
+ // Double-check that path is still in the options object
433
+ if (!options.path) {
434
+ console.error("Path is missing from options object after construction");
435
+ throw new Error("Path is missing from options object");
436
+ }
437
+
438
+ try {
439
+ // Call search with the options object
440
+ const result = await search(options);
441
+ return result;
442
+ } catch (searchError: any) {
443
+ console.error("Search function error:", searchError);
444
+ throw new Error(`Search function error: ${searchError.message || String(searchError)}`);
445
+ }
446
+ } catch (error: any) {
447
+ console.error('Error executing code search:', error);
448
+ throw new McpError(
449
+ 'MethodNotFound' as unknown as ErrorCode,
450
+ `Error executing code search: ${error.message || String(error)}`
451
+ );
452
+ }
453
+ }
454
+
455
+ private async executeCodeQuery(args: QueryCodeArgs): Promise<string> {
456
+ try {
457
+ // Validate required parameters
458
+ if (!args.path) {
459
+ throw new Error("Path is required");
460
+ }
461
+ if (!args.pattern) {
462
+ throw new Error("Pattern is required");
463
+ }
464
+
465
+ // Create a single options object with both pattern and path
466
+ const options: any = {
467
+ path: args.path,
468
+ pattern: args.pattern,
469
+ language: args.language,
470
+ ignore: args.ignore,
471
+ allowTests: args.allowTests,
472
+ maxResults: args.maxResults,
473
+ format: args.format,
474
+ timeout: args.timeout || this.defaultTimeout
475
+ };
476
+
477
+ // Use noGitignore from args, or fall back to PROBE_NO_GITIGNORE environment variable
478
+ if (args.noGitignore !== undefined) {
479
+ options.noGitignore = args.noGitignore;
480
+ } else if (process.env.PROBE_NO_GITIGNORE) {
481
+ options.noGitignore = process.env.PROBE_NO_GITIGNORE === 'true';
482
+ }
483
+
484
+ console.log("Executing query with options:", JSON.stringify({
485
+ path: options.path,
486
+ pattern: options.pattern
487
+ }));
488
+
489
+ const result = await query(options);
490
+ return result;
491
+ } catch (error: any) {
492
+ console.error('Error executing code query:', error);
493
+ throw new McpError(
494
+ 'MethodNotFound' as unknown as ErrorCode,
495
+ `Error executing code query: ${error.message || String(error)}`
496
+ );
497
+ }
498
+ }
499
+
500
+ private async executeCodeExtract(args: ExtractCodeArgs): Promise<string> {
501
+ try {
502
+ // Validate required parameters
503
+ if (!args.path) {
504
+ throw new Error("Path is required");
505
+ }
506
+ if (!args.files || !Array.isArray(args.files) || args.files.length === 0) {
507
+ throw new Error("Files array is required and must not be empty");
508
+ }
509
+
510
+ // Create a single options object with files and other parameters
511
+ const options: any = {
512
+ files: args.files,
513
+ path: args.path,
514
+ allowTests: args.allowTests,
515
+ contextLines: args.contextLines,
516
+ format: args.format,
517
+ timeout: args.timeout || this.defaultTimeout
518
+ };
519
+
520
+ // Use noGitignore from args, or fall back to PROBE_NO_GITIGNORE environment variable
521
+ if (args.noGitignore !== undefined) {
522
+ options.noGitignore = args.noGitignore;
523
+ } else if (process.env.PROBE_NO_GITIGNORE) {
524
+ options.noGitignore = process.env.PROBE_NO_GITIGNORE === 'true';
525
+ }
526
+
527
+ // Call extract with the complete options object
528
+ try {
529
+ // Track request size for token usage
530
+ const requestSize = JSON.stringify(args).length;
531
+ const requestTokens = Math.ceil(requestSize / 4); // Approximate token count
532
+
533
+ // Execute the extract command
534
+ const result = await extract(options);
535
+
536
+ // Parse the result to extract token information if available
537
+ let responseTokens = 0;
538
+ let totalTokens = 0;
539
+
540
+ // Try to extract token information from the result
541
+ if (typeof result === 'string') {
542
+ const tokenMatch = result.match(/Total tokens returned: (\d+)/);
543
+ if (tokenMatch && tokenMatch[1]) {
544
+ responseTokens = parseInt(tokenMatch[1], 10);
545
+ totalTokens = requestTokens + responseTokens;
546
+ }
547
+
548
+ // Remove spinner debug output lines
549
+ const cleanedLines = result.split('\n').filter(line =>
550
+ !line.match(/^⠙|^⠹|^⠧|^⠇|^⠏/) &&
551
+ !line.includes('Thinking...Extract:') &&
552
+ !line.includes('Extract results:')
553
+ );
554
+
555
+ // Add token usage information if not already present
556
+ if (!result.includes('Token Usage:')) {
557
+ cleanedLines.push('');
558
+ cleanedLines.push('Token Usage:');
559
+ cleanedLines.push(` Request tokens: ${requestTokens}`);
560
+ cleanedLines.push(` Response tokens: ${responseTokens}`);
561
+ cleanedLines.push(` Total tokens: ${totalTokens}`);
562
+ }
563
+
564
+ return cleanedLines.join('\n');
565
+ }
566
+
567
+ return result;
568
+ } catch (error: any) {
569
+ console.error(`Error extracting:`, error);
570
+ return `Error extracting: ${error.message || String(error)}`;
571
+ }
572
+ } catch (error: any) {
573
+ console.error('Error executing code extract:', error);
574
+ throw new McpError(
575
+ 'MethodNotFound' as unknown as ErrorCode,
576
+ `Error executing code extract: ${error.message || String(error)}`
577
+ );
578
+ }
579
+ }
580
+
581
+ async run() {
582
+ // The @probelabs/probe package now handles binary path management internally
583
+ // We don't need to verify or download the binary in the MCP server anymore
584
+
585
+ // Just connect the server to the transport
586
+ const transport = new StdioServerTransport();
587
+ await this.server.connect(transport);
588
+ console.error('Probe MCP server running on stdio');
589
+ }
590
+ }
591
+
592
+ const server = new ProbeServer(cliConfig.timeout);
593
+ server.run().catch(console.error);
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@probelabs/probe",
3
+ "version": "0.6.0-rc56",
4
+ "description": "Node.js wrapper for the probe code search tool",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "probe": "bin/probe"
9
+ },
10
+ "files": [
11
+ "src/**/*",
12
+ "bin/probe",
13
+ "bin/.gitkeep",
14
+ "scripts/postinstall.js",
15
+ "build/mcp/**/*"
16
+ ],
17
+ "scripts": {
18
+ "postinstall": "node scripts/postinstall.js",
19
+ "build:mcp": "node scripts/build-mcp.cjs",
20
+ "build": "npm run build:mcp",
21
+ "prepack": "npm run build"
22
+ },
23
+ "keywords": [
24
+ "probe",
25
+ "code-search",
26
+ "search",
27
+ "ast",
28
+ "code-analysis",
29
+ "ai",
30
+ "llm",
31
+ "tools"
32
+ ],
33
+ "author": "probelabs",
34
+ "license": "ISC",
35
+ "dependencies": {
36
+ "axios": "^1.8.3",
37
+ "fs-extra": "^11.1.1",
38
+ "tar": "^6.2.0",
39
+ "zod": "^3.24.2",
40
+ "@modelcontextprotocol/sdk": "^1.17.0"
41
+ },
42
+ "devDependencies": {
43
+ "typescript": "^5.9.2",
44
+ "@types/node": "^20.17.19",
45
+ "@types/fs-extra": "^11.0.4",
46
+ "cross-env": "^7.0.3"
47
+ },
48
+ "peerDependencies": {
49
+ "ai": "^4.1.60"
50
+ },
51
+ "engines": {
52
+ "node": ">=14.16.0"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/probelabs/probe.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/probelabs/probe/issues"
60
+ },
61
+ "homepage": "https://github.com/probelabs/probe#readme"
62
+ }