Skip to main content
Glama

loom-fs — serveur MCP filesystem à rôles

Serveur MCP qui expose une vue restreinte du système de fichiers à un agent. Chaque répertoire accessible est déclaré explicitement, avec un rôle qui détermine ce qui peut y être fait.

Écrit pour remplacer @modelcontextprotocol/server-filesystem, qui accorde lecture, écriture et suppression sur toute racine passée en argument, sans granularité ni filtrage des fichiers sensibles.


Modèle de sécurité

Le rôle est un plafond

Un rôle définit le maximum de droits accordables. Une racine peut restreindre en dessous via permissions, jamais élargir. Une tentative d'élargissement échoue au chargement de la configuration, pas à l'exécution.

Rôle

Plafond

Usage visé

reference

read

documentation, specs, données à ne jamais modifier

sourcecode

read, write

un dépôt de code — modifie, ne supprime pas

workspace

read, write, delete

dossier de travail jetable

Le point de passage unique

security.resolve() est le seul endroit qui transforme une chaîne fournie par le modèle en chemin réel. Tous les outils y passent. Il applique trois contrôles dans cet ordre, et le message d'erreur indique toujours lequel a mordu :

  1. Permission — l'opération est-elle dans les droits de la racine ? → root 'x' (role reference) grants [read], not write

  2. Confinementos.path.realpath est appliqué à la cible et à la racine, puis on vérifie l'inclusion sur les chemins résolus. → path escapes the root

  3. Motifs de refus — appliqués à chaque composant du chemin relatif. → matches deny pattern '.env'

Résoudre les symlinks avant le contrôle d'inclusion est ce qui fait échouer un lien sortant ici plutôt qu'au open(). Une racine atteinte via un symlink fonctionne normalement : elle est résolue une fois au chargement.

Les cibles inexistantes résolvent correctement — realpath résout le préfixe existant et concatène le reste — donc la création de fichier ne bute pas sur le contrôle de confinement.

Motifs de refus

Trois niveaux qui s'additionnent, jamais ne se soustraient :

  1. BASE_DENY, en dur dans le code, non désactivable ;

  2. deny au niveau de la configuration, appliqué à toutes les racines ;

  3. deny d'une racine particulière.

.env  .env.*  *.pem  *.key  *.p12  *.pfx
id_rsa*  id_ecdsa*  id_ed25519*
.ssh  .aws  .gnupg  .netrc  .npmrc  .pypirc
.git  .venv  node_modules  __pycache__  .mcp-trash

La liste de base reste dans le code délibérément. Si elle passait en configuration, elle deviendrait retirable — et c'est exactement le genre de ligne qu'on commente « juste pour ce test » un soir. Une garantie qu'un fichier de config peut désactiver n'est plus une garantie. extra = "forbid" sur Config fait qu'une tentative de la redéfinir depuis le TOML échoue au chargement au lieu d'être ignorée en silence.

Ces motifs filtrent aussi les résultats : un fichier refusé n'apparaît ni dans list_directory ni dans search_files. Le modèle ne sait pas qu'il existe.

Ce que ce filtrage n'est pas

Le filtrage porte sur des noms. Un secret codé en dur dans settings.py est lu sans obstacle. C'est une heuristique qui attrape les cas usuels, pas une frontière.

La frontière, c'est le choix des racines. Le reste est du confort.


Related MCP server: Filesystem MCP

Configuration

TOML, chemin passé via --config.

audit_log = "/home/[user]/.local/state/loom-fs-mcp/audit.log"
audit_log_max_bytes = 8388608   # 8 Mio par fichier
audit_log_backups = 3           # audit.log.1 … .3, soit 32 Mio au total

max_search_results = 200
max_search_bytes = 67108864     # budget total scanné par recherche

# Motifs refusés sur toutes les racines, en plus de la liste de base du code.
deny = ["*.sqlite3", "*.dump"]

[[roots]]
name = "loom"
path = "/home/[user]/dev/loom/src"
role = "sourcecode"

[[roots]]
name = "loom-docs"
path = "/home/[user]/dev/docs"
role = "reference"

[[roots]]
name = "scratch"
path = "/home/[user]/temp"
role = "workspace"
deny = ["*.tmp"]

Champs d'une racine

Champ

Obligatoire

Détail

name

oui

^[a-z0-9][a-z0-9._-]{0,31}$, unique

path

oui

doit exister et être un répertoire ; ~ et variables d'env développés

role

oui

reference, sourcecode ou workspace

permissions

non

sous-ensemble du plafond du rôle ; défaut = plafond

deny

non

motifs supplémentaires

max_read_bytes

non

défaut : 1 Mio / 2 Mio / 8 Mio selon le rôle

Règles de validation

Toute incohérence lève au chargement — le serveur ne démarre pas en mode dégradé.

  • Un répertoire inexistant est rejeté.

  • Deux racines dont l'une contient l'autre sont rejetées : la permission effective serait ambiguë.

  • Deux racines de même nom sont rejetées.

  • Une racine sans aucune permission est rejetée.


