Salta al contenuto principale

Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API

Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API Claude ha MCP integrato nativamente. Ollama non ha nulla.

Cosa ne pensi di questo articolo?

Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API
AI InfrastructureFoto:Tara WinsteadPexels

In Breve

Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API Claude ha MCP integrato nativamente. Ollama non ha nulla.

Model Context Protocol (MCP): connettere strumenti a Ollama come a Claude API

Claude ha MCP integrato nativamente. Ollama non ha nulla. Questa frase riassume il problema che questo episodio risolve. Il Model Context Protocol è lo standard aperto che Anthropic ha rilasciato nel novembre 2024 e donato alla Linux Foundation nel dicembre 2025: definisce come un LLM host comunica con server esterni per invocare strumenti, leggere risorse e usare prompt template. In meno di 18 mesi, l'ecosistema è esploso - oltre 2.000 server MCP community-built al Q1 2026, adottato da OpenAI, Google DeepMind, Microsoft Copilot e molti altri.

Ollama espone un'API REST compatibile OpenAI, gestisce quantizzazione, model registry e single-GPU inference in modo eccellente, ma non ha concetto di protocollo MCP. Se vuoi costruire un agente che usa strumenti (legge file, interroga un database, chiama una API esterna) con Ollama come backend locale, devi costruirtelo tu. In questo episodio implementiamo un MCP server wrapper in FastAPI che traduce il dialetto JSON-RPC MCP in chiamate all'API Ollama, completo di tool registry configurabile via YAML, error handling conforme allo spec, e un security layer con autenticazione e rate limiting. Come caso d'uso reale usiamo l'integrazione nel ml-service del portfolio.

Cosa Imparerai

  • La struttura del protocollo MCP: tipi di messaggio JSON-RPC, handshake initialize, capabilities negotiation
  • Perché Ollama non supporta MCP nativamente e quale architettura di wrapper ha senso
  • Implementare un MCP server proxy FastAPI passo per passo
  • Progettare un tool registry caricato da YAML con schema validation
  • Costruire tool concreti: filesystem reader e database query tool
  • Gestire errori secondo lo spec MCP con codici standard (-32600, -32601, -32602, -32603)
  • Aggiungere autenticazione Bearer token e rate limiting al layer MCP

Prerequisiti

  • Ollama installato e funzionante (vedi EP02)
  • Python 3.11+ e familiarità con FastAPI e async/await
  • Conoscenza base di JSON-RPC 2.0 (request/response/notification)
  • Almeno un modello Ollama scaricato (es. llama3.1:8b)

Sezione 1 - Il Protocollo MCP: architettura e tipi di messaggio

MCP usa JSON-RPC 2.0 come wire format su tre transport supportati: stdio (processo locale), HTTP con SSE (Server-Sent Events), e WebSocket. La scelta del transport dipende dal contesto: stdio è ideale per tool CLI locali, HTTP/SSE per server remoti o microservizi come nel nostro caso.

Il protocollo distingue tre ruoli: Host (l'applicazione LLM che inizia la connessione), Client (il connettore dentro l'host), Server (il servizio che espone strumenti e risorse). Il nostro wrapper FastAPI gioca il ruolo di Server MCP verso i client che lo chiamano, e di Client verso Ollama.

Ecco i tipi di messaggio fondamentali:

Tipo messaggio Campi obbligatori Risposta attesa Esempi metodi
Request jsonrpc, id, method Si, con stesso id initialize, tools/list, tools/call
Response (success) jsonrpc, id, result No (è già una risposta) -
Response (error) jsonrpc, id, error No -
Notification jsonrpc, method (no id) No initialized, notifications/progress

La sessione MCP inizia sempre con un handshake initialize: il client dichiara le proprie capabilities, il server risponde con le sue, poi il client invia la notifica initialized per segnalare che la sessione è pronta. Solo dopo questo scambio si possono invocare strumenti.

Esempio completo di handshake initialize con le strutture JSON-RPC reali:

// 1. Client invia initialize request
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {}
    },
    "clientInfo": {
      "name": "portfolio-ml-service",
      "version": "1.0.0"
    }
  }
}

// 2. Server risponde con le proprie capabilities
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2024-11-05",
    "capabilities": {
      "tools": {},
      "resources": { "subscribe": false }
    },
    "serverInfo": {
      "name": "ollama-mcp-wrapper",
      "version": "0.1.0"
    }
  }
}

