Support nested or raw JSON bodies for complex MCP tool requests #16

Closed
opened 2026-07-09 20:25:50 +00:00 by coilyco-ops · 2 comments
Member

Context from coilyco-bridge/deploy#107. The tailnet-only read-only signoz-mcp landed using ward-mcp, but the deploy run reported that SigNoz query_range was awkward because ward-mcp's current grammar does not model the nested/raw JSON body shape precisely.

Problem:

  • Some HTTP-backed MCP tools need structured request bodies deeper than scalar fields.
  • SigNoz POST /api/v3/query_range is the immediate example: the useful query body is nested, and a lossy/scalar-only MCP schema makes the tool less ergonomic or less precise.
  • Deploy worked around this enough for v1, but future query tools will hit the same limitation.

Design ask:

  • Evaluate adding a ward-mcp way to expose nested JSON input or a raw JSON body parameter for a tool.
  • Preserve safety and typed discoverability where possible.
  • Avoid forcing every complex endpoint into bespoke MCP server code.
  • Include migration guidance for existing simple scalar tools.

Acceptance for design:

  • Post a recommendation covering grammar/API shape, safety tradeoffs, and whether implementation should be in ward-mcp core or generated chart/config conventions.
  • If implementation is straightforward, file or carry a follow-up implementation issue.

Related: coilyco-bridge/deploy#107, signoz-mcp query_range.

Context from coilyco-bridge/deploy#107. The tailnet-only read-only `signoz-mcp` landed using ward-mcp, but the deploy run reported that SigNoz `query_range` was awkward because ward-mcp's current grammar does not model the nested/raw JSON body shape precisely. Problem: * Some HTTP-backed MCP tools need structured request bodies deeper than scalar fields. * SigNoz `POST /api/v3/query_range` is the immediate example: the useful query body is nested, and a lossy/scalar-only MCP schema makes the tool less ergonomic or less precise. * Deploy worked around this enough for v1, but future query tools will hit the same limitation. Design ask: * Evaluate adding a ward-mcp way to expose nested JSON input or a raw JSON body parameter for a tool. * Preserve safety and typed discoverability where possible. * Avoid forcing every complex endpoint into bespoke MCP server code. * Include migration guidance for existing simple scalar tools. Acceptance for design: * Post a recommendation covering grammar/API shape, safety tradeoffs, and whether implementation should be in ward-mcp core or generated chart/config conventions. * If implementation is straightforward, file or carry a follow-up implementation issue. Related: coilyco-bridge/deploy#107, signoz-mcp query_range.
Author
Member

🔎 ward agent advisor - cross-repo fan-out

ward agent advisor --harness codex ran a one-shot standard research pass on this question:

Read the issue title, body, and comment thread below as the research brief. Identify the decisions, options, open questions, and risks, then give a clear advisory recommendation that can be posted back on the issue.

Recommendation

Implement this in core, not in chart/config conventions. The limitation is not in the Helm chart or the per-service .mcp.kdl. It is in the shared cli-guard/http/opcore model that ward-mcp projects.

As of 2026-07-09, the deployed SigNoz example in /substrate/deploy/services/signoz-mcp/signoz.mcp.kdl already targets POST /api/v5/query_range, not v3, and the awkward part is exactly what the issue describes: the request body has nested/object-valued fields like compositeQuery, variables, and formatOptions.

What the code says today

The current behavior is split between runtime capability and schema expressiveness:

  • opcore.ParseInline only accepts flat inline body declarations like body "title" "body".
  • Those inline body fields are always lowered to flat Field{Name, Type:"string"} values.
  • opcore.Schema is also flat: map[string]Property, with no nested object model.
  • ward-mcp then exposes that flat schema as MCP inputSchema.
  • But at call time, ward-mcp already unmarshals tool arguments as map[string]any and passes body values through verbatim.

That means nested JSON is already possible at runtime for top-level body keys if the MCP client sends it anyway. The real bug is that the generated schema is lossy or misleading, so clients lose discoverability and may coerce object-valued inputs incorrectly.

