Repository navigation
feat(server): createOndocs, one request guard, allowedOrigins for Ask, locales and hooks for MCP - #168
Merged
Conversation
…endpoints
- requestGuard() in server/shared.ts runs the method, content-type, rate
limit, body cap, JSON and budget checks for both handlers; each words a
refusal in its own format. corsPolicy() is the MCP endpoint's CORS code,
shared.
- createAskHandler takes allowedOrigins: CORS for each listed origin, with
Retry-After exposed. The site's hand-written cors.ts wrapper is gone.
- createMcpHandler takes indexes (search and list_pages take a locale, fetch
reads any index) and the rerank and retrieve hooks.
- createOndocs returns { ask, mcp } from one set of options.
- handleAsk is split into named stages.
- The init Worker exposes Retry-After to the site's pages.
Roadmap item: OD2-48.
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Roadmap item: OD2-48, one handler factory and one request guard.
What was wrong
The ask endpoint and the MCP endpoint had drifted apart, so "one index for people and agents" did not hold in the API:
createMcpHandlertook noindexesand norerankorretrievehooks: itssearchcalled plainretrieve(). Agents could search only the default locale, and skipped a site's reranker.createAskHandlerhad only staticheadersfor CORS, which name one origin. The site needed a hand-written 38-line wrapper,examples/nextjs/app/api/ask/cors.ts, to serve its three live demos. The Worker thatondocs init --host github-pageswrites did not exposeRetry-After, so a dialog on the site's origin could not read it on a 429 (useAskreads it into the error'sretryAfter).handleAskand inhandleMcpandcallTool. On main,handleAskhas a cyclomatic complexity of 60, 333 lines and 109 statements.What changed
One request guard (
src/server/shared.ts).requestGuard()runs the checks both handlers share and returns aRefusal(a reason, a status and headers). Each handler words it in its own format:{ error: { code, message } }, or a JSON-RPC error.read()checks the method, the JSON content type, the rate limit (the MCP endpoint skips it there), the body cap and the parse.limit()andadmit()run the rate limit and a budget check on their own, failing closed unlessrateLimitFailure: 'open'. Statuses, codes, messages and headers are as before, and the MCP endpoint still counts only tool calls.corsPolicy()is the MCP endpoint's CORS code, moved there so both handlers use it.allowedOriginsoncreateAskHandler. It takes a list of origins, or'*'. A listed origin getsaccess-control-allow-originwith its own origin,vary: originandaccess-control-expose-headers: retry-after. Its preflight getsPOST, OPTIONS,content-typeand a max-age of one day. Any other origin gets no CORS headers.Where this differs from the finding: the ask endpoint does not answer other origins with a 403, as the MCP endpoint does. A browser on another origin cannot get past the preflight a JSON post needs, so a 403 would add no protection. It would also turn away the site's own dialog behind a proxy that changes the URL the endpoint sees, because the own-origin check compares
Originwithrequest.url. The site's wrapper did not send a 403 either, and its test still checks that a foreign origin's preflight gets a 204 with no CORS headers.indexesand the retrieval hooks oncreateMcpHandler. Withindexes,searchandlist_pagestake alocale. The schema lists it only when there are locales, as an enum of them. Without alocale, or with one that has no index, the tools readindex, as the ask endpoint does.fetchfinds a page in whichever index has it.retrievaltakesrerankandretrieve, run through the ask endpoint's ownretrieveWithHooks, behind the same relevance gate. The MCP endpoint's CORS headers now exposeretry-aftertoo.createOndocs(src/server/ondocs.ts, exported fromondocs/server). It returns{ ask, mcp }from one set of options.index,indexes,embeddingModel,embeddingProviderOptions,siteNameandretrievalare shared. So areheaders,rateLimitFailure,maxBodyBytesandonError, with each endpoint's own values going over them. An index given as a function is loaded once for both. Each endpoint's own options go inask(model, for one) andmcp, andmcp: { embeddingModel: false }keeps agents' searches keyword-only.Where this differs from the finding's sketch:
allowedOriginsandrateLimitbelong to each endpoint, not to both. A sharedallowedOriginswould turn on the MCP endpoint's 403 check and shut out hosted clients such as claude.ai, whose servers send anOriginheader. A test covers this. A shared limiter would let one endpoint's traffic use up the other's bucket.handleAsk in stages (
src/server/handler.ts). The request now goes throughanswerRequest: the guard, feedback,parseQuestion,checkChallenge, the index,rewriteFollowUp, the cache, the budget, thenretrieveAndAnswer.handleAskadds the CORS headers to whatever comes back. Each stage's code is moved as it was. With eslint'scomplexity,max-lines-per-functionandmax-statementsrules onsrc/server(thresholds 15, 120 and 50), the request path's largest function goes fromhandleAskat 60 toanswerRequestat 19.handleMcpgoes from 35 to 28, andcallToolfrom 28 to 20.createAskHandleris 37, up from 36, and most of that comes from its option defaults.The site.
examples/nextjs/app/api/ask/cors.tsis gone.lib/ask-handler.tssetsallowedOrigins: DEMO_ORIGINS, which now lives inlib/site.tsand is made fromDEMOS.test/website-ask-cors.test.tsnow runs the real route with the site's own options instead of a mocked handler. The index is mocked, aswebsite-counters.test.tsalready does. A new case checks that a demo can readRetry-Afteron a 429.init and the Worker template. The Worker's
corsForaddsaccess-control-expose-headers: retry-after. The template is synced and the init snapshot updated. The Vercel template's comment now points atallowedOrigins. The Worker keeps its staticheadersinstead of switching toallowedOrigins.templates/cloudflare-workerdepends on the published ondocs range until the release PR moves it, and a version without the option would drop it without a word, so the "Deploy to Cloudflare" button would give a Worker with no CORS.Docs.
ask-endpoint.mdgets anallowedOriginsrow and a pointer tocreateOndocs.mcp.mdgets "One call for both endpoints", locales, the hooks and the new option rows. Insecurity.md, "Cross-origin requests" now usesallowedOriginsand links to the site's handler instead of the deleted wrapper. The three example sites are synced withpnpm build && pnpm examples:sync.The changeset is a minor: this is new API.
How it was tested
The new tests were written before the fix, and run against main's sources:
test/create-ondocs.test.ts(new, 9 tests). Both endpoints get the same index, embedding model and hooks. Locales work on both. A lazy index is loaded once.mcp.embeddingModel: falsekeeps search keyword-only. Each endpoint's retrieval and headers go over the shared ones. The rate limits are separate. The shared guard gives both endpoints the same statuses, each in its own format, for: a wrong method, a wrong content type, a body too large or not JSON, the rate limit withRetry-After, failing closed and open, and the daily budget.test/handler.test.ts(4 new tests). WithallowedOrigins: the preflight, an answer and a 429 withRetry-Afterreadable. Other origins, and the site's own origin, get no CORS headers.'*'works.test/mcp.test.ts(6 new tests, plus one new assertion).rerankandretrieverun on search, and a hook that throws is reported. Thelocaleschema, search and listing by locale, andfetchfrom any index.retry-afteris exposed.test/website-ask-cors.test.ts: the real route, plus the newRetry-Aftercase.test/workers-ai.test.ts: the template Worker exposesretry-after.With main's
src,templatesand site code checked out under these tests, 21 of them fail: all 9 increate-ondocs.test.ts, 3 inhandler.test.ts, 7 inmcp.test.ts, the Worker'sretry-afterassertion inworkers-ai.test.ts, and theDEMO_ORIGINScase inwebsite-ask-cors.test.ts, which fails only because the list moved tolib/site.ts. On this branch, all 114 tests in those five files pass.Gate, rebased on main after the 0.12.0 release:
pnpm install --frozen-lockfile --prefer-offline, thenpnpm format:check,pnpm lint,pnpm typecheck,pnpm test(95 files passed, 1 skipped; 1,303 tests passed, 10 skipped),pnpm build,pnpm examples:check,pnpm template:checkandpnpm check:doc-importsall pass. Before the rebase,pnpm validate, which addscheck:packageand the four example builds, also passed (exit 0).Follow-ups
indexesto the MCP endpoint in whatondocs initwrites and in the Astro route, so agents on a site with several locales can search each one.allowedOrigins, switch the init Worker andtemplates/cloudflare-workerfrom staticheaderstoallowedOrigins: [siteOf(env).origin].complexitycap.--max-warnings 0makes awarnfail, so a cap of 25 first needscreateAskHandler's option defaults split out, and the larger functions outsidesrc/servertoo.createOndocsin the README. The README is in another wave-3 bundle (od-embed-defaults), so it is left alone here.