Initial commit

This commit is contained in:
kdusek
2025-12-09 12:13:01 +01:00
commit 8e654ed209
13332 changed files with 2695056 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
"""
EdgarTools MCP Tool Handlers
This module contains workflow-oriented tool handlers for the MCP server.
"""
from edgar.ai.mcp.tools.utils import (
check_output_size,
format_error_with_suggestions,
)
__all__ = [
"check_output_size",
"format_error_with_suggestions",
]

View File

@@ -0,0 +1,192 @@
"""
Company Research Tool Handler
Provides comprehensive company intelligence including profile,
financials, recent activity, and ownership information.
"""
import logging
from typing import Any
from mcp.types import TextContent
from edgar import Company
from edgar.ai.mcp.tools.utils import (
build_company_profile,
check_output_size,
format_error_with_suggestions,
)
logger = logging.getLogger(__name__)
async def handle_company_research(args: dict[str, Any]) -> list[TextContent]:
"""
Handle company research tool requests.
Provides comprehensive company intelligence in one call, combining:
- Company profile (name, CIK, ticker, industry)
- Latest financial information (optional)
- Recent filing activity (optional)
- Ownership highlights (optional)
Args:
args: Tool arguments containing:
- identifier (required): Company ticker, CIK, or name
- include_financials (default True): Include latest financials
- include_filings (default True): Include recent filing summary
- include_ownership (default False): Include ownership highlights
- detail_level (default "standard"): minimal/standard/detailed
Returns:
List containing TextContent with company research results
"""
identifier = args.get("identifier")
detail_level = args.get("detail_level", "standard")
include_financials = args.get("include_financials", True)
include_filings = args.get("include_filings", True)
include_ownership = args.get("include_ownership", False)
if not identifier:
return [TextContent(
type="text",
text="Error: identifier parameter is required"
)]
try:
# Get company
company = Company(identifier)
# Build response parts
response_parts = []
# 1. Company profile
profile = build_company_profile(company, detail_level)
response_parts.append(profile)
# 2. Latest financials (if requested)
if include_financials:
try:
financials = extract_latest_financials(company, detail_level)
if financials:
response_parts.append("\n\nLatest Financials:")
response_parts.append(financials)
except Exception as e:
logger.warning(f"Could not retrieve financials: {e}")
response_parts.append(f"\n\nFinancials: Not available ({str(e)})")
# 3. Recent filings (if requested)
if include_filings:
try:
filings = recent_filing_summary(company, detail_level)
if filings:
response_parts.append("\n\nRecent Filings:")
response_parts.append(filings)
except Exception as e:
logger.warning(f"Could not retrieve filings: {e}")
response_parts.append(f"\n\nRecent Filings: Not available ({str(e)})")
# 4. Ownership highlights (if requested)
if include_ownership:
try:
ownership = ownership_highlights(company)
if ownership:
response_parts.append("\n\nOwnership Highlights:")
response_parts.append(ownership)
except Exception as e:
logger.warning(f"Could not retrieve ownership: {e}")
response_parts.append(f"\n\nOwnership: Not available ({str(e)})")
# Combine response
response_text = "\n".join(response_parts)
# Check output size and truncate if needed
response_text = check_output_size(response_text)
return [TextContent(type="text", text=response_text)]
except Exception as e:
logger.error(f"Error in company research: {e}", exc_info=True)
return [TextContent(
type="text",
text=format_error_with_suggestions(e)
)]
def extract_latest_financials(company: Any, detail_level: str = "standard") -> str:
"""
Extract latest financial information for a company.
Args:
company: Company object
detail_level: Level of detail to include
Returns:
Formatted financial summary
"""
try:
# Get income statement with 3 periods for trend analysis (annual) with concise format for LLM
stmt = company.income_statement(periods=3, annual=True, concise_format=True)
if detail_level == "minimal":
# Just key metrics
parts = ["Latest Annual Period"]
# TODO: Extract specific metrics once we understand the API better
return stmt.to_llm_string()
else:
# Standard or detailed
return stmt.to_llm_string()
except Exception as e:
logger.warning(f"Could not extract financials: {e}")
return ""
def recent_filing_summary(company: Any, detail_level: str = "standard") -> str:
"""
Get summary of recent filing activity.
Args:
company: Company object
detail_level: Level of detail to include
Returns:
Formatted filing summary
"""
try:
# Get recent filings (last 5)
filings = company.get_filings(limit=5)
if not filings:
return "No recent filings found"
parts = []
for filing in filings:
if detail_level == "minimal":
parts.append(f"- {filing.form} ({filing.filing_date})")
else:
parts.append(f"- {filing.form} - {filing.filing_date}")
if hasattr(filing, 'description') and filing.description:
parts.append(f" {filing.description}")
return "\n".join(parts)
except Exception as e:
logger.warning(f"Could not retrieve filings: {e}")
return ""
def ownership_highlights(company: Any) -> str:
"""
Get ownership highlights (insider/institutional activity).
Args:
company: Company object
Returns:
Formatted ownership summary
"""
# TODO: Implement once we understand ownership data access
# This might require analyzing Form 4 (insider) and 13F (institutional) filings
logger.info("Ownership highlights not yet implemented")
return "Ownership data: Feature not yet implemented"

