Skip to content

Commit 8865a29

Browse files
Fix Bing Search MCP (#435)
- Makes it much faster, but now needs AOAI (instead of being able to use MCP sampling)
1 parent 1479ab0 commit 8865a29

15 files changed

Lines changed: 426 additions & 899 deletions

File tree

mcp-servers/mcp-server-bing-search/.env.example

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,5 @@
33

44
# Required for the service
55
BING_SEARCH_API_KEY=
6-
7-
# Optional for running tests outside of a MCP Server context.
86
ASSISTANT__AZURE_OPENAI_ENDPOINT=
97

mcp-servers/mcp-server-bing-search/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This is a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP
77
## Tools
88

99
### `search(query: str) -> str`
10+
1011
- Calls the Bing Search API with the provided query.
1112
- Processes each URL from the search results:
1213
- Gets the content of the page
@@ -17,6 +18,7 @@ This is a [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP
1718
- Returns the processed content and links as a LLM-friendly string.
1819

1920
### `click(hashes: list[str]) -> str`
21+
2022
- Takes a list of hashes (which originate from the `search` tool).
2123
- For each hash gets the corresponding URL from the local cache.
2224
- Then does the same processing as `search` for each URL and returns a similar LLM-friendly string.
@@ -31,6 +33,13 @@ make
3133

3234
To create the virtual environment and install dependencies.
3335

36+
### Setup Environment Variables
37+
38+
Create a `.env` file based on `.env.sample` and populate it with:
39+
40+
- `BING_SEARCH_API_KEY`
41+
- `ASSISTANT__AZURE_OPENAI_ENDPOINT` - This is necessary if you want to post process web content.
42+
3443
### Running the Server
3544

3645
Use the VSCode launch configuration, or run manually:

mcp-servers/mcp-server-bing-search/mcp_server_bing_search/config.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@ class Settings(BaseSettings):
2222
dev: bool = True
2323
bing_search_api_key: Annotated[str, Field(validation_alias="BING_SEARCH_API_KEY")] = ""
2424
azure_endpoint: Annotated[str | None, Field(validation_alias="ASSISTANT__AZURE_OPENAI_ENDPOINT")] = None
25-
concurrency_limit: int = 1
2625
mkitdown_temp: Path = MKITDOWN_TEMP
2726
url_cache_file: Path = URL_CACHE_FILE
28-
num_search_results: int = 5
27+
num_search_results: int = 4
2928
max_links: int = 25
3029
improve_with_sampling: bool = True

mcp-servers/mcp-server-bing-search/mcp_server_bing_search/prompts/clean_website.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
You should remove aspects that are artifacts of the HTML to markdown conversion and do not help with the understanding of the content \
1010
such as navigation, extraneous links, dropdowns, images, etc.
1111
- You should preserve things like formatting of content itself.
12-
- If the content is long, you should only include the most important 4000 tokens of text
12+
- If the content is long, you should only include the most important 4000 tokens of text.
1313
- If the content is documentation, an article, blog, etc you absolutely must return the content in its entirety.
1414
- Be sure to include authors and dates. Also preserve important data, if it exists, like GitHub stars, viewership, things that hint at recency, etc.
1515
- It is critical that the content you wish to keep, you copy WORD FOR WORD.

mcp-servers/mcp-server-bing-search/mcp_server_bing_search/server.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,21 @@ def create_mcp_server() -> FastMCP:
2020
@mcp.tool()
2121
async def search(query: str, ctx: Context) -> str:
2222
"""
23-
Search for web results based on the provided query. Returns the content of the websites found.
24-
It will provide a list of links with unique hashes for each link. These can be used to identify URLs to go to using the click tool. You should not discuss these links with the user.
23+
Search for web results based on the provided query and returns the content of the websites found.
24+
Make sure to provide a specific query to ensure you find what you are looking for.
25+
At the end, it will provide a list of links that are available to be clicked.
26+
The links have short hashes that can be used to which URLs to click. You should not discuss these links with the user.
2527
"""
26-
result = await search_tool(query, context=ctx)
28+
result = await search_tool(query)
2729
return result
2830

2931
@mcp.tool()
3032
async def click_link(hashes: list[str], ctx: Context) -> str:
3133
"""
32-
"Clicks" the links identified by provided hashes. The hashes are unique identifiers for each link from the search tool.
34+
"Clicks" the links identified by provided hashes.
35+
The hashes are unique identifiers for each link from the search tool.
3336
"""
34-
result = await click_tool(hashes, context=ctx)
37+
result = await click_tool(hashes)
3538
return result
3639

3740
return mcp
Lines changed: 32 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,51 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
from typing import Any, Callable
4-
5-
from mcp.server.fastmcp import Context
3+
import asyncio
4+
import logging
5+
import time
66

77
from mcp_server_bing_search import settings
88
from mcp_server_bing_search.types import Link, WebResult
9-
from mcp_server_bing_search.utils import embarrassingly_parallel_async, format_web_results, lookup_url
9+
from mcp_server_bing_search.utils import format_web_results, lookup_url
1010
from mcp_server_bing_search.web.process_website import process_website
1111
from mcp_server_bing_search.web.search_bing import search_bing
1212

13+
logger = logging.getLogger(__name__)
14+
15+
16+
def sync_process_website(
17+
website: Link,
18+
apply_post_processing: bool = True,
19+
) -> WebResult:
20+
return asyncio.run(process_website(website, apply_post_processing))
21+
1322

14-
async def _process_websites(
23+
async def _process_websites_parallel(
1524
urls: list[str],
16-
context: Context | None = None,
17-
chat_completion_client: Callable[..., Any] | None = None,
1825
) -> str:
19-
"""
20-
Process a list of URLs to extract and format web content.
26+
tasks = []
27+
for url in urls:
28+
tasks.append(asyncio.to_thread(sync_process_website, Link(url=url), settings.improve_with_sampling))
2129

22-
Returns:
23-
A formatted string containing web content suitable for LLM consumption.
24-
"""
25-
if not urls:
26-
return "No results found."
27-
28-
processed_web_results: list[WebResult] = []
29-
if settings.concurrency_limit > 1:
30-
processed_web_results = await embarrassingly_parallel_async(
31-
func=process_website,
32-
args_list=[
33-
(
34-
Link(url=url),
35-
context,
36-
chat_completion_client,
37-
settings.improve_with_sampling,
38-
)
39-
for url in urls
40-
], # type: ignore
41-
concurrency_limit=settings.concurrency_limit,
42-
)
43-
else:
44-
for url in urls:
45-
processed_result = await process_website(
46-
website=Link(url=url),
47-
context=context,
48-
chat_completion_client=chat_completion_client,
49-
apply_post_processing=settings.improve_with_sampling,
50-
)
51-
processed_web_results.append(processed_result)
30+
start_time = time.time()
31+
results = await asyncio.gather(*tasks)
32+
end_time = time.time()
33+
response_duration = round(end_time - start_time, 4)
34+
logger.info(f"Processed {len(urls)} URLs in {response_duration} seconds.")
5235

5336
# Filter out any None or empty results
54-
web_results = [result for result in processed_web_results if result and result.content]
37+
web_results = [result for result in results if result and result.content]
5538

5639
if not web_results:
5740
return "No content could be extracted from the provided URLs."
5841

59-
# TODO: Deduplicating links across all results
42+
web_results = format_web_results(web_results)
43+
return web_results
6044

61-
# Format the results into an LLM-friendly string
62-
formatted_results = format_web_results(web_results)
63-
return formatted_results
6445

65-
66-
async def search(
67-
query: str, context: Context | None = None, chat_completion_client: Callable[..., Any] | None = None
68-
) -> str:
46+
async def search(query: str) -> str:
6947
"""
70-
Search for web results using the provided query and token limit.
48+
Searches for web results using the provided query.
7149
7250
Returns:
7351
A formatted string containing web search results suitable for LLM consumption.
@@ -78,12 +56,11 @@ async def search(
7856
return "No results found."
7957

8058
urls = [result.url for result in search_results]
81-
return await _process_websites(urls, context, chat_completion_client)
59+
results = await _process_websites_parallel(urls)
60+
return results
8261

8362

84-
async def click(
85-
hashes: list[str], context: Context | None = None, chat_completion_client: Callable[..., Any] | None = None
86-
) -> str:
63+
async def click(hashes: list[str]) -> str:
8764
"""
8865
Looks up each of the hashes to get the associated URLs and then processes the URLs.
8966
@@ -103,4 +80,5 @@ async def click(
10380
if not urls:
10481
return "No results found."
10582

106-
return await _process_websites(urls, context, chat_completion_client)
83+
results = await _process_websites_parallel(urls)
84+
return results
Lines changed: 31 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3-
import asyncio
4-
import concurrent.futures
53
import json
6-
from collections.abc import Awaitable, Callable, Collection, Set
7-
from typing import Any, Literal
4+
from collections.abc import Collection, Set
5+
from typing import Literal
86

97
import tiktoken
108

119
from mcp_server_bing_search import settings
12-
from mcp_server_bing_search.types import WebResult
10+
from mcp_server_bing_search.types import Link, WebResult
1311

1412

1513
def lookup_url(url_hash: str) -> str | None:
@@ -35,10 +33,31 @@ def lookup_url(url_hash: str) -> str | None:
3533
return None
3634

3735

36+
def consolidate_links(web_results: list[WebResult]) -> list[Link]:
37+
"""
38+
Extracts and deduplicates links from a list of web results.
39+
40+
Args:
41+
web_results: List of WebResult objects containing links
42+
43+
Returns:
44+
A list of unique Link objects across all web results
45+
"""
46+
unique_links = {}
47+
48+
for result in web_results:
49+
for link in result.links:
50+
if link.unique_id not in unique_links:
51+
unique_links[link.unique_id] = link
52+
53+
return list(unique_links.values())
54+
55+
3856
def format_web_results(web_results: list[WebResult]) -> str:
3957
"""
4058
Creates an LLM friendly representation of the web results.
4159
"""
60+
unique_links = consolidate_links(web_results)
4261

4362
formatted_results = ""
4463
for result in web_results:
@@ -47,14 +66,15 @@ def format_web_results(web_results: list[WebResult]) -> str:
4766
formatted_result += f"<url>{result.url}</url>\n"
4867
formatted_result += f"<title>{result.title}</title>\n"
4968
formatted_result += f"<content>{result.content}\n</content>\n"
50-
formatted_result += "<links>\n"
51-
for link in result.links:
52-
formatted_result += f"<link id={link.unique_id}>{link}</link>\n"
53-
54-
formatted_result += "</links>\n"
5569
formatted_result += "</website>\n"
5670
formatted_results += formatted_result
5771

72+
if unique_links:
73+
formatted_results += "<links>\n"
74+
for link in unique_links:
75+
formatted_results += f"<link id={link.unique_id}>{link}</link>\n"
76+
formatted_results += "</links>\n"
77+
5878
return formatted_results.strip()
5979

6080

@@ -64,7 +84,7 @@ def __init__(
6484
model: str,
6585
allowed_special: Literal["all"] | Set[str] | None = None,
6686
disallowed_special: Literal["all"] | Collection[str] | None = None,
67-
):
87+
) -> None:
6888
self.model = model
6989
self.allowed_special = allowed_special
7090
self.disallowed_special = disallowed_special
@@ -109,66 +129,3 @@ def num_tokens_in_str(self, text: str) -> int:
109129
disallowed_special=self.disallowed_special if self.disallowed_special is not None else (),
110130
)
111131
)
112-
113-
114-
async def embarrassingly_parallel_async(
115-
func: Callable[..., Awaitable[Any]],
116-
args_list: tuple[tuple[Any, ...], ...] | None = None,
117-
kwargs_list: list[dict[str, Any]] | None = None,
118-
concurrency_limit: int = 10,
119-
) -> list[Any]:
120-
"""Execute multiple async functions independently in separate threads.
121-
122-
Each async function runs in its own thread with its own event loop,
123-
providing true isolation between tasks.
124-
125-
Args:
126-
func: An async function that returns an awaitable
127-
args_list: A tuple of tuples each containing positional arguments
128-
kwargs_list: A list of dictionaries containing keyword arguments
129-
concurrency_limit: Maximum number of threads to use (None means ThreadPoolExecutor default)
130-
131-
Raises:
132-
ValueError: If neither args_list nor kwargs_list is provided
133-
ValueError: If both are provided but have different lengths
134-
135-
Returns:
136-
list[Any]: Results from each function call in the order tasks were submitted
137-
"""
138-
if not args_list and not kwargs_list:
139-
raise ValueError("Either args_list or kwargs_list must be provided")
140-
141-
if args_list and kwargs_list and len(args_list) != len(kwargs_list):
142-
raise ValueError("args_list and kwargs_list must be of the same length")
143-
144-
def run_async_in_thread(idx: int) -> Any:
145-
"""Run an async function in a new event loop in the current thread."""
146-
args = args_list[idx] if args_list else ()
147-
kwargs = kwargs_list[idx] if kwargs_list else {}
148-
149-
# Create a new event loop for this thread
150-
loop = asyncio.new_event_loop()
151-
asyncio.set_event_loop(loop)
152-
try:
153-
# Run the async function to completion and return the result
154-
return loop.run_until_complete(func(*args, **kwargs))
155-
finally:
156-
# Clean up
157-
loop.close()
158-
159-
task_count = len(args_list) if args_list else len(kwargs_list or [])
160-
results = []
161-
162-
# Use ThreadPoolExecutor to run each task in a separate thread
163-
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency_limit) as executor:
164-
# Submit all tasks and collect their futures
165-
futures = [executor.submit(run_async_in_thread, i) for i in range(task_count)]
166-
167-
# Retrieve results in the order they were submitted
168-
for future in concurrent.futures.as_completed(futures):
169-
try:
170-
results.append(future.result())
171-
except Exception as exc:
172-
results.append(exc)
173-
174-
return results

