imprint-mcp 0.2.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/CHANGELOG.md +168 -0
- package/LICENSE +21 -0
- package/README.md +322 -0
- package/examples/discoverandgo/README.md +57 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/cron.json +8 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/index.ts +89 -0
- package/examples/discoverandgo/book_discoverandgo_museum_pass/workflow.json +39 -0
- package/examples/echo/README.md +37 -0
- package/examples/echo/echo_test/index.ts +31 -0
- package/examples/google-flights/search_google_flights/index.ts +101 -0
- package/examples/google-flights/search_google_flights/parser.test.ts +140 -0
- package/examples/google-flights/search_google_flights/parser.ts +189 -0
- package/examples/google-flights/search_google_flights/playbook.yaml +130 -0
- package/examples/google-flights/search_google_flights/workflow.json +48 -0
- package/examples/google-hotels/search_google_hotels/index.ts +194 -0
- package/examples/google-hotels/search_google_hotels/parser.test.ts +168 -0
- package/examples/google-hotels/search_google_hotels/parser.ts +330 -0
- package/examples/google-hotels/search_google_hotels/playbook.yaml +125 -0
- package/examples/google-hotels/search_google_hotels/workflow.json +111 -0
- package/examples/namecheap-domains/search_namecheap_domains/index.ts +144 -0
- package/examples/namecheap-domains/search_namecheap_domains/parser.ts +380 -0
- package/examples/namecheap-domains/search_namecheap_domains/playbook.yaml +50 -0
- package/examples/namecheap-domains/search_namecheap_domains/request-transform.ts +136 -0
- package/examples/namecheap-domains/search_namecheap_domains/workflow.json +97 -0
- package/examples/southwest/README.md +81 -0
- package/examples/southwest/search_southwest_flights/backends.json +23 -0
- package/examples/southwest/search_southwest_flights/cron.json +19 -0
- package/examples/southwest/search_southwest_flights/index.ts +110 -0
- package/examples/southwest/search_southwest_flights/playbook.yaml +46 -0
- package/examples/southwest/search_southwest_flights/workflow.json +54 -0
- package/package.json +78 -0
- package/prompts/compile-agent.md +580 -0
- package/prompts/intent-detection.md +198 -0
- package/prompts/playbook-compilation.md +279 -0
- package/prompts/request-triage.md +74 -0
- package/prompts/tool-candidate-detection.md +104 -0
- package/src/cli.ts +1287 -0
- package/src/imprint/agent.ts +468 -0
- package/src/imprint/app-api-hosts.ts +53 -0
- package/src/imprint/backend-ladder.ts +568 -0
- package/src/imprint/check.ts +136 -0
- package/src/imprint/chromium.ts +211 -0
- package/src/imprint/claude-cli-compile.ts +640 -0
- package/src/imprint/cli-credential.ts +394 -0
- package/src/imprint/codex-cli-compile.ts +712 -0
- package/src/imprint/compile-agent-types.ts +40 -0
- package/src/imprint/compile-agent.ts +404 -0
- package/src/imprint/compile-tools.ts +1389 -0
- package/src/imprint/compile.ts +720 -0
- package/src/imprint/cookie-jar.ts +246 -0
- package/src/imprint/credential-bundle.ts +195 -0
- package/src/imprint/credential-extract.ts +290 -0
- package/src/imprint/credential-store.ts +707 -0
- package/src/imprint/cron.ts +312 -0
- package/src/imprint/doctor.ts +223 -0
- package/src/imprint/emit.ts +154 -0
- package/src/imprint/etld.ts +134 -0
- package/src/imprint/freeform-redact.ts +216 -0
- package/src/imprint/inject-listener.ts +137 -0
- package/src/imprint/install.ts +795 -0
- package/src/imprint/integrations.ts +385 -0
- package/src/imprint/is-compiled.ts +2 -0
- package/src/imprint/json-path.ts +100 -0
- package/src/imprint/llm.ts +998 -0
- package/src/imprint/load-json.ts +54 -0
- package/src/imprint/log.ts +33 -0
- package/src/imprint/login.ts +166 -0
- package/src/imprint/mcp-compile-server.ts +282 -0
- package/src/imprint/mcp-maintenance.ts +1790 -0
- package/src/imprint/mcp-server.ts +350 -0
- package/src/imprint/multi-progress.ts +69 -0
- package/src/imprint/notify.ts +155 -0
- package/src/imprint/paths.ts +64 -0
- package/src/imprint/playbook-parser.ts +21 -0
- package/src/imprint/playbook-runner.ts +465 -0
- package/src/imprint/probe-backends.ts +251 -0
- package/src/imprint/progress.ts +28 -0
- package/src/imprint/record.ts +470 -0
- package/src/imprint/redact.ts +550 -0
- package/src/imprint/replay-capture.ts +387 -0
- package/src/imprint/request-context.ts +66 -0
- package/src/imprint/runtime-link.ts +73 -0
- package/src/imprint/runtime.ts +942 -0
- package/src/imprint/sensitive-keys.ts +156 -0
- package/src/imprint/session-diff.ts +409 -0
- package/src/imprint/session-merge.ts +198 -0
- package/src/imprint/session-writer.ts +149 -0
- package/src/imprint/sites.ts +27 -0
- package/src/imprint/stealth-fetch.ts +434 -0
- package/src/imprint/teach-state.ts +235 -0
- package/src/imprint/teach.ts +2120 -0
- package/src/imprint/tool-candidates.ts +423 -0
- package/src/imprint/tool-loader.ts +186 -0
- package/src/imprint/tool-selection.ts +70 -0
- package/src/imprint/tracing.ts +508 -0
- package/src/imprint/types.ts +472 -0
- package/src/imprint/version.ts +21 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `imprint mcp-server` — exposes every generated tool under
|
|
3
|
+
* <IMPRINT_HOME>/<site>/ as an MCP tool. Stdio + Streamable HTTP transports.
|
|
4
|
+
* See docs/getting-started.md for Claude Desktop / mcp-inspector wire-up.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
|
9
|
+
import { resolve as pathResolve } from 'node:path';
|
|
10
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
13
|
+
import {
|
|
14
|
+
CallToolRequestSchema,
|
|
15
|
+
type CallToolResult,
|
|
16
|
+
ListToolsRequestSchema,
|
|
17
|
+
type Tool,
|
|
18
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
19
|
+
import { resolveLadder, runWithLadder } from './backend-ladder.ts';
|
|
20
|
+
import { createLog } from './log.ts';
|
|
21
|
+
import { imprintHomeDir } from './paths.ts';
|
|
22
|
+
import { loadBackendsCache } from './probe-backends.ts';
|
|
23
|
+
import { checkSiteCredentialsReady } from './runtime.ts';
|
|
24
|
+
import { availableSitesHint } from './sites.ts';
|
|
25
|
+
import type { StealthFetch } from './stealth-fetch.ts';
|
|
26
|
+
import {
|
|
27
|
+
type ResolvedTool as DiscoveredTool,
|
|
28
|
+
buildZodValidator,
|
|
29
|
+
discoverTools,
|
|
30
|
+
} from './tool-loader.ts';
|
|
31
|
+
import type { ConcreteBackend, ToolResult, WorkflowParameter } from './types.ts';
|
|
32
|
+
import { VERSION } from './version.ts';
|
|
33
|
+
|
|
34
|
+
interface RunMcpServerOptions {
|
|
35
|
+
/** Site name. */
|
|
36
|
+
site: string;
|
|
37
|
+
/** Override generated asset root. Defaults to IMPRINT_HOME (~/.imprint). */
|
|
38
|
+
assetRoot?: string;
|
|
39
|
+
/** Use Streamable HTTP transport instead of stdio. */
|
|
40
|
+
http?: boolean;
|
|
41
|
+
/** Port for HTTP transport (default 8765). */
|
|
42
|
+
port?: number;
|
|
43
|
+
/** Hostname for HTTP transport (default 127.0.0.1). */
|
|
44
|
+
host?: string;
|
|
45
|
+
/** Server display name advertised to clients. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** Server version. */
|
|
48
|
+
version?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ResolvedTool extends DiscoveredTool {
|
|
52
|
+
inputSchema: Tool['inputSchema'];
|
|
53
|
+
playbookPath?: string;
|
|
54
|
+
/** Probe-cached ladder; runtime starts here instead of the default. */
|
|
55
|
+
preferredOrder?: ConcreteBackend[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Tool description shown to MCP clients. Includes the operator's
|
|
59
|
+
* recorded narration when present — surprisingly load-bearing for the
|
|
60
|
+
* LLM picking the right tool. */
|
|
61
|
+
function buildToolDescription(w: ResolvedTool['workflow']): string {
|
|
62
|
+
const base = w.intent.description;
|
|
63
|
+
const said = w.intent.userSaid?.trim();
|
|
64
|
+
return said ? `${base}\n\nRecording context: "${said}"` : base;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** MCP advertises tool input as JSON Schema; build it directly from
|
|
68
|
+
* workflow parameters rather than going through Zod. */
|
|
69
|
+
function buildJsonSchema(parameters: WorkflowParameter[]): Tool['inputSchema'] {
|
|
70
|
+
const properties: Record<string, { type: string; description: string }> = {};
|
|
71
|
+
const required: string[] = [];
|
|
72
|
+
for (const p of parameters) {
|
|
73
|
+
properties[p.name] = { type: p.type, description: p.description };
|
|
74
|
+
if (p.default === undefined) required.push(p.name);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties,
|
|
79
|
+
required: required.length ? required : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const log = createLog('mcp');
|
|
84
|
+
|
|
85
|
+
/** Build the MCP Server with all discovered tools registered. */
|
|
86
|
+
function buildServer(
|
|
87
|
+
name: string,
|
|
88
|
+
version: string,
|
|
89
|
+
tools: ResolvedTool[],
|
|
90
|
+
assetRoot: string,
|
|
91
|
+
): Server {
|
|
92
|
+
const server = new Server(
|
|
93
|
+
{ name, version },
|
|
94
|
+
{
|
|
95
|
+
capabilities: { tools: {} },
|
|
96
|
+
instructions:
|
|
97
|
+
'Imprint runs deterministic workflows captured from real browser sessions. Tools prefer fetch API replay, may use gated fetch-bootstrap only for declared browser-minted state, then stealth-fetch for bot-defense state, and playbook only for full DOM interaction. Error codes: AUTH_EXPIRED (401, run `imprint login <site>`); STATE_MISSING (required cookie/state was unavailable or ambiguous); FORBIDDEN (403); RATE_LIMITED (429, back off); BAD_RESPONSE (other 4xx/5xx); NETWORK (fetch failed); UNKNOWN (everything else).',
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const validators = new Map(
|
|
102
|
+
tools.map((t) => [t.workflow.toolName, buildZodValidator(t.workflow.parameters)] as const),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Per-site stealth-fetch cache so the ~12s bootstrap runs once per site.
|
|
106
|
+
const stealthCache = new Map<string, StealthFetch>();
|
|
107
|
+
|
|
108
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
109
|
+
tools: tools.map((t) => ({
|
|
110
|
+
name: t.workflow.toolName,
|
|
111
|
+
description: buildToolDescription(t.workflow),
|
|
112
|
+
inputSchema: t.inputSchema,
|
|
113
|
+
})),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
server.setRequestHandler(CallToolRequestSchema, async (req): Promise<CallToolResult> => {
|
|
117
|
+
const tool = tools.find((t) => t.workflow.toolName === req.params.name);
|
|
118
|
+
if (!tool) {
|
|
119
|
+
return {
|
|
120
|
+
isError: true,
|
|
121
|
+
content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const validator = validators.get(req.params.name);
|
|
126
|
+
const parsed = validator?.safeParse(req.params.arguments ?? {});
|
|
127
|
+
if (parsed && !parsed.success) {
|
|
128
|
+
return {
|
|
129
|
+
isError: true,
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: `Invalid arguments: ${parsed.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join('; ')}`,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const args = (parsed?.data ?? req.params.arguments ?? {}) as Record<
|
|
139
|
+
string,
|
|
140
|
+
string | number | boolean
|
|
141
|
+
>;
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const ladder = resolveLadder('auto', tool.preferredOrder);
|
|
145
|
+
const { result, usedBackend } = await runWithLadder(
|
|
146
|
+
ladder,
|
|
147
|
+
tool,
|
|
148
|
+
args,
|
|
149
|
+
assetRoot,
|
|
150
|
+
stealthCache,
|
|
151
|
+
);
|
|
152
|
+
if (!result.ok) {
|
|
153
|
+
const text = formatToolError(result);
|
|
154
|
+
return {
|
|
155
|
+
isError: true,
|
|
156
|
+
content: [{ type: 'text', text: `${text}\n(backend: ${usedBackend})` }],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const text =
|
|
160
|
+
typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
|
|
161
|
+
return { content: [{ type: 'text', text: `${text}\n\n(backend: ${usedBackend})` }] };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
164
|
+
return { isError: true, content: [{ type: 'text', text: `[INTERNAL] ${msg}` }] };
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return server;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatToolError(result: Extract<ToolResult, { ok: false }>): string {
|
|
172
|
+
const lines = [`[${result.error}] ${result.message}`];
|
|
173
|
+
if (result.error === 'STATE_MISSING' && result.missing?.length) {
|
|
174
|
+
for (const item of result.missing) {
|
|
175
|
+
lines.push(
|
|
176
|
+
` - ${item.name}: ${item.failure} (${item.capability})${item.message ? ` — ${item.message}` : ''}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (result.remediation) lines.push(` → ${result.remediation}`);
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function runMcpServer(opts: RunMcpServerOptions): Promise<void> {
|
|
185
|
+
const assetRoot = opts.assetRoot ?? imprintHomeDir();
|
|
186
|
+
const discovered = await discoverTools(assetRoot, opts.site, '[imprint mcp]');
|
|
187
|
+
const tools: ResolvedTool[] = discovered.map((t) => {
|
|
188
|
+
const playbookPath = pathResolve(t.dir, 'playbook.yaml');
|
|
189
|
+
const cache = loadBackendsCache(t.site, assetRoot, t.dir);
|
|
190
|
+
return {
|
|
191
|
+
...t,
|
|
192
|
+
inputSchema: buildJsonSchema(t.workflow.parameters),
|
|
193
|
+
playbookPath: existsSync(playbookPath) ? playbookPath : undefined,
|
|
194
|
+
preferredOrder: cache?.preferredOrder,
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
if (tools.length === 0) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`No generated tool found for site "${opts.site}"\n${availableSitesHint(assetRoot, opts.site)}\n→ run \`imprint teach ${opts.site}\` or \`imprint emit ~/.imprint/<site>/<toolName>/workflow.json\` to codegen a tool.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const name = opts.name ?? `imprint-${opts.site}`;
|
|
204
|
+
const version = opts.version ?? VERSION;
|
|
205
|
+
|
|
206
|
+
for (const t of tools) {
|
|
207
|
+
log(`registered ${t.workflow.toolName} (${t.site}) — ${t.workflow.parameters.length} param(s)`);
|
|
208
|
+
if (t.preferredOrder) {
|
|
209
|
+
log(` preferred backend order (probed): ${t.preferredOrder.join(' → ')}`);
|
|
210
|
+
}
|
|
211
|
+
if (t.playbookPath) {
|
|
212
|
+
log(' playbook.yaml found (available as ladder fallback)');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Pre-flight: warn loudly if any tool's site has a credentials manifest
|
|
217
|
+
// declaring secrets that aren't yet provisioned. We log instead of throw
|
|
218
|
+
// so the MCP server still comes up — the user might be intentionally
|
|
219
|
+
// running an unauthenticated subset of tools — but the warning gives them
|
|
220
|
+
// the exact commands to run before the first tool call fails.
|
|
221
|
+
const reportedSites = new Set<string>();
|
|
222
|
+
for (const t of tools) {
|
|
223
|
+
if (reportedSites.has(t.site)) continue;
|
|
224
|
+
reportedSites.add(t.site);
|
|
225
|
+
try {
|
|
226
|
+
const report = await checkSiteCredentialsReady(t.site);
|
|
227
|
+
if (!report.ok) {
|
|
228
|
+
// Two-line summary on the warning, then the full multi-line
|
|
229
|
+
// remediation block. The message is already formatted for humans.
|
|
230
|
+
log(
|
|
231
|
+
` ⚠ site "${t.site}" is missing ${report.missing.length} credential(s) declared in credentials.manifest.json`,
|
|
232
|
+
);
|
|
233
|
+
for (const line of report.message.split('\n')) {
|
|
234
|
+
log(` ${line}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch (err) {
|
|
238
|
+
log(
|
|
239
|
+
` ⚠ credential pre-flight for "${t.site}" failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (opts.http) {
|
|
245
|
+
const port = opts.port ?? 8765;
|
|
246
|
+
const host = opts.host ?? '127.0.0.1';
|
|
247
|
+
await runHttp(name, version, tools, host, port, assetRoot);
|
|
248
|
+
} else {
|
|
249
|
+
await runStdio(name, version, tools, assetRoot);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Stdio transport. The SDK's StdioServerTransport just attaches data
|
|
255
|
+
* listeners to process.stdin and returns; if we let runMcpServer resolve
|
|
256
|
+
* here, cli.ts would call process.exit(0) and kill the server before any
|
|
257
|
+
* client request arrived. Block until the transport closes (client EOFs
|
|
258
|
+
* stdin) or we get SIGINT/SIGTERM.
|
|
259
|
+
*/
|
|
260
|
+
async function runStdio(
|
|
261
|
+
name: string,
|
|
262
|
+
version: string,
|
|
263
|
+
tools: ResolvedTool[],
|
|
264
|
+
assetRoot: string,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const server = buildServer(name, version, tools, assetRoot);
|
|
267
|
+
const transport = new StdioServerTransport();
|
|
268
|
+
await server.connect(transport);
|
|
269
|
+
log(`stdio transport ready (${tools.length} tool${tools.length === 1 ? '' : 's'})`);
|
|
270
|
+
|
|
271
|
+
await new Promise<void>((resolve) => {
|
|
272
|
+
const done = (reason: string): void => {
|
|
273
|
+
log(`stdio transport closing: ${reason}`);
|
|
274
|
+
resolve();
|
|
275
|
+
};
|
|
276
|
+
transport.onclose = () => done('client disconnected');
|
|
277
|
+
process.once('SIGINT', () => done('SIGINT'));
|
|
278
|
+
process.once('SIGTERM', () => done('SIGTERM'));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Streamable HTTP transport. We construct a tiny Node http server ourselves
|
|
284
|
+
* so we know exactly when the listen completes — fastmcp's wrapper has been
|
|
285
|
+
* unreliable about that under Bun.
|
|
286
|
+
*
|
|
287
|
+
* One transport instance + one Server instance handle every request. POST
|
|
288
|
+
* `/mcp` carries the JSON-RPC payload. The transport handles framing,
|
|
289
|
+
* accept-header negotiation (json vs SSE), and session id management.
|
|
290
|
+
*/
|
|
291
|
+
async function runHttp(
|
|
292
|
+
name: string,
|
|
293
|
+
version: string,
|
|
294
|
+
tools: ResolvedTool[],
|
|
295
|
+
host: string,
|
|
296
|
+
port: number,
|
|
297
|
+
assetRoot: string,
|
|
298
|
+
): Promise<void> {
|
|
299
|
+
const server = buildServer(name, version, tools, assetRoot);
|
|
300
|
+
const transport = new StreamableHTTPServerTransport({
|
|
301
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
302
|
+
});
|
|
303
|
+
await server.connect(transport);
|
|
304
|
+
|
|
305
|
+
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
306
|
+
if (req.url?.startsWith('/mcp')) {
|
|
307
|
+
try {
|
|
308
|
+
// The transport reads the body itself when we pass undefined as the
|
|
309
|
+
// 3rd arg AND the request is a POST; for GET (SSE keep-alive) it
|
|
310
|
+
// pumps the response stream.
|
|
311
|
+
await transport.handleRequest(req, res);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
314
|
+
res.end(
|
|
315
|
+
JSON.stringify({
|
|
316
|
+
error: err instanceof Error ? err.message : String(err),
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (req.url === '/health') {
|
|
323
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
324
|
+
res.end(JSON.stringify({ status: 'ok', tools: tools.length }));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
328
|
+
res.end('Not found. POST /mcp for the MCP endpoint, GET /health for status.');
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await new Promise<void>((resolve, reject) => {
|
|
332
|
+
httpServer.once('error', reject);
|
|
333
|
+
httpServer.listen(port, host, () => {
|
|
334
|
+
httpServer.off('error', reject);
|
|
335
|
+
resolve();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
log(`HTTP transport ready on http://${host}:${port}/mcp (health: /health)`);
|
|
339
|
+
|
|
340
|
+
// Keep the process alive until SIGINT/SIGTERM. Without this, bun
|
|
341
|
+
// sometimes exits even though the http server is listening.
|
|
342
|
+
await new Promise<void>((resolve) => {
|
|
343
|
+
const shutdown = (sig: NodeJS.Signals): void => {
|
|
344
|
+
log(`received ${sig}, shutting down`);
|
|
345
|
+
httpServer.close(() => resolve());
|
|
346
|
+
};
|
|
347
|
+
process.once('SIGINT', () => shutdown('SIGINT'));
|
|
348
|
+
process.once('SIGTERM', () => shutdown('SIGTERM'));
|
|
349
|
+
});
|
|
350
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-place multi-line progress renderer for concurrent compile agents.
|
|
3
|
+
*
|
|
4
|
+
* Uses CSI CPL (\x1b[nF — cursor previous line) to move back to the
|
|
5
|
+
* first rendered line, then CSI ED (\x1b[J — erase to end of screen)
|
|
6
|
+
* before rewriting. Everything is emitted in a single write() call
|
|
7
|
+
* so the terminal processes it atomically.
|
|
8
|
+
*
|
|
9
|
+
* Falls back to plain newline-per-update for non-TTY output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const isTTY = (): boolean => process.stderr.isTTY ?? false;
|
|
13
|
+
|
|
14
|
+
export class MultiProgress {
|
|
15
|
+
private lines = new Map<string, string>();
|
|
16
|
+
private renderedCount = 0;
|
|
17
|
+
private paused = false;
|
|
18
|
+
|
|
19
|
+
update(key: string, message: string): void {
|
|
20
|
+
this.lines.set(key, message);
|
|
21
|
+
if (this.paused) return;
|
|
22
|
+
if (!isTTY()) {
|
|
23
|
+
process.stderr.write(`${message}\n`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.redraw();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
remove(key: string): void {
|
|
30
|
+
this.lines.delete(key);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Erase all rendered progress lines from the terminal. */
|
|
34
|
+
clear(): void {
|
|
35
|
+
if (!isTTY() || this.renderedCount === 0) return;
|
|
36
|
+
process.stderr.write(`\x1b[${this.renderedCount}F\x1b[J`);
|
|
37
|
+
this.renderedCount = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Stop writing to the terminal. Updates are buffered in memory. */
|
|
41
|
+
pause(): void {
|
|
42
|
+
this.paused = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Resume writing. Redraws current state immediately. */
|
|
46
|
+
resume(): void {
|
|
47
|
+
this.paused = false;
|
|
48
|
+
if (isTTY() && this.lines.size > 0) this.redraw();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render(): void {
|
|
52
|
+
if (this.paused) return;
|
|
53
|
+
if (!isTTY() || this.lines.size === 0) return;
|
|
54
|
+
this.redraw();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private redraw(): void {
|
|
58
|
+
let buf = '';
|
|
59
|
+
if (this.renderedCount > 0) {
|
|
60
|
+
buf += `\x1b[${this.renderedCount}F`;
|
|
61
|
+
}
|
|
62
|
+
buf += '\x1b[J';
|
|
63
|
+
for (const [, msg] of this.lines) {
|
|
64
|
+
buf += `│ ${msg}\n`;
|
|
65
|
+
}
|
|
66
|
+
process.stderr.write(buf);
|
|
67
|
+
this.renderedCount = this.lines.size;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification hooks for the cron daemon. Two concerns:
|
|
3
|
+
* - evaluateNotifyWhen: predicate engine ("price_below" etc).
|
|
4
|
+
* - notify / providers: deliver to Pushover + ntfy in parallel.
|
|
5
|
+
*
|
|
6
|
+
* Every configured provider fires on each call; nothing configured is
|
|
7
|
+
* a silent no-op. Failures are caught and logged so a flaky provider
|
|
8
|
+
* can't crash the cron loop. See docs/notifications.md for setup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { extractNumbers } from './json-path.ts';
|
|
12
|
+
import { createLog } from './log.ts';
|
|
13
|
+
import type { NotifyWhen } from './types.ts';
|
|
14
|
+
|
|
15
|
+
const PUSHOVER_URL = 'https://api.pushover.net/1/messages.json';
|
|
16
|
+
|
|
17
|
+
interface NotifyResult {
|
|
18
|
+
/** True if the provider was configured AND the API accepted the message. */
|
|
19
|
+
delivered: boolean;
|
|
20
|
+
/** Set when delivery was attempted-but-failed, OR provider was skipped. */
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface NotifyDecision {
|
|
25
|
+
notify: boolean;
|
|
26
|
+
/** Used as the push title when notify=true. */
|
|
27
|
+
title?: string;
|
|
28
|
+
/** Used as the push body when notify=true. */
|
|
29
|
+
message?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function evaluateNotifyWhen(
|
|
33
|
+
pred: NotifyWhen,
|
|
34
|
+
data: unknown,
|
|
35
|
+
toolName = 'workflow',
|
|
36
|
+
): NotifyDecision {
|
|
37
|
+
switch (pred.type) {
|
|
38
|
+
case 'price_below': {
|
|
39
|
+
const paths = Array.isArray(pred.pricePath) ? pred.pricePath : [pred.pricePath];
|
|
40
|
+
// Union the values from every path that matches — gracefully handles
|
|
41
|
+
// tools that return different shapes from different backends.
|
|
42
|
+
const prices: number[] = [];
|
|
43
|
+
for (const p of paths) {
|
|
44
|
+
try {
|
|
45
|
+
prices.push(...extractNumbers(data, p));
|
|
46
|
+
} catch {
|
|
47
|
+
// Path didn't match this shape — try the next one. If ALL paths
|
|
48
|
+
// throw, prices stays empty and we treat it as "no signal" below.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (prices.length === 0) return { notify: false }; // empty / misconfigured path
|
|
52
|
+
const min = Math.min(...prices);
|
|
53
|
+
if (min < pred.threshold) {
|
|
54
|
+
return {
|
|
55
|
+
notify: true,
|
|
56
|
+
title: `imprint: price drop on ${toolName}`,
|
|
57
|
+
message: `Lowest price $${min} (under your $${pred.threshold} threshold) — ${prices.length} option${prices.length === 1 ? '' : 's'} found.`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return { notify: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const log = createLog('notify');
|
|
66
|
+
|
|
67
|
+
/** Push to every configured provider in parallel; returns per-provider results. */
|
|
68
|
+
export async function notify(
|
|
69
|
+
title: string,
|
|
70
|
+
message: string,
|
|
71
|
+
fetchImpl: typeof fetch = fetch,
|
|
72
|
+
): Promise<Record<string, NotifyResult>> {
|
|
73
|
+
const [pushover, ntfy] = await Promise.all([
|
|
74
|
+
notifyPushover(title, message, fetchImpl),
|
|
75
|
+
notifyNtfy(title, message, fetchImpl),
|
|
76
|
+
]);
|
|
77
|
+
return { pushover, ntfy };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function notifyPushover(
|
|
81
|
+
title: string,
|
|
82
|
+
message: string,
|
|
83
|
+
fetchImpl: typeof fetch = fetch,
|
|
84
|
+
): Promise<NotifyResult> {
|
|
85
|
+
const token = process.env.PUSHOVER_TOKEN;
|
|
86
|
+
const user = process.env.PUSHOVER_USER;
|
|
87
|
+
if (!token || !user) {
|
|
88
|
+
return {
|
|
89
|
+
delivered: false,
|
|
90
|
+
reason:
|
|
91
|
+
'PUSHOVER_TOKEN / PUSHOVER_USER not set (or set NTFY_URL for free push — see docs/notifications.md)',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = new URLSearchParams({ token, user, title, message });
|
|
96
|
+
try {
|
|
97
|
+
const r = await fetchImpl(PUSHOVER_URL, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
100
|
+
body: body.toString(),
|
|
101
|
+
});
|
|
102
|
+
if (!r.ok) {
|
|
103
|
+
const text = await r.text().catch(() => '<no body>');
|
|
104
|
+
log(`Pushover rejected: ${r.status} ${text}`);
|
|
105
|
+
return { delivered: false, reason: `HTTP ${r.status}: ${text}` };
|
|
106
|
+
}
|
|
107
|
+
log(`notified Pushover: ${title}`);
|
|
108
|
+
return { delivered: true };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
log(`Pushover request failed: ${msg}`);
|
|
112
|
+
return { delivered: false, reason: msg };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function notifyNtfy(
|
|
117
|
+
title: string,
|
|
118
|
+
message: string,
|
|
119
|
+
fetchImpl: typeof fetch = fetch,
|
|
120
|
+
): Promise<NotifyResult> {
|
|
121
|
+
const url = process.env.NTFY_URL;
|
|
122
|
+
if (!url) {
|
|
123
|
+
return {
|
|
124
|
+
delivered: false,
|
|
125
|
+
reason:
|
|
126
|
+
'NTFY_URL not set (e.g. https://ntfy.sh/your-secret-topic — see docs/notifications.md)',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// POST body to /<topic>; title + priority ride as headers; bearer auth
|
|
131
|
+
// only needed for protected topics on self-hosted instances.
|
|
132
|
+
const headers: Record<string, string> = {
|
|
133
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
134
|
+
Title: title,
|
|
135
|
+
Priority: 'high',
|
|
136
|
+
Tags: 'warning',
|
|
137
|
+
};
|
|
138
|
+
const token = process.env.NTFY_TOKEN;
|
|
139
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const r = await fetchImpl(url, { method: 'POST', headers, body: message });
|
|
143
|
+
if (!r.ok) {
|
|
144
|
+
const text = await r.text().catch(() => '<no body>');
|
|
145
|
+
log(`ntfy rejected: ${r.status} ${text}`);
|
|
146
|
+
return { delivered: false, reason: `HTTP ${r.status}: ${text}` };
|
|
147
|
+
}
|
|
148
|
+
log(`notified ntfy: ${title}`);
|
|
149
|
+
return { delivered: true };
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
152
|
+
log(`ntfy request failed: ${msg}`);
|
|
153
|
+
return { delivered: false, reason: msg };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import {
|
|
4
|
+
isAbsolute as pathIsAbsolute,
|
|
5
|
+
join as pathJoin,
|
|
6
|
+
relative as pathRelative,
|
|
7
|
+
resolve as pathResolve,
|
|
8
|
+
} from 'node:path';
|
|
9
|
+
|
|
10
|
+
export function imprintHomeDir(): string {
|
|
11
|
+
const raw = process.env.IMPRINT_HOME ?? pathJoin(homedir(), '.imprint');
|
|
12
|
+
const resolved = pathResolve(raw);
|
|
13
|
+
if (!pathIsAbsolute(resolved)) {
|
|
14
|
+
throw new Error(`IMPRINT_HOME must resolve to an absolute path, got: ${raw}`);
|
|
15
|
+
}
|
|
16
|
+
return resolved;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function validatePathSegment(label: string, value: string): void {
|
|
20
|
+
if (value.includes('..') || value.includes('/') || value.includes('\\')) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`Invalid ${label}: "${value}". Must not contain path separators or ".." sequences.`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function localSiteDir(site: string): string {
|
|
28
|
+
validatePathSegment('site name', site);
|
|
29
|
+
return pathJoin(imprintHomeDir(), site);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function localToolDir(site: string, toolName: string): string {
|
|
33
|
+
validatePathSegment('tool name', toolName);
|
|
34
|
+
return pathJoin(localSiteDir(site), toolName);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function localSessionsDir(site: string): string {
|
|
38
|
+
return pathJoin(localSiteDir(site), 'sessions');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function defaultSessionJsonlPath(site: string, timestamp: string): string {
|
|
42
|
+
return pathJoin(localSessionsDir(site), `${timestamp}.jsonl`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveLocalSitePath(site: string, value: string): string {
|
|
46
|
+
return pathIsAbsolute(value) ? value : pathResolve(localSiteDir(site), value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function relativeToLocalSite(site: string, absolutePath: string): string | null {
|
|
50
|
+
let root: string;
|
|
51
|
+
let target: string;
|
|
52
|
+
try {
|
|
53
|
+
root = realpathSync(pathResolve(localSiteDir(site)));
|
|
54
|
+
target = realpathSync(pathResolve(absolutePath));
|
|
55
|
+
} catch {
|
|
56
|
+
root = pathResolve(localSiteDir(site));
|
|
57
|
+
target = pathResolve(absolutePath);
|
|
58
|
+
}
|
|
59
|
+
const relative = pathRelative(root, target);
|
|
60
|
+
if (relative === '' || (!relative.startsWith('..') && !pathIsAbsolute(relative))) {
|
|
61
|
+
return relative;
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** YAML → Playbook (Zod-validated). */
|
|
2
|
+
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { type Playbook, PlaybookSchema } from './types.ts';
|
|
5
|
+
|
|
6
|
+
export function parsePlaybook(yaml: string): Playbook {
|
|
7
|
+
let raw: unknown;
|
|
8
|
+
try {
|
|
9
|
+
raw = YAML.parse(yaml);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Playbook YAML failed to parse: ${err instanceof Error ? err.message : String(err)}`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
const parsed = PlaybookSchema.safeParse(raw);
|
|
16
|
+
if (!parsed.success) {
|
|
17
|
+
const issues = parsed.error.errors.map((e) => ` ${e.path.join('.')}: ${e.message}`).join('\n');
|
|
18
|
+
throw new Error(`Playbook failed schema validation:\n${issues}`);
|
|
19
|
+
}
|
|
20
|
+
return parsed.data;
|
|
21
|
+
}
|