View File

@@ -0,0 +1,106 @@
"""
Financial Analysis Tool Handler
Provides multi-period financial statement analysis.
"""
import logging
from typing import Any
from mcp.types import TextContent
from edgar import Company
from edgar.ai.mcp.tools.utils import (
check_output_size,
format_error_with_suggestions,
)
logger = logging.getLogger(__name__)
async def handle_analyze_financials(args: dict[str, Any]) -> list[TextContent]:
"""
Handle financial analysis tool requests.
Provides multi-period financial statement analysis using Company
convenience methods (income_statement, balance_sheet, cash_flow).
Args:
args: Tool arguments containing:
- company (required): Company ticker, CIK, or name
- periods (default 4): Number of periods to analyze
- annual (default True): Annual (true) or quarterly (false)
- statement_types (default ["income"]): Statements to include
Returns:
List containing TextContent with financial analysis results
"""
company_id = args.get("company")
periods = args.get("periods", 4)
annual = args.get("annual", True)
statement_types = args.get("statement_types", ["income"])
if not company_id:
return [TextContent(
type="text",
text="Error: company parameter is required"
)]
try:
# Get company
company = Company(company_id)
# Extract requested statements
response_parts = []
response_parts.append(f"Financial Analysis: {company.name}")
response_parts.append(f"Periods: {periods} {'Annual' if annual else 'Quarterly'}")
response_parts.append("")
# Process each requested statement type
if "income" in statement_types:
try:
stmt = company.income_statement(periods=periods, annual=annual, concise_format=True)
response_parts.append("=== Income Statement ===")
response_parts.append(stmt.to_llm_string())
response_parts.append("")
except Exception as e:
logger.warning(f"Could not retrieve income statement: {e}")
response_parts.append(f"Income Statement: Not available ({str(e)})")
response_parts.append("")
if "balance" in statement_types:
try:
stmt = company.balance_sheet(periods=periods, annual=annual, concise_format=True)
response_parts.append("=== Balance Sheet ===")
response_parts.append(stmt.to_llm_string())
response_parts.append("")
except Exception as e:
logger.warning(f"Could not retrieve balance sheet: {e}")
response_parts.append(f"Balance Sheet: Not available ({str(e)})")
response_parts.append("")
if "cash_flow" in statement_types:
try:
stmt = company.cash_flow(periods=periods, annual=annual, concise_format=True)
response_parts.append("=== Cash Flow Statement ===")
response_parts.append(stmt.to_llm_string())
response_parts.append("")
except Exception as e:
logger.warning(f"Could not retrieve cash flow: {e}")
response_parts.append(f"Cash Flow: Not available ({str(e)})")
response_parts.append("")
# Combine response
response_text = "\n".join(response_parts)
# Check output size and truncate if needed
response_text = check_output_size(response_text, max_tokens=3000) # Larger limit for financials
return [TextContent(type="text", text=response_text)]
except Exception as e:
logger.error(f"Error in financial analysis: {e}", exc_info=True)
return [TextContent(
type="text",
text=format_error_with_suggestions(e)
)]

View File