mcp-servers/mcp-server-bing-search/mcp_server_bing_search/web/get_content.py

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,17 @@
1-
# Copyright (c) Microsoft. All rights reserved.
2-
3-
import asyncio
41
import logging
52

6-
import requests
3+
import httpx
74

85
logger = logging.getLogger(__name__)
96

107

118
async def get_raw_web_content(url: str) -> str:
12-
"""
13-
Fetches raw web content from a given URL using requests.
14-
15-
Uses asyncio.to_thread to make the synchronous requests call non-blocking.
16-
Returns an empty string if any errors occur.
17-
18-
Args:
19-
url: The URL to fetch content from.
20-
21-
Returns:
22-
str: The raw web content as a string, or empty string on error.
23-
"""
249
try:
25-
response = await asyncio.to_thread(
26-
requests.get,
27-
url,
28-
timeout=10,
29-
)
30-
response.raise_for_status()
31-
return response.text
32-
33-
except requests.RequestException as e:
10+
async with httpx.AsyncClient(timeout=10) as client:
11+
response = await client.get(url)
12+
response.raise_for_status()
13+
return response.text
14+
except httpx.RequestError as e:
3415
logger.error(f"Failed to get web content: {e}")
3516
return ""
3617
except Exception as e:

0 commit comments

Comments
 (0)