Perché ho messo un server MCP dentro un’app iOS

Applicazioni • Programmazione

Perché ho messo un server MCP dentro un'app iOS

Volevo che si potesse chiedere a Claude “quanto ho speso di benzina questo mese?” e ottenere la risposta dai dati della mia app. Il problema è che l’app in questione — Magica, il mio tracker per auto — è privacy-first: i dati stanno sul telefono, non ho un cloud, non ne ho mai voluto uno. E quasi tutte le “funzioni AI” che vedo in giro funzionano nel modo opposto: prendono i tuoi dati, li spediscono a un server, li fanno passare da un modello lì, ti rimandano il risultato.

Dare accesso a un LLM significava, in apparenza, tradire l’unica promessa su cui l’app è costruita. La soluzione a cui sono arrivato è la cosa più strana che ho spedito quest’anno: un server MCP che gira dentro l’app, sul telefono dell’utente. Qui racconto perché, e cosa ho imparato facendolo.

Il vincolo, non la feature

Parto dal vincolo perché è lui il protagonista. Magica non ha backend. È una scelta, non una mancanza: niente account, niente sync su un mio server, i dati vivono sul dispositivo e al massimo su iCloud dell’utente. Questo mi toglie dal tavolo l’opzione facile — “carico i dati da qualche parte e ci metto un LLM davanti” — e mi lascia con una domanda più interessante: come faccio a dare a un assistente AI accesso ai dati senza che i dati vadano da nessuna parte?

La risposta onesta è che l’LLM deve venire lui dai dati, non viceversa. E i dati sono sul telefono. Quindi il pezzo che l’assistente deve poter chiamare deve stare sul telefono.

Perché MCP e non un’integrazione mia

Potevo inventarmi un formato mio: un endpoint, uno schema di richieste, e poi scrivere un plugin per ogni assistente. L’ho scartato subito. È lavoro infinito e fragile, e nessuno lo adotterebbe.

MCP — il Model Context Protocol — risolve esattamente questo: è lo standard con cui un assistente (Claude, Cursor, Codex, Continue…) scopre e chiama “tool” esterni mentre conversa. Se espongo i dati di Magica come server MCP, qualsiasi client che parla il protocollo li può usare senza che io scriva niente di specifico per lui. Interoperabilità gratis. Ho definito 11 tool (8 in lettura, 3 in scrittura) e qualche resource, e da quel momento “supporto Claude” e “supporto Cursor” sono diventati la stessa cosa.

La parte non ovvia è che MCP non impone dove giri il server. Di solito gira su una macchina “vera”. Io l’ho messo dentro l’app.

La scelta radicale: il server è sul telefono

Quando accendi l’API Mode, Magica avvia un piccolo server HTTP che ascolta su 0.0.0.0:8080, sulla rete locale. Lo stesso server serve sia la REST API sia l’endpoint MCP (POST /api/v1/mcp, JSON-RPC 2.0), con lo stesso token. Il tuo assistente, sul portatile, si collega al telefono via Wi-Fi e chiama i tool. I dati non lasciano il dispositivo: viene recuperato solo ciò che chiedi, nel momento in cui lo chiedi.

Detto così sembra elegante. Nella pratica significa accettare una lista di trade-off che, se avessi avuto un backend, non avrei mai avuto. Ed è qui che la storia diventa interessante.

I trade-off che ho scelto di tenere

LAN-only, in HTTP. Il server parla HTTP in chiaro, senza TLS. Su una rete locale trusted è una scelta ragionata: gestire certificati validi per un IP 192.168.x.x che cambia, dentro un’app, è un incubo sproporzionato rispetto al rischio. Il costo è che l’utente che vuole raggiungere il server da fuori casa deve costruirsi lui il tunnel cifrato (Tailscale, Cloudflare) — e lo documento come tale, “a tuo rischio”. Non fingo che sia una feature di prodotto.

