Repository navigation
feat(sdk): answer budget, client profiles, an answer cost meter and forwarded headers - #1001
Conversation
…r and http.forwardHeaders
- output.budget { maxBytes, lists } (server, app, tool; false turns it off) fits tool answers
and resource reads without cutting inside JSON: whole list items, then string tails, with
the note in _meta['frontmcp/budget'] and a last text line. Runs in the new fitAnswer stage.
- clients profiles keyed by a clientInfo.name glob: structured both | only | text (shapeAnswer
stage; text also leaves outputSchema out of tools/list), annotations full | no-numbers for
resources/list, instructions full | compact for initialize and server/discover.
- Platform detection learns codex (codex-mcp-client) and claude-code.
- /metrics counts frontmcp_tool_answer_bytes_total, _estimated_tokens_total (bytes / 4) and
_duplicated_bytes_total per tool and client platform (meterAnswer stage).
- http.forwardHeaders keeps more request headers in customHeaders, over HTTP and for direct
calls; authorization, cookie, proxy-* and hop-by-hop headers are refused at startup.
- An invalid http, output or clients option fails @FrontMcp with InvalidDecoratorMetadataError.
- New e2e app demo-e2e-answer-shaping; skills catalog updated.
…reate() docs to the headers it keeps
- The UI metadata stub kept its own platform union, so its exported options rejected codex,
claude-code, generic-mcp and ext-apps; it uses AIPlatformType now.
- create() takes no http option, so its direct calls keep x-frontmcp-* headers only: the
build-for-sdk page no longer claims forwardHeaders there, and the HTTP page names createDirect().
- Tests: a plugin replaces the fitAnswer stage with Around('fitAnswer'), and this.fetch() sends a
forwarded header only to a fetch.forwardCustomHeadersTo origin.
…ping on the answer a hook replaced, safer forwardHeaders - output.budget sends an answer whole, with a warning once per tool, when the cut would break the outputSchema a client validates structuredContent against (minItems, tuples, patterns); the reference client rejected such answers with -32602. - tools:call-tool and resources:read-resource record every answer they respond with in state.output, so an answer a hook replaced around finalize is the one fitted, shaped and counted, instead of the tool's original. - A server with clients profiles marks 2026-07-28 cacheable results private: tools/list, resources/list and server/discover now differ per client. - http.forwardHeaders also refuses set-cookie, host, forwarded, x-forwarded-* and x-real-ip; a glob matching one (x-*) fails at startup. - Client names and header names over 256 characters match no profile or glob, so a long client-chosen name cannot stall a multi-wildcard glob.
…claude A claude-code client was detected as 'claude' and got resourceMode 'inline' by default; its new 'claude-code' platform type fell back to 'cdn'.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: true
|
| Layer / File(s) | Summary |
|---|---|
Budget and client-profile contracts libs/sdk/src/common/metadata/output-policy.ts, libs/sdk/src/common/types/options/clients/*, libs/sdk/src/common/metadata/front-mcp.metadata.ts, libs/sdk/src/common/decorators/front-mcp.decorator.ts, libs/sdk/src/answer/index.ts, libs/sdk/src/index.ts, apps/e2e/demo-e2e-dts-consumer/*, libs/skills/catalog/create-tool/references/output-schema.md |
Output policies accept byte budgets and list-retention settings. @FrontMcp accepts validated client profiles with structured-answer, annotation, and instruction modes. Public exports and consumer type fixtures cover the options. |
Budget fitting and response flows libs/sdk/src/answer/answer-budget.ts, libs/sdk/src/answer/__tests__/answer-budget.spec.ts, libs/sdk/src/resource/flows/read-resource.flow.ts, libs/sdk/src/tool/flows/call-tool.flow.ts, libs/sdk/src/answer/__tests__/answer-shaping.flows.spec.ts, libs/skills/catalog/create-tool/references/output-schema.md, libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md |
Tool and resource answers can be fitted to configured byte budgets. Fitting records omissions and checks output-schema constraints before replacing a tool answer. The flows run answer processing after finalization. |
Client profiles and platform-aware output libs/sdk/src/answer/client-profile.ts, libs/sdk/src/tool/flows/tools-list.flow.ts, libs/sdk/src/resource/flows/resources-list.flow.ts, libs/sdk/src/skill/skill-instructions.helper.ts, libs/sdk/src/transport/*, libs/sdk/src/notification/notification.service.ts, libs/auth/src/common/session.types.ts, libs/uipack/src/adapters/*, libs/skills/catalog/frontmcp-config/references/configure-client-profiles.md, libs/skills/catalog/frontmcp-config/references/configure-transport.md |
Client profiles shape tool answers, tool listings, resource annotations, and injected instructions. Client names are passed through instruction-composition paths. Platform detection and UI serving recognize Claude Code and Codex. |
Answer metering and integrated verification libs/sdk/src/answer/answer-meter.ts, libs/sdk/src/answer/__tests__/answer-meter.spec.ts, libs/skills/catalog/frontmcp-observability/references/metrics-endpoint.md, apps/e2e/demo-e2e-answer-shaping/*, libs/testing/src/server/port-registry.ts |
Tool-answer byte, estimated-token, and duplicated-byte counters are recorded when metrics are enabled. The E2E demo and tests cover budgets, profiles, metrics, and TypeScript consumer declarations. |
Forwarded request headers
| Layer / File(s) | Summary |
|---|---|
Forward-header rules and validation libs/sdk/src/common/types/options/http/*, libs/sdk/src/common/utils/forward-headers.utils.ts, libs/sdk/src/common/types/options/__tests__/answer-shaping.options.spec.ts, libs/sdk/src/common/utils/__tests__/forward-headers.utils.spec.ts, libs/skills/catalog/frontmcp-config/references/configure-http.md, libs/skills/catalog/frontmcp-development/references/decorators-guide.md |
HTTP options accept header names and globs. Validation rejects patterns that match protected headers. The forwarder matches case-insensitively and rejects names longer than 256 characters. |
Header filtering in request contexts libs/sdk/src/context/metadata.utils.ts, libs/sdk/src/context/frontmcp-context-storage.ts, libs/sdk/src/scope/scope.instance.ts, libs/sdk/src/flows/flow.registry.ts, libs/sdk/src/context/__tests__/forwarded-headers.spec.ts, libs/skills/catalog/frontmcp-config/references/configure-http.md, libs/skills/catalog/create-tool/references/execution-context.md |
Request metadata and in-process contexts retain configured headers. Context storage passes the matcher to metadata extraction, while fetch forwarding remains limited to configured origins. |
Estimated code review effort: 4 (Complex) | ~60 minutes
Sequence Diagram(s)
sequenceDiagram
participant MCPClient
participant CallToolFlow
participant AnswerBudget
participant ClientProfile
participant AnswerMeter
MCPClient->>CallToolFlow: call tool
CallToolFlow->>AnswerBudget: fit answer to configured budget
CallToolFlow->>ClientProfile: shape answer for resolved profile
CallToolFlow->>AnswerMeter: record answer cost when enabled
CallToolFlow-->>MCPClient: return processed answer
Suggested reviewers: alexmercerpo
Merge Risk: 
Low · previous review ff2bd
-
These issues affect opt-in header forwarding, unusually long budget notes, and testing support for the new platforms. The PR is mergeable with owner awareness, but those paths should be corrected before relying on them.
Pre-merge checks: 
5
-
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 65.00% which is sufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 40 functions across 50 files. (33 skipped: 17 unsupported, 16 over the file limit.)
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
|
@coderabbitai review |
…at/m05-answer-shaping
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T14:30:45.659Z |
Action not completed
|
…at/m05-answer-shaping
…orwardHeaders A content-* glob kept the inbound content-length, which fetch() then sent to an allow-listed origin with a body of another length. content-length is now never kept. A forwardHeaders name or glob inside a refused family (x-forwarded-port, proxy-t*) passed validation and was then silently dropped; it now fails startup like the other refused headers. Also add the codex and claude-code platform values to the platform helpers of @frontmcp/testing, which mirror the SDK's platform types.
Stacked on #1000
What and why
Proposal items 2.4 (b), 5.2 and 1.10: answers sized for the model reading them, per-client answer shaping, a meter
for what answers cost, and custom request headers beyond
x-frontmcp-*.The reported problems:
tools/callthat returns a 300 KB list fills the model's context. The onlytruncation lived in two plugins (CodeCall's output sanitizer, skilled-openapi's 256 KiB cap).
content[0].textand the same value instructuredContent, also for tools without an output schema. A client reads one of them; the other is wasted.clientInfowas captured but never used to shape answers, and platform detection had no value for Codex(
codex-mcp-clientmatchedmcpand becamegeneric-mcp) nor told aclaude-codeclient from otherclaudeclients./metricshad afrontmcp_tool_category with no counters behind it.x-frontmcp-*headers reached a tool. A server whose clients sendx-acme-tenanthad to rename theheader or read the raw request in a hook.
What this PR changes:
output.budget: { maxBytes, lists?: 'keep-newest' | 'keep-oldest' }, on@FrontMcp,@Appand@Tool(Tool > App > server, like
undeclaredKeys;budget: falseturns off a wider budget). Off unless set.maxBytes(at least 1024) is measured as UTF-8 JSON on each copy ofthe data, so an answer with both the JSON text and
structuredContentsends each within the budget.'keep-newest', the default; thelast with
'keep-oldest'), then the end of long strings, largest values first. Plain text is cut at its end.Images, audio, embedded resources, HTML (
ui://widgets) and blobs are left alone._meta['frontmcp/budget']({ maxBytes, lists, omitted: [{ path: '$.rows', items: 880, of: 1000 }] }) and to a last text content (a lasttext/plaincontent for a resource read):This answer was shortened to fit its 65536-byte budget: left out the first 880 of 1000 items of $.rows.outputSchemaa client validatesstructuredContentagainst (the reference clientrejects such an answer with
-32602): when leaving items out would go below.min(n), shorten a tuple or break apattern, the answer is sent whole and the server logs a warning once per tool.
this.callTool()are never cut.clientsprofiles on@FrontMcp, keyed by a glob ofclientInfo.name(first match wins, case-insensitive; aname over 256 characters matches none, so a long client-chosen name cannot stall a multi-
*glob). With profilesset, every 2026-07-28 cacheable result is
cacheScope: 'private', sincetools/list,resources/listandserver/discovernow differ per client:structured: 'both' | 'only' | 'text'.'only'replaces the text that repeatsstructuredContentwith aone-line summary (
The answer is in structuredContent: rows (30 items), total: 1000.), only for tools whoseoutputSchematools/listlists to that client, never for errors.'text'dropsstructuredContentand leavesoutputSchemaout of that client'stools/list, so a client that validatesstructuredContentdoes not fail.annotations: 'full' | 'no-numbers'overridesskillsConfig.resourceAnnotations(Cherry-pick: fix(sdk): send no numeric skill resource annotation and a compact skills line in the instructions by default #994) per client inresources/list:'no-numbers'dropspriorityfrom every resource,'full'gives eachSKILL.mdpriority: 0.8.instructions: 'full' | 'compact'overridesskillsConfig.injectInstructionsper client, oninitializeand onthe 2026-07-28
server/discover.codex(any name containingcodex; Codex sendscodex-mcp-client, some versionsCodex) andclaude-code(a client namedclaude-code). Codex keepsstructuredContenton the UI path, asit did when it was detected as
generic-mcp./metrics:frontmcp_tool_answer_bytes_total,frontmcp_tool_answer_estimated_tokens_total(bytes / 4, rounded up per answer) and
frontmcp_tool_answer_duplicated_bytes_total(text that is exactly the JSONof
structuredContent), each labelledtool(full name) andclient.clientis the detected platform, neverthe raw client name, so a client cannot mint label values. Counted only with
metrics.enabledand thetoolscategory included.
http.forwardHeaders: string[](names or globs): more request headers kept inthis.context.metadata.customHeaders, over HTTP and for direct calls to acreateDirect()server(
DirectCallOptions.metadata.customHeaders).authorization,cookie,set-cookie,proxy-*, hop-by-hop headers and the client-address headershost,forwarded,x-forwarded-*andx-real-ip(a client could forge them for an upstream that trusts this server) arenever kept, whatever header a glob matches at request time, and a name or glob that matches one fails startup
(
x-*does: it matchesx-forwarded-for). A header name over 256 characters is never kept. The request-log redaction is unchanged, andthis.fetch()still sends custom headers only to thefetch.forwardCustomHeadersToorigins.tools:call-toolstages,fitAnswer,shapeAnswerandmeterAnswer;resources:read-resourcegetsfitAnswer. Each answer the flow sends, thetool's or a hook's
ctx.respond()(anAround('finalize')hook that redacts it included), becomesstate.output,which these stages work on. A plugin wraps or replaces them and reads
state.outputor responds;BUDGET_META_KEY,fitToolAnswer(),fitResourceAnswer()andANSWER_COUNTERSareexported for that.
http,outputorclientsoption stops@FrontMcpwith
InvalidDecoratorMetadataError(@FrontMcp invalid metadata for "http": … FrontMCP never forwards authorization, cookie, proxy-*, hop-by-hop or client-address (host, forwarded, x-forwarded-*, x-real-ip) headers …); it was a bareZodErrorbefore.Different from the plan, and why:
finalize, not before it.finalizeparses the answer and can throwInvalidOutputError; a finalize-phase stage placed before it that throws skipsfinalizeand itsFinallyhooks(the observability span would never close).
tools:call-toolandresources:read-resourcerecord each answer theyrespond with in
state.output, and each later stage that changes it responds again, so an answer a hook replacedaround
finalizeis the one fitted and shaped. ExistingWill/Did/Finally('finalize')hooks are unaffected; ahook's own answer (
ctx.respond()beforefinalize) is now fitted, shaped and counted like the tool's.model pays for; the duplicate is what
clientsprofiles remove, and the meter counts it separately.structured: 'text'also hidesoutputSchemafrom that client'stools/list. The MCP spec requiresstructuredContentwhen a tool lists an output schema, and the reference client SDK rejects an answer without it.clientlabel is the platform, for bounded label cardinality.forwardHeadersalso applies to direct calls, which read custom headers the way HTTP requests do (DirectCallOptions.metadata(custom headers, user agent, client IP) is dropped in direct mode #709).User-facing change
output.budget(@FrontMcp,@App,@Tool),clients(@FrontMcp),http.forwardHeaders.OutputBudget,BudgetListPolicy,ClientProfile,StructuredAnswerMode,ClientAnnotationsMode,ClientInstructionsMode,ClientProfilesOptionsInput,BudgetNote,BudgetOmission;exported
BUDGET_META_KEY,fitToolAnswer(),fitResourceAnswer(),ANSWER_COUNTERS.tools:call-toolstagesfitAnswer,shapeAnswer,meterAnswer; newresources:read-resourcestagefitAnswer.AIPlatformTypevalues'codex'and'claude-code'(also accepted bytransport.platformDetection.mappings)./metricscountersfrontmcp_tool_answer_bytes_total,frontmcp_tool_answer_estimated_tokens_total,frontmcp_tool_answer_duplicated_bytes_total.composeCallerInstructions()takes an optionalclientName;McpHandlerOptions.composeInstructionsgets theclient name as a second argument.
codex-mcp-clientwas platformgeneric-mcp→codex;claude-codewasclaude→claude-code. UI serving is unchanged for both. Code that comparesplatformTypewith'generic-mcp'or'claude'to recognise these clients needs the new values.http,outputorclientsoption threw aZodErrorfrom@FrontMcp→InvalidDecoratorMetadataErrornaming the field.
@FrontMcpoptions reference (http,output, newclients), the@Tool/@Appoutputoption, the tool output-schema page (new answer budget section), the HTTP configurationpage (forwarded headers), the transport / platform detection page, the
/metricspage (answer cost counters), therequest context page (
customHeaders), and a new client profiles page.create-tool/references/output-schema.md(new "Answer budget" section) anddecorator-options.md(outputrow);frontmcp-config/references/configure-client-profiles.md(new) withexamples/configure-client-profiles/coding-agent-profiles.md(new), theSKILL.mdrouting and examples tables,and
skills-manifest.json;frontmcp-config/references/configure-http.md(new "Forwarded Request Headers" section, checklist,troubleshooting);
frontmcp-config/references/configure-transport.md(new "Client Platform Detection" section);frontmcp-config/references/configure-skills-http.md(per-client overrides ofresourceAnnotationsandinjectInstructions);frontmcp-observability/references/metrics-endpoint.md(answer cost counters);frontmcp-development/references/decorators-guide.md(http,output,clientsrows),create-plugin-hooks.md(which headers reachcustomHeaders; the stages afterfinalizeandstate.output) andcreate-tool/references/execution-context.md(which headers reachcustomHeaders).Tests
Unit (
libs/sdk):answer/__tests__/answer-budget.spec.ts: a 300 KB answer fitted to the budget as valid JSON with the newest itemskept and contiguous;
keep-oldest; a long string cut with the characters counted; the single item kept and fittedwhen no whole item fits; no split surrogate pair, UTF-8 bytes counted; an answer of numbers and keys left as it is;
structuredContent and the text that repeats it fitted alike with the note text and
_meta; JSON text withoutstructuredContent; plain text; text that only looks like JSON; the note naming five parts and counting the rest;
pretty JSON compacted without a note; images, HTML and blobs left alone; the resource note content; the
outputSchemacheck naming a cut belowminItemsor a shortened tuple, and a schema it cannot check.answer/__tests__/client-profile.spec.ts: profile resolution (first glob, case-insensitive, no match); where theclient name comes from; a name over 256 characters matches no profile; the one-line summary;
'only'/'text'/'both', and'only'left out for tools whoseschema is not listed and for errors; annotations for
'no-numbers'and'full'.answer/__tests__/answer-meter.spec.ts: bytes, estimated tokens and duplicated bytes; when metering is on; the threecounters with their labels.
answer/__tests__/answer-shaping.flows.spec.ts, through connected clients: a tool answer and a resource read fittedto
output.budget;budget: false; athis.callTool()answer left whole;'only','text'with nooutputSchemalisted (the upstream client accepts the answer),'both'for a tool without a schema;no-numbersand
fullannotations against another client; the instructions profile; the counters withclient: 'codex';a plugin that replaces the
fitAnswerstage withAround('fitAnswer')answers in place of the budget; an answerwhose cut would break its
.min(200)schema is sent whole (before, the upstream client rejected it with-32602);an answer an
Around('finalize')plugin redacted stays redacted for astructured: 'text'client.common/types/options/__tests__/answer-shaping.options.spec.ts: the budget, profile andforwardHeadersschemas,budget resolution, and
InvalidDecoratorMetadataErrorat boot for each option.common/utils/__tests__/forward-headers.utils.spec.tsandcontext/__tests__/forwarded-headers.spec.ts: refusedheaders and globs (
set-cookie,host,forwarded,x-forwarded-*,x-real-ip, andx-*at boot), a headername over 256 characters, case-insensitive matching, HTTP and in-process metadata, the context storage, and
this.fetch()forwarding a kept header only to a
forwardCustomHeadersToorigin.notification/__tests__/platform-detection.spec.ts:codexandclaude-codefromclientInfoand the user agent.transport/mcp-handlers/__tests__/initialize-request.handler.spec.ts: the handler passes the client name to theinstructions composer.
transport/mcp-20260728/__tests__/cache-scope.per-caller.spec.ts: each cacheable 2026-07-28 result iscacheScope: 'private'on a server withclientsprofiles.libs/uipack/src/adapters/__tests__/serving-mode.spec.ts: Codex keepsstructuredContent; aclaude-codeclient is servedas
claude.template-renderer.spec.ts: aclaude-codeclient getsresourceMode: 'inline'widgets, as when it wasdetected as
claude.E2E:
apps/e2e/demo-e2e-answer-shaping(10 tests): a 300 KB tool answer fitted to the 64 KB budget, valid JSON,newest rows kept, with the note and
_meta; an answer within the budget left alone; an answer whoserows.min(500)a cut would break sent whole; a resource read fitted; aclient named
summary-agentgetsstructured: 'only'; atext-agentgets nooutputSchemaand nostructuredContent; another client gets both; the three counters on/metrics; anx-acme-tenantheader reaches atool while
x-otherdoes not;src/refused-header.main.ts(forwardHeaders: ['authorization']) fails at boot withthe message.
apps/e2e/demo-e2e-dts-consumer/fixture/answer-shaping-must-compile.ts: the options, types andBUDGET_META_KEYtype-check from the published declarations;
tool-must-fail.tsrejectsbudget.lists: 'newest'.Red first: the unit specs failed before the implementation (flows 8 of 12,
forwardHeadersvalidation 8, boot errors3, metadata 3, platform detection 5). The review fixes were red first too: the cut below
.min(200)(the upstreamclient threw
-32602), the redacted answer (the client got the tool's original), the fivecacheScopecases, 14header refusals, the two 256-character caps and the
claude-coderesourceMode.Ran:
nx test sdk(586 suites, 7628 tests),nx run-many -t test -p uipack observability plugin-cache plugin-codecall plugin-remember plugin-approval plugin-feature-flags skills testing auth,nx test demo-e2e-answer-shaping(10/10),nx test demo-e2e-dts-consumer(16/16), and the e2e apps covering the changedflows:
demo-e2e-hooks(23),demo-e2e-protocol-20260728(145),demo-e2e-cache(14),demo-e2e-tasks(15),demo-e2e-standalone(53),demo-e2e-ui(361);eslinton the changed files (no errors),nx build sdk uipack,node scripts/fix-unused-imports.mjs,prettier --checkon the changed files, andtsc --noEmitof the sdk anduipack libraries.
Summary by CodeRabbit