|
| 1 | +""" |
| 2 | +MCP Search Tool Module |
| 3 | +
|
| 4 | +Defines search tools that are registered with the MCP server for advanced query processing |
| 5 | +and file attachment handling. |
| 6 | +""" |
| 7 | + |
| 8 | +import textwrap |
| 9 | +from pathlib import Path |
| 10 | +from typing import Literal |
| 11 | + |
| 12 | +from mcp.server.fastmcp import FastMCP |
| 13 | +from pydantic import BaseModel, Field |
| 14 | + |
| 15 | +from .api_client import call_provider |
| 16 | +from .config import PROVIDER_CONFIG |
| 17 | +from .types import APIKeyError, ModelConfig, ProviderType, QueryType |
| 18 | + |
| 19 | + |
| 20 | +class Message(BaseModel): |
| 21 | + """Message model for chat interactions.""" |
| 22 | + |
| 23 | + role: str = Field(description="The role of the message sender (user/assistant)") |
| 24 | + content: str = Field(description="The content of the message") |
| 25 | + |
| 26 | + |
| 27 | +# Create MCP server instance |
| 28 | +mcp: FastMCP = FastMCP( |
| 29 | + "perplexity-advanced", |
| 30 | + log_level="WARNING", |
| 31 | + dependencies=["httpx"], |
| 32 | +) |
| 33 | + |
| 34 | + |
| 35 | +def process_attachments(attachment_paths: list[str]) -> str: |
| 36 | + """ |
| 37 | + Processes file attachments and formats them into an XML string. |
| 38 | +
|
| 39 | + Reads the contents of each file and wraps them in XML tags with the following structure: |
| 40 | + <files> |
| 41 | + <file path="/absolute/path/to/file1"> |
| 42 | + [file1 contents] |
| 43 | + </file> |
| 44 | + <file path="/absolute/path/to/file2"> |
| 45 | + [file2 contents] |
| 46 | + </file> |
| 47 | + </files> |
| 48 | +
|
| 49 | + Args: |
| 50 | + attachment_paths: List of absolute file paths to process |
| 51 | +
|
| 52 | + Returns: |
| 53 | + str: XML-formatted string containing file contents |
| 54 | +
|
| 55 | + Raises: |
| 56 | + ValueError: If a file is not found, is invalid, or cannot be read |
| 57 | + """ |
| 58 | + if not attachment_paths: |
| 59 | + return "" |
| 60 | + |
| 61 | + result = ["<files>"] |
| 62 | + |
| 63 | + # Process each file |
| 64 | + for file_path in attachment_paths: |
| 65 | + try: |
| 66 | + abs_path = Path(file_path).resolve(strict=True) |
| 67 | + if not abs_path.is_file(): |
| 68 | + raise ValueError(f"'{abs_path}' is not a valid file") |
| 69 | + |
| 70 | + # Read file content |
| 71 | + with abs_path.open(encoding="utf-8") as f: |
| 72 | + file_content = f.read() |
| 73 | + |
| 74 | + # Add file content with proper indentation |
| 75 | + result.append(f'\t<file path="{abs_path}">') |
| 76 | + # Indent each line of the content |
| 77 | + content_lines = file_content.splitlines() |
| 78 | + result.extend(f"\t\t{line}" for line in content_lines) |
| 79 | + result.append("\t</file>") |
| 80 | + |
| 81 | + except FileNotFoundError: |
| 82 | + raise ValueError(f"File not found: {file_path}") |
| 83 | + except Exception as e: |
| 84 | + raise ValueError(f"Error processing file '{file_path}': {e}") from e |
| 85 | + |
| 86 | + result.append("</files>") |
| 87 | + formatted_xml = "\n".join(result) |
| 88 | + return formatted_xml |
| 89 | + |
| 90 | + |
| 91 | +@mcp.tool( |
| 92 | + name="ask_perplexity", |
| 93 | + description=( |
| 94 | + """Perplexity is fundamentally an LLM that can search the internet, gather information, and answer users’ queries. |
| 95 | +
|
| 96 | + For example, let's suppose we want to find out the latest version of Python. |
| 97 | + 1. You would search on Google. |
| 98 | + 2. Then read the top two or three results directly to verify. |
| 99 | +
|
| 100 | + Perplexity does that work for you. |
| 101 | +
|
| 102 | + To answer a user's query, Perplexity searches, opens the top search results, finds information on those websites, and then provides the answer. |
| 103 | +
|
| 104 | + Perplexity can be used with two types of queries: simple and complex. Choosing the right query type to fulfill the user’s request is most important. |
| 105 | +
|
| 106 | + SIMPLE Query: |
| 107 | + - Cheap and fast (on average, 10x cheaper and 3x faster than complex queries). |
| 108 | + - Suitable for straightforward questions such as "What is the latest version of Python?" |
| 109 | + - Pricing: $1/M input tokens, $1/M output tokens. |
| 110 | +
|
| 111 | + COMPLEX Query: |
| 112 | + - Slower and more expensive (on average, 10x more expensive and 3x slower). |
| 113 | + - Suitable for tasks requiring multiple steps of reasoning or deep analysis, such as "Analyze the attached code to examine the current status of a specific library and create a migration plan." |
| 114 | + - Pricing: $1/M input tokens, $5/M output tokens. |
| 115 | +
|
| 116 | + Instructions: |
| 117 | + - When reviewing the user’s request, if you find anything unexpected, uncertain, or questionable, do not hesitate to use the "ask_perplexity" tool to consult Perplexity. |
| 118 | + - Since Perplexity is also an LLM, prompt engineering techniques are paramount. |
| 119 | + - Remember the basics of prompt engineering, such as providing clear instructions, sufficient context, and examples. |
| 120 | + - Include as much context and relevant files as possible to smoothly fulfill the user’s request. |
| 121 | +
|
| 122 | + Note: All queries must be in English for optimal results. |
| 123 | + """ |
| 124 | + ), |
| 125 | +) |
| 126 | +async def ask_perplexity( |
| 127 | + query: str = Field(description="The query to search for"), |
| 128 | + query_type: Literal["simple", "complex"] = Field(description="Type of query to determine model selection"), |
| 129 | + attachment_paths: list[str] = Field( |
| 130 | + description="An optional list of absolute file paths to attach as context for the search query", |
| 131 | + ), |
| 132 | +) -> str: |
| 133 | + """ |
| 134 | + Performs an advanced search using the appropriate API provider and model. |
| 135 | +
|
| 136 | + This function processes any attached files by reading their contents and formatting them |
| 137 | + into XML before appending them to the original query. The combined query is then sent |
| 138 | + to either OpenRouter or Perplexity API based on the available configuration. |
| 139 | +
|
| 140 | + Args: |
| 141 | + query: The search query text |
| 142 | + query_type: Query complexity type ('simple' or 'complex') |
| 143 | + attachment_paths: Optional list of files to include as context |
| 144 | +
|
| 145 | + Returns: |
| 146 | + str: XML-formatted result containing reasoning (if available) and answer |
| 147 | +
|
| 148 | + Raises: |
| 149 | + ValueError: If the query is empty or attachments cannot be processed |
| 150 | + APIKeyError: If no API provider is configured |
| 151 | + """ |
| 152 | + if not query: |
| 153 | + raise ValueError("Query must not be empty") |
| 154 | + |
| 155 | + # Process any file attachments and get the XML string |
| 156 | + attachments_xml = "" |
| 157 | + if attachment_paths: |
| 158 | + attachments_xml = process_attachments(attachment_paths) |
| 159 | + |
| 160 | + # Combine the original query with the attachment contents |
| 161 | + if query_type == "complex": |
| 162 | + query = textwrap.dedent( |
| 163 | + f""" |
| 164 | + <system> |
| 165 | + Think or reason about the user's words in as much detail as possible. Summarize everything thoroughly. |
| 166 | + List all the elements that need to be considered regarding the user’s question or prompt. |
| 167 | +
|
| 168 | + <idea-reflection> |
| 169 | + Form your opinions about these elements from an objective standpoint, avoiding an overly pessimistic or overly optimistic view. Opinions should be specific and realistic. |
| 170 | + Then logically verify these opinions once more. If they hold up, proceed to the next thought; if not, re-examine them. |
| 171 | + </idea-reflection> |
| 172 | +
|
| 173 | + By carrying out this reflection process, you can accumulate opinions that have been logically reviewed and verified. |
| 174 | + Finally, combine these logically validated pieces of reasoning to form your answer. By doing this way, provide responses that are verifiable and logically sound. |
| 175 | + </system> |
| 176 | +
|
| 177 | + <user-request> |
| 178 | + {query} |
| 179 | + </user-request> |
| 180 | + """[1:] |
| 181 | + ) |
| 182 | + query_with_attachments = query + attachments_xml |
| 183 | + |
| 184 | + # Retrieve available providers |
| 185 | + available_providers: list[ProviderType] = [p for p, cfg in PROVIDER_CONFIG.items() if cfg["key"]] |
| 186 | + if not available_providers: |
| 187 | + raise APIKeyError("No API key available") |
| 188 | + provider: ProviderType = available_providers[0] |
| 189 | + config: ModelConfig = PROVIDER_CONFIG[provider] |
| 190 | + |
| 191 | + # Map query_type from string to QueryType enum |
| 192 | + match query_type: |
| 193 | + case "simple": |
| 194 | + query_type_enum = QueryType.SIMPLE |
| 195 | + case "complex": |
| 196 | + query_type_enum = QueryType.COMPLEX |
| 197 | + case _: |
| 198 | + raise ValueError(f"Invalid query type: {query_type}") |
| 199 | + |
| 200 | + model: str = config["models"][query_type_enum] |
| 201 | + include_reasoning = provider == "openrouter" and query_type_enum == QueryType.COMPLEX |
| 202 | + |
| 203 | + # Call the provider with the combined query |
| 204 | + response = await call_provider( |
| 205 | + provider, model, [{"role": "user", "content": query_with_attachments}], include_reasoning |
| 206 | + ) |
| 207 | + # Format the result as raw text with XML-like tags |
| 208 | + result = "" |
| 209 | + |
| 210 | + # Add reasoning if available |
| 211 | + reasoning = response.get("reasoning") |
| 212 | + if reasoning is not None: |
| 213 | + result += f"<think>\n{reasoning}\n</think>\n\n" |
| 214 | + |
| 215 | + # Add answer |
| 216 | + content = response.get("content", "") |
| 217 | + result += f"<answer>\n{content}\n</answer>" |
| 218 | + |
| 219 | + return result |
0 commit comments