// 3. Client invia notifica initialized (no id = no response)
{
  "jsonrpc": "2.0",
  "method": "initialized"
}

// 4. Client chiede la lista degli strumenti disponibili
{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/list"
}

// 5. Server risponde con il tool registry
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "read_file",
        "description": "Legge il contenuto di un file dal filesystem locale",
        "inputSchema": {
          "type": "object",
          "properties": {
            "path": { "type": "string", "description": "Percorso assoluto del file" }
          },
          "required": ["path"]
        }
      }
    ]
  }
}

Nota il campo inputSchema: segue la specifica JSON Schema draft-07. Ogni tool dichiara esattamente quali argomenti accetta, con tipi e vincoli. Questo schema viene passato al modello Ollama come parte del system prompt strutturato, poiché Ollama non ha un endpoint nativo per "tool calling" separato dalla chat.

Sezione 2 - Perché Ollama non supporta MCP e come aggirarlo

Ollama espone POST /api/chat compatibile con il formato OpenAI messages. A partire dalla versione 0.3, Ollama supporta un campo tools nel payload della chat request, ma questo è il formato OpenAI function calling - non MCP. I due protocolli differiscono in modo fondamentale:

Caratteristica MCP OpenAI Function Calling Anthropic Tools
Protocollo wire JSON-RPC 2.0 su stdio/HTTP/WS REST JSON nel payload chat REST JSON nel payload chat
Statefulness Stateful (sessione, capabilities negoziata) Stateless (ogni request indipendente) Stateless
Tool discovery Runtime via tools/list Definito nel payload ogni volta Definito nel payload ogni volta
Server indipendente Si, il server MCP è un processo separato No, logic inline nel codice No, logic inline nel codice
Ecosistema condiviso 2.000+ server community, interoperabili Ogni impl. proprietaria Ogni impl. proprietaria
Provider lock-in Zero - standard aperto Linux Foundation OpenAI dipendente Anthropic dipendente

L'architettura del wrapper che costruiamo ha tre layer. Il MCP layer espone endpoint HTTP che ricevono messaggi JSON-RPC MCP. Il translation layer converte tool calls MCP in prompt strutturati per Ollama e interpreta le risposte del modello per estrarre le invocazioni di tool. Il tool execution layer esegue concretamente gli strumenti (legge file, interroga DB) e restituisce i risultati nel formato MCP.

Sezione 3 - Wrapper FastAPI MCP-compatible per Ollama

Installiamo le dipendenze necessarie e implementiamo il server FastAPI. Il wrapper espone un singolo endpoint POST /mcp che gestisce tutti i messaggi JSON-RPC MCP e un endpoint GET /mcp/sse per lo streaming tramite Server-Sent Events.

# ml_service/mcp/server.py
# Wrapper MCP-compatible per Ollama - gestisce initialize, tools/list, tools/call

from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any

import httpx
from fastapi import FastAPI, Request, HTTPException, Depends
from fastapi.responses import JSONResponse

from .auth import verify_token
from .rate_limit import RateLimiter
from .tool_registry import ToolRegistry
from .tool_executor import execute_tool
from .error_codes import MCPError, PARSE_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, INTERNAL_ERROR

logger = logging.getLogger(__name__)

# URL Ollama locale (mai cloud provider - vedi CLAUDE.md)
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_MODEL = "llama3.1:8b"

# Carica il registry una volta all'avvio (immutabile in runtime)
tool_registry = ToolRegistry.from_yaml("config/tools.yaml")
rate_limiter = RateLimiter(requests_per_minute=60)

app = FastAPI(title="Ollama MCP Wrapper", version="0.1.0")


async def handle_initialize(params: dict) -> dict:
    """Risponde alla richiesta initialize con le capabilities del server."""
    return {
        "protocolVersion": "2024-11-05",
        "capabilities": {
            "tools": {},
            "resources": { "subscribe": False },
        },
        "serverInfo": {
            "name": "ollama-mcp-wrapper",
            "version": "0.1.0",
        },
    }


async def handle_tools_list() -> dict:
    """Restituisce tutti gli strumenti registrati nel tool registry."""
    tools = tool_registry.get_all_tools()
    return {"tools": tools}


