# I scored my own MCP server

Language: en

Identifiant : article:auditer-ses-propres-mcp

Noa Matout

Everyone audits other people's servers. I measured mine, and a good score did not mean a closed door.

In May 2026, the NSA published a guidance sheet on the security of MCP servers.
In July, the Cloud Security Alliance noted that the main development
environments automatically launch the MCP servers declared in a project, with
the developer's rights and without process isolation.

My first reflex was to wonder which of the servers I use were affected. It was
the wrong reflex. I write one myself, and it runs in production on my server.

## The first measurement

`systemd-analyze security` gives a service an exposure score, from zero to ten,
by reading the confinement directives it declares. The lower the score, the
better. My MCP server, the one that queries my training data, scores
**9.2**.

```
Overall exposure level: 9.2 UNSAFE
```

No hardening directive at all. It runs under my agent's main account, with full
access to the file system, no memory limit, no system call filter. I had written
it carefully on the code side, and launched it without any precaution on the
system side.

For comparison, I measured a service I had written that same week while paying
attention, with a dynamic user, a protected file system and capped memory. It
scores 8.1.

```
Overall exposure level: 8.1 EXPOSED
```

Eight out of ten while applying good practice. The score is harsh, and it is
right to be: a service that keeps network access, no system call filter and all
the default capabilities is still a wide surface, however clean it is otherwise.

## The second measurement, which wiped the smile off my face

An exposure score reads what a service **declares**. It never checks what a
process can **reach**. I wanted to close that gap with a probe: a small program
that tries to open a connection to the internet, read my agent's API keys, and
write outside its scope. I ran it twice: without confinement, then inside a
transient service declaring everything it should, private network, home
directory read-only, protected file system, isolated temporary space.

Without confinement:

```
RESEAU ATTEINT
CONFIG AGENT LUE
ECRITURE /tmp REUSSIE
```

With confinement:

```
RESEAU ATTEINT
CONFIG AGENT LUE
ECRITURE /tmp REUSSIE
```

Strictly identical. The confinement had confined nothing. (The probe prints in
French: network reached, agent config read, write to /tmp succeeded.)

## What the log said, and that nobody reads

The explanation had been there from the start, in the system log, on a line I
had no reason to look for:

```
PrivateNetwork=yes is configured, but the kernel does not support
or we lack privileges for network namespace, proceeding without.
```

"Proceeding without". It warns, then carries on without the protection. The
command succeeded, the service started, the exit code was zero, and the
requested protection did not exist.

It is a question of privileges: a user session manager cannot create a network
namespace. The same directives applied by the administrator work, and the probe
then reports an unreachable network. So the confinement is correct; the rights
were what was missing.

I tried the other route, the one that in theory requires no privilege:
`bubblewrap`, the isolation tool behind Flatpak. Immediate refusal:

```
bwrap: setting up uid map: Permission denied
```

Recent versions of Ubuntu restrict unprivileged user namespaces, through a
setting that is on by default. With it lifted, bubblewrap does the job
perfectly: network cut, keys outright nonexistent since the home directory is
replaced by an empty file system. But lifting that setting requires the
administrator, and it protects against real attacks.

## What it means

On a recent machine, **an unprivileged user cannot confine a process** without
the administrator stepping in first. Neither through systemd, nor through
bubblewrap.

Yet that is exactly the situation the Cloud Security Alliance describes: a
developer clones a repository, the repository ships an MCP configuration, and
their environment launches the declared servers with the developer's own
rights. They are not the administrator of their work machine, and the tool that
promised them a sandbox without privileges would hand them a sandbox that does
not exist.

So the worst case is not the absence of confinement. It is confinement that is
declared and silently dropped, because it produces exactly the same
configuration file, the same absence of error, and a confidence with no
foundation.

## What it looks like once fixed

On this server, I am the administrator, so the privilege problem does not
exist: it is enough to make it a properly hardened system service. I wrote an
override file rather than rewriting the unit, and the paths open for writing are
not guessed: they are taken from the file descriptors the process actually had
open.

The score goes from 9.2 to
**1.5**.

```
avant   9.2  UNSAFE
apres   1.5  OK
```

Ingestion still works, the Garmin sync resumed without a single error, and the
proof does not rest on the score. It rests on what the running process shows of
itself: no capabilities, `no_new_privs` set, a mount namespace separate from the
system's, a system call filter in place.

