Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
bin
.DS_Store
.env
perplexity
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ The server requires a Perplexity API key and supports various configuration opti

### Optional
- `PERPLEXITY_DEFAULT_MODEL`: Default model to use (default: "sonar")
- `sonar`: Fast, cost-effective search for quick facts
- `sonar`: Fast, cost-effective search for quick facts (1200 tokens/sec)
- `sonar-pro`: Comprehensive search with better depth and coverage
- `sonar-reasoning`: Chain-of-Thought reasoning for logical tasks
- `sonar-reasoning-pro`: Advanced reasoning with enhanced capabilities
- `sonar-deep-research`: Most advanced model for research-intensive tasks
- `PERPLEXITY_MAX_TOKENS`: Maximum tokens in response (default: 1024)
- `PERPLEXITY_TEMPERATURE`: Response randomness 0-2 (default: 0.2)
- `PERPLEXITY_TOP_P`: Nucleus sampling parameter (default: 0.9)
Expand Down Expand Up @@ -373,6 +376,32 @@ The server handles various error conditions:

Errors are returned with descriptive messages to help diagnose issues.

## What's New in 2025

This MCP server has been updated to support the latest Perplexity API features:

### New Models (January-August 2025)
- **sonar-reasoning**: Chain-of-Thought reasoning for logical tasks
- **sonar-reasoning-pro**: Advanced reasoning capabilities
- **sonar-deep-research**: Research-intensive tasks with async support

### Enhanced Search Features
- **Academic Mode** (June 2025): Native `search_mode: "academic"` for peer-reviewed sources
- **SEC Filings** (July 2025): Direct SEC domain filtering with `search_domain: "sec"`
- **Latest Updated Filter** (June 2025): Filter by webpage modification dates
- **Enhanced Search Results**: Detailed metadata including publication dates

### Security Improvements
- Input validation for all parameters
- Path traversal protection in cache operations
- Sanitized error messages
- Updated to use modern Go stdlib (replaced deprecated `ioutil`)

### API Compatibility
- Backwards compatible with existing implementations
- Automatic fallback for deprecated `citations` field
- Support for both old and new API response formats

## License

