An MCP tool can expose an action to an assistant. The protocol describes how to present and call that tool. It does not decide which data your server is allowed to read. A limited action on public information is easier to control as a starting point.
Describe a single action
Imagine a tool that returns a public project’s name from its slug. No free-form SQL query, no file path and no URL supplied by the assistant. The contract stays small.
{
"name": "get_public_project",
"description": "Read the name of a public project from its slug.",
"inputSchema": {
"type": "object",
"properties": {
"slug": {
"type": "string",
"pattern": "^[a-z0-9-]{1,80}$"
}
},
"required": [
"slug"
],
"additionalProperties": false
},
"annotations": {
"readOnlyHint": true,
"destructiveHint": false,
"openWorldHint": false
}
}
The schema describes the expected arguments. The handler must still validate what it receives. A precise description helps the assistant choose a tool, but does not act as access control.
Enforce the boundary in code
Here is the function the handler can call. The catalogue comes from the server. An unknown slug or a private project returns nothing. Even by guessing its ID, the caller cannot retrieve a confidential name.
type Project = { name: string; visibility: "public" | "private" };
// Exemple de contrôle dans le gestionnaire serveur, avant toute lecture.
// Le catalogue est fourni par le serveur, jamais par les arguments de l’outil.
export function readPublicProject(
catalog: Readonly<Record<string, Project>>,
input: unknown,
): { name: string } | null {
if (!input || typeof input !== "object" || !("slug" in input)) return null;
const slug = input.slug;
if (typeof slug !== "string" || !/^[a-z0-9-]{1,80}$/.test(slug)) return null;
if (!Object.hasOwn(catalog, slug)) return null;
const project = catalog[slug];
if (project.visibility !== "public") return null;
return { name: project.name };
}
This code deliberately focuses on the read permission rule. A real server also needs transport, full schema validation, protocol errors and usage limits. A tool that exposes private data must check identity and permissions on every call.
readOnlyHint is still a hint
The annotation declares an intention to read only. It does not prevent a malicious server from writing or deleting data. The specification says to treat these annotations as hints and not trust those from an untrusted server.
Actual protection comes from the account’s permissions, the operations exposed and server checks. If the source is a database, an account restricted to reads limits what the code can do when something goes wrong. For a destructive action, require a separate confirmation and check permissions at execution time.
Test what should be rejected too
In this example, the tests cover a public project, a private project, an unknown slug and malformed arguments. The right result is not just “the assistant found the project”. It is also “it cannot read the one it has no access to”.