async def handle_tools_call(params: dict) -> dict:
    """
    Esegue uno strumento specifico.
    Il nome e gli argomenti vengono validati prima dell'esecuzione.
    """
    tool_name = params.get("name")
    arguments = params.get("arguments", {})

    if not tool_name:
        raise MCPError(INVALID_REQUEST, "Campo 'name' mancante nei parametri tools/call")

    tool_def = tool_registry.get_tool(tool_name)
    if not tool_def:
        raise MCPError(METHOD_NOT_FOUND, f"Strumento sconosciuto: {tool_name}")

    # Valida gli argomenti rispetto all'input schema del tool
    validation_error = tool_registry.validate_arguments(tool_name, arguments)
    if validation_error:
        raise MCPError(INVALID_REQUEST, f"Argomenti non validi: {validation_error}")

    # Esegui il tool nel layer di esecuzione
    result_text = await execute_tool(tool_name, arguments)
    return {
        "content": [{"type": "text", "text": result_text}]
    }


HANDLERS = {
    "initialize": handle_initialize,
    "tools/list": handle_tools_list,
    "tools/call": handle_tools_call,
}


@app.post("/mcp")
async def mcp_endpoint(
    request: Request,
    _token: str = Depends(verify_token),
) -> JSONResponse:
    """
    Entry point unico per tutti i messaggi MCP JSON-RPC.
    Gestisce request, response e notifiche (initialized).
    """
    # Rate limiting per IP
    client_ip = request.client.host
    if not rate_limiter.allow(client_ip):
        raise HTTPException(status_code=429, detail="Rate limit superato")

    try:
        body = await request.json()
    except json.JSONDecodeError:
        return _error_response(None, PARSE_ERROR, "JSON non valido")

    # Valida struttura JSON-RPC base
    if body.get("jsonrpc") != "2.0" or "method" not in body:
        return _error_response(body.get("id"), INVALID_REQUEST, "Formato JSON-RPC non valido")

    msg_id = body.get("id")  # None per le notifiche
    method = body["method"]
    params = body.get("params", {})

    # Le notifiche (no id) non richiedono risposta
    if msg_id is None:
        logger.debug("Notifica MCP ricevuta: %s", method)
        return JSONResponse(content=None, status_code=204)

    handler = HANDLERS.get(method)
    if not handler:
        return _error_response(msg_id, METHOD_NOT_FOUND, f"Metodo sconosciuto: {method}")

    try:
        # Dispatch al handler corretto con gli argomenti necessari
        if method == "tools/call":
            result = await handler(params)
        elif method == "initialize":
            result = await handler(params)
        else:
            result = await handler()

        return JSONResponse(content={
            "jsonrpc": "2.0",
            "id": msg_id,
            "result": result,
        })

    except MCPError as exc:
        return _error_response(msg_id, exc.code, exc.message)
    except Exception as exc:
        logger.exception("Errore interno nell'handler MCP %s", method)
        return _error_response(msg_id, INTERNAL_ERROR, "Errore interno del server")


def _error_response(msg_id: Any, code: int, message: str) -> JSONResponse:
    """Costruisce una risposta JSON-RPC di errore conforme allo spec MCP."""
    return JSONResponse(content={
        "jsonrpc": "2.0",
        "id": msg_id,
        "error": {"code": code, "message": message},
    })

Punti chiave dell'implementazione: il dispatcher usa un dizionario HANDLERS invece di un if/elif chain, mantenendo il codice estendibile. Le notifiche MCP (messaggi senza id) vengono riconosciute e gestite silenziosamente con un 204. Gli errori sono catturati a due livelli: errori MCP attesi (schema validation, tool not found) e errori interni imprevisti, ognuno con il codice corretto.

Sezione 4 - Tool Registry: configurazione YAML e caricamento

Il tool registry carica la definizione degli strumenti da un file YAML al boot. Questo approccio mantiene la configurazione separata dal codice e permette di aggiungere nuovi strumenti senza modificare il server.

# config/tools.yaml
# Registry degli strumenti MCP disponibili nel wrapper Ollama

