@quidli/connect-mcp 0.3.0 → 0.4.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 CHANGED
@@ -8,38 +8,44 @@ Use [Quidli Connect](https://connect.quid.li) from Cursor, Claude Desktop, or an
8
8
  | | **Hosted** (`mcp.connect.quid.li`) | **Local** (`npx @quidli/connect-mcp`) |
9
9
  | --------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
10
10
  | **Setup** | Add a URL in MCP settings | Run a local process via `npx` |
11
- | **Auth** | Connect API key only | API key, x402 pay-per-call, or none for lookup/scores/agent |
12
- | **Pros** | No install; always on Quidli infrastructure; simplest setup | Wallet private key never leaves your machine; pay per call with USDC instead of an API key |
13
- | **Cons** | Requires a Connect API key; no pay-per-call billing | Requires Node.js 20+; `npx` may download the package on first run |
11
+ | **Auth** | Optional API key. Without a key: lookup/scores/agent/price under a shared anonymous quota | API key, x402 pay-per-call, or none for lookup/scores/agent |
12
+ | **Pros** | No install; try without a key; always on Quidli infrastructure | Wallet private key never leaves your machine; pay per call with USDC instead of an API key |
13
+ | **Cons** | Anonymous traffic shares a global rate limit; no pay-per-call billing | Requires Node.js 20+; `npx` may download the package on first run |
14
14
  | **Limitations** | Cannot use x402 / wallet auth — do **not** send a private key to the hosted endpoint | x402 mode needs USDC on Base (mainnet `8453`); `connect_drop` requires an API key |
15
15
 
16
16
 
17
- Get a Connect API key at [connect.quid.li](https://connect.quid.li) → **Enable API access**.
17
+ Get a Connect API key at [connect.quid.li](https://connect.quid.li) → **Enable API access** for higher limits, `connect_me`, and Smart Send.
18
18
 
19
19
  ---
20
20
 
21
21
  ## Hosted (recommended)
22
22
 
23
- Best if you have a Connect API key and want zero local setup.
23
+ Zero local setup. Lookup, scores, agent, and price work without an API key (shared anonymous quota). Add a key for higher limits, your profile (`connect_me`), and Smart Send.
24
24
 
25
25
  ### Cursor
26
26
 
27
27
  **If you already have other MCP servers:** skip the deeplink and [add manually](#cursor-manual) — the one-click install can overwrite your entire `mcp.json` in some Cursor versions.
28
28
 
29
- [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=quidli-connect&config=eyJ1cmwiOiJodHRwczovL21jcC5jb25uZWN0LnF1aWQubGkiLCJoZWFkZXJzIjp7IngtYXBpLWtleSI6IiJ9fQ==)
29
+ [Add to Cursor](cursor://anysphere.cursor-deeplink/mcp/install?name=quidli-connect&config=eyJ1cmwiOiJodHRwczovL21jcC5jb25uZWN0LnF1aWQubGkifQ==)
30
30
 
31
31
  Or paste into **Cursor Settings → MCP → Add new MCP server**:
32
32
 
33
33
  ```
34
- cursor://anysphere.cursor-deeplink/mcp/install?name=quidli-connect&config=eyJ1cmwiOiJodHRwczovL21jcC5jb25uZWN0LnF1aWQubGkiLCJoZWFkZXJzIjp7IngtYXBpLWtleSI6IiJ9fQ==
34
+ cursor://anysphere.cursor-deeplink/mcp/install?name=quidli-connect&config=eyJ1cmwiOiJodHRwczovL21jcC5jb25uZWN0LnF1aWQubGkifQ==
35
35
  ```
36
36
 
37
- Then open **Cursor Settings → MCP → quidli-connect** and set `headers.x-api-key` to your Connect API key.
38
-
39
37
  #### Add manually {#cursor-manual}
40
38
 
41
39
  Merge this into `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project) under `mcpServers` — do not replace your existing entries:
42
40
 
41
+ ```json
42
+ "quidli-connect": {
43
+ "url": "https://mcp.connect.quid.li"
44
+ }
45
+ ```
46
+
47
+ Optional — higher limits and Smart Send:
48
+
43
49
  ```json
44
50
  "quidli-connect": {
45
51
  "url": "https://mcp.connect.quid.li",
@@ -52,7 +58,7 @@ Merge this into `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project) un
52
58
  ### Claude Desktop
53
59
 
54
60
  1. Open **Claude → Settings → Connectors** (or **Settings → Developer → Edit Config** on older versions).
55
- 2. Add a custom MCP connector with URL `https://mcp.connect.quid.li` and header `x-api-key: <your-connect-api-key>`.
61
+ 2. Add a custom MCP connector with URL `https://mcp.connect.quid.li`. Optionally set header `x-api-key: <your-connect-api-key>`.
56
62
 
57
63
  Or merge this into your config file and restart Claude:
58
64
 
@@ -63,10 +69,7 @@ Or merge this into your config file and restart Claude:
63
69
  {
64
70
  "mcpServers": {
65
71
  "quidli-connect": {
66
- "url": "https://mcp.connect.quid.li",
67
- "headers": {
68
- "x-api-key": "<your-connect-api-key>"
69
- }
72
+ "url": "https://mcp.connect.quid.li"
70
73
  }
71
74
  }
72
75
  }
@@ -83,13 +86,8 @@ If your Claude version does not support remote `url` connectors, use the local b
83
86
  "-y",
84
87
  "mcp-remote@latest",
85
88
  "--http",
86
- "https://mcp.connect.quid.li",
87
- "--header",
88
- "x-api-key:${CONNECT_API_KEY}"
89
- ],
90
- "env": {
91
- "CONNECT_API_KEY": "<your-connect-api-key>"
92
- }
89
+ "https://mcp.connect.quid.li"
90
+ ]
93
91
  }
94
92
  }
95
93
  }
@@ -97,7 +95,7 @@ If your Claude version does not support remote `url` connectors, use the local b
97
95
 
98
96
  Requires **Node.js 20+** for the bridge. Restart Claude Desktop after saving. You should see a hammer icon in the chat input when tools are available.
99
97
 
100
- Until a valid API key is set, requests return `401` with setup instructions.
98
+ Without a key, `initialize` / `tools/list` always succeed. Anonymous `tools/call` share a global quota (HTTP **429** when exceeded — get a key for higher limits). Placeholder keys still return **401**.
101
99
 
102
100
  ---
103
101
 
@@ -0,0 +1,28 @@
1
+ import type { AnonymousQuotaConfig } from './config.js';
2
+ export declare const ANON_QUOTA_EXEMPT_TOOLS: Set<string>;
3
+ export declare const ANON_AGENT_TOOL = "connect_agent_prompt";
4
+ export interface QuotaDecision {
5
+ allowed: boolean;
6
+ retryAfterSec?: number;
7
+ message?: string;
8
+ }
9
+ export declare class SlidingWindowLimiter {
10
+ private readonly limit;
11
+ private readonly windowMs;
12
+ private readonly now;
13
+ private hits;
14
+ constructor(limit: number, windowMs?: number, now?: () => number);
15
+ private prune;
16
+ wouldExceed(): {
17
+ exceeded: boolean;
18
+ retryAfterSec: number;
19
+ };
20
+ record(): void;
21
+ }
22
+ export declare class AnonymousToolQuota {
23
+ private readonly global;
24
+ private readonly agent;
25
+ constructor(global: SlidingWindowLimiter, agent: SlidingWindowLimiter);
26
+ static fromConfig(config: AnonymousQuotaConfig, now?: () => number): AnonymousToolQuota;
27
+ consume(body: unknown): QuotaDecision;
28
+ }
@@ -0,0 +1,78 @@
1
+ import { getJsonRpcMethod, getJsonRpcToolName } from './jsonrpc.js';
2
+ export const ANON_QUOTA_EXEMPT_TOOLS = new Set(['connect_get_price']);
3
+ export const ANON_AGENT_TOOL = 'connect_agent_prompt';
4
+ const GLOBAL_QUOTA_MESSAGE = 'Anonymous MCP quota exceeded. Get a Connect API key at https://connect.quid.li (Enable API access) for higher limits.';
5
+ const AGENT_QUOTA_MESSAGE = 'Anonymous MCP agent quota exceeded. Get a Connect API key at https://connect.quid.li (Enable API access) for higher limits.';
6
+ export class SlidingWindowLimiter {
7
+ limit;
8
+ windowMs;
9
+ now;
10
+ hits = [];
11
+ constructor(limit, windowMs = 60_000, now = Date.now) {
12
+ this.limit = limit;
13
+ this.windowMs = windowMs;
14
+ this.now = now;
15
+ }
16
+ prune(now) {
17
+ const cutoff = now - this.windowMs;
18
+ while (this.hits.length > 0 && this.hits[0] <= cutoff) {
19
+ this.hits.shift();
20
+ }
21
+ }
22
+ wouldExceed() {
23
+ const now = this.now();
24
+ this.prune(now);
25
+ if (this.hits.length >= this.limit) {
26
+ const retryAfterSec = Math.max(1, Math.ceil((this.hits[0] + this.windowMs - now) / 1000));
27
+ return { exceeded: true, retryAfterSec };
28
+ }
29
+ return { exceeded: false, retryAfterSec: 0 };
30
+ }
31
+ record() {
32
+ this.hits.push(this.now());
33
+ }
34
+ }
35
+ export class AnonymousToolQuota {
36
+ global;
37
+ agent;
38
+ constructor(global, agent) {
39
+ this.global = global;
40
+ this.agent = agent;
41
+ }
42
+ static fromConfig(config, now) {
43
+ return new AnonymousToolQuota(new SlidingWindowLimiter(config.rpm, 60_000, now), new SlidingWindowLimiter(config.agentRpm, 60_000, now));
44
+ }
45
+ consume(body) {
46
+ const method = getJsonRpcMethod(body);
47
+ if (method !== 'tools/call') {
48
+ return { allowed: true };
49
+ }
50
+ const toolName = getJsonRpcToolName(body);
51
+ if (toolName && ANON_QUOTA_EXEMPT_TOOLS.has(toolName)) {
52
+ return { allowed: true };
53
+ }
54
+ if (toolName === ANON_AGENT_TOOL) {
55
+ const agentLimit = this.agent.wouldExceed();
56
+ if (agentLimit.exceeded) {
57
+ return {
58
+ allowed: false,
59
+ retryAfterSec: agentLimit.retryAfterSec,
60
+ message: AGENT_QUOTA_MESSAGE,
61
+ };
62
+ }
63
+ }
64
+ const globalLimit = this.global.wouldExceed();
65
+ if (globalLimit.exceeded) {
66
+ return {
67
+ allowed: false,
68
+ retryAfterSec: globalLimit.retryAfterSec,
69
+ message: GLOBAL_QUOTA_MESSAGE,
70
+ };
71
+ }
72
+ if (toolName === ANON_AGENT_TOOL) {
73
+ this.agent.record();
74
+ }
75
+ this.global.record();
76
+ return { allowed: true };
77
+ }
78
+ }
package/dist/config.d.ts CHANGED
@@ -19,5 +19,12 @@ export declare function loadHttpServerConfig(env?: NodeJS.ProcessEnv): HttpServe
19
19
  * or with CONNECT_API_KEY / EVM_PRIVATE_KEY. /account/me and /drop require an API key.
20
20
  */
21
21
  export declare function loadConfig(env?: NodeJS.ProcessEnv): ConnectClientConfig;
22
- /** Hosted HTTP MCP: api-key from request header only. */
23
- export declare function createHostedClientConfig(apiKey: string, baseUrl: string): ConnectClientConfig;
22
+ export interface AnonymousQuotaConfig {
23
+ allowAnonymous: boolean;
24
+ rpm: number;
25
+ agentRpm: number;
26
+ }
27
+ export declare function isAnonymousMcpAllowed(env?: NodeJS.ProcessEnv): boolean;
28
+ export declare function loadAnonymousQuotaConfig(env?: NodeJS.ProcessEnv): AnonymousQuotaConfig;
29
+ /** Hosted HTTP MCP: api-key from request header when present. */
30
+ export declare function createHostedClientConfig(apiKey: string | undefined, baseUrl: string): ConnectClientConfig;
package/dist/config.js CHANGED
@@ -39,11 +39,35 @@ export function loadConfig(env = process.env) {
39
39
  x402EvmNetwork,
40
40
  };
41
41
  }
