#
tokens: 5979/50000 14/14 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .env.example
├── .gitignore
├── jest.config.js
├── logo.png
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── config
│   │   └── env.ts
│   ├── index.ts
│   ├── services
│   │   └── tools.ts
│   ├── tools
│   │   ├── __tests__
│   │   │   └── search-stock-news.test.ts
│   │   ├── drama-search.ts
│   │   ├── general-search.ts
│   │   └── search-stock-news.ts
│   └── types
│       └── tools.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------

```
# Tavily API Configuration
TAVILY_API_KEY=your_api_key_here

# Search Configuration
MAX_RESULTS=20
SEARCH_DEPTH=basic
MIN_SCORE=0.4

# Query Templates (comma-separated)
QUERY_TEMPLATES={symbol} ({company_name}) latest stock price movements, {symbol} ({company_name}) earnings reports

# Domain Configuration (comma-separated)
INCLUDE_DOMAINS=https://cafef.vn,https://nguoiquansat.vn
EXCLUDE_DOMAINS=rs.nguoiquansat.vn,en.vneconomy.vn,en.vietstock.vn

# Server Configuration
TRANSPORT_TYPE=stdio
PORT=8080 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# TypeScript
*.tsbuildinfo
dist/
build/
out/

# IDE and editors
.idea/
.vscode/
*.swp
*.swo
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local

# Testing
coverage/

# Debug
.debug/

# Environment variables
.env
.env.*
!.env.example

# Logs
logs/
*.log

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional stylelint cache
.stylelintcache

# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.* 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
<h1 align="center">Search Stock News MCP Server 🚀</h1>

<p align="center">
  <img src="logo.png" width="300" alt="VLD Logo">
</p>

> 🔌 **Compatible with Cline, Cursor, Claude Desktop, and any other MCP Clients!**
> 
> Search Stock News MCP works seamlessly with any MCP client

The Model Context Protocol (MCP) is an open standard that enables AI systems to interact seamlessly with various data sources and tools, facilitating secure, two-way connections.

The Search Stock News MCP server provides:

* Real-time stock news search capabilities via Tavily API
* Multiple customizable search query templates
* Configurable search parameters and filtering
* Domain-specific content filtering
* Type-safe operations with TypeScript

## Prerequisites 🔧

Before you begin, ensure you have:

* Tavily API Key
* Claude Desktop, Cursor, or any MCP-compatible client
* Node.js (v16 or higher)
* Git installed (only needed if using Git installation method)

## Search Stock News MCP Server Installation ⚡

### Running with NPX

```bash
npx -y search-stock-news-mcp@latest
```

### Installing via Smithery

To install Search Stock News MCP Server for Claude Desktop automatically via Smithery:

```bash
npx -y @smithery/cli install search-stock-news-mcp --client claude
```

## Configuring MCP Clients ⚙️

### Configuring Cline 🤖

The easiest way to set up the Search Stock News MCP server in Cline is through the marketplace:

1. Open Cline in VS Code
2. Click on the Cline icon in the sidebar
3. Navigate to the "MCP Servers" tab
4. Search "Search Stock News" and click "install"
5. When prompted, enter your Tavily API key

Alternatively, manually configure the server in Cline:

1. Open the Cline MCP settings file:
```bash
# For macOS:
code ~/Library/Application\ Support/Code/User/globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json

