rails_mcp_engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +134 -0
- data/Rakefile +10 -0
- data/app/controllers/rails_mcp_engine/application_controller.rb +5 -0
- data/app/controllers/rails_mcp_engine/chat_controller.rb +110 -0
- data/app/controllers/rails_mcp_engine/playground_controller.rb +183 -0
- data/app/lib/tool_meta.rb +91 -0
- data/app/lib/tool_schema/builder.rb +71 -0
- data/app/lib/tool_schema/fast_mcp_builder.rb +66 -0
- data/app/lib/tool_schema/fast_mcp_factory.rb +49 -0
- data/app/lib/tool_schema/ruby_llm_builder.rb +80 -0
- data/app/lib/tool_schema/ruby_llm_factory.rb +48 -0
- data/app/lib/tool_schema/sorbet_type_mapper.rb +137 -0
- data/app/services/tools/meta_tool_service.rb +165 -0
- data/app/views/rails_mcp_engine/chat/show.html.erb +478 -0
- data/app/views/rails_mcp_engine/playground/show.html.erb +138 -0
- data/config/routes.rb +9 -0
- data/lib/rails_mcp_engine/engine.rb +42 -0
- data/lib/rails_mcp_engine/version.rb +3 -0
- data/lib/rails_mcp_engine.rb +6 -0
- metadata +126 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
<div class="chat-container">
|
|
2
|
+
<div class="chat-header">
|
|
3
|
+
<h2>Chat with LLM</h2>
|
|
4
|
+
<div class="chat-controls">
|
|
5
|
+
<div class="control-group">
|
|
6
|
+
<label for="model-select">Model:</label>
|
|
7
|
+
<select id="model-select">
|
|
8
|
+
<% @models.each do |model| %>
|
|
9
|
+
<option value="<%= model %>" <%= 'selected' if model == 'gpt-4o' %>><%= model %></option>
|
|
10
|
+
<% end %>
|
|
11
|
+
</select>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="control-group">
|
|
14
|
+
<span class="tools-count"><%= @tools.length %> tools available</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="control-group">
|
|
17
|
+
<button id="clear-chat-button" onclick="clearConversation()" class="clear-button">Clear Chat</button>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div class="chat-messages" id="chat-messages">
|
|
23
|
+
<div class="welcome-message">
|
|
24
|
+
<h3>Welcome to LLM Chat Testing</h3>
|
|
25
|
+
<p>This interface allows you to chat with OpenAI models using all registered tools.</p>
|
|
26
|
+
<p><strong>Available tools:</strong></p>
|
|
27
|
+
<ul>
|
|
28
|
+
<% @tools.each do |tool| %>
|
|
29
|
+
<li><code><%= tool[:name] %></code> - <%= tool[:description] %></li>
|
|
30
|
+
<% end %>
|
|
31
|
+
</ul>
|
|
32
|
+
<p>Select a model and start chatting!</p>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div class="chat-input-container">
|
|
37
|
+
<textarea id="chat-input" placeholder="Type your message..." rows="3"></textarea>
|
|
38
|
+
<div class="button-row">
|
|
39
|
+
<button id="send-button" onclick="sendMessage()">Send</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<script>
|
|
46
|
+
let conversationHistory = [];
|
|
47
|
+
|
|
48
|
+
// Load conversation history from localStorage on page load
|
|
49
|
+
function initializeChat() {
|
|
50
|
+
// Restore conversation history
|
|
51
|
+
const savedHistory = localStorage.getItem('chat_conversation_history');
|
|
52
|
+
if (savedHistory) {
|
|
53
|
+
try {
|
|
54
|
+
conversationHistory = JSON.parse(savedHistory);
|
|
55
|
+
// Restore messages to UI
|
|
56
|
+
conversationHistory.forEach(msg => {
|
|
57
|
+
addMessageToUI(msg.role, msg.content);
|
|
58
|
+
});
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error('Failed to load conversation history:', e);
|
|
61
|
+
conversationHistory = [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Restore selected model
|
|
66
|
+
const savedModel = localStorage.getItem('chat_selected_model');
|
|
67
|
+
if (savedModel) {
|
|
68
|
+
const modelSelect = document.getElementById('model-select');
|
|
69
|
+
if (modelSelect) {
|
|
70
|
+
modelSelect.value = savedModel;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Save model selection on change
|
|
75
|
+
const modelSelect = document.getElementById('model-select');
|
|
76
|
+
if (modelSelect) {
|
|
77
|
+
modelSelect.addEventListener('change', (e) => {
|
|
78
|
+
localStorage.setItem('chat_selected_model', e.target.value);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
document.addEventListener('DOMContentLoaded', initializeChat);
|
|
84
|
+
|
|
85
|
+
function setupChatInput() {
|
|
86
|
+
const chatInput = document.getElementById('chat-input');
|
|
87
|
+
if (!chatInput) return;
|
|
88
|
+
|
|
89
|
+
// Idempotent attachment
|
|
90
|
+
if (chatInput.dataset.listenerAttached === 'true') return;
|
|
91
|
+
|
|
92
|
+
chatInput.addEventListener('keydown', handleChatInputKeydown);
|
|
93
|
+
chatInput.dataset.listenerAttached = 'true';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function handleChatInputKeydown(e) {
|
|
97
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
98
|
+
if (e.isComposing) return;
|
|
99
|
+
e.preventDefault();
|
|
100
|
+
sendMessage();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Initialize
|
|
105
|
+
if (document.readyState === 'loading') {
|
|
106
|
+
document.addEventListener('DOMContentLoaded', setupChatInput);
|
|
107
|
+
} else {
|
|
108
|
+
setupChatInput();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendMessage() {
|
|
112
|
+
const input = document.getElementById('chat-input');
|
|
113
|
+
const message = input.value.trim();
|
|
114
|
+
const model = document.getElementById('model-select').value;
|
|
115
|
+
|
|
116
|
+
if (!message) return;
|
|
117
|
+
|
|
118
|
+
// Clear input
|
|
119
|
+
input.value = '';
|
|
120
|
+
|
|
121
|
+
// Add user message to UI
|
|
122
|
+
addMessageToUI('user', message);
|
|
123
|
+
|
|
124
|
+
// Disable send button
|
|
125
|
+
const sendButton = document.getElementById('send-button');
|
|
126
|
+
sendButton.disabled = true;
|
|
127
|
+
sendButton.textContent = 'Sending...';
|
|
128
|
+
|
|
129
|
+
// Send to server
|
|
130
|
+
fetch('<%= chat_send_path %>', {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: {
|
|
133
|
+
'Content-Type': 'application/json',
|
|
134
|
+
'X-CSRF-Token': document.querySelector('[name="csrf-token"]').content
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
message: message,
|
|
138
|
+
model: model,
|
|
139
|
+
conversation_history: JSON.stringify(conversationHistory)
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
.then(response => response.json())
|
|
143
|
+
.then(data => {
|
|
144
|
+
if (data.error) {
|
|
145
|
+
addMessageToUI('error', data.error);
|
|
146
|
+
} else {
|
|
147
|
+
conversationHistory = data.conversation_history;
|
|
148
|
+
|
|
149
|
+
// Save to localStorage
|
|
150
|
+
localStorage.setItem('chat_conversation_history', JSON.stringify(conversationHistory));
|
|
151
|
+
|
|
152
|
+
// Display tool results if any
|
|
153
|
+
if (data.tool_results && data.tool_results.length > 0) {
|
|
154
|
+
data.tool_results.forEach(toolResult => {
|
|
155
|
+
addToolResultToUI(toolResult);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Display final assistant message
|
|
160
|
+
const lastMessage = conversationHistory[conversationHistory.length - 1];
|
|
161
|
+
if (lastMessage && lastMessage.role === 'assistant') {
|
|
162
|
+
addMessageToUI('assistant', lastMessage.content);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
.catch(error => {
|
|
167
|
+
addMessageToUI('error', 'Network error: ' + error.message);
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
sendButton.disabled = false;
|
|
171
|
+
sendButton.textContent = 'Send';
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function addMessageToUI(role, content) {
|
|
176
|
+
const messagesContainer = document.getElementById('chat-messages');
|
|
177
|
+
const messageDiv = document.createElement('div');
|
|
178
|
+
messageDiv.className = `message message-${role}`;
|
|
179
|
+
|
|
180
|
+
const roleLabel = document.createElement('div');
|
|
181
|
+
roleLabel.className = 'message-role';
|
|
182
|
+
roleLabel.textContent = role === 'user' ? 'You' : role === 'assistant' ? 'Assistant' : 'Error';
|
|
183
|
+
|
|
184
|
+
const contentDiv = document.createElement('div');
|
|
185
|
+
contentDiv.className = 'message-content';
|
|
186
|
+
contentDiv.textContent = content || '(no content)';
|
|
187
|
+
|
|
188
|
+
messageDiv.appendChild(roleLabel);
|
|
189
|
+
messageDiv.appendChild(contentDiv);
|
|
190
|
+
messagesContainer.appendChild(messageDiv);
|
|
191
|
+
|
|
192
|
+
// Scroll to bottom
|
|
193
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function addToolResultToUI(toolResult) {
|
|
197
|
+
const messagesContainer = document.getElementById('chat-messages');
|
|
198
|
+
const toolDiv = document.createElement('div');
|
|
199
|
+
toolDiv.className = 'message message-tool';
|
|
200
|
+
|
|
201
|
+
const toolHeader = document.createElement('div');
|
|
202
|
+
toolHeader.className = 'tool-header';
|
|
203
|
+
toolHeader.innerHTML = `<strong>🔧 Tool Call:</strong> <code>${toolResult.name}</code>`;
|
|
204
|
+
|
|
205
|
+
const toolContent = document.createElement('div');
|
|
206
|
+
toolContent.className = 'tool-content';
|
|
207
|
+
|
|
208
|
+
const argsDiv = document.createElement('div');
|
|
209
|
+
argsDiv.innerHTML = `<strong>Arguments:</strong> <pre>${JSON.stringify(toolResult.arguments, null, 2)}</pre>`;
|
|
210
|
+
|
|
211
|
+
const resultDiv = document.createElement('div');
|
|
212
|
+
resultDiv.innerHTML = `<strong>Result:</strong> <pre>${toolResult.content}</pre>`;
|
|
213
|
+
|
|
214
|
+
toolContent.appendChild(argsDiv);
|
|
215
|
+
toolContent.appendChild(resultDiv);
|
|
216
|
+
|
|
217
|
+
toolDiv.appendChild(toolHeader);
|
|
218
|
+
toolDiv.appendChild(toolContent);
|
|
219
|
+
messagesContainer.appendChild(toolDiv);
|
|
220
|
+
|
|
221
|
+
// Scroll to bottom
|
|
222
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function clearConversation() {
|
|
226
|
+
if (confirm('Clear all conversation history?')) {
|
|
227
|
+
conversationHistory = [];
|
|
228
|
+
localStorage.removeItem('chat_conversation_history');
|
|
229
|
+
|
|
230
|
+
// Clear UI - remove all messages except welcome message
|
|
231
|
+
const messagesContainer = document.getElementById('chat-messages');
|
|
232
|
+
const messages = messagesContainer.querySelectorAll('.message');
|
|
233
|
+
messages.forEach(msg => msg.remove());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
</script>
|
|
237
|
+
|
|
238
|
+
<style>
|
|
239
|
+
.chat-container {
|
|
240
|
+
height: calc(100vh - 8rem);
|
|
241
|
+
display: flex;
|
|
242
|
+
flex-direction: column;
|
|
243
|
+
background: white;
|
|
244
|
+
border-radius: 12px;
|
|
245
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.chat-header {
|
|
249
|
+
padding: 1rem 1.5rem;
|
|
250
|
+
border-bottom: 1px solid #e5e7eb;
|
|
251
|
+
background: #f9fafb;
|
|
252
|
+
border-radius: 12px 12px 0 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.chat-header h2 {
|
|
256
|
+
margin: 0 0 0.75rem 0;
|
|
257
|
+
font-size: 1.25rem;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.chat-controls {
|
|
261
|
+
display: flex;
|
|
262
|
+
gap: 1rem;
|
|
263
|
+
flex-wrap: wrap;
|
|
264
|
+
align-items: center;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.control-group {
|
|
268
|
+
display: flex;
|
|
269
|
+
align-items: center;
|
|
270
|
+
gap: 0.5rem;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.control-group label {
|
|
274
|
+
font-size: 0.9rem;
|
|
275
|
+
font-weight: 500;
|
|
276
|
+
color: #374151;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.control-group select {
|
|
280
|
+
padding: 0.4rem 0.6rem;
|
|
281
|
+
border: 1px solid #d1d5db;
|
|
282
|
+
border-radius: 6px;
|
|
283
|
+
font-size: 0.9rem;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.tools-count {
|
|
287
|
+
font-size: 0.85rem;
|
|
288
|
+
color: #6b7280;
|
|
289
|
+
padding: 0.4rem 0.8rem;
|
|
290
|
+
background: #eef2ff;
|
|
291
|
+
border-radius: 6px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.clear-button {
|
|
295
|
+
padding: 0.4rem 0.8rem;
|
|
296
|
+
background: #ef4444;
|
|
297
|
+
color: white;
|
|
298
|
+
border: none;
|
|
299
|
+
border-radius: 6px;
|
|
300
|
+
font-size: 0.85rem;
|
|
301
|
+
font-weight: 500;
|
|
302
|
+
cursor: pointer;
|
|
303
|
+
transition: background 0.2s;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.clear-button:hover {
|
|
307
|
+
background: #dc2626;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.chat-messages {
|
|
311
|
+
flex: 1;
|
|
312
|
+
overflow-y: auto;
|
|
313
|
+
padding: 1.5rem;
|
|
314
|
+
background: #ffffff;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.welcome-message {
|
|
318
|
+
background: #f0f9ff;
|
|
319
|
+
border: 1px solid #bae6fd;
|
|
320
|
+
border-radius: 8px;
|
|
321
|
+
padding: 1.5rem;
|
|
322
|
+
color: #0c4a6e;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.welcome-message h3 {
|
|
326
|
+
margin-top: 0;
|
|
327
|
+
color: #0369a1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.welcome-message ul {
|
|
331
|
+
margin: 0.5rem 0;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.welcome-message code {
|
|
335
|
+
background: #e0f2fe;
|
|
336
|
+
padding: 0.15rem 0.4rem;
|
|
337
|
+
border-radius: 3px;
|
|
338
|
+
font-size: 0.9rem;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.message {
|
|
342
|
+
margin-bottom: 1.5rem;
|
|
343
|
+
animation: fadeIn 0.3s;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
@keyframes fadeIn {
|
|
347
|
+
from { opacity: 0; transform: translateY(10px); }
|
|
348
|
+
to { opacity: 1; transform: translateY(0); }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
.message-role {
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
font-size: 0.85rem;
|
|
354
|
+
margin-bottom: 0.5rem;
|
|
355
|
+
color: #6b7280;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.message-user .message-role {
|
|
359
|
+
color: #3730a3;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.message-assistant .message-role {
|
|
363
|
+
color: #059669;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.message-error .message-role {
|
|
367
|
+
color: #dc2626;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.message-content {
|
|
371
|
+
background: #f3f4f6;
|
|
372
|
+
padding: 0.75rem 1rem;
|
|
373
|
+
border-radius: 8px;
|
|
374
|
+
line-height: 1.6;
|
|
375
|
+
white-space: pre-wrap;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.message-user .message-content {
|
|
379
|
+
background: #eef2ff;
|
|
380
|
+
color: #1e1b4b;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.message-assistant .message-content {
|
|
384
|
+
background: #f0fdf4;
|
|
385
|
+
color: #14532d;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.message-error .message-content {
|
|
389
|
+
background: #fef2f2;
|
|
390
|
+
color: #7f1d1d;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.message-tool {
|
|
394
|
+
background: #fffbeb;
|
|
395
|
+
border: 1px solid #fde68a;
|
|
396
|
+
border-radius: 8px;
|
|
397
|
+
padding: 1rem;
|
|
398
|
+
margin-bottom: 1.5rem;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.tool-header {
|
|
402
|
+
margin-bottom: 0.75rem;
|
|
403
|
+
color: #78350f;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
.tool-header code {
|
|
407
|
+
background: #fef3c7;
|
|
408
|
+
padding: 0.15rem 0.5rem;
|
|
409
|
+
border-radius: 4px;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.tool-content {
|
|
413
|
+
font-size: 0.9rem;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.tool-content pre {
|
|
417
|
+
background: #fefce8;
|
|
418
|
+
padding: 0.5rem;
|
|
419
|
+
border-radius: 4px;
|
|
420
|
+
overflow-x: auto;
|
|
421
|
+
margin: 0.25rem 0;
|
|
422
|
+
font-size: 0.85rem;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.chat-input-container {
|
|
426
|
+
padding: 1rem 1.5rem;
|
|
427
|
+
border-top: 1px solid #e5e7eb;
|
|
428
|
+
background: #f9fafb;
|
|
429
|
+
display: flex;
|
|
430
|
+
flex-direction: column;
|
|
431
|
+
gap: 0.75rem;
|
|
432
|
+
border-radius: 0 0 12px 12px;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.button-row {
|
|
436
|
+
display: flex;
|
|
437
|
+
justify-content: flex-end;
|
|
438
|
+
width: 100%;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
#chat-input {
|
|
443
|
+
width: 100%;
|
|
444
|
+
padding: 0.75rem;
|
|
445
|
+
border: 1px solid #d1d5db;
|
|
446
|
+
border-radius: 8px;
|
|
447
|
+
font-family: inherit;
|
|
448
|
+
font-size: 0.95rem;
|
|
449
|
+
resize: none;
|
|
450
|
+
box-sizing: border-box;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#chat-input:focus {
|
|
454
|
+
outline: none;
|
|
455
|
+
border-color: #3b82f6;
|
|
456
|
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#send-button {
|
|
460
|
+
padding: 0.75rem 1.5rem;
|
|
461
|
+
background: #3b82f6;
|
|
462
|
+
color: white;
|
|
463
|
+
border: none;
|
|
464
|
+
border-radius: 8px;
|
|
465
|
+
font-weight: 600;
|
|
466
|
+
cursor: pointer;
|
|
467
|
+
transition: background 0.2s;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#send-button:hover:not(:disabled) {
|
|
471
|
+
background: #2563eb;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#send-button:disabled {
|
|
475
|
+
background: #9ca3af;
|
|
476
|
+
cursor: not-allowed;
|
|
477
|
+
}
|
|
478
|
+
</style>
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
|
|
2
|
+
<div class="card">
|
|
3
|
+
<h2>Registered tools</h2>
|
|
4
|
+
<% if @tools.empty? %>
|
|
5
|
+
<p>No tools have been registered yet. Start by adding one above.</p>
|
|
6
|
+
<% else %>
|
|
7
|
+
<ul class="tool-list">
|
|
8
|
+
<% @tools.each_with_index do |tool, index| %>
|
|
9
|
+
<li class="tool-item">
|
|
10
|
+
<div class="tool-header">
|
|
11
|
+
<div onclick="toggleToolDetails(<%= index %>)" style="flex: 1; display: flex; align-items: center; cursor: pointer;">
|
|
12
|
+
<span class="pill"><%= tool[:name] %></span>
|
|
13
|
+
<strong><%= tool[:description] %></strong>
|
|
14
|
+
<span class="expand-icon" id="expand-icon-<%= index %>">▼</span>
|
|
15
|
+
</div>
|
|
16
|
+
<%= button_to 'Delete', playground_delete_tool_path(tool[:name]),
|
|
17
|
+
method: :delete,
|
|
18
|
+
class: 'delete-tool-btn',
|
|
19
|
+
data: { confirm: "Are you sure you want to delete '#{tool[:name]}'?" } %>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="tool-details" id="tool-details-<%= index %>" style="display: none;">
|
|
22
|
+
<div class="detail-section">
|
|
23
|
+
<h4>Parameters</h4>
|
|
24
|
+
<% if tool[:params].empty? %>
|
|
25
|
+
<p class="no-params">No parameters</p>
|
|
26
|
+
<% else %>
|
|
27
|
+
<table class="params-table">
|
|
28
|
+
<thead>
|
|
29
|
+
<tr>
|
|
30
|
+
<th>Name</th>
|
|
31
|
+
<th>Type</th>
|
|
32
|
+
<th>Required</th>
|
|
33
|
+
<th>Description</th>
|
|
34
|
+
</tr>
|
|
35
|
+
</thead>
|
|
36
|
+
<tbody>
|
|
37
|
+
<% tool[:params].each do |param| %>
|
|
38
|
+
<tr>
|
|
39
|
+
<td><code><%= param[:name] %></code></td>
|
|
40
|
+
<td><span class="type-badge"><%= param[:type] %></span></td>
|
|
41
|
+
<td><%= param[:required] ? '✓' : '✗' %></td>
|
|
42
|
+
<td><%= param[:description] || '-' %></td>
|
|
43
|
+
</tr>
|
|
44
|
+
<% if param[:enum].present? %>
|
|
45
|
+
<tr class="enum-row">
|
|
46
|
+
<td colspan="4">
|
|
47
|
+
<strong>Allowed values:</strong> <%= param[:enum].join(', ') %>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
<% end %>
|
|
51
|
+
<% if param[:children].present? %>
|
|
52
|
+
<tr class="children-row">
|
|
53
|
+
<td colspan="4">
|
|
54
|
+
<strong>Nested fields:</strong>
|
|
55
|
+
<ul class="nested-params">
|
|
56
|
+
<% param[:children].each do |child| %>
|
|
57
|
+
<li>
|
|
58
|
+
<code><%= child[:name] %></code>
|
|
59
|
+
(<span class="type-badge"><%= child[:type] %></span>)
|
|
60
|
+
<%= child[:required] ? '[required]' : '[optional]' %>
|
|
61
|
+
- <%= child[:description] || 'No description' %>
|
|
62
|
+
</li>
|
|
63
|
+
<% end %>
|
|
64
|
+
</ul>
|
|
65
|
+
</td>
|
|
66
|
+
</tr>
|
|
67
|
+
<% end %>
|
|
68
|
+
<% end %>
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
<% end %>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="detail-section">
|
|
74
|
+
<h4>Return Type</h4>
|
|
75
|
+
<p><span class="type-badge"><%= tool[:return_type][:type] %></span></p>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="detail-section">
|
|
78
|
+
<h4>Full Schema</h4>
|
|
79
|
+
<pre><%= JSON.pretty_generate(tool.except(:service_class)) %></pre>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</li>
|
|
83
|
+
<% end %>
|
|
84
|
+
</ul>
|
|
85
|
+
<% end %>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<div class="card">
|
|
89
|
+
<h2>Register a tool service</h2>
|
|
90
|
+
<p>Paste a Ruby class that extends <code>ToolMeta</code> and has a Sorbet signature. The controller will evaluate it and register both RubyLLM and FastMCP tool wrappers.</p>
|
|
91
|
+
<%= form_with url: playground_register_path, method: :post do %>
|
|
92
|
+
<label for="source">Tool class source code</label><br>
|
|
93
|
+
<%= text_area_tag :source, params[:source], placeholder: "class Tools::EchoService\n extend T::Sig\n extend ToolMeta\n\n tool_description 'Echoes the provided payload'\n tool_param :message, description: 'Message to return'\n\n sig { params(message: String).returns(String) }\n def call(message:)\n message\n end\nend" %>
|
|
94
|
+
<div class="actions">
|
|
95
|
+
<%= submit_tag 'Register tool' %>
|
|
96
|
+
</div>
|
|
97
|
+
<% end %>
|
|
98
|
+
|
|
99
|
+
<% if @register_result.present? %>
|
|
100
|
+
<h3>Registration response</h3>
|
|
101
|
+
<pre><%= JSON.pretty_generate(@register_result) %></pre>
|
|
102
|
+
<% end %>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="card">
|
|
106
|
+
<h2>Run a tool</h2>
|
|
107
|
+
<p>Select a tool and provide JSON arguments to send through the generated RubyLLM wrapper.</p>
|
|
108
|
+
<%= form_with url: playground_run_path, method: :post do %>
|
|
109
|
+
<label for="tool_name">Tool name</label><br>
|
|
110
|
+
<%= select_tag :tool_name, options_for_select(@tools.map { |schema| [schema[:name], schema[:name]] }, params[:tool_name]), prompt: 'Choose a tool' %>
|
|
111
|
+
<br><br>
|
|
112
|
+
<label for="arguments">Arguments (JSON)</label><br>
|
|
113
|
+
<%= text_area_tag :arguments, params[:arguments], placeholder: '{"message": "Hello"}' %>
|
|
114
|
+
<div class="actions">
|
|
115
|
+
<%= submit_tag 'Run tool' %>
|
|
116
|
+
</div>
|
|
117
|
+
<% end %>
|
|
118
|
+
|
|
119
|
+
<% if @test_result.present? %>
|
|
120
|
+
<h3>Execution response</h3>
|
|
121
|
+
<pre><%= JSON.pretty_generate(@test_result) %></pre>
|
|
122
|
+
<% end %>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
<script>
|
|
126
|
+
function toggleToolDetails(index) {
|
|
127
|
+
const details = document.getElementById('tool-details-' + index);
|
|
128
|
+
const icon = document.getElementById('expand-icon-' + index);
|
|
129
|
+
|
|
130
|
+
if (details.style.display === 'none') {
|
|
131
|
+
details.style.display = 'block';
|
|
132
|
+
icon.textContent = '▲';
|
|
133
|
+
} else {
|
|
134
|
+
details.style.display = 'none';
|
|
135
|
+
icon.textContent = '▼';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
</script>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
RailsMcpEngine::Engine.routes.draw do
|
|
2
|
+
get 'playground', to: 'playground#show', as: :playground
|
|
3
|
+
post 'playground/register', to: 'playground#register', as: :playground_register
|
|
4
|
+
post 'playground/run', to: 'playground#run', as: :playground_run
|
|
5
|
+
delete 'playground/delete/:tool_name', to: 'playground#delete_tool', as: :playground_delete_tool
|
|
6
|
+
|
|
7
|
+
get 'chat', to: 'chat#show', as: :chat
|
|
8
|
+
post 'chat/send', to: 'chat#send_message', as: :chat_send
|
|
9
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require 'rails'
|
|
2
|
+
require 'ruby_llm'
|
|
3
|
+
require 'fast_mcp'
|
|
4
|
+
|
|
5
|
+
module RailsMcpEngine
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace RailsMcpEngine
|
|
8
|
+
|
|
9
|
+
# Define the base class expected by the engine, inheriting from the real gem
|
|
10
|
+
# This ensures ApplicationTool is available to the host app and the engine
|
|
11
|
+
initializer 'rails_mcp_engine.define_base_class' do
|
|
12
|
+
unless defined?(::ApplicationTool)
|
|
13
|
+
class ::ApplicationTool < FastMcp::Tool
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add engine directories to LOAD_PATH so internal requires work
|
|
19
|
+
config.before_configuration do
|
|
20
|
+
$LOAD_PATH.unshift root.join('app/lib').to_s
|
|
21
|
+
$LOAD_PATH.unshift root.join('app/services').to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Trigger tool generation on boot and reload
|
|
25
|
+
config.to_prepare do
|
|
26
|
+
RailsMcpEngine::Engine.build_tools!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.build_tools!
|
|
30
|
+
# Ensure all services are loaded so they register in ToolMeta
|
|
31
|
+
# This is critical for development mode where autoloading is lazy
|
|
32
|
+
# and for test mode where eager_load is false
|
|
33
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
34
|
+
|
|
35
|
+
ToolMeta.registry.each do |service_class|
|
|
36
|
+
schema = ToolSchema::Builder.build(service_class)
|
|
37
|
+
ToolSchema::RubyLlmFactory.build(service_class, schema)
|
|
38
|
+
ToolSchema::FastMcpFactory.build(service_class, schema)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|