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]"),