@@ -0,0 +1,238 @@
"""
Industry Analysis Tool Handlers
Provides industry sector analysis and competitive benchmarking capabilities.
"""
import logging
from typing import Any
from mcp.types import TextContent
from edgar import Company
from edgar.ai.mcp.tools.utils import (
check_output_size,
format_error_with_suggestions,
)
logger = logging.getLogger(__name__)
# Industry function mapping
INDUSTRY_FUNCTIONS = {
"pharmaceuticals": "get_pharmaceutical_companies",
"biotechnology": "get_biotechnology_companies",
"software": "get_software_companies",
"semiconductors": "get_semiconductor_companies",
"banking": "get_banking_companies",
"investment": "get_investment_companies",
"insurance": "get_insurance_companies",
"real_estate": "get_real_estate_companies",
"oil_gas": "get_oil_gas_companies",
"retail": "get_retail_companies",
}
async def handle_industry_overview(args: dict[str, Any]) -> list[TextContent]:
"""
Handle industry overview tool requests.
Provides overview of an industry sector including:
- Total company count
- SIC code(s)
- Major public companies
- Industry description
Args:
args: Tool arguments containing:
- industry (required): Industry sector name
- include_top_companies (default True): Include major companies
- limit (default 10): Number of top companies to show
Returns:
List containing TextContent with industry overview
"""
industry = args.get("industry")
include_top = args.get("include_top_companies", True)
limit = args.get("limit", 10)
if not industry:
return [TextContent(
type="text",
text="Error: industry parameter is required"
)]
if industry not in INDUSTRY_FUNCTIONS:
return [TextContent(
type="text",
text=f"Error: Unknown industry '{industry}'. Must be one of: {', '.join(INDUSTRY_FUNCTIONS.keys())}"
)]
try:
# Import and call the appropriate industry function
from edgar.ai import helpers
function_name = INDUSTRY_FUNCTIONS[industry]
get_companies = getattr(helpers, function_name)
companies = get_companies()
# Build response
response_parts = [
f"# {industry.replace('_', ' ').title()} Industry Overview",
"",
f"**Total Companies**: {len(companies):,}",
]
# Get unique SIC codes
sic_codes = sorted(companies['sic'].unique().tolist())
if len(sic_codes) == 1:
response_parts.append(f"**SIC Code**: {sic_codes[0]}")
else:
response_parts.append(f"**SIC Codes**: {', '.join(map(str, sic_codes))}")
# Get primary description (from first company)
if len(companies) > 0 and 'sic_description' in companies.columns:
primary_desc = companies['sic_description'].iloc[0]
response_parts.append(f"**Description**: {primary_desc}")
response_parts.append("")
# Add major companies if requested
if include_top and len(companies) > 0:
# Filter to companies with tickers (publicly traded)
public = companies[companies['ticker'].notna()].copy()
if len(public) > 0:
response_parts.append("## Major Public Companies")
response_parts.append("")
# Show top N companies
top_companies = public.head(limit)
for _, row in top_companies.iterrows():
ticker = row['ticker'] if row['ticker'] else 'N/A'
exchange = row['exchange'] if row['exchange'] else 'N/A'
response_parts.append(
f"- **{ticker}** - {row['name']} ({exchange})"
)
else:
response_parts.append("*No public companies found in this sector*")
# Combine response
response_text = "\n".join(response_parts)
# Check output size
response_text = check_output_size(response_text)
return [TextContent(type="text", text=response_text)]
except Exception as e:
logger.error(f"Error in industry overview: {e}", exc_info=True)
return [TextContent(
type="text",
text=format_error_with_suggestions(e)
)]
async def handle_compare_industry_companies(args: dict[str, Any]) -> list[TextContent]:
"""
Handle industry company comparison tool requests.
Compares financial performance of companies within an industry sector.
Args:
args: Tool arguments containing:
- industry (required): Industry sector name
- companies (optional): Specific tickers to compare
- limit (default 5): Number of companies if not specified
- periods (default 3): Number of periods for comparison
- annual (default True): Annual (true) or quarterly (false)
Returns:
List containing TextContent with comparative analysis
"""
industry = args.get("industry")
company_tickers = args.get("companies")
limit = args.get("limit", 5)
periods = args.get("periods", 3)
annual = args.get("annual", True)
if not industry:
return [TextContent(
type="text",
text="Error: industry parameter is required"
)]
if industry not in INDUSTRY_FUNCTIONS:
return [TextContent(
type="text",
text=f"Error: Unknown industry '{industry}'. Must be one of: {', '.join(INDUSTRY_FUNCTIONS.keys())}"
)]
try:
# Import and call the appropriate industry function
from edgar.ai import helpers
function_name = INDUSTRY_FUNCTIONS[industry]
get_companies = getattr(helpers, function_name)
companies = get_companies()
# Select companies
if company_tickers:
# Filter to specified tickers
selected = companies[companies['ticker'].isin(company_tickers)].copy()
if len(selected) == 0:
return [TextContent(
type="text",
text=f"Error: None of the specified tickers found in {industry} industry"
)]
else:
# Use top N companies with tickers
public = companies[companies['ticker'].notna()].copy()
if len(public) == 0:
return [TextContent(
type="text",
text=f"Error: No public companies found in {industry} industry"
)]
selected = public.head(limit)
# Compare financials
response_parts = [
f"# {industry.replace('_', ' ').title()} Industry Comparison",
f"",
f"Comparing {len(selected)} companies over {periods} {'annual' if annual else 'quarterly'} periods",
"",
]
for _, row in selected.iterrows():
ticker = row['ticker']
try:
company = Company(ticker)
stmt = company.income_statement(
periods=periods,
annual=annual,
concise_format=True
)
response_parts.append(f"## {ticker} - {row['name']}")
response_parts.append("")
response_parts.append(stmt.to_llm_string())
response_parts.append("")
except Exception as e:
logger.warning(f"Could not get financials for {ticker}: {e}")
response_parts.append(f"## {ticker} - {row['name']}")
response_parts.append(f"*Financial data not available: {str(e)}*")
response_parts.append("")
# Combine response
response_text = "\n".join(response_parts)
# Check output size (larger limit for comparative data)
response_text = check_output_size(response_text, max_tokens=5000)
return [TextContent(type="text", text=response_text)]
except Exception as e:
logger.error(f"Error in industry comparison: {e}", exc_info=True)
return [TextContent(
type="text",
text=format_error_with_suggestions(e)
)]