tools:
  - name: read_file
    description: "Legge il contenuto testuale di un file dal filesystem locale"
    executor: filesystem
    inputSchema:
      type: object
      properties:
        path:
          type: string
          description: "Percorso assoluto del file da leggere"
        encoding:
          type: string
          description: "Encoding del file (default: utf-8)"
          default: "utf-8"
      required:
        - path
    security:
      allowedBasePaths:
        - "/opt/portfolio/data"
        - "/tmp/mcp-sandbox"
      maxFileSizeBytes: 1048576  # 1MB

  - name: query_articles
    description: "Esegue una query SQL di sola lettura sul database articoli del portfolio"
    executor: database
    inputSchema:
      type: object
      properties:
        sql:
          type: string
          description: "Query SQL SELECT (solo lettura, INSERT/UPDATE/DELETE vietati)"
        limit:
          type: integer
          description: "Numero massimo di righe restituite (default: 10, max: 100)"
          default: 10
          minimum: 1
          maximum: 100
      required:
        - sql
    security:
      readOnly: true
      allowedTables:
        - articles
        - categories
        - tags

  - name: search_knowledge_base
    description: "Cerca nella knowledge base RAG del portfolio usando embedding semantici"
    executor: rag
    inputSchema:
      type: object
      properties:
        query:
          type: string
          description: "Query in linguaggio naturale da cercare nella KB"
        top_k:
          type: integer
          description: "Numero di risultati semantici da restituire (default: 5)"
          default: 5
          minimum: 1
          maximum: 20
      required:
        - query

Il file YAML per ogni strumento specifica: name (identificatore univoco), description (usata dal modello per decidere quando invocare il tool), executor (quale classe Python esegue il tool), inputSchema (JSON Schema standard per validazione), e security (vincoli di sicurezza specifici per tool, come path allowlist o read-only flag). Il registry li carica e li serializza nel formato atteso dalla spec MCP per la risposta tools/list.

Sezione 5 - Tool concreti: Filesystem Reader e Database Query

Con il registry definito, implementiamo i due tool più comuni. Il filesystem tool applica una allowlist di path base per evitare path traversal attacks. Il database tool si connette al PostgreSQL locale del portfolio e intercetta tentativi di scrittura prima che arrivino al DB.

# ml_service/mcp/tools/filesystem_tool.py
# Tool MCP per lettura file con sandboxing e size limit

import os
import pathlib
from ..tool_registry import ToolSecurityConfig


async def read_file_tool(path: str, encoding: str = "utf-8",
                          security: ToolSecurityConfig = None) -> str:
    """
    Legge un file dal filesystem applicando sandboxing.
    Lancia ValueError per violazioni di sicurezza (mai silenzioso).
    """
    resolved = pathlib.Path(path).resolve()

    # Verifica che il path sia dentro uno dei base path consentiti
    allowed_bases = security.allowed_base_paths if security else []
    if not any(str(resolved).startswith(base) for base in allowed_bases):
        raise ValueError(
            f"Accesso negato: {resolved} non è dentro i path consentiti: {allowed_bases}"
        )

    if not resolved.exists():
        raise FileNotFoundError(f"File non trovato: {resolved}")

    if not resolved.is_file():
        raise IsADirectoryError(f"Il path punta a una directory, non un file: {resolved}")

    # Controlla la dimensione prima di leggere
    max_bytes = security.max_file_size_bytes if security else 1_048_576
    file_size = resolved.stat().st_size
    if file_size > max_bytes:
        raise ValueError(
            f"File troppo grande: {file_size} byte (limite: {max_bytes} byte)"
        )

    # Lettura sicura con encoding esplicito
    content = resolved.read_text(encoding=encoding, errors="replace")
    lines = content.splitlines()

    # Restituisce un sommario se il file è molto lungo
    if len(lines) > 500:
        return (
            f"[File troncato - {len(lines)} righe totali, mostro le prime 500]\n"
            + "\n".join(lines[:500])
        )
    return content
# ml_service/mcp/tools/database_tool.py
# Tool MCP per query SQL read-only sul database PostgreSQL del portfolio

import re
import asyncpg
from typing import Any

# Pattern per rilevare statement di scrittura (whitelisting approach)
_WRITE_PATTERN = re.compile(
    r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b",
    re.IGNORECASE,
)

_DB_DSN = "postgresql://fedcal01@localhost:5432/portfolio"


