unique-sdk 2026.40.0.dev6__tar.gz → 2026.40.0.dev7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/PKG-INFO +1 -1
  2. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/pyproject.toml +1 -1
  3. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/mcp.py +163 -35
  4. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/README.md +0 -0
  5. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/__init__.py +0 -0
  6. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_api_requestor.py +0 -0
  7. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_api_resource.py +0 -0
  8. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_api_version.py +0 -0
  9. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_error.py +0 -0
  10. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_http_client.py +0 -0
  11. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_list_object.py +0 -0
  12. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_object_classes.py +0 -0
  13. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_request_options.py +0 -0
  14. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_unique_object.py +0 -0
  15. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_unique_ql.py +0 -0
  16. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_unique_response.py +0 -0
  17. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_util.py +0 -0
  18. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_version.py +0 -0
  19. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/_webhook.py +0 -0
  20. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/__init__.py +0 -0
  21. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_acronyms.py +0 -0
  22. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_agentic_table.py +0 -0
  23. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_analytics_order.py +0 -0
  24. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_benchmarking.py +0 -0
  25. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_briefing.py +0 -0
  26. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_chat_completion.py +0 -0
  27. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_content.py +0 -0
  28. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_context_memory.py +0 -0
  29. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_elicitation.py +0 -0
  30. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_embedding.py +0 -0
  31. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_event.py +0 -0
  32. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_folder.py +0 -0
  33. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_group.py +0 -0
  34. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_integrated.py +0 -0
  35. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_llm_models.py +0 -0
  36. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_mcp.py +0 -0
  37. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_message.py +0 -0
  38. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_message_assessment.py +0 -0
  39. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_message_execution.py +0 -0
  40. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_message_log.py +0 -0
  41. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_message_tool.py +0 -0
  42. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_module.py +0 -0
  43. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  44. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_search.py +0 -0
  45. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_search_string.py +0 -0
  46. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  47. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_space.py +0 -0
  48. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_user.py +0 -0
  49. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/api_resources/_web_search.py +0 -0
  50. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/__init__.py +0 -0
  51. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/__main__.py +0 -0
  52. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/cli.py +0 -0
  53. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/__init__.py +0 -0
  54. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  55. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/agentic_table.py +0 -0
  56. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/agentic_table_write.py +0 -0
  57. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/browser.py +0 -0
  58. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/cite_file.py +0 -0
  59. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/elicitation.py +0 -0
  60. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/files.py +0 -0
  61. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/folders.py +0 -0
  62. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/navigation.py +0 -0
  63. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/read.py +0 -0
  64. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  65. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/search.py +0 -0
  66. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/subagent.py +0 -0
  67. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/web_search.py +0 -0
  68. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/commands/web_search_config.py +0 -0
  69. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/config.py +0 -0
  70. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/formatting.py +0 -0
  71. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/identity.py +0 -0
  72. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/metadata_filter.py +0 -0
  73. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/shell.py +0 -0
  74. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-agentic-table/SKILL.md +0 -0
  75. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  76. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  77. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  78. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  79. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  80. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  81. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-uploaded-search/SKILL.md +0 -0
  82. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  83. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/state.py +0 -0
  84. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/cli/workspace.py +0 -0
  85. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/analytics_order_run.py +0 -0
  86. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/benchmarking_run.py +0 -0
  87. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/chat_history.py +0 -0
  88. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/chat_in_space.py +0 -0
  89. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/file_io.py +0 -0
  90. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/sources.py +0 -0
  91. {unique_sdk-2026.40.0.dev6 → unique_sdk-2026.40.0.dev7}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.40.0.dev6
3
+ Version: 2026.40.0.dev7
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.40.0.dev6"
3
+ version = "2026.40.0.dev7"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -43,9 +43,10 @@ _MCP_OUTPUT_TEXT_CHAR_LIMIT = 200_000
43
43
  # Per-turn manifest of citable MCP sources, consumed by the runner to stitch
44
44
  # ``[mcpsourceN]`` markers into ``<sup>N</sup>`` footnotes + reference chips
45
45
  # (UN-21285). One entry per retrieved item: a short *title* describing what was
46
- # retrieved (no URL — those are technical/misleading). The runner labels the
47
- # chip with that title + the MCP tool name; falls back to the tool alone when
48
- # the result carries no recognizable title.
46
+ # retrieved, plus that item's own http(s) link when the tool result includes
47
+ # one. The link is stored on the row and is not copied into the Sources block.
48
+ # The runner labels the chip with that title + the MCP tool name; falls back
49
+ # to the tool alone when the result carries no recognizable title.
49
50
  _MCP_REFS_LOG_RELATIVE_PATH = Path(".unique") / "mcp-refs.jsonl"