Niente 24/7. Il server gira mentre l’app è in foreground e si auto-spegne dopo qualche minuto di inattività, per la batteria. Un iPhone va in standby: non esiste un “accesso remoto sempre attivo”. Ho passato un po’ di tempo a chiedermi se combatterlo, e alla fine ho deciso di assecondarlo — l’auto-stop è configurabile (fino a un’ora, o mai), ma il default protegge l’utente da se stesso. È un limite che preferisco dichiarare piuttosto che mascherare.

Auth vera, nonostante sia “solo la LAN”. Ogni richiesta vuole un JWT Bearer, con scope read o readwrite. Si ottiene o con un pairing interattivo (codice a sei cifre che approvi dall’app) o con un manual token a scadenza. Ogni client ha il suo token, li vedi in una lista, li revochi singolarmente, e la revoca persiste in una blocklist anche dopo il restart. “È solo la rete di casa” non è una scusa per non avere un modello di autorizzazione: la rete di casa ospita anche l’aspirapolvere smart del vicino.

I dettagli sporchi che scopri solo costruendola

Le decisioni di architettura sono la parte pulita. Poi ci sono le cose che impari solo quando colleghi davvero un client reale.

Claude Desktop vuole HTTPS. Il custom connector dell’interfaccia accetta solo URL https://. Il mio server è HTTP sulla LAN. Risultato: per usarlo serve un bridge stdio — mcp-remote — con una flag esplicita che dice “sì, lo so, è HTTP, fidati”:

"args": ["-y", "mcp-remote", "http://192.168.1.42:8080/api/v1/mcp",
         "--allow-http", "--header", "Authorization: Bearer ..."]

Senza --allow-http, il processo esce e Claude mostra un laconico “Server disconnected”. Un pomeriggio buttato a capire che il problema non era il mio server.

L’errore OAuth che non è un errore OAuth. Quando l’auth falliva (token scaduto), nei log di Claude compariva un HTTP 404: Invalid OAuth error response: SyntaxError: Unexpected end of JSON input. Zero riferimenti al vero problema. Cos’era? mcp-remote, ricevuto un 401, tenta un fallback OAuth: va a cercare i .well-known che io non implemento, riceve un 404 vuoto, e il parser JSON esplode su una stringa vuota. Il messaggio d’errore parla di OAuth; la causa è un bearer scaduto. Questi sono i bug che ti insegnano a scrivere documentazione di troubleshooting seria.

La conformance costa attenzione. Un client MCP browser-based fa un preflight CORS: se l’OPTIONS sul mio endpoint non risponde 204 con gli header giusti, la connessione muore prima di iniziare. Il server deve rilasciare un Mcp-Session-Id sull’initialize e accettarlo dopo, rispondere 405 al GET (in v1 non emetto stream server-initiated, quindi niente SSE long-lived), gestire il DELETE per chiudere la sessione. Sono i dettagli della spec Streamable HTTP che non puoi “quasi” implementare: o sei conforme o il client, semplicemente, non ti vede.

La chiave HMAC nel Keychain. Reinstalli l’app? Nuova chiave, e tutti i token firmati con la vecchia diventano 401 di colpo. Ovvio col senno di poi, sorprendente quando succede all’utente che non capisce perché “ieri funzionava”.

Cosa mi porto a casa

La lezione non è tecnica, è di metodo: il vincolo forte ha prodotto il design migliore. Se avessi avuto un backend a disposizione, avrei fatto la cosa banale — dati in cloud, LLM davanti — e Magica sarebbe stata un tracker come tanti con una casella “AI” in più. Non poterlo fare mi ha costretto a una soluzione che, guarda caso, è anche più rispettosa: l’assistente viene dai tuoi dati, sul tuo dispositivo, e ciò che non chiedi non viene mai toccato.

Ci sono cose che con un server sul telefono non farò mai bene — il sempre-attivo, il multi-utente, l’accesso da remoto senza attriti. Le accetto. In cambio ho una feature che nessun concorrente ha, costruita senza tradire la premessa dell’app. Per un progetto indie, dove il differenziatore non è il budget ma le scelte, mi sembra un buon affare.

Se vuoi vederla dal lato utente — cosa ci si può chiedere e come si collega — l’ho raccontata sul blog di Magica. Qui volevo solo spiegare perché il server gira in un posto così insolito.

Scritto il