async def query_articles_tool(sql: str, limit: int = 10) -> str:
    """
    Esegue una query SQL di sola lettura sul DB PostgreSQL del portfolio.
    Applica un limite massimo di righe e blocca qualsiasi statement di scrittura.
    """
    # Validazione: blocca statement di scrittura prima di connettersi
    if _WRITE_PATTERN.search(sql):
        raise ValueError(
            "Query non consentita: contiene statement di scrittura "
            "(INSERT/UPDATE/DELETE/DROP/...). Solo SELECT è permesso."
        )

    # Applica LIMIT se non già presente nella query
    sql_normalized = sql.strip().rstrip(";")
    if "LIMIT" not in sql_normalized.upper():
        sql_normalized = f"{sql_normalized} LIMIT {min(limit, 100)}"

    # Connessione asincrona con timeout esplicito (evita query lunghe)
    conn: asyncpg.Connection = await asyncpg.connect(
        _DB_DSN, timeout=10.0, command_timeout=5.0
    )
    try:
        rows = await conn.fetch(sql_normalized)
    finally:
        await conn.close()

    if not rows:
        return "Nessun risultato trovato."

    # Formatta i risultati come tabella testuale per il modello
    headers = list(rows[0].keys())
    header_line = " | ".join(headers)
    separator = "-" * len(header_line)
    data_lines = [" | ".join(str(row[h]) for h in headers) for row in rows]

    return "\n".join([header_line, separator] + data_lines)

La scelta di restituire testo strutturato (tabella plain text) invece di JSON è deliberata: Ollama consuma il risultato del tool come messaggio di tipo tool nel thread di conversazione, e il modello elabora testo molto meglio di JSON nidificato quando deve continuare a ragionare sulla risposta.

Sezione 6 - Error Protocol Handler

Lo spec MCP definisce codici di errore standard derivati da JSON-RPC 2.0. Usare i codici sbagliati causa problemi di debug difficili: un client MCP che riceve -32603 Internal Error per un argomento mancante non può distinguere se il problema è suo o del server.

# ml_service/mcp/error_codes.py
# Codici errore MCP conformi allo spec JSON-RPC 2.0 + estensioni MCP

# Errori standard JSON-RPC 2.0
PARSE_ERROR = -32700       # JSON non parsabile
INVALID_REQUEST = -32600   # Struttura JSON-RPC non valida
METHOD_NOT_FOUND = -32601  # Metodo/tool non esiste
INVALID_PARAMS = -32602    # Parametri non validi per il metodo
INTERNAL_ERROR = -32603    # Errore interno del server

# Estensioni MCP nel range -32000 / -32099 (riservato per server errors)
TOOL_EXECUTION_ERROR = -32000   # Errore durante l'esecuzione del tool
SECURITY_VIOLATION = -32001     # Tentativo di accesso non autorizzato
RATE_LIMIT_EXCEEDED = -32002    # Rate limit superato a livello MCP
SESSION_NOT_INITIALIZED = -32003  # tools/call prima di initialize


class MCPError(Exception):
    """
    Eccezione che porta codice e messaggio MCP-conformi.
    Viene catturata dal dispatcher e convertita in error response JSON-RPC.
    """
    def __init__(self, code: int, message: str, data: dict | None = None):
        super().__init__(message)
        self.code = code
        self.message = message
        self.data = data  # campo opzionale per dettagli aggiuntivi


def make_error_response(msg_id: any, code: int, message: str,
                         data: dict | None = None) -> dict:
    """
    Costruisce una error response JSON-RPC 2.0 completa.
    data può contenere informazioni aggiuntive di debug (non esporre stack trace).
    """
    error_obj: dict = {"code": code, "message": message}
    if data:
        error_obj["data"] = data

    return {
        "jsonrpc": "2.0",
        "id": msg_id,
        "error": error_obj,
    }


# Esempio di uso nel layer di esecuzione tool:
# try:
#     result = await execute_tool(name, args)
# except FileNotFoundError as e:
#     raise MCPError(TOOL_EXECUTION_ERROR, f"File non trovato: {e}")
# except PermissionError:
#     raise MCPError(SECURITY_VIOLATION, "Accesso negato al percorso richiesto")

Sezione 7 - Security Layer: autenticazione e rate limiting

Un MCP server esposto anche solo su rete locale deve avere autenticazione. Senza di essa, qualsiasi processo sulla stessa macchina (o sulla stessa rete) può invocare strumenti con accesso al filesystem e al database. Implementiamo due livelli: Bearer token per autenticazione, e sliding window rate limiter per-IP per prevenire abusi.

# ml_service/mcp/auth.py e rate_limit.py
# Security layer per il wrapper MCP Ollama