# For Windows:
code %APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json
```

2. Add the Search Stock News server configuration:
```json
{
  "mcpServers": {
    "search-stock-news-mcp": {
      "command": "npx",
      "args": ["-y", "search-stock-news-mcp@latest"],
      "env": {
        "TAVILY_API_KEY": "your-api-key-here"
      },
      "disabled": false,
      "autoApprove": []
    }
  }
}
```

### Configuring Cursor 🖥️

To set up the Search Stock News MCP server in Cursor:

1. Open Cursor Settings
2. Navigate to Features > MCP Servers
3. Click on the "+ Add New MCP Server" button
4. Fill out the following information:
   * **Name**: "search-stock-news-mcp"
   * **Type**: "command"
   * **Command**:
   ```bash
   env TAVILY_API_KEY=your-api-key-here npx -y search-stock-news-mcp@latest
   ```

### Configuring Claude Desktop 🖥️

#### For macOS:
```bash
touch "$HOME/Library/Application Support/Claude/claude_desktop_config.json"
open -e "$HOME/Library/Application Support/Claude/claude_desktop_config.json"
```

#### For Windows:
```bash
code %APPDATA%\Claude\claude_desktop_config.json
```

Add the server configuration:
```json
{
  "mcpServers": {
    "search-stock-news-mcp": {
      "command": "npx",
      "args": ["-y", "search-stock-news-mcp@latest"],
      "env": {
        "TAVILY_API_KEY": "your-api-key-here"
      }
    }
  }
}
```

## Usage Examples 🎯

1. **Basic Stock News Search**:
```json
{
  "symbol": "AAPL",
  "companyName": "Apple Inc.",
  "maxResults": 10
}
```

2. **Advanced Search with Filters**:
```json
{
  "symbol": "TSLA",
  "companyName": "Tesla Inc.",
  "maxResults": 20,
  "searchDepth": "advanced",
  "minScore": 0.6
}
```

3. **Custom Domain Search**:
```json
{
  "symbol": "MSFT",
  "companyName": "Microsoft Corporation",
  "includeDomains": ["reuters.com", "bloomberg.com"]
}
```

## Troubleshooting 🛠️

### Common Issues

1. **Server Not Found**
   * Verify npm installation
   * Check configuration syntax
   * Ensure Node.js is properly installed

2. **API Key Issues**
   * Verify your Tavily API key is valid
   * Check the API key is correctly set in config
   * Ensure no spaces or quotes around the API key

3. **Search Results Issues**
   * Check search parameters are within valid ranges
   * Verify domain filters are correctly formatted
   * Ensure company name and symbol are accurate

## Acknowledgments ✨

* Model Context Protocol for the MCP specification
* Anthropic for Claude Desktop
* Tavily for the News Search API

## License

MIT 
```

--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------

```javascript
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  moduleFileExtensions: ["ts", "js"],
  transform: {
    "^.+\\.ts$": "ts-jest",
  },
  testMatch: ["**/__tests__/**/*.test.ts"],
  moduleNameMapper: {
    "^@/(.*)$": "<rootDir>/src/$1",
  },
}; 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "lib": ["ES2020"],
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": false,
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
} 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "search-stock-news-mcp",
  "version": "1.0.12",
  "description": "MCP server for searching stock news using Tavily API",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "type": "commonjs",
  "bin": {
    "search-stock-news-mcp": "dist/index.js"
  },
  "files": [
    "dist",
    "README.md",
    "LICENSE"
  ],
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "prepare": "npm run build"
  },
  "keywords": [],
  "author": "Hieu TRAN @ Cognitive Stack",
  "license": "MIT",
  "dependencies": {
    "@tavily/core": "^0.4.0",
    "dotenv": "^16.0.0",
    "fastmcp": "^1.27.7",
    "zod": "^3.0.0"
  },
  "devDependencies": {
    "@types/jest": "^29.0.0",
    "@types/node": "^20.0.0",
    "jest": "^29.0.0",
    "ts-jest": "^29.0.0",
    "tsx": "^3.0.0",
    "typescript": "^5.0.0"
  }
}

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/usr/bin/env node
"use strict";

import { FastMCP } from "fastmcp";
import { tools } from "./services/tools";
import { Tool } from "./types/tools";
import dotenv from "dotenv";

// Load environment variables
dotenv.config();

const server = new FastMCP({
  name: "Search Stock News MCP",
  version: "1.0.12",
  roots: {
    enabled: false
  }
});

// Register all tools
tools.forEach((tool) => {
  (server.addTool as Tool)(tool);
});

// Get transport type from environment variable or default to stdio
const transportType = process.env.TRANSPORT_TYPE || "stdio";

async function main() {
  try {
    if (transportType === "sse") {
      await server.start({
        transportType: "sse",
        sse: {
          endpoint: "/sse",
          port: parseInt(process.env.PORT || "8080", 10),
        },
      });
    } else {
      await server.start({
        transportType: "stdio",
      });
    }
  } catch (error) {
    console.error("Failed to start server:", error);
    process.exit(1);
  }
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});
```

--------------------------------------------------------------------------------
/src/tools/general-search.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { tavily } from '@tavily/core';
import { getConfig } from '../config/env';
import { GeneralSearchResult } from '../types/tools';

