Mes canvis
This commit is contained in:
@@ -18,3 +18,6 @@ PIPER_URL=
|
|||||||
LLAMA_CPP_URL=https://ollama.epsem.aranroig.com/v1/chat/completitions
|
LLAMA_CPP_URL=https://ollama.epsem.aranroig.com/v1/chat/completitions
|
||||||
LLAMA_PREAMBLE=./prompts/preamble.md
|
LLAMA_PREAMBLE=./prompts/preamble.md
|
||||||
LLAMA_API_KEY=your_api_key
|
LLAMA_API_KEY=your_api_key
|
||||||
|
|
||||||
|
# MCP server (Python FastMCP) — SSH-tunelled from remote machine
|
||||||
|
MCP_URL=http://localhost:5001
|
||||||
10
backend/run-mcp.sh
Executable file
10
backend/run-mcp.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
REMOTE_PORT=2223
|
||||||
|
HOST=ollama.epsem.aranroig.com
|
||||||
|
PORT=5001
|
||||||
|
REMOTE_USER=root
|
||||||
|
REMOTE_HOST=ollama.epsem.aranroig.com
|
||||||
|
ssh -p ${REMOTE_PORT} -N -R ${HOST}:${PORT}:localhost:${PORT} -o ServerAliveInterval=30 \
|
||||||
|
-o ServerAliveCountMax=3 \
|
||||||
|
-o ExitOnForwardFailure=yes \
|
||||||
|
${REMOTE_USER}@${REMOTE_HOST}
|
||||||
@@ -87,7 +87,7 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||||||
tmpTxt = txtPath;
|
tmpTxt = txtPath;
|
||||||
await writeFileAsync(txtPath, transcription);
|
await writeFileAsync(txtPath, transcription);
|
||||||
|
|
||||||
const llmResponse = await llamacppService.chatWithPreamble(transcription).catch(
|
const llmResponse = await llamacppService.chatWithMcpTools(transcription).catch(
|
||||||
(err: unknown) => {
|
(err: unknown) => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
console.error(`[audio] llama.cpp failed: ${msg}`);
|
console.error(`[audio] llama.cpp failed: ${msg}`);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import router from './routes/router.js';
|
|||||||
import { getAppPort, getConfig } from './config.js';
|
import { getAppPort, getConfig } from './config.js';
|
||||||
import { whisperService } from './services/whisper.service.js';
|
import { whisperService } from './services/whisper.service.js';
|
||||||
import { piperService as piperWorker } from './services/piper.service.js';
|
import { piperService as piperWorker } from './services/piper.service.js';
|
||||||
import { mcpClient } from './services/mcpClient.service.js';
|
import { mcpClient } from './services/mcp.service.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -30,12 +30,9 @@ const server = app.listen(getAppPort(), async () => {
|
|||||||
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
console.log(`QuiBot backend listening on port ${getAppPort()}`);
|
||||||
whisperService.spawn();
|
whisperService.spawn();
|
||||||
piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ });
|
piperWorker.initWav().catch(() => { /* model may not exist yet → lazy init on first TTS call */ });
|
||||||
try {
|
mcpClient.connect().catch((err) => {
|
||||||
await mcpClient.start();
|
console.error(`[mcp] Failed to start MCP client: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
console.log('[server] MCP client started');
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error(`[server] MCP client failed to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
|
|||||||
@@ -1,54 +1,171 @@
|
|||||||
import { getLlamacppUrl, getLlamacppApiKey, getLlamacppPreamble } from '../config.js';
|
import { getLlamacppUrl, getLlamacppApiKey, getLlamacppPreamble } from '../config.js';
|
||||||
|
import { mcpClient, McpToolDef } from './mcp.service.js';
|
||||||
|
|
||||||
interface LlamaRequest {
|
interface LlamaMessage {
|
||||||
messages: Array<{ role: string; content: string }>;
|
role: string;
|
||||||
|
content?: string | null;
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LlamaChatChoice {
|
interface LlamaToolCallResult {
|
||||||
message: {
|
content: Array<{
|
||||||
content: string;
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
}>;
|
||||||
|
isError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlamaToolDefinition {
|
||||||
|
type: 'function';
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: object;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlamaRequest {
|
||||||
|
messages: LlamaMessage[];
|
||||||
|
tools?: LlamaToolDefinition[];
|
||||||
|
tool_choice?: 'auto' | 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LlamaResponseChoice {
|
||||||
|
message?: {
|
||||||
|
content?: string;
|
||||||
|
tool_calls?: Array<{
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
function: { name: string; arguments: string };
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LlamaResponse {
|
interface LlamaResponse {
|
||||||
choices?: LlamaChatChoice[];
|
choices?: LlamaResponseChoice[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_TOOL_ITERATIONS = 10;
|
||||||
|
|
||||||
export const llamacppService = {
|
export const llamacppService = {
|
||||||
async chat(messages: Array<{ role: string; content: string }>): Promise<string> {
|
async chat(messages: Array<{ role: string; content: string }>): Promise<string> {
|
||||||
|
let history: LlamaMessage[] = messages.map(m => ({ role: m.role, content: m.content }));
|
||||||
|
|
||||||
const apiUrl = getLlamacppUrl();
|
const apiUrl = getLlamacppUrl();
|
||||||
if (!apiUrl) {
|
if (!apiUrl) return '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = getLlamacppApiKey();
|
const apiKey = getLlamacppApiKey();
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
if (apiKey) {
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
||||||
|
const request: LlamaRequest = { messages: history };
|
||||||
|
const res = await fetch(apiUrl, { method: 'POST', headers, body: JSON.stringify(request) });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`llama.cpp request failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const data = (await res.json()) as LlamaResponse;
|
||||||
method: 'POST',
|
const choice = data.choices?.[0];
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ messages } satisfies LlamaRequest),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!choice?.message || !choice.message.content) {
|
||||||
const text = await response.text().catch(() => '');
|
return '';
|
||||||
throw new Error(`llama.cpp request failed (${response.status}): ${text.slice(0, 300)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as LlamaResponse;
|
return choice.message.content.trim();
|
||||||
const content = data.choices?.[0]?.message?.content?.trim() ?? '';
|
|
||||||
return content;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async chatWithPreamble(userText: string): Promise<string> {
|
async chatWithPreamble(userText: string): Promise<string> {
|
||||||
const preamble = getLlamacppPreamble();
|
const preamble = getLlamacppPreamble();
|
||||||
const messages = preamble ? [
|
const msgs = preamble
|
||||||
{ role: 'system', content: preamble },
|
? [{ role: 'system' as const, content: preamble }, { role: 'user' as const, content: userText }]
|
||||||
{ role: 'user', content: userText },
|
: [{ role: 'user' as const, content: userText }];
|
||||||
] : [{ role: 'user', content: userText }];
|
return this.chat(msgs);
|
||||||
return this.chat(messages);
|
},
|
||||||
|
|
||||||
|
async chatWithMcpTools(userText: string): Promise<string> {
|
||||||
|
const preamble = getLlamacppPreamble();
|
||||||
|
const initialMessages: LlamaMessage[] = preamble
|
||||||
|
? [{ role: 'system', content: preamble }, { role: 'user', content: userText }]
|
||||||
|
: [{ role: 'user', content: userText }];
|
||||||
|
|
||||||
|
if (!mcpClient.getReady()) {
|
||||||
|
console.log('[llama] MCP not ready, falling back to preamble-only chat');
|
||||||
|
return this.chatWithPreamble(userText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = mcpClient.getTools();
|
||||||
|
const llamaTools: LlamaToolDefinition[] = tools.map((t) => ({
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: t.inputSchema,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this._chatWithTools(initialMessages, llamaTools);
|
||||||
|
},
|
||||||
|
|
||||||
|
async _chatWithTools(messages: LlamaMessage[], tools: LlamaToolDefinition[]): Promise<string> {
|
||||||
|
const apiUrl = getLlamacppUrl();
|
||||||
|
if (!apiUrl) return '';
|
||||||
|
|
||||||
|
let iter = 0;
|
||||||
|
const history: LlamaMessage[] = messages.map((m) => ({ ...m }));
|
||||||
|
|
||||||
|
while (iter < MAX_TOOL_ITERATIONS) {
|
||||||
|
const apiKey = getLlamacppApiKey();
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
||||||
|
|
||||||
|
const body: LlamaRequest = { messages: history, tools, tool_choice: 'auto' };
|
||||||
|
const res = await fetch(apiUrl, { method: 'POST', headers, body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`llama.cpp request failed (${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as LlamaResponse;
|
||||||
|
const choice = data.choices?.[0];
|
||||||
|
|
||||||
|
if (!choice?.message) {
|
||||||
|
throw new Error('llama.cpp response has no message');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!choice.message.tool_calls || choice.message.tool_calls.length === 0) {
|
||||||
|
return choice.message.content?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({ role: 'assistant', ...choice.message });
|
||||||
|
|
||||||
|
for (const toolCall of choice.message.tool_calls) {
|
||||||
|
let resultText = '';
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(toolCall.function.arguments);
|
||||||
|
resultText = await mcpClient.callTool(toolCall.function.name, args);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
resultText = `Error calling tool "${toolCall.function.name}": ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
role: 'tool',
|
||||||
|
content: resultText,
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
iter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Exceeded max tool-call iterations (${MAX_TOOL_ITERATIONS})`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { getMcpUrl } from '../config';
|
|
||||||
|
|
||||||
class McpHttpService {
|
|
||||||
private sessionId: string | null = null;
|
|
||||||
|
|
||||||
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
|
||||||
const baseUrl = getMcpUrl();
|
|
||||||
if (!baseUrl) {
|
|
||||||
throw new Error('MCP HTTP service not configured (set MCP_URL env var)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${baseUrl}/mcp`;
|
|
||||||
|
|
||||||
if (!this.sessionId) {
|
|
||||||
const initRes = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 1,
|
|
||||||
method: 'initialize',
|
|
||||||
params: {
|
|
||||||
protocolVersion: '2025-03-26',
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const initData = await initRes.json();
|
|
||||||
this.sessionId = String(initData.sessionId || initData.result?.sessionId);
|
|
||||||
|
|
||||||
await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: 2,
|
|
||||||
method: 'notifications/initialized',
|
|
||||||
}),
|
|
||||||
...(this.sessionId && { headers: { 'Mcp-SessionId': this.sessionId } }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(this.sessionId && { 'Mcp-SessionId': this.sessionId }),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: Date.now(),
|
|
||||||
method: 'tools/call',
|
|
||||||
params: { name, arguments: args },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.error) {
|
|
||||||
return { text: JSON.stringify(data.error), isError: true };
|
|
||||||
}
|
|
||||||
const content = data.result?.content?.[0];
|
|
||||||
if (!content?.text) {
|
|
||||||
throw new Error('MCP tool returned no content');
|
|
||||||
}
|
|
||||||
return { text: content.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
|
||||||
this.sessionId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mcpHttpService = new McpHttpService();
|
|
||||||
105
backend/src/services/mcp.service.ts
Normal file
105
backend/src/services/mcp.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { getMcpUrl } from '../config.js';
|
||||||
|
|
||||||
|
export interface McpToolDef {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
inputSchema: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mc: Client | null = null;
|
||||||
|
let cachedTools: McpToolDef[] = [];
|
||||||
|
let connected = false;
|
||||||
|
let connecting = false;
|
||||||
|
|
||||||
|
async function connectInternal(): Promise<void> {
|
||||||
|
if (connected) return;
|
||||||
|
if (connecting) throw new Error('MCP client connection already in progress');
|
||||||
|
connecting = true;
|
||||||
|
|
||||||
|
const rawUrl = getMcpUrl();
|
||||||
|
if (!rawUrl) {
|
||||||
|
console.warn('[mcp] MCP_URL not configured, tools disabled');
|
||||||
|
connecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the URL points at the /sse endpoint (FastMCP default)
|
||||||
|
let connectUrl = rawUrl;
|
||||||
|
try {
|
||||||
|
const u = new URL(rawUrl);
|
||||||
|
if (u.pathname === '/' || u.pathname === '') {
|
||||||
|
u.pathname = '/sse';
|
||||||
|
connectUrl = u.toString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not a valid URL, use as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[mcp] Connecting to ${connectUrl}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mc = new Client(
|
||||||
|
{ name: 'quibot-backend', version: '1.0.0' },
|
||||||
|
{ capabilities: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
const transport = new SSEClientTransport(new URL(connectUrl));
|
||||||
|
await mc.connect(transport);
|
||||||
|
|
||||||
|
console.log('[mcp] Connected, listing tools...');
|
||||||
|
const toolsResult = await mc.listTools();
|
||||||
|
cachedTools = (toolsResult.tools ?? []).map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
description: t.description ?? '',
|
||||||
|
inputSchema: t.inputSchema as object,
|
||||||
|
}));
|
||||||
|
|
||||||
|
connected = true;
|
||||||
|
connecting = false;
|
||||||
|
console.log(`[mcp] Connected to MCP server with ${cachedTools.length} tool(s): ${cachedTools.map((t) => t.name).join(', ') || '(none)'}`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
connecting = false;
|
||||||
|
console.error(`[mcp] Connection failed: ${msg}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mcpClient = {
|
||||||
|
async connect(): Promise<void> {
|
||||||
|
await connectInternal();
|
||||||
|
},
|
||||||
|
|
||||||
|
getReady(): boolean {
|
||||||
|
return connected;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTools(): readonly McpToolDef[] {
|
||||||
|
return cachedTools;
|
||||||
|
},
|
||||||
|
|
||||||
|
async callTool(name: string, args: Record<string, unknown>): Promise<string> {
|
||||||
|
if (!mc) throw new Error('MCP client not connected');
|
||||||
|
const result = await mc.callTool({ name, arguments: args });
|
||||||
|
const content = result.content as Array<{ type: string; text?: string }>;
|
||||||
|
const texts = content
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((c) => c.text ?? '');
|
||||||
|
return texts.join('\n') || '[MCP tool returned no text content]';
|
||||||
|
},
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
if (mc) {
|
||||||
|
try {
|
||||||
|
await mc.close();
|
||||||
|
} catch {
|
||||||
|
// ignore close errors on shutdown
|
||||||
|
}
|
||||||
|
mc = null;
|
||||||
|
}
|
||||||
|
connected = false;
|
||||||
|
cachedTools = [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import { spawn, ChildProcess } from 'child_process';
|
|
||||||
import { join } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { getMcpUrl } from '../config';
|
|
||||||
import { mcpHttpService } from './mcp.http.service';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = join(__filename, '..');
|
|
||||||
|
|
||||||
// Path to the compiled MCP server (two levels up from backend/src/)
|
|
||||||
const MCP_BIN = join(__dirname, '..', '..', 'mcp', 'dist', 'index.js');
|
|
||||||
|
|
||||||
let _proc: ChildProcess | null = null;
|
|
||||||
let nextId = 1;
|
|
||||||
let pending = new Map<number | string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
||||||
|
|
||||||
function send(msg: Record<string, unknown>): number {
|
|
||||||
const id = nextId++;
|
|
||||||
_proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, ...msg }) + '\n');
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mcpClient = {
|
|
||||||
async start(): Promise<void> {
|
|
||||||
const hasMcpBin = (() => {
|
|
||||||
try {
|
|
||||||
require('fs').accessSync(MCP_BIN);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (!hasMcpBin) {
|
|
||||||
const url = getMcpUrl();
|
|
||||||
if (url) {
|
|
||||||
console.log('[mcp] Local MCP binary not found, using HTTP service at', url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error('MCP local binary and HTTP URL both unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_proc) return;
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
_proc = spawn('node', [MCP_BIN], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env } });
|
|
||||||
|
|
||||||
_proc.stdout!.on('data', (chunk: Buffer) => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
for (const line of text.split('\n')) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
let parsed: { jsonrpc?: string; id?: number | string; method?: string; result?: unknown; error?: unknown };
|
|
||||||
try { parsed = JSON.parse(line); } catch { continue; }
|
|
||||||
if (parsed.jsonrpc !== '2.0') continue;
|
|
||||||
if (parsed.method) {
|
|
||||||
// notifications or responses without matching id — ignore for now
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!parsed.id) continue;
|
|
||||||
const p = pending.get(parsed.id);
|
|
||||||
if (!p) continue;
|
|
||||||
pending.delete(parsed.id);
|
|
||||||
if (parsed.error) {
|
|
||||||
p.reject(new Error(`MCP error: ${JSON.stringify(parsed.error)}`));
|
|
||||||
} else {
|
|
||||||
p.resolve(parsed.result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_proc.stderr!.on('data', (chunk: Buffer) => {
|
|
||||||
console.log(`[mcp-client] stderr: ${chunk.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
_proc.on('exit', (code, signal) => {
|
|
||||||
console.error(`[mcp-client] Exited code=${code} signal=${signal}`);
|
|
||||||
_proc = null;
|
|
||||||
for (const [, p] of pending) {
|
|
||||||
p.reject(new Error('MCP client process exited'));
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
_proc.on('error', (err: Error) => {
|
|
||||||
console.error(`[mcp-client] Error: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send initialize request
|
|
||||||
const initId = send({
|
|
||||||
method: 'initialize',
|
|
||||||
params: {
|
|
||||||
protocolVersion: '2025-03-26',
|
|
||||||
capabilities: {},
|
|
||||||
clientInfo: { name: 'quibot-backend', version: '1.0.0' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pending.set(initId, {
|
|
||||||
resolve: () => {
|
|
||||||
// Send initialized notification
|
|
||||||
_proc!.stdin!.write(
|
|
||||||
JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n',
|
|
||||||
);
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
reject,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const p = pending.get(initId);
|
|
||||||
if (p) {
|
|
||||||
pending.delete(initId);
|
|
||||||
p.reject(new Error('MCP initialize timed out'));
|
|
||||||
}
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async callTool(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
|
||||||
if (!_proc) {
|
|
||||||
await this.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await this._callToolLocal(name, args);
|
|
||||||
} catch (localErr) {
|
|
||||||
const url = getMcpUrl();
|
|
||||||
if (url) {
|
|
||||||
console.log(`[mcp] Local MCP failed: ${localErr instanceof Error ? localErr.message : localErr}. Falling back to HTTP service.`);
|
|
||||||
return await mcpHttpService.callTool(name, args);
|
|
||||||
}
|
|
||||||
throw localErr;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async _callToolLocal(name: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
|
||||||
if (!_proc?.stdin) {
|
|
||||||
throw new Error('MCP client not ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const id = send({
|
|
||||||
method: 'tools/call',
|
|
||||||
params: { name, arguments: args },
|
|
||||||
});
|
|
||||||
|
|
||||||
let cleared = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (cleared) return;
|
|
||||||
cleared = true;
|
|
||||||
pending.delete(id);
|
|
||||||
reject(new Error(`MCP tool "${name}" timed out`));
|
|
||||||
}, 180_000);
|
|
||||||
|
|
||||||
pending.set(id, {
|
|
||||||
resolve: (result: unknown) => {
|
|
||||||
if (cleared) return;
|
|
||||||
cleared = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
pending.delete(id);
|
|
||||||
const res = result as { content?: Array<{ type: string; text?: string }> };
|
|
||||||
if (res?.content?.[0]?.text !== undefined) {
|
|
||||||
resolve({ text: res.content[0].text });
|
|
||||||
} else {
|
|
||||||
reject(new Error('MCP tool returned no content'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reject: (err: Error) => {
|
|
||||||
if (cleared) return;
|
|
||||||
cleared = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async shutdown(): Promise<void> {
|
|
||||||
if (!_proc) return;
|
|
||||||
_proc.kill('SIGTERM');
|
|
||||||
const current = _proc;
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
let done = false;
|
|
||||||
const cleanup = () => {
|
|
||||||
if (done) return;
|
|
||||||
done = true;
|
|
||||||
_proc = null;
|
|
||||||
for (const [, p] of pending) {
|
|
||||||
p.reject(new Error('MCP client shut down'));
|
|
||||||
}
|
|
||||||
pending.clear();
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
current.once('exit', () => cleanup());
|
|
||||||
setTimeout(() => {
|
|
||||||
const proc = _proc;
|
|
||||||
if (proc && !proc.killed) proc.kill('SIGKILL');
|
|
||||||
cleanup();
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -161,7 +161,10 @@ private resolveInitError(err: Error): void {
|
|||||||
// reject all pending (new format: {resolve, reject})
|
// reject all pending (new format: {resolve, reject})
|
||||||
for (const [, entry] of this.respMap) entry.reject(new Error('piper process exited'));
|
for (const [, entry] of this.respMap) entry.reject(new Error('piper process exited'));
|
||||||
this.respMap.clear();
|
this.respMap.clear();
|
||||||
if (this.pendingInit) { this.initReject(new Error('piper process exited')); this.pendingInit = null; }
|
if (this.pendingInit && this.initReject) {
|
||||||
|
this.initReject(new Error('piper process exited'));
|
||||||
|
this.pendingInit = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── cleanup old WAV files every 5 min ──
|
// ── cleanup old WAV files every 5 min ──
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const SCRIPT_DIR = join(__dirname, '..');
|
|||||||
|
|
||||||
const PYTHON = join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
const PYTHON = join(SCRIPT_DIR, '..', '.venv', 'bin', 'python3');
|
||||||
|
|
||||||
const whisperModel = process.env.WHISPER_MODEL ?? 'base';
|
const whisperModel = process.env.WHISPER_MODEL ?? 'small';
|
||||||
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
const whisperLanguage = process.env.WHISPER_LANGUAGE ?? 'ca';
|
||||||
|
|
||||||
interface TranscriptResult {
|
interface TranscriptResult {
|
||||||
|
|||||||
3
mcp/.gitignore
vendored
3
mcp/.gitignore
vendored
@@ -1,2 +1 @@
|
|||||||
node_modules/
|
.venv/
|
||||||
dist/
|
|
||||||
46
mcp/mcp_server.py
Normal file
46
mcp/mcp_server.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from fastmcp import FastMCP
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
|
||||||
|
mcp = FastMCP("Test MCP server")
|
||||||
|
|
||||||
|
def fire_and_forget(url: str):
|
||||||
|
try:
|
||||||
|
requests.get(url, timeout=2)
|
||||||
|
except Exception:
|
||||||
|
pass # ignore all errors so it never affects the tool
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add(a: int, b: int) -> int:
|
||||||
|
"""Add two numbers together"""
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def greet(name: str) -> str:
|
||||||
|
"""Greet someome by its name"""
|
||||||
|
return f"Hello {name}! Welcome!"
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def multiply(a: int, b: int) -> int:
|
||||||
|
"""Multiply two numbers"""
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_time() -> str:
|
||||||
|
"""Get the current time"""
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime("%I:%M %p")
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def moure_brac() -> str:
|
||||||
|
"""Mou els braços"""
|
||||||
|
|
||||||
|
url = "http://quibot.local:8000/greet"
|
||||||
|
|
||||||
|
# start background request (non-blocking)
|
||||||
|
threading.Thread(target=fire_and_forget, args=(url,), daemon=True).start()
|
||||||
|
|
||||||
|
return "Braços moguts"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
mcp.run(transport="sse", port=5001)
|
||||||
1895
mcp/package-lock.json
generated
1895
mcp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "quibot-mcp",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "QuiBot MCP server — exposes robot controls as MCP tools and resources",
|
|
||||||
"type": "module",
|
|
||||||
"bin": {
|
|
||||||
"quibot-mcp": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "tsx src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@cfworker/json-schema": "^4.1.1",
|
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
||||||
"axios": "^1.7.0",
|
|
||||||
"form-data": "^4.0.0",
|
|
||||||
"zod": "^3.25"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.19.21",
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
581
mcp/src/index.ts
581
mcp/src/index.ts
@@ -1,581 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import axios, { AxiosError } from "axios";
|
|
||||||
import * as z from "zod";
|
|
||||||
import fs from "node:fs";
|
|
||||||
|
|
||||||
// --- Config from env (same as backend) ---
|
|
||||||
const RASPBERRY_PI_HOST = process.env.RASPBERRY_PI_HOST ?? "http://raspberrypi.local";
|
|
||||||
const RASPBERRY_PI_PORT = Number(process.env.RASPBERRY_PI_PORT) || 8000;
|
|
||||||
const QUIBOT_TOKEN = process.env.QUIBOT_TOKEN ?? "MY_SECRET_TOKEN";
|
|
||||||
|
|
||||||
const RPI_URL = `${RASPBERRY_PI_HOST}:${RASPBERRY_PI_PORT}`;
|
|
||||||
|
|
||||||
function rpiUrl(path: string, query?: Record<string, string>): string {
|
|
||||||
const url = `${RPI_URL}${path}`;
|
|
||||||
if (!query) return url;
|
|
||||||
const q = new URLSearchParams({ token: QUIBOT_TOKEN, ...query });
|
|
||||||
return `${url}?${q}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
async function rpiPost(path: string, query?: Record<string, string>, body?: unknown): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await axios.post(rpiUrl(path, query), body, { timeout: 10000 });
|
|
||||||
return res.data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof AxiosError && err.response) {
|
|
||||||
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpiGet(path: string, query?: Record<string, string>): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await axios.get(rpiUrl(path, query), { timeout: 10000 });
|
|
||||||
return res.data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof AxiosError && err.response) {
|
|
||||||
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rpiPostMultipart(path: string, formData: FormData): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
const res = await axios.post(rpiUrl(path), formData, {
|
|
||||||
timeout: 30000,
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof AxiosError && err.response) {
|
|
||||||
throw new Error(`Pi error ${err.response.status}: ${JSON.stringify(err.response.data)}`);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- MCP Server ---
|
|
||||||
const server = new McpServer({
|
|
||||||
name: "quibot",
|
|
||||||
version: "1.0.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
// === TOOLS ===
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"motor_step",
|
|
||||||
{
|
|
||||||
description: "Move stepper motors in a direction (fire-and-forget — motor runs until stop)",
|
|
||||||
inputSchema: z.object({
|
|
||||||
direction: z.enum(["forward", "backward", "left", "right"]),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ direction }) => {
|
|
||||||
const piPath = direction === "backward" ? "/motor/step/backwards" : `/motor/step/${direction}`;
|
|
||||||
try {
|
|
||||||
const result = await rpiPost(piPath);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${msg}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"motor_stop",
|
|
||||||
{
|
|
||||||
description: "Stop all stepper motors immediately (disables driver via GPIO EN)",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost("/motor/stop");
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"audio_upload_transcribe",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Upload an audio file to the Raspberry Pi and get transcription + LLM response. The Pi runs Whisper for transcription and llamacpp with preamble for response.",
|
|
||||||
inputSchema: z.object({
|
|
||||||
audioBase64: z.string().describe("Base64-encoded audio file"),
|
|
||||||
format: z.string().describe("Audio format (wav, m4a, mp3, etc.)"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ audioBase64, format }) => {
|
|
||||||
try {
|
|
||||||
const buffer = Buffer.from(audioBase64, "base64");
|
|
||||||
const formData = new FormData();
|
|
||||||
const fname = `audio-upload-${Date.now()}.${format}`;
|
|
||||||
formData.append("file", new Blob([buffer]), fname);
|
|
||||||
|
|
||||||
const result = await rpiPostMultipart("/transcribe", formData);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"motor_upload",
|
|
||||||
{
|
|
||||||
description: "Upload an audio file to the Raspberry Pi for processing",
|
|
||||||
inputSchema: z.object({
|
|
||||||
filePath: z.string().describe("Path to audio file on local machine"),
|
|
||||||
format: z.string().describe("Audio format (wav, m4a, mp3, etc.)"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ filePath, format }) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
return { content: [{ type: "text", text: `File not found: ${filePath}` }], isError: true };
|
|
||||||
}
|
|
||||||
const fd = new (await import("form-data")).default();
|
|
||||||
fd.append("file", fs.createReadStream(filePath));
|
|
||||||
fd.append("format", format);
|
|
||||||
await axios.post(`${RPI_URL}/audio/upload?format=${format}&token=${QUIBOT_TOKEN}`, fd, {
|
|
||||||
headers: fd.getHeaders(),
|
|
||||||
timeout: 30000,
|
|
||||||
});
|
|
||||||
return { content: [{ type: "text", text: `Uploaded: ${filePath}` }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"audio_list",
|
|
||||||
{
|
|
||||||
description: "List incoming audio files on the Raspberry Pi",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const files = await rpiGet("/audio/incoming");
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(files, null, 2) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"audio_lifecycle",
|
|
||||||
{
|
|
||||||
description: "Manage audio file lifecycle: lock, unlock, cancel, or process a file",
|
|
||||||
inputSchema: z.object({
|
|
||||||
filename: z.string().describe("Audio filename"),
|
|
||||||
action: z.enum(["lock", "unlock", "cancel", "process"]).describe("Lifecycle action to perform"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ filename, action }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost(`/audio/${action}/${filename}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"eye_set_shape",
|
|
||||||
{
|
|
||||||
description: "Set the LED eye shape on the robot's WS2811 matrix",
|
|
||||||
inputSchema: z.object({
|
|
||||||
shape: z.enum(["EYES_OPEN", "EYES_FW", "EYES_DOWN", "EYES_GESTURE"]).describe("Eye shape pattern"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ shape }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost(`/eye/shape/${shape}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"eye_set_color",
|
|
||||||
{
|
|
||||||
description: "Set the LED eye color on the robot's WS2811 matrix",
|
|
||||||
inputSchema: z.object({
|
|
||||||
color: z.enum(["RED", "GREEN", "BLUE", "YELLOW", "CYAN", "MAGENTA", "WHITE", "OFF", "ORANGE", "VIOLET", "DARK_RED"]).describe("Eye color name"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ color }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost(`/eye/color/${color}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"eye_toggle",
|
|
||||||
{
|
|
||||||
description: "Turn eyes on or off (enable/disable LED matrix breathing thread)",
|
|
||||||
inputSchema: z.object({
|
|
||||||
state: z.enum(["on", "off"]).describe("Eye power state"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ state }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost(`/eye/${state}`);
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"gesture_toggle_mode",
|
|
||||||
{
|
|
||||||
description: "Toggle between block mode and gesture mode on the robot",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost("/gesture/toggle");
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"gesture_on",
|
|
||||||
{
|
|
||||||
description: "Enable gesture sensor polling (PAJ7620U2)",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost("/gesture/on");
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"gesture_off",
|
|
||||||
{
|
|
||||||
description: "Disable gesture sensor polling",
|
|
||||||
inputSchema: z.object({}),
|
|
||||||
},
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost("/gesture/off");
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"run_command",
|
|
||||||
{
|
|
||||||
description: "Run a whitelisted system command on the Raspberry Pi",
|
|
||||||
inputSchema: z.object({
|
|
||||||
task: z.enum(["restart_nginx", "uptime", "update"]).describe("Command to run"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ task }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiPost("/run", { task });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
"tts_speak",
|
|
||||||
{
|
|
||||||
description: "Synthesize speech via Piper TTS and play it on the Raspberry Pi",
|
|
||||||
inputSchema: z.object({
|
|
||||||
text: z.string().describe("Text to speak"),
|
|
||||||
lang: z.string().default("ca").describe("Language code (ca, es, en)"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
async ({ text, lang }) => {
|
|
||||||
try {
|
|
||||||
const result = await rpiGet("/tts", { text, lang: lang || "ca" });
|
|
||||||
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// === RESOURCES ===
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"config",
|
|
||||||
"quibot://config",
|
|
||||||
{ description: "Current Raspberry Pi connection config and token" },
|
|
||||||
async () => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://config",
|
|
||||||
name: "QuiBot Configuration",
|
|
||||||
mimeType: "application/json",
|
|
||||||
text: JSON.stringify(
|
|
||||||
{ raspberryPiHost: RASPBERRY_PI_HOST, raspberryPiPort: RASPBERRY_PI_PORT, token: QUIBOT_TOKEN },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"available-directions",
|
|
||||||
"quibot://directions",
|
|
||||||
{ description: "Available motor movement directions" },
|
|
||||||
async () => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://directions",
|
|
||||||
name: "Available Motor Directions",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
text: ["forward", "backward"].map((d) => ` POST /motor/step/${d}`).join("\n"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"eye-shapes",
|
|
||||||
"quibot://eyes/shapes",
|
|
||||||
{ description: "Available LED eye shapes and their meanings" },
|
|
||||||
async () => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://eyes/shapes",
|
|
||||||
name: "Available Eye Shapes",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
text: [
|
|
||||||
" EYES_OPEN — Normal resting eyes (default)",
|
|
||||||
" EYES_FW — Forward-looking eyes",
|
|
||||||
" EYES_DOWN — Downward/downcast eyes",
|
|
||||||
" EYES_GESTURE — Gesture-acknowledge eyes",
|
|
||||||
].join("\n"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"color-actions",
|
|
||||||
"quibot://blocks/colors",
|
|
||||||
{ description: "Color-to-action mapping for block recognition" },
|
|
||||||
async () => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://blocks/colors",
|
|
||||||
name: "Color Block Actions",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
text: [
|
|
||||||
" RED → Advance forward",
|
|
||||||
" GREEN → Turn right",
|
|
||||||
" BLUE → Turn left",
|
|
||||||
" YELLOW → Take / pick up block",
|
|
||||||
" ORANGE → Leave / eject block",
|
|
||||||
" VIOLET → Idle",
|
|
||||||
" BLACK → Reference / no block",
|
|
||||||
].join("\n"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"gestures",
|
|
||||||
"quibot://gestures",
|
|
||||||
{ description: "Available PAJ7620U2 gestures" },
|
|
||||||
async () => ({
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://gestures",
|
|
||||||
name: "Available Gestures",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
text: [
|
|
||||||
" GS_FORWARD → Hand moving forward (toward sensor)",
|
|
||||||
" GS_BACKWARD → Hand moving backward (away from sensor)",
|
|
||||||
" GS_LEFT → Hand moving left",
|
|
||||||
" GS_RIGHT → Hand moving right",
|
|
||||||
" GS_UP → Hand moving up",
|
|
||||||
" GS_DOWN → Hand moving down",
|
|
||||||
" GS_CLOCKWISE → Clockwise wave motion",
|
|
||||||
" GS_ANTICLOCKWISE→ Counter-clockwise wave motion",
|
|
||||||
" GS_WAVE → Wave hello gesture",
|
|
||||||
].join("\n"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerResource(
|
|
||||||
"pi-status",
|
|
||||||
"quibot://status/pi",
|
|
||||||
{ description: "Check if Raspberry Pi HTTP server is reachable" },
|
|
||||||
async () => {
|
|
||||||
try {
|
|
||||||
const res = await axios.get(`${RPI_URL}/health`, { timeout: 5000 });
|
|
||||||
return {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://status/pi",
|
|
||||||
name: "Pi Status",
|
|
||||||
mimeType: "application/json",
|
|
||||||
text: JSON.stringify({ status: "connected", data: res.data }, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
uri: "quibot://status/pi",
|
|
||||||
name: "Pi Status",
|
|
||||||
mimeType: "application/json",
|
|
||||||
text: JSON.stringify({ status: "disconnected", error: "Cannot reach Raspberry Pi" }, null, 2),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// === PROMPTS ===
|
|
||||||
|
|
||||||
server.registerPrompt(
|
|
||||||
"quibot-setup",
|
|
||||||
{
|
|
||||||
description: "Get a complete reference for controlling the QuiBot robot via MCP",
|
|
||||||
},
|
|
||||||
() => ({
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: {
|
|
||||||
type: "text",
|
|
||||||
text: `# QuiBot MCP Server
|
|
||||||
|
|
||||||
You can control the physical QuiBot robot with these tools:
|
|
||||||
|
|
||||||
## Motor Control
|
|
||||||
- motor_step(direction) — Move forward, backward, left, or right
|
|
||||||
- motor_stop() — Stop all motors
|
|
||||||
- motor_upload(filePath, format) — Upload audio file to Pi
|
|
||||||
|
|
||||||
## Audio
|
|
||||||
- audio_upload_transcribe(audioBase64, format) — Upload + Whisper transcribe + llamacpp response
|
|
||||||
- audio_list() — List incoming audio files
|
|
||||||
- audio_lifecycle(filename, action) — lock/unlock/cancel/process audio
|
|
||||||
- tts_speak(text, lang) — Synthesize and play speech via Piper TTS
|
|
||||||
|
|
||||||
## Eyes (WS2811 LED Matrix)
|
|
||||||
- eye_set_shape(shape) — Set face expression shape
|
|
||||||
- eye_set_color(color) — Change eye color
|
|
||||||
- eye_toggle(state) — Turn eyes on/off
|
|
||||||
|
|
||||||
## Gesture Sensor
|
|
||||||
- gesture_on() / gesture_off() — Enable/disable gesture polling
|
|
||||||
- gesture_toggle_mode() — Toggle between block/gesture mode
|
|
||||||
|
|
||||||
## System
|
|
||||||
- run_command(task) — Run system commands on Pi (uptime, restart_nginx)
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
- quibot://status/pi — Check if Pi is reachable
|
|
||||||
- quibot://config — Current connection config
|
|
||||||
- quibot://blocks/colors — Color-to-action mapping
|
|
||||||
- quibot://gestures — Gesture reference
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// === START ===
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
console.error("[quibot-mcp] Server connected, waiting for requests via stdio...");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error("[quibot-mcp] Failed to start:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "Node16",
|
|
||||||
"moduleResolution": "Node16",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": false
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
81
rasp/server.py
Normal file
81
rasp/server.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from flask import Flask, request, jsonify
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'Rasp'))
|
||||||
|
|
||||||
|
import time
|
||||||
|
import pigpio
|
||||||
|
import motion
|
||||||
|
from motion import (
|
||||||
|
motion_setup, motion_setup_steppers, motion_setup_sensors, motion_cleanup,
|
||||||
|
enable_wheels, enable_arms, enable_syringe,
|
||||||
|
arms_home, syringe_home,
|
||||||
|
distance_to_object,
|
||||||
|
ON, OFF, CW, CCW,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _pi_connect():
|
||||||
|
pi = pigpio.pi()
|
||||||
|
if not pi.connected:
|
||||||
|
print("ERROR: pigpiod no està en marxa. Executa: sudo pigpiod -s 1")
|
||||||
|
sys.exit(1)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _setup_motors():
|
||||||
|
"""Setup mínim per a tests de motors (sense sensors I2C)."""
|
||||||
|
pi = _pi_connect()
|
||||||
|
motion_setup_steppers(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _setup_sensors():
|
||||||
|
"""Setup mínim per a tests de sensors I2C (sense steppers)."""
|
||||||
|
pi = _pi_connect()
|
||||||
|
motion_setup_sensors(pi)
|
||||||
|
return pi
|
||||||
|
|
||||||
|
def _teardown_motors(pi):
|
||||||
|
motion_cleanup()
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
def _teardown_sensors(pi):
|
||||||
|
pi.stop()
|
||||||
|
|
||||||
|
def test_arms_pair():
|
||||||
|
pi = _setup_motors()
|
||||||
|
enable_arms(ON); time.sleep(0.1)
|
||||||
|
motion.arm_R.move(+200); motion.arm_L.move(+200)
|
||||||
|
time.sleep(1.5)
|
||||||
|
motion.arm_R.move(-200); motion.arm_L.move(-200)
|
||||||
|
time.sleep(0.5)
|
||||||
|
enable_arms(OFF)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def home():
|
||||||
|
return jsonify({
|
||||||
|
"message": "Flask API is running"
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route("/greet", methods=["GET"])
|
||||||
|
def greet():
|
||||||
|
test_arms_pair()
|
||||||
|
return jsonify({
|
||||||
|
"message": "Hello, world!"
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# Simple error handlers
|
||||||
|
@app.errorhandler(404)
|
||||||
|
def not_found(e):
|
||||||
|
return jsonify({"error": "Route not found"}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(500)
|
||||||
|
def server_error(e):
|
||||||
|
return jsonify({"error": "Internal server error"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True, host="0.0.0.0", port=8000)
|
||||||
Reference in New Issue
Block a user