
When an AI agent works on a TYPO3 project, it mostly needs to answer three questions again and again: is the Composer setup still valid, does static analysis complain, and does the site still render? The easy way is to let the agent run shell commands. The problem with the easy way is that a shell can do everything else too.
The Model Context Protocol (MCP) gives you another option: you write a small server that offers a few tools with typed inputs, and the agent can call exactly those. Nothing more. This post builds such a server with the official TypeScript SDK, with three read-only tools for a DDEV-based PHP project.
Why read-only tools are easier to trust
With a shell, every permission decision is about a string. Is ddev exec composer validate safe? Yes. Is ddev exec composer validate && rm -rf var/ safe? Claude Code splits compound commands and checks each part, but you still review strings, and strings have many shapes.
With an MCP tool, the decision is about a function you wrote. The phpstan tool below accepts a path from a fixed list and a level from 0 to 10. There is no way to pass it a second command, because the command is assembled inside the server with execFile, without a shell in between. You review the code once, and every call afterwards is boring. Boring is what I want from an agent’s tools.
Step 1: set up the project
The MCP quickstart currently uses the package @modelcontextprotocol/server together with zod for input schemas, and Node.js 20 or newer. I put the server into tools/project-checks/ of the project:
mkdir -p tools/project-checks/src && cd tools/project-checks
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescriptIn package.json set "type": "module" and a "build": "tsc" script. The tsconfig.json from the quickstart works as is (module and moduleResolution set to Node16, outDir ./build, rootDir ./src).
Step 2: the server
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const run = promisify(execFile);
const PROJECT_DIR = process.env.PROJECT_DIR ?? process.cwd();
const BASE_URL = new URL(process.env.BASE_URL ?? "https://myport.ddev.site");
// Runs a command inside the DDEV web container. No shell is involved.
async function ddev(args: string[], timeout = 120_000): Promise<string> {
try {
const { stdout, stderr } = await run("ddev", ["exec", ...args], {
cwd: PROJECT_DIR,
timeout,
maxBuffer: 2 * 1024 * 1024,
});
return (stdout + stderr).trim() || "(no output)";
} catch (error: any) {
// A non-zero exit code is a result, not a crash: report the output
return `exit ${error.code ?? "?"}n${(error.stdout ?? "") + (error.stderr ?? "")}`.trim();
}
}
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
function createServer() {
const server = new McpServer({ name: "project-checks", version: "0.1.0" });
server.registerTool(
"composer_validate",
{
description: "Run composer validate --strict in the project. Changes nothing.",
inputSchema: z.object({}),
annotations: readOnly,
},
async () => text(await ddev(["composer", "validate", "--strict", "--no-check-publish"])),
);
server.registerTool(
"phpstan",
{
description: "Run PHPStan on one of the allowed paths and return the raw report.",
inputSchema: z.object({
path: z.enum(["Source/Extension", "packages"]).describe("Directory to analyse"),
level: z.number().int().min(0).max(10).default(5),
}),
annotations: readOnly,
},
async ({ path, level }) =>
text(await ddev([
"vendor/bin/phpstan", "analyse", path,
`--level=${level}`, "--no-progress", "--error-format=raw",
], 300_000)),
);
server.registerTool(
"smoke_check",
{
description: "Request one page of the local site; report HTTP status, size and error markers.",
inputSchema: z.object({
path: z.string().regex(/^/(?!/)[A-Za-z0-9/_.-]*$/).default("/").describe("Path, e.g. /blog/"),
}),
annotations: readOnly,
},
async ({ path }) => {
const url = new URL(path, BASE_URL);
if (url.origin !== BASE_URL.origin) {
return text(`Refused: ${url} is not on ${BASE_URL.origin}`);
}
const response = await fetch(url, { redirect: "manual" });
const body = await response.text();
const markers = ["Oops, an error occurred", "Fatal error", "Uncaught", "There has been a critical error"]
.filter((marker) => body.includes(marker));
return text(`${url} -> ${response.status}, ${body.length} bytes, markers: ${markers.join(", ") || "none"}`);
},
);
return server;
}
serveStdio(createServer, {
onerror: (error) => console.error("project-checks error:", error),
});
console.error("project-checks running on stdio");A few details that matter:
- No
console.log(). A stdio server talks JSON-RPC over stdout. One stray log line there breaks the protocol. Log to stderr withconsole.error(). serveStdiotakes a factory. The current SDK callscreateServer()whenever it needs an instance, so the function must return a newMcpServereach time. The olderserver.connect(new StdioServerTransport())wiring still compiles, but according to the quickstart it only serves clients that use the older handshake.- Annotations are hints.
readOnlyHint: truetells the client what the tool does; it enforces nothing. What makes the tool read-only is its code: fixed binaries, fixed arguments, an enum for paths. - Validate inputs twice. An easy mistake is a
smoke_checkthat accepts any path starting with a slash. A path like//example.comis a protocol-relative URL, andnew URL()happily resolves it to another host. The regex above rejects a second slash at the start, and the origin check catches anything I did not think of.
PHPStan has to be installed in the project (composer require --dev phpstan/phpstan), otherwise the tool just reports the missing binary. hoja.in itself has no PHPStan yet; the tool is generic.
Step 3: build and test without an agent
Test the server before any AI gets near it. The MCP Inspector has a CLI mode:
npm run build
npx @modelcontextprotocol/inspector --cli node build/index.js --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli node build/index.js
--method tools/call --tool-name smoke_check --tool-arg path=/blog/tools/list returns the three tools with their JSON schemas: the enum for path, minimum and maximum for level, and the annotations. That is what the model sees, so read it once with the eyes of somebody who does not know your project.
Step 4: register it in .mcp.json
For Claude Code, a project-scoped server goes into .mcp.json at the project root, which you commit. Environment variables can be expanded with ${VAR} or ${VAR:-default}. The relative path and the process.cwd() fallback assume the server is started from the project root; if your setup starts it elsewhere, set PROJECT_DIR and use an absolute path:
{
"mcpServers": {
"project-checks": {
"command": "node",
"args": ["tools/project-checks/build/index.js"],
"env": {
"BASE_URL": "${PROJECT_BASE_URL:-https://myport.ddev.site}"
}
}
}
}Claude Code asks for approval before it uses a project-scoped server in an interactive session, which is right: a cloned repository should not start processes on your machine silently. After approval the tools appear as mcp__project-checks__composer_validate and so on. Allow them without prompts in .claude/settings.json:
{
"permissions": {
"allow": ["mcp__project-checks__*"]
}
}An allow rule with a wildcard is only accepted after the literal mcp__<server>__ prefix, so you cannot accidentally allow every MCP tool of every server this way.
What this does not protect
Be honest about the boundary. The MCP server runs as your user, outside Claude Code’s Bash sandbox, so its own code must be trustworthy. And if the agent still has an unrestricted Bash tool next to it, the MCP server is a convenience, not a security measure. The value comes when you combine it with permissions: allow the three MCP tools, keep Bash on “ask” or deny the commands they replace. Then the routine checks run without interruptions, and every prompt you see is about something new.
If this post helped you, I will be really happy. Cheers!