ofw-mcp 2.0.10 → 2.0.12

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/dist/cache.js CHANGED
@@ -131,9 +131,9 @@ export function getMessage(id) {
131
131
  const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
132
132
  return r ? rowFromDb(r) : null;
133
133
  }
134
- export function listMessages(opts) {
135
- const { db } = openCache();
136
- const offset = (opts.page - 1) * opts.size;
134
+ // Build the WHERE clause + bound params for message queries. listMessages and
135
+ // countMessages share this so the filter semantics can't drift.
136
+ function buildMessageFilter(opts) {
137
137
  const wheres = [];
138
138
  const params = [];
139
139
  if (opts.folder !== undefined) {
@@ -153,35 +153,23 @@ export function listMessages(opts) {
153
153
  wheres.push('(subject LIKE ? OR body LIKE ?)');
154
154
  params.push(pattern, pattern);
155
155
  }
156
- const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '';
157
- params.push(opts.size, offset);
156
+ return {
157
+ where: wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '',
158
+ params,
159
+ };
160
+ }
161
+ export function listMessages(opts) {
162
+ const { db } = openCache();
163
+ const { where, params } = buildMessageFilter(opts);
164
+ const offset = (opts.page - 1) * opts.size;
158
165
  const rows = db.prepare(`SELECT * FROM messages ${where}
159
166
  ORDER BY sent_at DESC, id DESC
160
- LIMIT ? OFFSET ?`).all(...params);
167
+ LIMIT ? OFFSET ?`).all(...params, opts.size, offset);
161
168
  return rows.map(rowFromDb);
162
169
  }
163
170
  export function countMessages(opts) {
164
171
  const { db } = openCache();
165
- const wheres = [];
166
- const params = [];
167
- if (opts.folder !== undefined) {
168
- wheres.push('folder = ?');
169
- params.push(opts.folder);
170
- }
171
- if (opts.since !== undefined) {
172
- wheres.push('sent_at >= ?');
173
- params.push(opts.since);
174
- }
175
- if (opts.until !== undefined) {
176
- wheres.push('sent_at < ?');
177
- params.push(opts.until);
178
- }
179
- if (opts.q !== undefined && opts.q.length > 0) {
180
- const pattern = `%${opts.q}%`;
181
- wheres.push('(subject LIKE ? OR body LIKE ?)');
182
- params.push(pattern, pattern);
183
- }
184
- const where = wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '';
172
+ const { where, params } = buildMessageFilter(opts);
185
173
  const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`)
186
174
  .get(...params);
187
175
  return r?.n ?? 0;
@@ -295,13 +283,19 @@ export function upsertAttachmentForMessage(input) {
295
283
  const { db } = openCache();
296
284
  const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
297
285
  .get(input.fileId);
286
+ // messageId === 0 is the "metadata-only, not yet linked to a message"
287
+ // sentinel used by upload-without-send and download-by-id. Don't
288
+ // pollute the array with it — leave the list empty / unchanged.
289
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
298
290
  let messageIds;
299
- if (existing) {
300
- const arr = JSON.parse(existing.message_ids_json);
301
- messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
291
+ if (input.messageId === 0) {
292
+ messageIds = prior;
293
+ }
294
+ else if (prior.includes(input.messageId)) {
295
+ messageIds = prior;
302
296
  }
303
297
  else {
304
- messageIds = [input.messageId];
298
+ messageIds = [...prior, input.messageId];
305
299
  }
306
300
  db.prepare(`INSERT INTO attachments (
307
301
  file_id, file_name, label, mime_type, size_bytes,
package/dist/client.js CHANGED
@@ -28,78 +28,56 @@ function readVar(key) {
28
28
  return trimmed;
29
29
  }
30
30
  const BASE_URL = 'https://ofw.ourfamilywizard.com';
31
- const STATIC_HEADERS = {
31
+ const OFW_PROTOCOL_HEADERS = {
32
32
  'ofw-client': 'WebApplication',
33
33
  'ofw-version': '1.0.0',
34
- Accept: 'application/json',
35
- 'Content-Type': 'application/json',
36
34
  };
35
+ // Parse a Content-Disposition header for a filename. Prefers RFC 6266
36
+ // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
37
+ function parseContentDispositionFilename(cd) {
38
+ const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
39
+ if (extMatch) {
40
+ const raw = extMatch[1].trim().replace(/^"|"$/g, '');
41
+ try {
42
+ return decodeURIComponent(raw);
43
+ }
44
+ catch {
45
+ return raw;
46
+ }
47
+ }
48
+ const m = /filename="?([^";]+)"?/i.exec(cd);
49
+ return m ? m[1] : null;
50
+ }
37
51
  export class OFWClient {
38
52
  token = null;
39
53
  tokenExpiry = null;
40
54
  async request(method, path, body) {
41
55
  await this.ensureAuthenticated();
42
- return this.doRequest(method, path, body, false);
56
+ const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
57
+ const text = await response.text();
58
+ return (text ? JSON.parse(text) : null);
43
59
  }
44
60
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
45
61
  async requestBinary(method, path) {
46
62
  await this.ensureAuthenticated();
47
- return this.doRequestBinary(method, path, false);
48
- }
49
- async doRequestBinary(method, path, isRetry) {
50
- const headers = {
51
- 'ofw-client': 'WebApplication',
52
- 'ofw-version': '1.0.0',
53
- Accept: 'application/octet-stream',
54
- Authorization: `Bearer ${this.token}`,
55
- };
56
- const response = await fetch(`${BASE_URL}${path}`, { method, headers });
57
- if (response.status === 401 && !isRetry) {
58
- this.token = null;
59
- this.tokenExpiry = null;
60
- await this.ensureAuthenticated();
61
- return this.doRequestBinary(method, path, true);
62
- }
63
- if (response.status === 429 && !isRetry) {
64
- await new Promise((r) => setTimeout(r, 2000));
65
- return this.doRequestBinary(method, path, true);
66
- }
67
- if (!response.ok) {
68
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
69
- }
70
- const buf = Buffer.from(await response.arrayBuffer());
71
- const cd = response.headers.get('content-disposition') ?? '';
72
- // RFC 6266: filename*=UTF-8''… takes priority; fall back to filename="…"
73
- let suggestedFileName = null;
74
- const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
75
- if (extMatch) {
76
- try {
77
- suggestedFileName = decodeURIComponent(extMatch[1].trim().replace(/^"|"$/g, ''));
78
- }
79
- catch {
80
- suggestedFileName = extMatch[1];
81
- }
82
- }
83
- else {
84
- const m = /filename="?([^";]+)"?/i.exec(cd);
85
- if (m)
86
- suggestedFileName = m[1];
87
- }
63
+ const response = await this.fetchWithRetry(method, path, undefined, 'application/octet-stream', false);
88
64
  return {
89
- body: buf,
65
+ body: Buffer.from(await response.arrayBuffer()),
90
66
  contentType: response.headers.get('content-type'),
91
- suggestedFileName,
67
+ suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
92
68
  };
93
69
  }
94
- async doRequest(method, path, body, isRetry) {
70
+ // Single fetch+retry scaffold for both JSON and binary callers. Handles
71
+ // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
72
+ // turns any other non-2xx into a thrown Error.
73
+ async fetchWithRetry(method, path, body, accept, isRetry) {
95
74
  const isFormData = body instanceof FormData;
96
75
  const headers = {
97
- 'ofw-client': 'WebApplication',
98
- 'ofw-version': '1.0.0',
99
- Accept: 'application/json',
76
+ ...OFW_PROTOCOL_HEADERS,
77
+ Accept: accept,
100
78
  Authorization: `Bearer ${this.token}`,
101
79
  };
102
- if (!isFormData)
80
+ if (body !== undefined && !isFormData)
103
81
  headers['Content-Type'] = 'application/json';
104
82
  const response = await fetch(`${BASE_URL}${path}`, {
105
83
  method,
@@ -110,20 +88,19 @@ export class OFWClient {
110
88
  this.token = null;
111
89
  this.tokenExpiry = null;
112
90
  await this.ensureAuthenticated();
113
- return this.doRequest(method, path, body, true);
91
+ return this.fetchWithRetry(method, path, body, accept, true);
114
92
  }
115
93
  if (response.status === 429) {
116
94
  if (!isRetry) {
117
95
  await new Promise((r) => setTimeout(r, 2000));
118
- return this.doRequest(method, path, body, true);
96
+ return this.fetchWithRetry(method, path, body, accept, true);
119
97
  }
120
98
  throw new Error('Rate limited by OFW API');
121
99
  }
122
100
  if (!response.ok) {
123
101
  throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
124
102
  }
125
- const text = await response.text();
126
- return (text ? JSON.parse(text) : null);
103
+ return response;
127
104
  }
128
105
  async ensureAuthenticated() {
129
106
  if (!this.isTokenExpiredSoon())
@@ -139,23 +116,24 @@ export class OFWClient {
139
116
  // Spring Security requires a SESSION cookie before accepting the login POST.
140
117
  // GET /ofw/login.form with redirect:manual to capture the Set-Cookie from the 303 response.
141
118
  const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
142
- headers: { 'ofw-client': 'WebApplication', 'ofw-version': '1.0.0' },
119
+ headers: { ...OFW_PROTOCOL_HEADERS },
143
120
  redirect: 'manual',
144
121
  });
145
122
  // Extract just the SESSION=value part (strip attributes like Path, Secure, etc.)
146
123
  const setCookie = initResponse.headers.get('set-cookie') ?? '';
147
- const sessionCookie = setCookie.split(';')[0]; // split always returns a string; empty string is falsy
124
+ const sessionCookie = setCookie.split(';')[0];
148
125
  const response = await fetch(`${BASE_URL}/ofw/login`, {
149
126
  method: 'POST',
150
127
  headers: {
151
- ...STATIC_HEADERS,
128
+ ...OFW_PROTOCOL_HEADERS,
129
+ Accept: 'application/json',
152
130
  'Content-Type': 'application/x-www-form-urlencoded',
153
131
  ...(sessionCookie ? { Cookie: sessionCookie } : {}),
154
132
  },
155
133
  body: new URLSearchParams({
156
134
  submit: 'Sign In',
157
135
  _eventId: 'submit',
158
- username: username,
136
+ username,
159
137
  password,
160
138
  }).toString(),
161
139
  });
package/dist/config.js CHANGED
@@ -23,8 +23,21 @@ export function getAttachmentsDir() {
23
23
  const override = process.env.OFW_ATTACHMENTS_DIR;
24
24
  if (override && override.trim().length > 0)
25
25
  return override.trim();
26
- // Sibling to the cache db: ~/.cache/ofw-mcp/attachments/<hash>/
27
- const username = readUsername();
28
- const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
29
- return join(getCacheDir(), 'attachments', hash);
26
+ // Default to ~/Downloads/ofw-mcp/ — the cache dir (~/.cache/...) is hidden and
27
+ // typically outside the filesystem allowlist of sandboxed MCP hosts like
28
+ // Claude Desktop, so files written there are unreadable to the model that
29
+ // just downloaded them. Downloads is the standard "user-accessible files"
30
+ // location across macOS/Linux/Windows.
31
+ return join(homedir(), 'Downloads', 'ofw-mcp');
32
+ }
33
+ // Default for ofw_download_attachment's `inline` arg when the caller doesn't
34
+ // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
35
+ // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
36
+ // hosts where filesystem reads back to the model aren't available.
37
+ // Accepts: "1", "true", "yes", "on" (case-insensitive) → true; anything else → false.
38
+ export function getDefaultInlineAttachments() {
39
+ const raw = process.env.OFW_INLINE_ATTACHMENTS;
40
+ if (typeof raw !== 'string')
41
+ return false;
42
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
30
43
  }
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
17
17
  import { registerCalendarTools } from './tools/calendar.js';
18
18
  import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.0.10' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.12' });
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
package/dist/sync.js CHANGED
@@ -1,26 +1,30 @@
1
1
  import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
- export async function fetchAndCacheAttachmentMeta(client, fileId, messageId) {
3
- try {
4
- const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
5
- upsertAttachmentForMessage({
6
- fileId: meta.fileId ?? fileId,
7
- fileName: meta.fileName ?? `file-${fileId}`,
8
- label: meta.label ?? meta.fileName ?? `file-${fileId}`,
9
- mimeType: meta.fileType ?? 'application/octet-stream',
10
- sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
11
- metadata: meta,
12
- messageId,
13
- });
14
- }
15
- catch {
16
- // Attachment metadata failures shouldn't break the surrounding sync.
17
- // The file ids stay in the message's listData; the model can retry later
18
- // via ofw_download_attachment, which will surface the actual error.
19
- }
2
+ import { mapRecipients } from './tools/_shared.js';
3
+ // Fetches OFW attachment metadata for one file id and writes it to the cache.
4
+ // Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
5
+ // best-effort helper below; callers that need the result (download tool) let
6
+ // the throw propagate.
7
+ export async function fetchAttachmentMeta(client, fileId, messageId) {
8
+ const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
9
+ upsertAttachmentForMessage({
10
+ fileId: meta.fileId ?? fileId,
11
+ fileName: meta.fileName ?? `file-${fileId}`,
12
+ label: meta.label ?? meta.fileName ?? `file-${fileId}`,
13
+ mimeType: meta.fileType ?? 'application/octet-stream',
14
+ sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
15
+ metadata: meta,
16
+ messageId,
17
+ });
20
18
  }
21
19
  export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
22
20
  for (const fid of fileIds) {
23
- await fetchAndCacheAttachmentMeta(client, fid, messageId);
21
+ // Best-effort: a single bad attachment shouldn't break the surrounding
22
+ // sync. The file id stays in the message's listData; the model can
23
+ // retry later via ofw_download_attachment, which surfaces the real error.
24
+ try {
25
+ await fetchAttachmentMeta(client, fid, messageId);
26
+ }
27
+ catch { /* swallow */ }
24
28
  }
25
29
  }
26
30
  export async function resolveFolderIds(client) {
@@ -40,13 +44,6 @@ export async function resolveFolderIds(client) {
40
44
  setMeta('drafts_folder_id', ids.drafts);
41
45
  return ids;
42
46
  }
43
- function recipientsFromList(item) {
44
- return (item.recipients ?? []).map((r) => ({
45
- userId: r.user.id,
46
- name: r.user.name,
47
- viewedAt: r.viewed?.dateTime ?? null,
48
- }));
49
- }
50
47
  export async function syncMessageFolder(client, folder, folderId, opts) {
51
48
  let page = 1;
52
49
  let synced = 0;
@@ -93,7 +90,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
93
90
  subject: item.subject ?? '(no subject)',
94
91
  fromUser: item.from?.name ?? '',
95
92
  sentAt: item.date?.dateTime ?? new Date().toISOString(),
96
- recipients: recipientsFromList(item),
93
+ recipients: mapRecipients(item.recipients),
97
94
  body,
98
95
  fetchedBodyAt,
99
96
  replyToId: null,
@@ -139,11 +136,7 @@ export async function syncDrafts(client, draftsFolderId) {
139
136
  id: item.id,
140
137
  subject: detail.subject ?? item.subject ?? '(no subject)',
141
138
  body: detail.body ?? '',
142
- recipients: (item.recipients ?? []).map((r) => ({
143
- userId: r.user?.id ?? 0,
144
- name: r.user?.name ?? '',
145
- viewedAt: r.viewed?.dateTime ?? null,
146
- })),
139
+ recipients: mapRecipients(item.recipients),
147
140
  replyToId: item.replyToId ?? null,
148
141
  modifiedAt,
149
142
  listData: item,
@@ -0,0 +1,22 @@
1
+ import { isAbsolute, join, resolve } from 'node:path';
2
+ export function jsonResponse(payload) {
3
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
4
+ }
5
+ export function textResponse(text) {
6
+ return { content: [{ type: 'text', text }] };
7
+ }
8
+ // Translates OFW API recipient shape into the cache's normalized Recipient.
9
+ // Used wherever we surface or persist recipients (sync, get_message, send,
10
+ // save_draft) — all five call sites had near-identical inline mappings.
11
+ export function mapRecipients(items) {
12
+ return (items ?? []).map((r) => ({
13
+ userId: r.user?.id ?? 0,
14
+ name: r.user?.name ?? '',
15
+ viewedAt: r.viewed?.dateTime ?? null,
16
+ }));
17
+ }
18
+ // Expand a user-provided path: ~ → $HOME, relative → absolute.
19
+ export function expandPath(p) {
20
+ const expanded = p.startsWith('~/') ? join(process.env.HOME ?? '', p.slice(2)) : p;
21
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
22
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { jsonResponse, textResponse } from './_shared.js';
2
3
  export function registerCalendarTools(server, client) {
3
4
  server.registerTool('ofw_list_events', {
4
5
  description: 'List OurFamilyWizard calendar events in a date range',
@@ -11,7 +12,7 @@ export function registerCalendarTools(server, client) {
11
12
  }, async (args) => {
12
13
  const variant = args.detailed ? 'detailed' : 'basic';
13
14
  const data = await client.request('GET', `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`);
14
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
15
+ return jsonResponse(data);
15
16
  });
16
17
  server.registerTool('ofw_create_event', {
17
18
  description: 'Create a calendar event in OurFamilyWizard',
@@ -31,7 +32,7 @@ export function registerCalendarTools(server, client) {
31
32
  },
32
33
  }, async (args) => {
33
34
  const data = await client.request('POST', '/pub/v1/calendar/events', args);
34
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
35
+ return jsonResponse(data);
35
36
  });
36
37
  server.registerTool('ofw_update_event', {
37
38
  description: 'Update an existing OurFamilyWizard calendar event',
@@ -49,7 +50,7 @@ export function registerCalendarTools(server, client) {
49
50
  }, async (args) => {
50
51
  const { eventId, ...updateData } = args;
51
52
  const data = await client.request('PUT', `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
52
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
53
+ return jsonResponse(data);
53
54
  });
54
55
  server.registerTool('ofw_delete_event', {
55
56
  description: 'Delete an OurFamilyWizard calendar event',
@@ -59,6 +60,6 @@ export function registerCalendarTools(server, client) {
59
60
  },
60
61
  }, async (args) => {
61
62
  await client.request('DELETE', `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
62
- return { content: [{ type: 'text', text: `Event ${args.eventId} deleted` }] };
63
+ return textResponse(`Event ${args.eventId} deleted`);
63
64
  });
64
65
  }
@@ -1,11 +1,12 @@
1
1
  import { z } from 'zod';
2
+ import { jsonResponse } from './_shared.js';
2
3
  export function registerExpenseTools(server, client) {
3
4
  server.registerTool('ofw_get_expense_totals', {
4
5
  description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
5
6
  annotations: { readOnlyHint: true },
6
7
  }, async () => {
7
8
  const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
8
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
9
+ return jsonResponse(data);
9
10
  });
10
11
  server.registerTool('ofw_list_expenses', {
11
12
  description: 'List OurFamilyWizard expenses with pagination',
@@ -18,7 +19,7 @@ export function registerExpenseTools(server, client) {
18
19
  const start = args.start ?? 0;
19
20
  const max = args.max ?? 20;
20
21
  const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
21
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
22
+ return jsonResponse(data);
22
23
  });
23
24
  server.registerTool('ofw_create_expense', {
24
25
  description: 'Log a new expense in OurFamilyWizard',
@@ -29,6 +30,6 @@ export function registerExpenseTools(server, client) {
29
30
  },
30
31
  }, async (args) => {
31
32
  const data = await client.request('POST', '/pub/v2/expense/expenses', args);
32
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
33
+ return jsonResponse(data);
33
34
  });
34
35
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { jsonResponse } from './_shared.js';
2
3
  export function registerJournalTools(server, client) {
3
4
  server.registerTool('ofw_list_journal_entries', {
4
5
  description: 'List OurFamilyWizard journal entries',
@@ -12,7 +13,7 @@ export function registerJournalTools(server, client) {
12
13
  const start = args.start ?? 1;
13
14
  const max = args.max ?? 10;
14
15
  const data = await client.request('GET', `/pub/v1/journals?start=${start}&max=${max}`);
15
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
16
+ return jsonResponse(data);
16
17
  });
17
18
  server.registerTool('ofw_create_journal_entry', {
18
19
  description: 'Create a new journal entry in OurFamilyWizard',
@@ -23,6 +24,6 @@ export function registerJournalTools(server, client) {
23
24
  },
24
25
  }, async (args) => {
25
26
  const data = await client.request('POST', '/pub/v1/journals', args);
26
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
27
+ return jsonResponse(data);
27
28
  });
28
29
  }