import os
import time
import hashlib
import secrets
from collections import defaultdict, deque
from fastapi import HTTPException, Header
from typing import Annotated


# --- Autenticazione Bearer Token ---

def _load_api_keys() -> set[str]:
    """
    Carica i token API accettati dalla variabile d'ambiente.
    Formato: MCP_API_KEYS=key1,key2,key3
    Mai hardcodare token nel codice sorgente.
    """
    raw = os.environ.get("MCP_API_KEYS", "")
    if not raw:
        raise RuntimeError(
            "MCP_API_KEYS non impostata. Impossibile avviare il server MCP senza autenticazione."
        )
    # Hashiamo le chiavi in memoria per evitare timing attacks nella comparazione
    return {hashlib.sha256(k.strip().encode()).hexdigest() for k in raw.split(",") if k.strip()}


_VALID_KEY_HASHES: set[str] = _load_api_keys()


async def verify_token(authorization: Annotated[str | None, Header()] = None) -> str:
    """
    Dependency FastAPI: verifica il Bearer token nell'header Authorization.
    Usa comparazione constant-time per evitare timing attacks.
    """
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Token di autenticazione mancante o malformato")

    token = authorization.removeprefix("Bearer ").strip()
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    # secrets.compare_digest è constant-time (previene timing oracle)
    if not any(secrets.compare_digest(token_hash, valid) for valid in _VALID_KEY_HASHES):
        raise HTTPException(status_code=403, detail="Token non valido")

    return token


# --- Rate Limiter Sliding Window ---

class RateLimiter:
    """
    Rate limiter a finestra scorrevole per-IP.
    Thread-safe per FastAPI async (GIL protegge le operazioni deque).
    """
    def __init__(self, requests_per_minute: int = 60):
        self._rpm = requests_per_minute
        self._window_sec = 60.0
        # Dizionario IP -> deque di timestamp (secondi epoch float)
        self._windows: dict[str, deque] = defaultdict(deque)

    def allow(self, client_ip: str) -> bool:
        """
        Ritorna True se la richiesta è consentita, False se supera il rate limit.
        Rimuove i timestamp fuori dalla finestra prima di contare.
        """
        now = time.monotonic()
        window = self._windows[client_ip]

        # Rimuovi timestamp più vecchi di 60 secondi
        cutoff = now - self._window_sec
        while window and window[0] < cutoff:
            window.popleft()

        if len(window) >= self._rpm:
            return False  # Limite raggiunto

        window.append(now)
        return True

Alcune scelte di design esplicite: i token API vengono hashati in memoria al boot (non confronto in chiaro), secrets.compare_digest previene timing oracle attacks, e il rate limiter usa time.monotonic() invece di time.time() per essere immuno agli aggiustamenti dell'orologio di sistema. La variabile MCP_API_KEYS non va mai nel repo - entra in .env.prod sul VPS.

Sezione 8 - Integrazione nel portfolio: ml-service come MCP client verso Ollama

Nel ml-service del portfolio, il wrapper MCP si inserisce tra i route handler FastAPI e Ollama. Quando un utente fa una query all'endpoint /internal/ai/chat, il servizio costruisce un client MCP temporaneo, inizializza la sessione, chiede la lista dei tool disponibili, passa la lista al modello Ollama come contesto, e gestisce l'eventuale tool call loop fino alla risposta finale.

Il pattern che emerge è quello di un ReAct loop (Reasoning + Acting) gestito manualmente: il modello ragiona sulla query, decide se invocare un tool, il client MCP esegue il tool, il risultato torna al modello come messaggio tool, il modello ragiona di nuovo. Il loop si interrompe quando il modello produce una risposta finale senza invocare tool.

In termini di numeri concreti sul portfolio: l'overhead del wrapper è stabile sotto i 4ms per request nelle misurazioni locali. Il collo di bottiglia rimane Ollama con llama3.1:8b in 4-bit GGUF: circa 180-250ms per la prima generazione di token, poi 40-60 token/sec. Il tool execution aggiunge 5-30ms a seconda del tool (filesystem < 5ms, database 10-30ms a seconda della query). Il loop completo con un singolo tool call costa circa 400-600ms end-to-end - accettabile per un servizio interno.

FAQ

Cos'è il Model Context Protocol (MCP)?