## The detour that teaches the most

My first version scored 1.6 and looked
finished to me. Then I ran my probe again inside that confinement:

```
LU        <configuration de l'agent, avec ses clés>
LU        <un fichier de mémoire de l'agent>
refuse    ecriture hors perimetre
```

An excellent score, and the MCP server could still read my agent's API keys and
its memory. I had made the home directory read-only, which prevents writing, not
reading. And to steal a key, reading is enough.

The fix is to mask the home directory with an empty file system and re-expose
only the launcher, then to make the agent's memory folders explicitly
inaccessible: they are none of a sports ingestion service's business. Logs go to
the system journal, which removes the last need to write there. Same probe,
afterwards:

```
refuse    <configuration de l'agent>   (FileNotFoundError)
refuse    <fichier de mémoire>         (PermissionError)
ECRIT     sa base, normal
RESEAU    garmin joignable, normal
```

The configuration file does not return a permission error: it simply no longer
exists for this process.

The score gain between 1.6 and
1.5 is negligible. The real gain is the
difference between a server that can read my keys and a server that cannot.
**A good score is not a closed door.**

## And this time, the failure is loud

Along the way, I left an inconsistency: a path allowed for writing that no
longer existed once the home directory was masked. The service refused to
start.

```
Failed to set up mount namespacing: No such file or directory
Main process exited, code=exited, status=226/NAMESPACE
```

It is the exact opposite of the unprivileged behaviour described above. At the
system level, systemd fails closed: no protection, no start. At the user level,
it starts without the protection and writes a line in a log. The same request,
two opposite philosophies, and only the first one is defensible.

## What I am writing next

A tool for the part that bothered me most: checking what an MCP server can
actually reach, by launching a probe under the same startup conditions rather
than rereading the declared directives. Without ever running the server itself,
so that auditing a hostile server does not mean handing it control.

It is published: [See the code of the tool that measures what a server can reach](https://github.com/NoaMatout/mcp-reach). Two of its tests are negative controls, and
they are what it stands on: a probe launched under real confinement must report
the network as unreachable, and without confinement must report it reachable. A
tool that gave the same answer everywhere would pass its whole suite while being
useless.

## The lesson, beyond MCP

A protection you have never seen refuse something does not exist.

I had written the right directives. I had never seen them block anything.
Between the two lies precisely the difference between a configuration and a
guarantee.

## What I could not verify

Whether the behaviour is the same on other distributions: my measurements cover
only one, in a recent version. Ubuntu's namespace setting is specific to it,
even though systemd's privilege limitation is not.

Nor have I audited the MCP servers I use without having written them. That is
next on my list.

## Sources

- [NSA guidance on MCP security](https://www.nsa.gov/Portals/75/documents/Cybersecurity/CSI_MCP_SECURITY.pdf),
  "Model Context Protocol (MCP): Security Design Considerations for AI-Driven
  Automation", published in May 2026.
- [Cloud Security Alliance note on automatic execution](https://labs.cloudsecurityalliance.org/research/csa-research-note-mcp-tool-poisoning-auto-execution-20260701/),
  research note of July 1, 2026, on automatic execution by development
  environments.

The measurements on my own server are mine, reproducible with the commands
quoted. Service names and paths have been removed from the outputs shown.

## Scope of the measurements

exposureBefore : 9.2 out of 10. Exposure score of my MCP server before hardening, as reported by systemd's analysis tool. The lower, the better.

meterExposure : 8.1 out of 10. Exposure score of another of my services, written with the usual good practice, recorded at the same time.

exposureFirstPass : 1.6 out of 10. Score of the first hardened version, which could still read the agent's keys.

exposureAfter : 1.5 out of 10. Score of the final version, where the agent's keys and memory are out of reach.

## I check what gets through.

Same probe, same machine. Only the confinement changes.

### Without confinement

- Open a connection to the internet : reached.

- Read the agent’s API keys : read.

- Write outside its scope : succeeded.

### Under real confinement

- Open a connection to the internet : unreachable.

- Read the agent’s API keys : nonexistent for this process.

- Write outside its scope : denied.

[See the code of the tool that measures what a server can reach](https://github.com/NoaMatout/mcp-reach)