42
- /** Hosted HTTP MCP: api-key from request header only. */
42
+ const DEFAULT_ANON_RPM = 50;
43
+ const DEFAULT_ANON_AGENT_RPM = 5;
44
+ export function isAnonymousMcpAllowed(env = process.env) {
45
+ const raw = env.CONNECT_MCP_ALLOW_ANONYMOUS?.trim().toLowerCase();
46
+ return raw === 'true' || raw === '1';
47
+ }
48
+ function parsePositiveInt(raw, fallback, name) {
49
+ if (!raw?.trim()) {
50
+ return fallback;
51
+ }
52
+ const value = Number(raw);
53
+ if (!Number.isInteger(value) || value < 1) {
54
+ throw new Error(`${name} must be a positive integer (got "${raw}")`);
55
+ }
56
+ return value;
57
+ }
58
+ export function loadAnonymousQuotaConfig(env = process.env) {
59
+ return {
60
+ allowAnonymous: isAnonymousMcpAllowed(env),
61
+ rpm: parsePositiveInt(env.CONNECT_MCP_ANON_RPM, DEFAULT_ANON_RPM, 'CONNECT_MCP_ANON_RPM'),
62
+ agentRpm: parsePositiveInt(env.CONNECT_MCP_ANON_AGENT_RPM, DEFAULT_ANON_AGENT_RPM, 'CONNECT_MCP_ANON_AGENT_RPM'),
63
+ };
64
+ }
65
+ /** Hosted HTTP MCP: api-key from request header when present. */
43
66
  export function createHostedClientConfig(apiKey, baseUrl) {
67
+ const trimmed = apiKey?.trim() || undefined;
44
68
  return {
45
69
  baseUrl,
46
- apiKey,
70
+ apiKey: trimmed,
47
71
  x402EvmNetwork: DEFAULT_X402_EVM_NETWORK,
48
72
  };
49
73
  }
