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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module RailsMcpEngine
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,6 @@
1
+ require 'rails_mcp_engine/version'
2
+ require 'rails_mcp_engine/engine'
3
+
4
+ module RailsMcpEngine
5
+ # Your code goes here...
6
+ end