export default async (
  query: string,
  maxResults: number = 10,
  searchDepth: 'basic' | 'advanced' = 'basic',
  minScore: number = 0.4
): Promise<GeneralSearchResult[]> => {
  const config = getConfig();
  const { apiKey } = config;

  // Initialize Tavily client
  const tvly = tavily({ apiKey });

  try {
    const response = await tvly.search(query, {
      searchDepth,
      maxResults,
      includeDomains: [],
      excludeDomains: []
    });

    // Transform and filter the results
    const filteredResults = response.results
      .map((result) => ({
        title: result.title,
        url: result.url,
        content: result.content,
        publishedDate: result.publishedDate,
        score: result.score,
      }))
      .filter(result => result.score >= minScore)
      .sort((a, b) => b.score - a.score);

    return filteredResults;

  } catch (error) {
    console.error(`Error performing general search: ${error}`);
    throw error;
  }
}; 
```

--------------------------------------------------------------------------------
/src/types/tools.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { z } from "zod";
import { FastMCP } from "fastmcp";

export type ToolConfig = {
  name: string;
  description: string;
  parameters: z.ZodObject<any>;
  execute: (args: any) => Promise<string>;
};

export type Tool = FastMCP["addTool"];

export interface StockNewsConfig {
  apiKey: string;
  maxResults: number;
  searchDepth: 'basic' | 'advanced';
  minScore: number;
  queryTemplates: string[];
  includeDomains: string[];
  excludeDomains: string[];
}

export interface StockNewsResult {
  title: string;
  url: string;
  content: string;
  publishedDate: string;
  score: number;
}

export interface GeneralSearchResult {
  title: string;
  url: string;
  content: string;
  publishedDate: string;
  score: number;
}

export interface GeneralSearchConfig {
  apiKey: string;
  maxResults: number;
  searchDepth: 'basic' | 'advanced';
  minScore: number;
}

export interface DramaSearchResult {
  title: string;
  url: string;
  content: string;
  publishedDate: string;
  score: number;
  category: 'legal' | 'scandal' | 'financial' | 'product' | 'workforce' | 'environmental' | 'security' | 'other';
}

export interface DramaSearchConfig {
  apiKey: string;
  maxResults: number;
  searchDepth: 'basic' | 'advanced';
  minScore: number;
  dramaQueryTemplates: string[];
  dramaIncludeDomains: string[];
} 
```

--------------------------------------------------------------------------------
/src/tools/search-stock-news.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { tavily } from '@tavily/core';
import { getConfig } from '../config/env';
import { StockNewsResult } from '../types/tools';

export default async (
  symbol: string,
  companyName: string,
  days: number,
  minScore: number
): Promise<Array<{
  searchQuery: string;
  results: StockNewsResult[];
}>> => {
  const config = getConfig();
  const { 
    apiKey, 
    maxResults, 
    searchDepth,
    queryTemplates,
    includeDomains,
    excludeDomains
  } = config;

  // Initialize Tavily client
  const tvly = tavily({ apiKey });

  const allResults = [];

  // Process each template
  for (const template of queryTemplates) {
    const searchQuery = template
      .replace('{symbol}', symbol)
      .replace('{company_name}', companyName);

    try {
      const response = await tvly.search(searchQuery, {
        searchDepth,
        topics: ['news'],
        timeRange: "d",
        days,
        maxResults,
        includeDomains,
        excludeDomains
      });

      // Transform and filter the results
      const filteredResults = response.results
        .map((result) => ({
          title: result.title,
          url: result.url,
          content: result.content,
          publishedDate: result.publishedDate,
          score: result.score,
        }))
        .filter(result => result.score >= minScore)
        .sort((a, b) => b.score - a.score);

      allResults.push({
        searchQuery,
        results: filteredResults
      });

    } catch (error) {
      console.error(`Error searching with template: ${template}`, error);
      // Continue with other templates even if one fails
      continue;
    }
  }

  return allResults;
}; 
```

--------------------------------------------------------------------------------
/src/services/tools.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { z } from "zod";
import searchStockNews from "../tools/search-stock-news";
import generalSearch from "../tools/general-search";
import dramaSearch from "../tools/drama-search";
import { ToolConfig } from "../types/tools";

export const tools: ToolConfig[] = [
  {
    name: "search-stock-news",
    description: "Search for stock-related news using Tavily API",
    parameters: z.object({
      symbol: z.string().describe("Stock symbol (e.g., AAPL)"),
      companyName: z.string().describe("Company name (e.g., Apple Inc.)"),
      maxResults: z.number().optional().describe("Maximum number of results to return"),
      searchDepth: z.enum(["basic", "advanced"]).optional().describe("Search depth level"),
      minScore: z.number().optional().describe("Minimum relevance score threshold")
    }),
    execute: async (args) => {
      const results = await searchStockNews(
        args.symbol,
        args.companyName,
        args.maxResults || 10,
        args.minScore || 0.4
      );
      return JSON.stringify(results, null, 2);
    }
  },
  {
    name: "general-search",
    description: "Perform a general web search using Tavily API",
    parameters: z.object({
      query: z.string().describe("Search query"),
      maxResults: z.number().optional().describe("Maximum number of results to return"),
      searchDepth: z.enum(["basic", "advanced"]).optional().describe("Search depth level"),
      minScore: z.number().optional().describe("Minimum relevance score threshold")
    }),
    execute: async (args) => {
      const results = await generalSearch(
        args.query,
        args.maxResults || 10,
        args.searchDepth || "basic",
        args.minScore || 0.4
      );
      return JSON.stringify(results, null, 2);
    }
  }
]; 
```