@@ -1,6 +1,6 @@
1
1
  export interface CursorMcpRemoteConfig {
2
2
  url: string;
3
- headers: {
3
+ headers?: {
4
4
  'x-api-key': string;
5
5
  };
6
6
  }
package/dist/deeplink.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import { CONNECT_MCP_API_KEY_PLACEHOLDER, CONNECT_MCP_REMOTE_URL } from './metadata.js';
2
2
  export function buildCursorRemoteMcpConfig(apiKey = CONNECT_MCP_API_KEY_PLACEHOLDER) {
3
+ const trimmed = apiKey.trim();
4
+ if (!trimmed) {
5
+ return { url: CONNECT_MCP_REMOTE_URL };
6
+ }
3
7
  return {
4
8
  url: CONNECT_MCP_REMOTE_URL,
5
9
  headers: {
6
- 'x-api-key': apiKey,
10
+ 'x-api-key': trimmed,
7
11
  },
8
12
  };
9
13
  }
@@ -0,0 +1,21 @@
1
+ import { AnonymousToolQuota } from './anonymous-quota.js';
2
+ import { type HeaderCarrier } from './http-auth.js';
3
+ export interface HostedMcpGateOk {
4
+ ok: true;
5
+ apiKey?: string;
6
+ }
7
+ export interface HostedMcpGateError {
8
+ ok: false;
9
+ status: number;
10
+ message: string;
11
+ code: number;
12
+ retryAfterSec?: number;
13
+ }
14
+ export type HostedMcpGateResult = HostedMcpGateOk | HostedMcpGateError;
15
+ export interface HostedMcpGateRequest extends HeaderCarrier {
16
+ body?: unknown;
17
+ }
18
+ export declare function gateHostedMcpRequest(req: HostedMcpGateRequest, options: {
19
+ env?: NodeJS.ProcessEnv;
20
+ anonymousQuota: AnonymousToolQuota;
21
+ }): HostedMcpGateResult;
@@ -0,0 +1,21 @@
1
+ import { validateRequestApiKey } from './http-auth.js';
2
+ export function gateHostedMcpRequest(req, options) {
3
+ const env = options.env ?? process.env;
4
+ const auth = validateRequestApiKey(req, env);
5
+ if (!auth.ok) {
6
+ return { ok: false, status: 401, message: auth.message, code: -32001 };
7
+ }
8
+ if (!auth.apiKey) {
9
+ const quota = options.anonymousQuota.consume(req.body);
10
+ if (!quota.allowed) {
11
+ return {
12
+ ok: false,
13
+ status: 429,
14
+ message: quota.message ?? 'Anonymous MCP quota exceeded.',
15
+ code: -32029,
16
+ retryAfterSec: quota.retryAfterSec,
17
+ };
18
+ }
19
+ }
20
+ return { ok: true, apiKey: auth.apiKey };
21
+ }
@@ -1,13 +1,16 @@
1
- import type { IncomingMessage } from 'node:http';
1
+ import type { IncomingHttpHeaders } from 'node:http';
2
2
  export interface RequestApiKeyResult {
3
3
  ok: true;
4
- apiKey: string;
4
+ apiKey?: string;
5
5
  }
6
6
  export interface RequestApiKeyError {
7
7
  ok: false;
8
8
  message: string;
9
9
  }
10
10
  export type RequestApiKeyValidation = RequestApiKeyResult | RequestApiKeyError;
11
- export declare function extractRequestApiKey(req: IncomingMessage): string | undefined;
11
+ export interface HeaderCarrier {
12
+ headers: IncomingHttpHeaders;
13
+ }
14
+ export declare function extractRequestApiKey(req: HeaderCarrier): string | undefined;
12
15
  export declare function isPlaceholderApiKey(apiKey: string): boolean;
13
- export declare function validateRequestApiKey(req: IncomingMessage): RequestApiKeyValidation;
16
+ export declare function validateRequestApiKey(req: HeaderCarrier, env?: NodeJS.ProcessEnv): RequestApiKeyValidation;
package/dist/http-auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isAnonymousMcpAllowed } from './config.js';
1
2
  import { CONNECT_MCP_API_KEY_PLACEHOLDER } from './metadata.js';
