From 351575d554d352438b0086617a41f806aa1024b5 Mon Sep 17 00:00:00 2001 From: htjulia Date: Fri, 30 Oct 2026 09:52:23 +0900 Subject: [PATCH 1/2] fix(mcp): move tool error problem to _meta A coded tool error wrote its RFC 9457 problem object into structuredContent. The spec reserves that field for the payload the tool's outputSchema describes and requires it to conform, and some clients (the TypeScript SDK 1.x) validate it on error results too, so they replaced the actionable error with a schema-violation error. Every built-in tool that declares an outputSchema was affected. The problem now travels in the result _meta under org.htcom.protean/problem (McpToolResult.PROBLEM_META_KEY), next to any _meta the tool attached. list_runtimes declared runtimes[].scope as a string although it is null whenever auto-provision is off (the default), so every default-install result violated its outputSchema and strict-schema mode rejected the call outright. scope is now nullable. Tests sweep every schema-declaring tool's error path and run list_runtimes under strict validation; guides and changelog (en+ko) document the new error location. Fixes #89, Fixes #90 Co-Authored-By: htjulia --- CHANGELOG.ko.md | 26 ++++++- CHANGELOG.md | 33 +++++++- docs/guide/08-mcp-integration.ko.md | 21 ++++- docs/guide/08-mcp-integration.md | 21 ++++- .../org/htcom/protean/mcp/McpDispatcher.java | 24 +++--- .../java/org/htcom/protean/mcp/McpTool.java | 5 +- .../org/htcom/protean/mcp/McpToolResult.java | 12 ++- .../protean/mcp/tools/ListRuntimesTool.java | 5 +- .../org/htcom/protean/McpCancelMetaTest.java | 30 +++++++- .../org/htcom/protean/McpDispatcherTest.java | 9 ++- .../htcom/protean/McpExtensionPointsTest.java | 5 +- .../McpOutputSchemaConformanceTest.java | 77 +++++++++++++++++++ .../htcom/protean/McpStrictSchemaTest.java | 6 +- .../org/htcom/protean/McpToolObjectTest.java | 4 +- .../org/htcom/protean/McpWriteToolsTest.java | 3 +- 15 files changed, 243 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.ko.md b/CHANGELOG.ko.md index a812c49..c303184 100644 --- a/CHANGELOG.ko.md +++ b/CHANGELOG.ko.md @@ -10,8 +10,9 @@ ## [Unreleased] -콘솔 SSE 스트림의 wire 계약에 하나가 추가됐다(아래). 소비자가 컴파일하는 Java API 자체는 -`0.0.1` 에서 바뀌지 않았고 그대로 링크된다. 그 밖에 바뀐 것은 내장 MCP 툴이 **무엇을 답하는가**다. +wire 계약 변경은 둘이다 — 콘솔 SSE 스트림에 하나가 추가됐고, **MCP 툴 오류의 problem detail 이 +`structuredContent` 에서 `_meta` 로 옮겨졌다**(둘 다 아래). 소비자가 컴파일하는 Java API 는 상수 +하나가 늘었을 뿐 `0.0.1` 에서 바뀌지 않았고 그대로 링크된다. 그 밖에 바뀐 것은 내장 MCP 툴이 **무엇을 답하는가**다. 이 툴들을 wrap·subclass·위임해서 쓰고 있다면 이 절 끝의 마이그레이션 노트를 읽어야 한다. ### 추가 @@ -27,9 +28,18 @@ 연결 시점의 `capacity`. `EventSource` 가 재연결할 때마다 다시 나가며, 클라이언트는 모르는 필드를 무시해야 한다. 기존 클라이언트에는 영향이 없다 — `EventSource` 는 리스너가 없는 named 이벤트를 버린다. 문서는 `docs/guide/04-rest-api.ko.md`. +- `McpToolResult.PROBLEM_META_KEY`(`"org.htcom.protean/problem"`) — 코드가 붙은 툴 오류가 이제 + problem detail 을 싣는 `_meta` 키(아래 *변경* 참고). ### 수정 +- `protean.list_runtimes` 가 `runtimes[].scope` 를 `string` 으로 선언했는데, 같은 툴의 설명은 + auto-provision 이 꺼져 있으면(기본값) `null` 이라고 적고 있었다. 그래서 기본 설치에서는 모든 + 호출이 툴의 `outputSchema` 를 어기는 결과를 냈다 — 검증하는 클라이언트는 `scope: null found, + string expected` 를 보고했고, `protean.mcp.strict-schema=true` 에서는 디스패처의 출력 검사가 + 호출을 `OUTPUT_SCHEMA_VIOLATION` 으로 바꿔 툴을 아예 쓸 수 없었다. 이제 `scope` 는 + `["string", "null"]` 로 선언한다. + - `protean.list_modules` 의 `trustTier` 필터가 인자(`String`)를 `ModuleStatus.trustTier()` (`TrustTier` enum)와 비교해 `String.equals(Object)` 가 모든 모듈에 대해 false 였다 — **어떤 값을 줘도 필터가 아무것도 맞추지 못했다.** 에러도 없고 형태가 멀쩡한 빈 목록이 @@ -55,6 +65,18 @@ ### 변경 +- **코드가 붙은 MCP 툴 오류가 RFC 9457 problem detail 을 `structuredContent` 가 아니라 result + `_meta` 의 `org.htcom.protean/problem` 에 싣는다.** `0.0.1` 에서는 problem 객체(`type`·`title`· + `detail`·`code` 와 확장 멤버)를 `structuredContent` 에 썼는데, 스펙은 그 자리를 툴의 + `outputSchema` 가 기술하는 페이로드에 두고 스키마를 따르도록 요구한다. `outputSchema` 를 선언한 + 내장 툴이 전부 해당됐다 — 예: auto-provision 이 꺼진 상태의 `protean.scope_list`, 없는 모듈에 + 대한 `protean.patch_module`. 오류 result 에서도 `structuredContent` 를 검증하는 클라이언트 + (TypeScript SDK `@modelcontextprotocol/sdk` 1.x 가 그렇다)는 실제 오류를 `Structured content + does not match the tool's output schema` 로 바꿔 버려, 에이전트가 무엇이 잘못됐는지 보지 + 못했다. **오류에서 `structuredContent.code` 를 읽던 클라이언트는 + `_meta["org.htcom.protean/problem"].code` 를 읽어야 한다.** `[CODE]` 접두 텍스트는 그대로다. + 툴이 붙인 `_meta` 는 problem 과 함께 유지된다. 성공 result 와 JSON-RPC 프로토콜 오류 + (`error.data`)는 바뀌지 않았다. 문서는 `docs/guide/08-mcp-integration.ko.md`(*툴 오류*). - 커밋되는 개발 버전이 `0.1.0-SNAPSHOT` 이 됐다(종전에는 릴리스된 좌표 그대로인 `0.0.1-SNAPSHOT`). **GitHub Packages 의 개발 스냅샷도 이 좌표로 옮겨 간다** — `org.htcom:protean:0.0.1-SNAPSHOT` 에 고정된 빌드는 더 이상 갱신을 받지 못하므로 diff --git a/CHANGELOG.md b/CHANGELOG.md index dc67d74..6c8ddc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,11 @@ While the version is `0.x`, the public API may change between minor releases. ## [Unreleased] -One addition to the wire contract of the console SSE stream (below); the Java API a -consumer compiles against is otherwise unchanged from `0.0.1` and still links. What -else changed is what the built-in MCP tools *answer*. If you wrap, subclass, or +Two wire-contract changes: an addition to the console SSE stream, and **a move of the +MCP tool-error problem detail from `structuredContent` to `_meta`** (both below). The +Java API a consumer compiles against gains one constant and is otherwise unchanged +from `0.0.1`, so it still links. What else changed is what the built-in MCP tools +*answer*. If you wrap, subclass, or delegate to them, read the migration note at the end of this section. ### Added @@ -31,9 +33,19 @@ delegate to them, read the migration note at the end of this section. and clients must ignore fields they do not recognize. Existing clients are unaffected — `EventSource` drops named events that have no listener. Documented in `docs/guide/04-rest-api.md`. +- `McpToolResult.PROBLEM_META_KEY` (`"org.htcom.protean/problem"`) — the `_meta` key a + coded tool error now carries its problem detail under (see *Changed*). ### Fixed +- `protean.list_runtimes` declared `runtimes[].scope` as `string`, while its own + description says it is `null` when auto-provision is off — the default. Every call + on a default install therefore returned a result that violated the tool's + `outputSchema`: a client validating it reported `scope: null found, string + expected`, and with `protean.mcp.strict-schema=true` the dispatcher's own output + check turned the call into `OUTPUT_SCHEMA_VIOLATION`, so the tool could not be used + at all. `scope` is now declared `["string", "null"]`. + - `protean.list_modules`'s `trustTier` filter compared the argument (a `String`) against `ModuleStatus.trustTier()` (a `TrustTier` enum), so `String.equals(Object)` was false for every module and **the filter matched nothing for any value** — with @@ -64,6 +76,21 @@ delegate to them, read the migration note at the end of this section. ### Changed +- **A coded MCP tool error now carries its RFC 9457 problem detail in the result + `_meta` under `org.htcom.protean/problem`, not in `structuredContent`.** On `0.0.1` + the problem object (`type`·`title`·`detail`·`code` and extension members) was + written to `structuredContent`, which the spec reserves for the payload the tool's + `outputSchema` describes and requires to conform to it. Every built-in tool that + declares an `outputSchema` was affected — for example `protean.scope_list` with + auto-provision off, or `protean.patch_module` on an unknown module. A client that + validates `structuredContent` on error results too — the TypeScript SDK's + `@modelcontextprotocol/sdk` 1.x does — replaced the actionable error with + `Structured content does not match the tool's output schema`, so the agent never + saw what went wrong. **Clients that read `structuredContent.code` on an error must + read `_meta["org.htcom.protean/problem"].code` instead**; the `[CODE]`-prefixed text + content is unchanged. A tool's own `_meta` is kept alongside the problem. Success + results and JSON-RPC protocol errors (`error.data`) are unchanged. Documented in + `docs/guide/08-mcp-integration.md` (*Tool errors*). - The committed development version is now `0.1.0-SNAPSHOT` (it was `0.0.1-SNAPSHOT`, left at the released coordinate). **Development snapshots on GitHub Packages move with it** — a build pinned to `org.htcom:protean:0.0.1-SNAPSHOT` stops receiving diff --git a/docs/guide/08-mcp-integration.ko.md b/docs/guide/08-mcp-integration.ko.md index a0e0168..87f1ab8 100644 --- a/docs/guide/08-mcp-integration.ko.md +++ b/docs/guide/08-mcp-integration.ko.md @@ -273,8 +273,27 @@ tier 모델과 키별 의미는 [03. 설정](03-configuration.ko.md) 참고. 일반 모듈 대신 **LIBRARY** 모듈(shared-module 타입 공유)을 배포하려면 top-level 에 `kind: "LIBRARY"` 와 `exports`(발행할 패키지)를 주고 `controller` 는 생략한다; 라이브러리를 소비하는 모듈은 `uses`(라이브러리 모듈 id)를 추가한다. `controller` 처럼 `id`/`version` 과 같은 레벨에 둔다. [02. 모듈 작성 §8](02-module-authoring.ko.md) 참고. +### 툴 오류 + 게이트 거부·컴파일 실패 같은 도메인 실패는 JSON-RPC error 가 아니라 tool result 의 `isError: true`(+진단 텍스트)로 매핑된다. 프로토콜 수준 오류(알 수 없는 툴·필수 인자 누락 등)만 JSON-RPC error 로 나간다. +코드가 붙은 툴 오류는 RFC 9457 problem detail 을 `structuredContent` 가 **아니라** result **`_meta`** 의 `org.htcom.protean/problem` 키(상수 `McpToolResult.PROBLEM_META_KEY`)에 싣는다. 툴이 직접 붙인 `_meta` 키는 그대로 함께 나간다: + +```json +{ + "content": [{ "type": "text", "text": "[STATE_CONFLICT] scope tools require worker.db.auto-provision=true — …" }], + "isError": true, + "_meta": { + "org.htcom.protean/problem": { + "type": "urn:protean:error:state-conflict", "title": "State conflict", + "detail": "scope tools require worker.db.auto-provision=true — …", "code": "STATE_CONFLICT" + } + } +} +``` + +`structuredContent` 는 툴의 `outputSchema` 가 기술하는 페이로드 전용이다. 스펙은 그것이 스키마를 따르도록 요구하고, 일부 클라이언트는 오류 result 에서도 검증한다 — 거기에 problem 객체가 있으면 그 클라이언트는 실제 오류 대신 스키마 위반을 보고한다. 안정 `code`(와 `gate`·`moduleId`·`diagnostics` 같은 확장 멤버)는 `_meta["org.htcom.protean/problem"]` 에서 읽는다. `[CODE]` 접두 텍스트는 텍스트만 보여 주는 클라이언트를 위한 폴백이다. + ## 인증·인가 **라이브러리는 인증을 구현하지 않는다.** 두 축으로 나뉜다. @@ -424,7 +443,7 @@ claude mcp add --transport http protean http://localhost:8080/platform/mcp - `structuredContent` 가 존재하고 **객체**인지 - 스키마의 **top-level `required`** 필드가 모두 있는지 -둘 중 하나라도 어기면 조용히 흘리지 않고 `isError`("출력 스키마 위반: …") 로 막는다 — 잘못된 구조화 출력이 클라로 새는 버그 계열을 서버에서 차단하기 위함이다. `isError` result(도메인 실패)에는 이 검사를 적용하지 않는다(구조화 출력이 없는 게 정상). +둘 중 하나라도 어기면 조용히 흘리지 않고 `isError`("출력 스키마 위반: …") 로 막는다 — 잘못된 구조화 출력이 클라로 새는 버그 계열을 서버에서 차단하기 위함이다. `isError` result(도메인 실패)에는 이 검사를 적용하지 않는다(구조화 출력이 없는 게 정상) — 코드가 붙은 오류의 problem detail 은 `structuredContent` 가 아니라 `_meta` 에 실린다(위 [툴 오류](#툴-오류) 참고). 커스텀 툴이 오류 result 에 `structuredContent` 를 직접 붙인다면 그것도 스키마를 따라야 한다 — 일부 클라이언트는 거기서도 검증한다. **기본값에선 중첩 필드·타입·형식 등 전체 JSON Schema 검증은 하지 않는다.** MCP 코어는 의도적으로 zero-dep 이라 스키마 밸리데이터를 끌어오지 않으며, 스펙도 전체 검증을 **클라이언트 몫**("Clients SHOULD validate structured results against this schema")으로 둔다. 즉 `outputSchema` 는 클라가 완전 검증할 수 있도록 **정확히 광고**하는 것이 서버의 역할이고, 기본 서버측 가드는 명백한 계약 위반(누락·비객체)만 잡는 안전망이다. diff --git a/docs/guide/08-mcp-integration.md b/docs/guide/08-mcp-integration.md index e6a5652..58821e1 100644 --- a/docs/guide/08-mcp-integration.md +++ b/docs/guide/08-mcp-integration.md @@ -278,8 +278,27 @@ Manage the DB **scope** lifecycle under `worker.db.auto-provision` — the MCP a To deploy a **LIBRARY** module (shared-module typed sharing) instead of a normal one, add `kind: "LIBRARY"` and `exports` (the packages to publish) at the top level and omit `controller`; a module that consumes a library adds `uses` (the library module ids). These sit alongside `id`/`version`, the same as `controller`. See [02. Module Authoring §8](02-module-authoring.md). +### Tool errors + Domain failures such as a gate rejection or compile failure are not mapped to a JSON-RPC error but to a tool result with `isError: true` (+ diagnostic text). Only protocol-level errors (unknown tool, missing required argument, etc.) go out as JSON-RPC errors. +A coded tool error carries its RFC 9457 problem detail in the result **`_meta`** under the key `org.htcom.protean/problem` (the constant `McpToolResult.PROBLEM_META_KEY`), next to any `_meta` the tool attached — **not** in `structuredContent`: + +```json +{ + "content": [{ "type": "text", "text": "[STATE_CONFLICT] scope tools require worker.db.auto-provision=true — …" }], + "isError": true, + "_meta": { + "org.htcom.protean/problem": { + "type": "urn:protean:error:state-conflict", "title": "State conflict", + "detail": "scope tools require worker.db.auto-provision=true — …", "code": "STATE_CONFLICT" + } + } +} +``` + +`structuredContent` is reserved for the payload the tool's `outputSchema` describes. The spec requires it to conform to that schema, and some clients validate it on error results too — a problem object there would make them report a schema violation instead of the actual error. Read the stable `code` (and extension members such as `gate`, `moduleId`, `diagnostics`) from `_meta["org.htcom.protean/problem"]`; the `[CODE]`-prefixed text is the fallback for clients that surface only text. + ## Authentication & authorization **The library implements no authentication.** It splits into two axes. @@ -429,7 +448,7 @@ When a tool that declared `outputSchema` returns **success**, the dispatcher che - that `structuredContent` exists and is an **object** - that all of the schema's **top-level `required`** fields are present -If either is violated, it does not silently pass but blocks with `isError` ("output schema violation: …") — to stop, at the server, the class of bugs where malformed structured output leaks to the client. This check is not applied to `isError` results (domain failures), where the absence of structured output is normal. +If either is violated, it does not silently pass but blocks with `isError` ("output schema violation: …") — to stop, at the server, the class of bugs where malformed structured output leaks to the client. This check is not applied to `isError` results (domain failures), where the absence of structured output is normal — a coded error's problem detail goes in `_meta`, not `structuredContent` (see [Tool errors](#tool-errors) above). A custom tool that does attach `structuredContent` to an error result must keep it conformant too, since some clients validate it there as well. **By default it does not perform full JSON Schema validation of nested fields, types, formats, etc.** The MCP core is deliberately zero-dep and does not pull in a schema validator, and the spec too leaves full validation to the **client** ("Clients SHOULD validate structured results against this schema"). That is, the server's job is to **advertise `outputSchema` accurately** so the client can validate fully, and the default server-side guard is a safety net that catches only blatant contract violations (missing/non-object). diff --git a/src/main/java/org/htcom/protean/mcp/McpDispatcher.java b/src/main/java/org/htcom/protean/mcp/McpDispatcher.java index 7c5ef1d..c7395e1 100644 --- a/src/main/java/org/htcom/protean/mcp/McpDispatcher.java +++ b/src/main/java/org/htcom/protean/mcp/McpDispatcher.java @@ -626,16 +626,20 @@ private JsonNode toolResultNode(McpToolResult r) { if (r.isError()) { res.put("isError", true); } - // Success: the tool payload. Error (a problem is attached): the RFC 9457 problem-detail shape as structuredContent. - JsonNode structured = r.structured(); - if (structured == null && r.problem() != null) { - structured = r.problem().toJson(mapper); - } - if (structured != null) { - res.set("structuredContent", structured); - } - if (r.meta() != null) { - res.set("_meta", r.meta()); // additional metadata attached by the tool + if (r.structured() != null) { + res.set("structuredContent", r.structured()); + } + // A stable-code error's RFC 9457 problem goes in _meta, never in structuredContent: a tool's outputSchema + // describes its success payload, the spec requires structuredContent to conform to it, and clients that + // validate it on error results too would replace the actionable error with a schema-violation error. + JsonNode meta = r.meta(); // additional metadata attached by the tool + if (r.problem() != null) { + ObjectNode merged = meta instanceof ObjectNode o ? o.deepCopy() : mapper.createObjectNode(); + merged.set(McpToolResult.PROBLEM_META_KEY, r.problem().toJson(mapper)); + meta = merged; + } + if (meta != null) { + res.set("_meta", meta); } return res; } diff --git a/src/main/java/org/htcom/protean/mcp/McpTool.java b/src/main/java/org/htcom/protean/mcp/McpTool.java index 959dae1..4bee410 100644 --- a/src/main/java/org/htcom/protean/mcp/McpTool.java +++ b/src/main/java/org/htcom/protean/mcp/McpTool.java @@ -40,7 +40,10 @@ default String title() { * Output JSON Schema (optional). When declared, a successful result's {@code structuredContent} must conform * to it; the dispatcher validates a minimal fit (is-object and top-level {@code required}) and blocks with * {@code isError} on a mismatch (guarding against structuredContent-class bugs). Full schema validation is the - * client's responsibility (per spec). {@code null} = not declared. + * client's responsibility (per spec). Some clients validate {@code structuredContent} on error results too, so a + * tool that attaches {@code structuredContent} to an {@code isError} result must keep it conformant as well; + * stable-code errors never do — their problem detail travels in {@code _meta} under + * {@link McpToolResult#PROBLEM_META_KEY}. {@code null} = not declared. */ default ObjectNode outputSchema() { return null; diff --git a/src/main/java/org/htcom/protean/mcp/McpToolResult.java b/src/main/java/org/htcom/protean/mcp/McpToolResult.java index a55a45a..41c75c3 100644 --- a/src/main/java/org/htcom/protean/mcp/McpToolResult.java +++ b/src/main/java/org/htcom/protean/mcp/McpToolResult.java @@ -20,12 +20,16 @@ * (ModuleStatus) or a {@code jobId} union. Currently it always returns only the terminal state. * *