View File

@@ -0,0 +1,137 @@
"""
Utility functions for MCP tool handlers.
Provides helper functions for output management, error handling,
and data formatting for MCP responses.
"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
def check_output_size(data: str, max_tokens: int = 2000) -> str:
"""
Prevent context overflow with intelligent summarization.
Estimates token count and truncates/summarizes if needed to stay
within context window limits.
Args:
data: The text data to check
max_tokens: Maximum allowed tokens (default: 2000)
Returns:
Original data if under limit, truncated data otherwise
"""
# Rough estimation: 1 token ≈ 4 characters
estimated_tokens = len(data) / 4
if estimated_tokens > max_tokens:
# Simple truncation with ellipsis
# TODO: Implement smarter summarization in future
char_limit = int(max_tokens * 4 * 0.9) # 90% of limit to be safe
truncated = data[:char_limit]
logger.warning(f"Output truncated: {int(estimated_tokens)} tokens -> {max_tokens} tokens")
return f"{truncated}\n\n... (output truncated to stay within token limit)"
return data
def format_error_with_suggestions(error: Exception) -> str:
"""
Provide helpful error messages with alternatives.
Creates AI-friendly error messages that include specific suggestions
for common error types.
Args:
error: The exception that occurred
Returns:
Formatted error message with suggestions
"""
error_type = type(error).__name__
error_message = str(error)
# Define helpful suggestions for common errors
suggestions_map = {
"CompanyNotFound": [
"Try searching by CIK instead of ticker",
"Use the full company name",
"Check spelling of ticker symbol"
],
"NoFinancialsAvailable": [
"Company may not have filed recent 10-K/10-Q",
"Try include_financials=False for basic info",
"Check filing history with edgar_market_monitor tool"
],
"FileNotFoundError": [
"The requested filing may not be available",
"Try a different form type or date range",
"Verify the company has filed this type of document"
],
"HTTPError": [
"SEC EDGAR website may be temporarily unavailable",
"Check your internet connection",
"Try again in a few moments"
],
"ValueError": [
"Check that all required parameters are provided",
"Verify parameter formats (e.g., valid ticker symbols)",
"Review the tool's parameter documentation"
]
}
suggestions = suggestions_map.get(error_type, [
"Try rephrasing your request",
"Check parameter values",
"Consult the tool documentation"
])
# Format the error response
response_parts = [
f"Error: {error_message}",
f"Error Type: {error_type}",
"",
"Suggestions:"
]
for i, suggestion in enumerate(suggestions, 1):
response_parts.append(f"{i}. {suggestion}")
return "\n".join(response_parts)
def build_company_profile(company: Any, detail_level: str = "standard") -> str:
"""
Build a company profile summary.
Args:
company: Company object
detail_level: Level of detail (minimal/standard/detailed)
Returns:
Formatted company profile text
"""
parts = [f"Company: {company.name}"]
# Add CIK
parts.append(f"CIK: {company.cik}")
# Add ticker if available
if hasattr(company, 'tickers') and company.tickers:
parts.append(f"Ticker: {company.tickers[0]}")
# Add industry/sector if available and detail level permits
if detail_level in ["standard", "detailed"]:
if hasattr(company, 'sic_description'):
parts.append(f"Industry: {company.sic_description}")
# Add description for detailed level
if detail_level == "detailed":
if hasattr(company, 'description') and company.description:
parts.append(f"\nDescription: {company.description}")
return "\n".join(parts)