pattern-mcp 0.3.0 → 0.5.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/README.md +466 -5
- package/dist/index.js +762 -14
- package/dist/telemetry.js +222 -0
- package/package.json +3 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in, anonymous product telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Off by default. Enabling it (PATTERN_TELEMETRY=1) answers two questions
|
|
5
|
+
* the product can't answer any other way without asking users directly:
|
|
6
|
+
*
|
|
7
|
+
* - Do people come back and use Pattern on a second or third project on
|
|
8
|
+
* their own, unprompted? (tracked via distinct project hashes seen per
|
|
9
|
+
* anonymous install, on every recommend_component call)
|
|
10
|
+
* - How often does a BYO Anthropic key actually run dry or get rate
|
|
11
|
+
* limited in real sessions, not just the one time it happened during
|
|
12
|
+
* manual testing? (tracked via captureApiError)
|
|
13
|
+
*
|
|
14
|
+
* What gets sent, when enabled: an anonymous, randomly generated install
|
|
15
|
+
* ID (see installId() below); a one-way SHA-256 hash of project_id,
|
|
16
|
+
* truncated to 16 hex chars -- never the raw project_id string; the verdict
|
|
17
|
+
* shape already written to the local call log (verdict, confidence,
|
|
18
|
+
* ensemble_triggered, estimated cost); and, on a failed Anthropic API call,
|
|
19
|
+
* only the HTTP status and a coarse error classification (rate_limit /
|
|
20
|
+
* insufficient_credit / other) -- never the request or response body.
|
|
21
|
+
* component_need text, requirements_checked evidence, and the API key
|
|
22
|
+
* itself are never sent. See SECURITY.md and README.md for the full
|
|
23
|
+
* disclosure and the exact opt-in instructions.
|
|
24
|
+
*
|
|
25
|
+
* Reuses Pattern's existing PostHog project (the same one the marketing
|
|
26
|
+
* site sends browser events to) with its public, write-only project key --
|
|
27
|
+
* safe to embed in a distributed package the same way that key is already
|
|
28
|
+
* embedded in the site's client bundle. CLI events are namespaced with a
|
|
29
|
+
* "pattern_cli_" event prefix and source: "cli" so they're never confused
|
|
30
|
+
* with website traffic in queries or dashboards.
|
|
31
|
+
*/
|
|
32
|
+
import { PostHog } from "posthog-node";
|
|
33
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
34
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
// Opt-in, not opt-out -- deliberate, given who this tool is for. See
|
|
38
|
+
// README's telemetry section: a local-first tool aimed at developers who
|
|
39
|
+
// notice and care about silent tracking is exactly the audience §06 of the
|
|
40
|
+
// product brief already flags as sensitive to "no paper trail" trust gaps.
|
|
41
|
+
// Any of "1", "true", "yes" (case-insensitive) turns it on.
|
|
42
|
+
const TELEMETRY_ENABLED = /^(1|true|yes)$/i.test(process.env.PATTERN_TELEMETRY ?? "");
|
|
43
|
+
// One-time startup notice, printed to stderr -- the closest thing to an
|
|
44
|
+
// opt-in prompt an MCP stdio server can safely show. stdin is the JSON-RPC
|
|
45
|
+
// channel the client uses to talk to this process; blocking on it to read
|
|
46
|
+
// a y/n keypress would fight the protocol handshake instead of showing a
|
|
47
|
+
// dialog, so there's no safe way to do an interactive prompt here. This
|
|
48
|
+
// prints once ever (gated by TELEMETRY_NOTICE_PATH, not by whether this is
|
|
49
|
+
// a fresh install), so someone who installed Pattern before telemetry
|
|
50
|
+
// existed sees it exactly once on their first run after upgrading, the
|
|
51
|
+
// same as a brand-new install does on its first run ever. Call from
|
|
52
|
+
// main() at startup -- never from inside a tool call, so it can't be
|
|
53
|
+
// mistaken for a response to the calling agent.
|
|
54
|
+
export function printTelemetryNoticeOnce() {
|
|
55
|
+
try {
|
|
56
|
+
readFileSync(TELEMETRY_NOTICE_PATH, "utf8");
|
|
57
|
+
return; // Already shown -- never repeat.
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// No marker yet -- fall through and show it.
|
|
61
|
+
}
|
|
62
|
+
const status = TELEMETRY_ENABLED
|
|
63
|
+
? "ON, because PATTERN_TELEMETRY is set"
|
|
64
|
+
: "OFF (the default -- nothing is sent unless you opt in)";
|
|
65
|
+
console.error([
|
|
66
|
+
"",
|
|
67
|
+
"Pattern -- one-time telemetry notice (this will not print again)",
|
|
68
|
+
`Anonymous usage telemetry is currently ${status}.`,
|
|
69
|
+
"",
|
|
70
|
+
"When enabled, Pattern sends an anonymous per-install ID, a one-way",
|
|
71
|
+
"hash of project_id (never the raw string), and the same verdict",
|
|
72
|
+
"summary already written to ~/.pattern/calls.log (verdict,",
|
|
73
|
+
"confidence, reason, estimated cost). component_need, domain,",
|
|
74
|
+
"framework, existing_stack, and your API key are never sent.",
|
|
75
|
+
"Full field list: https://github.com/donaldrichard19-LVD/pattern-mcp#telemetry",
|
|
76
|
+
"",
|
|
77
|
+
"To help improve Pattern by sharing anonymous usage data, opt in:",
|
|
78
|
+
" PATTERN_TELEMETRY=1",
|
|
79
|
+
"Already on and want it off instead? Unset PATTERN_TELEMETRY (or set it to 0).",
|
|
80
|
+
"",
|
|
81
|
+
].join("\n"));
|
|
82
|
+
try {
|
|
83
|
+
mkdirSync(dirname(TELEMETRY_NOTICE_PATH), { recursive: true });
|
|
84
|
+
writeFileSync(TELEMETRY_NOTICE_PATH, new Date().toISOString(), "utf8");
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Couldn't persist the marker -- worst case this prints again next
|
|
88
|
+
// run. Never blocks startup or a tool call over it.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Public PostHog project API key (phc_...). Write-only: it can send events,
|
|
92
|
+
// it cannot read or query data back, so it's safe to ship in source the
|
|
93
|
+
// same way it's already shipped in the marketing site's client bundle.
|
|
94
|
+
// Override for self-hosting or testing against a different project.
|
|
95
|
+
const POSTHOG_KEY = process.env.PATTERN_POSTHOG_KEY ?? "phc_yUq5SpVfS9JxMm6QgFAYAfwzszAvbHQQsdN4xAqqJt3U";
|
|
96
|
+
const POSTHOG_HOST = process.env.PATTERN_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
|
97
|
+
const INSTALL_ID_PATH = process.env.PATTERN_INSTALL_ID_PATH ?? join(homedir(), ".pattern", "install_id");
|
|
98
|
+
// Marker for the one-time startup notice below -- deliberately a separate
|
|
99
|
+
// file from install_id, not reused as an existence check. install_id gets
|
|
100
|
+
// created the moment ANY telemetry function runs (including a disabled
|
|
101
|
+
// no-op path in some future refactor); this marker exists purely to answer
|
|
102
|
+
// "has this specific human seen the notice yet," so it's written only from
|
|
103
|
+
// printTelemetryNoticeOnce itself.
|
|
104
|
+
const TELEMETRY_NOTICE_PATH = process.env.PATTERN_TELEMETRY_NOTICE_PATH ?? join(homedir(), ".pattern", "telemetry_notice_shown");
|
|
105
|
+
let cachedInstallId;
|
|
106
|
+
// Stable per-install anonymous ID, generated once and persisted locally --
|
|
107
|
+
// the distinct_id every telemetry event is keyed by. This is what makes
|
|
108
|
+
// "same install, second project" observable at all; without it every event
|
|
109
|
+
// would look like a brand-new anonymous user. Never derived from anything
|
|
110
|
+
// that identifies a person or machine (no hostname, no MAC, no username) --
|
|
111
|
+
// purely a random UUID with no way to reverse it to an identity.
|
|
112
|
+
function installId() {
|
|
113
|
+
if (cachedInstallId)
|
|
114
|
+
return cachedInstallId;
|
|
115
|
+
try {
|
|
116
|
+
cachedInstallId = readFileSync(INSTALL_ID_PATH, "utf8").trim();
|
|
117
|
+
if (cachedInstallId)
|
|
118
|
+
return cachedInstallId;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// No file yet -- fall through and create one.
|
|
122
|
+
}
|
|
123
|
+
cachedInstallId = randomUUID();
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(dirname(INSTALL_ID_PATH), { recursive: true });
|
|
126
|
+
writeFileSync(INSTALL_ID_PATH, cachedInstallId, "utf8");
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Couldn't persist (e.g. read-only home dir) -- still usable for this
|
|
130
|
+
// process's lifetime, just won't be stable across restarts. Telemetry
|
|
131
|
+
// is best-effort by design; this never blocks a tool call.
|
|
132
|
+
}
|
|
133
|
+
return cachedInstallId;
|
|
134
|
+
}
|
|
135
|
+
// One-way hash so a project_id string (which may be a real repo/project
|
|
136
|
+
// name someone doesn't want sent anywhere) never leaves the machine in
|
|
137
|
+
// readable form, while still letting the same project produce the same
|
|
138
|
+
// hash every time -- which is exactly what's needed to count distinct
|
|
139
|
+
// projects per install without ever seeing what those projects are named.
|
|
140
|
+
export function hashProjectId(projectId) {
|
|
141
|
+
return createHash("sha256").update(projectId).digest("hex").slice(0, 16);
|
|
142
|
+
}
|
|
143
|
+
// Classifies a thrown Anthropic API error by status code and the coarse
|
|
144
|
+
// shape of the error body, without ever inspecting or forwarding the body
|
|
145
|
+
// itself. Matches the two failure modes called out in the product brief's
|
|
146
|
+
// Risks section (§06): a key that's out of money, and rate limiting.
|
|
147
|
+
export function classifyApiError(message) {
|
|
148
|
+
const statusMatch = message.match(/Anthropic API error (\d+)/);
|
|
149
|
+
const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : null;
|
|
150
|
+
if (status === 429)
|
|
151
|
+
return { type: "rate_limit", status };
|
|
152
|
+
if (status === 400 && /credit balance|insufficient/i.test(message)) {
|
|
153
|
+
return { type: "insufficient_credit", status };
|
|
154
|
+
}
|
|
155
|
+
return { type: "other", status };
|
|
156
|
+
}
|
|
157
|
+
let client;
|
|
158
|
+
function getClient() {
|
|
159
|
+
if (!TELEMETRY_ENABLED || !POSTHOG_KEY)
|
|
160
|
+
return undefined;
|
|
161
|
+
if (!client) {
|
|
162
|
+
client = new PostHog(POSTHOG_KEY, {
|
|
163
|
+
host: POSTHOG_HOST,
|
|
164
|
+
// Low volume, long-lived process (an MCP server, not a batch job) --
|
|
165
|
+
// flush promptly rather than buffering, so an event isn't silently
|
|
166
|
+
// lost if the server process is killed shortly after a call.
|
|
167
|
+
flushAt: 1,
|
|
168
|
+
flushInterval: 0,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return client;
|
|
172
|
+
}
|
|
173
|
+
// Fire-and-forget by design: telemetry must never be able to slow down or
|
|
174
|
+
// break a tool call. Every failure path here is swallowed, not surfaced --
|
|
175
|
+
// including "telemetry is disabled," which is the common case.
|
|
176
|
+
function capture(event, properties) {
|
|
177
|
+
const posthog = getClient();
|
|
178
|
+
if (!posthog)
|
|
179
|
+
return;
|
|
180
|
+
try {
|
|
181
|
+
posthog.capture({
|
|
182
|
+
distinctId: installId(),
|
|
183
|
+
event,
|
|
184
|
+
properties: { ...properties, source: "cli" },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// Never let a telemetry failure affect the tool call it's attached to.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
export function captureRecommendation(args) {
|
|
192
|
+
capture("pattern_cli_recommend_component", {
|
|
193
|
+
project_hash: args.projectId ? hashProjectId(args.projectId) : null,
|
|
194
|
+
verdict: args.verdict ?? null,
|
|
195
|
+
confidence: args.confidence ?? null,
|
|
196
|
+
reason: args.reason ?? null,
|
|
197
|
+
ensemble_triggered: args.ensembleTriggered ?? false,
|
|
198
|
+
estimated_cost_usd: args.estimatedCostUsd ?? null,
|
|
199
|
+
served_from_ledger: args.servedFromLedger ?? false,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
export function captureApiError(args) {
|
|
203
|
+
const { type, status } = classifyApiError(args.message);
|
|
204
|
+
capture("pattern_cli_api_error", {
|
|
205
|
+
tool: args.tool,
|
|
206
|
+
error_type: type,
|
|
207
|
+
status_code: status,
|
|
208
|
+
project_hash: args.projectId ? hashProjectId(args.projectId) : null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Best-effort drain on clean shutdown so the last event(s) of a session
|
|
212
|
+
// aren't dropped. Safe to call even when telemetry was never enabled.
|
|
213
|
+
export async function shutdownTelemetry() {
|
|
214
|
+
if (!client)
|
|
215
|
+
return;
|
|
216
|
+
try {
|
|
217
|
+
await client.shutdown();
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// Ignore -- process is exiting either way.
|
|
221
|
+
}
|
|
222
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pattern-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP tool that judges whether a UI component need should be met with an existing shadcn/ui, 21st.dev, or ReUI component or requires a custom build, using field/requirement coverage scored against real component code.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"prepublishOnly": "npm run build"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
+
"posthog-node": "^5.51.4"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@types/node": "^22.0.0",
|