@apitap/core 1.0.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.
Files changed (236) hide show
  1. package/LICENSE +60 -0
  2. package/README.md +362 -0
  3. package/SKILL.md +270 -0
  4. package/dist/auth/crypto.d.ts +31 -0
  5. package/dist/auth/crypto.js +66 -0
  6. package/dist/auth/crypto.js.map +1 -0
  7. package/dist/auth/handoff.d.ts +29 -0
  8. package/dist/auth/handoff.js +180 -0
  9. package/dist/auth/handoff.js.map +1 -0
  10. package/dist/auth/manager.d.ts +46 -0
  11. package/dist/auth/manager.js +127 -0
  12. package/dist/auth/manager.js.map +1 -0
  13. package/dist/auth/oauth-refresh.d.ts +16 -0
  14. package/dist/auth/oauth-refresh.js +91 -0
  15. package/dist/auth/oauth-refresh.js.map +1 -0
  16. package/dist/auth/refresh.d.ts +43 -0
  17. package/dist/auth/refresh.js +217 -0
  18. package/dist/auth/refresh.js.map +1 -0
  19. package/dist/capture/anti-bot.d.ts +15 -0
  20. package/dist/capture/anti-bot.js +43 -0
  21. package/dist/capture/anti-bot.js.map +1 -0
  22. package/dist/capture/blocklist.d.ts +6 -0
  23. package/dist/capture/blocklist.js +70 -0
  24. package/dist/capture/blocklist.js.map +1 -0
  25. package/dist/capture/body-diff.d.ts +8 -0
  26. package/dist/capture/body-diff.js +102 -0
  27. package/dist/capture/body-diff.js.map +1 -0
  28. package/dist/capture/body-variables.d.ts +13 -0
  29. package/dist/capture/body-variables.js +142 -0
  30. package/dist/capture/body-variables.js.map +1 -0
  31. package/dist/capture/domain.d.ts +8 -0
  32. package/dist/capture/domain.js +34 -0
  33. package/dist/capture/domain.js.map +1 -0
  34. package/dist/capture/entropy.d.ts +33 -0
  35. package/dist/capture/entropy.js +100 -0
  36. package/dist/capture/entropy.js.map +1 -0
  37. package/dist/capture/filter.d.ts +11 -0
  38. package/dist/capture/filter.js +49 -0
  39. package/dist/capture/filter.js.map +1 -0
  40. package/dist/capture/graphql.d.ts +21 -0
  41. package/dist/capture/graphql.js +99 -0
  42. package/dist/capture/graphql.js.map +1 -0
  43. package/dist/capture/idle.d.ts +23 -0
  44. package/dist/capture/idle.js +44 -0
  45. package/dist/capture/idle.js.map +1 -0
  46. package/dist/capture/monitor.d.ts +26 -0
  47. package/dist/capture/monitor.js +183 -0
  48. package/dist/capture/monitor.js.map +1 -0
  49. package/dist/capture/oauth-detector.d.ts +18 -0
  50. package/dist/capture/oauth-detector.js +96 -0
  51. package/dist/capture/oauth-detector.js.map +1 -0
  52. package/dist/capture/pagination.d.ts +9 -0
  53. package/dist/capture/pagination.js +40 -0
  54. package/dist/capture/pagination.js.map +1 -0
  55. package/dist/capture/parameterize.d.ts +17 -0
  56. package/dist/capture/parameterize.js +63 -0
  57. package/dist/capture/parameterize.js.map +1 -0
  58. package/dist/capture/scrubber.d.ts +5 -0
  59. package/dist/capture/scrubber.js +38 -0
  60. package/dist/capture/scrubber.js.map +1 -0
  61. package/dist/capture/session.d.ts +46 -0
  62. package/dist/capture/session.js +445 -0
  63. package/dist/capture/session.js.map +1 -0
  64. package/dist/capture/token-detector.d.ts +16 -0
  65. package/dist/capture/token-detector.js +62 -0
  66. package/dist/capture/token-detector.js.map +1 -0
  67. package/dist/capture/verifier.d.ts +17 -0
  68. package/dist/capture/verifier.js +147 -0
  69. package/dist/capture/verifier.js.map +1 -0
  70. package/dist/cli.d.ts +2 -0
  71. package/dist/cli.js +930 -0
  72. package/dist/cli.js.map +1 -0
  73. package/dist/discovery/auth.d.ts +17 -0
  74. package/dist/discovery/auth.js +81 -0
  75. package/dist/discovery/auth.js.map +1 -0
  76. package/dist/discovery/fetch.d.ts +17 -0
  77. package/dist/discovery/fetch.js +59 -0
  78. package/dist/discovery/fetch.js.map +1 -0
  79. package/dist/discovery/frameworks.d.ts +11 -0
  80. package/dist/discovery/frameworks.js +249 -0
  81. package/dist/discovery/frameworks.js.map +1 -0
  82. package/dist/discovery/index.d.ts +21 -0
  83. package/dist/discovery/index.js +219 -0
  84. package/dist/discovery/index.js.map +1 -0
  85. package/dist/discovery/openapi.d.ts +13 -0
  86. package/dist/discovery/openapi.js +175 -0
  87. package/dist/discovery/openapi.js.map +1 -0
  88. package/dist/discovery/probes.d.ts +9 -0
  89. package/dist/discovery/probes.js +70 -0
  90. package/dist/discovery/probes.js.map +1 -0
  91. package/dist/index.d.ts +25 -0
  92. package/dist/index.js +25 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/inspect/report.d.ts +52 -0
  95. package/dist/inspect/report.js +191 -0
  96. package/dist/inspect/report.js.map +1 -0
  97. package/dist/mcp.d.ts +8 -0
  98. package/dist/mcp.js +526 -0
  99. package/dist/mcp.js.map +1 -0
  100. package/dist/orchestration/browse.d.ts +38 -0
  101. package/dist/orchestration/browse.js +198 -0
  102. package/dist/orchestration/browse.js.map +1 -0
  103. package/dist/orchestration/cache.d.ts +15 -0
  104. package/dist/orchestration/cache.js +24 -0
  105. package/dist/orchestration/cache.js.map +1 -0
  106. package/dist/plugin.d.ts +17 -0
  107. package/dist/plugin.js +158 -0
  108. package/dist/plugin.js.map +1 -0
  109. package/dist/read/decoders/deepwiki.d.ts +2 -0
  110. package/dist/read/decoders/deepwiki.js +148 -0
  111. package/dist/read/decoders/deepwiki.js.map +1 -0
  112. package/dist/read/decoders/grokipedia.d.ts +2 -0
  113. package/dist/read/decoders/grokipedia.js +210 -0
  114. package/dist/read/decoders/grokipedia.js.map +1 -0
  115. package/dist/read/decoders/hackernews.d.ts +2 -0
  116. package/dist/read/decoders/hackernews.js +168 -0
  117. package/dist/read/decoders/hackernews.js.map +1 -0
  118. package/dist/read/decoders/index.d.ts +2 -0
  119. package/dist/read/decoders/index.js +12 -0
  120. package/dist/read/decoders/index.js.map +1 -0
  121. package/dist/read/decoders/reddit.d.ts +2 -0
  122. package/dist/read/decoders/reddit.js +142 -0
  123. package/dist/read/decoders/reddit.js.map +1 -0
  124. package/dist/read/decoders/twitter.d.ts +12 -0
  125. package/dist/read/decoders/twitter.js +187 -0
  126. package/dist/read/decoders/twitter.js.map +1 -0
  127. package/dist/read/decoders/wikipedia.d.ts +2 -0
  128. package/dist/read/decoders/wikipedia.js +66 -0
  129. package/dist/read/decoders/wikipedia.js.map +1 -0
  130. package/dist/read/decoders/youtube.d.ts +2 -0
  131. package/dist/read/decoders/youtube.js +69 -0
  132. package/dist/read/decoders/youtube.js.map +1 -0
  133. package/dist/read/extract.d.ts +25 -0
  134. package/dist/read/extract.js +320 -0
  135. package/dist/read/extract.js.map +1 -0
  136. package/dist/read/index.d.ts +14 -0
  137. package/dist/read/index.js +66 -0
  138. package/dist/read/index.js.map +1 -0
  139. package/dist/read/peek.d.ts +9 -0
  140. package/dist/read/peek.js +137 -0
  141. package/dist/read/peek.js.map +1 -0
  142. package/dist/read/types.d.ts +44 -0
  143. package/dist/read/types.js +3 -0
  144. package/dist/read/types.js.map +1 -0
  145. package/dist/replay/engine.d.ts +53 -0
  146. package/dist/replay/engine.js +441 -0
  147. package/dist/replay/engine.js.map +1 -0
  148. package/dist/replay/truncate.d.ts +16 -0
  149. package/dist/replay/truncate.js +92 -0
  150. package/dist/replay/truncate.js.map +1 -0
  151. package/dist/serve.d.ts +31 -0
  152. package/dist/serve.js +149 -0
  153. package/dist/serve.js.map +1 -0
  154. package/dist/skill/generator.d.ts +44 -0
  155. package/dist/skill/generator.js +419 -0
  156. package/dist/skill/generator.js.map +1 -0
  157. package/dist/skill/importer.d.ts +26 -0
  158. package/dist/skill/importer.js +80 -0
  159. package/dist/skill/importer.js.map +1 -0
  160. package/dist/skill/search.d.ts +19 -0
  161. package/dist/skill/search.js +51 -0
  162. package/dist/skill/search.js.map +1 -0
  163. package/dist/skill/signing.d.ts +16 -0
  164. package/dist/skill/signing.js +34 -0
  165. package/dist/skill/signing.js.map +1 -0
  166. package/dist/skill/ssrf.d.ts +27 -0
  167. package/dist/skill/ssrf.js +210 -0
  168. package/dist/skill/ssrf.js.map +1 -0
  169. package/dist/skill/store.d.ts +7 -0
  170. package/dist/skill/store.js +93 -0
  171. package/dist/skill/store.js.map +1 -0
  172. package/dist/stats/report.d.ts +26 -0
  173. package/dist/stats/report.js +157 -0
  174. package/dist/stats/report.js.map +1 -0
  175. package/dist/types.d.ts +214 -0
  176. package/dist/types.js +3 -0
  177. package/dist/types.js.map +1 -0
  178. package/package.json +58 -0
  179. package/src/auth/crypto.ts +92 -0
  180. package/src/auth/handoff.ts +229 -0
  181. package/src/auth/manager.ts +140 -0
  182. package/src/auth/oauth-refresh.ts +120 -0
  183. package/src/auth/refresh.ts +300 -0
  184. package/src/capture/anti-bot.ts +63 -0
  185. package/src/capture/blocklist.ts +75 -0
  186. package/src/capture/body-diff.ts +109 -0
  187. package/src/capture/body-variables.ts +156 -0
  188. package/src/capture/domain.ts +34 -0
  189. package/src/capture/entropy.ts +121 -0
  190. package/src/capture/filter.ts +56 -0
  191. package/src/capture/graphql.ts +124 -0
  192. package/src/capture/idle.ts +45 -0
  193. package/src/capture/monitor.ts +224 -0
  194. package/src/capture/oauth-detector.ts +106 -0
  195. package/src/capture/pagination.ts +49 -0
  196. package/src/capture/parameterize.ts +68 -0
  197. package/src/capture/scrubber.ts +49 -0
  198. package/src/capture/session.ts +502 -0
  199. package/src/capture/token-detector.ts +76 -0
  200. package/src/capture/verifier.ts +171 -0
  201. package/src/cli.ts +1031 -0
  202. package/src/discovery/auth.ts +99 -0
  203. package/src/discovery/fetch.ts +85 -0
  204. package/src/discovery/frameworks.ts +231 -0
  205. package/src/discovery/index.ts +256 -0
  206. package/src/discovery/openapi.ts +230 -0
  207. package/src/discovery/probes.ts +76 -0
  208. package/src/index.ts +26 -0
  209. package/src/inspect/report.ts +247 -0
  210. package/src/mcp.ts +618 -0
  211. package/src/orchestration/browse.ts +250 -0
  212. package/src/orchestration/cache.ts +37 -0
  213. package/src/plugin.ts +188 -0
  214. package/src/read/decoders/deepwiki.ts +180 -0
  215. package/src/read/decoders/grokipedia.ts +246 -0
  216. package/src/read/decoders/hackernews.ts +198 -0
  217. package/src/read/decoders/index.ts +15 -0
  218. package/src/read/decoders/reddit.ts +158 -0
  219. package/src/read/decoders/twitter.ts +211 -0
  220. package/src/read/decoders/wikipedia.ts +75 -0
  221. package/src/read/decoders/youtube.ts +75 -0
  222. package/src/read/extract.ts +396 -0
  223. package/src/read/index.ts +78 -0
  224. package/src/read/peek.ts +175 -0
  225. package/src/read/types.ts +37 -0
  226. package/src/replay/engine.ts +559 -0
  227. package/src/replay/truncate.ts +116 -0
  228. package/src/serve.ts +189 -0
  229. package/src/skill/generator.ts +473 -0
  230. package/src/skill/importer.ts +107 -0
  231. package/src/skill/search.ts +76 -0
  232. package/src/skill/signing.ts +36 -0
  233. package/src/skill/ssrf.ts +238 -0
  234. package/src/skill/store.ts +107 -0
  235. package/src/stats/report.ts +208 -0
  236. package/src/types.ts +233 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,618 @@
