@pipeworx/mcp-alpaca 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pipeworx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # mcp-alpaca
2
+
3
+ Alpaca MCP — real-time US stock market data via the Alpaca Market Data API
4
+
5
+ Part of [Pipeworx](https://pipeworx.io) — an MCP gateway connecting AI agents to 1325+ live data sources.
6
+
7
+ ## Tools
8
+
9
+ | Tool | Description |
10
+ |------|-------------|
11
+
12
+ ## Quick Start
13
+
14
+ Add to your MCP client (Claude Desktop, Cursor, Windsurf, etc.):
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "alpaca": {
20
+ "url": "https://gateway.pipeworx.io/alpaca/mcp"
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Or connect to the full Pipeworx gateway for access to all 1325+ data sources:
27
+
28
+ ```json
29
+ {
30
+ "mcpServers": {
31
+ "pipeworx": {
32
+ "url": "https://gateway.pipeworx.io/mcp"
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## Using with ask_pipeworx
39
+
40
+ Instead of calling tools directly, you can ask questions in plain English:
41
+
42
+ ```
43
+ ask_pipeworx({ question: "your question about Alpaca data" })
44
+ ```
45
+
46
+ The gateway picks the right tool and fills the arguments automatically.
47
+
48
+ ## More
49
+
50
+ - [All tools and guides](https://github.com/pipeworx-io/examples)
51
+ - [pipeworx.io](https://pipeworx.io)
52
+
53
+ ## License
54
+
55
+ MIT
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@pipeworx/mcp-alpaca",
3
+ "version": "0.1.0",
4
+ "description": "Alpaca MCP — real-time US stock market data via the Alpaca Market Data API",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "keywords": ["mcp", "mcp-server", "model-context-protocol", "pipeworx", "alpaca"],
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/pipeworx-io/mcp-alpaca"
13
+ },
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.7.0"
19
+ }
20
+ }
package/server.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.pipeworx-io/alpaca",
4
+ "title": "Alpaca",
5
+ "description": "Alpaca MCP — real-time US stock market data via the Alpaca Market Data API",
6
+ "version": "0.1.0",
7
+ "websiteUrl": "https://pipeworx.io/packs/alpaca",
8
+ "repository": {
9
+ "url": "https://github.com/pipeworx-io/mcp-alpaca",
10
+ "source": "github"
11
+ },
12
+ "remotes": [
13
+ {
14
+ "type": "streamable-http",
15
+ "url": "https://gateway.pipeworx.io/alpaca/mcp"
16
+ }
17
+ ]
18
+ }
package/src/index.ts ADDED
@@ -0,0 +1,586 @@
1
+ interface McpToolDefinition {
2
+ name: string;
3
+ description: string;
4
+ inputSchema: {
5
+ type: 'object';
6
+ properties: Record<string, unknown>;
7
+ required?: string[];
8
+ };
9
+ }
10
+
11
+ interface McpToolExport {
12
+ tools: McpToolDefinition[];
13
+ callTool: (name: string, args: Record<string, unknown>) => Promise<unknown>;
14
+ meter?: { credits: number };
15
+ cost?: Record<string, unknown>;
16
+ provider?: string;
17
+ }
18
+
19
+ /**
20
+ * Alpaca MCP — real-time US stock market data via the Alpaca Market Data API
21
+ * (data.alpaca.markets, v2). BYOK: free Alpaca accounts get real-time
22
+ * IEX-feed data including extended hours; VWAP comes back on every bar.
23
+ *
24
+ * Tools:
25
+ * - alpaca_snapshot: latest trade/quote, today's OHLCV, prev close, change %,
26
+ * latest minute bar (= the extended-hours price during pre/post market)
27
+ * - alpaca_bars: historical OHLCV bars with per-bar VWAP and trade counts
28
+ * - alpaca_intraday_vwap: session cumulative VWAP (from 04:00 ET) vs current
29
+ * price — answers "is X above or below VWAP right now" in one call
30
+ *
31
+ * Auth: `_apiKey` REQUIRED as "key_id:secret_key" (combined-credential
32
+ * pattern, like kroger/dataforseo). Split on the FIRST colon; sent as
33
+ * APCA-API-KEY-ID / APCA-API-SECRET-KEY headers. Free at alpaca.markets —
34
+ * a paper-trading account is enough for market data.
35
+ *
36
+ * Feed: `iex` everywhere (the free-tier feed). `sip` needs a paid Alpaca
37
+ * data subscription — the 403 subscription error is caught and explained.
38
+ */
39
+
40
+
41
+ const BASE_URL = 'https://data.alpaca.markets/v2/stocks';
42
+
43
+ const KEY_DESC =
44
+ 'REQUIRED: your Alpaca credentials as "key_id:secret_key" — both halves joined by a colon. Free at alpaca.markets (a paper-trading account is enough for market data).';
45
+
46
+ const tools: McpToolExport['tools'] = [
47
+ {
48
+ name: 'alpaca_snapshot',
49
+ description:
50
+ 'Real-time US stock snapshot with extended-hours coverage: latest trade price, bid/ask quote, today\'s OHLCV, previous close, change and change % vs previous close, plus the latest minute bar with its VWAP — during pre-market and after-hours the minute bar IS the live extended-hours price. Answers "pre-market price of OKTA", "overnight change %", "after-hours volume and high/low". Multiple symbols comma-separated (max 20). BYOK Alpaca (free account), real-time IEX feed. Example: alpaca_snapshot({ symbols: "OKTA,AAPL", _apiKey: "key_id:secret_key" })',
51
+ inputSchema: {
52
+ type: 'object' as const,
53
+ properties: {
54
+ symbols: {
55
+ type: 'string',
56
+ description: 'Comma-separated ticker symbols, e.g. "OKTA" or "BLK,AAPL,RHI,CTAS" (max 20)',
57
+ },
58
+ _apiKey: { type: 'string', description: KEY_DESC },
59
+ },
60
+ required: ['symbols', '_apiKey'],
61
+ },
62
+ },
63
+ {
64
+ name: 'alpaca_bars',
65
+ description:
66
+ 'Historical US stock OHLCV bars with per-bar VWAP and trade count. Timeframes 1Min/5Min/15Min/1Hour/1Day; intraday bars include pre-market and after-hours sessions on the IEX feed. Each bar: {t, o, h, l, c, v, vwap, trades}. Use start/end ISO timestamps or a lookback shorthand like "2h", "5d". Multiple symbols comma-separated (max 20). BYOK Alpaca (free account). Example: alpaca_bars({ symbols: "AAPL", timeframe: "5Min", lookback: "1d", _apiKey: "key_id:secret_key" })',
67
+ inputSchema: {
68
+ type: 'object' as const,
69
+ properties: {
70
+ symbols: {
71
+ type: 'string',
72
+ description: 'Comma-separated ticker symbols, e.g. "AAPL,MSFT" (max 20)',
73
+ },
74
+ timeframe: {
75
+ type: 'string',
76
+ description: 'Bar size: "1Min", "5Min", "15Min", "1Hour", or "1Day" (default "5Min")',
77
+ },
78
+ start: {
79
+ type: 'string',
80
+ description: 'Start time, ISO/RFC-3339 (e.g. "2026-07-18T09:30:00-04:00") or date "2026-07-18"',
81
+ },
82
+ end: {
83
+ type: 'string',
84
+ description: 'End time, ISO/RFC-3339 or date (default: now)',
85
+ },
86
+ lookback: {
87
+ type: 'string',
88
+ description: 'Shorthand window ending now, instead of start/end: e.g. "30m", "2h", "1d", "5d", "2w"',
89
+ },
90
+ limit: {
91
+ type: 'number',
92
+ description: 'Max bars per symbol to return, 1-1000 (default 100)',
93
+ },
94
+ _apiKey: { type: 'string', description: KEY_DESC },
95
+ },
96
+ required: ['symbols', '_apiKey'],
97
+ },
98
+ },
99
+ {
100
+ name: 'alpaca_intraday_vwap',
101
+ description:
102
+ 'Current session VWAP for US stocks — is the price above or below VWAP right now? Fetches today\'s 1-minute bars from 04:00 ET (pre-market included), computes the cumulative volume-weighted average price, and compares it to the latest trade. Returns current_price, session_vwap, above_vwap, and session volume per symbol. Answers "current VWAP for BLK, AAPL, RHI, CTAS — above or below?" in one call. Multiple symbols comma-separated (max 20). BYOK Alpaca (free account), real-time IEX feed. Example: alpaca_intraday_vwap({ symbols: "BLK,AAPL,RHI,CTAS", _apiKey: "key_id:secret_key" })',
103
+ inputSchema: {
104
+ type: 'object' as const,
105
+ properties: {
106
+ symbols: {
107
+ type: 'string',
108
+ description: 'Comma-separated ticker symbols, e.g. "BLK,AAPL,RHI,CTAS" (max 20)',
109
+ },
110
+ _apiKey: { type: 'string', description: KEY_DESC },
111
+ },
112
+ required: ['symbols', '_apiKey'],
113
+ },
114
+ },
115
+ ];
116
+
117
+ // ── Auth ────────────────────────────────────────────────────────────
118
+
119
+ interface Creds {
120
+ keyId: string;
121
+ secret: string;
122
+ }
123
+
124
+ // Combined-credential pattern: split on the FIRST colon (Alpaca secrets can
125
+ // themselves contain colons in principle; key IDs never do).
126
+ function parseKey(raw: unknown): Creds {
127
+ const key = String(raw ?? '').trim();
128
+ const idx = key.indexOf(':');
129
+ if (!key || idx < 1 || idx === key.length - 1) {
130
+ throw new Error(
131
+ 'Alpaca requires _apiKey in the form "key_id:secret_key" — your API Key ID and Secret Key joined by a colon, e.g. "PKABC123:xYz789secret". Both values come from the same API-key screen at alpaca.markets (free; a paper-trading account is enough for market data).',
132
+ );
133
+ }
134
+ return { keyId: key.slice(0, idx), secret: key.slice(idx + 1) };
135
+ }
136
+
137
+ // ── HTTP ────────────────────────────────────────────────────────────
138
+
139
+ async function alpacaError(res: Response, tool: string): Promise<Error> {
140
+ let message = '';
141
+ try {
142
+ const text = await res.text();
143
+ try {
144
+ message = String((JSON.parse(text) as { message?: string }).message ?? '');
145
+ } catch {
146
+ message = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 200);
147
+ }
148
+ } catch {
149
+ /* body unreadable — status alone will have to do */
150
+ }
151
+ if (res.status === 429) {
152
+ return new Error(
153
+ `Alpaca ${tool}: rate-limit (HTTP 429). The free tier allows 200 requests/min per account — wait a minute and retry, or batch symbols into one call (comma-separated, up to 20).`,
154
+ );
155
+ }
156
+ if (/subscription/i.test(message)) {
157
+ return new Error(
158
+ `Alpaca ${tool}: the account's data subscription rejected this request (${message}). This pack uses feed=iex, the feed included with free accounts; the "sip" consolidated feed requires a paid Alpaca market-data subscription. If it persists, accept the market-data agreement in the alpaca.markets dashboard.`,
159
+ );
160
+ }
161
+ if (res.status === 401 || res.status === 403) {
162
+ return new Error(
163
+ `Alpaca ${tool}: auth rejected (HTTP ${res.status}). _apiKey must be "key_id:secret_key" — the API Key ID and Secret Key from alpaca.markets joined by a colon. Verify both halves are current (regenerating the key invalidates the old secret). Keys are free; a paper-trading account is enough for market data.`,
164
+ );
165
+ }
166
+ return new Error(`Alpaca ${tool} error: HTTP ${res.status}${message ? ` — ${message}` : ''}`);
167
+ }
168
+
169
+ async function api(
170
+ path: string,
171
+ params: URLSearchParams,
172
+ creds: Creds,
173
+ tool: string,
174
+ ): Promise<unknown> {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), 10_000);
177
+ try {
178
+ const res = await fetch(`${BASE_URL}${path}?${params}`, {
179
+ headers: {
180
+ 'APCA-API-KEY-ID': creds.keyId,
181
+ 'APCA-API-SECRET-KEY': creds.secret,
182
+ Accept: 'application/json',
183
+ },
184
+ signal: controller.signal,
185
+ });
186
+ if (!res.ok) throw await alpacaError(res, tool);
187
+ return res.json();
188
+ } catch (e) {
189
+ if (e instanceof Error && e.name === 'AbortError') {
190
+ throw new Error(
191
+ `Alpaca ${tool}: request timed out after 10s. Retry once; if it persists, reduce the symbol count or date range.`,
192
+ );
193
+ }
194
+ throw e;
195
+ } finally {
196
+ clearTimeout(timer);
197
+ }
198
+ }
199
+
200
+ // ── Symbols ─────────────────────────────────────────────────────────
201
+
202
+ function parseSymbols(args: Record<string, unknown>): string[] {
203
+ const raw = String(args.symbols ?? args.symbol ?? args.tickers ?? args.ticker ?? '').trim();
204
+ const syms = [...new Set(raw.split(',').map((s) => s.trim().toUpperCase()).filter(Boolean))];
205
+ if (syms.length === 0) {
206
+ throw new Error(
207
+ 'Alpaca tools require symbols — comma-separated tickers like "AAPL" or "BLK,AAPL,RHI,CTAS".',
208
+ );
209
+ }
210
+ if (syms.length > 20) {
211
+ throw new Error(
212
+ `Alpaca tools accept up to 20 symbols per call (got ${syms.length}). Split the list into batches.`,
213
+ );
214
+ }
215
+ return syms;
216
+ }
217
+
218
+ // ── Eastern-time session math ───────────────────────────────────────
219
+ // ET boundaries via Intl.DateTimeFormat('America/New_York') — the runtime's
220
+ // tz database handles DST, same technique as the mbta pack.
221
+
222
+ function etParts(d: Date): { date: string; minutes: number } {
223
+ const parts = new Intl.DateTimeFormat('en-CA', {
224
+ timeZone: 'America/New_York',
225
+ year: 'numeric',
226
+ month: '2-digit',
227
+ day: '2-digit',
228
+ hour: '2-digit',
229
+ minute: '2-digit',
230
+ hour12: false,
231
+ }).formatToParts(d);
232
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00';
233
+ const hour = get('hour') === '24' ? 0 : Number(get('hour'));
234
+ return {
235
+ date: `${get('year')}-${get('month')}-${get('day')}`,
236
+ minutes: hour * 60 + Number(get('minute')),
237
+ };
238
+ }
239
+
240
+ // Current ET UTC-offset as "-04:00" / "-05:00" — used to build RFC-3339
241
+ // timestamps pinned to ET wall-clock times (the 04:00 ET session start).
242
+ function etOffset(d: Date): string {
243
+ const parts = new Intl.DateTimeFormat('en-US', {
244
+ timeZone: 'America/New_York',
245
+ timeZoneName: 'longOffset',
246
+ }).formatToParts(d);
247
+ const v = parts.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT-05:00';
248
+ const off = v.replace('GMT', '');
249
+ return off || '-05:00';
250
+ }
251
+
252
+ const MARKET_OPEN_MIN = 9 * 60 + 30; // 09:30 ET
253
+ const MARKET_CLOSE_MIN = 16 * 60; // 16:00 ET
254
+
255
+ type Session = 'pre-market' | 'regular' | 'after-hours';
256
+
257
+ function classifySession(timestamp: string): Session {
258
+ const { minutes } = etParts(new Date(timestamp));
259
+ if (minutes < MARKET_OPEN_MIN) return 'pre-market';
260
+ if (minutes >= MARKET_CLOSE_MIN) return 'after-hours';
261
+ return 'regular';
262
+ }
263
+
264
+ // ── VWAP math (exported for unit tests) ─────────────────────────────
265
+ // Session cumulative VWAP = Σ(bar_vwap · bar_volume) / Σ(bar_volume).
266
+ // Bars missing `vw` fall back to close; zero-volume bars contribute nothing.
267
+
268
+ export function sessionVwap(bars: Array<{ v: number; vw?: number; c: number }>): number | null {
269
+ let pv = 0;
270
+ let vol = 0;
271
+ for (const b of bars) {
272
+ const volume = b.v ?? 0;
273
+ if (volume <= 0) continue;
274
+ const price = b.vw ?? b.c;
275
+ pv += price * volume;
276
+ vol += volume;
277
+ }
278
+ return vol > 0 ? pv / vol : null;
279
+ }
280
+
281
+ // ── Upstream shapes ─────────────────────────────────────────────────
282
+
283
+ interface AlpacaBar {
284
+ t: string;
285
+ o: number;
286
+ h: number;
287
+ l: number;
288
+ c: number;
289
+ v: number;
290
+ n?: number;
291
+ vw?: number;
292
+ }
293
+
294
+ interface AlpacaTrade {
295
+ t: string;
296
+ p: number;
297
+ s: number;
298
+ }
299
+
300
+ interface AlpacaQuote {
301
+ t: string;
302
+ bp: number;
303
+ bs: number;
304
+ ap: number;
305
+ as: number;
306
+ }
307
+
308
+ interface AlpacaSnapshot {
309
+ latestTrade?: AlpacaTrade;
310
+ latestQuote?: AlpacaQuote;
311
+ minuteBar?: AlpacaBar;
312
+ dailyBar?: AlpacaBar;
313
+ prevDailyBar?: AlpacaBar;
314
+ }
315
+
316
+ function shapeBar(b: AlpacaBar) {
317
+ return {
318
+ t: b.t,
319
+ o: b.o,
320
+ h: b.h,
321
+ l: b.l,
322
+ c: b.c,
323
+ v: b.v,
324
+ vwap: b.vw ?? null,
325
+ trades: b.n ?? null,
326
+ };
327
+ }
328
+
329
+ const round = (x: number, dp = 4) => Math.round(x * 10 ** dp) / 10 ** dp;
330
+
331
+ // ── alpaca_snapshot ─────────────────────────────────────────────────
332
+
333
+ function shapeSnapshot(symbol: string, s: AlpacaSnapshot | undefined) {
334
+ if (!s || (!s.latestTrade && !s.dailyBar && !s.minuteBar)) {
335
+ return {
336
+ symbol,
337
+ error: `No data for "${symbol}" — verify it is a US exchange ticker (e.g. "OKTA", "BRK.B"). Company names like "Okta" are rejected upstream.`,
338
+ };
339
+ }
340
+ const prevClose = s.prevDailyBar?.c ?? null;
341
+ const lastPrice = s.latestTrade?.p ?? null;
342
+ const change = lastPrice != null && prevClose != null ? round(lastPrice - prevClose, 4) : null;
343
+ const changePct =
344
+ lastPrice != null && prevClose != null && prevClose !== 0
345
+ ? round(((lastPrice - prevClose) / prevClose) * 100, 3)
346
+ : null;
347
+ const minuteBar = s.minuteBar;
348
+ const session = minuteBar ? classifySession(minuteBar.t) : null;
349
+ return {
350
+ symbol,
351
+ latest_trade: s.latestTrade
352
+ ? { price: s.latestTrade.p, size: s.latestTrade.s, time: s.latestTrade.t }
353
+ : null,
354
+ latest_quote: s.latestQuote
355
+ ? {
356
+ bid: s.latestQuote.bp,
357
+ bid_size: s.latestQuote.bs,
358
+ ask: s.latestQuote.ap,
359
+ ask_size: s.latestQuote.as,
360
+ time: s.latestQuote.t,
361
+ }
362
+ : null,
363
+ today: s.dailyBar
364
+ ? {
365
+ open: s.dailyBar.o,
366
+ high: s.dailyBar.h,
367
+ low: s.dailyBar.l,
368
+ close: s.dailyBar.c,
369
+ volume: s.dailyBar.v,
370
+ vwap: s.dailyBar.vw ?? null,
371
+ }
372
+ : null,
373
+ previous_close: prevClose,
374
+ change,
375
+ change_percent: changePct,
376
+ // During pre/post market the latest minute bar IS the live
377
+ // extended-hours price/volume on the IEX feed.
378
+ latest_minute_bar: minuteBar ? shapeBar(minuteBar) : null,
379
+ session,
380
+ extended_hours: session != null && session !== 'regular',
381
+ };
382
+ }
383
+
384
+ async function snapshot(args: Record<string, unknown>, creds: Creds) {
385
+ const symbols = parseSymbols(args);
386
+ const params = new URLSearchParams({ symbols: symbols.join(','), feed: 'iex' });
387
+ const data = (await api('/snapshots', params, creds, 'alpaca_snapshot')) as Record<
388
+ string,
389
+ AlpacaSnapshot
390
+ >;
391
+ return {
392
+ feed: 'iex',
393
+ count: symbols.length,
394
+ note: 'change/change_percent compare the latest trade (extended hours included) to the previous regular-session close. latest_minute_bar.vwap is the most recent 1-minute VWAP; use alpaca_intraday_vwap for the full-session cumulative VWAP.',
395
+ snapshots: symbols.map((sym) => shapeSnapshot(sym, data[sym])),
396
+ };
397
+ }
398
+
399
+ // ── alpaca_bars ─────────────────────────────────────────────────────
400
+
401
+ const TIMEFRAMES: Record<string, string> = {
402
+ '1min': '1Min',
403
+ '5min': '5Min',
404
+ '15min': '15Min',
405
+ '1hour': '1Hour',
406
+ '1h': '1Hour',
407
+ '60min': '1Hour',
408
+ '1day': '1Day',
409
+ '1d': '1Day',
410
+ daily: '1Day',
411
+ };
412
+
413
+ const LOOKBACK_MS: Record<string, number> = {
414
+ m: 60_000,
415
+ h: 3_600_000,
416
+ d: 86_400_000,
417
+ w: 604_800_000,
418
+ };
419
+
420
+ function parseLookback(raw: string): number {
421
+ const m =
422
+ /^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|wk|week|weeks)$/i.exec(
423
+ raw.trim(),
424
+ );
425
+ if (!m) {
426
+ throw new Error(
427
+ `Alpaca alpaca_bars: unrecognized lookback "${raw}". Use forms like "30m", "2h", "1d", "5d", "2w" — or pass explicit start/end ISO timestamps.`,
428
+ );
429
+ }
430
+ return Number(m[1]) * LOOKBACK_MS[m[2][0].toLowerCase()];
431
+ }
432
+
433
+ async function bars(args: Record<string, unknown>, creds: Creds) {
434
+ const symbols = parseSymbols(args);
435
+ const rawTf = String(args.timeframe ?? '5Min').trim();
436
+ const timeframe = TIMEFRAMES[rawTf.toLowerCase()];
437
+ if (!timeframe) {
438
+ throw new Error(
439
+ `Alpaca alpaca_bars: timeframe must be one of 1Min, 5Min, 15Min, 1Hour, 1Day (got "${rawTf}").`,
440
+ );
441
+ }
442
+ const limit = Math.min(Math.max(Number(args.limit ?? 100) || 100, 1), 1000);
443
+
444
+ let start = args.start ? String(args.start).trim() : '';
445
+ if (!start && args.lookback) {
446
+ start = new Date(Date.now() - parseLookback(String(args.lookback))).toISOString();
447
+ }
448
+ if (!start) {
449
+ // Sensible default window: enough history to fill `limit` bars.
450
+ const defaults: Record<string, number> = {
451
+ '1Min': 86_400_000,
452
+ '5Min': 3 * 86_400_000,
453
+ '15Min': 7 * 86_400_000,
454
+ '1Hour': 14 * 86_400_000,
455
+ '1Day': 200 * 86_400_000,
456
+ };
457
+ start = new Date(Date.now() - defaults[timeframe]).toISOString();
458
+ }
459
+
460
+ const params = new URLSearchParams({
461
+ symbols: symbols.join(','),
462
+ timeframe,
463
+ start,
464
+ feed: 'iex',
465
+ limit: String(Math.min(limit * symbols.length, 10_000)),
466
+ sort: 'asc',
467
+ });
468
+ if (args.end) params.set('end', String(args.end).trim());
469
+
470
+ const data = (await api('/bars', params, creds, 'alpaca_bars')) as {
471
+ bars?: Record<string, AlpacaBar[]>;
472
+ next_page_token?: string | null;
473
+ };
474
+ const bySymbol = data.bars ?? {};
475
+ return {
476
+ feed: 'iex',
477
+ timeframe,
478
+ start,
479
+ end: args.end ? String(args.end) : 'now',
480
+ note: 'Intraday bars include pre-market and after-hours trading on the IEX feed. vwap is the per-bar volume-weighted average price.',
481
+ truncated: Boolean(data.next_page_token),
482
+ results: symbols.map((sym) => {
483
+ const list = (bySymbol[sym] ?? []).slice(-limit);
484
+ return {
485
+ symbol: sym,
486
+ bar_count: list.length,
487
+ note:
488
+ list.length === 0
489
+ ? `No bars for "${sym}" in this window — verify the ticker and that the window covers a trading session.`
490
+ : undefined,
491
+ bars: list.map(shapeBar),
492
+ };
493
+ }),
494
+ };
495
+ }
496
+
497
+ // ── alpaca_intraday_vwap ────────────────────────────────────────────
498
+
499
+ async function intradayVwap(args: Record<string, unknown>, creds: Creds) {
500
+ const symbols = parseSymbols(args);
501
+ const now = new Date();
502
+ const { date: etDate } = etParts(now);
503
+ const sessionStart = `${etDate}T04:00:00${etOffset(now)}`;
504
+
505
+ // Today's 1-minute bars from 04:00 ET (pre-market start). Up to ~960
506
+ // bars/symbol — paginate so a 20-symbol call still sees the full session.
507
+ const bySymbol: Record<string, AlpacaBar[]> = {};
508
+ let pageToken: string | null | undefined;
509
+ for (let page = 0; page < 5; page++) {
510
+ const params = new URLSearchParams({
511
+ symbols: symbols.join(','),
512
+ timeframe: '1Min',
513
+ start: sessionStart,
514
+ feed: 'iex',
515
+ limit: '10000',
516
+ sort: 'asc',
517
+ });
518
+ if (pageToken) params.set('page_token', pageToken);
519
+ const data = (await api('/bars', params, creds, 'alpaca_intraday_vwap')) as {
520
+ bars?: Record<string, AlpacaBar[]>;
521
+ next_page_token?: string | null;
522
+ };
523
+ for (const [sym, list] of Object.entries(data.bars ?? {})) {
524
+ (bySymbol[sym] ??= []).push(...list);
525
+ }
526
+ pageToken = data.next_page_token;
527
+ if (!pageToken) break;
528
+ }
529
+
530
+ const tradeData = (await api(
531
+ '/trades/latest',
532
+ new URLSearchParams({ symbols: symbols.join(','), feed: 'iex' }),
533
+ creds,
534
+ 'alpaca_intraday_vwap',
535
+ )) as { trades?: Record<string, AlpacaTrade> };
536
+ const trades = tradeData.trades ?? {};
537
+
538
+ return {
539
+ feed: 'iex',
540
+ session_date_et: etDate,
541
+ session_start: sessionStart,
542
+ note: 'session_vwap is the cumulative volume-weighted average price over all 1-minute IEX bars since 04:00 ET today (pre-market included). above_vwap compares the latest trade to it.',
543
+ results: symbols.map((sym) => {
544
+ const symBars = bySymbol[sym] ?? [];
545
+ const vwap = sessionVwap(symBars);
546
+ const trade = trades[sym];
547
+ const price = trade?.p ?? null;
548
+ if (vwap == null || price == null) {
549
+ return {
550
+ symbol: sym,
551
+ error: `No session data for "${sym}" today — verify the ticker, and that a US trading session is underway or completed (weekends and market holidays have zero bars).`,
552
+ };
553
+ }
554
+ return {
555
+ symbol: sym,
556
+ current_price: price,
557
+ current_price_time: trade.t,
558
+ session: classifySession(trade.t),
559
+ session_vwap: round(vwap),
560
+ above_vwap: price > vwap,
561
+ distance_from_vwap_percent: round(((price - vwap) / vwap) * 100, 3),
562
+ session_volume: symBars.reduce((s, b) => s + (b.v ?? 0), 0),
563
+ bar_count: symBars.length,
564
+ };
565
+ }),
566
+ };
567
+ }
568
+
569
+ // ── Dispatch ────────────────────────────────────────────────────────
570
+
571
+ async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
572
+ const creds = parseKey(args._apiKey);
573
+ delete args._apiKey;
574
+ switch (name) {
575
+ case 'alpaca_snapshot':
576
+ return snapshot(args, creds);
577
+ case 'alpaca_bars':
578
+ return bars(args, creds);
579
+ case 'alpaca_intraday_vwap':
580
+ return intradayVwap(args, creds);
581
+ default:
582
+ throw new Error(`Unknown tool: ${name}`);
583
+ }
584
+ }
585
+
586
+ export default { tools, callTool, meter: { credits: 1 } } satisfies McpToolExport;
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true
12
+ },
13
+ "include": ["src"]
14
+ }