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/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\"}"; 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]"),