Decision

Use a two-tier design:

  1. Primary path: add nested typed body support to opcore and the inline grammar.
  2. Escape hatch: also allow a body field to be marked as a raw JSON object/array blob when modeling the full nested structure would be noisy or unstable.

That preserves typed discoverability for normal cases while avoiding bespoke Go MCP servers for endpoints like SigNoz query builders.

Keep existing flat syntax unchanged as sugar:

can create issue {
    path "/repos/{owner}/{repo}/issues"
    body "title" "body"
}

Add an additive block form for richer cases, for example:

can read query {
    path "/api/v5/query_range"
    body {
        field "schemaVersion" type="string"
        field "start" type="integer" required=true
        field "end" type="integer" required=true
        field "requestType" type="string" required=true

        object "formatOptions" raw=true
        object "variables" raw=true
        object "compositeQuery" raw=true required=true
    }
}

And later, if needed, permit fully nested authoring as an extension of the same shape:

body {
    object "compositeQuery" {
        array "queries" items="object" raw=true
    }
}

The key point is the model, not the exact node spelling:

  • field for scalar fields
  • object for object-valued fields
  • array for arrays
  • raw=true for a JSON blob escape hatch at that field

Why this shape

This is the best fit for the current architecture:

  • It stays transport-neutral in opcore rather than hard-coding MCP-only behavior.
  • It keeps existing simple tools simple.
  • It matches the existing cli-guard design, where the CLI side already has a comparable escape hatch via --body-file for shapes too complex for flags.
  • It gives ward-mcp a truthful JSON Schema instead of today’s flat string-only approximation.

Why not chart/config conventions only

A chart-level convention cannot fix the real problem, because the bad abstraction is upstream:

  • the parser only knows flat body names
  • the neutral schema only knows flat properties
  • ward-mcp can only project what opcore tells it

A deploy-side convention can document a workaround, but it cannot restore typed discoverability for all consumers.

Why not raw-JSON-only as the whole answer

A single raw JSON parameter would be the fastest patch, but it is too blunt as the default:

  • it hides useful structure from MCP clients
  • it weakens required-field discoverability inside the blob
  • it makes every complex endpoint feel bespoke again

Raw JSON should exist, but as the fallback, not the only surface.

Safety tradeoffs

The safety impact is acceptable if the feature is constrained correctly.

  • Body content is already outside the URL metachar gate today. Nested body support does not create a new injection surface for path/query.
  • Keep raw JSON allowed only for body/form payloads, never for path or query.
  • Require raw JSON to decode as a JSON object or array, not an arbitrary string.
  • Preserve the existing request-size cap in ward-mcp (8 MiB) and keep set/fixed-body behavior authoritative.
  • Do not let a raw body field bypass restrict, auth, or method/path controls.

Migration guidance

This can be fully additive.

  • Keep body "a" "b" working exactly as it does now.
  • Treat it as shorthand for flat optional string fields.
  • Only specs that need richer bodies opt into the new block form.
  • Existing simple Forgejo-style tools need no migration.
  • signoz-mcp can migrate just its read_query tool once the core feature lands.

Open questions

These are the only design questions worth settling before implementation:

  1. How much schema richness is needed in v1?
    My recommendation: only string|boolean|integer|number|object|array, plus required and raw=true. Do not start with oneOf, enum, nullable, or full JSON Schema embedding.
  2. Should ward-mcp also validate tool arguments against the generated schema server-side?
    Not required for v1. The main defect is the lossy advertised schema, not request forwarding. Server-side validation can be a later tightening pass if client interoperability demands it.
  3. Do we need fully modeled nested arrays-of-objects immediately?
    Probably not. A raw=true object field is enough for the SigNoz-style query envelope, and typed nested authoring can grow incrementally.

Advisory conclusion

The recommendation is:

  • Implement nested/raw body support in cli-guard core (opcore).
  • Update ward-mcp to expose the richer schema and document the new authoring form.
  • Do not solve this only in deploy/chart conventions.
  • Make raw JSON a field-level escape hatch, not the default modeling strategy.

