guarded-sql-mcp
This server lets an LLM agent query a fictional IT asset inventory (PostgreSQL) through seven fixed, read-only, parameterized tools — it cannot write SQL or reach unlisted tables/columns.
List sites (
list_sites, no params): site code, name, city, and count of non-retired devices.Search devices (
search_devices): filter by site code, status (online/offline/retired), or OS substring; returns hostname, site, OS, status, IP, last check-in.Get one device (
get_device, by hostname): device plus site name and the assigned person's name and email.Find people (
find_people): search by partial name or email; returns full name, email, department, site.Device software (
device_software, by hostname): installed packages, optionally filtered by name substring; returns name, version, installed_at.Stale devices (
stale_devices): non-retired devices not seen in N days (1–365, default 30) or never, oldest first, optionally by site.Open tickets (
open_tickets): unclosed tickets, highest priority first, filterable by site and priority (low/medium/high/critical).
Every tool is read-only, takes only bounded literal arguments (limit 1–100, default 25; string patterns/lengths enforced; unknown keys like sql rejected before the database is touched), matches text literally (no wildcard escapes), returns JSON with rowCount/hasMore/truncated, caps rows at 100, cells at 1000 chars and responses at 64 KiB, and excludes password/secret columns entirely.
Provides read-only access to a PostgreSQL database through a fixed catalog of parameterized queries. An LLM agent can query an IT asset inventory to list sites, search devices, find people, inspect installed software, identify stale devices, and view open tickets, without writing SQL or accessing restricted tables and columns.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@guarded-sql-mcpshow me devices that haven't checked in for 90 days"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
guarded-sql-mcp
An MCP server that gives an LLM agent read access to a PostgreSQL database through a fixed catalog of parameterized queries. The model can pick a query and fill in bounded parameters. It cannot write SQL, and it cannot reach tables or columns that the catalog and the database role do not allow. This is a public reimplementation of a pattern I use in five internal, closed-source MCP servers that expose PostgreSQL and SQL Server to LLM agents; none of that code is here. It was written from scratch, with a fictional schema and data, so that the approach and the tests behind it can be read in full.
The schema is a fictional IT asset inventory (sites, devices, people, installed software, tickets).
Threat model
Treat everything the model sends as untrusted input. It may have read a malicious ticket title, a poisoned web page or a crafted email, and its tool arguments can carry whatever that content asked for. A system prompt that says "never read the password column" is a request, not an access control. So every restriction here is enforced in code or in the database, and each one is covered by a test:
No free-form SQL is reachable by the model. Every tool maps to one static, reviewed statement. Arguments are only ever bind parameters.
Password and secret columns are excluded by construction. They are rejected in the catalog at startup, stripped from results at runtime, and not granted to the database role.
The table allowlist is checked in code, not described in a prompt. A catalog entry that touches a table outside the allowlist stops the server from starting.
A single call cannot flood the model's context. Rows are capped at 100, every string cell at 1000 characters, and the whole JSON response at 64 KiB. Without the byte cap, one call over a few wide rows could return megabytes. Anything cut is flagged
truncated: truein the result and recorded in the audit log.
Related MCP server: django-mcp-sql
Defense layers
flowchart TD
S["Startup: validateCatalog()"] -.->|must pass before the server is built| T
M["Model: tools/call"] --> T{"Tool in the catalog?"}
T -- no --> R1["Refused"]
T -- yes --> V{"Strict zod schema:<br/>bounded fields, no unknown keys"}
V -- invalid --> R2["Refused, executor never called"]
V -- valid --> P["params() builds $1..$n bind values"]
P --> X["PgExecutor: BEGIN READ ONLY,<br/>SET LOCAL statement_timeout,<br/>static SQL ending in LIMIT, COMMIT"]
X --> DB[("PostgreSQL as mcp_readonly:<br/>table and column grants")]
DB --> C["Row cap, then projection onto declared<br/>columns; sensitive keys dropped"]
C --> SZ["Size cap: long cells cut,<br/>trailing rows dropped to fit 64 KiB"]
SZ --> OUT["structuredContent + JSON text to the model"]
X -- error --> E["Generic error to the model,<br/>details to the audit log"]Fixed catalog (src/catalog.ts). One MCP tool per entry. There is no generic query tool and no parameter named
sql,queryorstatement.Input validation. Each entry has a
z.strictObjectschema. Unknown keys are rejected. Every string has a maximum length and usually a pattern (hostnames:^[A-Za-z0-9-]{1,63}$), every number has explicit bounds,limitis 1..100. Search text is matched withILIKE ... ESCAPE '\'after escaping%and_, so the model cannot turn a search into a wildcard dump.Startup catalog validation (src/validateCatalog.ts). Before the server is created, every entry is checked (see What the validator rejects). One bad entry means the process exits.
Read-only execution (src/executor.ts). Every call runs as
BEGIN READ ONLY, a transaction-localstatement_timeout(default 5 s), the statement, thenCOMMIT, orROLLBACKon any error. Every statement must end inLIMIT, and at most 100 rows leave the server. TheLIMITis bound to one row more than requested, so a result can sayhasMore: truewhen more rows matched than were returned.Output projection. Each row is rebuilt from the entry's declared column list. Undeclared keys are dropped. Keys matching the sensitive pattern are dropped at any depth, including inside JSON values, even if declared. Drops are recorded in the audit log.
Response size cap (src/response.ts). Every cell becomes a JSON scalar (dates as ISO strings, JSON values serialized). String cells longer than
MAX_CELL_CHARS(default 1000) are cut and end with…[truncated N chars]. Trailing rows are then dropped until the serialized body fitsMAX_RESPONSE_BYTES(default 64 KiB). The result carriestruncated: trueand the audit record says how many cells were cut and rows dropped.Database role (db/roles.sql).
mcp_readonlyhasSELECTon the allowlisted tables only, column-levelSELECTonpeoplethat leaves outpassword_hashandmfa_secret, nothing onapi_tokens, and noCREATEorTEMP. Its sessions default todefault_transaction_read_only = on,statement_timeout = 5sandidle_in_transaction_session_timeout = 10s, so even a session opened outside the server (psql with the same credentials) is read-only and time-bounded. The code layers do not rely on this, and the integration tests check it separately.
Database errors reach the model as a generic message. The SQLSTATE and message go to the audit log: JSON lines on stderr, because stdout is the MCP stdio channel.
{"ts":"2026-09-25T16:30:15.850Z","event":"tool_call","tool":"find_people","args":{"name_or_email":"rivera","limit":25},"rowCount":1,"durationMs":3.1,"outcome":"ok","hasMore":false,"truncated":false,"responseBytes":157}Tools
Tool | Parameters | Returns |
| none | code, name, city, count of non-retired devices |
|
| hostname, site, os, status, ip, last_seen_at |
|
| device, site, and the assigned person's name and email |
|
| full_name, email, department, site |
|
| name, version, installed_at |
|
| non-retired devices not seen for |
|
| ticket_id, title, priority, status, site, hostname, opened_at |
site is a site code such as north-branch. limit is 1-100, default 25.
All tools are annotated readOnlyHint: true.
Every tool declares an outputSchema derived from its column list, and a
successful result carries the same body twice: as structuredContent
(validated against that schema by the SDK) and as JSON text for clients that
do not read structured output:
{"rowCount":1,"hasMore":false,"truncated":false,"rows":[{"full_name":"Sam Rivera","email":"sam.rivera@example.com","department":"IT","site":"north-branch"}]}hasMore: true means more rows matched than were returned. truncated: true
means the size limits cut a value or dropped rows (layer 6 above). Cells are JSON
scalars: strings, numbers, booleans or null; timestamps are ISO 8601
strings.
Quick start
Requirements: Node.js 22 or later, and Docker for the local database.
The package is not published to npm yet, so run it from a clone:
git clone https://github.com/JimmyAlter/guarded-sql-mcp.git
cd guarded-sql-mcp
docker compose up -d # postgres:16 with db/schema.sql, roles.sql, seed.sql
npm ci
npm run build # produces dist/index.jsThe server reads its settings from environment variables and does not load
.env files. .env.example lists them, and the MCP client
passes them (see below). Always connect as mcp_readonly, never as the owner.
At startup the server logs a privilege_warning (src/roleCheck.ts)
if the role is a superuser, has BYPASSRLS, is a member of pg_read_all_data
or pg_write_all_data, can CREATE in a schema or create temporary tables,
can write to any table in public, has any privilege on a table outside the
allowlist (such as api_tokens), or can SELECT a column whose name looks
sensitive (such as people.password_hash). The integration tests check that
mcp_readonly gets no warnings and that the owner gets all of them.
Variable | Default | |
| required | A |
|
| Per-statement timeout inside each transaction (1-600000) |
|
| Upper bound on one tool result's JSON body (4096-1048576) |
|
| Longest string a cell may carry before it is cut (100-100000) |
| off |
|
Claude Code:
claude mcp add --transport stdio guarded-sql \
--env DATABASE_URL=postgres://mcp_readonly:mcp_readonly_dev@localhost:5432/inventory \
-- node /absolute/path/to/guarded-sql-mcp/dist/index.jsClaude Desktop (or any client that takes an mcpServers block):
{
"mcpServers": {
"guarded-sql": {
"command": "node",
"args": ["/absolute/path/to/guarded-sql-mcp/dist/index.js"],
"env": {
"DATABASE_URL": "postgres://mcp_readonly:mcp_readonly_dev@localhost:5432/inventory"
}
}
}
}Once the package is published to npm, npx -y guarded-sql-mcp will replace
node /absolute/path/to/guarded-sql-mcp/dist/index.js in both snippets. Until
then that command does not work.
The password in db/roles.sql and the compose file is for local development.
Anywhere else, set a real one with ALTER ROLE mcp_readonly PASSWORD '...'.
Demo
scripts/demo.mjs connects a real MCP SDK client to the
built server over an in-memory transport and makes five calls. The only stand-in
is the database: a fake executor answers every query with one fixed row copied
from db/seed.sql and records whether it was called, so the output is the same
on every run and needs no PostgreSQL. This is not an LLM session; it shows
exactly what any MCP client, model-driven or not, gets back.
npm run build && npm run demoThe output below was produced by that command, unedited (audit records are
printed without their ts and durationMs fields, which change every run).
CI runs node scripts/demo.mjs --check and fails if it drifts:
tools/list -> 7 tools: list_sites, search_devices, get_device, find_people, device_software, stale_devices, open_tickets
## A normal call
tools/call find_people {"name_or_email":"rivera"}
isError: false
text: {"rowCount":1,"hasMore":false,"truncated":false,"rows":[{"full_name":"Sam Rivera","email":"sam.rivera@example.com","department":"IT","site":"north-branch"}]}
structuredContent equals text: true
database reached: yes
audit: {"event":"tool_call","tool":"find_people","args":{"name_or_email":"rivera","limit":25},"rowCount":1,"outcome":"ok","hasMore":false,"truncated":false,"responseBytes":157}
## Refused: unknown key `sql`
tools/call get_device {"hostname":"nb-lt-001","sql":"SELECT token_hash FROM api_tokens"}
isError: true
text: MCP error -32602: Input validation error: Invalid arguments for tool get_device: Unrecognized key: "sql"
database reached: no
audit: (none: refused before the handler ran)
## Refused: limit above the cap
tools/call search_devices {"limit":1000}
isError: true
text: MCP error -32602: Input validation error: Invalid arguments for tool search_devices: Too big: expected number to be <=100 at limit
database reached: no
audit: (none: refused before the handler ran)
## Refused: injection-looking hostname
tools/call get_device {"hostname":"x'; DROP TABLE devices;--"}
isError: true
text: MCP error -32602: Input validation error: Invalid arguments for tool get_device: hostname: 1-63 letters, digits or hyphens at hostname
database reached: no
audit: (none: refused before the handler ran)
## Refused: a tool that does not exist
tools/call run_sql {"sql":"SELECT 1"}
isError: true
text: MCP error -32602: Tool run_sql not found
database reached: no
audit: (none: refused before the handler ran)Invalid arguments are refused by the SDK against the tool's strict schema before the handler runs, so they never reach the database and are not in the audit log (see Limitations).
Adding a query
Add an entry with defineQuery in src/catalog.ts and append
it to CATALOG:
export const devicesByPerson = defineQuery({
name: 'devices_by_person',
title: 'Devices by person',
description: 'List devices assigned to a person, by exact email address.',
input: z.strictObject({
email: z.string().max(120).regex(/^[^\s@]+@[^\s@]+$/),
limit,
}),
sql: `
SELECT d.hostname, d.os, d.status
FROM devices d
JOIN people p ON p.id = d.assigned_person_id
WHERE lower(p.email) = lower($1)
ORDER BY d.hostname
LIMIT $2`,
params: (i) => [i.email, fetchLimit(i.limit)],
tables: ['devices', 'people'],
columns: ['hostname', 'os', 'status'],
example: { email: 'sam.rivera@example.com' },
});If the query needs a table or column the role cannot read, update
db/roles.sql as well. The integration suite runs every entry's example
against the seeded database and expects rows back.
What the validator rejects
Rule | Example |
| Declares or reads |
| SQL reads a table missing from |
| Comma joins, schema-qualified or quoted names, |
| A declared column, or any identifier in the SQL, matching |
| Anything that does not start with |
|
|
|
|
| No trailing |
| A trailing |
| Comments, dollar quoting, unbalanced quotes |
|
|
|
|
| Self-explanatory |
Testing
npm run typecheck # tsc --noEmit, strict
npm test # unit + protocol tests, no database needed
npm run lint # typescript-eslint, strict-type-checked
npm run build && npm run smoke # start dist/index.js over stdio and check tools/list
npm run demo # the transcript in the Demo section (after build)
npm run test:integration # needs DATABASE_URL (as mcp_readonly); skipped otherwiseWhat each suite shows:
Unit: validator (
test/unit/validateCatalog.test.ts). The real catalog passes. For each rule above, a small inline catalog that breaks it is rejected, includingFROM a JOIN bwith no alias and a sensitive column behind an innocent alias.Unit: inputs (
test/unit/inputs.test.ts). Out-of-range limits, bad hostnames such asx'; DROP TABLE devices;--, overlong and control-character strings, unknown keys likesqlare all rejected.%and_are escaped, and everyILIKEhas a matchingESCAPE '\'.Unit: executor (
test/unit/executor.test.ts). Projection keeps only declared columns and drops sensitive keys at any depth. The row cap holds when the executor returns 500 rows. Arguments reach the executor only as bind parameters. Against a recording fake pool,PgExecutorissuesBEGIN READ ONLY, the timeout, the query,COMMIT, andROLLBACKon error.Unit: response size (
test/unit/response.test.ts,test/unit/config.test.ts). Long cells are cut without splitting a surrogate pair, trailing rows are dropped until the body fits, escaped characters are counted at their serialized size, and one over-wide row yields zero rows rather than an oversized response. Out-of-range limits in the environment stop the server from starting.Protocol (
test/protocol/server.test.ts). A real SDKClientconnects overInMemoryTransport.tools/listis exactly the catalog, and every schema is closed and bounded. Invalid arguments and unknown tools are refused before the executor runs. When a fake executor returnspassword_hash,mfa_secretandtoken_hash, none of them reaches the client. Database errors come back generic. Each tool'soutputSchemalists exactly its columns, andstructuredContentequals the JSON text. An executor that returns 101 rows of ~1 MB each produces a response under 64 KiB, flaggedtruncatedand audited.Integration (
test/integration/database.test.ts). Runs against PostgreSQL loaded withdb/*.sql, connected asmcp_readonly. Every tool returns rows with no sensitive keys and none of the seeded secret values. Writes through the executor fail with25006(read-only transaction), and the timeout cancelspg_sleep. A fresh session asmcp_readonlyshowsdefault_transaction_read_only = on, and writes still get42501inside an explicitREAD WRITEtransaction. The role gets42501onapi_tokens, onpeople.password_hash, and onSELECT *orrow_to_json(p)frompeople. Injection-looking inputs return zero rows, not errors.
CI (.github/workflows/ci.yml) runs typecheck, lint, tests,
build and the stdio smoke test on Node 22 and 24. It also runs the integration
suite against a postgres:16 service, with REQUIRE_INTEGRATION=1 so a
missing database fails the job instead of skipping it.
Limitations and non-goals
Not a general SQL tool. If a question is not covered by the catalog, the answer is a new reviewed entry, not a more flexible tool.
PostgreSQL only. The pattern works for SQL Server too, but this repository does not implement it.
The validator is a guard, not a parser. It uses regular expressions over a small catalog that humans review, and it fails closed on constructs it cannot verify (
EXTRACT(x FROM y),IS DISTINCT FROM, comma joins). Never use it to vet SQL from a user or a model. It also cannot see whole-row references such asSELECT p FROM people p. The column grants catch those, and the integration tests show it.Projection works on names, not content. It removes keys that look sensitive. It cannot know whether an innocently named column holds a secret.
Tool output is untrusted too. Names, emails and ticket titles are returned to the model by design, and a ticket title can itself contain a prompt injection. This server does not sanitize content. The client must treat tool results as data.
Refused calls are not audited. The SDK answers calls with invalid arguments or unknown tool names before the handler runs, so they do not appear in the audit log. Calls that pass validation are always logged.
The byte cap is per body, and the body is sent twice.
MAX_RESPONSE_BYTESbounds the JSON body; the same body goes out asstructuredContentand as text, so a result on the wire is at most about twice the cap plus the JSON-RPC envelope.The audit log keeps arguments verbatim by default. Search text such as a person's name or email in
find_peopleends up in the log as typed. SetAUDIT_REDACT_ARGS=1to log every string argument as"[redacted]"(numbers such aslimitare kept). Results are never logged, only row counts and sizes.No per-user authorization. It is a local stdio server. Whoever can start it gets the database role's access.
License
MIT © Thiago Langone
Available Tools
7 toolsdevice_softwareDevice softwareARead-onlyIdempotent
List software installed on a device, optionally filtered by package name.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Maximum rows to return (1-100, default 25). | |
| hostname | Yes | Device hostname, e.g. "nb-lt-001". Case-insensitive. | |
| name_contains | No | Substring of the software name, e.g. "Chrome". Matched literally: % and _ have no special meaning. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, idempotentHint=true, destructiveHint=false, and openWorldHint=false, fully covering the safety profile. The description adds that filtering is optional and by package name, but does not discuss pagination, sorting, or limits. With annotations handling safety, a 3 is appropriate.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Single sentence, front-loaded with the core action, and no wasted words. It efficiently conveys the purpose and optional filter.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the rich schema (100% coverage) and annotations, and the absence of an output schema, the description is nearly complete. The only missing element is any indication of return volume or that results are paginated, but as a simple list tool, this is a minor gap.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so all three parameters are well-documented in the schema (limit, hostname, name_contains with patterns and examples). The description adds the concept of optional filtering by package name, which is already in the schema. Baseline 3 is correct when schema does the heavy lifting.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('List') and resource ('software installed on a device') and adds the filtering capability. It is clear what the tool does, though it does not differentiate itself from siblings like get_device, which might also return software info.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the description: use this to see installed software. However, there is no explicit guidance on when to choose this over get_device or other alternatives, and no exclusions or prerequisites are mentioned.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
find_peopleFind peopleARead-onlyIdempotent
Find people whose name or email contains the given text. Returns name, email, department and site.
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Maximum rows to return (1-100, default 25). | |
| name_or_email | Yes | Part of a full name or email address. Matched literally: % and _ have no special meaning. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint, idempotentHint, destructiveHint=false and openWorldHint=false, so the safety profile is fully covered. The description adds the literal-match rule (also present in the schema) and lists return fields (covered by the output schema), so it contributes little genuinely new behavioral context, e.g. no case-sensitivity or empty-result behavior.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two short sentences, zero filler, with the core purpose and match rule front-loaded ahead of the return summary. Every clause earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
For a simple read-only lookup with a full output schema and 100% parameter coverage, the description supplies everything needed to invoke it correctly. It is slightly redundant in restating return fields and omits minor details like case sensitivity, keeping it just short of fully complete.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so both parameters (name_or_email, limit) are already documented with pattern, length and default details. The description's 'contains the given text' merely restates the parameter without adding format or edge-case semantics beyond the schema. Baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (find) and resource (people) plus the exact matching semantics ('name or email contains the given text'), which is more than a restatement of the name. No sibling tool operates on people, so an agent can distinguish it immediately from list_sites, search_devices, and the other device-oriented siblings.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied by the name and the matching description, but there is no explicit when-to-use, no prerequisites, and no named alternative for related lookups. Adequate for a simple, single-purpose lookup tool, but it leaves the agent to infer context.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
get_deviceGet deviceARead-onlyIdempotent
Get one device by hostname, including its site and the name and email of the person it is assigned to.
| Name | Required | Description | Default |
|---|---|---|---|
| hostname | Yes | Device hostname, e.g. "nb-lt-001". Case-insensitive. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already cover the safety profile (readOnly, idempotent, non-destructive, not open-world), so the bar for additional disclosure is lower. The description adds return-content context (site and assigned person), but does not describe permissions, rate limits, not-found behavior, or other operational traits.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence that states the action, scope, and returned details with no wasted words. It is appropriately sized for a simple read-only lookup tool.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
Given the simple read-only operation, complete parameter schema, annotations, and the presence of an output schema, the description is sufficient. It summarizes the single-record lookup and its notable return fields without needing to duplicate the output schema.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the hostname parameter's pattern, length limit, example, and case-insensitivity are already documented in the schema. The description only repeats 'by hostname' and adds no syntax or format details beyond the schema.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('Get') and resource ('device') with singular scope ('one device by hostname'), which implicitly distinguishes it from search_devices and list-style siblings. It also names the returned entity context (site and assignee), so an agent knows this is a single-record lookup.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description gives no explicit guidance on when to use this tool versus search_devices or other siblings, nor any exclusions. The only usage cue is the required hostname, which is already fully specified in the schema.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
list_sitesList sitesARead-onlyIdempotent
List all sites with their code, city and number of non-retired devices.
| Name | Required | Description | Default |
|---|---|---|---|
No parameters | |||
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint=true, idempotentHint=true, destructiveHint=false, and openWorldHint=false, so the safety profile is fully covered by structured data. The description's only added behavior detail is the 'non-retired devices' scoping nuance, which is useful but modest; with an output schema present, describing return fields adds little.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence that names the action, the resource, and the returned fields with zero filler. Nothing is repeated from the schema or annotations.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
This is a zero-parameter, read-only listing tool with annotations covering safety and an output schema covering the return shape, so the description does not need to explain results. It is complete for the call, though it says nothing about volume, ordering, or whether the list is paginated.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
The tool takes zero parameters, so the baseline of 4 applies. The description correctly avoids inventing parameter semantics for a parameterless call.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('List') and resource ('sites') and even enumerates the shape of each result (code, city, count of non-retired devices). No sibling among search_devices, get_device, find_people, device_software, stale_devices, or open_tickets overlaps with site listing, so there is nothing to differentiate against; the definition is clear as written.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
There is no statement of when to reach for this tool versus any other, nor any prerequisite or exclusion. The phrase 'all sites' implicitly signals there is no filtering or pagination option, but that is inference rather than guidance. No alternative site-lookup tool exists among the siblings, which softens but does not remove the gap.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
open_ticketsOpen ticketsARead-onlyIdempotent
List tickets that are not closed, highest priority first, optionally filtered by site and priority.
| Name | Required | Description | Default |
|---|---|---|---|
| site | No | Site code as returned by list_sites, e.g. "north-branch". | |
| limit | No | Maximum rows to return (1-100, default 25). | |
| priority | No | Ticket priority. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive, and closed-world. The description adds genuine value beyond them by disclosing the implicit filter ('not closed') and the sort order ('highest priority first'), which the annotations do not cover. It stops short of describing pagination or result caps.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence carrying filter, ordering, and scope with zero filler. Every clause earns its place.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists and annotations are rich, so return values and safety profile need not be explained. The description covers scope, ordering, and filters adequately. Only minor gaps (e.g. the limit cap) remain, and those are already in the schema.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 100%, so all three parameters including the limit default (25) are fully documented in the schema. The description names only site and priority, adding no format or constraint detail beyond what the schema already provides. Baseline 3 is appropriate.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (List) and resource (tickets) with precise scope: 'not closed, highest priority first'. The filters and ordering make the purpose unambiguous. No sibling tool lists tickets, so explicit differentiation isn't needed, keeping this just short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
'Optionally filtered by site and priority' hints at when the filters apply, but there is no guidance on when to use this versus alternatives, nor any exclusion or prerequisite. Usage is implied rather than stated.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
search_devicesSearch devicesARead-onlyIdempotent
Search devices by site, status and operating system. Returns hostname, site, OS, status, IP and last check-in time.
| Name | Required | Description | Default |
|---|---|---|---|
| site | No | Site code as returned by list_sites, e.g. "north-branch". | |
| limit | No | Maximum rows to return (1-100, default 25). | |
| status | No | Device status. | |
| os_contains | No | Substring of the operating system name, e.g. "Windows". Matched literally: % and _ have no special meaning. |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnlyHint, idempotentHint and openWorldHint=false, so the safety profile is covered. The description adds the returned field set (hostname, site, OS, status, IP, last check-in), which is useful context, but says nothing about ordering, total counts, or paging behavior beyond the schema's limit.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Two sentences, filters first and return shape second, with zero filler. Every clause carries information an agent can act on.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With a 100%-documented schema, full annotations and an output schema, the description needs only to state purpose and filters, which it does. It is slightly incomplete in not routing the agent away from the overlapping stale_devices sibling.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so site, status, os_contains and limit are all documented with formats and constraints in the schema. The description only restates the filter names and adds no syntax or matching nuance the schema does not already carry.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb (search) and resource (devices) plus the filter dimensions. An agent can distinguish it from get_device (single device) and list_sites, though it never explicitly contrasts itself with the sibling stale_devices, which is also a device-listing tool.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The filter list implies when this tool is useful (you have a site/status/OS to narrow by), but there is no explicit guidance on when to prefer it over stale_devices or get_device, and no statement of prerequisites or result limits beyond what the schema says.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
stale_devicesStale devicesARead-onlyIdempotent
List non-retired devices that have not checked in for at least the given number of days (or never), oldest first.
| Name | Required | Description | Default |
|---|---|---|---|
| days | No | Days without check-in (1-365, default 30). | |
| site | No | Site code as returned by list_sites, e.g. "north-branch". | |
| limit | No | Maximum rows to return (1-100, default 25). |
Output Schema
| Name | Required | Description |
|---|---|---|
| rows | Yes | |
| hasMore | Yes | More rows matched than were returned. |
| rowCount | Yes | Rows in this response. |
| truncated | Yes | Long values were cut or rows dropped to fit the response size limit. |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
Annotations already declare readOnly, idempotent, non-destructive and closed-world, so safety is covered. The description adds genuine behavioral context beyond that: the 'non-retired' exclusion rule, the 'never checked in' edge case, and the 'oldest first' ordering — details an agent would otherwise have to guess.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
A single front-loaded sentence with the filter, the exclusion, and the sort order, and no filler. Every clause carries information an agent needs.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
With an output schema present, return values need no explanation, and annotations cover the safety profile. The description supplies the domain semantics (staleness window, retired exclusion, ordering); only explicit sibling routing is missing for a 5.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema description coverage is 100%, so the schema already documents days, site and limit, including ranges and defaults. The description only echoes the 'days' semantics ('given number of days (or never)') and adds nothing for site or limit, so the baseline 3 applies.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb ('List') and a precisely scoped resource ('non-retired devices that have not checked in for at least the given number of days (or never)'), including sort order. The scope is clearly distinct from siblings like search_devices, but the description never names or contrasts with them, so an agent must infer the boundary.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The filter conditions imply when to use it (auditing stale/inactive hardware), and 'non-retired' quietly tells the agent retired devices are out of scope. However, there is no explicit statement of when to prefer this over search_devices or any other alternative, nor any prerequisite guidance.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
7 tool updates
v0.3.0- Changed
device_software1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "installed_at": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "version": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
find_people1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "department": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "email": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "full_name": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
get_device1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "assigned_email": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "assigned_to": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "hostname": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "ip": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "last_seen_at": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "os": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site_name": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "status": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
list_sites1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "active_devices": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "city": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "code": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "name": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
open_tickets1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "hostname": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "opened_at": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "priority": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "status": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "ticket_id": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "title": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
search_devices1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "hostname": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "ip": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "last_seen_at": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "os": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "status": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
- Changed
stale_devices1 field changed- changed
Output schema / (root)Previous value: -nullNew value: +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "properties": { + "hasMore": { + "description": "More rows matched than were returned.", + "type": "boolean" + }, + "rowCount": { + "description": "Rows in this response.", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "rows": { + "items": { + "additionalProperties": false, + "properties": { + "hostname": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "last_seen_at": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "os": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "site": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + }, + "status": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "type": "array" + }, + "truncated": { + "description": "Long values were cut or rows dropped to fit the response size limit.", + "type": "boolean" + } + }, + "required": [ + "rowCount", + "hasMore", + "truncated", + "rows" + ], + "type": "object" +}
7 tool updates
v0.1.0- First observed
device_software - First observed
find_people - First observed
get_device - First observed
list_sites - First observed
open_tickets - First observed
search_devices - First observed
stale_devices
TDQS
Scored across 7 tools
Each tool targets a distinct resource or query need: sites, device search, single device, people search, software, stale devices, and open tickets. The only potential overlap between search_devices and stale_devices is resolved by stale_devices' unique time-since-check-in criterion.
Four tools follow a clear verb_noun pattern (list_sites, search_devices, get_device, find_people), but device_software, stale_devices, and open_tickets are noun phrases with no leading verb. Snake_case is consistent, but the overall convention is mixed.
Seven tools is well-scoped for a read-only IT asset and ticketing query server. Each tool addresses a distinct query dimension without redundancy or bloat.
The surface covers core read queries across sites, devices, people, software, and tickets, which is strong for a guarded SQL query server. Minor gaps include no ticket detail/closed-ticket query, no person detail beyond search, and no write operations.
Maintenance
Related MCP Connectors
Safe, read-only Postgres and MySQL access for AI agents. Audit log + column-level controls.
Query PostgreSQL databases in plain English — LLM-generated, safety-validated SQL.
Deterministic safety, correctness & cost gate that vets Postgres SQL before your AI agent runs it.
Query 40 databases from Claude, ChatGPT, or Cursor — on any device. Read-only, encrypted, audited.
Related MCP Servers
- FlicenseNot gradedqualityFmaintenanceProvides a secure, schema-aware PostgreSQL database agent for LLMs, enabling natural language queries and validated SQL execution with strong security guardrails.9 npm5-
- AlicenseNot gradedqualityBmaintenanceProvides a read-only PostgreSQL SQL surface for LLM agents via MCP, with defense-in-depth security layers for safe database queries.138 PyPI4MIT
- FlicenseNot gradedqualityCmaintenanceEnables LLM agents to explore and query a Postgres database through secure, read-only tools.-
- FlicenseNot gradedqualityCmaintenanceEnables AI agents to query a Postgres data warehouse through a governed, read-only SQL interface with policy enforcement, row limits, schema-level PII isolation, and a full audit trail.1-