Skip to content

Commit 9b15825

Browse files
committed
add mcp in and out nodes
1 parent b39ea53 commit 9b15825

File tree

15 files changed

+540
-34
lines changed

15 files changed

+540
-34
lines changed

src/context/RuntimeContext.jsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function RuntimeProvider({ children }) {
1919
const [isRunning, setIsRunning] = useState(false);
2020
const [nodeStatuses, setNodeStatuses] = useState({});
2121
const [mcpStatus, setMcpStatus] = useState('disabled'); // disabled, connecting, connected, error
22+
const mcpMessagesRef = useRef([]); // Queue for mcp-output node messages
2223
const [hasCanvasNodes, setHasCanvasNodes] = useState(false);
2324
const [hasButtonsNodes, setHasButtonsNodes] = useState(false)
2425
const { addMessage, addDownload, addError, clear, clearErrors, messages, errors } = useDebug();
@@ -148,6 +149,19 @@ export function RuntimeProvider({ children }) {
148149
return;
149150
}
150151

152+
// Handle MCP message queue action
153+
if (action === 'mcpQueueMessage') {
154+
mcpMessagesRef.current.push(params);
155+
// Keep queue from growing unbounded (max 1000 messages)
156+
if (mcpMessagesRef.current.length > 1000) {
157+
mcpMessagesRef.current = mcpMessagesRef.current.slice(-1000);
158+
}
159+
// Update status on the node with new count
160+
const count = mcpMessagesRef.current.filter(m => m.nodeId === nodeId).length;
161+
peerRef.current.methods.emitEvent(nodeId, 'queueUpdate', { count });
162+
return;
163+
}
164+
151165
const nodeDef = nodeRegistry.get(nodeType);
152166
if (!nodeDef?.mainThread?.[action]) {
153167
logger.warn(`No mainThread handler for ${nodeType}.${action}`);
@@ -169,6 +183,11 @@ export function RuntimeProvider({ children }) {
169183
return await audioManager.handleMainThreadCall(nodeId, action, params);
170184
}
171185

186+
// Handle MCP queue count query
187+
if (action === 'mcpGetQueueCount') {
188+
return mcpMessagesRef.current.filter(m => m.nodeId === params.nodeId).length;
189+
}
190+
172191
const nodeDef = nodeRegistry.get(nodeType);
173192
if (!nodeDef?.mainThread?.[action]) {
174193
throw new Error(`No mainThread handler for ${nodeType}.${action}`);
@@ -760,6 +779,47 @@ export function RuntimeProvider({ children }) {
760779
return nodeStatusesRef.current;
761780
});
762781

782+
peerRef.current.addHandler('mcpGetMessages', (limit = 100, clear = true) => {
783+
const messages = mcpMessagesRef.current.slice(0, limit);
784+
if (clear && messages.length > 0) {
785+
// Remove returned messages from queue
786+
mcpMessagesRef.current = mcpMessagesRef.current.slice(messages.length);
787+
788+
// Notify mcp-output nodes that queue was cleared
789+
// Group remaining messages by nodeId to get counts
790+
const remainingByNode = {};
791+
for (const msg of mcpMessagesRef.current) {
792+
remainingByNode[msg.nodeId] = (remainingByNode[msg.nodeId] || 0) + 1;
793+
}
794+
795+
// Find all mcp-output nodes and update their status
796+
const state = flowStateRef.current;
797+
const mcpOutputNodes = Object.values(state.nodes).filter(n => n._node.type === 'mcp-output');
798+
for (const node of mcpOutputNodes) {
799+
const count = remainingByNode[node._node.id] || 0;
800+
peerRef.current.methods.emitEvent(node._node.id, 'queueUpdate', { count });
801+
}
802+
}
803+
return messages;
804+
});
805+
806+
peerRef.current.addHandler('mcpSendMessage', (payload, topic = '') => {
807+
// Find all mcp-input nodes and send message to them
808+
const state = flowStateRef.current;
809+
const mcpInputNodes = Object.values(state.nodes).filter(n => n._node.type === 'mcp-input');
810+
811+
if (mcpInputNodes.length === 0) {
812+
return { success: false, error: 'No mcp-input nodes found in flows' };
813+
}
814+
815+
// Emit message to each mcp-input node
816+
for (const node of mcpInputNodes) {
817+
peerRef.current.methods.emitEvent(node._node.id, 'mcpMessage', { payload, topic });
818+
}
819+
820+
return { success: true, nodeCount: mcpInputNodes.length };
821+
});
822+
763823
peerRef.current.addHandler('mcpGetCanvasSvg', () => {
764824
// Get the canvas SVG from the DOM
765825
const svgElement = document.querySelector('.canvas-svg');

src/nodes/ai/mcp-input.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* MCP Input Node - Runtime implementation
3+
*
4+
* Receives messages from AI agents via MCP send_mcp_message tool
5+
*/
6+
export const mcpInputRuntime = {
7+
type: 'mcp-input',
8+
9+
onInit() {
10+
this.receivedCount = 0;
11+
this.mcpConnected = false;
12+
this.updateStatus();
13+
14+
// Listen for MCP connection status changes
15+
this.on('mcpConnectionStatus', ({ status }) => {
16+
this.mcpConnected = status === 'connected';
17+
this.updateStatus();
18+
});
19+
20+
// Listen for messages from main thread
21+
this.on('mcpMessage', (msg) => {
22+
// Filter by topic if configured
23+
const topicFilter = this.config.topic || '';
24+
if (topicFilter && msg.topic !== topicFilter) {
25+
return; // Skip messages that don't match topic filter
26+
}
27+
28+
this.receivedCount++;
29+
this.updateStatus();
30+
this.send({
31+
payload: msg.payload,
32+
topic: msg.topic || 'mcp-in'
33+
});
34+
});
35+
},
36+
37+
updateStatus() {
38+
if (!this.mcpConnected) {
39+
this.status({ text: 'MCP disconnected', fill: 'red' });
40+
} else if (this.receivedCount > 0) {
41+
this.status({ text: `Received: ${this.receivedCount}`, fill: 'green' });
42+
} else {
43+
this.status({ text: 'Ready', fill: 'green' });
44+
}
45+
}
46+
};

src/nodes/ai/mcp-input.jsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* MCP Input Node - Receives messages from AI agents via MCP
3+
*/
4+
export const mcpInputNode = {
5+
type: 'mcp-input',
6+
category: 'ai',
7+
description: 'Receives messages from AI agents via MCP',
8+
paletteLabel: 'mcp-in',
9+
label: (node) => node._node.name || 'mcp-in',
10+
color: '#a66bbf', // Purple for AI nodes
11+
fontColor: '#fff',
12+
icon: true,
13+
faChar: '\uf0e0', // envelope
14+
faColor: 'rgba(255,255,255,0.9)',
15+
16+
inputs: 0,
17+
outputs: 1,
18+
19+
defaults: {
20+
topic: { type: 'string', default: '', label: 'Topic filter', description: 'Only receive messages with this topic (empty = all)' }
21+
},
22+
23+
messageInterface: {
24+
writes: {
25+
payload: {
26+
type: 'any',
27+
description: 'Message content from AI agent'
28+
},
29+
topic: {
30+
type: 'string',
31+
description: 'Message topic'
32+
}
33+
}
34+
},
35+
36+
renderHelp() {
37+
return (
38+
<>
39+
<p>Receives messages sent by AI agents via the MCP <code>send_mcp_message</code> tool.</p>
40+
41+
<h5>Use Cases</h5>
42+
<ul>
43+
<li>AI-initiated speech output</li>
44+
<li>AI controlling flows and devices</li>
45+
<li>Two-way AI conversation flows</li>
46+
</ul>
47+
48+
<h5>Options</h5>
49+
<ul>
50+
<li><strong>Topic filter</strong> - Only receive messages with matching topic (leave empty to receive all)</li>
51+
</ul>
52+
53+
<h5>Output</h5>
54+
<ul>
55+
<li><code>msg.payload</code> - Message content</li>
56+
<li><code>msg.topic</code> - Message topic</li>
57+
</ul>
58+
59+
<h5>Example</h5>
60+
<p>Wire this node to a <strong>speech</strong> node to let AI agents speak to you.</p>
61+
</>
62+
);
63+
}
64+
};

src/nodes/ai/mcp-output.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* MCP Output Node - Runtime implementation
3+
*
4+
* Queues messages for MCP consumption via the mcp-messages tool
5+
*/
6+
export const mcpOutputRuntime = {
7+
type: 'mcp-output',
8+
9+
async onInit() {
10+
this.queueCount = 0;
11+
this.mcpConnected = false;
12+
13+
// Listen for MCP connection status changes
14+
this.on('mcpConnectionStatus', ({ status }) => {
15+
this.mcpConnected = status === 'connected';
16+
this.updateStatus();
17+
});
18+
19+
// Listen for queue updates from main thread
20+
this.on('queueUpdate', (data) => {
21+
this.queueCount = data.count;
22+
this.updateStatus();
23+
});
24+
25+
// Get initial queue count from main thread
26+
const initialCount = await this.mainThreadCall('mcpGetQueueCount', { nodeId: this.id });
27+
this.queueCount = initialCount || 0;
28+
this.updateStatus();
29+
},
30+
31+
updateStatus() {
32+
if (!this.mcpConnected) {
33+
this.status({ text: 'MCP disconnected', fill: 'red' });
34+
} else if (this.queueCount > 0) {
35+
this.status({ text: `Queued: ${this.queueCount}`, fill: 'blue' });
36+
} else {
37+
this.status({ text: 'Ready', fill: 'green' });
38+
}
39+
},
40+
41+
onInput(msg) {
42+
const text = typeof msg.payload === 'string'
43+
? msg.payload
44+
: JSON.stringify(msg.payload);
45+
46+
const topic = msg.topic || this.config.topic || '';
47+
48+
if (!text || text.trim() === '') {
49+
return;
50+
}
51+
52+
// Send to MCP queue via the worker's MCP integration
53+
this.mainThread('mcpQueueMessage', {
54+
text: text.trim(),
55+
topic,
56+
nodeId: this.id,
57+
nodeName: this.name || 'mcp-out',
58+
timestamp: Date.now()
59+
});
60+
}
61+
};

src/nodes/ai/mcp-output.jsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* MCP Output Node - Sends messages to the MCP queue for AI agent consumption
3+
*/
4+
export const mcpOutputNode = {
5+
type: 'mcp-output',
6+
category: 'ai',
7+
description: 'Sends text to the MCP message queue for AI agents',
8+
paletteLabel: 'mcp-out',
9+
label: (node) => node._node.name || 'mcp-out',
10+
color: '#a66bbf', // Purple for AI nodes
11+
fontColor: '#fff',
12+
icon: true,
13+
faChar: '\uf0e0', // envelope
14+
faColor: 'rgba(255,255,255,0.9)',
15+
16+
inputs: 1,
17+
outputs: 0,
18+
19+
defaults: {
20+
topic: { type: 'string', default: '', label: 'Topic' }
21+
},
22+
23+
messageInterface: {
24+
reads: {
25+
payload: {
26+
type: 'string',
27+
description: 'Text message to send to MCP queue',
28+
required: true
29+
},
30+
topic: {
31+
type: 'string',
32+
description: 'Optional topic/category for the message',
33+
optional: true
34+
}
35+
}
36+
},
37+
38+
renderHelp() {
39+
return (
40+
<>
41+
<p>Sends text messages to a queue that AI agents can read via MCP.</p>
42+
43+
<h5>Use Cases</h5>
44+
<ul>
45+
<li>Voice recognition output to AI conversation</li>
46+
<li>Sensor alerts for AI to respond to</li>
47+
<li>Event notifications for AI processing</li>
48+
</ul>
49+
50+
<h5>Input</h5>
51+
<ul>
52+
<li><code>msg.payload</code> - Text message to queue</li>
53+
<li><code>msg.topic</code> - Optional topic (overrides node setting)</li>
54+
</ul>
55+
56+
<h5>MCP Integration</h5>
57+
<p>AI agents can retrieve messages using the <code>get_mcp_messages</code> tool,
58+
which returns and clears the queue.</p>
59+
60+
<h5>Example</h5>
61+
<p>Wire a <strong>voicerec</strong> node to this node to enable voice conversations with an AI agent.</p>
62+
</>
63+
);
64+
}
65+
};

src/nodes/audio/oscillator.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,24 @@ export const audioOscillatorRuntime = {
4242
});
4343
}
4444

45+
// Handle frequency ramp (for sweeps/glides)
46+
if (msg.rampFrequency !== undefined) {
47+
this.mainThread('rampAudioParam', {
48+
param: 'frequency',
49+
value: msg.rampFrequency,
50+
duration: msg.rampTime || 0.1
51+
});
52+
}
53+
54+
// Handle detune ramp
55+
if (msg.rampDetune !== undefined) {
56+
this.mainThread('rampAudioParam', {
57+
param: 'detune',
58+
value: msg.rampDetune,
59+
duration: msg.rampTime || 0.1
60+
});
61+
}
62+
4563
// Handle PeriodicWave for custom waveforms (FFT-based synthesis)
4664
// realTable = real (cosine) coefficients, imagTable = imaginary (sine) coefficients
4765
if (Array.isArray(msg.realTable)) {
@@ -51,6 +69,17 @@ export const audioOscillatorRuntime = {
5169
});
5270
}
5371

72+
// Harmonics shorthand - array of harmonic amplitudes [fundamental, 2nd, 3rd, ...]
73+
// Automatically builds imagTable for sine-phase harmonics
74+
if (Array.isArray(msg.harmonics)) {
75+
const imagTable = [0, ...msg.harmonics]; // DC offset of 0, then harmonics
76+
const realTable = new Array(imagTable.length).fill(0); // All cosine terms zero
77+
this.mainThread('setPeriodicWave', {
78+
realTable,
79+
imagTable
80+
});
81+
}
82+
5483
// One-shot mode: play for a duration then stop
5584
if (msg.duration !== undefined) {
5685
this.mainThread('playAudioNode', {

0 commit comments

Comments
 (0)