MIT License - see LICENSE file for details.
22 changes: 15 additions & 7 deletions pkg/cache/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package cache
import (
"crypto/rand"
"fmt"
"io/ioutil"
"math/big"
"os"
"path/filepath"
Expand Down Expand Up @@ -103,13 +102,13 @@ func SaveResult(rootFolder, query, searchType, model, result string, parameters
return "", fmt.Errorf("failed to marshal metadata: %w", err)
}

if err := ioutil.WriteFile(metadataPath, metadataBytes, 0644); err != nil {
if err := os.WriteFile(metadataPath, metadataBytes, 0644); err != nil {
return "", fmt.Errorf("failed to write metadata file: %w", err)
}

// Save result
resultPath := filepath.Join(resultFolder, resultFile)
if err := ioutil.WriteFile(resultPath, []byte(result), 0644); err != nil {
if err := os.WriteFile(resultPath, []byte(result), 0644); err != nil {
return "", fmt.Errorf("failed to write result file: %w", err)
}

Expand All @@ -128,7 +127,7 @@ func ListPreviousQueries(rootFolder string) ([]QueryListItem, error) {
}

// Read all subdirectories
entries, err := ioutil.ReadDir(rootFolder)
entries, err := os.ReadDir(rootFolder)
if err != nil {
return nil, fmt.Errorf("failed to read results directory: %w", err)
}
Expand All @@ -144,7 +143,7 @@ func ListPreviousQueries(rootFolder string) ([]QueryListItem, error) {
metadataPath := filepath.Join(rootFolder, uniqueID, metadataFile)

// Read metadata
metadataBytes, err := ioutil.ReadFile(metadataPath)
metadataBytes, err := os.ReadFile(metadataPath)
if err != nil {
continue // Skip if metadata file doesn't exist or can't be read
}
Expand Down Expand Up @@ -181,15 +180,24 @@ func GetPreviousResult(rootFolder, uniqueID string) (string, error) {
return "", fmt.Errorf("invalid unique ID format: must be %d alphanumeric characters", idLength)
}

resultPath := filepath.Join(rootFolder, uniqueID, resultFile)
// Sanitize paths to prevent path traversal
cleanRootFolder := filepath.Clean(rootFolder)
cleanUniqueID := filepath.Clean(uniqueID)

// Additional check: ensure uniqueID doesn't contain path separators
if strings.Contains(cleanUniqueID, string(filepath.Separator)) {
return "", fmt.Errorf("invalid unique ID: must not contain path separators")
}

resultPath := filepath.Join(cleanRootFolder, cleanUniqueID, resultFile)

// Check if result file exists
if _, err := os.Stat(resultPath); os.IsNotExist(err) {
return "", fmt.Errorf("result with ID '%s' not found", uniqueID)
}

// Read result file
resultBytes, err := ioutil.ReadFile(resultPath)
resultBytes, err := os.ReadFile(resultPath)
if err != nil {
return "", fmt.Errorf("failed to read result file: %w", err)
}
Expand Down
9 changes: 6 additions & 3 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,15 @@ func LoadConfig() (*Config, error) {
// validateModel checks if the model is valid
func validateModel(model string) error {
validModels := map[string]bool{
types.ModelSonar: true,
types.ModelSonarPro: true,
types.ModelSonar: true,
types.ModelSonarPro: true,
types.ModelSonarReasoning: true,
types.ModelSonarReasoningPro: true,
types.ModelSonarDeepResearch: true,
}

if !validModels[model] {
return fmt.Errorf("model '%s' is not valid. Available models: 'sonar' (fast, basic search) or 'sonar-pro' (comprehensive search with better depth)", model)
return fmt.Errorf("model '%s' is not valid. Available models: 'sonar', 'sonar-pro', 'sonar-reasoning', 'sonar-reasoning-pro', 'sonar-deep-research'", model)
}
return nil
}
Expand Down
3 changes: 2 additions & 1 deletion pkg/search/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ func (c *Client) callAPI(ctx context.Context, req *types.PerplexityRequest) (*ty
if resp.StatusCode != http.StatusOK {
var errResp types.ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
// Sanitize error: don't expose raw response body
return nil, fmt.Errorf("API error (status %d): failed to parse error response", resp.StatusCode)
}
return nil, handleAPIError(resp.StatusCode, &errResp)
}
Expand Down
92 changes: 80 additions & 12 deletions pkg/search/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,51 @@ type Searcher struct {
// NewSearcher creates a new searcher instance
func NewSearcher(cfg *config.Config) (*Searcher, error) {
client := NewClient(cfg.APIKey, cfg.Timeout)

return &Searcher{
client: client,
config: cfg,
}, nil
}

// validateSearchParams validates search parameters for security and correctness
func validateSearchParams(params *SearchParams) error {
// Validate query length (max 10000 characters to prevent abuse)
if len(params.Query) == 0 {
return fmt.Errorf("query cannot be empty")
}
if len(params.Query) > 10000 {
return fmt.Errorf("query too long: maximum 10000 characters")
}

// Validate domain filters (max 100 domains to prevent abuse)
if len(params.SearchDomainFilter) > 100 {
return fmt.Errorf("too many domain filters: maximum 100 domains")
}
if len(params.SearchExcludeDomains) > 100 {
return fmt.Errorf("too many exclude domains: maximum 100 domains")
}

// Validate temperature if specified
if params.Temperature != nil && (*params.Temperature < 0 || *params.Temperature > 2) {
return fmt.Errorf("temperature must be between 0 and 2")
}

// Validate max tokens if specified
if params.MaxTokens != nil && (*params.MaxTokens < 1 || *params.MaxTokens > 100000) {
return fmt.Errorf("max_tokens must be between 1 and 100000")
}

return nil
}

// Search performs a general web search
func (s *Searcher) Search(ctx context.Context, params *SearchParams) (string, error) {
// Validate parameters
if err := validateSearchParams(params); err != nil {
return "", fmt.Errorf("invalid search parameters: %w", err)
}

// Build request with default model for general search
req := s.buildRequest(params, s.config.DefaultModel)

Expand All @@ -49,8 +85,13 @@ func (s *Searcher) Search(ctx context.Context, params *SearchParams) (string, er
return s.formatResponseWithCache(resp, params), nil
}

// AcademicSearch performs an academic-focused search
// AcademicSearch performs an academic-focused search using native search_mode: "academic"
func (s *Searcher) AcademicSearch(ctx context.Context, params *SearchParams) (string, error) {
// Validate parameters
if err := validateSearchParams(params); err != nil {
return "", fmt.Errorf("invalid search parameters: %w", err)
}

// Use sonar-pro model for academic search if not specified
if params.Model == "" {
params.Model = types.ModelSonarPro
Expand All @@ -59,9 +100,13 @@ func (s *Searcher) AcademicSearch(ctx context.Context, params *SearchParams) (st
// Build request
req := s.buildRequest(params, s.config.DefaultModel)

// Set academic search mode
// Use native academic search mode (new API feature from June 2025)
req.SearchMode = "academic"
req.SearchContextSize = 10 // Higher context size for academic content

// Higher context size for academic content (if not specified)
if req.SearchContextSize == 0 {
req.SearchContextSize = 10
}

// Handle subject area if provided
if params.SubjectArea != "" {
Expand All @@ -77,8 +122,13 @@ func (s *Searcher) AcademicSearch(ctx context.Context, params *SearchParams) (st
return s.formatResponseWithCache(resp, params), nil
}

// FinancialSearch performs a financial/SEC filing focused search
// FinancialSearch performs a financial/SEC filing focused search with native SEC domain support
func (s *Searcher) FinancialSearch(ctx context.Context, params *SearchParams) (string, error) {
// Validate parameters
if err := validateSearchParams(params); err != nil {
return "", fmt.Errorf("invalid search parameters: %w", err)
}

// Use sonar-pro model for financial search if not specified
if params.Model == "" {
params.Model = types.ModelSonarPro
Expand All @@ -87,6 +137,11 @@ func (s *Searcher) FinancialSearch(ctx context.Context, params *SearchParams) (s
// Build request
req := s.buildRequest(params, s.config.DefaultModel)

// Use native SEC domain filter if report type is specified (new API feature from July 2025)
if params.ReportType != "" || params.Ticker != "" {
req.SearchDomain = "sec"
}

// Handle financial-specific parameters
var contextAdditions []string
if params.Ticker != "" {
Expand Down Expand Up @@ -122,6 +177,11 @@ func (s *Searcher) FinancialSearch(ctx context.Context, params *SearchParams) (s

// FilteredSearch performs an advanced search with comprehensive filtering options
func (s *Searcher) FilteredSearch(ctx context.Context, params *SearchParams) (string, error) {
// Validate parameters
if err := validateSearchParams(params); err != nil {
return "", fmt.Errorf("invalid search parameters: %w", err)
}

// Use sonar-pro model for filtered search if not specified
if params.Model == "" {
params.Model = types.ModelSonarPro
Expand Down Expand Up @@ -293,24 +353,32 @@ func (s *Searcher) formatResponse(resp *types.PerplexityResponse) string {

content := resp.Choices[0].Message.Content

// Always append source URLs if available (for LLM to fetch if needed)
if len(resp.Citations) > 0 {
// Prefer search_results (new API format) over citations (deprecated)
if len(resp.SearchResults) > 0 {
// Extract URLs for quick reference
content += "\n\n## Source URLs\n"
for i, url := range resp.Citations {
content += fmt.Sprintf("%d. %s\n", i+1, url)
for i, result := range resp.SearchResults {
content += fmt.Sprintf("%d. %s\n", i+1, result.URL)
}
}

// Include detailed search results if available
if len(resp.SearchResults) > 0 {
// Include detailed search results with metadata
content += "\n\n## Detailed Sources\n"
for i, result := range resp.SearchResults {
content += fmt.Sprintf("\n%d. **%s**\n", i+1, result.Title)
content += fmt.Sprintf(" URL: %s\n", result.URL)
if result.PublicationDate != "" {
content += fmt.Sprintf(" Published: %s\n", result.PublicationDate)
}
if result.Snippet != "" {
content += fmt.Sprintf(" Snippet: %s\n", result.Snippet)
}
}
} else if len(resp.Citations) > 0 {
// Fallback to citations if search_results not available (legacy API response)
content += "\n\n## Source URLs\n"
for i, url := range resp.Citations {
content += fmt.Sprintf("%d. %s\n", i+1, url)
}
}

// Append related questions if available
Expand Down
26 changes: 18 additions & 8 deletions pkg/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package types

// Model constants
const (
ModelSonar = "sonar"
ModelSonarPro = "sonar-pro"
ModelSonar = "sonar"
ModelSonarPro = "sonar-pro"
ModelSonarReasoning = "sonar-reasoning"
ModelSonarReasoningPro = "sonar-reasoning-pro"
ModelSonarDeepResearch = "sonar-deep-research"
)

// Recency filter constants
Expand Down Expand Up @@ -45,18 +48,21 @@ type PerplexityRequest struct {
Stream bool `json:"stream,omitempty"`
PresencePenalty float64 `json:"presence_penalty,omitempty"`
FrequencyPenalty float64 `json:"frequency_penalty,omitempty"`
SearchDomain string `json:"search_domain,omitempty"` // New: "sec" for SEC filings
SearchDomainFilter []string `json:"search_domain_filter,omitempty"`
SearchExcludeDomains []string `json:"search_exclude_domains,omitempty"`
ReturnImages bool `json:"return_images,omitempty"`
ReturnRelatedQuestions bool `json:"return_related_questions,omitempty"`
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
ReturnCitations bool `json:"return_citations"`
CitationQuality string `json:"citation_quality,omitempty"`
SearchMode string `json:"search_mode,omitempty"`
SearchMode string `json:"search_mode,omitempty"` // New: "academic" for academic filtering
DateRangeStart string `json:"date_range_start,omitempty"`
DateRangeEnd string `json:"date_range_end,omitempty"`
LatestUpdated string `json:"latest_updated,omitempty"` // New: filter by webpage modification date
Location string `json:"location,omitempty"`
SearchContextSize int `json:"search_context_size,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"` // New: "low", "medium", "high" for deep research
}

// PerplexityResponse represents the response from Perplexity API
Expand All @@ -67,7 +73,7 @@ type PerplexityResponse struct {
Created int64 `json:"created"`
Choices []Choice `json:"choices"`
Usage Usage `json:"usage"`
Citations []string `json:"citations,omitempty"`
Citations []string `json:"citations,omitempty"` // Deprecated: use SearchResults instead
SearchResults []SearchResult `json:"search_results,omitempty"`
RelatedQuestions []string `json:"related_questions,omitempty"`
}
Expand All @@ -90,9 +96,10 @@ type Usage struct {

// SearchResult represents a search result with citation
type SearchResult struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
PublicationDate string `json:"publication_date,omitempty"` // New: publication date from API
}

// ErrorResponse represents an error response from the API
Expand All @@ -108,6 +115,7 @@ type ErrorResponse struct {
type SearchParameters struct {
Query string `json:"query"`
Model string `json:"model,omitempty"`
SearchDomain string `json:"search_domain,omitempty"` // New: "sec" for SEC filings
SearchDomainFilter []string `json:"search_domain_filter,omitempty"`
SearchExcludeDomains []string `json:"search_exclude_domains,omitempty"`
SearchRecencyFilter string `json:"search_recency_filter,omitempty"`
Expand All @@ -118,12 +126,14 @@ type SearchParameters struct {
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
SearchMode string `json:"search_mode,omitempty"`
SearchMode string `json:"search_mode,omitempty"` // New: "academic" for academic mode
CitationQuality string `json:"citation_quality,omitempty"`
DateRangeStart string `json:"date_range_start,omitempty"`
DateRangeEnd string `json:"date_range_end,omitempty"`
LatestUpdated string `json:"latest_updated,omitempty"` // New: filter by webpage modification date
Location string `json:"location,omitempty"`
SearchContextSize *int `json:"search_context_size,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"` // New: "low", "medium", "high"
}

// AcademicSearchParameters contains parameters specific to academic search
Expand Down