50
51
  _MCP_REFS_LOCK_FILENAME = "mcp-refs.lock"
51
52
  # Persistent per-chat seed for ``[mcpsourceN]`` numbering. The SI runner wipes
@@ -68,6 +69,16 @@ _MCP_REF_TEXT_CHAR_LIMIT = 100_000
68
69
  # Keys an MCP tool's JSON result commonly uses for a record's human title.
69
70
  _TITLE_KEYS = ("title", "name", "displayName", "subject", "summary", "key")
70
71
 
72
+ # Preferred keys for a record's own http(s) link. Any other string field is
73
+ # inspected only after these miss.
74
+ _URL_KEYS = ("url", "uri", "href", "webUrl", "web_url", "outlookWebLink", "link")
75
+
76
+ # Markdown list item: a bullet or numbered title, then an indented http(s) URL.
77
+ # A list marker is required so a prose line above an indented link is not a hit.
78
+ _MARKDOWN_TITLE_URL = re.compile(
79
+ r"(?m)^(?:[-*]\s+|\d+\.\s+)(.+?)\n[ \t]+(https?://\S+)\s*$"
80
+ )
81
+
71
82
  # Keys an MCP tool's JSON result commonly uses for the optional "details" line
72
83
  # (UN-22310) — e.g. a date and an author such as "10/10/2026 - Jamie Dimon".
73
84
  # Best-effort: only top-level record keys are inspected for these (nested
@@ -247,6 +258,35 @@ def _records_from_parsed(parsed: Any) -> list[dict[str, Any]]:
247
258
  return []
248
259
 
249
260
 
261
+ def _is_http_url(value: object) -> str | None:
262
+ """Return ``value`` when it is an http(s) URL, else None."""
263
+ if not isinstance(value, str):
264
+ return None
265
+ stripped = value.strip()
266
+ if stripped.startswith("https://") or stripped.startswith("http://"):
267
+ return stripped
268
+ return None
269
+
270
+
271
+ def _url_from_item(obj: dict[str, Any]) -> str | None:
272
+ """First http(s) field on a record. Preferred keys, then any string field."""
273
+ for key in _URL_KEYS:
274
+ url = _is_http_url(obj.get(key))
275
+ if url:
276
+ return url
277
+ for value in obj.values():
278
+ url = _is_http_url(value)
279
+ if url:
280
+ return url
281
+ return None
282
+
283
+
284
+ def _with_url(item: dict[str, Any], url: str | None) -> dict[str, Any]:
285
+ if url:
286
+ item["url"] = url
287
+ return item
288
+
289
+
250
290
  def _titles_from_json(text: str) -> list[dict[str, Any]]:
251
291
  """Best-effort: pull a human title out of a JSON result, e.g. an Atlassian
252
292
  page/issue returned as JSON-in-text. Tolerates a non-JSON preamble before
@@ -262,16 +302,44 @@ def _titles_from_json(text: str) -> list[dict[str, Any]]:
262
302
  title = _title_from_json(entry)
263
303
  if title:
264
304
  items.append(
265
- {
266
- "title": title,
267
- "snippet": None,
268
- "details": _details_from_json(entry),
269
- "text": _record_text(entry),
270
- }
305
+ _with_url(
306
+ {
307
+ "title": title,
308
+ "snippet": None,
309
+ "details": _details_from_json(entry),
310
+ "text": _record_text(entry),
311
+ },
312
+ _url_from_item(entry),
313
+ )
271
314
  )
272
315
  return items
273
316
 
274
317
 
318
+ def _titles_from_markdown_list(text: str) -> list[dict[str, Any]]:
319
+ """One item per markdown list entry that pairs a title with an http(s) URL.
320
+
321
+ Returns [] when no pair is found.
322
+ """
323
+ if not text.strip():
324
+ return []
325
+ items: list[dict[str, Any]] = []
326
+ for match in _MARKDOWN_TITLE_URL.finditer(text):
327
+ title = match.group(1).strip().lstrip("-* ").strip()
328
+ url = _is_http_url(match.group(2))
329
+ if not title or not url:
330
+ continue
331
+ items.append(
332
+ {
333
+ "title": title,
334
+ "snippet": None,
335
+ "details": None,
336
+ "text": match.group(0).strip(),
337
+ "url": url,
338
+ }
339
+ )
340
+ return items
341
+
342
+
275
343
  def _get_by_dotted_path(obj: Any, path: str) -> Any:
276
344
  """Walk a dotted path over dicts and lists (numeric segments index lists),
277
345
  e.g. ``"issues"``, ``"fields.summary"``, ``"result.items.0.key"``. Returns