Outils

La surface d'outils s'adapte à la configuration : les outils d'écriture ne sont enregistrés que si au moins une racine accorde write, ceux de suppression que si une racine accorde delete. Avec uniquement des racines reference, le modèle voit quatre outils de lecture et rien d'autre — pas un outil désactivé qu'il pourrait tenter d'appeler.

Outil

Permission

Détail

list_roots

racines, rôles, permissions effectives. À appeler en premier.

list_directory

read

une seule profondeur, entrées filtrées

read_file

read

UTF-8 ; refuse le binaire (octet nul dans les 4 premiers Kio) et le hors-limite

search_files

read

voir ci-dessous

write_file

write

refuse d'écraser sans overwrite=true

edit_file

write

remplace une occurrence exacte et unique ; échoue si 0 ou ≥2

move_file

write

source et destination dans la même racine

delete

delete

déplace vers la corbeille — n'efface rien

Corbeille

delete déplace la cible vers <racine>/.mcp-trash/<horodatage UTC>/. Rien n'est effacé, la restauration est manuelle. La corbeille est elle-même dans les motifs de refus, donc inaccessible via les outils — impossible de vider la corbeille par l'agent. La racine elle-même ne peut pas être supprimée.


Recherche

search_files(root, pattern, contains=None, max_results=None)

Parcours

security.iter_files() élague avant de descendre : un .venv ou un node_modules sous la racine coûte un fnmatch sur son nom, pas un parcours complet. C'est tout le gain — le reste est du détail.

Mesuré sur un arbre de 24 300 fichiers dont 300 utiles, le reste étant du .venv et du node_modules : 1224 ms → 3,2 ms, soit un facteur 380. La version précédente utilisait Path.glob() puis filtrait, donc traversait le bruit à chaque appel.

Parcours en largeur, tri par répertoire : l'ordre est déterministe, et un résultat tronqué contient les correspondances les plus proches de la racine plutôt qu'une tranche arbitraire.

Les symlinks sont ignorés en bloc. Ça règle d'un coup le confinement par entrée et les cycles — un lien circulaire ne fait plus tourner la boucle indéfiniment. Un répertoire illisible est sauté, jamais fatal au parcours.

Motifs

** traverse les répertoires, * et ? restent dans un segment, [...] définit une classe, [!...] sa négation.

