cf-mcp 0.9.2

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,292 @@
1
+ import { h, render } from 'https://esm.sh/preact@10';
2
+ import { useState, useEffect, useRef, useCallback } from 'https://esm.sh/preact@10/hooks';
3
+ import htm from 'https://esm.sh/htm@3';
4
+
5
+ const html = htm.bind(h);
6
+
7
+ const TOOLS = TOOL_SCHEMAS_PLACEHOLDER;
8
+ const CATEGORIES = CATEGORIES_PLACEHOLDER;
9
+ const TOPICS = TOPICS_PLACEHOLDER;
10
+ const CHANGELOG = CHANGELOG_PLACEHOLDER;
11
+
12
+ // Helper to render topic options (used in multiple places)
13
+ const renderTopicOptions = () =>
14
+ TOPICS.map(topic => html`<option value=${topic.name}>${topic.name.replace(/_/g, ' ')}</option>`);
15
+
16
+ // Reusable select field component
17
+ function SelectField({ id, label, value, onChange, required, placeholder, options }) {
18
+ return html`
19
+ <div class="form-group">
20
+ <label class="form-label" for=${id}>${label}</label>
21
+ <select id=${id} class="form-select" value=${value} onChange=${onChange} required=${required}>
22
+ <option value="">${placeholder}</option>
23
+ ${options}
24
+ </select>
25
+ </div>
26
+ `;
27
+ }
28
+
29
+ // Custom hook for MCP tool calls
30
+ function useMcpToolCall() {
31
+ const [result, setResult] = useState({ loading: false, error: null, content: null });
32
+
33
+ const callTool = useCallback(async (toolName, args = {}, loadingText = 'Loading...') => {
34
+ setResult({ loading: true, loadingText, error: null, content: null });
35
+
36
+ try {
37
+ const response = await fetch('/http', {
38
+ method: 'POST',
39
+ headers: {
40
+ 'Content-Type': 'application/json',
41
+ 'Accept': 'application/json, text/event-stream'
42
+ },
43
+ body: JSON.stringify({
44
+ jsonrpc: '2.0',
45
+ id: Date.now(),
46
+ method: 'tools/call',
47
+ params: { name: toolName, arguments: args }
48
+ })
49
+ });
50
+
51
+ const data = await response.json();
52
+
53
+ if (data.error) {
54
+ setResult({ loading: false, error: 'Error: ' + (data.error.message || JSON.stringify(data.error)), content: null });
55
+ } else if (data.result?.content) {
56
+ const content = data.result.content[0];
57
+ const text = content.text || JSON.stringify(content);
58
+ setResult({
59
+ loading: false,
60
+ error: data.result.isError ? text : null,
61
+ content: data.result.isError ? null : text
62
+ });
63
+ } else {
64
+ setResult({ loading: false, error: null, content: JSON.stringify(data, null, 2) });
65
+ }
66
+ } catch (err) {
67
+ setResult({ loading: false, error: 'Network error: ' + err.message, content: null });
68
+ }
69
+ }, []);
70
+
71
+ const reset = useCallback(() => {
72
+ setResult({ loading: false, error: null, content: null });
73
+ }, []);
74
+
75
+ return { result, callTool, reset };
76
+ }
77
+
78
+ // Shared ResultArea component
79
+ function ResultArea({ result }) {
80
+ const containerRef = useRef(null);
81
+
82
+ useEffect(() => {
83
+ if (result.content && containerRef.current) {
84
+ containerRef.current.innerHTML = marked.parse(result.content);
85
+ convertListsToDefinitionLists(containerRef.current);
86
+ }
87
+ }, [result.content]);
88
+
89
+ if (result.loading) {
90
+ return html`<div class="result-area visible"><span class="loading">${result.loadingText || 'Loading...'}</span></div>`;
91
+ }
92
+
93
+ if (result.error) {
94
+ return html`<div class="result-area visible error">${result.error}</div>`;
95
+ }
96
+
97
+ if (result.content) {
98
+ return html`<div class="result-area visible success" ref=${containerRef}></div>`;
99
+ }
100
+
101
+ return html`<div class="result-area"></div>`;
102
+ }
103
+
104
+ function convertListsToDefinitionLists(container) {
105
+ const lists = container.querySelectorAll('ul');
106
+ lists.forEach(ul => {
107
+ const items = ul.querySelectorAll('li');
108
+ const isDefinitionList = Array.from(items).every(li => li.innerHTML.includes(' — '));
109
+ if (!isDefinitionList || items.length === 0) return;
110
+
111
+ const dl = document.createElement('dl');
112
+ items.forEach(li => {
113
+ const parts = li.innerHTML.split(' — ');
114
+ if (parts.length >= 2) {
115
+ const dt = document.createElement('dt');
116
+ dt.innerHTML = parts[0];
117
+ const dd = document.createElement('dd');
118
+ dd.innerHTML = parts.slice(1).join(' — ');
119
+ dl.appendChild(dt);
120
+ dl.appendChild(dd);
121
+ }
122
+ });
123
+ ul.replaceWith(dl);
124
+ });
125
+ }
126
+
127
+ // Helper to create a select input
128
+ function renderSelect(id, value, onChange, required, placeholder, options) {
129
+ return html`
130
+ <select id=${id} class="form-select" value=${value} onChange=${onChange} required=${required}>
131
+ <option value="">${placeholder}</option>
132
+ ${options}
133
+ </select>
134
+ `;
135
+ }
136
+
137
+ // Form field component
138
+ function FormField({ name, propDef, required, value, onChange, toolName }) {
139
+ const id = `field-${name}`;
140
+ const description = propDef.description || '';
141
+ const handleChange = e => onChange(name, e.target.value);
142
+
143
+ let input;
144
+
145
+ if (propDef.enum) {
146
+ input = renderSelect(id, value, handleChange, required, '-- Select --',
147
+ propDef.enum.map(val => html`<option value=${val}>${val}</option>`));
148
+ } else if (propDef.type === 'integer') {
149
+ const attrs = name === 'limit' ? { min: 1, max: 20 } : {};
150
+ input = html`
151
+ <input
152
+ type="number"
153
+ id=${id}
154
+ class="form-input"
155
+ placeholder=${description}
156
+ value=${value}
157
+ onChange=${e => onChange(name, e.target.value ? parseInt(e.target.value, 10) : '')}
158
+ required=${required}
159
+ ...${attrs}
160
+ />
161
+ `;
162
+ } else if (name === 'category') {
163
+ input = renderSelect(id, value, handleChange, required, '-- All Categories --',
164
+ CATEGORIES.map(cat => html`<option value=${cat}>${cat}</option>`));
165
+ } else if (name === 'name' && toolName === 'cf_get_topic') {
166
+ input = renderSelect(id, value, handleChange, required, '-- Select Topic --', renderTopicOptions());
167
+ } else {
168
+ input = html`
169
+ <input
170
+ type="text"
171
+ id=${id}
172
+ class="form-input"
173
+ placeholder=${description}
174
+ value=${value}
175
+ onInput=${e => onChange(name, e.target.value)}
176
+ required=${required}
177
+ />
178
+ `;
179
+ }
180
+
181
+ return html`
182
+ <div class="form-group">
183
+ <label class="form-label" for=${id}>
184
+ ${name} ${required && html`<span class="required">*</span>`}
185
+ </label>
186
+ ${input}
187
+ ${description && html`<div class="form-hint">${description}</div>`}
188
+ </div>
189
+ `;
190
+ }
191
+
192
+ // Tool Explorer component
193
+ function ToolExplorer() {
194
+ const [selectedTool, setSelectedTool] = useState(TOOLS[0]?.name || '');
195
+ const [formValues, setFormValues] = useState({ limit: 20 });
196
+ const { result, callTool, reset } = useMcpToolCall();
197
+
198
+ const tool = TOOLS.find(t => t.name === selectedTool);
199
+ const schema = tool?.inputSchema || {};
200
+ const properties = schema.properties || {};
201
+ const required = schema.required || [];
202
+
203
+ const handleFieldChange = (name, value) => {
204
+ setFormValues(prev => ({ ...prev, [name]: value }));
205
+ };
206
+
207
+ const handleToolChange = (e) => {
208
+ setSelectedTool(e.target.value);
209
+ reset();
210
+ };
211
+
212
+ const handleSubmit = async (e) => {
213
+ e.preventDefault();
214
+
215
+ const args = {};
216
+ for (const [name, value] of Object.entries(formValues)) {
217
+ if (value !== '' && value !== undefined && properties[name]) {
218
+ args[name] = value;
219
+ }
220
+ }
221
+
222
+ callTool(selectedTool, args, 'Executing...');
223
+ };
224
+
225
+ return html`
226
+ <div class="explorer">
227
+ <form onSubmit=${handleSubmit}>
228
+ <${SelectField}
229
+ id="tool-select"
230
+ label="Select Tool"
231
+ value=${selectedTool}
232
+ onChange=${handleToolChange}
233
+ placeholder="-- Select Tool --"
234
+ options=${TOOLS.map(t => html`<option value=${t.name}>${t.name}</option>`)}
235
+ />
236
+ ${Object.entries(properties).map(([propName, propDef]) => html`
237
+ <${FormField}
238
+ key=${propName}
239
+ name=${propName}
240
+ propDef=${propDef}
241
+ required=${required.includes(propName)}
242
+ value=${formValues[propName] ?? (propName === 'limit' ? 20 : '')}
243
+ onChange=${handleFieldChange}
244
+ toolName=${selectedTool}
245
+ />
246
+ `)}
247
+ <button type="submit" class="btn-execute" disabled=${result.loading}>Execute</button>
248
+ </form>
249
+ <${ResultArea} result=${result} />
250
+ </div>
251
+ `;
252
+ }
253
+
254
+ // Topics Explorer component
255
+ function TopicsExplorer() {
256
+ const [selectedTopic, setSelectedTopic] = useState('');
257
+ const { result, callTool, reset } = useMcpToolCall();
258
+
259
+ const handleChange = (e) => {
260
+ const value = e.target.value;
261
+ setSelectedTopic(value);
262
+ if (value) {
263
+ callTool('cf_get_topic', { name: value }, 'Loading topic...');
264
+ } else {
265
+ reset();
266
+ }
267
+ };
268
+
269
+ return html`
270
+ <div class="explorer">
271
+ <${SelectField}
272
+ id="topic-select"
273
+ label="Select Topic"
274
+ value=${selectedTopic}
275
+ onChange=${handleChange}
276
+ placeholder="-- Select a topic --"
277
+ options=${renderTopicOptions()}
278
+ />
279
+ <${ResultArea} result=${result} />
280
+ </div>
281
+ `;
282
+ }
283
+
284
+ // Mount components
285
+ render(html`<${ToolExplorer} />`, document.getElementById('tool-explorer-root'));
286
+ render(html`<${TopicsExplorer} />`, document.getElementById('topics-explorer-root'));
287
+
288
+ // Render changelog
289
+ const changelogEl = document.getElementById('changelog-content');
290
+ if (changelogEl && CHANGELOG) {
291
+ changelogEl.innerHTML = marked.parse(CHANGELOG);
292
+ }
@@ -0,0 +1,165 @@
1
+ * { box-sizing: border-box; }
2
+ body {
3
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
4
+ line-height: 1.6;
5
+ max-width: 800px;
6
+ margin: 0 auto;
7
+ padding: 2rem;
8
+ background: #0d1117;
9
+ color: #c9d1d9;
10
+ }
11
+ h1 { color: #58a6ff; margin-bottom: 0.5rem; }
12
+ h2 { color: #58a6ff; margin-top: 2rem; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }
13
+ a { color: #58a6ff; }
14
+ code {
15
+ background: #161b22;
16
+ padding: 0.2rem 0.4rem;
17
+ border-radius: 4px;
18
+ font-size: 0.9em;
19
+ }
20
+ pre {
21
+ background: #161b22;
22
+ padding: 1rem;
23
+ border-radius: 8px;
24
+ overflow-x: auto;
25
+ border: 1px solid #30363d;
26
+ }
27
+ pre code { background: none; padding: 0; }
28
+ .stats {
29
+ display: grid;
30
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
31
+ gap: 1rem;
32
+ margin: 1rem 0;
33
+ }
34
+ .stat {
35
+ background: #161b22;
36
+ padding: 1rem;
37
+ border-radius: 8px;
38
+ text-align: center;
39
+ border: 1px solid #30363d;
40
+ }
41
+ .stat-value { font-size: 2rem; font-weight: bold; color: #58a6ff; }
42
+ .stat-label { color: #8b949e; font-size: 0.9rem; }
43
+ .endpoint {
44
+ background: #161b22;
45
+ padding: 1rem;
46
+ border-radius: 8px;
47
+ margin: 0.5rem 0;
48
+ border: 1px solid #30363d;
49
+ }
50
+ .endpoint-path { font-weight: bold; color: #7ee787; }
51
+ .endpoint-desc { color: #8b949e; margin-top: 0.25rem; }
52
+ .tools { margin: 1rem 0; }
53
+ .tool {
54
+ background: #161b22;
55
+ padding: 0.75rem 1rem;
56
+ border-radius: 8px;
57
+ margin: 0.5rem 0;
58
+ border: 1px solid #30363d;
59
+ }
60
+ .tool-name { font-weight: bold; color: #ffa657; }
61
+ .tool-desc { color: #8b949e; font-size: 0.9rem; }
62
+ .explorer {
63
+ background: #161b22;
64
+ border: 1px solid #30363d;
65
+ border-radius: 8px;
66
+ padding: 1.5rem;
67
+ margin: 1rem 0;
68
+ }
69
+ .form-group {
70
+ margin-bottom: 1rem;
71
+ }
72
+ .form-label {
73
+ display: block;
74
+ margin-bottom: 0.25rem;
75
+ color: #c9d1d9;
76
+ font-size: 0.9rem;
77
+ }
78
+ .form-label .required {
79
+ color: #f85149;
80
+ }
81
+ .form-input, .form-select {
82
+ width: 100%;
83
+ padding: 0.5rem 0.75rem;
84
+ background: #0d1117;
85
+ border: 1px solid #30363d;
86
+ border-radius: 6px;
87
+ color: #c9d1d9;
88
+ font-size: 0.9rem;
89
+ }
90
+ .form-input:focus, .form-select:focus {
91
+ outline: none;
92
+ border-color: #58a6ff;
93
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.3);
94
+ }
95
+ .form-input::placeholder {
96
+ color: #6e7681;
97
+ }
98
+ .form-hint {
99
+ font-size: 0.8rem;
100
+ color: #6e7681;
101
+ margin-top: 0.25rem;
102
+ }
103
+ .btn-execute {
104
+ background: #238636;
105
+ color: #fff;
106
+ border: none;
107
+ padding: 0.75rem 1.5rem;
108
+ border-radius: 6px;
109
+ font-size: 1rem;
110
+ cursor: pointer;
111
+ font-weight: 500;
112
+ }
113
+ .btn-execute:hover {
114
+ background: #2ea043;
115
+ }
116
+ .btn-execute:disabled {
117
+ background: #21262d;
118
+ color: #6e7681;
119
+ cursor: not-allowed;
120
+ }
121
+ .result-area {
122
+ background: #0d1117;
123
+ border: 1px solid #30363d;
124
+ border-radius: 6px;
125
+ padding: 1rem;
126
+ margin-top: 1rem;
127
+ word-wrap: break-word;
128
+ font-size: 0.9rem;
129
+ max-height: 400px;
130
+ overflow-y: auto;
131
+ display: none;
132
+ }
133
+ .result-area.visible {
134
+ display: block;
135
+ }
136
+ .result-area.error {
137
+ border-color: #f85149;
138
+ color: #f85149;
139
+ }
140
+ .result-area.success {
141
+ border-color: #238636;
142
+ }
143
+ .loading {
144
+ color: #6e7681;
145
+ font-style: italic;
146
+ }
147
+ /* Markdown content styles */
148
+ .result-area h1 { font-size: 1.4em; color: #58a6ff; margin: 0 0 0.5em 0; border-bottom: 1px solid #30363d; padding-bottom: 0.3em; }
149
+ .result-area h2 { font-size: 1.2em; color: #58a6ff; margin: 1em 0 0.5em 0; }
150
+ .result-area h3 { font-size: 1.1em; color: #58a6ff; margin: 1em 0 0.5em 0; }
151
+ .result-area p { margin: 0.5em 0; }
152
+ .result-area strong { color: #ffa657; }
153
+ .result-area code { background: #21262d; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
154
+ .result-area pre { background: #21262d; padding: 0.75em; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; }
155
+ .result-area pre code { background: none; padding: 0; }
156
+ .result-area table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
157
+ .result-area th, .result-area td { border: 1px solid #30363d; padding: 0.4em 0.6em; text-align: left; }
158
+ .result-area th { background: #21262d; color: #58a6ff; }
159
+ .result-area ul, .result-area ol { margin: 0.5em 0; padding-left: 1.5em; }
160
+ .result-area li { margin: 0.25em 0; }
161
+ .result-area a { color: #58a6ff; }
162
+ .result-area dl { margin: 0.5em 0; }
163
+ .result-area dt { color: #c9d1d9; margin-top: 0.75em; padding-bottom: 0.25em; border-bottom: 1px solid #21262d; }
164
+ .result-area dt:first-child { margin-top: 0; }
165
+ .result-area dd { margin: 0.25em 0 0 1em; color: #8b949e; }
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module CF
6
+ module MCP
7
+ module Tools
8
+ class FindRelated < ::MCP::Tool
9
+ tool_name "cf_find_related"
10
+ description "Find all items related to a given Cute Framework item (bidirectional relationship search)"
11
+
12
+ input_schema(
13
+ type: "object",
14
+ properties: {
15
+ name: {type: "string", description: "Name of the item to find relations for (e.g., 'CF_Sprite', 'cf_make_sprite')"}
16
+ },
17
+ required: ["name"]
18
+ )
19
+
20
+ def self.call(name:, server_context: {})
21
+ index = server_context[:index]
22
+ return error_response("Index not available") unless index
23
+
24
+ item = index.find(name)
25
+ return text_response("Not found: '#{name}'") unless item
26
+
27
+ # Forward references: items this item references
28
+ forward_refs = (item.related || []).map { |ref_name|
29
+ ref_item = index.find(ref_name)
30
+ if ref_item
31
+ "- `#{ref_item.name}` (#{ref_item.type}) — #{ref_item.brief}"
32
+ else
33
+ "- `#{ref_name}` (not found in index)"
34
+ end
35
+ }
36
+
37
+ # Back references: items that reference this item
38
+ back_refs = []
39
+ index.items.each_value do |other_item|
40
+ next if other_item.name == name
41
+ next unless other_item.related&.include?(name)
42
+
43
+ back_refs << "- `#{other_item.name}` (#{other_item.type}) — #{other_item.brief}"
44
+ end
45
+
46
+ if forward_refs.empty? && back_refs.empty?
47
+ return text_response("# #{name}\n\nNo related items found.\n\n**Tip:** Not all items have explicit relationships documented.")
48
+ end
49
+
50
+ lines = ["# Related items for #{name}", ""]
51
+
52
+ unless forward_refs.empty?
53
+ lines << "## References (items this references)"
54
+ lines.concat(forward_refs)
55
+ lines << ""
56
+ end
57
+
58
+ unless back_refs.empty?
59
+ lines << "## Referenced by (items that reference this)"
60
+ lines.concat(back_refs)
61
+ lines << ""
62
+ end
63
+
64
+ text_response(lines.join("\n"))
65
+ end
66
+
67
+ def self.text_response(text)
68
+ ::MCP::Tool::Response.new([{type: "text", text: text}])
69
+ end
70
+
71
+ def self.error_response(message)
72
+ ::MCP::Tool::Response.new([{type: "text", text: "Error: #{message}"}], error: true)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module CF
6
+ module MCP
7
+ module Tools
8
+ class GetDetails < ::MCP::Tool
9
+ tool_name "cf_get_details"
10
+ description "Get detailed documentation for a specific Cute Framework item by exact name"
11
+
12
+ input_schema(
13
+ type: "object",
14
+ properties: {
15
+ name: {type: "string", description: "Exact name of the item (e.g., 'cf_make_app', 'CF_Sprite', 'CF_PlayDirection')"}
16
+ },
17
+ required: ["name"]
18
+ )
19
+
20
+ NAMING_TIP = "**Tip:** Cute Framework uses `cf_` prefix for functions and `CF_` prefix for types (structs/enums)."
21
+
22
+ def self.call(name:, server_context: {})
23
+ index = server_context[:index]
24
+ return error_response("Index not available") unless index
25
+
26
+ item = index.find(name)
27
+
28
+ if item.nil?
29
+ # Try a fuzzy search to suggest alternatives
30
+ suggestions = index.search(name, limit: 5)
31
+ if suggestions.empty?
32
+ text_response("Not found: '#{name}'\n\n#{NAMING_TIP}")
33
+ else
34
+ formatted = suggestions.map { |s| "- `#{s.name}` (#{s.type}) — #{s.brief}" }.join("\n")
35
+ text_response("Not found: '#{name}'\n\n**Similar items:**\n#{formatted}\n\n#{NAMING_TIP}")
36
+ end
37
+ else
38
+ output = item.to_text(detailed: true, index: index)
39
+
40
+ # Append related topics section for API items
41
+ if item.type != :topic
42
+ related_topics = index.topics_for(name)
43
+ if related_topics.any?
44
+ output += "\n\n## Related Topics\n"
45
+ output += related_topics.map { |t| "- **#{t.name}** — #{t.brief}" }.join("\n")
46
+ output += "\n\n**Tip:** Use `cf_get_topic` to read the full topic content."
47
+ end
48
+ end
49
+
50
+ text_response(output)
51
+ end
52
+ end
53
+
54
+ def self.text_response(text)
55
+ ::MCP::Tool::Response.new([{type: "text", text: text}])
56
+ end
57
+
58
+ def self.error_response(message)
59
+ ::MCP::Tool::Response.new([{type: "text", text: "Error: #{message}"}], error: true)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module CF
6
+ module MCP
7
+ module Tools
8
+ class GetTopic < ::MCP::Tool
9
+ tool_name "cf_get_topic"
10
+ description "Get the full content of a Cute Framework topic guide document"
11
+
12
+ input_schema(
13
+ type: "object",
14
+ properties: {
15
+ name: {type: "string", description: "Topic name (e.g., 'audio', 'collision', 'drawing', 'coroutines')"}
16
+ },
17
+ required: ["name"]
18
+ )
19
+
20
+ def self.call(name:, server_context: {})
21
+ index = server_context[:index]
22
+ return error_response("Index not available") unless index
23
+
24
+ topic = index.find(name)
25
+
26
+ if topic.nil? || topic.type != :topic
27
+ # Try fuzzy match on topic names
28
+ suggestions = index.topics.select { |t|
29
+ t.name.include?(name) || name.include?(t.name) || t.name.delete("_").include?(name.delete("_"))
30
+ }
31
+
32
+ if suggestions.empty?
33
+ text_response("Topic not found: '#{name}'\n\nUse `cf_list_topics` to see available topics.")
34
+ else
35
+ formatted = suggestions.map { |t| "- **#{t.name}** — #{t.brief}" }.join("\n")
36
+ text_response("Topic not found: '#{name}'\n\n**Similar topics:**\n#{formatted}")
37
+ end
38
+ else
39
+ text_response(topic.to_text(detailed: true, index: index))
40
+ end
41
+ end
42
+
43
+ def self.text_response(text)
44
+ ::MCP::Tool::Response.new([{type: "text", text: text}])
45
+ end
46
+
47
+ def self.error_response(message)
48
+ ::MCP::Tool::Response.new([{type: "text", text: "Error: #{message}"}], error: true)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end