2
3
  const PLACEHOLDER_API_KEYS = new Set([
3
4
  'your-connect-api-key',
@@ -14,9 +15,12 @@ export function extractRequestApiKey(req) {
14
15
  export function isPlaceholderApiKey(apiKey) {
15
16
  return PLACEHOLDER_API_KEYS.has(apiKey);
16
17
  }
17
- export function validateRequestApiKey(req) {
18
+ export function validateRequestApiKey(req, env = process.env) {
18
19
  const apiKey = extractRequestApiKey(req);
19
20
  if (!apiKey) {
21
+ if (isAnonymousMcpAllowed(env)) {
22
+ return { ok: true };
23
+ }
20
24
  return {
21
25
  ok: false,
22
26
  message: 'Missing x-api-key header. Get a key at https://connect.quid.li (Enable API access) and set it in your MCP headers.',
@@ -0,0 +1,2 @@
1
+ export declare function getJsonRpcMethod(body: unknown): string | undefined;
2
+ export declare function getJsonRpcToolName(body: unknown): string | undefined;
@@ -0,0 +1,14 @@
1
+ export function getJsonRpcMethod(body) {
2
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
3
+ return undefined;
4
+ }
5
+ const method = body.method;
6
+ return typeof method === 'string' ? method : undefined;
7
+ }
8
+ export function getJsonRpcToolName(body) {
9
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
10
+ return undefined;
11
+ }
12
+ const params = body.params;
13
+ return typeof params?.name === 'string' ? params.name : undefined;
14
+ }
package/dist/metadata.js CHANGED
@@ -3,7 +3,7 @@ export const CONNECT_MCP_REMOTE_URL = 'https://mcp.connect.quid.li';
3
3
  export const CONNECT_MCP_SERVER_INFO = {
4
4
  name: 'quidli-connect',
5
5
  title: 'Quidli Connect',
6
- version: '0.1.0',
6
+ version: '0.4.0',
7
7
  websiteUrl: 'https://connect.quid.li',
8
8
  icons: [
9
9
  {
package/dist/mount.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import type { IRouter } from 'express';
2
+ import { AnonymousToolQuota } from './anonymous-quota.js';
2
3
  export { resolveRequestHost } from './request-host.js';
3
4
  export interface ConnectMcpMountOptions {
4
5
  baseUrl: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ anonymousQuota?: AnonymousToolQuota;
5
8
  }
6
9
  export declare function resolveEmbeddedApiBaseUrl(port: number, env?: NodeJS.ProcessEnv): string;
7
10
  export declare function isConnectMcpHost(hostname: string, env?: NodeJS.ProcessEnv): boolean;
package/dist/mount.js CHANGED
@@ -1,16 +1,20 @@
1
1
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
2
- import { createHostedClientConfig } from './config.js';
2
+ import { AnonymousToolQuota } from './anonymous-quota.js';
3
+ import { createHostedClientConfig, loadAnonymousQuotaConfig } from './config.js';
3
4
  import { createMcpServer } from './create-mcp-server.js';
4
- import { validateRequestApiKey } from './http-auth.js';
5
+ import { gateHostedMcpRequest } from './hosted-gate.js';
5
6
  export { resolveRequestHost } from './request-host.js';
6
- function sendJsonRpcError(res, status, message) {
7
+ function sendJsonRpcError(res, status, message, options) {
7
8
  if (res.headersSent) {
8
9
  return;
9
10
  }
11
+ if (options?.retryAfterSec) {
12
+ res.setHeader('Retry-After', String(options.retryAfterSec));
13
+ }
10
14
  res.status(status).json({
11
15
  jsonrpc: '2.0',
12
16
  error: {
13
- code: -32001,
17
+ code: options?.code ?? -32001,
14
18
  message,
15
19
  },
16
20
  id: null,
@@ -37,6 +41,8 @@ export function isConnectMcpHost(hostname, env = process.env) {
37
41
  }
38
42
  export function mountConnectMcpHttp(router, options) {
39
43
  const { baseUrl } = options;
44
+ const env = options.env ?? process.env;
45
+ const anonymousQuota = options.anonymousQuota ?? AnonymousToolQuota.fromConfig(loadAnonymousQuotaConfig(env));
40
46
  router.get('/health', (_req, res) => {
41
47
  res.json({ ok: true, service: 'quidli-connect-mcp' });
42
48
  });
@@ -44,13 +50,16 @@ export function mountConnectMcpHttp(router, options) {
44
50
  // No in-memory session map, so Cloud Run restarts / multiple instances
45
51
  // never cause "Missing or invalid MCP session" errors.
46
52
  router.all('/', async (req, res) => {
47
- const auth = validateRequestApiKey(req);
48
- if (!auth.ok) {
49
- sendJsonRpcError(res, 401, auth.message);
53
+ const gate = gateHostedMcpRequest(req, { env, anonymousQuota });
54
+ if (!gate.ok) {
55
+ sendJsonRpcError(res, gate.status, gate.message, {
56
+ code: gate.code,
57
+ retryAfterSec: gate.retryAfterSec,
58
+ });
50
59
  return;
51
60
  }
52
61
  try {
53
- const server = createMcpServer(createHostedClientConfig(auth.apiKey, baseUrl));
62
+ const server = createMcpServer(createHostedClientConfig(gate.apiKey, baseUrl));
54
63
  const transport = new StreamableHTTPServerTransport({
55
64
  sessionIdGenerator: undefined, // stateless — no session tracking
56
65
  enableJsonResponse: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quidli/connect-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Quidli Connect product APIs",
5
5
  "keywords": [
6
6
  "mcp",