Stable-code errors ({@link #error(ErrorCode, String)}) carry an RFC 9457 shape in {@code problem}, and the - * boundary ({@code McpDispatcher.toolResultNode}) serializes it into {@code structuredContent}. The mapping lives - * in one boundary — the mapper is not placed here. Plain-text errors without a {@code problem} are promoted to - * codes in a later phase. + * boundary ({@code McpDispatcher.toolResultNode}) serializes it into the result {@code _meta} under + * {@link #PROBLEM_META_KEY} — not into {@code structuredContent}, which is reserved for the payload described by + * the tool's {@code outputSchema}. The mapping lives in one boundary — the mapper is not placed here. Plain-text + * errors without a {@code problem} are promoted to codes in a later phase. */ public record McpToolResult(String text, boolean isError, JsonNode structured, JsonNode meta, ProblemDetail problem) { + /** {@code _meta} key under which a stable-code error result carries its RFC 9457 problem detail. */ + public static final String PROBLEM_META_KEY = "org.htcom.protean/problem"; + public static McpToolResult ok(String text) { return new McpToolResult(text, false, null, null, null); } @@ -41,7 +45,7 @@ public static McpToolResult error(String text) { /** * Stable-code error (RFC 9457). {@code text} is a {@code [CODE]}-prefixed detail for lossy harnesses, and - * {@code problem} is the problem-detail shape that the boundary serializes into {@code structuredContent}. + * {@code problem} is the problem-detail shape that the boundary serializes into {@code _meta[PROBLEM_META_KEY]}. * Attach structured remediation data with {@link #with} (e.g. {@code gate}, {@code diagnostics}). */ public static McpToolResult error(ErrorCode code, String detail) { diff --git a/src/main/java/org/htcom/protean/mcp/tools/ListRuntimesTool.java b/src/main/java/org/htcom/protean/mcp/tools/ListRuntimesTool.java index da68d16..add78db 100644 --- a/src/main/java/org/htcom/protean/mcp/tools/ListRuntimesTool.java +++ b/src/main/java/org/htcom/protean/mcp/tools/ListRuntimesTool.java @@ -86,8 +86,9 @@ public ObjectNode outputSchema() { p.putObject("runtimeId").put("type", "string") .put("description", "Opaque host id, joins to a module's runtimeId (main | worker: | container:)"); p.putObject("mode").put("type", "string").put("description", "in-process | worker | container"); - p.putObject("scope").put("type", "string") - .put("description", "DB scope this runtime is bound to; null when auto-provision is off"); + ObjectNode scope = p.putObject("scope"); + scope.putArray("type").add("string").add("null"); // null when auto-provision is off (the default) + scope.put("description", "DB scope this runtime is bound to; null when auto-provision is off"); p.putObject("state").put("type", "string").put("description", "LIVE | RETIRING"); p.putObject("sinceEpochMs").put("type", "integer").put("description", "Start time (epoch ms) for uptime"); p.putObject("moduleIds").put("type", "array").put("description", "Modules hosted here; empty when warm/retiring") diff --git a/src/test/java/org/htcom/protean/McpCancelMetaTest.java b/src/test/java/org/htcom/protean/McpCancelMetaTest.java index 095ebfd..c829c87 100644 --- a/src/test/java/org/htcom/protean/McpCancelMetaTest.java +++ b/src/test/java/org/htcom/protean/McpCancelMetaTest.java @@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.htcom.protean.error.ErrorCode; import org.htcom.protean.mcp.McpCallContext; import org.htcom.protean.mcp.McpDispatcher; import org.htcom.protean.mcp.McpTool; @@ -125,6 +126,31 @@ void request_meta_flows_to_tool_and_result_meta_serialized() { assertEquals("abc-123", result.path("_meta").path("trace").asText(), "request _meta flows to result _meta: " + result); } + @Test + void coded_error_problem_joins_the_tool_meta_without_touching_structured_content() { + // A coded error that also carries tool _meta: the problem is added next to the tool's keys, and nothing lands in + // structuredContent (which belongs to the outputSchema-described success payload). + McpTool failing = new McpTool() { + @Override public String name() { return "test.fail"; } + @Override public String description() { return "coded error with _meta"; } + @Override public ObjectNode inputSchema() { return inputObj(); } + @Override public McpToolResult call(JsonNode args, McpCallContext ctx) { + return McpToolResult.error(ErrorCode.STATE_CONFLICT, "not now").withMeta(ctx.meta()); + } + }; + McpDispatcher d = new McpDispatcher(mapper, List.of(failing), allowAll, null, null, null, null); + ObjectNode meta = mapper.createObjectNode(); + meta.put("trace", "abc-123"); + JsonNode result = d.dispatch(callReq("r4", "test.fail", null, meta), McpCallContext.anonymous()).path("result"); + assertTrue(result.path("isError").asBoolean(false)); + assertFalse(result.has("structuredContent"), "a coded error must not emit structuredContent: " + result); + assertEquals("abc-123", result.path("_meta").path("trace").asText(), "the tool's own _meta is kept: " + result); + assertEquals("STATE_CONFLICT", + result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText(), "problem in _meta: " + result); + assertEquals("abc-123", meta.path("trace").asText()); + assertFalse(meta.has(McpToolResult.PROBLEM_META_KEY), "the tool's _meta object is copied, not mutated"); + } + @Test void authorization_denied_has_clear_error_text() { ModuleActionAuthorizer deny = @@ -138,10 +164,10 @@ void authorization_denied_has_clear_error_text() { McpDispatcher d = new McpDispatcher(mapper, List.of(anyTool), deny, null, null, null, null); JsonNode result = d.dispatch(callReq("r3", "test.guarded", null, null), McpCallContext.anonymous()).path("result"); assertTrue(result.path("isError").asBoolean(false)); - // RFC 9457 - [CODE]-prefixed text (for lossy harnesses) plus problem-detail in structuredContent. + // RFC 9457 - [CODE]-prefixed text (for lossy harnesses) plus problem-detail in _meta. String text = result.path("content").get(0).path("text").asText(); assertTrue(text.contains("[PERMISSION_DENIED]"), "code-prefixed text: " + text); - JsonNode problem = result.path("structuredContent"); + JsonNode problem = result.path("_meta").path(McpToolResult.PROBLEM_META_KEY); assertEquals("PERMISSION_DENIED", problem.path("code").asText(), "discriminator key (code)"); assertEquals("test.guarded", problem.path("tool").asText(), "tool name carried as structured correction data"); assertEquals("CUSTOM", problem.path("action").asText(), "action is structured too"); diff --git a/src/test/java/org/htcom/protean/McpDispatcherTest.java b/src/test/java/org/htcom/protean/McpDispatcherTest.java index 48e665f..4f55556 100644 --- a/src/test/java/org/htcom/protean/McpDispatcherTest.java +++ b/src/test/java/org/htcom/protean/McpDispatcherTest.java @@ -14,6 +14,7 @@ import org.htcom.protean.mcp.McpCallContext; import org.htcom.protean.mcp.McpDispatcher; import org.htcom.protean.mcp.McpException; +import org.htcom.protean.mcp.McpToolResult; import org.htcom.protean.module.ModuleDescriptor; import org.htcom.protean.module.ModulePlatform; import org.junit.jupiter.api.AfterEach; @@ -320,7 +321,7 @@ void tools_call_lifecycle_read_tools_reflect_deployed_module() { missArgs.put("id", "does-not-exist"); JsonNode miss = callTool("protean.get_module", missArgs); assertTrue(miss.path("isError").asBoolean(false)); - JsonNode problem = miss.path("structuredContent"); + JsonNode problem = miss.path("_meta").path(McpToolResult.PROBLEM_META_KEY); assertEquals("MODULE_NOT_FOUND", problem.path("code").asText(), "discriminator key"); assertEquals("urn:protean:error:module-not-found", problem.path("type").asText()); assertEquals("does-not-exist", problem.path("moduleId").asText(), "moduleId extension for correlation"); @@ -365,15 +366,15 @@ void get_module_source_returns_source_and_supports_class_targeting() { missClass.put("className", "no.such.Clazz"); JsonNode missClassRes = callTool("protean.get_module_source", missClass); assertTrue(missClassRes.path("isError").asBoolean(false)); - assertEquals("MODULE_NOT_FOUND", missClassRes.path("structuredContent").path("code").asText()); - assertEquals("no.such.Clazz", missClassRes.path("structuredContent").path("resource").asText()); + assertEquals("MODULE_NOT_FOUND", missClassRes.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); + assertEquals("no.such.Clazz", missClassRes.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("resource").asText()); // Missing module -> tool result isError + code ObjectNode missMod = mapper.createObjectNode(); missMod.put("id", "does-not-exist"); JsonNode missModRes = callTool("protean.get_module_source", missMod); assertTrue(missModRes.path("isError").asBoolean(false)); - assertEquals("MODULE_NOT_FOUND", missModRes.path("structuredContent").path("code").asText()); + assertEquals("MODULE_NOT_FOUND", missModRes.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); } private JsonNode callTool(String tool, ObjectNode args) { diff --git a/src/test/java/org/htcom/protean/McpExtensionPointsTest.java b/src/test/java/org/htcom/protean/McpExtensionPointsTest.java index 3b3b0fd..5e0c95b 100644 --- a/src/test/java/org/htcom/protean/McpExtensionPointsTest.java +++ b/src/test/java/org/htcom/protean/McpExtensionPointsTest.java @@ -14,6 +14,7 @@ import org.htcom.mcpext.McpExtBeans; import org.htcom.protean.mcp.McpCallContext; import org.htcom.protean.mcp.McpDispatcher; +import org.htcom.protean.mcp.McpToolResult; import org.htcom.protean.mcp.ModuleActionAuthorizer; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -87,10 +88,10 @@ void deploy_is_denied_by_custom_authorizer() { args.putArray("files"); JsonNode result = callTool("protean.deploy_module", args); assertTrue(result.path("isError").asBoolean(false)); - // RFC 9457 - [CODE]-prefixed text plus structuredContent problem-detail (code/tool/action). + // RFC 9457 - [CODE]-prefixed text plus the problem-detail (code/tool/action) in _meta. String text = result.path("content").get(0).path("text").asText(); assertTrue(text.contains("[PERMISSION_DENIED]"), text); - JsonNode problem = result.path("structuredContent"); + JsonNode problem = result.path("_meta").path(McpToolResult.PROBLEM_META_KEY); assertEquals("PERMISSION_DENIED", problem.path("code").asText()); assertEquals("protean.deploy_module", problem.path("tool").asText()); } diff --git a/src/test/java/org/htcom/protean/McpOutputSchemaConformanceTest.java b/src/test/java/org/htcom/protean/McpOutputSchemaConformanceTest.java index 3cc8ac5..0707f8f 100644 --- a/src/test/java/org/htcom/protean/McpOutputSchemaConformanceTest.java +++ b/src/test/java/org/htcom/protean/McpOutputSchemaConformanceTest.java @@ -13,6 +13,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.htcom.protean.mcp.McpCallContext; import org.htcom.protean.mcp.McpDispatcher; +import org.htcom.protean.mcp.McpToolResult; +import org.htcom.protean.mcp.ModuleActionAuthorizer; +import org.htcom.protean.mcp.SchemaValidator; +import org.htcom.protean.mcp.tools.ListRuntimesTool; import org.htcom.protean.module.ModulePlatform; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -26,7 +30,9 @@ import java.util.List; import java.util.stream.Stream; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Library-completeness guarantee: every declared MCP {@code outputSchema} is (1) a structurally valid JSON Schema @@ -102,6 +108,7 @@ void real_core_tool_outputs_conform_to_their_output_schema() { conforms("protean.module_versions", callTool("protean.module_versions", idArgs())); conforms("protean.module_metrics", callTool("protean.module_metrics", null)); conforms("protean.query_traces", callTool("protean.query_traces", null)); + conforms("protean.list_runtimes", callTool("protean.list_runtimes", null)); } finally { try { platform.uninstall(ID); @@ -111,6 +118,76 @@ void real_core_tool_outputs_conform_to_their_output_schema() { } } + @Test + void list_runtimes_null_scope_passes_strict_output_validation() { + // Auto-provision is off by default, so the main runtime's scope is null. The outputSchema must admit that, or + // strict mode turns every default-install call into OUTPUT_SCHEMA_VIOLATION. + assertTrue(platform.runtimes().stream().anyMatch(r -> r.scope() == null), + "precondition: a runtime with a null scope exists by default"); + McpDispatcher strict = new McpDispatcher(mapper, List.of(new ListRuntimesTool(mapper, platform)), + (caller, action, moduleId) -> ModuleActionAuthorizer.Decision.allow(), + null, null, null, null, true, SchemaValidator.create()); + ObjectNode params = mapper.createObjectNode(); + params.put("name", "protean.list_runtimes"); + params.set("arguments", mapper.createObjectNode()); + ObjectNode req = mapper.createObjectNode(); + req.put("jsonrpc", "2.0"); + req.put("id", 3); + req.put("method", "tools/call"); + req.set("params", params); + JsonNode result = strict.dispatch(req, McpCallContext.anonymous()).path("result"); + conforms("protean.list_runtimes", result); + } + + /** + * Some clients validate {@code structuredContent} against the outputSchema even on {@code isError} results, so a + * tool-execution error must never put a non-conforming payload there. Each schema-declaring tool is probed with + * argument shapes that provoke one (a capability gate, an unknown module, an unknown name); a missing required + * argument is a JSON-RPC protocol error rather than a tool result, so it exercises nothing here. + */ + @Test + void error_results_never_carry_a_nonconforming_structured_content() { + List probes = List.of( + mapper.createObjectNode(), + mapper.createObjectNode().put("id", "no-such-module"), + mapper.createObjectNode().put("name", "no-such-name").put("key", "no.such.key")); + List exercised = new ArrayList<>(); + declaredOutputSchemas().forEach(a -> { + String name = (String) a.get()[0]; + JsonNode schema = (JsonNode) a.get()[1]; + for (ObjectNode args : probes) { + JsonNode result = callTool(name, args.deepCopy()); + if (!result.path("isError").asBoolean(false)) { + continue; + } + if (result.has("structuredContent")) { + SchemaConformance.assertConforms(schema, result.path("structuredContent"), name + " (error result)"); + } + JsonNode problem = result.path("_meta").path(McpToolResult.PROBLEM_META_KEY); + assertTrue(problem.isObject() && !problem.path("code").asText().isEmpty(), + name + " coded error carries its problem in _meta: " + result); + exercised.add(name); + break; + } + }); + // Guard against a vacuous pass: the probes must actually reach the error path of a broad set of tools. + assertTrue(exercised.containsAll(List.of("protean.scope_list", "protean.get_module", "protean.module_versions")), + "error paths exercised: " + exercised); + assertTrue(exercised.size() >= 10, "error paths exercised: " + exercised); + } + + @Test + void gated_scope_list_reports_its_problem_in_meta() { + // The case that surfaced the defect: auto-provision is off, so scope_list errors with STATE_CONFLICT. Its + // outputSchema requires 'scopes' with no extra properties — a problem object there would violate it. + JsonNode result = callTool("protean.scope_list", null); + assertTrue(result.path("isError").asBoolean(false), "scope_list is gated when auto-provision is off: " + result); + assertFalse(result.has("structuredContent"), "no structuredContent on the gated error: " + result); + assertEquals("STATE_CONFLICT", result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); + assertTrue(result.path("content").get(0).path("text").asText().contains("auto-provision"), + "the actionable guidance stays in the text content: " + result); + } + private void conforms(String name, JsonNode result) { assertFalse(result.path("isError").asBoolean(false), name + " unexpectedly errored: " + result); SchemaConformance.assertConforms(schemaOf(name), result.path("structuredContent"), name); diff --git a/src/test/java/org/htcom/protean/McpStrictSchemaTest.java b/src/test/java/org/htcom/protean/McpStrictSchemaTest.java index e1549b8..2ee365c 100644 --- a/src/test/java/org/htcom/protean/McpStrictSchemaTest.java +++ b/src/test/java/org/htcom/protean/McpStrictSchemaTest.java @@ -106,14 +106,14 @@ void strict_rejects_wrong_typed_input() { badArg.put("n", "not-a-number"); // string where integer is required — top-level presence check would miss this JsonNode result = callTool(strict(customTool("ok", false), SchemaValidator.create()), badArg); assertTrue(result.path("isError").asBoolean(false), "wrong-typed input rejected: " + result); - assertEquals("INVALID_ARGUMENT", result.path("structuredContent").path("code").asText()); + assertEquals("INVALID_ARGUMENT", result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); } @Test void strict_rejects_missing_required_input() { JsonNode result = callTool(strict(customTool("ok", false), SchemaValidator.create()), mapper.createObjectNode()); assertTrue(result.path("isError").asBoolean(false), "missing required arg rejected: " + result); - assertEquals("INVALID_ARGUMENT", result.path("structuredContent").path("code").asText()); + assertEquals("INVALID_ARGUMENT", result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); } @Test @@ -121,7 +121,7 @@ void strict_rejects_nested_type_output_violation() { // "value" IS present (so the top-level guard passes) but is an integer, not the declared string. JsonNode result = callTool(strict(customTool("123", true), SchemaValidator.create()), argN(1)); assertTrue(result.path("isError").asBoolean(false), "wrong-typed output rejected under strict: " + result); - assertEquals("OUTPUT_SCHEMA_VIOLATION", result.path("structuredContent").path("code").asText()); + assertEquals("OUTPUT_SCHEMA_VIOLATION", result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); } @Test diff --git a/src/test/java/org/htcom/protean/McpToolObjectTest.java b/src/test/java/org/htcom/protean/McpToolObjectTest.java index fe984c8..69a221b 100644 --- a/src/test/java/org/htcom/protean/McpToolObjectTest.java +++ b/src/test/java/org/htcom/protean/McpToolObjectTest.java @@ -143,10 +143,10 @@ void nonconforming_structured_output_is_blocked_as_error() { params.set("arguments", mapper.createObjectNode()); JsonNode result = call(dispatcher(fullTool(false)), "tools/call", params).path("result"); assertTrue(result.path("isError").asBoolean(false), "a missing required field is blocked via isError"); - // RFC 9457: [CODE]-prefixed text + structuredContent.code. + // RFC 9457: [CODE]-prefixed text + the problem code in _meta. String text = result.path("content").get(0).path("text").asText(); assertTrue(text.contains("[OUTPUT_SCHEMA_VIOLATION]"), text); - assertEquals("OUTPUT_SCHEMA_VIOLATION", result.path("structuredContent").path("code").asText()); + assertEquals("OUTPUT_SCHEMA_VIOLATION", result.path("_meta").path(McpToolResult.PROBLEM_META_KEY).path("code").asText()); } @Test diff --git a/src/test/java/org/htcom/protean/McpWriteToolsTest.java b/src/test/java/org/htcom/protean/McpWriteToolsTest.java index 6d22b01..9c532b4 100644 --- a/src/test/java/org/htcom/protean/McpWriteToolsTest.java +++ b/src/test/java/org/htcom/protean/McpWriteToolsTest.java @@ -15,6 +15,7 @@ import org.htcom.protean.mcp.McpCallContext; import org.htcom.protean.mcp.McpDispatcher; import org.htcom.protean.mcp.McpException; +import org.htcom.protean.mcp.McpToolResult; import org.htcom.protean.module.ModulePlatform; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -161,7 +162,7 @@ void deploy_without_tests_is_tool_error_with_diagnostics() { JsonNode result = callTool("protean.deploy_module", args).path("result"); assertTrue(result.path("isError").asBoolean(false), "a gate rejection is a tool result isError"); // Gate failure is emitted structurally: code=GATE_FAILED + gate identifier. - JsonNode problem = result.path("structuredContent"); + JsonNode problem = result.path("_meta").path(McpToolResult.PROBLEM_META_KEY); assertEquals("GATE_FAILED", problem.path("code").asText()); assertEquals("tests", problem.path("gate").asText(), "the failed gate identifier"); assertTrue(result.path("content").get(0).path("text").asText().contains("[GATE_FAILED]"), From 40777a484eaf053ef894267992228e3dc51eb3ac Mon Sep 17 00:00:00 2001 From: htjulia Date: Fri, 30 Oct 2026 10:07:32 +0900 Subject: [PATCH 2/2] test(mcp): assert the tool error shape over HTTP The error-shape tests drove McpDispatcher directly, so nothing in CI checked what POST /platform/mcp actually puts on the wire. A coded scope_list error must arrive with its problem in _meta and no structuredContent. Related: #90 Co-Authored-By: htjulia --- .../org/htcom/protean/McpHttpControllerTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/test/java/org/htcom/protean/McpHttpControllerTest.java b/src/test/java/org/htcom/protean/McpHttpControllerTest.java index 3353cc2..ae74c9a 100644 --- a/src/test/java/org/htcom/protean/McpHttpControllerTest.java +++ b/src/test/java/org/htcom/protean/McpHttpControllerTest.java @@ -62,6 +62,22 @@ void tools_call_list_modules_over_http() throws Exception { .andExpect(jsonPath("$.result.content[0].type").value("text")); } + @Test + void coded_tool_error_over_http_carries_its_problem_in_meta() throws Exception { + // The wire shape a client actually receives: auto-provision is off, so scope_list errors with STATE_CONFLICT. + // The problem detail must arrive in _meta, never in structuredContent (validated against the outputSchema). + String body = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"protean.scope_list\",\"arguments\":{}}}"; + mockMvc.perform(post("/platform/mcp").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.isError").value(true)) + .andExpect(jsonPath("$.result.structuredContent").doesNotExist()) + .andExpect(jsonPath("$.result._meta['org.htcom.protean/problem'].code").value("STATE_CONFLICT")) + .andExpect(jsonPath("$.result._meta['org.htcom.protean/problem'].type") + .value("urn:protean:error:state-conflict")) + .andExpect(jsonPath("$.result.content[0].text").value(org.hamcrest.Matchers.startsWith("[STATE_CONFLICT]"))); + } + @Test void notification_gets_202_no_body() throws Exception { String body = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";