quilr-fde-assessment
Quilr FDE assessment
Four tasks from the FDE brief, each one a gate, and each gate's refusal a test you can run with no network and no key. An MCP server, a security gateway, a streaming PII guardrail and a model router, on the official MCP SDK.
-32602 · -32001 · [REDACTED] · rate_limited
make install # uv sync, Python 3.13
make test # the whole suite, all four tasks
make check # lint, claim check, figure check, then the suite
make bench # what the task 3 guardrail costs
make run-task1 # MCP server on stdio
make run-task2 # security gateway on 8080, mock downstream on 8081
make run-task3 # streaming PII guardrail on 8082
make run-task4 # model router demo, prints every routing outcome
make run-live # the same guardrail against a real provider, needs LLM_API_KEYA bad request stops at one of four gates, and each gate has its own proof.
The question it answers | Where its truth comes from | When it changes | |
Schema gate, task 1 | Do the arguments fit the advertised schema? | The server run as a subprocess over stdio, where | When a Pydantic model changes, since one model is both |
Role gate, task 2 | May this role call this tool? | The mock downstream's own request log, empty after a viewer calls | When the |
Hold gate, task 3 | Can this text still change? |
| When a pattern's bounded length changes |
Budget gate, task 4 | Is there budget, and is the primary up? | One sqlite row per charge, read back by | When the window, the limit or the timeout changes |
1. The schema gate
Two tools over stdio, one Pydantic model each as schema and validator.
The SDK's
call_tooldecorator turns every exception into anisErrorresult, bad arguments included. I wanted-32602, so both handlers sit on the low level server.A missing customer still gets
isError, because that is a domain answer.
Step | What it has to prove before the next step may start | How it fails |
| The advertised schema is the model's own | Two sources drift, so there is one |
| Strict, so unknown fields are refused, |
|
The refund | The balance is checked and debited in one step under a lock, in |
|
stdout | Every line parses as one JSON-RPC message, and a forced stray | It cannot, since |
2. The role gate
A viewer calling any admin_ tool gets -32001 back before the downstream hears about it.
The
admin_prefix is the brief's rule and a deny list. A privileged method added under another name sails through, so anything real gets a per method allow list.
Step | What it has to prove before the next step may start | How it fails |
Bearer | The token resolves to admin or viewer, in | 401 with |
Envelope | One JSON-RPC 2.0 object, and a batch is refused, in | 400 with |
| An | 200 with |
Forward | The client's bearer is dropped and the decided role travels as | 502 with |
3. The hold gate
POST /v1/generate streams the reply with emails, SSNs and Luhn valid cards replaced by [REDACTED], split values included.
The redactor emits only text that can no longer change. Every pattern has a bounded length, the longest a 320 character email at the RFC 5321 limits, so it never holds more than twice that, 640 characters.
Plain prose at the front costs under 1 ms at the first token, and the 320 character email inside a longer token costs 583 ms, 53 chunks held. Both are medians of ten paired trials against a scripted upstream.
Step | What it has to prove before the next step may start | How it fails |
Cut | Anything further back than 320 characters is settled, and inside that window the cut walks back to the nearest prose space, in | Held, never emitted early |
Match | A match crossing the cut pulls the cut back to its own start, so | Held until it resolves, 640 characters at most |
Card | Luhn over runs of whole groups, longest first, so | A run that fails Luhn is left alone, in |
Flush | The held tail leaves when the upstream ends, in | Nothing stays in the buffer |
First token record, | Chunks held | The guardrail adds | Range over ten pairs |
Safe prose | 0 | under 1 ms | -1 to 1 ms |
A 15 char email | 1 | 11 ms | 10 to 16 ms |
A 19 char Luhn card | 1 | 11 ms | 10 to 11 ms |
An 11 char SSN | 1 | 11 ms | 10 to 15 ms |
A 320 char email | 26 | 286 ms | 282 to 287 ms |
A 1,000 char unbroken token | 26 | 286 ms | 283 to 288 ms |
A 320 char email inside a token | 53 | 583 ms | 580 to 586 ms |
4. The budget gate
Every charge is one sqlite row on disk, so the window slides, and a 429 or a timeout on the primary fails over.
A request that produced no completion hands its tokens back rather than spending the tenant's next minute. The objection is that endless failures then cost nothing, and production would count them against an abuse budget.
Step | What it has to prove before the next step may start | How it fails |
Admit | The estimate, prompt characters over four plus the output budget, fits under 50,000 tokens in the trailing 60 seconds, in |
|
Race | Twenty threads on one key win exactly ten of ten 5,000 token slots, in | The loser waits on |
Primary | Answers inside 3000 ms without a 429, raced at 60 ms in | The secondary, in |
Error | One payload with a fixed message and a request id, in |
|
The four tasks, as a map
Each gate as a lane, and no arrow from the role gate into the schema gate, because task 1 speaks stdio and the gateway's downstream in this repo is the mock.
%%{init: {"theme": "base", "themeVariables": {"primaryColor": "#1F1E1D", "primaryTextColor": "#F4F1EA", "primaryBorderColor": "#3A3734", "lineColor": "#B8B0A4", "textColor": "#F4F1EA", "clusterBkg": "#141413", "clusterBorder": "#3A3734", "titleColor": "#B8B0A4", "edgeLabelBackground": "#141413", "fontSize": "16px"}, "flowchart": {"curve": "linear", "nodeSpacing": 14, "rankSpacing": 22, "padding": 6, "diagramPadding": 8}}}%%
flowchart TB
subgraph S2["02 role gate, task 2"]
direction LR
B2["admin_ tool<br/>as viewer?"] -- yes --> B3["-32001<br/>not forwarded"]
B2 -- no --> B4["forwarded to<br/>the downstream"]
end
subgraph S1["01 schema gate, task 1"]
direction LR
A2["arguments fit<br/>the schema?"] -- no --> A3["-32602<br/>Invalid params"]
A2 -- yes --> A4["handler runs"]
end
subgraph S4["04 budget gate, task 4"]
direction LR
D2["budget in the<br/>last 60 s?"] -- no --> D3["rate_limited<br/>retry_after_seconds"]
D2 -- yes --> D4["primary first,<br/>secondary on a 429<br/>or after 3000 ms"]
end
subgraph S3["03 hold gate, task 3"]
direction LR
C2["could this text<br/>still change?"] -- yes --> C3["held, 640 chars<br/>at most"]
C2 -- no --> C4["emitted, PII<br/>as [REDACTED]"]
end
S1 ~~~ S3
S2 ~~~ S4
classDef stop fill:#1F1E1D,stroke:#CC785C,stroke-width:2px,color:#F4F1EA
classDef hold fill:#1F1E1D,stroke:#B8B0A4,stroke-width:2px,color:#F4F1EA
class A3,B3,D3 stop
class C3 holdtools/draw_figures.py draws every figure above from the constants in src/ and the two reports under reports/, and make figures-check goes red when a committed file drifts from its generator.
Recounted on every make check
make claims runs the suite, rereads the constants from src/ and the measurements from reports/bench_report.json, and fails when a badge, a record row or a sentence above has drifted.
claims ok, 263 passed and 0 skipped from 155 functions, 640 chars held at most, worst first token 583 ms, Python 3.13What I left out, and why
All 263 tests, cases from 155 functions, run with no network and no key, because the scripted upstream and provider keep them deterministic.
Every timing test runs at 60 ms to stay quick, so the 3000 ms timeout is never raced live, and the real number would need a fake clock.
Admission runs before any provider has counted, so the limiter charges four characters a token and nothing reconciles the estimate against the bill.
Twenty threads race the limiter and no two processes do, because each thread holds its own connection, so the lock between workers is inferred, not proven.
Task 1 has met only this repo's stdio client, which reads raw bytes off stdout the SDK client would parse away.
More in docs/REFEREE.md.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/jameswniu/quilr-fde-assessment'
If you have feedback or need assistance with the MCP directory API, please join our Discord server