Constraining the Agent: What I Learned Wiring MCP Into a Legacy System A developer connecting an AI agent to a legacy PHP application and MySQL database with write access found the capability trivial to set up but the engineering of constraints much harder. The agent correctly identified a real undocumented bug, but the developer realized the gap between easy capability and necessary safety measures. They built a disposable dummy database and advocate for dedicated read-only accounts as the default, with write access as a deliberate escalation. Notes from January 2026, when giving an AI agent live database access still felt reckless. Written for engineers who are about to do this, and for the leads deciding whether they should. I had just finished connecting a coding agent to two things at once: the filesystem of a legacy PHP application, and the MySQL database behind it. Both with write access. It took about twenty minutes. Then I ran a test prompt, watched the agent read a table definition, cross reference it against a model file, and correctly identify a column that existed in the database but had never been declared in the code, a real bug, in a system nobody had documented in years, and I felt genuinely excited for about ninety seconds. Then I thought about what else it could do with those same permissions. I had just handed a non-deterministic process the ability to modify source files and issue arbitrary SQL against a system that people depend on. The capability was trivial to set up. The judgment about whether it should have that capability was entirely mine, and nothing in those twenty minutes had asked me for it. That gap, between how easy the capability is and how much engineering the constraint requires, is what this article is about. The bottleneck in modernizing an old system has never been typing speed. It's context. You cannot safely change what you don't understand, and understanding is expensive because the knowledge is distributed across a database schema, a codebase, and assumptions nobody wrote down. This is exactly where AI assistants used to fall apart. Ask a model to query a table it has never seen and it produces something like this: -- What the model guesses from a million tutorials SELECT id, email, created at FROM users WHERE is active = 1; Confident, clean, and useless, because the legacy schema actually looks like this: -- What exists once the agent can read the schema SELECT id usr pk, correo e, fec reg FROM tbl usr sys WHERE estado reg = 'A'; Both queries are syntactically perfect. Only one of them runs. MCP closes this gap. Instead of guessing the schema, the agent queries it. Instead of guessing the model class, it reads the file. Generated code stops being a plausible looking artifact and starts being grounded in what actually exists. That's a real improvement. It's also what makes the security question urgent rather than theoretical, because grounding requires access, and access is the whole risk. What I ended up building was less about capability and more about blast radius. Before anything else, I built a dummy database, same schema, synthetic data, no relationship to anything real. Every agent driven exploration, every "what happens if we add this column," ran against the copy. This is not a sophisticated idea. It's the same instinct that stops you from testing a migration against production. But it's worth stating plainly, because the tooling makes it easy to skip: the connection string is the only difference between a safe experiment and an incident, and nothing in the setup process prompts you to notice which one you're configuring. The disposable database is also what makes everything downstream cheaper. When the worst case is "restore a snapshot," you can afford to let the agent be wrong. A database MCP server has no permissions of its own. It executes queries as whatever user you gave it. If that user can DELETE , the agent can DELETE . The correct default is a dedicated account with SELECT only, used for every task that involves understanding the system rather than changing it, which, in modernization work, is most of them. Write access should be a deliberate escalation for a specific task, not the standing configuration. I understood this later than I should have. The configuration I first implemented used a full privilege account, and it worked, so I didn't question it. Working is not the same as correct. Agents are good at shell commands, and shell commands are how you lose work. I added a permissions config that blocks version control operations outright. The shape looks like this: // .claude/settings.json — illustrative { "permissions": { "deny": "Bash git commit: ", "Bash git push: ", "Bash git rebase: ", "Bash git reset: ", "Bash rm -rf: " } } The reasoning: git is the undo button. If the agent can operate the undo button, there is no undo button. Every other guardrail assumes I can revert to a known good state, so the mechanism that provides known good states has to sit outside the agent's reach. In practice this cost nothing. I commit manually, which I was doing anyway, and it means every change passes through a moment where a human reads a diff and decides. Rather than one agent doing everything, I defined several with narrow mandates, each a separate definition in the project's .claude/agents/ folder: Here is the security reviewer, trimmed to its essentials: .claude/agents/security-reviewer.md — illustrative --- name: security-reviewer description: Reviews diffs for security issues only. Run after any implementation. --- You receive a diff and nothing else. You do not evaluate functionality, style, or performance. Look specifically for: - SQL built by string concatenation - Authorization checks that trust client supplied identifiers - Secrets or connection strings appearing in code - Input that reaches file paths, shell commands, or queries unvalidated Output: findings with file, line, severity, and the attack you would attempt. If you find nothing, state exactly what you checked. The reason to split roles is not that five agents are smarter than one. It's that a single agent asked to implement and critique its own work in the same context will tend toward agreement with itself. A fresh context with an adversarial brief produces meaningfully different output than "now check your work." Separation of duties is an old control; it translates almost directly. Alongside the agents, a CLAUDE.md at the project root with persistent instructions that load into every session. This is where you encode everything the model's generic priors will get wrong about your system. A skeleton of mine, placeholders only: CLAUDE.md — skeleton Hard rules - Only ever connect to the development database. Never any other host. - Never run git commands. A human commits. - Never modify /vendor or applied migrations. - Schema changes are written as migration files, never executed directly. Things you would guess wrong - Primary keys follow id