That gives the narrowest change that fixes the real abstraction leak while preserving backward compatibility for the current flat tools.


The work spans multiple repos, so it was filed as 2 tracked issue(s), in dependency order:

  1. coilyco-flight-deck/cli-guard#212 - feat(opcore): support nested body schema and raw JSON body fields in ParseInline
  2. coilyco-flight-deck/ward-mcp#17 - feat(mcp): project opcore nested/raw body schema into MCP tool inputSchema

Researched and posted automatically by ward agent advisor --harness codex (ward#424). This is one-shot research, not a carried change - verify before acting on it.

— Codex, via ward agent

### 🔎 ward agent advisor - cross-repo fan-out `ward agent advisor --harness codex` ran a one-shot **standard** research pass on this question: > Read the issue title, body, and comment thread below as the research brief. Identify the decisions, options, open questions, and risks, then give a clear advisory recommendation that can be posted back on the issue. ## Recommendation Implement this in **core**, not in chart/config conventions. The limitation is not in the Helm chart or the per-service `.mcp.kdl`. It is in the shared `cli-guard/http/opcore` model that ward-mcp projects. As of **2026-07-09**, the deployed SigNoz example in `/substrate/deploy/services/signoz-mcp/signoz.mcp.kdl` already targets **`POST /api/v5/query_range`**, not `v3`, and the awkward part is exactly what the issue describes: the request body has nested/object-valued fields like `compositeQuery`, `variables`, and `formatOptions`. ## What the code says today The current behavior is split between **runtime capability** and **schema expressiveness**: - `opcore.ParseInline` only accepts flat inline body declarations like `body "title" "body"`. - Those inline body fields are always lowered to flat `Field{Name, Type:"string"}` values. - `opcore.Schema` is also flat: `map[string]Property`, with no nested object model. - `ward-mcp` then exposes that flat schema as MCP `inputSchema`. - But at call time, `ward-mcp` already unmarshals tool arguments as `map[string]any` and passes body values through verbatim. That means nested JSON is **already possible at runtime** for top-level body keys if the MCP client sends it anyway. The real bug is that the generated schema is lossy or misleading, so clients lose discoverability and may coerce object-valued inputs incorrectly. ## Decision Use a **two-tier design**: 1. **Primary path**: add nested typed body support to `opcore` and the inline grammar. 2. **Escape hatch**: also allow a body field to be marked as a **raw JSON object/array** blob when modeling the full nested structure would be noisy or unstable. That preserves typed discoverability for normal cases while avoiding bespoke Go MCP servers for endpoints like SigNoz query builders. ## Recommended grammar shape Keep existing flat syntax unchanged as sugar: ```kdl can create issue { path "/repos/{owner}/{repo}/issues" body "title" "body" } ``` Add an additive block form for richer cases, for example: ```kdl can read query { path "/api/v5/query_range" body { field "schemaVersion" type="string" field "start" type="integer" required=true field "end" type="integer" required=true field "requestType" type="string" required=true object "formatOptions" raw=true object "variables" raw=true object "compositeQuery" raw=true required=true } } ``` And later, if needed, permit fully nested authoring as an extension of the same shape: ```kdl body { object "compositeQuery" { array "queries" items="object" raw=true } } ``` The key point is the model, not the exact node spelling: - `field` for scalar fields - `object` for object-valued fields - `array` for arrays - `raw=true` for a JSON blob escape hatch at that field ## Why this shape This is the best fit for the current architecture: - It stays **transport-neutral** in `opcore` rather than hard-coding MCP-only behavior. - It keeps existing simple tools simple. - It matches the existing `cli-guard` design, where the CLI side already has a comparable escape hatch via `--body-file` for shapes too complex for flags. - It gives ward-mcp a truthful JSON Schema instead of today’s flat string-only approximation. ## Why not chart/config conventions only A chart-level convention cannot fix the real problem, because the bad abstraction is upstream: - the parser only knows flat body names - the neutral schema only knows flat properties - ward-mcp can only project what opcore tells it A deploy-side convention can document a workaround, but it cannot restore typed discoverability for all consumers. ## Why not raw-JSON-only as the whole answer A single raw JSON parameter would be the fastest patch, but it is too blunt as the default: - it hides useful structure from MCP clients - it weakens required-field discoverability inside the blob - it makes every complex endpoint feel bespoke again Raw JSON should exist, but as the fallback, not the only surface. ## Safety tradeoffs The safety impact is acceptable if the feature is constrained correctly. - Body content is already outside the URL metachar gate today. Nested body support does not create a new injection surface for path/query. - Keep raw JSON allowed **only for body/form payloads**, never for path or query. - Require raw JSON to decode as a JSON object or array, not an arbitrary string. - Preserve the existing request-size cap in ward-mcp (`8 MiB`) and keep `set`/fixed-body behavior authoritative. - Do not let a raw body field bypass `restrict`, auth, or method/path controls. ## Migration guidance This can be fully additive. - Keep `body "a" "b"` working exactly as it does now. - Treat it as shorthand for flat optional string fields. - Only specs that need richer bodies opt into the new block form. - Existing simple Forgejo-style tools need no migration. - `signoz-mcp` can migrate just its `read_query` tool once the core feature lands. ## Open questions These are the only design questions worth settling before implementation: 1. **How much schema richness is needed in v1?** My recommendation: only `string|boolean|integer|number|object|array`, plus `required` and `raw=true`. Do not start with `oneOf`, `enum`, nullable, or full JSON Schema embedding. 2. **Should ward-mcp also validate tool arguments against the generated schema server-side?** Not required for v1. The main defect is the lossy advertised schema, not request forwarding. Server-side validation can be a later tightening pass if client interoperability demands it. 3. **Do we need fully modeled nested arrays-of-objects immediately?** Probably not. A `raw=true` object field is enough for the SigNoz-style query envelope, and typed nested authoring can grow incrementally. ## Advisory conclusion The recommendation is: - **Implement nested/raw body support in `cli-guard` core (`opcore`)**. - **Update ward-mcp to expose the richer schema and document the new authoring form**. - **Do not solve this only in deploy/chart conventions**. - **Make raw JSON a field-level escape hatch, not the default modeling strategy**. That gives the narrowest change that fixes the real abstraction leak while preserving backward compatibility for the current flat tools. --- The work spans multiple repos, so it was filed as 2 tracked issue(s), in dependency order: 1. [coilyco-flight-deck/cli-guard#212](https://forgejo.coilysiren.me/coilyco-flight-deck/cli-guard/issues/212) - feat(opcore): support nested body schema and raw JSON body fields in ParseInline 2. [coilyco-flight-deck/ward-mcp#17](https://forgejo.coilysiren.me/coilyco-flight-deck/ward-mcp/issues/17) - feat(mcp): project opcore nested/raw body schema into MCP tool inputSchema --- Researched and posted automatically by `ward agent advisor --harness codex` (ward#424). This is one-shot research, not a carried change - verify before acting on it. <!-- ward-agent-reply --> <!-- ward-agent-signature --> — Codex, via `ward agent`
Author
Member

Design acceptance is satisfied by the advisor result above. It recommends implementing the schema model in cli-guard core, then consuming it from ward-mcp, and it filed the follow-up issues in dependency order: coilyco-flight-deck/cli-guard#212 and coilyco-flight-deck/ward-mcp#17. The agent is closing this design ticket and leaving implementation tracked on those issues.

Design acceptance is satisfied by the advisor result above. It recommends implementing the schema model in cli-guard core, then consuming it from ward-mcp, and it filed the follow-up issues in dependency order: coilyco-flight-deck/cli-guard#212 and coilyco-flight-deck/ward-mcp#17. The agent is closing this design ticket and leaving implementation tracked on those issues.
Sign in to join this conversation.
No description provided.