MCP è un protocollo open source basato su JSON-RPC 2.0, originariamente sviluppato da Anthropic nel novembre 2024 e donato alla Linux Foundation nel dicembre 2025. Definisce come un host LLM (ad esempio Claude Desktop o un'applicazione custom) comunica con server esterni che espongono strumenti, risorse e prompt template in modo standardizzato. In meno di 18 mesi il registry community ha superato 2.000 implementazioni e il protocollo è stato adottato da OpenAI, Google DeepMind e Microsoft.

Ollama supporta MCP nativamente?

No. Ollama espone un'API REST compatibile OpenAI ma non implementa il protocollo MCP. A partire dalla versione 0.3 supporta il campo tools nel payload della chat nel formato OpenAI function calling - diverso da MCP. Per usare MCP con Ollama devi costruire un proxy wrapper che riceve richieste MCP (JSON-RPC), le traduce in chiamate all'API Ollama e restituisce risposte conformi allo spec MCP.

Qual è la differenza principale tra MCP e OpenAI function calling?

OpenAI function calling è strettamente accoppiato all'API OpenAI: definisci le funzioni nel payload della chat e il modello restituisce un tool_calls block. MCP è un protocollo separato e indipendente dal modello: un MCP server espone strumenti tramite JSON-RPC e qualsiasi client MCP-compatibile può invocarli, indipendentemente dal provider LLM sottostante. MCP è stateful (sessione negoziata), OpenAI function calling è stateless.

Quanta latenza aggiunge il wrapper MCP rispetto a chiamare Ollama direttamente?

Con un'implementazione FastAPI async e il tool registry in memoria, l'overhead del wrapper è tipicamente sotto i 5ms per round-trip. Il collo di bottiglia rimane l'inferenza Ollama stessa (centinaia di ms). In produzione, misura il p99 end-to-end e aggiusta il connection pool verso Ollama se vedi picchi superiori ai 50ms di overhead netto.

Come gestisco l'autenticazione nel MCP server?

La spec MCP non impone un meccanismo di autenticazione specifico a livello di trasporto. In pratica, per un server HTTP/SSE conviene aggiungere un middleware FastAPI che verifica un header Authorization: Bearer <token>. Per ambienti locali (stdio transport) l'autenticazione è gestita dall'OS tramite permessi file e socket Unix. Mai esporre il wrapper MCP su porte pubbliche senza autenticazione.

Posso usare MCP in produzione per un'applicazione multi-utente?

Sì, ma devi aggiungere esplicitamente: autenticazione per-utente, rate limiting per prevenire abusi, isolamento dei contesti (ogni sessione MCP è stateful, quindi gestisci session ID separati), e audit logging per tracciare quali strumenti ogni utente ha invocato e con quali argomenti. Il wrapper che abbiamo costruito ha già autenticazione e rate limiting; il session management è il passo successivo.

MCP funziona con modelli diversi da Llama su Ollama?

Sì. Il wrapper MCP è agnostico rispetto al modello: puoi puntarlo verso qualsiasi modello disponibile in Ollama (Mistral, Phi-3, Gemma, Qwen, ecc.). L'unico requisito è che il modello supporti tool calling nel formato messages-based. I modelli sotto i 3B parametri tendono a ignorare le istruzioni di tool use; testa sempre con il modello target prima di andare in produzione.

Conclusione

Il Model Context Protocol risolve un problema reale: standardizza come i LLM si connettono agli strumenti, eliminando il vendor lock-in e permettendo di costruire ecosistemi interoperabili. Ollama non lo supporta nativamente, ma con un wrapper FastAPI di circa 300 righe hai un MCP server completo che parla il protocollo standard e richiama Ollama sotto - senza cambiare nulla nell'infrastruttura esistente.

I punti chiave da portare a casa: il protocollo usa JSON-RPC 2.0 su HTTP/SSE o stdio, la sessione è stateful con handshake initialize obbligatorio, il tool registry è dinamico (puoi aggiungerci strumenti senza riavviare il server), e il security layer non è opzionale anche in ambiente locale. L'overhead del wrapper è trascurabile rispetto alla latenza di inferenza Ollama.

Prossimo episodio: EP10 - Hybrid cloud-fallback strategy: come gestire il caso in cui Ollama va offline alle 2 AM, quando aggiungere un failover cloud, e come tenere sotto controllo i costi quando il fallback si attiva.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti