unique-sdk 2026.28.0.dev7__tar.gz → 2026.28.0.dev8__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 (87) hide show
  1. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/PKG-INFO +1 -1
  2. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/pyproject.toml +1 -1
  3. unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/mcp.py +342 -0
  4. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +16 -0
  5. unique_sdk-2026.28.0.dev7/unique_sdk/cli/commands/mcp.py +0 -93
  6. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/README.md +0 -0
  7. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/__init__.py +0 -0
  8. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_requestor.py +0 -0
  9. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_resource.py +0 -0
  10. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_version.py +0 -0
  11. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_error.py +0 -0
  12. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_http_client.py +0 -0
  13. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_list_object.py +0 -0
  14. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_object_classes.py +0 -0
  15. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_request_options.py +0 -0
  16. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_object.py +0 -0
  17. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_ql.py +0 -0
  18. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_response.py +0 -0
  19. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_util.py +0 -0
  20. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_version.py +0 -0
  21. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_webhook.py +0 -0
  22. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/__init__.py +0 -0
  23. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_acronyms.py +0 -0
  24. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_agentic_table.py +0 -0
  25. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_analytics_order.py +0 -0
  26. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_benchmarking.py +0 -0
  27. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_briefing.py +0 -0
  28. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_chat_completion.py +0 -0
  29. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_content.py +0 -0
  30. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  31. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_elicitation.py +0 -0
  32. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_embedding.py +0 -0
  33. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_event.py +0 -0
  34. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_folder.py +0 -0
  35. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_group.py +0 -0
  36. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_integrated.py +0 -0
  37. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_llm_models.py +0 -0
  38. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_mcp.py +0 -0
  39. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message.py +0 -0
  40. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_assessment.py +0 -0
  41. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_execution.py +0 -0
  42. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_log.py +0 -0
  43. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_tool.py +0 -0
  44. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_module.py +0 -0
  45. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  46. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search.py +0 -0
  47. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search_string.py +0 -0
  48. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  49. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_space.py +0 -0
  50. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_user.py +0 -0
  51. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_web_search.py +0 -0
  52. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__init__.py +0 -0
  53. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__main__.py +0 -0
  54. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/cli.py +0 -0
  55. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/__init__.py +0 -0
  56. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  57. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py +0 -0
  58. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  59. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/elicitation.py +0 -0
  60. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/files.py +0 -0
  61. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/folders.py +0 -0
  62. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/navigation.py +0 -0
  63. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/read.py +0 -0
  64. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  65. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/search.py +0 -0
  66. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/subagent.py +0 -0
  67. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search.py +0 -0
  68. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search_config.py +0 -0
  69. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/config.py +0 -0
  70. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/formatting.py +0 -0
  71. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/metadata_filter.py +0 -0
  72. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/shell.py +0 -0
  73. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
  74. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  75. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
  76. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  77. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  78. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  79. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  80. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/state.py +0 -0
  81. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/analytics_order_run.py +0 -0
  82. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/benchmarking_run.py +0 -0
  83. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_history.py +0 -0
  84. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_in_space.py +0 -0
  85. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/file_io.py +0 -0
  86. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/sources.py +0 -0
  87. {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/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.28.0.dev7
3
+ Version: 2026.28.0.dev8
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.28.0.dev7"
3
+ version = "2026.28.0.dev8"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -0,0 +1,342 @@
1
+ """MCP command: call MCP server tools via the Unique platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import unique_sdk
12
+ from unique_sdk.cli.commands._citation_manifest import (
13
+ UnsafeRefsLogPathError,
14
+ _append_turn_refs_manifest_entry,
15
+ _locked_turn_refs_manifest,
16
+ _read_turn_refs_manifest,
17
+ )
18
+ from unique_sdk.cli.formatting import format_mcp_response
19
+ from unique_sdk.cli.state import ShellState
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ # Per-turn manifest of MCP tool output text, consumed by the Swappable
24
+ # Intelligence runner to ground the hallucination check against MCP-retrieved
25
+ # information (UN-21951). Carries *text only* — no source numbers/markers
26
+ # (referencing is UN-21285, tracked separately).
27
+ _MCP_OUTPUT_LOG_RELATIVE_PATH = Path(".unique") / "mcp-output.jsonl"
28
+ # Writer-side cap so a single huge/raw tool result cannot bloat the manifest.
29
+ # The evaluator applies its own per-source cap as well.
30
+ _MCP_OUTPUT_TEXT_CHAR_LIMIT = 50_000
31
+
32
+ # Per-turn manifest of citable MCP sources, consumed by the runner to stitch
33
+ # ``[mcpsourceN]`` markers into ``<sup>N</sup>`` footnotes + reference chips
34
+ # (UN-21285). One entry per retrieved item: a short *title* describing what was
35
+ # retrieved (no URL — those are technical/misleading). The runner labels the
36
+ # chip with that title + the MCP tool name; falls back to the tool alone when
37
+ # the result carries no recognizable title.
38
+ _MCP_REFS_LOG_RELATIVE_PATH = Path(".unique") / "mcp-refs.jsonl"
39
+ _MCP_REFS_LOCK_FILENAME = "mcp-refs.lock"
40
+ _MCP_SNIPPET_CHAR_LIMIT = 300
41
+ _MCP_MAX_ITEMS_PER_CALL = 8
42
+
43
+ # Keys an MCP tool's JSON result commonly uses for a record's human title.
44
+ _TITLE_KEYS = ("title", "name", "displayName", "subject", "summary", "key")
45
+
46
+ # Canonical ``toolName`` written to both manifests: the **bare advertised tool
47
+ # name** (the payload ``name`` the agent passes to ``unique-cli mcp``).
48
+ # ``unique-cli mcp`` only runs in skills mode, where the agent invokes a tool by
49
+ # its bare advertised name (the SI ``mcp_skill_generator`` renders the example
50
+ # payload with that bare name). The SI runner therefore resolves a friendly
51
+ # display label by keying its tool-config map on this bare name. The
52
+ # ``mcp__<server>__<tool>`` parsing below is a defensive fallback for that
53
+ # double-underscore convention and does not fire in skills mode; there
54
+ # ``serverName`` is sourced from ``response.mcpServerId`` instead (see
55
+ # ``cmd_mcp``).
56
+
57
+
58
+ def _server_name_from_tool(name: str) -> str | None:
59
+ """Best-effort server name for the ``mcp__<server>__<tool>`` convention.
60
+
61
+ Defensive only — the bare advertised tool name carries no ``mcp__`` prefix
62
+ in skills mode (the only mode that runs this command), so this returns
63
+ ``None`` there and the caller falls back to ``response.mcpServerId``.
64
+ """
65
+ parts = name.split("__")
66
+ if len(parts) >= 3 and parts[0] == "mcp":
67
+ return parts[1]
68
+ return None
69
+
70
+
71
+ def _snippet(text: str | None) -> str | None:
72
+ if not isinstance(text, str):
73
+ return None
74
+ collapsed = " ".join(text.split())
75
+ return collapsed[:_MCP_SNIPPET_CHAR_LIMIT] or None
76
+
77
+
78
+ def _title_from_json(obj: dict[str, Any]) -> str | None:
79
+ for key in _TITLE_KEYS:
80
+ value = obj.get(key)
81
+ if isinstance(value, str) and value.strip():
82
+ return value.strip()
83
+ return None
84
+
85
+
86
+ def _titles_from_json(text: str) -> list[dict[str, Any]]:
87
+ """Best-effort: pull a human title out of a JSON result (object or list of
88
+ objects), e.g. an Atlassian page/issue returned as JSON-in-text. Returns []
89
+ when the text is not JSON or carries no recognizable title.
90
+ """
91
+ try:
92
+ parsed = json.loads(text)
93
+ except (ValueError, TypeError):
94
+ return []
95
+ candidates = parsed if isinstance(parsed, list) else [parsed]
96
+ items: list[dict[str, Any]] = []
97
+ for entry in candidates:
98
+ if isinstance(entry, dict):
99
+ title = _title_from_json(entry)
100
+ if title:
101
+ items.append({"title": title, "snippet": None})
102
+ return items
103
+
104
+
105
+ def _extract_mcp_citation_items(
106
+ response: Any,
107
+ *,
108
+ tool_name: str,
109
+ server_name: str | None,
110
+ ) -> list[dict[str, Any]]:
111
+ """Context for what the tool retrieved: ``{title, snippet}`` per item.
112
+
113
+ Titles come from MCP ``resource_link`` names (spec-native) or a best-effort
114
+ JSON-title heuristic over text blocks (for tools like Atlassian that return
115
+ JSON-in-text). No URLs are extracted — the chip is display-only. Falls back
116
+ to a single title-less item (the runner names it after the tool) when the
117
+ result carries no recognizable title.
118
+ """
119
+ content = getattr(response, "content", None) or []
120
+ items: list[dict[str, Any]] = []
121
+
122
+ for block in content:
123
+ if not isinstance(block, dict):
124
+ continue
125
+ if block.get("type") == "resource_link":
126
+ name = (block.get("name") or "").strip()
127
+ if name:
128
+ items.append(
129
+ {"title": name, "snippet": _snippet(block.get("description"))}
130
+ )
131
+
132
+ if not items:
133
+ for block in content:
134
+ if isinstance(block, dict) and block.get("type") == "text":
135
+ items.extend(_titles_from_json(block.get("text") or ""))
136
+
137
+ if not items:
138
+ # No recognizable title — one chip named after the tool itself.
139
+ items.append({"title": None, "snippet": None})
140
+
141
+ return items[:_MCP_MAX_ITEMS_PER_CALL]
142
+
143
+
144
+ def _next_mcp_source_number(entries: list[dict[str, Any]]) -> int:
145
+ numbers = [
146
+ entry["sourceNumber"]
147
+ for entry in entries
148
+ if isinstance(entry.get("sourceNumber"), int)
149
+ ]
150
+ return max(numbers, default=0) + 1
151
+
152
+
153
+ def _item_dedup_key(tool_name: str, item: dict[str, Any]) -> str:
154
+ """Dedup by title when present (same item = one chip), else by tool."""
155
+ title = item.get("title")
156
+ if isinstance(title, str) and title.strip():
157
+ return f"title:{tool_name}:{title.strip()}"
158
+ return f"tool:{tool_name}"
159
+
160
+
161
+ def _annotate_mcp_results_for_citations(
162
+ response: Any,
163
+ *,
164
+ tool_name: str,
165
+ server_name: str | None,
166
+ refs_log_path: Path | None = None,
167
+ ) -> list[tuple[int, dict[str, Any]]]:
168
+ """Assign per-turn ``[mcpsourceN]`` numbers to each retrieved item and append
169
+ the refs manifest. Returns ``[(sourceNumber, item)]`` for the footer.
170
+
171
+ Items dedup by title across the turn (same item keeps one number), or by
172
+ tool for the title-less fallback. Best-effort — returns ``[]`` on any failure
173
+ (the tool result is unaffected; only the citation footer is skipped).
174
+ """
175
+ refs_log_path = refs_log_path or (Path.cwd() / _MCP_REFS_LOG_RELATIVE_PATH)
176
+ annotated: list[tuple[int, dict[str, Any]]] = []
177
+ try:
178
+ items = _extract_mcp_citation_items(
179
+ response, tool_name=tool_name, server_name=server_name
180
+ )
181
+ with _locked_turn_refs_manifest(
182
+ refs_log_path, lock_filename=_MCP_REFS_LOCK_FILENAME
183
+ ):
184
+ entries = _read_turn_refs_manifest(refs_log_path)
185
+ numbers_by_key: dict[str, int] = {}
186
+ for entry in entries:
187
+ if isinstance(entry.get("sourceNumber"), int):
188
+ stored_tool = entry.get("toolName") or tool_name
189
+ numbers_by_key[_item_dedup_key(stored_tool, entry)] = entry[
190
+ "sourceNumber"
191
+ ]
192
+ for item in items:
193
+ key = _item_dedup_key(tool_name, item)
194
+ source_number = numbers_by_key.get(key)
195
+ if source_number is None:
196
+ source_number = _next_mcp_source_number(entries)
197
+ manifest_entry = {
198
+ "sourceNumber": source_number,
199
+ "toolName": tool_name,
200
+ "serverName": server_name,
201
+ "title": item.get("title"),
202
+ "snippet": item.get("snippet"),
203
+ }
204
+ try:
205
+ _append_turn_refs_manifest_entry(refs_log_path, manifest_entry)
206
+ except (UnsafeRefsLogPathError, OSError) as exc:
207
+ _LOGGER.warning(
208
+ "mcp: failed to append refs manifest entry: %s", exc
209
+ )
210
+ break
211
+ numbers_by_key[key] = source_number
212
+ entries.append(manifest_entry)
213
+ annotated.append((source_number, item))
214
+ except (UnsafeRefsLogPathError, OSError) as exc:
215
+ _LOGGER.warning("mcp: failed to append refs manifest: %s", exc)
216
+ return []
217
+ except Exception as exc: # noqa: BLE001 — never break the tool call
218
+ _LOGGER.warning("mcp: failed to extract citations: %s", exc)
219
+ return []
220
+ return annotated
221
+
222
+
223
+ def _citation_footer(annotated: list[tuple[int, dict[str, Any]]]) -> str:
224
+ """Tell the agent which marker to cite each retrieved item with."""
225
+ if not annotated:
226
+ return ""
227
+ lines = ["", "Sources — cite each fact with the marker for the item it came from:"]
228
+ for source_number, item in annotated:
229
+ label = item.get("title") or "this MCP tool result"
230
+ lines.append(f" [mcpsource{source_number}] {label}")
231
+ return "\n".join(lines)
232
+
233
+
234
+ def _append_mcp_output_manifest(
235
+ name: str, text: str, *, server_name: str | None = None
236
+ ) -> None:
237
+ """Best-effort append of one MCP tool result to the per-turn manifest.
238
+
239
+ Never raises: a manifest failure must not change what the agent sees as
240
+ the tool result. The groundedness check simply does not fire for this
241
+ call when the write fails.
242
+ """
243
+ try:
244
+ refs_log_path = Path.cwd() / _MCP_OUTPUT_LOG_RELATIVE_PATH
245
+ _append_turn_refs_manifest_entry(
246
+ refs_log_path,
247
+ {
248
+ "toolName": name,
249
+ "serverName": server_name,
250
+ "text": text[:_MCP_OUTPUT_TEXT_CHAR_LIMIT],
251
+ },
252
+ )
253
+ except (UnsafeRefsLogPathError, OSError) as exc:
254
+ _LOGGER.warning("mcp: failed to append output manifest: %s", exc)
255
+
256
+
257
+ def _read_payload(
258
+ payload: str | None,
259
+ file: str | None,
260
+ stdin: bool,
261
+ ) -> str:
262
+ """Resolve the JSON payload from one of the three input sources."""
263
+ sources = sum([payload is not None, file is not None, stdin])
264
+ if sources == 0:
265
+ raise ValueError(
266
+ "No payload provided. Pass a JSON string, use --file, or --stdin."
267
+ )
268
+ if sources > 1:
269
+ raise ValueError(
270
+ "Ambiguous input: provide exactly one of PAYLOAD, --file, or --stdin."
271
+ )
272
+
273
+ if stdin:
274
+ return sys.stdin.read()
275
+ if file is not None:
276
+ return Path(file).read_text(encoding="utf-8")
277
+ assert payload is not None
278
+ return payload
279
+
280
+
281
+ def _parse_and_validate(raw: str) -> tuple[str, dict[str, Any]]:
282
+ """Parse JSON and validate the required ``name`` and ``arguments`` fields."""
283
+ try:
284
+ data = json.loads(raw)
285
+ except json.JSONDecodeError as exc:
286
+ raise ValueError(f"Invalid JSON: {exc}") from exc
287
+
288
+ if not isinstance(data, dict):
289
+ raise ValueError("Payload must be a JSON object.")
290
+
291
+ if "name" not in data:
292
+ raise ValueError('Missing required field "name" in JSON payload.')
293
+
294
+ name: str = data["name"]
295
+ arguments: dict[str, Any] = data.get("arguments", {})
296
+
297
+ if not isinstance(arguments, dict):
298
+ raise ValueError('"arguments" must be a JSON object.')
299
+
300
+ return name, arguments
301
+
302
+
303
+ def cmd_mcp(
304
+ state: ShellState,
305
+ chat_id: str,
306
+ message_id: str,
307
+ payload: str | None = None,
308
+ file: str | None = None,
309
+ stdin: bool = False,
310
+ ) -> str:
311
+ """Call an MCP tool with a JSON payload containing name and arguments."""
312
+ try:
313
+ raw = _read_payload(payload, file, stdin)
314
+ name, arguments = _parse_and_validate(raw)
315
+
316
+ response = unique_sdk.MCP.call_tool(
317
+ user_id=state.config.user_id,
318
+ company_id=state.config.company_id,
319
+ name=name,
320
+ chatId=chat_id,
321
+ messageId=message_id,
322
+ arguments=arguments,
323
+ )
324
+
325
+ except (ValueError, OSError, unique_sdk.APIError) as e:
326
+ return f"mcp: {e}"
327
+
328
+ try:
329
+ formatted = format_mcp_response(response, tool_name=name)
330
+ except Exception as fmt_exc:
331
+ try:
332
+ fallback = json.dumps(dict(response), indent=2, default=str)
333
+ except Exception:
334
+ fallback = repr(response)
335
+ formatted = f"mcp: formatter error ({fmt_exc}); raw response:\n{fallback}"
336
+
337
+ server_name = _server_name_from_tool(name) or getattr(response, "mcpServerId", None)
338
+ _append_mcp_output_manifest(name, formatted, server_name=server_name)
339
+ annotated = _annotate_mcp_results_for_citations(
340
+ response, tool_name=name, server_name=server_name
341
+ )
342
+ return formatted + _citation_footer(annotated)
@@ -130,6 +130,22 @@ unique-cli mcp -c chat_123 -m msg_456 "$payload"
130
130
  python generate_payload.py | unique-cli mcp -c chat_123 -m msg_456 --stdin
131
131
  ```
132
132
 
133
+ ## Citation Rules
134
+
135
+ Cite a fact retrieved from an MCP tool with `[mcpsourceN]`, where `N` matches the `sourceNumber` the command printed in the `Sources` footer beneath the tool output. Each footer line names one retrieved item; cite a fact with the marker of the specific item it came from. The Unique platform's Swappable Intelligence runner converts each `[mcpsourceN]` marker in your final answer into a `<sup>N</sup>` footnote and a display-only reference chip labelled with the retrieved item and the MCP tool name (e.g. "RAG Retrieval Baseline — Retrieve Confluence page (MCP)"). Without `[mcpsourceN]` markers, MCP-retrieved facts in your answer appear as plain text only, with no footnote and no chip.
136
+
137
+ ```
138
+ ACME's Q3 revenue was 1.2M [mcpsource1], up from 0.9M a year earlier [mcpsource2].
139
+ ```
140
+
141
+ **Rules** (enforced by the platform's reference post-processor, which reads `.unique/mcp-refs.jsonl` rather than your prose):
142
+
143
+ 1. **`[mcpsourceN]` is for MCP tool results only.** Knowledge-base results from `unique-cli search` use `[sourceN]`, and public web results from `unique-cli web-search` use `[websourceN]` — never mix the namespaces.
144
+ 2. Only cite numbers you saw in the **current** turn's MCP `Sources` footer. Numbers from previous turns are stale and will be silently dropped.
145
+ 3. Write `mcpsource` in singular form with the number in digits: `[mcpsource1]`, `[mcpsource2]` — not `[McpSource 1]` or `[mcpsource one]`.
146
+ 4. The same retrieved item keeps one stable `[mcpsourceN]` for the whole turn, so every fact from that item uses the same marker.
147
+ 5. Do not invent source numbers for remembered or inferred facts.
148
+
133
149
  ## Prerequisites
134
150
 
135
151
  Requires these environment variables:
@@ -1,93 +0,0 @@
1
- """MCP command: call MCP server tools via the Unique platform."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import sys
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- import unique_sdk
11
- from unique_sdk.cli.formatting import format_mcp_response
12
- from unique_sdk.cli.state import ShellState
13
-
14
-
15
- def _read_payload(
16
- payload: str | None,
17
- file: str | None,
18
- stdin: bool,
19
- ) -> str:
20
- """Resolve the JSON payload from one of the three input sources."""
21
- sources = sum([payload is not None, file is not None, stdin])
22
- if sources == 0:
23
- raise ValueError(
24
- "No payload provided. Pass a JSON string, use --file, or --stdin."
25
- )
26
- if sources > 1:
27
- raise ValueError(
28
- "Ambiguous input: provide exactly one of PAYLOAD, --file, or --stdin."
29
- )
30
-
31
- if stdin:
32
- return sys.stdin.read()
33
- if file is not None:
34
- return Path(file).read_text(encoding="utf-8")
35
- assert payload is not None
36
- return payload
37
-
38
-
39
- def _parse_and_validate(raw: str) -> tuple[str, dict[str, Any]]:
40
- """Parse JSON and validate the required ``name`` and ``arguments`` fields."""
41
- try:
42
- data = json.loads(raw)
43
- except json.JSONDecodeError as exc:
44
- raise ValueError(f"Invalid JSON: {exc}") from exc
45
-
46
- if not isinstance(data, dict):
47
- raise ValueError("Payload must be a JSON object.")
48
-
49
- if "name" not in data:
50
- raise ValueError('Missing required field "name" in JSON payload.')
51
-
52
- name: str = data["name"]
53
- arguments: dict[str, Any] = data.get("arguments", {})
54
-
55
- if not isinstance(arguments, dict):
56
- raise ValueError('"arguments" must be a JSON object.')
57
-
58
- return name, arguments
59
-
60
-
61
- def cmd_mcp(
62
- state: ShellState,
63
- chat_id: str,
64
- message_id: str,
65
- payload: str | None = None,
66
- file: str | None = None,
67
- stdin: bool = False,
68
- ) -> str:
69
- """Call an MCP tool with a JSON payload containing name and arguments."""
70
- try:
71
- raw = _read_payload(payload, file, stdin)
72
- name, arguments = _parse_and_validate(raw)
73
-
74
- response = unique_sdk.MCP.call_tool(
75
- user_id=state.config.user_id,
76
- company_id=state.config.company_id,
77
- name=name,
78
- chatId=chat_id,
79
- messageId=message_id,
80
- arguments=arguments,
81
- )
82
-
83
- except (ValueError, OSError, unique_sdk.APIError) as e:
84
- return f"mcp: {e}"
85
-
86
- try:
87
- return format_mcp_response(response, tool_name=name)
88
- except Exception as fmt_exc:
89
- try:
90
- fallback = json.dumps(dict(response), indent=2, default=str)
91
- except Exception:
92
- fallback = repr(response)
93
- return f"mcp: formatter error ({fmt_exc}); raw response:\n{fallback}"