Skip to content

feat(server): createOndocs, one request guard, allowedOrigins for Ask, locales and hooks for MCP - #168

Merged
dgesteves merged 1 commit into
mainfrom
fix/od2-48-handler-factory-request-guard
Oct 16, 2026
Merged

dgesteves merged 1 commit into
mainfrom
fix/od2-48-handler-factory-request-guard

Conversation

@dgesteves

Copy link
Copy Markdown
Owner

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:

  • createMcpHandler took no indexes and no rerank or retrieve hooks: its search called plain retrieve(). Agents could search only the default locale, and skipped a site's reranker.
  • createAskHandler had only static headers for 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 that ondocs init --host github-pages writes did not expose Retry-After, so a dialog on the site's origin could not read it on a 429 (useAsk reads it into the error's retryAfter).
  • The request guards (method, content type, rate limit failing closed or open, body cap, JSON parse, daily budget) were written twice, in handleAsk and in handleMcp and callTool. On main, handleAsk has 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 a Refusal (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() and admit() run the rate limit and a budget check on their own, failing closed unless rateLimitFailure: '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.

allowedOrigins on createAskHandler. It takes a list of origins, or '*'. A listed origin gets access-control-allow-origin with its own origin, vary: origin and access-control-expose-headers: retry-after. Its preflight gets POST, OPTIONS, content-type and 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 Origin with request.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.

indexes and the retrieval hooks on createMcpHandler. With indexes, search and list_pages take a locale. The schema lists it only when there are locales, as an enum of them. Without a locale, or with one that has no index, the tools read index, as the ask endpoint does. fetch finds a page in whichever index has it. retrieval takes rerank and retrieve, run through the ask endpoint's own retrieveWithHooks, behind the same relevance gate. The MCP endpoint's CORS headers now expose retry-after too.

createOndocs (src/server/ondocs.ts, exported from ondocs/server). It returns { ask, mcp } from one set of options. index, indexes, embeddingModel, embeddingProviderOptions, siteName and retrieval are shared. So are headers, rateLimitFailure, maxBodyBytes and onError, 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 in ask (model, for one) and mcp, and mcp: { embeddingModel: false } keeps agents' searches keyword-only.

Where this differs from the finding's sketch: allowedOrigins and rateLimit belong to each endpoint, not to both. A shared allowedOrigins would turn on the MCP endpoint's 403 check and shut out hosted clients such as claude.ai, whose servers send an Origin header. 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 through answerRequest: the guard, feedback, parseQuestion, checkChallenge, the index, rewriteFollowUp, the cache, the budget, then retrieveAndAnswer. handleAsk adds the CORS headers to whatever comes back. Each stage's code is moved as it was. With eslint's complexity, max-lines-per-function and max-statements rules on src/server (thresholds 15, 120 and 50), the request path's largest function goes from handleAsk at 60 to answerRequest at 19. handleMcp goes from 35 to 28, and callTool from 28 to 20. createAskHandler is 37, up from 36, and most of that comes from its option defaults.

The site. examples/nextjs/app/api/ask/cors.ts is gone. lib/ask-handler.ts sets allowedOrigins: DEMO_ORIGINS, which now lives in lib/site.ts and is made from DEMOS. test/website-ask-cors.test.ts now runs the real route with the site's own options instead of a mocked handler. The index is mocked, as website-counters.test.ts already does. A new case checks that a demo can read Retry-After on a 429.

init and the Worker template. The Worker's corsFor adds access-control-expose-headers: retry-after. The template is synced and the init snapshot updated. The Vercel template's comment now points at allowedOrigins. The Worker keeps its static headers instead of switching to allowedOrigins. templates/cloudflare-worker depends 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.md gets an allowedOrigins row and a pointer to createOndocs. mcp.md gets "One call for both endpoints", locales, the hooks and the new option rows. In security.md, "Cross-origin requests" now uses allowedOrigins and links to the site's handler instead of the deleted wrapper. The three example sites are synced with pnpm 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: false keeps 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 with Retry-After, failing closed and open, and the daily budget.
  • test/handler.test.ts (4 new tests). With allowedOrigins: the preflight, an answer and a 429 with Retry-After readable. Other origins, and the site's own origin, get no CORS headers. '*' works.
  • test/mcp.test.ts (6 new tests, plus one new assertion). rerank and retrieve run on search, and a hook that throws is reported. The locale schema, search and listing by locale, and fetch from any index. retry-after is exposed.
  • test/website-ask-cors.test.ts: the real route, plus the new Retry-After case.
  • test/workers-ai.test.ts: the template Worker exposes retry-after.

With main's src, templates and site code checked out under these tests, 21 of them fail: all 9 in create-ondocs.test.ts, 3 in handler.test.ts, 7 in mcp.test.ts, the Worker's retry-after assertion in workers-ai.test.ts, and the DEMO_ORIGINS case in website-ask-cors.test.ts, which fails only because the list moved to lib/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, then pnpm 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:check and pnpm check:doc-imports all pass. Before the rebase, pnpm validate, which adds check:package and the four example builds, also passed (exit 0).

Follow-ups

  • Pass indexes to the MCP endpoint in what ondocs init writes and in the Astro route, so agents on a site with several locales can search each one.
  • Once a release has allowedOrigins, switch the init Worker and templates/cloudflare-worker from static headers to allowedOrigins: [siteOf(env).origin].
  • An eslint complexity cap. --max-warnings 0 makes a warn fail, so a cap of 25 first needs createAskHandler's option defaults split out, and the larger functions outside src/server too.
  • Move the MCP protocol negotiation (2025 revisions and 2026-07-28) into its own module, apart from the tools.
  • Mention createOndocs in the README. The README is in another wave-3 bundle (od-embed-defaults), so it is left alone here.

…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.
@dgesteves
dgesteves merged commit 11a11a7 into main Oct 16, 2026
22 checks passed
@dgesteves
dgesteves deleted the fix/od2-48-handler-factory-request-guard branch October 16, 2026 10:32
@dgesteves-release dgesteves-release Bot mentioned this pull request Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant