Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | import { INSTRUCTIONS } from './instructions' |
| 4 | import { ScreenerView } from './ScreenerView' |
| 5 | import { DEFAULT_COLUMNS, DEFAULT_SORT, MAX_COLUMNS, MAX_CRITERIA, OPS, summarize, VERDICTS } from './screener' |
| 6 | |
| 7 | // The screener on the Jaspers screener MCP (screener-mcp), its own connection, screener-mcp/jaspers. |
| 8 | // Five sources, one view. The schemas here are the contract with the orchestrator — what it may |
| 9 | // set, and what it may read back — so the filters are the server's filter language exactly, in raw |
| 10 | // units; which fields exist, and what each takes, is the server's to say (its guide), not ours. |
| 11 | |
| 12 | const Filter = z.object({ |
| 13 | field: z.string().min(1), |
| 14 | op: z.enum(OPS), |
| 15 | /** between takes [low, high], in takes a list, is_null and not_null take none. Raw units. */ |
| 16 | value: z.union([z.number(), z.string(), z.array(z.union([z.number(), z.string()]))]).optional(), |
| 17 | }) |
| 18 | |
| 19 | const Sort = z.object({ field: z.string().min(1), dir: z.enum(['asc', 'desc']) }) |
| 20 | |
| 21 | /** A criterion of the qualitative stage: a question a reader of the filings can answer yes or no. The server's own shape. */ |
| 22 | const Criterion = z.object({ |
| 23 | id: z.string().regex(/^[a-z0-9_]{1,40}$/), |
| 24 | question: z.string().min(5).max(500), |
| 25 | }) |
| 26 | |
| 27 | /** |
| 28 | * The panel's qualitative run. Setting `run` to {} with `criteria` in place is all the orchestrator |
| 29 | * does: the view starts the run over its list, follows it, and shows the verdicts. The fields are |
| 30 | * the view's own bookkeeping. |
| 31 | */ |
| 32 | const Run = z.object({ |
| 33 | id: z.string().optional(), |
| 34 | status: z.enum(['running', 'done', 'error']).optional(), |
| 35 | error: z.string().optional(), |
| 36 | }) |
| 37 | |
| 38 | const StateSchema = z.object({ |
| 39 | /** Always the COMPLETE set, joined by AND. */ |
| 40 | filters: z.array(Filter).default([]), |
| 41 | /** The user's own list of tickers, as the SEC writes them; empty for every company. */ |
| 42 | tickers: z.array(z.string()).max(1000).default([]), |
| 43 | includeUnlisted: z.boolean().default(false), |
| 44 | sort: Sort.default(DEFAULT_SORT), |
| 45 | page: z.number().int().min(0).default(0), |
| 46 | /** Fields shown besides the ones filtered and sorted on. */ |
| 47 | columns: z.array(z.string()).max(MAX_COLUMNS).default(DEFAULT_COLUMNS), |
| 48 | /** The server's session. The view writes it; leave it alone. */ |
| 49 | sessionId: z.string().nullable().default(null), |
| 50 | criteria: z.array(Criterion).max(MAX_CRITERIA).default([]), |
| 51 | run: Run.nullable().default(null), |
| 52 | /** Which verdicts the table shows once a run has results; empty for all. */ |
| 53 | verdicts: z.array(z.enum(VERDICTS)).default([]), |
| 54 | /** The ticker whose evidence is open; empty for none. */ |
| 55 | open: z.string().default(''), |
| 56 | }) |
| 57 | |
| 58 | const Progress = z.object({ |
| 59 | total: z.number(), |
| 60 | queued: z.number(), |
| 61 | running: z.number(), |
| 62 | error: z.number(), |
| 63 | pass: z.number(), |
| 64 | fail: z.number(), |
| 65 | unclear: z.number(), |
| 66 | unverified: z.number(), |
| 67 | }) |
| 68 | |
| 69 | const OutputSchema = z.object({ |
| 70 | session_id: z.string().nullable(), |
| 71 | universe: z.number(), |
| 72 | count: z.number(), |
| 73 | /** matches: what the filter passes on its own. left: what remains after it and every one before it. */ |
| 74 | applied_filters: z.array(Filter.extend({ matches: z.number(), left: z.number().optional() })), |
| 75 | tickers: z.array(z.string()), |
| 76 | unknown_tickers: z.array(z.string()), |
| 77 | sort: Sort, |
| 78 | qualitative_ready: z.boolean(), |
| 79 | qualitative_max: z.number(), |
| 80 | qualitative: z |
| 81 | .object({ |
| 82 | run_id: z.string().nullable(), |
| 83 | status: z.string(), |
| 84 | progress: Progress.nullable(), |
| 85 | /** The run's first phase: no company is read before its companies' earnings calls are in. */ |
| 86 | earnings_calls: z.object({ status: z.enum(['fetching', 'done', 'off']), done: z.number(), total: z.number() }).optional(), |
| 87 | passed: z.array(z.string()).optional(), |
| 88 | error: z.string().optional(), |
| 89 | }) |
| 90 | .optional(), |
| 91 | open: z.string(), |
| 92 | }) |
| 93 | |
| 94 | // A call on a session is not a function of its arguments alone, since the session is on the server: |
| 95 | // the rows of one call are never served to another. |
| 96 | const screen = defineSource({ |
| 97 | mcp: 'jaspers', |
| 98 | tool: 'screen_companies', |
| 99 | ttlMs: 0, |
| 100 | description: |
| 101 | 'Screen US SEC registrants with filters {field, op, value} on any field of a company, narrowed over several calls in one session. Raw units: USD, fractions. With a screener element on screen, set its state instead.', |
| 102 | }) |
| 103 | |
| 104 | const guide = defineSource({ |
| 105 | mcp: 'jaspers', |
| 106 | tool: 'get_guide', |
| 107 | ttlMs: 600_000, |
| 108 | description: |
| 109 | 'How to read the screener\'s data. topic "fields": every field with its kind, unit and ops. "screening": the filter language with examples, and how the qualitative stage works. "overview": coverage, limits and freshness.', |
| 110 | }) |
| 111 | |
| 112 | const qualitativeStart = defineSource({ |
| 113 | mcp: 'jaspers', |
| 114 | tool: 'start_qualitative_screen', |
| 115 | description: |
| 116 | 'Start the qualitative stage over a session\'s list: every company read against criteria in words, with verified quotes. Costs model calls per company. With a screener element on screen, set its state.criteria and state.run instead; it starts and follows the run itself.', |
| 117 | }) |
| 118 | |
| 119 | const qualitative = defineSource({ |
| 120 | mcp: 'jaspers', |
| 121 | tool: 'get_qualitative_screen', |
| 122 | ttlMs: 0, |
| 123 | description: |
| 124 | 'Progress and results of a qualitative run. With evidence: true (and tickers or verdicts set) each criterion lists the ids of its citations, and citations holds each quote once with its title and link.', |
| 125 | }) |
| 126 | |
| 127 | const company = defineSource({ |
| 128 | mcp: 'jaspers', |
| 129 | tool: 'get_company', |
| 130 | description: 'One company by ticker: identity, latest-fiscal-year fundamentals, institutional holders, insiders, newest filings.', |
| 131 | }) |
| 132 | |
| 133 | export default definePlugin({ |
| 134 | id: 'screener-mcp', |
| 135 | connections: { |
| 136 | // The server signs its users in through Jaspers' Account: the terminal connects it on its own sign-in |
| 137 | // with Jaspers (before 0.4.0, on an OAuth flow of the connection's own, Authorize in Settings under |
| 138 | // Plugins) and sends the token as a bearer on every call. Nothing to paste, and nothing in the URL. |
| 139 | jaspers: defineConnection({ |
| 140 | url: 'https://s.jsprai.com/mcp', |
| 141 | auth: 'oauth', |
| 142 | tools: ['screen_companies', 'start_qualitative_screen', 'get_qualitative_screen', 'get_company', 'get_guide'], |
| 143 | }), |
| 144 | }, |
| 145 | sources: { screen, guide, 'qualitative-start': qualitativeStart, qualitative, company }, |
| 146 | views: { |
| 147 | screener: defineView(ScreenerView, { |
| 148 | title: 'Screener', |
| 149 | state: StateSchema, |
| 150 | output: OutputSchema, |
| 151 | instructions: INSTRUCTIONS, |
| 152 | renders: [screen], |
| 153 | summarize: (state, output) => summarize(state, output), |
| 154 | }), |
| 155 | }, |
| 156 | }) |