--------------------------------------------------------------------------------
/src/tools/__tests__/search-stock-news.test.ts:
--------------------------------------------------------------------------------

```typescript
import searchStockNews from "../search-stock-news";

jest.mock("../../config/env", () => ({
  getConfig: jest.fn().mockReturnValue({
    apiKey: "test-api-key",
    maxResults: 20,
    searchDepth: "basic",
    minScore: 0.4,
    queryTemplates: [
      '{symbol} ({company_name}) latest stock price movements'
    ],
    includeDomains: ['https://test.com'],
    excludeDomains: ['https://exclude.com']
  }),
}));

jest.mock('@tavily/core', () => ({
  tavily: jest.fn().mockReturnValue({
    search: jest.fn().mockResolvedValue({
      results: [
        {
          title: "Test News",
          url: "https://test.com/news",
          content: "Test content",
          publishedDate: "2024-01-01",
          score: 0.8
        }
      ]
    })
  })
}));

describe("searchStockNews", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it("should search for stock news with default configuration", async () => {
    const result = await searchStockNews("AAPL", "Apple Inc.");
    
    expect(result).toHaveLength(1);
    expect(result[0].template).toBe('{symbol} ({company_name}) latest stock price movements');
    expect(result[0].results).toHaveLength(1);
    expect(result[0].results[0].title).toBe("Test News");
  });

  it("should handle custom configuration", async () => {
    const customConfig = {
      maxResults: 10,
      searchDepth: "advanced" as const,
      minScore: 0.5,
      queryTemplates: ["Custom template"],
      includeDomains: ["https://custom.com"],
      excludeDomains: ["https://exclude-custom.com"]
    };

    const result = await searchStockNews("AAPL", "Apple Inc.", customConfig);
    
    expect(result).toHaveLength(1);
    expect(result[0].template).toBe("Custom template");
  });

  it("should handle search errors gracefully", async () => {
    const mockTavily = require('@tavily/core').tavily;
    mockTavily.mockReturnValueOnce({
      search: jest.fn().mockRejectedValue(new Error("Search failed"))
    });

    const result = await searchStockNews("AAPL", "Apple Inc.");
    
    expect(result).toHaveLength(0);
  });
}); 
```

--------------------------------------------------------------------------------
/src/config/env.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { StockNewsConfig, DramaSearchConfig } from "../types/tools";

const parseCommaSeparatedString = (str: string | undefined, defaultValue: string[]): string[] => {
  if (!str) return defaultValue;
  return str.split(',').map(item => item.trim()).filter(Boolean);
};

