Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.

6.3 KB
1import { defineConnection, definePlugin, defineSource, defineView } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3import { INSTRUCTIONS } from './instructions'
4import { ScreenerView } from './ScreenerView'
5import { 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
12const 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
19const 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. */
22const 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 */
32const Run = z.object({
33 id: z.string().optional(),
34 status: z.enum(['running', 'done', 'error']).optional(),
35 error: z.string().optional(),
36})
37
38const 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
58const 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
69const 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.
96const 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
104const 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
112const 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
119const 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
127const 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
133export 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})