@@ -310,6 +378,26 @@ def _render_title_template(template: str, record: dict[str, Any]) -> str | None:
310
378
  return rendered or None
311
379
 
312
380
 
381
+ def _as_content_dict(block: Any) -> dict[str, Any] | None:
382
+ """Normalize an MCP content block to a dict.
383
+
384
+ Client libraries often hand back typed objects (``mcp.types.TextContent``)
385
+ with the same fields as the wire dict. Only known attribute names are
386
+ copied so a hostile object cannot inject unexpected keys.
387
+ """
388
+ if isinstance(block, dict):
389
+ return block
390
+ block_type = getattr(block, "type", None)
391
+ if not isinstance(block_type, str) or not block_type.strip():
392
+ return None
393
+ out: dict[str, Any] = {"type": block_type}
394
+ for key in ("text", "name", "description", "uri"):
395
+ value = getattr(block, key, None)
396
+ if value is not None:
397
+ out[key] = value
398
+ return out
399
+
400
+
313
401
  def _mapped_records(response: Any, list_path: str | None) -> list[Any]:
314
402
  """Locate the records a reference mapping applies to, preferring the
315
403
  MCP-native ``structuredContent`` then any JSON-in-text block (preamble
@@ -326,8 +414,9 @@ def _mapped_records(response: Any, list_path: str | None) -> list[Any]:
326
414
  if structured is not None:
327
415
  sources.append(structured)
328
416
  for block in getattr(response, "content", None) or []:
329
- if isinstance(block, dict) and block.get("type") == "text":
330
- parsed = _loads_embedded_json(block.get("text") or "")
417
+ normalized = _as_content_dict(block)
418
+ if normalized is not None and normalized.get("type") == "text":
419
+ parsed = _loads_embedded_json(normalized.get("text") or "")
331
420
  if parsed is not None:
332
421
  sources.append(parsed)
333
422
 
@@ -363,8 +452,9 @@ def _first_text_title(response: Any, max_chars: int) -> str | None:
363
452
  """Title from the first non-empty line of the first text block — for a
364
453
  non-JSON result such as a fetched Markdown document (e.g. ``read_doc``)."""
365
454
  for block in getattr(response, "content", None) or []:
366
- if isinstance(block, dict) and block.get("type") == "text":
367
- title = _leading_title_line(block.get("text"), max_chars)
455
+ normalized = _as_content_dict(block)
456
+ if normalized is not None and normalized.get("type") == "text":
457
+ title = _leading_title_line(normalized.get("text"), max_chars)
368
458
  if title:
369
459
  return title
370
460
  return None
@@ -375,9 +465,10 @@ def _all_text_blocks(response: Any) -> str | None:
375
465
  result (e.g. a fetched document) recorded as that item's ground truth."""
376
466
  texts: list[str] = []
377
467
  for block in getattr(response, "content", None) or []:
378
- if not isinstance(block, dict) or block.get("type") != "text":
468
+ normalized = _as_content_dict(block)
469
+ if normalized is None or normalized.get("type") != "text":
379
470
  continue
380
- text = block.get("text")
471
+ text = normalized.get("text")
381
472
  if isinstance(text, str) and text.strip():
382
473
  texts.append(text)
383
474
  return "\n\n".join(texts) or None