export const getConfig = (): StockNewsConfig & DramaSearchConfig => {
  const apiKey = process.env.TAVILY_API_KEY;
  if (!apiKey) {
    throw new Error("TAVILY_API_KEY environment variable is not set");
  }

  const maxResults = parseInt(process.env.MAX_RESULTS || "20", 10);
  const searchDepth = (process.env.SEARCH_DEPTH || "basic") as 'basic' | 'advanced';
  const minScore = parseFloat(process.env.MIN_SCORE || "0.4");
  
  // Stock News default templates
  const defaultStockQueryTemplates = [
    '{symbol} ({company_name}) latest stock price movements, trading volume analysis, and market sentiment',
    '{symbol} ({company_name}) earnings reports, revenue guidance, and financial metrics',
    '{symbol} ({company_name}) company news, regulatory filings, and material events'
  ];
  
  // Drama Search default templates
  const defaultDramaQueryTemplates = [
    "{company_name} ({symbol}) controversy",
    "{company_name} ({symbol}) scandal",
    "{company_name} ({symbol}) lawsuit",
    "{company_name} ({symbol}) legal issues",
    "{company_name} ({symbol}) controversy latest news",
    "{company_name} ({symbol}) scandal latest news",
    "{company_name} ({symbol}) drama",
    "{company_name} ({symbol}) problems",
    "{company_name} ({symbol}) issues",
    "{company_name} ({symbol}) negative news"
  ];
  
  const defaultIncludeDomains = [
    'https://cafef.vn',
    'https://nguoiquansat.vn'
  ];
  
  const defaultExcludeDomains = [
    'rs.nguoiquansat.vn',
    'en.vneconomy.vn',
    'en.vietstock.vn'
  ];

  // Drama Search default domains
  const defaultDramaIncludeDomains = [
    'https://reuters.com',
    'https://bloomberg.com',
    'https://wsj.com',
    'https://ft.com',
    'https://cnbc.com',
    'https://cafef.vn',
    'https://nguoiquansat.vn',
    'https://voz.vn/f/%C4%90iem-bao.33/'
  ];

  return {
    apiKey,
    maxResults,
    searchDepth,
    minScore,
    // Stock News config
    queryTemplates: parseCommaSeparatedString(process.env.QUERY_TEMPLATES, defaultStockQueryTemplates),
    includeDomains: parseCommaSeparatedString(process.env.INCLUDE_DOMAINS, defaultIncludeDomains),
    excludeDomains: parseCommaSeparatedString(process.env.EXCLUDE_DOMAINS, defaultExcludeDomains),
    // Drama Search config
    dramaQueryTemplates: parseCommaSeparatedString(process.env.DRAMA_QUERY_TEMPLATES, defaultDramaQueryTemplates),
    dramaIncludeDomains: parseCommaSeparatedString(process.env.DRAMA_INCLUDE_DOMAINS, defaultDramaIncludeDomains)
  };
}; 
```

--------------------------------------------------------------------------------
/src/tools/drama-search.ts:
--------------------------------------------------------------------------------

```typescript
"use strict";

import { tavily } from '@tavily/core';
import { getConfig } from '../config/env';
import { DramaSearchResult } from '../types/tools';

export default async (
  symbol: string,
  companyName: string,
  maxResults: number = 10,
  searchDepth: 'basic' | 'advanced' = 'basic',
  minScore: number = 0.4
): Promise<Array<{
  searchQuery: string;
  results: DramaSearchResult[];
}>> => {
  const config = getConfig();
  const { apiKey, dramaQueryTemplates, dramaIncludeDomains } = config;

  // Initialize Tavily client
  const tvly = tavily({ apiKey });

  const allResults = [];

  // Process each template
  for (const template of dramaQueryTemplates) {
    const searchQuery = template
      .replace('{symbol}', symbol)
      .replace('{company_name}', companyName);

    try {
      const response = await tvly.search(searchQuery, {
        searchDepth,
        maxResults,
        includeDomains: dramaIncludeDomains,
        excludeDomains: []
      });

      // Transform and filter the results
      const filteredResults = response.results
        .map((result) => ({
          title: result.title,
          url: result.url,
          content: result.content,
          publishedDate: result.publishedDate,
          score: result.score,
          category: determineDramaCategory(result.title, result.content)
        }))
        .filter(result => result.score >= minScore)
        .sort((a, b) => b.score - a.score);

      allResults.push({
        searchQuery,
        results: filteredResults
      });

    } catch (error) {
      console.error(`Error searching with template: ${template}`, error);
      // Continue with other templates even if one fails
      continue;
    }
  }

  return allResults;
};

// Helper function to categorize drama/controversy type
function determineDramaCategory(title: string, content: string): "legal" | "scandal" | "financial" | "product" | "workforce" | "environmental" | "security" | "other" {
  const text = (title + ' ' + content).toLowerCase();
  
  if (text.includes('lawsuit') || text.includes('legal') || text.includes('court')) {
    return 'legal';
  }
  if (text.includes('scandal') || text.includes('controversy')) {
    return 'scandal';
  }
  if (text.includes('financial') || text.includes('earnings') || text.includes('stock')) {
    return 'financial';
  }
  if (text.includes('product') || text.includes('service') || text.includes('quality')) {
    return 'product';
  }
  if (text.includes('employee') || text.includes('workforce') || text.includes('staff')) {
    return 'workforce';
  }
  if (text.includes('environmental') || text.includes('climate') || text.includes('pollution')) {
    return 'environmental';
  }
  if (text.includes('security') || text.includes('breach') || text.includes('hack')) {
    return 'security';
  }
  
  return 'other';
} 
```