From 1d0a34ed1c038c2fa8338a923f55dd7fc9512709 Mon Sep 17 00:00:00 2001 From: Theodor Date: Fri, 25 Sep 2026 20:25:25 +0200 Subject: [PATCH] Add a search tool that resolves names to tickers and CIKs Agents often start with a company or person's name and no identifier. The new `search` tool wraps GET /v1/search (companies by ticker or name, insiders by tokenised name) and its description frames it as the first call in that situation, before get_company_overview or get_insider_profile. Hand-written rather than generated so the description can steer agents; the "Search" operation is added to HANDLED_BY_HANDWRITTEN so codegen does not emit a duplicate. Tool count 37 -> 38 in tests, README and the server.json description. No version bump. --- CHANGELOG.md | 7 +++++++ README.md | 13 ++++++++++--- codegen/generate.mjs | 1 + server.json | 2 +- src/index.ts | 17 ++++++++++++++++- src/tools/search.ts | 27 +++++++++++++++++++++++++++ src/types.ts | 18 ++++++++++++++++++ test/mcp-test.mjs | 10 +++++++--- 8 files changed, 87 insertions(+), 8 deletions(-) create mode 100644 src/tools/search.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d44e053..7a9855a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ This ensures clients can always see what tools are available in a given version --- +## [Unreleased] + +**Tool count: 38** (+1: `search`). Free tools 23 → 24; gated unchanged. + +### Added +- `search` (hand-written) — resolve a company name, ticker fragment, or a person's name to a ticker or CIK in one call, returning separately-ranked `companies` and `insiders` lists. Free plan. Wraps `GET /v1/search` (backend insiderapi #314, live since 2026-09-25). Framed in `SERVER_INSTRUCTIONS` and its own description as the obvious first call when an agent has a name/fragment but no identifier yet. Kept hand-written (added to `HANDLED_BY_HANDWRITTEN` in `codegen/generate.mjs`) rather than left to codegen so the description could explicitly steer agents to call it before `get_company_overview`/`get_insider_profile`/etc., matching the pattern used for `research_company`. + ## [1.15.0] — 2026-09-24 **Tool count: 37** (+1: `list_schedule13_dg`). Free tools unchanged at 23; gated 13 → 14. diff --git a/README.md b/README.md index 597bb26..e96dd62 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # form4api-mcp -> Production-grade SEC Form 4 insider trading data for any MCP-compatible AI assistant — **amendment-aware, 10b5-1 clean, with Form 144 + institutional 13F-HR overlay, plus congressional STOCK Act trades and insider/Congress convergence** — 35 tools + 6 ready-made research prompts +> Production-grade SEC Form 4 insider trading data for any MCP-compatible AI assistant — **amendment-aware, 10b5-1 clean, with Form 144 + institutional 13F-HR overlay, plus congressional STOCK Act trades and insider/Congress convergence** — 36 tools + 6 ready-made research prompts [![npm version](https://badge.fury.io/js/form4api-mcp.svg)](https://www.npmjs.com/package/form4api-mcp) [![Available on mcp.so](https://img.shields.io/badge/mcp.so-form4api-blue)](https://mcp.so) @@ -136,7 +136,13 @@ FORM4API_KEY=YOUR_API_KEY npx form4api-mcp --- -## Available tools (35) +## Available tools (36) + +### Search + +| Tool | Description | Plan | +|---|---|---| +| `search` | Resolve a company name, ticker fragment, or a person's name to a ticker or CIK — the usual first call when you don't already have an identifier. Returns separately-ranked `companies` and `insiders` lists | Free | ### Form 4 insider trading @@ -273,12 +279,13 @@ The MCP wraps the same backend as all of the above — every fact your LLM cites ## Plans -**22 of the 35 tools work on the free plan, and every tool that is free today stays free.** +**23 of the 36 tools work on the free plan, and every tool that is free today stays free.** New premium capability gets tiered as it ships; nothing that already works on your key is taken away later. | Tool | Free | Pro | Business | |---|---|---|---| +| `search` | ✓ | ✓ | ✓ | | `get_transactions`, `get_recent_filings`, `get_filing` | ✓ | ✓ | ✓ | | `get_insider_profile`, `get_insider_transactions` | ✓ | ✓ | ✓ | | `get_company_overview`, `get_company_insiders` | ✓ | ✓ | ✓ | diff --git a/codegen/generate.mjs b/codegen/generate.mjs index dd593d8..725e357 100644 --- a/codegen/generate.mjs +++ b/codegen/generate.mjs @@ -69,6 +69,7 @@ const HANDLED_BY_HANDWRITTEN = new Set([ 'ListHoldings', // → get_holdings (src/tools/holdings.ts) 'ListManagers', // → get_managers (src/tools/managers.ts) 'GetKeyUsage', // → check_usage (src/tools/usage.ts) + 'Search', // → search (src/tools/search.ts) ]) // Override the default snake_case mapping for a clearer LLM tool name on a diff --git a/server.json b/server.json index ffd5899..d6f26bc 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.theodor90/form4api-mcp", "title": "Form4API — SEC Insider Trading", - "description": "Real-time SEC Form 4, Form 144, 13F-HR & congressional STOCK Act insider data. 37 tools + 6 prompts.", + "description": "Real-time SEC Form 4, Form 144, 13F-HR & congressional STOCK Act insider data. 38 tools + 6 prompts.", "version": "1.15.0", "websiteUrl": "https://www.form4api.com", "repository": { diff --git a/src/index.ts b/src/index.ts index e9497b0..c96bba9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import { getInsiderCareerSummarySchema, getInsiderCareerSummary } from './tools/ import { checkUsageSchema, checkUsage } from './tools/usage.js' import { verifySetupSchema, verifySetup } from './tools/verify-setup.js' import { researchCompanySchema, researchCompany } from './tools/research.js' +import { searchSchema, search } from './tools/search.js' import { GENERATED_TOOLS } from './tools/_generated.js' import { RECIPE_PROMPTS } from './prompts/recipes.js' @@ -32,7 +33,7 @@ const { version } = JSON.parse( const SERVER_INSTRUCTIONS = `Form4API insider trading data: amendment-aware Form 4 transactions, 10b5-1 plan flags, Form 144 intent-to-sell, institutional 13F-HR overlay, and congressional STOCK Act trades — including the insider-cluster-buy x congressional-purchase convergence signal. -Start with \`research_company\` to get bundled insider context for any ticker. If something fails, run \`verify_setup\` first. Try \`get_public_stats\` without a key to preview the data. +Only have a company name, ticker fragment, or a person's name? Call \`search\` first to resolve it to a ticker or CIK. Start with \`research_company\` to get bundled insider context for any ticker. If something fails, run \`verify_setup\` first. Try \`get_public_stats\` without a key to preview the data. Try these prompts: • "What insider trades happened at NVDA in the last 30 days, excluding 10b5-1 plans?" @@ -279,6 +280,20 @@ server.tool( }, ) +server.tool( + 'search', + 'Resolve free-text input — a company name, ticker fragment, or a person\'s name — to a ticker or CIK. This is usually the right FIRST call when a user names a company or insider you don\'t already have an identifier for; feed the resulting ticker into get_company_overview/get_transactions or the CIK into get_insider_profile/get_insider_transactions. Returns two independently-ranked lists: `companies` (only ones with a resolved public ticker, ranked exact ticker match, then ticker-prefix, then name-prefix, then name-contains) and `insiders` (matched by splitting the query on whitespace so every token must match the name, e.g. "tim cook" matches SEC-style "Cook Timothy D"; ranked by total filing count, most active first). Each insider\'s `ticker` is a ticker associated with that insider (may be null). `q` must be 2-64 characters after trimming (400 QUERY_TOO_SHORT/QUERY_TOO_LONG). `limit` applies independently to each list, default 8, max 20. Free plan, no caching.', + searchSchema.shape, + READ_ONLY, + async (input) => { + try { + return wrapResult(await search(client, input as Parameters[1])) + } catch (err) { + return wrapError(err) + } + }, +) + server.tool( 'research_company', "Bundled insider-research context for one ticker in a single call — company profile, recent insider transactions (10b5-1 flagged), cluster buy/sell signals, monthly sentiment score, and a computed net buy/sell direction summary. Use this FIRST for any company insider-research question; it replaces 4 separate calls (get_company_overview, get_transactions, get_signals, get_sentiment) and degrades gracefully — if signals/sentiment require a plan you don't have, they come back null with an explanatory `_unavailable` note instead of failing the whole call. Company/transaction data works on Free; signals/sentiment sections require Business. Fetches all sections concurrently. Research and decision-support only, not investment advice.", diff --git a/src/tools/search.ts b/src/tools/search.ts new file mode 100644 index 0000000..585fa43 --- /dev/null +++ b/src/tools/search.ts @@ -0,0 +1,27 @@ +import { z } from 'zod' +import type { Form4ApiClient } from '../client.js' +import type { SearchResponse } from '../types.js' + +export const searchSchema = z.object({ + q: z + .string() + .describe( + 'Search text — a company name/ticker fragment and/or a person\'s name, in any order (e.g. "tim cook" matches insider "Cook Timothy D"). Trimmed before validation; must be 2-64 characters after trimming.', + ), + limit: z + .number() + .int() + .min(1) + .max(20) + .optional() + .describe('Maximum number of results per section (companies, insiders), applied independently to each. Defaults to 8, clamped to 1-20.'), +}) + +export type SearchInput = z.infer + +export async function search(client: Form4ApiClient, input: SearchInput): Promise { + return client.get('/v1/search', { + q: input.q, + limit: input.limit, + }) +} diff --git a/src/types.ts b/src/types.ts index a170598..654e641 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,24 @@ export interface CompanyInsider { totalTransactions: number } +export interface SearchCompanyResult { + ticker: string + name: string + cik: string +} + +export interface SearchInsiderResult { + cik: string + name: string + title: string | null + ticker: string | null +} + +export interface SearchResponse { + companies: SearchCompanyResult[] + insiders: SearchInsiderResult[] +} + export interface Signal { signalId: number ticker: string diff --git a/test/mcp-test.mjs b/test/mcp-test.mjs index ef3bc44..f119438 100644 --- a/test/mcp-test.mjs +++ b/test/mcp-test.mjs @@ -1,7 +1,7 @@ /** * MCP protocol test — spawns the server and verifies: * 1. initialize handshake - * 2. tools/list returns all 37 tools with correct names + * 2. tools/list returns all 38 tools with correct names * 3. prompts/list returns all 6 recipe prompts * 4. prompts/get returns rendered messages for a sample of prompts * 5. (optional) live tool call if FORM4API_KEY is set @@ -69,6 +69,10 @@ const EXPECTED_TOOLS = [ // Auto-generated after the Schedule 13D/13G ownership-crossings endpoint // shipped; picked up by the 2026-09-24 OpenAPI sync (v1.15.0). 'list_schedule13_dg', + // Hand-written combined company/insider name resolver (insiderapi #314, + // 2026-09-25) — framed as the obvious first call to resolve a name to a + // ticker/CIK before calling other tools. + 'search', ] // Recipe prompts (v1.9.0, 2026-07-11) — the MCP "prompts" capability. @@ -153,11 +157,11 @@ async function runTest() { server.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + '\n') // ── Test 2: tools/list ──────────────────────────────────────────────── - console.log('\n[2] tools/list — 37 tools registered') + console.log('\n[2] tools/list — 38 tools registered') const listRes = await send('tools/list', {}) const toolNames = (listRes.result?.tools ?? []).map(t => t.name) assert(!listRes.error, 'no error in tools/list response') - assert(toolNames.length === 37, `37 tools returned (got ${toolNames.length})`) + assert(toolNames.length === 38, `38 tools returned (got ${toolNames.length})`) for (const name of EXPECTED_TOOLS) { assert(toolNames.includes(name), `tool "${name}" present`) }