@@ -412,6 +503,7 @@ def _extract_with_reference_mapping(
412
503
  # points at the text field within each item (e.g. ``content``); leave unset
413
504
  # when each item is itself a plain string.
414
505
  title_text_path = mapping.get("titleTextPath") or mapping.get("title_text_path")
506
+ url_path = mapping.get("urlPath") or mapping.get("url_path")
415
507
  try:
416
508
  title_max_chars = int(
417
509
  mapping.get("titleMaxChars")
@@ -449,13 +541,22 @@ def _extract_with_reference_mapping(
449
541
  if details_path and is_dict
450
542
  else None
451
543
  )
544
+ url = None
545
+ if is_dict:
546
+ if url_path:
547
+ url = _is_http_url(_get_by_dotted_path(record, url_path))
548
+ if not url:
549
+ url = _url_from_item(record)
452
550
  items.append(
453
- {
454
- "title": title,
455
- "snippet": None,
456
- "details": str(details).strip() if details else None,
457
- "text": _record_text(record),
458
- }
551
+ _with_url(
552
+ {
553
+ "title": title,
554
+ "snippet": None,
555
+ "details": str(details).strip() if details else None,
556
+ "text": _record_text(record),
557
+ },
558
+ url,
559
+ )
459
560
  )
460
561
  if items:
461
562
  return items
@@ -492,9 +593,13 @@ def _extract_mcp_citation_items(
492
593
  destructuring of a list result); when it yields nothing we fall back to the
493
594
  generic heuristic: MCP ``resource_link`` names (spec-native) or a best-effort
494
595
  JSON-title heuristic over text blocks (for tools like Atlassian that return
495
- JSON-in-text). No URLs are extracted — the chip is display-only. Falls back
496
- to a single title-less item (the runner names it after the tool) when the
497
- result carries no recognizable title.
596
+ JSON-in-text), or a markdown list that pairs a title with an indented
597
+ http(s) URL. Typed content blocks from an MCP client library are
598
+ normalized to dicts first so a list result is not collapsed to one
599
+ title-less chip. An http(s) link on a record is stored on the row and is
600
+ not copied into the Sources block. Falls back to a single title-less item
601
+ (the runner names it after the tool) when the result carries no
602
+ recognizable title.
498
603
 
499
604
  ``text`` is the item's underlying retrieved text (the serialized record, a
500
605
  fetched document body, or — for the title-less fallback — ``fallback_text``,
@@ -511,23 +616,39 @@ def _extract_mcp_citation_items(
511
616
  items: list[dict[str, Any]] = []
512
617
 
513
618
  for block in content:
514
- if not isinstance(block, dict):
619
+ normalized = _as_content_dict(block)
620
+ if normalized is None:
515
621
  continue
516
- if block.get("type") == "resource_link":
517
- name = (block.get("name") or "").strip()
622
+ if normalized.get("type") == "resource_link":
623
+ name = (normalized.get("name") or "").strip()
518
624
  if name:
519
625
  items.append(
520
- {
521
- "title": name,
522
- "snippet": _snippet(block.get("description")),
523
- "text": block.get("description") or None,
524
- }
626
+ _with_url(
627
+ {
628
+ "title": name,
629
+ "snippet": _snippet(normalized.get("description")),
630
+ "text": normalized.get("description") or None,
631
+ },
632
+ _is_http_url(normalized.get("uri")),
633
+ )
525
634
  )
526
635
 
527
636
  if not items:
528
637
  for block in content:
529
- if isinstance(block, dict) and block.get("type") == "text":
530
- items.extend(_titles_from_json(block.get("text") or ""))
638
+ normalized = _as_content_dict(block)
639
+ if normalized is not None and normalized.get("type") == "text":
640
+ items.extend(_titles_from_json(normalized.get("text") or ""))
641
+
642
+ if not items:
643
+ for block in content:
644
+ normalized = _as_content_dict(block)
645
+ if normalized is not None and normalized.get("type") == "text":
646
+ items.extend(_titles_from_markdown_list(normalized.get("text") or ""))
647
+
648
+ if not items and fallback_text:
649
+ items.extend(_titles_from_json(fallback_text))
650
+ if not items:
651
+ items.extend(_titles_from_markdown_list(fallback_text))
531
652
 
532
653
  if not items:
533
654
  # No recognizable title — one chip named after the tool itself.
@@ -696,6 +817,9 @@ def _annotate_mcp_results_for_citations(
696
817
  "details": item.get("details"),
697
818
  "text": _ref_text(item),
698
819
  }
820
+ item_url = item.get("url")
821
+ if isinstance(item_url, str) and item_url:
822
+ manifest_entry["url"] = item_url
699
823
  try:
700
824
  _append_turn_refs_manifest_entry(refs_log_path, manifest_entry)
701
825
  except (UnsafeRefsLogPathError, OSError) as exc:
@@ -717,6 +841,10 @@ def _annotate_mcp_results_for_citations(
717
841
  if stored is not None and new_details and not stored.get("details"):
718
842
  stored["details"] = new_details
719
843
  needs_rewrite = True
844
+ new_url = item.get("url")
845
+ if stored is not None and new_url and not stored.get("url"):
846
+ stored["url"] = new_url
847
+ needs_rewrite = True
720
848
  # ``text`` upgrades to the longer capture: the common turn
721
849
  # is a search (small per-record JSON) followed by a full
722
850
  # fetch of the same titled item — the later, richer text is
@@ -759,7 +887,7 @@ def _citation_sources_block(
759
887
  Rendered *before* the tool output (leading block, not a trailing footer):
760
888
  the agent harness spills oversized tool results to a file wholesale, and a
761
889
  partial or programmatic read of that file only reliably sees the head — a
762
- trailing marker list would be exactly what such reads miss (UN-22309).
890
+ trailing marker list would be exactly what such reads miss.
763
891
 
764
892
  Each line names the ``tool_name`` alongside the marker so the model has a
765
893
  stronger anchor than a bare integer: ``{title} — {tool_name}`` when the item