@dropspot/mcp 0.0.0-stage → 1.0.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/README.md +113 -2
- package/dist/index.js +754 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dropspot/mcp — Drop Spot MCP Server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@dropspot/mcp)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://modelcontextprotocol.io)
|
|
6
|
+
|
|
7
|
+
Official **Model Context Protocol (MCP)** server for [Drop Spot](https://godropspot.com) — the passwordless client document intake and automated follow-up platform for accountants, law firms, and service professionals.
|
|
8
|
+
|
|
9
|
+
Connect Drop Spot directly to **Claude Desktop**, **Cursor AI**, or **Google Antigravity**. Instruct your AI in plain English to prepare client checklists, dispatch secure zero-password upload links, monitor completion status, and review incoming paperwork—with zero coding required.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## ⚡ Quickstart
|
|
14
|
+
|
|
15
|
+
### 1. Get your Drop Spot API Key
|
|
16
|
+
1. Sign in to your Drop Spot workspace at [godropspot.com](https://godropspot.com).
|
|
17
|
+
2. Go to **Settings** → **Developer API & MCP**.
|
|
18
|
+
3. Click **Generate New API Key**, select your desired scopes (or Full Access), and copy your token (`ds_live_...`).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### 2. Configure Your AI Client
|
|
23
|
+
|
|
24
|
+
#### Option A: Claude Desktop
|
|
25
|
+
Add the following to your `claude_desktop_config.json`:
|
|
26
|
+
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
27
|
+
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"dropspot": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "@dropspot/mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"DROPSPOT_API_KEY": "ds_live_YOUR_ACTUAL_KEY_HERE",
|
|
37
|
+
"DROPSPOT_API_BASE_URL": "https://godropspot.com/api/v1"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
#### Option B: Cursor AI
|
|
45
|
+
Add to `.cursor/mcp.json` in your project or global settings:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"dropspot": {
|
|
51
|
+
"command": "npx",
|
|
52
|
+
"args": ["-y", "@dropspot/mcp"],
|
|
53
|
+
"env": {
|
|
54
|
+
"DROPSPOT_API_KEY": "ds_live_YOUR_ACTUAL_KEY_HERE",
|
|
55
|
+
"DROPSPOT_API_BASE_URL": "https://godropspot.com/api/v1"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
#### Option C: Smithery CLI
|
|
63
|
+
Install automatically into Claude Desktop with one command:
|
|
64
|
+
```bash
|
|
65
|
+
npx -y @smithery/cli install @dropspot/mcp --client claude
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 💬 Prompts Your AI Understands
|
|
71
|
+
|
|
72
|
+
Once connected, your AI gains native understanding of your Drop Spot workspace:
|
|
73
|
+
|
|
74
|
+
- *"Create an intake request for Sarah Jenkins using our 2026 Tax Return template."*
|
|
75
|
+
- *"Show me our client list and search for Acme Corp."*
|
|
76
|
+
- *"Update Sarah's phone number to (555) 234-5678 and add a note that she is a VIP client."*
|
|
77
|
+
- *"Check if Sarah uploaded her W-2 form yet."*
|
|
78
|
+
- *"Review Sarah's uploaded W-2 and approve it."*
|
|
79
|
+
- *"Reject item #2 with the note: 'Page 2 is blurry. Please re-upload a clear scan.' "*
|
|
80
|
+
- *"List all our active document requests with pending items."*
|
|
81
|
+
- *"Create a reusable checklist template called 'Quarterly Bookkeeping Close'."*
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## 🛠️ Available MCP Tools (12 Tools)
|
|
86
|
+
|
|
87
|
+
| Tool Name | Description |
|
|
88
|
+
| :--- | :--- |
|
|
89
|
+
| `create_intake_request` | Create a new document intake dossier with an itemized checklist. Generates a passwordless magic upload link and optionally emails the client. |
|
|
90
|
+
| `list_intake_requests` | List and filter active document requests in the workspace by status or client email. |
|
|
91
|
+
| `get_intake_request` | Retrieve complete details of a specific request, including each checklist item, upload statuses, and file metadata. |
|
|
92
|
+
| `review_item` | Review an uploaded file: approve it or reject it with custom feedback to prompt the client for a re-upload. |
|
|
93
|
+
| `get_file_download_link` | Generate a short-lived (5-minute) secure presigned URL to inspect or download an uploaded client file. |
|
|
94
|
+
| `list_clients` | Search and browse your firm's client directory by name, email, or company. |
|
|
95
|
+
| `get_client` | Fetch a client record with their full intake history and metadata. |
|
|
96
|
+
| `create_or_update_client` | Add a new client or update existing contact details (phone, company, notes). |
|
|
97
|
+
| `list_templates` | View your reusable checklist templates (e.g. 1040 Tax Return, KYC, Mortgage Intake). |
|
|
98
|
+
| `get_template` | Inspect a specific checklist template and its default items. |
|
|
99
|
+
| `create_template` | Create a new reusable checklist template with predefined items and categories. |
|
|
100
|
+
| `update_template` | Edit an existing template's name, description, or checklist items. |
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 🔒 Security, Privacy & Compliance
|
|
105
|
+
|
|
106
|
+
- **Zero AI Training:** Client documents and personal data are **never** used to train public or proprietary AI models.
|
|
107
|
+
- **Short-Lived Signed URLs:** File downloads use ephemeral 5-minute pre-signed URLs; file binaries are never permanently exposed over the wire.
|
|
108
|
+
- **Scoped API Tokens:** Generate API keys restricted strictly to specific operations (`requests:read`, `requests:write`, `items:review`, `clients:write`, etc.).
|
|
109
|
+
- **Human-in-the-Loop:** AI tools execute actions on your behalf with complete audit trails. You retain full oversight with real-time logs and 1-click token revocation.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 📄 License
|
|
114
|
+
MIT © [Drop Spot Technologies](https://godropspot.com)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
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 { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
const API_KEY = process.env.DROPSPOT_API_KEY || '';
|
|
6
|
+
const BASE_URL = (process.env.DROPSPOT_API_BASE_URL || process.env.DROPSPOT_BASE_URL || 'https://portal.godropspot.com').replace(/\/+$/, '');
|
|
7
|
+
async function apiRequest(endpoint, options = {}) {
|
|
8
|
+
// Support both full paths and /api/v1 paths
|
|
9
|
+
const cleanEndpoint = endpoint.startsWith('/api/v1')
|
|
10
|
+
? (BASE_URL.endsWith('/api/v1') ? endpoint.replace('/api/v1', '') : endpoint)
|
|
11
|
+
: (BASE_URL.endsWith('/api/v1') ? endpoint : `/api/v1${endpoint}`);
|
|
12
|
+
const url = `${BASE_URL}${cleanEndpoint}`;
|
|
13
|
+
const headers = {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
16
|
+
...options.headers,
|
|
17
|
+
};
|
|
18
|
+
const response = await fetch(url, { ...options, headers });
|
|
19
|
+
const data = (await response.json());
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(data?.error || `API request failed with HTTP ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
return data;
|
|
24
|
+
}
|
|
25
|
+
const server = new Server({
|
|
26
|
+
name: 'dropspot-mcp',
|
|
27
|
+
version: '1.1.0',
|
|
28
|
+
}, {
|
|
29
|
+
capabilities: {
|
|
30
|
+
tools: {},
|
|
31
|
+
resources: {},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
// ----------------------------------------------------------------------------
|
|
35
|
+
// 1. LIST TOOLS
|
|
36
|
+
// ----------------------------------------------------------------------------
|
|
37
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
38
|
+
return {
|
|
39
|
+
tools: [
|
|
40
|
+
{
|
|
41
|
+
name: 'create_intake_request',
|
|
42
|
+
description: 'Create a new Drop Spot client document intake request (dossier). Generates a zero-password magic upload link and optionally emails the client.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
clientName: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'Full name of the client (e.g. "Jane Doe")',
|
|
49
|
+
},
|
|
50
|
+
clientEmail: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Email address of the client where the magic link will be sent',
|
|
53
|
+
},
|
|
54
|
+
clientPhone: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
description: 'Optional phone number for SMS reminders',
|
|
57
|
+
},
|
|
58
|
+
title: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
description: 'Title of the intake request (e.g. "2026 Tax Return Documentation")',
|
|
61
|
+
},
|
|
62
|
+
dueAt: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'ISO 8601 deadline date (optional, defaults to 14 days from now)',
|
|
65
|
+
},
|
|
66
|
+
sendInvitationEmail: {
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
description: 'Whether to automatically email the client the invitation link (defaults to true)',
|
|
69
|
+
},
|
|
70
|
+
items: {
|
|
71
|
+
type: 'array',
|
|
72
|
+
description: 'Checklist of items and documents required from the client',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
title: { type: 'string', description: 'Name of the required document' },
|
|
77
|
+
description: { type: 'string', description: 'Helpful instructions for the client' },
|
|
78
|
+
isRequired: { type: 'boolean', description: 'Whether this item is mandatory (default: true)' },
|
|
79
|
+
allowMultiple: { type: 'boolean', description: 'Allow uploading multiple files for this item' },
|
|
80
|
+
category: { type: 'string', description: 'Grouping category (e.g. "Tax", "Identification")' },
|
|
81
|
+
},
|
|
82
|
+
required: ['title'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ['clientName', 'clientEmail', 'title', 'items'],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'list_intake_requests',
|
|
91
|
+
description: 'List client intake requests in the Drop Spot workspace. Filter by status or client email.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
status: {
|
|
96
|
+
type: 'string',
|
|
97
|
+
enum: ['in_process', 'completed', 'canceled'],
|
|
98
|
+
description: 'Filter by dossier status',
|
|
99
|
+
},
|
|
100
|
+
clientEmail: {
|
|
101
|
+
type: 'string',
|
|
102
|
+
description: 'Filter by specific client email',
|
|
103
|
+
},
|
|
104
|
+
limit: {
|
|
105
|
+
type: 'number',
|
|
106
|
+
description: 'Number of requests to return (default: 20, max: 100)',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'get_intake_request',
|
|
113
|
+
description: 'Retrieve full details for an intake request by ID, including checklist items, upload progress, and file metadata.',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
requestId: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description: 'The UUID of the intake request',
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
required: ['requestId'],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'review_item',
|
|
127
|
+
description: 'Approve or reject a document uploaded by a client. If rejected, sends automated feedback asking the client to re-upload.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: {
|
|
131
|
+
itemId: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'The UUID of the checklist item to review',
|
|
134
|
+
},
|
|
135
|
+
decision: {
|
|
136
|
+
type: 'string',
|
|
137
|
+
enum: ['approve', 'reject'],
|
|
138
|
+
description: 'Approval decision',
|
|
139
|
+
},
|
|
140
|
+
rejectionNote: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
description: 'Reason for rejection to help client correct the issue (e.g. "Photo is blurry, please retake")',
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
required: ['itemId', 'decision'],
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'get_file_download_link',
|
|
150
|
+
description: 'Generate a secure, temporary presigned download URL for an uploaded file so an AI or operator can inspect the document contents.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
fileId: {
|
|
155
|
+
type: 'string',
|
|
156
|
+
description: 'The UUID of the uploaded file record',
|
|
157
|
+
},
|
|
158
|
+
expiresInSeconds: {
|
|
159
|
+
type: 'number',
|
|
160
|
+
description: 'Expiration in seconds (default: 300 = 5 minutes for privacy)',
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
required: ['fileId'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
// CLIENTS MANAGEMENT TOOLS
|
|
167
|
+
{
|
|
168
|
+
name: 'list_clients',
|
|
169
|
+
description: 'List and search clients in the Drop Spot directory. Filter by name, email, or company.',
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: 'object',
|
|
172
|
+
properties: {
|
|
173
|
+
search: {
|
|
174
|
+
type: 'string',
|
|
175
|
+
description: 'Search string to filter clients by name, email, or company name',
|
|
176
|
+
},
|
|
177
|
+
limit: {
|
|
178
|
+
type: 'number',
|
|
179
|
+
description: 'Max number of clients to return (default: 50, max: 100)',
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'get_client',
|
|
186
|
+
description: 'Retrieve detailed profile information for a specific client, including their active and historical document requests.',
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: 'object',
|
|
189
|
+
properties: {
|
|
190
|
+
clientId: {
|
|
191
|
+
type: 'string',
|
|
192
|
+
description: 'The UUID of the client',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
required: ['clientId'],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'create_or_update_client',
|
|
200
|
+
description: 'Add a new client to the directory or update an existing client’s contact details, phone, company, or notes.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
name: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Full name of the client',
|
|
207
|
+
},
|
|
208
|
+
email: {
|
|
209
|
+
type: 'string',
|
|
210
|
+
description: 'Email address of the client',
|
|
211
|
+
},
|
|
212
|
+
phone: {
|
|
213
|
+
type: 'string',
|
|
214
|
+
description: 'Optional phone number',
|
|
215
|
+
},
|
|
216
|
+
company: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
description: 'Company or business name',
|
|
219
|
+
},
|
|
220
|
+
notes: {
|
|
221
|
+
type: 'string',
|
|
222
|
+
description: 'Internal notes about this client',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
required: ['name', 'email'],
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
// TEMPLATES MANAGEMENT TOOLS
|
|
229
|
+
{
|
|
230
|
+
name: 'list_templates',
|
|
231
|
+
description: 'List available checklist templates (including starter packages for tax returns, KYC, and mortgage underwriting).',
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
includeStarters: {
|
|
236
|
+
type: 'boolean',
|
|
237
|
+
description: 'Whether to include built-in starter templates (default: true)',
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: 'get_template',
|
|
244
|
+
description: 'Get full details and checklist items for a specific reusable template.',
|
|
245
|
+
inputSchema: {
|
|
246
|
+
type: 'object',
|
|
247
|
+
properties: {
|
|
248
|
+
templateId: {
|
|
249
|
+
type: 'string',
|
|
250
|
+
description: 'The UUID or starter identifier of the template',
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
required: ['templateId'],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'create_template',
|
|
258
|
+
description: 'Create a new reusable document checklist template with predefined items and categories.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
name: {
|
|
263
|
+
type: 'string',
|
|
264
|
+
description: 'Template title (e.g. "Q1 Bookkeeping Review")',
|
|
265
|
+
},
|
|
266
|
+
description: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
description: 'Description of when to use this checklist',
|
|
269
|
+
},
|
|
270
|
+
isShared: {
|
|
271
|
+
type: 'boolean',
|
|
272
|
+
description: 'Whether this template is visible to the entire workspace team (default: true)',
|
|
273
|
+
},
|
|
274
|
+
items: {
|
|
275
|
+
type: 'array',
|
|
276
|
+
description: 'The list of checklist items',
|
|
277
|
+
items: {
|
|
278
|
+
type: 'object',
|
|
279
|
+
properties: {
|
|
280
|
+
title: { type: 'string', description: 'Name of the required document' },
|
|
281
|
+
description: { type: 'string', description: 'Instructions for the client' },
|
|
282
|
+
isRequired: { type: 'boolean', description: 'Whether item is mandatory (default: true)' },
|
|
283
|
+
allowMultiple: { type: 'boolean', description: 'Allow multiple files (default: false)' },
|
|
284
|
+
category: { type: 'string', description: 'Item category' },
|
|
285
|
+
},
|
|
286
|
+
required: ['title'],
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
required: ['name', 'items'],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: 'update_template',
|
|
295
|
+
description: 'Edit an existing custom checklist template (update name, description, or checklist items).',
|
|
296
|
+
inputSchema: {
|
|
297
|
+
type: 'object',
|
|
298
|
+
properties: {
|
|
299
|
+
templateId: {
|
|
300
|
+
type: 'string',
|
|
301
|
+
description: 'The UUID of the template to edit',
|
|
302
|
+
},
|
|
303
|
+
name: {
|
|
304
|
+
type: 'string',
|
|
305
|
+
description: 'Updated template name',
|
|
306
|
+
},
|
|
307
|
+
description: {
|
|
308
|
+
type: 'string',
|
|
309
|
+
description: 'Updated description',
|
|
310
|
+
},
|
|
311
|
+
items: {
|
|
312
|
+
type: 'array',
|
|
313
|
+
description: 'Updated list of items',
|
|
314
|
+
items: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
properties: {
|
|
317
|
+
title: { type: 'string' },
|
|
318
|
+
description: { type: 'string' },
|
|
319
|
+
isRequired: { type: 'boolean' },
|
|
320
|
+
allowMultiple: { type: 'boolean' },
|
|
321
|
+
category: { type: 'string' },
|
|
322
|
+
},
|
|
323
|
+
required: ['title'],
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
required: ['templateId'],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
});
|
|
333
|
+
// ----------------------------------------------------------------------------
|
|
334
|
+
// 2. CALL TOOL HANDLERS
|
|
335
|
+
// ----------------------------------------------------------------------------
|
|
336
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
337
|
+
const { name, arguments: args } = request.params;
|
|
338
|
+
if (!API_KEY) {
|
|
339
|
+
return {
|
|
340
|
+
isError: true,
|
|
341
|
+
content: [
|
|
342
|
+
{
|
|
343
|
+
type: 'text',
|
|
344
|
+
text: 'DROPSPOT_API_KEY environment variable is not configured. Please supply a valid Drop Spot API key.',
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
switch (name) {
|
|
351
|
+
case 'create_intake_request': {
|
|
352
|
+
const payload = {
|
|
353
|
+
clientName: args?.clientName,
|
|
354
|
+
clientEmail: args?.clientEmail,
|
|
355
|
+
clientPhone: args?.clientPhone,
|
|
356
|
+
title: args?.title,
|
|
357
|
+
dueAt: args?.dueAt,
|
|
358
|
+
sendInvitationEmail: args?.sendInvitationEmail ?? true,
|
|
359
|
+
items: args?.items,
|
|
360
|
+
};
|
|
361
|
+
const res = await apiRequest('/requests', {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
body: JSON.stringify(payload),
|
|
364
|
+
});
|
|
365
|
+
return {
|
|
366
|
+
content: [
|
|
367
|
+
{
|
|
368
|
+
type: 'text',
|
|
369
|
+
text: `✅ Intake request created successfully!\n\n` +
|
|
370
|
+
`• ID: ${res.data.id}\n` +
|
|
371
|
+
`• Title: ${res.data.title}\n` +
|
|
372
|
+
`• Client: ${res.data.clientName} (${res.data.clientEmail})\n` +
|
|
373
|
+
`• Magic Portal URL: ${res.data.portalUrl}\n` +
|
|
374
|
+
`• Due Date: ${new Date(res.data.dueAt).toLocaleDateString()}\n` +
|
|
375
|
+
`• Items Requested: ${res.data.items?.length || 0}\n` +
|
|
376
|
+
`• Invitation Email Sent: ${res.data.invitationEmailSent ? 'Yes' : 'No'}`,
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
case 'list_intake_requests': {
|
|
382
|
+
const params = new URLSearchParams();
|
|
383
|
+
if (args?.status)
|
|
384
|
+
params.set('status', String(args.status));
|
|
385
|
+
if (args?.clientEmail)
|
|
386
|
+
params.set('client_email', String(args.clientEmail));
|
|
387
|
+
if (args?.limit)
|
|
388
|
+
params.set('limit', String(args.limit));
|
|
389
|
+
const res = await apiRequest(`/requests?${params.toString()}`);
|
|
390
|
+
const requests = res.data || [];
|
|
391
|
+
if (requests.length === 0) {
|
|
392
|
+
return {
|
|
393
|
+
content: [{ type: 'text', text: 'No intake requests found matching the criteria.' }],
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const formatted = requests
|
|
397
|
+
.map((r) => {
|
|
398
|
+
return `• [${r.status.toUpperCase()}] ${r.title} — ${r.clientName} (${r.clientEmail})\n` +
|
|
399
|
+
` ID: ${r.id} | Progress: ${r.completedItemsCount}/${r.itemsCount} items approved | Due: ${new Date(r.dueAt).toLocaleDateString()}`;
|
|
400
|
+
})
|
|
401
|
+
.join('\n\n');
|
|
402
|
+
return {
|
|
403
|
+
content: [
|
|
404
|
+
{
|
|
405
|
+
type: 'text',
|
|
406
|
+
text: `Found ${requests.length} intake requests:\n\n${formatted}`,
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
case 'get_intake_request': {
|
|
412
|
+
const requestId = String(args?.requestId);
|
|
413
|
+
const res = await apiRequest(`/requests/${requestId}`);
|
|
414
|
+
const d = res.data;
|
|
415
|
+
let itemsReport = (d.items || [])
|
|
416
|
+
.map((item, idx) => {
|
|
417
|
+
const statusIcon = item.status === 'approved' ? '✅' : item.status === 'rejected' ? '❌' : item.status === 'uploaded' ? '📥' : '⏳';
|
|
418
|
+
let fileDetails = '';
|
|
419
|
+
if (item.files && item.files.length > 0) {
|
|
420
|
+
fileDetails = item.files
|
|
421
|
+
.map((f) => `\n - File: ${f.filename} (${Math.round(f.sizeBytes / 1024)} KB) [File ID: ${f.id}]`)
|
|
422
|
+
.join('');
|
|
423
|
+
}
|
|
424
|
+
const rejection = item.rejectionNote ? `\n - Note: "${item.rejectionNote}"` : '';
|
|
425
|
+
return `${idx + 1}. ${statusIcon} ${item.title} [Status: ${item.status}]${rejection}${fileDetails}`;
|
|
426
|
+
})
|
|
427
|
+
.join('\n');
|
|
428
|
+
const summary = `📋 Intake Dossier: ${d.title}\n` +
|
|
429
|
+
`• ID: ${d.id}\n` +
|
|
430
|
+
`• Client: ${d.clientName} <${d.clientEmail}>\n` +
|
|
431
|
+
`• Status: ${d.status}\n` +
|
|
432
|
+
`• Progress: ${d.progress.approvedItems}/${d.progress.requiredItems} required items approved (${d.progress.percent}%)\n` +
|
|
433
|
+
`• Due Date: ${new Date(d.dueAt).toLocaleString()}\n\n` +
|
|
434
|
+
`Checklist Items:\n${itemsReport}`;
|
|
435
|
+
return {
|
|
436
|
+
content: [{ type: 'text', text: summary }],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
case 'review_item': {
|
|
440
|
+
const itemId = String(args?.itemId);
|
|
441
|
+
const decision = String(args?.decision);
|
|
442
|
+
const rejectionNote = args?.rejectionNote ? String(args.rejectionNote) : undefined;
|
|
443
|
+
const res = await apiRequest(`/items/${itemId}/review`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
body: JSON.stringify({ decision, rejectionNote }),
|
|
446
|
+
});
|
|
447
|
+
const icon = decision === 'approve' ? '✅' : '❌';
|
|
448
|
+
return {
|
|
449
|
+
content: [
|
|
450
|
+
{
|
|
451
|
+
type: 'text',
|
|
452
|
+
text: `${icon} Item "${res.data.item.title}" has been marked as ${decision.toUpperCase()}.\n` +
|
|
453
|
+
(decision === 'reject' ? `• Rejection feedback dispatched to client: "${res.data.item.rejectionNote}"\n` : '') +
|
|
454
|
+
`• Dossier Completed: ${res.data.request.completed ? 'YES (All required items approved!)' : 'No (Items remaining)'}`,
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
case 'get_file_download_link': {
|
|
460
|
+
const fileId = String(args?.fileId);
|
|
461
|
+
const expiresIn = args?.expiresInSeconds ? Number(args.expiresInSeconds) : 300;
|
|
462
|
+
const res = await apiRequest(`/files/${fileId}/download?expires_in=${expiresIn}`);
|
|
463
|
+
const f = res.data;
|
|
464
|
+
return {
|
|
465
|
+
content: [
|
|
466
|
+
{
|
|
467
|
+
type: 'text',
|
|
468
|
+
text: `🔗 Secure download link generated (valid for ${Math.round(f.expiresInSeconds / 60)} minutes):\n\n` +
|
|
469
|
+
`• Filename: ${f.filename}\n` +
|
|
470
|
+
`• MIME Type: ${f.mimeType}\n` +
|
|
471
|
+
`• Size: ${(f.sizeBytes / 1024).toFixed(1)} KB\n` +
|
|
472
|
+
`• SHA-256: ${f.sha256Hash}\n` +
|
|
473
|
+
`• Direct URL: ${f.downloadUrl}`,
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// CLIENT HANDLERS
|
|
479
|
+
case 'list_clients': {
|
|
480
|
+
const params = new URLSearchParams();
|
|
481
|
+
if (args?.search)
|
|
482
|
+
params.set('search', String(args.search));
|
|
483
|
+
if (args?.limit)
|
|
484
|
+
params.set('limit', String(args.limit));
|
|
485
|
+
const res = await apiRequest(`/clients?${params.toString()}`);
|
|
486
|
+
const clients = res.data || [];
|
|
487
|
+
if (clients.length === 0) {
|
|
488
|
+
return {
|
|
489
|
+
content: [{ type: 'text', text: 'No clients found matching the search criteria.' }],
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
const formatted = clients
|
|
493
|
+
.map((c) => {
|
|
494
|
+
const company = c.company ? ` | Company: ${c.company}` : '';
|
|
495
|
+
const phone = c.phone ? ` | Phone: ${c.phone}` : '';
|
|
496
|
+
return `• ${c.name} <${c.email}>${company}${phone}\n ID: ${c.id}`;
|
|
497
|
+
})
|
|
498
|
+
.join('\n\n');
|
|
499
|
+
return {
|
|
500
|
+
content: [
|
|
501
|
+
{
|
|
502
|
+
type: 'text',
|
|
503
|
+
text: `Found ${clients.length} clients:\n\n${formatted}`,
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
case 'get_client': {
|
|
509
|
+
const clientId = String(args?.clientId);
|
|
510
|
+
const res = await apiRequest(`/clients/${clientId}`);
|
|
511
|
+
const c = res.data;
|
|
512
|
+
let requestsSummary = 'No document requests yet.';
|
|
513
|
+
if (c.requests && c.requests.length > 0) {
|
|
514
|
+
requestsSummary = c.requests
|
|
515
|
+
.map((r) => ` - [${r.status.toUpperCase()}] ${r.title} (ID: ${r.id})`)
|
|
516
|
+
.join('\n');
|
|
517
|
+
}
|
|
518
|
+
const report = `👤 Client Profile: ${c.name}\n` +
|
|
519
|
+
`• ID: ${c.id}\n` +
|
|
520
|
+
`• Email: ${c.email}\n` +
|
|
521
|
+
`• Phone: ${c.phone || 'Not provided'}\n` +
|
|
522
|
+
`• Company: ${c.company || 'Not provided'}\n` +
|
|
523
|
+
`• Notes: ${c.notes || 'None'}\n\n` +
|
|
524
|
+
`Document Intake Requests:\n${requestsSummary}`;
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: 'text', text: report }],
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
case 'create_or_update_client': {
|
|
530
|
+
const payload = {
|
|
531
|
+
name: args?.name,
|
|
532
|
+
email: args?.email,
|
|
533
|
+
phone: args?.phone,
|
|
534
|
+
company: args?.company,
|
|
535
|
+
notes: args?.notes,
|
|
536
|
+
};
|
|
537
|
+
const res = await apiRequest('/clients', {
|
|
538
|
+
method: 'POST',
|
|
539
|
+
body: JSON.stringify(payload),
|
|
540
|
+
});
|
|
541
|
+
const c = res.data;
|
|
542
|
+
const actionLabel = c.updatedExisting ? 'Updated existing client record' : 'Created new client record';
|
|
543
|
+
return {
|
|
544
|
+
content: [
|
|
545
|
+
{
|
|
546
|
+
type: 'text',
|
|
547
|
+
text: `✅ ${actionLabel}:\n\n` +
|
|
548
|
+
`• ID: ${c.id}\n` +
|
|
549
|
+
`• Name: ${c.name}\n` +
|
|
550
|
+
`• Email: ${c.email}\n` +
|
|
551
|
+
`• Phone: ${c.phone || 'N/A'}\n` +
|
|
552
|
+
`• Company: ${c.company || 'N/A'}\n` +
|
|
553
|
+
`• Notes: ${c.notes || 'N/A'}`,
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
// TEMPLATE HANDLERS
|
|
559
|
+
case 'list_templates': {
|
|
560
|
+
const params = new URLSearchParams();
|
|
561
|
+
if (args?.includeStarters !== undefined) {
|
|
562
|
+
params.set('includeStarters', String(args.includeStarters));
|
|
563
|
+
}
|
|
564
|
+
const res = await apiRequest(`/templates?${params.toString()}`);
|
|
565
|
+
const templates = res.data || [];
|
|
566
|
+
if (templates.length === 0) {
|
|
567
|
+
return {
|
|
568
|
+
content: [{ type: 'text', text: 'No checklist templates found in this workspace.' }],
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const formatted = templates
|
|
572
|
+
.map((t) => {
|
|
573
|
+
const badge = t.isStarter ? ' [STARTER]' : '';
|
|
574
|
+
return `• ${t.name}${badge}\n` +
|
|
575
|
+
` ID: ${t.id} | Items: ${t.items?.length || 0}\n` +
|
|
576
|
+
` ${t.description || 'No description'}`;
|
|
577
|
+
})
|
|
578
|
+
.join('\n\n');
|
|
579
|
+
return {
|
|
580
|
+
content: [
|
|
581
|
+
{
|
|
582
|
+
type: 'text',
|
|
583
|
+
text: `Found ${templates.length} templates:\n\n${formatted}`,
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
case 'get_template': {
|
|
589
|
+
const templateId = String(args?.templateId);
|
|
590
|
+
const res = await apiRequest(`/templates/${templateId}`);
|
|
591
|
+
const t = res.data;
|
|
592
|
+
const itemsList = (t.items || [])
|
|
593
|
+
.map((it, idx) => {
|
|
594
|
+
const req = it.isRequired ? '(Required)' : '(Optional)';
|
|
595
|
+
const multi = it.allowMultiple ? '[Multiple files]' : '';
|
|
596
|
+
return ` ${idx + 1}. ${it.title} ${req} ${multi}\n ${it.description || ''}`;
|
|
597
|
+
})
|
|
598
|
+
.join('\n');
|
|
599
|
+
const report = `📝 Template: ${t.name}\n` +
|
|
600
|
+
`• ID: ${t.id}\n` +
|
|
601
|
+
`• Description: ${t.description || 'None'}\n` +
|
|
602
|
+
`• Starter Template: ${t.isStarter ? 'Yes' : 'No'}\n\n` +
|
|
603
|
+
`Checklist Items (${t.items?.length || 0}):\n${itemsList}`;
|
|
604
|
+
return {
|
|
605
|
+
content: [{ type: 'text', text: report }],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
case 'create_template': {
|
|
609
|
+
const payload = {
|
|
610
|
+
name: args?.name,
|
|
611
|
+
description: args?.description,
|
|
612
|
+
isShared: args?.isShared ?? true,
|
|
613
|
+
items: args?.items,
|
|
614
|
+
};
|
|
615
|
+
const res = await apiRequest('/templates', {
|
|
616
|
+
method: 'POST',
|
|
617
|
+
body: JSON.stringify(payload),
|
|
618
|
+
});
|
|
619
|
+
const t = res.data;
|
|
620
|
+
return {
|
|
621
|
+
content: [
|
|
622
|
+
{
|
|
623
|
+
type: 'text',
|
|
624
|
+
text: `✅ Template created successfully!\n\n` +
|
|
625
|
+
`• ID: ${t.id}\n` +
|
|
626
|
+
`• Name: ${t.name}\n` +
|
|
627
|
+
`• Items: ${t.items?.length || 0}\n` +
|
|
628
|
+
`• Description: ${t.description || 'None'}`,
|
|
629
|
+
},
|
|
630
|
+
],
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
case 'update_template': {
|
|
634
|
+
const templateId = String(args?.templateId);
|
|
635
|
+
const payload = {};
|
|
636
|
+
if (args?.name !== undefined)
|
|
637
|
+
payload.name = args.name;
|
|
638
|
+
if (args?.description !== undefined)
|
|
639
|
+
payload.description = args.description;
|
|
640
|
+
if (args?.items !== undefined)
|
|
641
|
+
payload.items = args.items;
|
|
642
|
+
const res = await apiRequest(`/templates/${templateId}`, {
|
|
643
|
+
method: 'PATCH',
|
|
644
|
+
body: JSON.stringify(payload),
|
|
645
|
+
});
|
|
646
|
+
const t = res.data;
|
|
647
|
+
return {
|
|
648
|
+
content: [
|
|
649
|
+
{
|
|
650
|
+
type: 'text',
|
|
651
|
+
text: `✅ Template updated successfully!\n\n` +
|
|
652
|
+
`• ID: ${t.id}\n` +
|
|
653
|
+
`• Name: ${t.name}\n` +
|
|
654
|
+
`• Items: ${t.items?.length || 0}\n` +
|
|
655
|
+
`• Description: ${t.description || 'None'}`,
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
default:
|
|
661
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
return {
|
|
666
|
+
isError: true,
|
|
667
|
+
content: [
|
|
668
|
+
{
|
|
669
|
+
type: 'text',
|
|
670
|
+
text: `❌ Drop Spot MCP Error: ${error.message || String(error)}`,
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
// ----------------------------------------------------------------------------
|
|
677
|
+
// 3. RESOURCES: Dynamic Drop Spot Dossier Inspection
|
|
678
|
+
// ----------------------------------------------------------------------------
|
|
679
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
680
|
+
return {
|
|
681
|
+
resources: [
|
|
682
|
+
{
|
|
683
|
+
uri: 'dropspot://requests/active',
|
|
684
|
+
name: 'Active Client Intake Requests',
|
|
685
|
+
description: 'Real-time JSON dossier feed of all open document requests in the active workspace.',
|
|
686
|
+
mimeType: 'application/json',
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
uri: 'dropspot://clients/all',
|
|
690
|
+
name: 'Workspace Client Directory',
|
|
691
|
+
description: 'Complete client roster including names, emails, phone numbers, and company metadata.',
|
|
692
|
+
mimeType: 'application/json',
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
uri: 'dropspot://templates/all',
|
|
696
|
+
name: 'Checklist Templates',
|
|
697
|
+
description: 'All reusable document checklist templates available in the workspace.',
|
|
698
|
+
mimeType: 'application/json',
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
};
|
|
702
|
+
});
|
|
703
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
704
|
+
const uri = request.params.uri;
|
|
705
|
+
if (uri === 'dropspot://requests/active') {
|
|
706
|
+
const res = await apiRequest('/requests?status=in_process');
|
|
707
|
+
return {
|
|
708
|
+
contents: [
|
|
709
|
+
{
|
|
710
|
+
uri,
|
|
711
|
+
mimeType: 'application/json',
|
|
712
|
+
text: JSON.stringify(res.data, null, 2),
|
|
713
|
+
},
|
|
714
|
+
],
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
if (uri === 'dropspot://clients/all') {
|
|
718
|
+
const res = await apiRequest('/clients');
|
|
719
|
+
return {
|
|
720
|
+
contents: [
|
|
721
|
+
{
|
|
722
|
+
uri,
|
|
723
|
+
mimeType: 'application/json',
|
|
724
|
+
text: JSON.stringify(res.data, null, 2),
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (uri === 'dropspot://templates/all') {
|
|
730
|
+
const res = await apiRequest('/templates');
|
|
731
|
+
return {
|
|
732
|
+
contents: [
|
|
733
|
+
{
|
|
734
|
+
uri,
|
|
735
|
+
mimeType: 'application/json',
|
|
736
|
+
text: JSON.stringify(res.data, null, 2),
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
throw new Error(`Resource not found: ${uri}`);
|
|
742
|
+
});
|
|
743
|
+
// ----------------------------------------------------------------------------
|
|
744
|
+
// 4. SERVER INITIALIZATION & TRANSPORT
|
|
745
|
+
// ----------------------------------------------------------------------------
|
|
746
|
+
async function main() {
|
|
747
|
+
const transport = new StdioServerTransport();
|
|
748
|
+
await server.connect(transport);
|
|
749
|
+
console.error('Drop Spot MCP Server running on stdio');
|
|
750
|
+
}
|
|
751
|
+
main().catch((error) => {
|
|
752
|
+
console.error('Fatal error initializing Drop Spot MCP server:', error);
|
|
753
|
+
process.exit(1);
|
|
754
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dropspot/mcp",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Model Context Protocol (MCP) server for Drop Spot — automated client document collection, file intake, checklist management, and 1-click reviews for Claude Desktop, Cursor, and AI agents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://godropspot.com",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"claude-desktop",
|
|
11
|
+
"cursor",
|
|
12
|
+
"antigravity",
|
|
13
|
+
"file-sharing",
|
|
14
|
+
"document-collection",
|
|
15
|
+
"client-portal",
|
|
16
|
+
"client-intake",
|
|
17
|
+
"tax-documents",
|
|
18
|
+
"paperwork-automation",
|
|
19
|
+
"file-intake"
|
|
20
|
+
],
|
|
21
|
+
"main": "dist/index.js",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"bin": {
|
|
31
|
+
"dropspot-mcp": "dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"start": "node dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.6.1",
|
|
39
|
+
"zod": "^3.24.2"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@dropspot/tsconfig": "workspace:*",
|
|
43
|
+
"@types/node": "^22.10.1",
|
|
44
|
+
"typescript": "~5.7.2"
|
|
45
|
+
}
|
|
46
|
+
}
|