Un écart assumé avec glob standard : un motif sans / matche le nom de base à n'importe quelle profondeur. *.py trouve donc tous les fichiers Python de la racine, ce que les gens attendent — et ça évite qu'un agent conclue à tort qu'il n'y en a aucun. Pour la sémantique stricte, préciser pkg/*.py.

Sont rejetés : motif vide, absolu, contenant .., ou avec un [ non fermé. Les motifs compilés sont mémoïsés.

Filtre contains

Recherche en octets, sans décodage. Avant lecture, trois gardes : la taille est comparée au max_read_bytes de la racine, les 4 premiers Kio servent à détecter le binaire, et un budget global max_search_bytes arrête le scan.

Chaque correspondance rend le chemin, le numéro de ligne et un extrait — ce qui évite un read_file de suivi.

Résultat

{
  "matches": [{"path": "loom_ia/agent.py", "line": 483, "preview": "…"}],
  "truncated": false,
  "examined_files": 36,
  "scanned_bytes": 222742,
  "skipped_too_large": 0,
  "skipped_binary": 0,
  "note": null
}

truncated et note existent pour une raison précise : une liste tronquée qui se présente comme complète, c'est un agent qui conclut « ce symbole n'existe nulle part » alors qu'il a vu les 200 premiers fichiers sur 3 000.


Installation

cd ~/dev/loom-fs-mcp
uv sync
cp roots.example.toml roots.toml   # adapter les chemins
uv run pytest                      # 47 tests

Vérifier le démarrage en direct avant de brancher le client — ça évite de debugger à travers l'application :

uv run loom-fs-mcp --config roots.toml

Le serveur doit lister ses racines puis annoncer le transport stdio.

Branchement dans Claude Desktop

~/.config/Claude/claude_desktop_config.json :

{
  "mcpServers": {
    "loom-fs": {
      "command": "/home/[user]/dev/loom-fs-mcp/.venv/bin/loom-fs-mcp",
      "args": ["--config", "/home/[user]/dev/loom-fs-mcp/roots.toml"],
      "env": {
        "FASTMCP_SHOW_SERVER_BANNER": "false",
        "FASTMCP_CHECK_FOR_UPDATES": "off"
      }
    }
  }
}

Fermer complètement l'application avant d'éditer ce fichier : elle le réécrit à la fermeture et le bloc mcpServers serait perdu.


Journal d'audit

Une ligne ALLOW ou DENY par résolution, avec la racine, le chemin relatif et l'opération. Rotation par RotatingFileHandler, bornée par audit_log_max_bytes et audit_log_backups.

Pour purger à chaud, tronquer, ne pas supprimer : le serveur garde le descripteur ouvert, et après un rm il continue d'écrire dans l'inode supprimé, invisible, sans rendre l'espace avant l'arrêt du processus.

truncate -s 0 ~/.local/state/loom-fs-mcp/audit.log

Même raison si tu passes par logrotate : il faut copytruncate.

La rotation se fait dans le processus qui écrit. Plusieurs instances du serveur sur le même fichier se marcheraient dessus — un fichier par instance, ou WatchedFileHandler avec logrotate externe.


Dépannage

L'application reste bloquée au lancement. FastMCP interroge PyPI au démarrage (check_for_updates, défaut stable) et affiche une bannière. L'appel réseau s'intercale entre l'exec() et la réponse au initialize ; si le réseau est lent ou filtré, le client attend. Les deux variables d'environnement ci-dessus les coupent ; main() les pose aussi par setdefault, donc le serveur est correct même lancé sans environnement particulier.

FASTMCP_CHECK_FOR_UPDATES attend off — c'est un Literal['stable', 'prerelease', 'off']. Un booléen fait échouer la validation des réglages au démarrage.

uv run comme command. À éviter : uv résout les dépendances, et peut installer voire télécharger un interpréteur, sans rien écrire sur stdout pendant ce temps. Pointer directement .venv/bin/loom-fs-mcp supprime toute résolution entre l'exec() et le handshake. Si uv est indispensable, ajouter --frozen --no-sync.

Le serveur sort immédiatement en erreur. Presque toujours roots.toml : un répertoire déclaré n'existe pas, ou deux racines sont imbriquées. Le message le dit explicitement — le lancer en direct pour le lire.

Un refus inattendu. Le message nomme la règle qui a mordu. Le journal d'audit donne le détail.


Limites connues

Pas de quota ni de limitation de débit

Un agent en boucle peut lire des milliers de fichiers.

À noter avant d'en ajouter un : ce n'est pas un contrôle de sécurité. Si une injection détourne l'agent, un plafond à 200 fichiers laisse fuir 200 fichiers. Ce qu'un quota borne, c'est le coût et le rayon d'un emballement.

Et « par session » n'existe pas côté serveur : en stdio, le processus survit à toutes les conversations. Un compteur cumulatif finirait par se déclencher et bloquerait jusqu'au redémarrage de l'application, sans explication. S'il en faut un, ce serait un seau à jetons — régénéré dans le temps — comptant des octets plutôt que des appels : 10 000 petits fichiers ne coûtent rien, 50 fichiers de 8 Mio pèsent 400 Mio.

Pertinent le jour où ce serveur alimente un agent non surveillé. Peu utile en usage interactif.

.git est refusé

Donc pas d'historique : ni « pourquoi ce code est écrit comme ça », ni « qu'est-ce qui a changé dans ce commit ».

L'ouvrir en accès fichier ne rendrait presque rien : les objets sont compressés, donc le contrôle binaire refuse l'essentiel. Ce qui resterait lisible, c'est .git/config — qui contient des identifiants quand une remote est en HTTPS — et les hooks, qui sont du code exécuté à la prochaine opération git.

Un outil git dédié en lecture seule serait la bonne réponse, mais il est plus coûteux qu'il n'y paraît :

  • il travaille au niveau du dépôt, donc sur un périmètre plus large que les racines ;

  • l'historique contourne la liste de refus — un .env commité il y a deux ans est toujours dans les objets — il faudrait donc dériver des pathspecs :(exclude,glob) de la même liste, soit une seconde source de vérité à maintenir ;

  • lancer git dans un dépôt exécute la configuration de ce dépôt : alias, core.pager, diff.external, filtres textconv sont autant de commandes shell. Sur un dépôt cloné, c'est une exécution de code arbitraire déclenchée par un outil « lecture seule ». Neutralisation explicite obligatoire.

Écarté pour l'instant : l'archéologie git se fait en session Claude Code, dans le dépôt, où git fonctionne nativement. À reconsidérer si le besoin apparaît en Chat.

Le processus tourne hors VM

Avec les droits de l'utilisateur. Les racines sont la seule barrière réelle : leur choix est la décision de sécurité, tout le reste est de la défense en profondeur.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides secure, sandboxed file system access for AI assistants to read, write, and manage project files with controlled command execution capabilities, all confined to a designated workspace directory.
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Provides secure filesystem access for AI assistants with optimizations like file reading limits and depth-limited traversal to improve token efficiency. It enables AI models to read, write, and search files within explicitly allowed directories while automatically skipping large system folders.
    3
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides a secure, constrained filesystem workspace for LLM agents to manage files, notes, and code artifacts via stdio or remote HTTP. It features granular access controls, including extension whitelisting, storage quotas, and immutable paths for safe automated file operations.
    BSD 3-Clause
  • A
    license
    Not graded
    quality
    B
    maintenance
    Provides safe filesystem access for AI clients with root confinement, read-only mode, and file operations like read, write, search, copy, move, delete.
    MIT