Skip to content

Commit 4601a2a

Browse files
Enrich MCP search citation metadata
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9b1f390 commit 4601a2a

2 files changed

Lines changed: 210 additions & 3 deletions

File tree

python/packages/openai/agent_framework_openai/_chat_client.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ def _finalize_response_updates(
761761
) -> ChatResponse[Any]:
762762
"""Finalize streamed updates and add post-stream Azure AI Search citation metadata."""
763763
self._enrich_streamed_azure_ai_search_citations(updates)
764+
self._enrich_mcp_search_citations([content for update in updates for content in update.contents])
764765
return super()._finalize_response_updates(updates, response_format=response_format)
765766

766767
@classmethod
@@ -2035,6 +2036,89 @@ def _enrich_streamed_azure_ai_search_citations(cls, updates: Sequence[ChatRespon
20352036
continue
20362037
additional_properties["get_url"] = get_urls[doc_index]
20372038

2039+
@staticmethod
2040+
def _extract_mcp_search_documents_from_text(text: str) -> dict[str, Mapping[str, Any]]:
2041+
"""Extract MCP search-index document metadata JSON objects from hosted-MCP output text."""
2042+
documents: dict[str, Mapping[str, Any]] = {}
2043+
decoder = json.JSONDecoder()
2044+
start = 0
2045+
while True:
2046+
object_start = text.find("{", start)
2047+
if object_start < 0:
2048+
return documents
2049+
try:
2050+
value, offset = decoder.raw_decode(text[object_start:])
2051+
except json.JSONDecodeError:
2052+
start = object_start + 1
2053+
continue
2054+
if isinstance(value, Mapping):
2055+
document = cast("Mapping[str, Any]", value)
2056+
document_id = document.get("id")
2057+
if isinstance(document_id, str) and document_id:
2058+
documents[document_id] = document
2059+
start = object_start + offset
2060+
2061+
@classmethod
2062+
def _extract_mcp_search_documents_from_content(cls, content: Content) -> dict[str, Mapping[str, Any]]:
2063+
"""Extract MCP search-index document metadata from an MCP tool-result content item."""
2064+
documents: dict[str, Mapping[str, Any]] = {}
2065+
if content.type != "mcp_server_tool_result":
2066+
return documents
2067+
2068+
def _add_from_output(output: Any) -> None:
2069+
if isinstance(output, str):
2070+
documents.update(cls._extract_mcp_search_documents_from_text(output))
2071+
return
2072+
if isinstance(output, Content):
2073+
if output.type == "text" and isinstance(output.text, str):
2074+
documents.update(cls._extract_mcp_search_documents_from_text(output.text))
2075+
return
2076+
if isinstance(output, Sequence) and not isinstance(output, (str, bytes, bytearray)):
2077+
for item in cast("Sequence[object]", output):
2078+
_add_from_output(item)
2079+
2080+
_add_from_output(content.output)
2081+
raw_output = getattr(content.raw_representation, "output", None)
2082+
_add_from_output(raw_output)
2083+
return documents
2084+
2085+
@staticmethod
2086+
def _mcp_search_document_id(annotation: Annotation) -> str | None:
2087+
"""Return the document id encoded in an `mcp://searchindex/<id>` citation."""
2088+
for key in ("url", "title"):
2089+
value = annotation.get(key)
2090+
if isinstance(value, str) and value.startswith("mcp://searchindex/"):
2091+
return value.removeprefix("mcp://searchindex/").split("?", 1)[0].split("#", 1)[0]
2092+
return None
2093+
2094+
@classmethod
2095+
def _enrich_mcp_search_citations(cls, contents: Sequence[Content]) -> None:
2096+
"""Add MCP search-index document metadata to matching citation annotations."""
2097+
documents: dict[str, Mapping[str, Any]] = {}
2098+
for content in contents:
2099+
documents.update(cls._extract_mcp_search_documents_from_content(content))
2100+
if not documents:
2101+
return
2102+
2103+
for content in contents:
2104+
if content.type != "text" or not content.annotations:
2105+
continue
2106+
for annotation in content.annotations:
2107+
document_id = cls._mcp_search_document_id(annotation)
2108+
if document_id is None:
2109+
continue
2110+
document = documents.get(document_id)
2111+
if document is None:
2112+
continue
2113+
additional_properties = annotation.setdefault("additional_properties", {})
2114+
additional_properties.setdefault("mcp_document_id", document_id)
2115+
document_title = document.get("title")
2116+
if isinstance(document_title, str) and document_title:
2117+
additional_properties.setdefault("document_title", document_title)
2118+
source = document.get("source")
2119+
if isinstance(source, str) and source:
2120+
additional_properties.setdefault("source", source)
2121+
20382122
@staticmethod
20392123
def _get_search_tool_name(item_type: str) -> str:
20402124
"""Map OpenAI search output item types to unified content tool names."""
@@ -2452,7 +2536,10 @@ def _parse_response_from_openai(
24522536
# Set continuation_token when background operation is still in progress
24532537
if response.status and response.status in ("in_progress", "queued"):
24542538
args["continuation_token"] = OpenAIContinuationToken(response_id=response.id)
2455-
return ChatResponse(**args)
2539+
chat_response = ChatResponse(**args)
2540+
if chat_response.messages:
2541+
self._enrich_mcp_search_citations(chat_response.messages[0].contents)
2542+
return chat_response
24562543

24572544
def _parse_chunk_from_openai(
24582545
self,

python/packages/openai/tests/openai/test_openai_chat_client.py

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3542,13 +3542,18 @@ def test_streaming_annotation_added_with_url_citation() -> None:
35423542
assert region["end_index"] == 112
35433543

35443544

3545-
def _make_url_citation_event(*, title: str, get_url: str | None = None) -> MagicMock:
3545+
def _make_url_citation_event(
3546+
*,
3547+
title: str,
3548+
get_url: str | None = None,
3549+
url: str = "https://example.search.windows.net/",
3550+
) -> MagicMock:
35463551
event = MagicMock()
35473552
event.type = "response.output_text.annotation.added"
35483553
event.annotation_index = 0
35493554
event.annotation = {
35503555
"type": "url_citation",
3551-
"url": "https://example.search.windows.net/",
3556+
"url": url,
35523557
"title": title,
35533558
"start_index": 100,
35543559
"end_index": 112,
@@ -3558,6 +3563,17 @@ def _make_url_citation_event(*, title: str, get_url: str | None = None) -> Magic
35583563
return event
35593564

35603565

3566+
def _make_mcp_call_done_event(output: str) -> MagicMock:
3567+
event = MagicMock()
3568+
event.type = "response.output_item.done"
3569+
event.item = MagicMock()
3570+
event.item.type = "mcp_call"
3571+
event.item.id = "mcp_test"
3572+
event.item.call_id = None
3573+
event.item.output = output
3574+
return event
3575+
3576+
35613577
def _make_azure_ai_search_output_event(output: Any, *, event_type: str = "response.output_item.done") -> MagicMock:
35623578
event = MagicMock()
35633579
event.type = event_type
@@ -3691,6 +3707,110 @@ def test_streaming_azure_ai_search_output_ignores_unusable_get_url_data(title: s
36913707
assert "get_url" not in annotation["additional_properties"]
36923708

36933709

3710+
def test_streaming_mcp_searchindex_citation_enriched_from_mcp_output() -> None:
3711+
"""MCP search-index citations are enriched from retrieved document metadata in MCP output."""
3712+
client = OpenAIChatClient(model="test-model", api_key="test-key")
3713+
chat_options = ChatOptions()
3714+
function_call_ids: dict[int, tuple[str, str]] = {}
3715+
document_id = "inspection_procedures_p1_c0"
3716+
mcp_output = f"""
3717+
Retrieved 1 document.
3718+
3719+
【4:1†source】
3720+
{{
3721+
"id": "{document_id}",
3722+
"content": "Inspection Procedures content",
3723+
"title": "Inspection Procedures",
3724+
"source": "inspection_procedures.pdf"
3725+
}}
3726+
"""
3727+
3728+
mcp_update = client._parse_chunk_from_openai(
3729+
_make_mcp_call_done_event(mcp_output),
3730+
chat_options,
3731+
function_call_ids,
3732+
)
3733+
citation_update = client._parse_chunk_from_openai(
3734+
_make_url_citation_event(
3735+
title=f"mcp://searchindex/{document_id}",
3736+
url=f"mcp://searchindex/{document_id}",
3737+
),
3738+
chat_options,
3739+
function_call_ids,
3740+
)
3741+
3742+
response = client._finalize_response_updates([mcp_update, citation_update])
3743+
3744+
annotation = next(
3745+
annotation
3746+
for message in response.messages
3747+
for content in message.contents
3748+
for annotation in (content.annotations or [])
3749+
)
3750+
assert annotation["additional_properties"]["mcp_document_id"] == document_id
3751+
assert annotation["additional_properties"]["document_title"] == "Inspection Procedures"
3752+
assert annotation["additional_properties"]["source"] == "inspection_procedures.pdf"
3753+
3754+
3755+
def test_parse_response_enriches_mcp_searchindex_citation_from_mcp_output() -> None:
3756+
"""Non-streaming Responses output also gets MCP search-index document metadata."""
3757+
client = OpenAIChatClient(model="test-model", api_key="test-key")
3758+
document_id = "ticket_management_policy_p1_c0"
3759+
3760+
mock_mcp_item = MagicMock()
3761+
mock_mcp_item.type = "mcp_call"
3762+
mock_mcp_item.id = "mcp_123"
3763+
mock_mcp_item.call_id = None
3764+
mock_mcp_item.name = "knowledge_base_retrieve"
3765+
mock_mcp_item.server_label = "knowledge-base"
3766+
mock_mcp_item.arguments = '{"queries":["ticket policy"]}'
3767+
mock_mcp_item.output = f"""
3768+
Retrieved 1 document.
3769+
3770+
【14:1†source】
3771+
{{
3772+
"id": "{document_id}",
3773+
"content": "Ticket Management Policy content",
3774+
"title": "Ticket Management Policy",
3775+
"source": "ticket_management_policy.pdf"
3776+
}}
3777+
"""
3778+
3779+
mock_annotation = MagicMock()
3780+
mock_annotation.type = "url_citation"
3781+
mock_annotation.title = f"mcp://searchindex/{document_id}"
3782+
mock_annotation.url = f"mcp://searchindex/{document_id}"
3783+
mock_annotation.start_index = 221
3784+
mock_annotation.end_index = 233
3785+
3786+
mock_message_content = MagicMock()
3787+
mock_message_content.type = "output_text"
3788+
mock_message_content.text = "All tickets must be acknowledged within 1 hour.【14:1†source】"
3789+
mock_message_content.annotations = [mock_annotation]
3790+
mock_message_content.logprobs = None
3791+
3792+
mock_message_item = MagicMock()
3793+
mock_message_item.type = "message"
3794+
mock_message_item.content = [mock_message_content]
3795+
3796+
mock_response = MagicMock()
3797+
mock_response.id = "response_123"
3798+
mock_response.model = "test-model"
3799+
mock_response.created_at = 1000000000
3800+
mock_response.metadata = {}
3801+
mock_response.output = [mock_mcp_item, mock_message_item]
3802+
mock_response.usage = None
3803+
mock_response.status = "completed"
3804+
mock_response.conversation = None
3805+
3806+
response = client._parse_response_from_openai(mock_response, options={})
3807+
3808+
annotation = response.messages[0].contents[-1].annotations[0]
3809+
assert annotation["additional_properties"]["mcp_document_id"] == document_id
3810+
assert annotation["additional_properties"]["document_title"] == "Ticket Management Policy"
3811+
assert annotation["additional_properties"]["source"] == "ticket_management_policy.pdf"
3812+
3813+
36943814
def test_streaming_annotation_added_with_url_citation_no_url() -> None:
36953815
"""Test streaming annotation added event with url_citation but missing url is ignored."""
36963816
client = OpenAIChatClient(model="test-model", api_key="test-key")

0 commit comments

Comments
 (0)