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.
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/PKG-INFO +1 -1
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/pyproject.toml +1 -1
- unique_sdk-2026.28.0.dev8/unique_sdk/cli/commands/mcp.py +342 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +16 -0
- unique_sdk-2026.28.0.dev7/unique_sdk/cli/commands/mcp.py +0 -93
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/README.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_requestor.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_resource.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_api_version.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_error.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_http_client.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_list_object.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_object_classes.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_request_options.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_object.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_ql.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_unique_response.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_util.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_version.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/_webhook.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_acronyms.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_agentic_table.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_analytics_order.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_benchmarking.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_briefing.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_chat_completion.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_content.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_embedding.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_event.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_folder.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_group.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_integrated.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_llm_models.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_mcp.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_assessment.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_execution.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_log.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_tool.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_module.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_scheduled_task.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search_string.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_short_term_memory.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_space.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_user.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_web_search.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/__main__.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/cli.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/__init__.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/elicitation.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/files.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/folders.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/navigation.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/read.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/search.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/subagent.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search_config.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/config.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/formatting.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/metadata_filter.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/shell.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-dynamic-frontend/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/state.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/analytics_order_run.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/benchmarking_run.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_history.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/chat_in_space.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/file_io.py +0 -0
- {unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/sources.py +0 -0
- {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.
|
|
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>
|
|
@@ -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}"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/__init__.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_acronyms.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_agentic_table.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_analytics_order.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_benchmarking.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_briefing.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_chat_completion.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_content.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_elicitation.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_embedding.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_integrated.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_llm_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_log.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_message_tool.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_scheduled_task.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_search_string.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/api_resources/_web_search.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/cite_file.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/dynamic_frontend.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/elicitation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/navigation.py
RENAMED
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/scheduled_tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/cli/commands/web_search_config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/analytics_order_run.py
RENAMED
|
File without changes
|
{unique_sdk-2026.28.0.dev7 → unique_sdk-2026.28.0.dev8}/unique_sdk/utils/benchmarking_run.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|