@@ -1093,10 +1093,21 @@ TEST(mcp_text_result) {
10931093 PASS ();
10941094}
10951095
1096- TEST (mcp_text_result_wraps_plain_text_as_structured_content ) {
1096+ TEST (mcp_text_result_does_not_duplicate_plain_text_into_structured_content ) {
1097+ /* A non-JSON payload used to be repeated verbatim as
1098+ * structuredContent {"text": <payload>} beside content[0].text — 2.05x the
1099+ * payload measured on a 20k-node query_graph, i.e. half the transport budget
1100+ * and double the tokens for every LLM caller (#1375).
1101+ *
1102+ * structuredContent carries STRUCTURE; a string rewrapped in a one-key
1103+ * object has none, so the empty object is the honest answer and still
1104+ * satisfies the permissive outputSchema. The payload stays in content. */
10971105 char * json = cbm_mcp_text_result ("plain text" , false);
10981106 ASSERT_NOT_NULL (json );
1099- ASSERT_NOT_NULL (strstr (json , "\"structuredContent\":{\"text\":\"plain text\"}" ));
1107+ ASSERT_NOT_NULL (strstr (json , "\"structuredContent\":{}" ));
1108+ ASSERT_NULL (strstr (json , "\"structuredContent\":{\"text\"" ));
1109+ /* The payload is still delivered — exactly once. */
1110+ ASSERT_NOT_NULL (strstr (json , "\"text\":\"plain text\"" ));
11001111 ASSERT_NOT_NULL (strstr (json , "\"isError\":false" ));
11011112 free (json );
11021113 PASS ();
@@ -1813,6 +1824,84 @@ TEST(tool_get_code_snippet_clips_whole_file_node) {
18131824 PASS ();
18141825}
18151826
1827+ /* EVERY tool, not just the one that was reported.
1828+ *
1829+ * The duplication was invisible per-tool: each result looked reasonable on its
1830+ * own, and only measuring the wire showed half of it was redundant. A guard
1831+ * pinned to query_graph would not have caught it in search_graph, and would not
1832+ * catch it in whatever tool is added next. So this enumerates the tool table
1833+ * itself — a new tool is covered the moment it is registered, with no test edit.
1834+ *
1835+ * The invariant: for a NON-error result whose payload is not a JSON object,
1836+ * structuredContent must not carry the payload a second time. Errors are exempt
1837+ * and deliberately so — bounded, small, and structuredContent.error is the only
1838+ * machine-readable form of a failure a client gets. */
1839+ TEST (mcp_every_tool_result_is_duplication_free ) {
1840+ char tmp [256 ];
1841+ cbm_mcp_server_t * srv = setup_snippet_server (tmp , sizeof (tmp ));
1842+ ASSERT_NOT_NULL (srv );
1843+
1844+ int tools = cbm_mcp_tool_count ();
1845+ ASSERT_TRUE (tools > 0 ); /* an empty table would assert nothing at all */
1846+ int checked = 0 ;
1847+
1848+ for (int i = 0 ; i < tools ; i ++ ) {
1849+ const char * name = cbm_mcp_tool_name (i );
1850+ ASSERT_NOT_NULL (name );
1851+ /* Minimal args: most tools error out, which is fine — an error envelope
1852+ * is still an envelope, and the property must hold for it too. */
1853+ char * envelope = cbm_mcp_handle_tool (srv , name , "{\"project\":\"test-project\"}" );
1854+ if (!envelope ) {
1855+ continue ;
1856+ }
1857+ yyjson_doc * doc = yyjson_read (envelope , strlen (envelope ), 0 );
1858+ ASSERT_NOT_NULL (doc );
1859+ yyjson_val * root = yyjson_doc_get_root (doc );
1860+ yyjson_val * content = yyjson_obj_get (root , "content" );
1861+ yyjson_val * first = content ? yyjson_arr_get (content , 0 ) : NULL ;
1862+ yyjson_val * text_val = first ? yyjson_obj_get (first , "text" ) : NULL ;
1863+ const char * text = text_val ? yyjson_get_str (text_val ) : NULL ;
1864+ yyjson_val * structured = yyjson_obj_get (root , "structuredContent" );
1865+
1866+ /* outputSchema is declared for every tool, so this stays mandatory. */
1867+ ASSERT_NOT_NULL (structured );
1868+ ASSERT_TRUE (yyjson_is_obj (structured ));
1869+
1870+ yyjson_val * is_error = yyjson_obj_get (root , "isError" );
1871+ bool errored = is_error && yyjson_is_true (is_error );
1872+
1873+ if (text && text [0 ] && !errored ) {
1874+ /* If the payload is itself a JSON object, structuredContent is the
1875+ * PARSED form and legitimately holds the same data — that is the
1876+ * spec's structured+serialized pattern, not waste. Only the
1877+ * non-object case is checked here. */
1878+ yyjson_doc * as_json = yyjson_read (text , strlen (text ), 0 );
1879+ bool payload_is_object = as_json && yyjson_is_obj (yyjson_doc_get_root (as_json ));
1880+ if (as_json ) {
1881+ yyjson_doc_free (as_json );
1882+ }
1883+ if (!payload_is_object ) {
1884+ yyjson_val * dup = yyjson_obj_get (structured , "text" );
1885+ if (dup && yyjson_is_str (dup )) {
1886+ const char * dup_str = yyjson_get_str (dup );
1887+ /* The exact defect: same bytes, twice, in one reply. */
1888+ ASSERT_TRUE (!(dup_str && strcmp (dup_str , text ) == 0 ));
1889+ }
1890+ checked ++ ;
1891+ }
1892+ }
1893+ yyjson_doc_free (doc );
1894+ free (envelope );
1895+ }
1896+
1897+ /* If no tool produced a non-JSON payload, this test proved nothing — fail
1898+ * rather than report a green that was never exercised. */
1899+ ASSERT_TRUE (checked > 0 );
1900+ cbm_mcp_server_free (srv );
1901+ th_rmtree (tmp );
1902+ PASS ();
1903+ }
1904+
18161905TEST (tool_search_graph_includes_node_properties ) {
18171906 /* Node properties are OPT-IN columns in the default TOON output: the
18181907 * default row is qn/label/file/lines/degrees only, `fields` adds the
@@ -1830,7 +1919,11 @@ TEST(tool_search_graph_includes_node_properties) {
18301919 "\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
18311920 "\"name_pattern\":\"HandleRequest\",\"limit\":5}}}" );
18321921 ASSERT_NOT_NULL (resp );
1833- ASSERT_NOT_NULL (strstr (resp , "\"structuredContent\":{\"text\":" ));
1922+ /* TOON is not a JSON object, so structuredContent stays empty rather than
1923+ * repeating the whole table a second time (#1375). The payload travels once,
1924+ * in content. */
1925+ ASSERT_NOT_NULL (strstr (resp , "\"structuredContent\":{}" ));
1926+ ASSERT_NULL (strstr (resp , "\"structuredContent\":{\"text\":" ));
18341927 char * inner = extract_text_content (resp );
18351928 ASSERT_NOT_NULL (inner );
18361929 ASSERT_NOT_NULL (strstr (inner , "results:" )); /* TOON table header */
@@ -10358,7 +10451,8 @@ SUITE(mcp) {
1035810451 RUN_TEST (mcp_ingest_traces_items_disallow_additional_properties_issue731 );
1035910452 RUN_TEST (mcp_get_architecture_aspects_schema_enum_pr560 );
1036010453 RUN_TEST (mcp_text_result );
10361- RUN_TEST (mcp_text_result_wraps_plain_text_as_structured_content );
10454+ RUN_TEST (mcp_text_result_does_not_duplicate_plain_text_into_structured_content );
10455+ RUN_TEST (mcp_every_tool_result_is_duplication_free );
1036210456 RUN_TEST (mcp_cancel_matches_request_id );
1036310457 RUN_TEST (mcp_text_result_error );
1036410458
0 commit comments