1
+ #!/usr/bin/env node
2
+ // src/mcp.ts
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { searchSkills } from './skill/search.js';
7
+ import { readSkillFile } from './skill/store.js';
8
+ import { replayEndpoint } from './replay/engine.js';
9
+ import { AuthManager, getMachineId } from './auth/manager.js';
10
+ import { requestAuth } from './auth/handoff.js';
11
+ import { CaptureSession } from './capture/session.js';
12
+ import { discover } from './discovery/index.js';
13
+ import { SessionCache } from './orchestration/cache.js';
14
+ import { peek } from './read/peek.js';
15
+ import { read } from './read/index.js';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ const APITAP_DIR = join(homedir(), '.apitap');
20
+
21
+ export interface McpServerOptions {
22
+ skillsDir?: string;
23
+ /** @internal Skip SSRF check in replay — for testing only */
24
+ _skipSsrfCheck?: boolean;
25
+ }
26
+
27
+ const MAX_SESSIONS = 3;
28
+
29
+ export function createMcpServer(options: McpServerOptions = {}): McpServer {
30
+ const skillsDir = options.skillsDir;
31
+ const sessions = new Map<string, CaptureSession>();
32
+ const sessionCache = new SessionCache();
33
+
34
+ const server = new McpServer({
35
+ name: 'apitap',
36
+ version: '0.5.0',
37
+ });
38
+
39
+ // --- apitap_search ---
40
+ server.registerTool(
41
+ 'apitap_search',
42
+ {
43
+ description:
44
+ 'Search available API skill files for a domain or endpoint. ' +
45
+ 'Use this FIRST to check if ApiTap has captured a site\'s API before trying to replay. ' +
46
+ 'Returns matching endpoints with replayability tiers: ' +
47
+ 'green = public, no auth needed — safe to replay directly; ' +
48
+ 'yellow = needs auth credentials but no signing/anti-bot; ' +
49
+ 'orange = CSRF/session-bound, fragile replay; ' +
50
+ 'red = anti-bot protection, needs browser. ' +
51
+ 'If not found, use apitap_capture to capture the site first.',
52
+ inputSchema: z.object({
53
+ query: z.string().describe('Search query — domain name, endpoint path, or keyword (e.g. "polymarket", "events", "get-markets")'),
54
+ }),
55
+ annotations: {
56
+ readOnlyHint: true,
57
+ openWorldHint: false,
58
+ },
59
+ },
60
+ async ({ query }) => {
61
+ const result = await searchSkills(query, skillsDir);
62
+ return {
63
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
64
+ };
65
+ },
66
+ );
67
+
68
+ // --- apitap_discover ---
69
+ server.registerTool(
70
+ 'apitap_discover',
71
+ {
72
+ description:
73
+ 'Discover a site\'s APIs without launching a browser. ' +
74
+ 'Detects frameworks (WordPress, Shopify, Next.js, GraphQL), ' +
75
+ 'finds OpenAPI/Swagger specs, and probes common API paths. ' +
76
+ 'Use this BEFORE apitap_capture to check if expensive browser-based capture is needed. ' +
77
+ 'Returns { confidence, skillFile?, hints, frameworks?, specs?, probes? }. ' +
78
+ 'If confidence is "high" or "medium", a skeleton skill file is generated — ' +
79
+ 'try replaying its endpoints before resorting to capture.',
80
+ inputSchema: z.object({
81
+ url: z.string().describe('URL to discover (e.g. "https://example.com")'),
82
+ }),
83
+ annotations: {
84
+ readOnlyHint: true,
85
+ openWorldHint: true,
86
+ },
87
+ },
88
+ async ({ url }) => {
89
+ try {
90
+ const result = await discover(url);
91
+
92
+ // If we got a skill file, save it automatically
93
+ if (result.skillFile && (result.confidence === 'high' || result.confidence === 'medium')) {
94
+ const { writeSkillFile } = await import('./skill/store.js');
95
+ const path = await writeSkillFile(result.skillFile, skillsDir);
96
+ return {
97
+ content: [{ type: 'text' as const, text: JSON.stringify({ ...result, savedTo: path }) }],
98
+ };
99
+ }
100
+
101
+ return {
102
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
103
+ };
104
+ } catch (err: any) {
105
+ return {
106
+ content: [{ type: 'text' as const, text: `Discovery failed: ${err.message}` }],
107
+ isError: true,
108
+ };
109
+ }
110
+ },
111
+ );
112
+
113
+ // --- apitap_replay ---
114
+ server.registerTool(
115
+ 'apitap_replay',
116
+ {
117
+ description:
118
+ 'Replay a captured API endpoint to get live data without a browser. ' +
119
+ 'Check the endpoint tier first with apitap_search: ' +
120
+ 'green = will work, yellow = needs auth, orange/red = may fail. ' +
121
+ 'For POST endpoints with request bodies, params can include body variable paths ' +
122
+ '(e.g. "variables.limit": "25" for GraphQL). ' +
123
+ 'Returns { status, data } with the API response.',
124
+ inputSchema: z.object({
125
+ domain: z.string().describe('Domain of the API (e.g. "gamma-api.polymarket.com")'),
126
+ endpointId: z.string().describe('Endpoint ID from search results (e.g. "get-events", "post-graphql-GetPosts")'),
127
+ params: z.object({}).passthrough().optional().describe('Optional key-value parameters: path params (id), query params, or body variables (variables.limit for GraphQL)'),
128
+ fresh: z.boolean().optional().describe('Force token refresh before replay (opens browser to capture fresh CSRF/session tokens)'),
129
+ maxBytes: z.number().optional().describe('Maximum response size in bytes. Large responses are truncated to fit. Omit for unlimited.'),
130
+ }),
131
+ annotations: {
132
+ readOnlyHint: true,
133
+ openWorldHint: true,
134
+ },
135
+ },
136
+ async ({ domain, endpointId, params, fresh, maxBytes }) => {
137
+ const skill = await readSkillFile(domain, skillsDir);
138
+ if (!skill) {
139
+ return {
140
+ content: [{ type: 'text' as const, text: `No skill file found for "${domain}". Use apitap_capture to capture it first.` }],
141
+ isError: true,
142
+ };
143
+ }
144
+
145
+ const endpoint = skill.endpoints.find(e => e.id === endpointId);
146
+ if (!endpoint) {
147
+ return {
148
+ content: [{ type: 'text' as const, text: `Endpoint "${endpointId}" not found. Available: ${skill.endpoints.map(e => e.id).join(', ')}` }],
149
+ isError: true,
150
+ };
151
+ }
152
+
153
+ // Get auth manager for token injection and header auth
154
+ const machineId = await getMachineId();
155
+ const authManager = new AuthManager(APITAP_DIR, machineId);
156
+
157
+ // Inject stored header auth if needed
158
+ const hasStoredPlaceholder = Object.values(endpoint.headers).some(v => v === '[stored]');
159
+ if (hasStoredPlaceholder) {
160
+ try {
161
+ const storedAuth = await authManager.retrieve(domain);
162
+ if (storedAuth) {
163
+ endpoint.headers[storedAuth.header] = storedAuth.value;
164
+ }
165
+ } catch {
166
+ // Auth retrieval failed — proceed without it
167
+ }
168
+ }
169
+
170
+ try {
171
+ const result = await replayEndpoint(skill, endpointId, {
172
+ params: params as Record<string, string> | undefined,
173
+ authManager,
174
+ domain,
175
+ fresh: fresh ?? false,
176
+ maxBytes,
177
+ _skipSsrfCheck: options._skipSsrfCheck,
178
+ });
179
+ const cached = sessionCache.get(domain);
180
+ const fromCache = !cached || cached.source === 'disk';
181
+
182
+ return {
183
+ content: [{ type: 'text' as const, text: JSON.stringify({
184
+ status: result.status,
185
+ data: result.data,
186
+ domain,
187
+ endpointId,
188
+ tier: endpoint.replayability?.tier ?? 'unknown',
189
+ fromCache,
190
+ capturedAt: skill.capturedAt,
191
+ ...(result.refreshed ? { refreshed: result.refreshed } : {}),
192
+ ...(result.truncated ? { truncated: true } : {}),
193
+ }) }],
194
+ };
195
+ } catch (err: any) {
196
+ return {
197
+ content: [{ type: 'text' as const, text: `Replay failed: ${err.message}` }],
198
+ isError: true,
199
+ };
200
+ }
201
+ },
202
+ );
203
+
204
+ // --- apitap_replay_batch ---
205
+ server.registerTool(
206
+ 'apitap_replay_batch',
207
+ {
208
+ description:
209
+ 'Replay multiple API endpoints in parallel across different domains. ' +
210
+ 'Use this when you need data from several sites at once (e.g. comparing prices across stores). ' +
211
+ 'Each request gets its own result — one site failing does not affect others. ' +
212
+ 'Returns an array of { domain, endpointId, status, data, error?, tier?, capturedAt? }.',
213
+ inputSchema: z.object({
214
+ requests: z.array(z.object({
215
+ domain: z.string().describe('Domain of the API'),
216
+ endpointId: z.string().describe('Endpoint ID from search results'),
217
+ params: z.object({}).passthrough().optional().describe('Optional key-value parameters'),
218
+ })).describe('Array of replay requests to execute in parallel'),
219
+ maxBytes: z.number().optional().describe('Maximum response size in bytes per result. Large responses are truncated to fit.'),
220
+ }),
221
+ annotations: {
222
+ readOnlyHint: true,
223
+ openWorldHint: true,
224
+ },
225
+ },
226
+ async ({ requests, maxBytes }) => {
227
+ const { replayMultiple } = await import('./replay/engine.js');
228
+ const typed = requests.map(r => ({
229
+ domain: r.domain,
230
+ endpointId: r.endpointId,
231
+ params: r.params as Record<string, string> | undefined,
232
+ }));
233
+ const results = await replayMultiple(typed, { skillsDir, maxBytes, _skipSsrfCheck: options._skipSsrfCheck });
234
+ return {
235
+ content: [{ type: 'text' as const, text: JSON.stringify(results) }],
236
+ };
237
+ },
238
+ );
239
+
240
+ // --- apitap_browse ---
241
+ server.registerTool(
242
+ 'apitap_browse',
243
+ {
244
+ description:
245
+ 'High-level "just get me the data" tool. ' +
246
+ 'Checks if a skill file exists for the site, runs discovery if needed, ' +
247
+ 'and replays the best matching endpoint — all in one call. ' +
248
+ 'Use this when you want data from a URL without manually chaining search → discover → replay. ' +
249
+ 'Returns { success: true, data, domain, endpointId, tier } on success, ' +
250
+ 'or { success: false, suggestion: "capture_needed" } if the site needs browser-based capture first. ' +
251
+ 'For precise control over which endpoint to replay, use apitap_search + apitap_replay instead.',
252
+ inputSchema: z.object({
253
+ url: z.string().describe('URL to browse (e.g. "https://zillow.com/rentals/portland")'),
254
+ task: z.string().optional().describe('Optional task description (e.g. "find apartments under $1500") — passed through in response for correlation'),
255
+ maxBytes: z.number().optional().describe('Maximum response size in bytes (default: 50000). Large responses are truncated to fit.'),
256
+ }),
257
+ annotations: {
258
+ readOnlyHint: true,
259
+ openWorldHint: true,
260
+ },
261
+ },
262
+ async ({ url, task, maxBytes }) => {
263
+ const { browse: doBrowse } = await import('./orchestration/browse.js');
264
+ const result = await doBrowse(url, {
265
+ skillsDir,
266
+ cache: sessionCache,
267
+ task,
268
+ maxBytes: maxBytes ?? 50_000,
269
+ _skipSsrfCheck: options._skipSsrfCheck,
270
+ });
271
+ return {
272
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
273
+ };
274
+ },
275
+ );
276
+
277
+ // --- apitap_peek ---
278
+ server.registerTool(
279
+ 'apitap_peek',
280
+ {
281
+ description:
282
+ 'Zero-cost triage of a URL. HTTP HEAD only -- checks accessibility, ' +
283
+ 'bot protection, framework detection. Use before apitap_read to avoid wasting ' +
284
+ 'tokens on blocked sites. Returns { accessible, recommendation, botProtection, framework, signals }.',
285
+ inputSchema: z.object({
286
+ url: z.string().describe('URL to peek at (e.g. "https://example.com")'),
287
+ }),
288
+ annotations: {
289
+ readOnlyHint: true,
290
+ openWorldHint: true,
291
+ },
292
+ },
293
+ async ({ url }) => {
294
+ try {
295
+ const result = await peek(url);
296
+ return {
297
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
298
+ };
299
+ } catch (err: any) {
300
+ return {
301
+ content: [{ type: 'text' as const, text: `Peek failed: ${err.message}` }],
302
+ isError: true,
303
+ };
304
+ }
305
+ },
306
+ );
307
+
308
+ // --- apitap_read ---
309
+ server.registerTool(
310
+ 'apitap_read',
311
+ {
312
+ description:
313
+ 'Extract content from a URL without a browser. Uses side-channel APIs ' +
314
+ 'for known sites (Reddit, YouTube, Wikipedia, HN) and HTML content extraction for ' +
315
+ 'everything else. Returns structured JSON with clean markdown content. 0-10K tokens ' +
316
+ 'vs 50-200K for browser automation.',
317
+ inputSchema: z.object({
318
+ url: z.string().describe('URL to read (e.g. "https://en.wikipedia.org/wiki/TypeScript")'),
319
+ maxBytes: z.number().optional().describe('Maximum content size in bytes. Content is truncated to fit.'),
320
+ }),
321
+ annotations: {
322
+ readOnlyHint: true,
323
+ openWorldHint: true,
324
+ },
325
+ },
326
+ async ({ url, maxBytes }) => {
327
+ try {
328
+ const result = await read(url, { maxBytes: maxBytes ?? undefined });
329
+ if (!result) {
330
+ return {
331
+ content: [{ type: 'text' as const, text: JSON.stringify({ error: 'Failed to read content', url }) }],
332
+ isError: true,
333
+ };
334
+ }
335
+ return {
336
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
337
+ };
338
+ } catch (err: any) {
339
+ return {
340
+ content: [{ type: 'text' as const, text: `Read failed: ${err.message}` }],
341
+ isError: true,
342
+ };
343
+ }
344
+ },
345
+ );
346
+
347
+ // --- apitap_capture ---
348
+ server.registerTool(
349
+ 'apitap_capture',
350
+ {
351
+ description:
352
+ 'Capture a website\'s API traffic by launching an instrumented browser. ' +
353
+ 'Use this when apitap_search returns no results for a site. ' +
354
+ 'Navigates to the URL, captures API calls for the specified duration, ' +
355
+ 'and generates skill files for future replay. ' +
356
+ 'Returns { domains, totalRequests, filtered, skillFiles } summary.',
357
+ inputSchema: z.object({
358
+ url: z.string().describe('URL to capture (e.g. "https://polymarket.com")'),
359
+ duration: z.number().optional().describe('Capture duration in seconds (default: 30)'),
360
+ port: z.number().optional().describe('Connect to specific CDP port instead of scanning'),
361
+ }),
362
+ annotations: {
363
+ readOnlyHint: false,
364
+ destructiveHint: false,
365
+ openWorldHint: true,
366
+ },
367
+ },
368
+ async ({ url, duration, port }) => {
369
+ const dur = duration ?? 30;
370
+
371
+ const { execFile } = await import('node:child_process');
372
+ const { promisify } = await import('node:util');
373
+ const execFileAsync = promisify(execFile);
374
+
375
+ const cliArgs = ['--import', 'tsx', 'src/cli.ts', 'capture', url, '--duration', String(dur), '--json', '--no-verify'];
376
+ if (port) cliArgs.push('--port', String(port));
377
+
378
+ try {
379
+ const { stdout } = await execFileAsync('node', cliArgs, {
380
+ timeout: (dur + 30) * 1000,
381
+ env: { ...process.env, ...(skillsDir ? { APITAP_SKILLS_DIR: skillsDir } : {}) },
382
+ });
383
+ return {
384
+ content: [{ type: 'text' as const, text: stdout }],
385
+ };
386
+ } catch (err: any) {
387
+ return {
388
+ content: [{ type: 'text' as const, text: `Capture failed: ${err.message}` }],
389
+ isError: true,
390
+ };
391
+ }
392
+ },
393
+ );
394
+
395
+ // --- apitap_capture_start ---
396
+ server.registerTool(
397
+ 'apitap_capture_start',
398
+ {
399
+ description:
400
+ 'Start an interactive browser capture session. ' +
401
+ 'Launches a browser, navigates to the URL, and begins passively capturing API traffic. ' +
402
+ 'Returns a sessionId and a page snapshot with interactive elements (buttons, links, inputs). ' +
403
+ 'Use apitap_capture_interact to drive the browser and apitap_capture_finish to save skill files.',
404
+ inputSchema: z.object({
405
+ url: z.string().describe('URL to navigate to (e.g. "https://polymarket.com")'),
406
+ headless: z.boolean().optional().describe('Run browser in headless mode (default: true)'),
407
+ allDomains: z.boolean().optional().describe('Capture traffic from all domains, not just the target (default: false)'),
408
+ }),
409
+ annotations: {
410
+ readOnlyHint: false,
411
+ destructiveHint: false,
412
+ openWorldHint: true,
413
+ },
414
+ },
415
+ async ({ url, headless, allDomains }) => {
416
+ if (sessions.size >= MAX_SESSIONS) {
417
+ return {
418
+ content: [{ type: 'text' as const, text: `Maximum ${MAX_SESSIONS} concurrent sessions. Finish or abort an existing session first.` }],
419
+ isError: true,
420
+ };
421
+ }
422
+
423
+ try {
424
+ const session = new CaptureSession({
425
+ headless: headless ?? true,
426
+ allDomains: allDomains ?? false,
427
+ skillsDir,
428
+ });
429
+ const snapshot = await session.start(url);
430
+ sessions.set(session.id, session);
431
+ return {
432
+ content: [{ type: 'text' as const, text: JSON.stringify({ sessionId: session.id, snapshot }) }],
433
+ };
434
+ } catch (err: any) {
435
+ return {
436
+ content: [{ type: 'text' as const, text: `Failed to start capture session: ${err.message}` }],
437
+ isError: true,
438
+ };
439
+ }
440
+ },
441
+ );
442
+
443
+ // --- apitap_capture_interact ---
444
+ server.registerTool(
445
+ 'apitap_capture_interact',
446
+ {
447
+ description:
448
+ 'Interact with a live capture session browser. ' +
449
+ 'Actions: snapshot (get current state), click (ref), type (ref + text), select (ref + value), ' +
450
+ 'navigate (url), scroll (direction), wait (seconds, max 10). ' +
451
+ 'Every action returns a fresh page snapshot with updated elements and capture stats. ' +
452
+ 'Use element refs (e.g. "e0", "e3") from the snapshot to target clicks and typing.',
453
+ inputSchema: z.object({
454
+ sessionId: z.string().describe('Session ID from apitap_capture_start'),
455
+ action: z.enum(['snapshot', 'click', 'type', 'select', 'navigate', 'scroll', 'wait']).describe('Action to perform'),
456
+ ref: z.string().optional().describe('Element ref from snapshot (e.g. "e0") — required for click, type, select'),
457
+ text: z.string().optional().describe('Text to type — required for type action'),
458
+ value: z.string().optional().describe('Option value — required for select action'),
459
+ url: z.string().optional().describe('URL — required for navigate action'),
460
+ direction: z.enum(['up', 'down']).optional().describe('Scroll direction (default: down)'),
461
+ seconds: z.number().optional().describe('Seconds to wait (max 10) — for wait action'),
462
+ submit: z.boolean().optional().describe('Press Enter after typing (default: false)'),
463
+ }),
464
+ annotations: {
465
+ readOnlyHint: false,
466
+ destructiveHint: false,
467
+ openWorldHint: true,
468
+ },
469
+ },
470
+ async ({ sessionId, action, ref, text, value, url, direction, seconds, submit }) => {
471
+ const session = sessions.get(sessionId);
472
+ if (!session) {
473
+ // Clean up expired sessions
474
+ for (const [id, s] of sessions) {
475
+ if (!s.isActive) sessions.delete(id);
476
+ }
477
+ return {
478
+ content: [{ type: 'text' as const, text: `Session "${sessionId}" not found or expired.` }],
479
+ isError: true,
480
+ };
481
+ }
482
+
483
+ const result = await session.interact({
484
+ action: action as any,
485
+ ref,
486
+ text,
487
+ value,
488
+ url,
489
+ direction: direction as 'up' | 'down' | undefined,
490
+ seconds,
491
+ submit,
492
+ });
493
+
494
+ return {
495
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
496
+ ...(result.success ? {} : { isError: true }),
497
+ };
498
+ },
499
+ );
500
+
501
+ // --- apitap_capture_finish ---
502
+ server.registerTool(
503
+ 'apitap_capture_finish',
504
+ {
505
+ description:
506
+ 'Finish or abort a capture session. ' +
507
+ 'Without abort: closes browser, verifies endpoints, signs and writes skill files. ' +
508
+ 'With abort: closes browser without saving. ' +
509
+ 'Returns { aborted, domains: [{ domain, endpointCount, tiers, skillFile }] }.',
510
+ inputSchema: z.object({
511
+ sessionId: z.string().describe('Session ID from apitap_capture_start'),
512
+ abort: z.boolean().optional().describe('Abort without saving (default: false)'),
513
+ }),
514
+ annotations: {
515
+ readOnlyHint: false,
516
+ destructiveHint: false,
517
+ openWorldHint: false,
518
+ },
519
+ },
520
+ async ({ sessionId, abort: shouldAbort }) => {
521
+ const session = sessions.get(sessionId);
522
+ if (!session) {
523
+ // Clean up expired sessions
524
+ for (const [id, s] of sessions) {
525
+ if (!s.isActive) sessions.delete(id);
526
+ }
527
+ return {
528
+ content: [{ type: 'text' as const, text: `Session "${sessionId}" not found or expired.` }],
529
+ isError: true,
530
+ };
531
+ }
532
+
533
+ sessions.delete(sessionId);
534
+
535
+ try {
536
+ if (shouldAbort) {
537
+ await session.abort();
538
+ return {
539
+ content: [{ type: 'text' as const, text: JSON.stringify({ aborted: true, domains: [] }) }],
540
+ };
541
+ }
542
+
543
+ const result = await session.finish();
544
+ return {
545
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
546
+ };
547
+ } catch (err: any) {
548
+ return {
549
+ content: [{ type: 'text' as const, text: `Finish failed: ${err.message}` }],
550
+ isError: true,
551
+ };
552
+ }
553
+ },
554
+ );
555
+
556
+ // --- apitap_auth_request ---
557
+ server.registerTool(
558
+ 'apitap_auth_request',
559
+ {
560
+ description:
561
+ 'Request human authentication for a site that requires login. ' +
562
+ 'Opens a VISIBLE browser window where the human can log in, handle 2FA, solve CAPTCHAs. ' +
563
+ 'Captures session cookies and auth tokens after login, stores them encrypted. ' +
564
+ 'Default timeout is 5 minutes. ' +
565
+ 'After success, use apitap_replay or apitap_capture with the domain — auth will be injected automatically. ' +
566
+ 'Use this when: replay returns 401/403, discovery detects authRequired, or you know a site needs login.',
567
+ inputSchema: z.object({
568
+ domain: z.string().describe('Domain to authenticate (e.g. "github.com")'),
569
+ loginUrl: z.string().optional().describe('Login page URL (defaults to https://<domain>)'),
570
+ timeout: z.number().optional().describe('Timeout in seconds for human to complete login (default: 300)'),
571
+ }),
572
+ annotations: {
573
+ readOnlyHint: false,
574
+ openWorldHint: true,
575
+ },
576
+ },
577
+ async ({ domain, loginUrl, timeout }) => {
578
+ const machineId = await getMachineId();
579
+ const authManager = new AuthManager(APITAP_DIR, machineId);
580
+
581
+ try {
582
+ const result = await requestAuth(authManager, {
583
+ domain,
584
+ loginUrl,
585
+ timeout: timeout ? timeout * 1000 : undefined,
586
+ });
587
+
588
+ return {
589
+ content: [{ type: 'text' as const, text: JSON.stringify(result) }],
590
+ ...(result.success ? {} : { isError: true }),
591
+ };
592
+ } catch (err: any) {
593
+ return {
594
+ content: [{ type: 'text' as const, text: `Auth request failed: ${err.message}` }],
595
+ isError: true,
596
+ };
597
+ }
598
+ },
599
+ );
600
+
601
+ return server;
602
+ }
603
+
604
+ // --- stdio entry point ---
605
+ // Only start when run directly (not imported for testing)
606
+ const isMainModule = process.argv[1] && (
607
+ process.argv[1].endsWith('/mcp.ts') ||
608
+ process.argv[1].endsWith('/mcp.js')
609
+ );
610
+
611
+ if (isMainModule) {
612
+ const server = createMcpServer();
613
+ const transport = new StdioServerTransport();
614
+ server.connect(transport).catch((err) => {
615
+ console.error('MCP server failed to start:', err);
616
+ process.exit(1);
617
+ });
618
+ }