uniqueness-mcp 0.4.2 → 0.5.1

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 (3) hide show
  1. package/README.md +67 -0
  2. package/index.mjs +361 -140
  3. package/package.json +4 -3
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # uniqueness-mcp
2
+
3
+ [MCP](https://modelcontextprotocol.io) server for [Uniqueness Engine](https://uniquenessengine.com) — conservative, human-in-the-loop access to fact-checked **personal context**.
4
+
5
+ Commercial contract: `hard-paid-v1`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npx -y uniqueness-mcp
11
+ ```
12
+
13
+ Requires Node **18+**.
14
+
15
+ ## Cursor / Claude Desktop config
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "uniqueness": {
21
+ "command": "npx",
22
+ "args": ["-y", "uniqueness-mcp"],
23
+ "env": {
24
+ "UNIQUENESS_API_KEY": "uq_live_..."
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Without `UNIQUENESS_API_KEY`, use `begin_programmatic_signup` and `complete_programmatic_signup` after human email approval.
32
+
33
+ ## Tools
34
+
35
+ | Tool | Purpose |
36
+ |------|---------|
37
+ | `get_personal_context` | Submit one enrichment (`detail=compact` default). Alias: `get_connection_brief` |
38
+ | `resume_personal_context` | After human Checkout, retry with 402 idempotency key. Alias: `resume_connection_brief` |
39
+ | `get_billing_status` | Read spendable balance + auto-recharge state (read-only) |
40
+ | `begin_programmatic_signup` / `complete_programmatic_signup` | Named-device verification flow |
41
+ | `preflight_personal_contexts` | Batch estimate before spend. Alias: `preflight_connection_briefs` |
42
+ | `submit_feedback` | Rate a delivered brief |
43
+
44
+ ## Safety (by design)
45
+
46
+ - **No purchase tools** — MCP never buys a pack, saves a payment method, enables auto-recharge, or subscribes.
47
+ - **`402 payment_required`** returns structured `human_action_required`; present `recommended_offer.checkout_url` or `billing_url` only after explicit human consent.
48
+ - **No unpaid target storage** — after Checkout, the host must call `resume_personal_context` with the original target fields, `payment_required.retry.idempotency_key`, and the previously observed available balance.
49
+
50
+ Jordan Ellis is a **fictional static fixture** and the only free experience (`GET /api/examples/kitchen-sink`). Do not smoke-test real people without explicit human approval and paid credit.
51
+
52
+ ## Environment
53
+
54
+ ```bash
55
+ export UNIQUENESS_API_KEY=uq_live_...
56
+ export UNIQUENESS_API_URL=https://uniquenessengine.com # optional override
57
+ ```
58
+
59
+ ## Docs
60
+
61
+ - [MCP quickstart](https://uniquenessengine.com/documentation/mcp/quickstart)
62
+ - [Agent setup](https://uniquenessengine.com/agents)
63
+ - [llms.txt](https://uniquenessengine.com/llms.txt)
64
+ - [OpenAPI](https://uniquenessengine.com/openapi.json)
65
+ - [CLI](https://www.npmjs.com/package/uniqueness)
66
+
67
+ Support: support@uniquenessengine.com
package/index.mjs CHANGED
@@ -1,38 +1,118 @@
1
1
  #!/usr/bin/env node
2
- // Uniqueness Engine MCP server. Exposes the connection brief as an agent tool.
3
- // Auth: UNIQUENESS_API_KEY env (get one: POST /api/signup, or `npx uniqueness signup`).
4
- //
5
- // Claude/Cursor/Codex config:
6
- // { "mcpServers": { "uniqueness": {
7
- // "command": "npx", "args": ["-y", "uniqueness-mcp"],
8
- // "env": { "UNIQUENESS_API_KEY": "uq_live_..." } } } }
9
-
2
+ // Uniqueness Engine MCP server. This is a thin, conservative wrapper around
3
+ // the public API: it can ask a human to act, but it never purchases a pack,
4
+ // saves a payment method, enables auto-recharge, or subscribes by inference.
5
+ import { randomUUID } from "node:crypto";
10
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
8
  import { z } from "zod";
13
9
 
14
10
  const BASE = process.env.UNIQUENESS_API_URL || "https://uniquenessengine.com";
15
- const KEY = process.env.UNIQUENESS_API_KEY || "";
11
+ let KEY = process.env.UNIQUENESS_API_KEY || "";
12
+ const signupContinuations = new Map();
16
13
 
17
- // Keep in step with mcp/package.json cli/release-parity.test.ts asserts they match.
18
- const server = new McpServer({ name: "uniqueness", version: "0.4.2" });
14
+ const server = new McpServer({ name: "uniqueness", version: "0.5.1" });
19
15
 
20
- server.tool(
21
- "get_connection_brief",
22
- "Given a LinkedIn URL (or name + company, or email), return a fact-checked Connection Brief: " +
23
- "verified personal + professional signal, each claim grounded in a source (`evidence_quote` + " +
24
- "`source_url`), identity-gated (returns needs_review rather than guessing). Each delivered brief " +
25
- "carries a `usage` object (cache_status, credits_charged, credits_remaining) and a `capabilities` " +
26
- "block; optional `usage.card_gate_notice` is the server-authored pre-wall next action and should " +
27
- "be shown as-is rather than inferred from the balance. A refusal or 0-fact brief is free. " +
28
- "The response is COMPACT by default (identity + " +
29
- "top-ranked facts + capabilities) — pass detail:'full' for the complete brief; set detail " +
30
- "explicitly. The removed fields outreach_angles / gift_hook / safe_to_use_in_outreach are declared " +
31
- "in `capabilities`, never returned. Machine-readable contract: " +
32
- "https://uniquenessengine.com/openapi.json (free sample: GET /api/examples/kitchen-sink). " +
33
- "Use before personalized outreach or call prep. " +
34
- "Support: support@uniquenessengine.com.",
35
- {
16
+ const json = (value, isError = false) => ({
17
+ ...(isError ? { isError: true } : {}),
18
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
19
+ });
20
+
21
+ function pruneSignupContinuations() {
22
+ const now = Date.now();
23
+ for (const [id, continuation] of signupContinuations) {
24
+ if (continuation.expiresAt <= now) signupContinuations.delete(id);
25
+ }
26
+ while (signupContinuations.size > 100)
27
+ signupContinuations.delete(signupContinuations.keys().next().value);
28
+ }
29
+
30
+ function keyHeaders(body, auth) {
31
+ const headers = {};
32
+ if (body !== undefined) headers["content-type"] = "application/json";
33
+ if (auth && KEY) headers.authorization = `Bearer ${KEY}`;
34
+ return headers;
35
+ }
36
+
37
+ async function api(path, { method = "POST", body, auth = true } = {}) {
38
+ if (auth && !KEY) return { status: 401, body: { error: "missing_api_key" } };
39
+ const response = await fetch(`${BASE}${path}`, {
40
+ method,
41
+ headers: keyHeaders(body, auth),
42
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
43
+ });
44
+ return { status: response.status, body: await response.json().catch(() => ({})) };
45
+ }
46
+
47
+ function isPaymentRequired(value) {
48
+ return value && value.error === "payment_required" && value.reason === "insufficient_credits" &&
49
+ value.credits && value.retry && typeof value.billing_url === "string";
50
+ }
51
+
52
+ function spendableBalance(value) {
53
+ const candidates = [
54
+ value?.total_credits,
55
+ value?.credits_remaining,
56
+ value?.balance?.total_credits,
57
+ value?.balance?.spendable_credits,
58
+ ];
59
+ return candidates.find((candidate) => typeof candidate === "number" && Number.isFinite(candidate)) ?? null;
60
+ }
61
+
62
+ function paymentAction(contract) {
63
+ return json({
64
+ status: "human_action_required",
65
+ action: "checkout",
66
+ message: "Ask a human before surfacing a purchase action. After explicit consent, use payment_required.recommended_offer.checkout_url for the offered pack, or payment_required.billing_url if the human wants to choose another pack. This MCP server has not bought anything, saved a payment method, enabled auto-recharge, or started a subscription. It stores no unpaid target. After payment, the host may call resume_personal_context (alias: resume_connection_brief) with the original target, the returned retry.idempotency_key, and the previously observed available balance; the service verifies both the new balance and retry identity.",
67
+ payment_required: contract,
68
+ resume_tool: "resume_personal_context",
69
+ resume_tool_alias: "resume_connection_brief",
70
+ resume_requires: [
71
+ "original target fields",
72
+ "payment_required.retry.idempotency_key",
73
+ "payment_required.credits.available",
74
+ ],
75
+ fixture: contract.fixture,
76
+ });
77
+ }
78
+
79
+ function authenticationRequired() {
80
+ return json({
81
+ status: "human_action_required",
82
+ action: "programmatic_signup",
83
+ message: "No API key is configured. Begin named email approval; do not use a key from another person or assume approval.",
84
+ begin_tool: "begin_programmatic_signup",
85
+ }, true);
86
+ }
87
+
88
+ async function pollBrief(jobId) {
89
+ const deadline = Date.now() + 210000;
90
+ while (Date.now() < deadline) {
91
+ await new Promise((resolve) => setTimeout(resolve, 3000));
92
+ const poll = await api(`/api/jobs/${jobId}`, { method: "GET" });
93
+ if (poll.body.status === "done") return { kind: "brief", brief: poll.body.result };
94
+ if (poll.body.status === "refused") return { kind: "brief", brief: poll.body.result || { status: "needs_review", note: "Identity could not be confidently resolved." } };
95
+ if (poll.body.status === "failed") return { kind: "error", error: poll.body.error || "job_failed" };
96
+ }
97
+ return { kind: "pending", job_id: jobId };
98
+ }
99
+
100
+ async function submitBrief(args, idempotencyKey) {
101
+ const payload = { ...args, idempotency_key: idempotencyKey };
102
+ const submit = await api("/api/enrich", { body: payload });
103
+ if (submit.status === 402 && isPaymentRequired(submit.body)) return { kind: "payment_required", contract: submit.body };
104
+ if (submit.status === 401) return { kind: "unauthorized" };
105
+ if (submit.status === 409 && (submit.body.error === "idempotency_key_expired" || submit.body.error === "idempotency_key_mismatch"))
106
+ return { kind: "retry_rejected", error: submit.body.error };
107
+ if (submit.status >= 400) return { kind: "error", error: submit.body.error || `http_${submit.status}`, message: submit.body.message };
108
+ if (submit.body.personal_context || submit.body.brief)
109
+ return { kind: "brief", brief: submit.body.personal_context || submit.body.brief };
110
+ if (!submit.body.job_id) return { kind: "error", error: "invalid_enrich_response" };
111
+ return pollBrief(submit.body.job_id);
112
+ }
113
+
114
+ function briefInputSchema() {
115
+ return {
36
116
  linkedin_url: z.string().optional().describe("LinkedIn profile URL"),
37
117
  name: z.string().optional().describe("Full name (use with company)"),
38
118
  company: z.string().optional().describe("Company name (use with name)"),
@@ -40,135 +120,276 @@ server.tool(
40
120
  detail: z
41
121
  .enum(["compact", "full"])
42
122
  .optional()
43
- .describe("Response shape — compact (default, rolling out) or full"),
123
+ .describe(
124
+ 'Response shape. Defaults to "compact" (recommended for agents). Pass "full" for provenance sub-scores (debugging/eval/trust UI).',
125
+ ),
126
+ };
127
+ }
128
+
129
+ function cleanBriefArgs(args) {
130
+ const body = {};
131
+ for (const key of ["linkedin_url", "name", "company", "email", "detail"])
132
+ if (args[key]) body[key] = args[key];
133
+ // Agents should not inherit the HTTP production default (full). Compact is the
134
+ // recommended agent path; omit only means "use MCP default", not "use server default".
135
+ if (!body.detail) body.detail = "compact";
136
+ return body;
137
+ }
138
+
139
+ server.tool(
140
+ "begin_programmatic_signup",
141
+ "Start named API-key setup. It sends an approval link to a human-controlled email. Do not infer approval or claim a key until the approval endpoint reports approved. If claimed, the key is kept only by this running MCP process; durable named-key setup belongs to a human in Account.",
142
+ {
143
+ email: z.string().email().describe("Human-controlled email address"),
144
+ device_name: z.string().max(120).optional().describe("Named device the human should recognize"),
44
145
  },
45
146
  async (args) => {
46
- if (!KEY) {
47
- return {
48
- isError: true,
49
- content: [{ type: "text", text: "Missing UNIQUENESS_API_KEY. Get a free key: POST https://uniquenessengine.com/api/signup {email}." }],
50
- };
147
+ const response = await api("/api/signup", {
148
+ auth: false,
149
+ body: { email: args.email, client_name: "Uniqueness MCP", device_name: args.device_name || "MCP host" },
150
+ });
151
+ if (response.status !== 202 || response.body.status !== "email_verification_required")
152
+ return json({
153
+ status: "signup_failed",
154
+ error: response.body.error || response.status,
155
+ ...(response.body.error === "verification_delivery_failed"
156
+ ? {
157
+ retry_after_seconds: response.body.retry_after_seconds || 60,
158
+ message:
159
+ "No key was created. Start a new named signup after the retry interval.",
160
+ }
161
+ : {}),
162
+ }, true);
163
+ if (
164
+ typeof response.body.transaction_id !== "string" ||
165
+ !response.body.transaction_id ||
166
+ typeof response.body.polling_token !== "string" ||
167
+ !response.body.polling_token
168
+ )
169
+ return json({ status: "signup_failed", error: "invalid_verification_response" }, true);
170
+ pruneSignupContinuations();
171
+ const continuationId = randomUUID();
172
+ const parsedExpiry = Date.parse(response.body.expires_at || "");
173
+ signupContinuations.set(continuationId, {
174
+ transactionId: response.body.transaction_id,
175
+ pollingToken: response.body.polling_token,
176
+ claimAttempted: false,
177
+ expiresAt: Number.isFinite(parsedExpiry)
178
+ ? parsedExpiry
179
+ : Date.now() + 15 * 60 * 1000,
180
+ });
181
+ return json({
182
+ status: "human_action_required",
183
+ action: "email_approval",
184
+ message: "Ask the human to approve the named MCP/device request in their email. Approval is not inferred from this tool call.",
185
+ continuation_id: continuationId,
186
+ expires_at: response.body.expires_at,
187
+ complete_tool: "complete_programmatic_signup",
188
+ });
189
+ },
190
+ );
191
+
192
+ server.tool(
193
+ "complete_programmatic_signup",
194
+ "Check the in-process named email-approval request and claim a one-time key only after the server reports approved. The polling secret and key never appear in MCP output. A successful claim configures this running process only; a human must use Account for durable named-key setup.",
195
+ { continuation_id: z.string().uuid() },
196
+ async ({ continuation_id }) => {
197
+ pruneSignupContinuations();
198
+ const continuation = signupContinuations.get(continuation_id);
199
+ if (!continuation)
200
+ return json({
201
+ status: "verification_expired",
202
+ message:
203
+ "This process no longer holds that verification. Start a new named signup.",
204
+ });
205
+ if (continuation.claimAttempted)
206
+ return json({
207
+ status: "claim_already_attempted",
208
+ human_action_required: true,
209
+ action: "account_key_setup",
210
+ account_url: `${BASE}/account`,
211
+ message:
212
+ "To preserve the one-time claim boundary, this process will not call claim again. A human can sign in to Account for durable named-key setup.",
213
+ });
214
+ const path = `/api/signup/verification/${encodeURIComponent(continuation.transactionId)}`;
215
+ const headers = { "x-verification-token": continuation.pollingToken };
216
+ const poll = await fetch(`${BASE}${path}`, { headers });
217
+ const state = await poll.json().catch(() => ({}));
218
+ if (state.status !== "approved") {
219
+ const result = json({
220
+ status: state.status || "pending",
221
+ human_action_required: state.status === "pending",
222
+ message: state.status === "pending" ? "Approval is still pending. Wait for the human email action." : "This approval request cannot issue a key. Start a new named signup if needed.",
223
+ });
224
+ if (state.status && state.status !== "pending")
225
+ signupContinuations.delete(continuation_id);
226
+ return result;
51
227
  }
52
- if (!args.linkedin_url && !args.name) {
53
- return { isError: true, content: [{ type: "text", text: "Provide linkedin_url, or name (+ company)." }] };
228
+ // The service intentionally makes this a one-time disclosure. Do not retry a
229
+ // claim after an ambiguous response: the key could have been issued, and only
230
+ // a human Account session is appropriate for durable recovery/setup.
231
+ continuation.claimAttempted = true;
232
+ let issued;
233
+ try {
234
+ const claim = await fetch(`${BASE}${path}/claim`, { method: "POST", headers });
235
+ issued = await claim.json().catch(() => ({}));
236
+ } catch {
237
+ return json({
238
+ status: "claim_uncertain",
239
+ human_action_required: true,
240
+ action: "account_key_setup",
241
+ account_url: `${BASE}/account`,
242
+ message:
243
+ "The one-time claim response was not received. It will not be repeated here; a human can sign in to Account for durable named-key setup.",
244
+ }, true);
54
245
  }
55
- const body = {};
56
- if (args.linkedin_url) body.linkedin_url = args.linkedin_url;
57
- if (args.name) body.name = args.name;
58
- if (args.company) body.company = args.company;
59
- if (args.email) body.email = args.email;
60
- if (args.detail) body.detail = args.detail;
61
-
62
- // Async enrich+poll (mirrors cli/uniqueness.mjs `brief`). The sync /api/brief
63
- // path returns {} to the client past the ~124s delivery cliff on fresh
64
- // (uncached) profiles; /api/enrich submits a job we can poll to completion.
65
- // A cache hit / immediate brief comes back inline on the submit.
66
- const auth = { "content-type": "application/json", authorization: `Bearer ${KEY}` };
67
- const sub = await fetch(`${BASE}/api/enrich`, {
68
- method: "POST",
69
- headers: auth,
70
- body: JSON.stringify(body),
246
+ if (issued.status !== "approved" || !issued.api_key)
247
+ return json({
248
+ status: "claim_failed",
249
+ error: issued.status || "unknown",
250
+ human_action_required: true,
251
+ action: "account_key_setup",
252
+ account_url: `${BASE}/account`,
253
+ message:
254
+ "The one-time claim was not configured in this process. It will not be repeated here; a human can sign in to Account for durable named-key setup.",
255
+ }, true);
256
+ KEY = issued.api_key;
257
+ signupContinuations.delete(continuation_id);
258
+ return json({
259
+ status: "configured",
260
+ scope: "current_process_only",
261
+ account_url: `${BASE}/account`,
262
+ message: "MCP is configured only for this currently running process. The key was received once and is intentionally never repeated in tool output. For a durable named key or a future MCP configuration, have the human sign in to Account; do not ask an agent to expose or relay the key.",
71
263
  });
72
- const subJson = await sub.json().catch(() => ({}));
73
- if (sub.status === 401) return { isError: true, content: [{ type: "text", text: "401 unauthorized — invalid API key." }] };
74
- if (sub.status === 402) {
75
- if (subJson.error === "payment_method_required") {
76
- const reason = subJson.reason === "daily_free_budget_reached"
77
- ? "The shared daily free lookup budget is used. It resets daily; cached lookups still work. A completed purchase removes this cap."
78
- : subJson.reason === "uncached_live_run"
79
- ? "A card is required for another uncached live lookup. Any remaining credits still work on cached lookups."
80
- : "This lookup is blocked by the card gate. Cached lookups still work.";
81
- const setup = subJson.setup_url
82
- ? ` POST ${subJson.setup_url} with the Bearer key and follow the Stripe URL it returns.`
83
- : "";
84
- return { isError: true, content: [{ type: "text", text: `Card gate: ${reason}${setup}` }] };
85
- }
86
- if (subJson.error === "insufficient_credits") {
87
- return { isError: true, content: [{ type: "text", text: "Out of credits. Add credits or save a card with POST /api/billing/setup." }] };
88
- }
89
- return { isError: true, content: [{ type: "text", text: subJson.error || "Payment required." }] };
264
+ },
265
+ );
266
+
267
+ function registerGetPersonalContext(toolName, description) {
268
+ server.tool(toolName, description, briefInputSchema(), async (args) => {
269
+ if (!KEY) return authenticationRequired();
270
+ if (!args.linkedin_url && !args.name) return json({ error: "invalid_input", message: "Provide linkedin_url, or name (+ company)." }, true);
271
+ const body = cleanBriefArgs(args);
272
+ const result = await submitBrief(body, randomUUID());
273
+ if (result.kind === "payment_required") {
274
+ return paymentAction(result.contract);
90
275
  }
276
+ if (result.kind === "unauthorized") return json({ error: "unauthorized" }, true);
277
+ if (result.kind === "pending") return json({ status: "pending", job_id: result.job_id, message: "The job is still processing; call get_personal_context again with the same input to poll a fresh server request." });
278
+ if (result.kind === "retry_rejected") return json({ error: result.error, message: "Submit a new request with a new idempotency key." }, true);
279
+ if (result.kind === "error") return json({ error: result.error, message: result.message }, true);
280
+ return json(result.brief);
281
+ });
282
+ }
91
283
 
92
- // Fast path: cache hit / immediate brief.
93
- let brief = subJson.brief || null;
94
- let jobId = subJson.job_id || null;
95
- if (!brief) {
96
- if (!jobId) {
97
- return { isError: true, content: [{ type: "text", text: `enrich failed (${sub.status}): ${subJson.error || subJson.message || ""}` }] };
98
- }
99
- // Poll GET /api/jobs/:id every 3s until a terminal state or the deadline.
100
- // Briefs run ~45-180s, so give it up to ~210s before handing back the job_id.
101
- const deadline = Date.now() + 210000;
102
- while (Date.now() < deadline && !brief) {
103
- await new Promise((r) => setTimeout(r, 3000));
104
- const pr = await fetch(`${BASE}/api/jobs/${jobId}`, { headers: { authorization: `Bearer ${KEY}` } });
105
- const pj = await pr.json().catch(() => ({}));
106
- const st = pj.status;
107
- if (st === "done") brief = pj.result;
108
- else if (st === "refused") brief = pj.result || { status: "needs_review", note: "Identity could not be confidently resolved." };
109
- else if (st === "failed") return { isError: true, content: [{ type: "text", text: `job failed: ${pj.error || "unknown"}` }] };
110
- }
111
- if (!brief) {
112
- // Never return {} hand back the job_id so the caller can re-run to keep polling.
113
- return {
114
- content: [{ type: "text", text: `Still processing (job ${jobId}). The brief is taking longer than usual — re-run get_connection_brief with the same inputs to keep polling.` }],
115
- };
284
+ function registerResumePersonalContext(toolName, description) {
285
+ server.tool(
286
+ toolName,
287
+ description,
288
+ {
289
+ ...briefInputSchema(),
290
+ idempotency_key: z.string().min(1).max(200).describe("payment_required.retry.idempotency_key from the original 402"),
291
+ previous_available: z.number().int().nonnegative().describe("payment_required.credits.available from the original 402"),
292
+ },
293
+ async (args) => {
294
+ if (!KEY) return authenticationRequired();
295
+ const body = cleanBriefArgs(args);
296
+ if (!body.linkedin_url && !body.name)
297
+ return json({ error: "invalid_input", message: "Resupply the original linkedin_url, or name (+ company). No unpaid target is stored by this MCP server." }, true);
298
+ const account = await api("/api/account", { method: "GET" });
299
+ const balance = spendableBalance(account.body);
300
+ if (account.status !== 200 || balance === null || balance <= args.previous_available) {
301
+ return json({
302
+ status: "human_action_required",
303
+ action: "verify_checkout",
304
+ message: "The service does not yet show more spendable credit than the original 402, so the caller-supplied target was not retried. Complete/check Checkout and call this tool again; do not claim payment as proof.",
305
+ observed_balance: balance,
306
+ });
116
307
  }
117
- }
308
+ const result = await submitBrief(body, args.idempotency_key);
309
+ if (result.kind === "payment_required") return paymentAction(result.contract);
310
+ if (result.kind === "pending") return json({ status: "pending", job_id: result.job_id });
311
+ if (result.kind === "retry_rejected")
312
+ return json({ error: result.error, message: "Retry metadata is no longer valid. Submit a new request with a new idempotency key." }, true);
313
+ if (result.kind === "error" || result.kind === "unauthorized") return json({ error: result.error || "unauthorized", message: result.message }, true);
314
+ return json(result.brief);
315
+ },
316
+ );
317
+ }
118
318
 
119
- return { content: [{ type: "text", text: JSON.stringify(brief, null, 2) }] };
120
- }
319
+ function registerPreflightPersonalContexts(toolName, description) {
320
+ server.tool(
321
+ toolName,
322
+ description,
323
+ { profiles: z.array(z.object({ linkedin_url: z.string().optional(), name: z.string().optional(), company: z.string().optional(), email: z.string().optional() })).min(1).max(100) },
324
+ async ({ profiles }) => {
325
+ if (!KEY) return authenticationRequired();
326
+ const response = await api("/api/enrich/batch", { body: { profiles, estimate_only: true } });
327
+ if (response.status !== 200) return json({ error: response.body.error || `http_${response.status}`, message: response.body.message }, true);
328
+ return json({ status: "preflight", ...response.body, all_or_nothing: true });
329
+ },
330
+ );
331
+ }
332
+
333
+ registerGetPersonalContext(
334
+ "get_personal_context",
335
+ "Submit one full-quality written enrichment (personal context). A real target requires spendable credit. Defaults to detail=compact (recommended for agents); pass detail=full for provenance sub-scores. On canonical 402 this returns structured human_action_required; it never buys, saves a method, enables auto-recharge, or subscribes. Refusal, wrong-person, no-result, and system-failure outcomes release the exact credit. Support: support@uniquenessengine.com.",
336
+ );
337
+ registerGetPersonalContext(
338
+ "get_connection_brief",
339
+ "Deprecated alias for get_personal_context. Prefer get_personal_context.",
340
+ );
341
+ registerResumePersonalContext(
342
+ "resume_personal_context",
343
+ "After a human has completed Checkout, verify spendable credit with the service and only then resubmit caller-supplied original target fields with the 402 retry idempotency key. The MCP server stores no unpaid target and never trusts a payment assertion alone.",
344
+ );
345
+ registerResumePersonalContext(
346
+ "resume_connection_brief",
347
+ "Deprecated alias for resume_personal_context. Prefer resume_personal_context.",
348
+ );
349
+
350
+ server.tool(
351
+ "get_billing_status",
352
+ "Read spendable balance provenance and optional auto-recharge state. This is read-only; it never changes billing settings.",
353
+ {},
354
+ async () => {
355
+ if (!KEY) return authenticationRequired();
356
+ const [balance, recharge] = await Promise.all([
357
+ api("/api/account", { method: "GET" }),
358
+ api("/api/billing/auto-recharge", { method: "GET" }),
359
+ ]);
360
+ if (balance.status !== 200) return json({ error: balance.body.error || "balance_unavailable" }, true);
361
+ return json({
362
+ balance: balance.body,
363
+ auto_recharge: recharge.status === 200 ? recharge.body : { error: recharge.body.error || "unavailable" },
364
+ auto_recharge_note: "Auto-recharge is off by default. This MCP server cannot enable, retry, or disable it; a human must make an explicit choice in billing settings.",
365
+ });
366
+ },
367
+ );
368
+
369
+ registerPreflightPersonalContexts(
370
+ "preflight_personal_contexts",
371
+ "Read-only batch estimate. It never purchases credit, triggers auto-recharge, reserves credit, or starts a partial batch. Use the API directly with explicit human approval to submit an all-or-nothing batch.",
372
+ );
373
+ registerPreflightPersonalContexts(
374
+ "preflight_connection_briefs",
375
+ "Deprecated alias for preflight_personal_contexts. Prefer preflight_personal_contexts.",
121
376
  );
122
377
 
123
378
  server.tool(
124
379
  "submit_feedback",
125
- "Rate a Connection Brief 👍/👎 so the engine can improve. Pass the canonical_id " +
126
- "from the brief's identity (or the job_id if you have one) plus rating 'up' or 'down' " +
127
- "and an optional note on what was right or wrong.",
380
+ "Rate a delivered personal context. Feedback never changes billing.",
128
381
  {
129
- rating: z.enum(["up", "down"]).describe("'up' or 'down'"),
130
- canonical_id: z
131
- .string()
132
- .optional()
133
- .describe("brief.identity.canonical_id (preferred)"),
134
- job_id: z.string().optional().describe("job id, if you enriched async"),
135
- note: z.string().optional().describe("what was right/wrong (optional)"),
382
+ rating: z.enum(["up", "down"]),
383
+ canonical_id: z.string().optional(),
384
+ job_id: z.string().optional(),
385
+ note: z.string().optional(),
136
386
  },
137
387
  async (args) => {
138
- if (!KEY) {
139
- return {
140
- isError: true,
141
- content: [{ type: "text", text: "Missing UNIQUENESS_API_KEY." }],
142
- };
143
- }
144
- if (!args.canonical_id && !args.job_id) {
145
- return {
146
- isError: true,
147
- content: [
148
- { type: "text", text: "Provide canonical_id (or job_id) to rate." },
149
- ],
150
- };
151
- }
152
- const res = await fetch(`${BASE}/api/feedback`, {
153
- method: "POST",
154
- headers: {
155
- "content-type": "application/json",
156
- authorization: `Bearer ${KEY}`,
157
- },
158
- body: JSON.stringify({ ...args, source: "mcp" }),
159
- });
160
- const json = await res.json().catch(() => ({}));
161
- if (!res.ok)
162
- return {
163
- isError: true,
164
- content: [
165
- {
166
- type: "text",
167
- text: `feedback failed (${res.status}): ${json.error || ""}`,
168
- },
169
- ],
170
- };
171
- return { content: [{ type: "text", text: "Thanks — feedback recorded." }] };
388
+ if (!KEY) return authenticationRequired();
389
+ if (!args.canonical_id && !args.job_id) return json({ error: "invalid_input", message: "Provide canonical_id or job_id." }, true);
390
+ const response = await api("/api/feedback", { body: { ...args, source: "mcp" } });
391
+ if (response.status !== 200) return json({ error: response.body.error || `http_${response.status}` }, true);
392
+ return json({ status: "recorded", message: "Feedback recorded." });
172
393
  },
173
394
  );
174
395
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "uniqueness-mcp",
3
- "version": "0.4.2",
4
- "description": "MCP server for Uniqueness Engine fact-checked connection briefs as an agent tool.",
3
+ "version": "0.5.1",
4
+ "description": "Conservative human-in-the-loop MCP server for Uniqueness Engine fact-checked connection briefs.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "uniqueness-mcp": "index.mjs"
8
8
  },
9
9
  "files": [
10
- "index.mjs"
10
+ "index.mjs",
11
+ "README.md"
11
12
  ],
12
13
  "engines": {
13
14
  "node": ">=18"