yhn-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/dist/index.js +395 -0
  4. package/package.json +47 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 y.hn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # yhn-mcp
2
+
3
+ Model Context Protocol server for [y.hn](https://y.hn). Lets Claude Code, Cursor, Cline, and any other MCP-aware AI agent create and manage short links by talking to the y.hn API.
4
+
5
+ ## Quick start
6
+
7
+ 1. Generate an API key at <https://y.hn/dashboard/settings>.
8
+ 2. Add the server to your MCP client config (examples below).
9
+ 3. Restart the client. Type something like *"Shorten https://example.com with custom slug demo"* — the agent now has tools.
10
+
11
+ No global install needed; `npx` fetches the package on demand.
12
+
13
+ ### Claude Code
14
+
15
+ Add to `~/.claude.json` (or your project `.mcp.json`):
16
+
17
+ ```json
18
+ {
19
+ "mcpServers": {
20
+ "yhn": {
21
+ "command": "npx",
22
+ "args": ["-y", "yhn-mcp"],
23
+ "env": {
24
+ "YHN_API_KEY": "yhn_..."
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ ### Cursor
32
+
33
+ `Settings → MCP → New MCP Server`, paste the same JSON.
34
+
35
+ ### Cline / Continue / other MCP clients
36
+
37
+ Same JSON shape, drop into the client's MCP config. Anything that speaks stdio MCP works.
38
+
39
+ ## Available tools
40
+
41
+ | Tool | Purpose |
42
+ |---|---|
43
+ | `whoami` | Verify the API key, return plan + email |
44
+ | `create_link` | Create a short link (url, optional customSlug, password, expiresAt, OG, UTM, tags) |
45
+ | `list_links` | Paginated list with search / folder / tag filter |
46
+ | `get_link` | Fetch one link by ID |
47
+ | `update_link` | Patch any field (retarget, expire, archive, move folder) |
48
+ | `delete_link` | Permanent delete |
49
+ | `get_link_stats` | Time series + geo + device + referrer for one link |
50
+ | `bulk_create_links` | Create many links in one call (CSV import / migration) |
51
+ | `get_qr_url` | Returns a signed-style QR PNG URL for a link |
52
+ | `list_folders` / `create_folder` | Folder management |
53
+ | `list_tags` / `create_tag` | Tag management |
54
+ | `analytics_overview` | Account-wide totals + top links |
55
+ | `analytics_geo` | Country/city click breakdown |
56
+ | `list_conversion_goals` / `track_conversion` | Server-side conversion events |
57
+ | `list_webhooks` / `create_webhook` | Webhook subscriptions |
58
+ | `list_domains` | Custom domains (Pro+) |
59
+
60
+ More endpoints (A/B test, geo/device routing rules, pixels, marketplace) are exposed via the raw [y.hn API](https://y.hn/docs); ask if you'd like a tool for them.
61
+
62
+ ## Configuration
63
+
64
+ | env var | default | purpose |
65
+ |---|---|---|
66
+ | `YHN_API_KEY` | *required* | Your y.hn API key (`yhn_...`) |
67
+ | `YHN_BASE_URL` | `https://y.hn/api` | Override for self-hosted / staging |
68
+
69
+ ## Examples
70
+
71
+ Once installed, you can prompt naturally:
72
+
73
+ > Shorten `https://huggingface.co/blog/agents` and put it in folder `Reading list`.
74
+
75
+ > Bulk-shorten the URLs in `/path/to/links.txt`, set utm_source=newsletter on all of them, and email me the resulting CSV.
76
+
77
+ > What's my top performing link this week? Pull stats and tell me where the clicks came from.
78
+
79
+ The agent picks the right tool, calls it, and continues.
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ npm install
85
+ npm run build
86
+ node dist/index.js # smoke test (will exit because YHN_API_KEY missing)
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * y.hn MCP server.
4
+ *
5
+ * Lets AI agents (Claude Code, Cursor, Cline, etc.) create and manage y.hn short
6
+ * links over the Model Context Protocol. Auth is via your y.hn API key.
7
+ *
8
+ * Get a key at https://y.hn/dashboard/settings, then set YHN_API_KEY before
9
+ * launching this server.
10
+ */
11
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
12
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
13
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
14
+ const BASE_URL = (process.env.YHN_BASE_URL || 'https://y.hn/api').replace(/\/+$/, '');
15
+ const API_KEY = process.env.YHN_API_KEY;
16
+ if (!API_KEY) {
17
+ process.stderr.write('YHN_API_KEY is required.\n' +
18
+ 'Generate one at https://y.hn/dashboard/settings, then add it to your\n' +
19
+ 'MCP client config (Claude Code, Cursor, Cline, etc.).\n');
20
+ process.exit(1);
21
+ }
22
+ async function api(method, path, body, query) {
23
+ let url = `${BASE_URL}${path}`;
24
+ if (query) {
25
+ const sp = new URLSearchParams();
26
+ for (const [k, v] of Object.entries(query)) {
27
+ if (v !== undefined && v !== null && v !== '')
28
+ sp.set(k, String(v));
29
+ }
30
+ const qs = sp.toString();
31
+ if (qs)
32
+ url += `?${qs}`;
33
+ }
34
+ const res = await fetch(url, {
35
+ method,
36
+ headers: {
37
+ 'x-api-key': API_KEY,
38
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
39
+ },
40
+ body: body !== undefined ? JSON.stringify(body) : undefined,
41
+ });
42
+ const text = await res.text();
43
+ let parsed = text;
44
+ if (text) {
45
+ try {
46
+ parsed = JSON.parse(text);
47
+ }
48
+ catch {
49
+ // leave as text
50
+ }
51
+ }
52
+ if (!res.ok) {
53
+ const errMsg = parsed?.error ||
54
+ (typeof parsed === 'string' ? parsed : '') ||
55
+ res.statusText;
56
+ throw new Error(`yhn ${method} ${path} → ${res.status} ${errMsg}`);
57
+ }
58
+ return parsed;
59
+ }
60
+ const tools = [
61
+ // ── Account / auth ────────────────────────────────────────────────────
62
+ {
63
+ name: 'whoami',
64
+ description: 'Verify the configured y.hn API key and return account info: email, plan tier (free/pro/business), name. Use this first if anything feels off.',
65
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
66
+ run: () => api('GET', '/settings/api-key'),
67
+ },
68
+ // ── Links: CRUD ───────────────────────────────────────────────────────
69
+ {
70
+ name: 'create_link',
71
+ description: 'Create a new short link. Returns { shortUrl, slug, id, targetUrl }. ' +
72
+ "Tip: customSlug is optional — if omitted, y.hn generates a 6-char slug. " +
73
+ "Free plan: 25 links/month, links auto-expire in 30 days, no password, slugs must be ≥6 chars. " +
74
+ "Pro+: unlimited links, custom expiry, password, shorter slugs. " +
75
+ 'Pass UTM and OG fields directly — y.hn appends UTM to the redirect target and serves OG tags on share preview.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ url: { type: 'string', description: 'Target (long) URL to shorten. Must be http(s).' },
80
+ customSlug: {
81
+ type: 'string',
82
+ description: 'Optional custom slug. a-z0-9- only. ≥6 chars on free; ≥3 chars on Pro.',
83
+ },
84
+ password: { type: 'string', description: 'Pro+: password-protect the redirect.' },
85
+ expiresAt: { type: 'string', description: 'Pro+: ISO 8601 expiration timestamp.' },
86
+ fallbackUrl: {
87
+ type: 'string',
88
+ description: 'Where to send visitors when the link expires or hits maxClicks.',
89
+ },
90
+ maxClicks: { type: 'number', description: 'Deactivate after N clicks.' },
91
+ ogTitle: { type: 'string', description: 'Open Graph title for social previews.' },
92
+ ogDescription: { type: 'string', description: 'Open Graph description.' },
93
+ ogImage: { type: 'string', description: 'Open Graph image URL.' },
94
+ utmSource: { type: 'string' },
95
+ utmMedium: { type: 'string' },
96
+ utmCampaign: { type: 'string' },
97
+ tagIds: {
98
+ type: 'array',
99
+ items: { type: 'string' },
100
+ description: 'Tag IDs to attach (use list_tags / create_tag to manage tags).',
101
+ },
102
+ },
103
+ required: ['url'],
104
+ additionalProperties: false,
105
+ },
106
+ run: (args) => api('POST', '/links', args),
107
+ },
108
+ {
109
+ name: 'list_links',
110
+ description: 'List the authenticated user\'s links, paginated. Returns { links, total, page, totalPages }. ' +
111
+ 'Each link has slug, targetUrl, clicks, createdAt, archived, suspicious, and full metadata.',
112
+ inputSchema: {
113
+ type: 'object',
114
+ properties: {
115
+ page: { type: 'number', description: 'Page number (default 1).' },
116
+ limit: { type: 'number', description: 'Page size (default 20, max 100).' },
117
+ search: {
118
+ type: 'string',
119
+ description: 'Search across slug, targetUrl, title, tags (case-insensitive substring).',
120
+ },
121
+ folderId: { type: 'string', description: 'Filter to a specific folder.' },
122
+ tag: { type: 'string', description: 'Filter to links containing this tag substring.' },
123
+ },
124
+ additionalProperties: false,
125
+ },
126
+ run: (args) => api('GET', '/links', undefined, args),
127
+ },
128
+ {
129
+ name: 'get_link',
130
+ description: 'Get a single link by linkId (UUID). Returns full link object including rules, conversions, etc.',
131
+ inputSchema: {
132
+ type: 'object',
133
+ properties: { linkId: { type: 'string' } },
134
+ required: ['linkId'],
135
+ additionalProperties: false,
136
+ },
137
+ run: ({ linkId }) => api('GET', `/links/${linkId}`),
138
+ },
139
+ {
140
+ name: 'update_link',
141
+ description: 'Update a link. Pass linkId plus any fields to change. Same fields as create_link except url (use targetUrl to retarget).',
142
+ inputSchema: {
143
+ type: 'object',
144
+ properties: {
145
+ linkId: { type: 'string' },
146
+ targetUrl: { type: 'string' },
147
+ password: { type: 'string' },
148
+ expiresAt: { type: 'string' },
149
+ fallbackUrl: { type: 'string' },
150
+ maxClicks: { type: 'number' },
151
+ ogTitle: { type: 'string' },
152
+ ogDescription: { type: 'string' },
153
+ ogImage: { type: 'string' },
154
+ utmSource: { type: 'string' },
155
+ utmMedium: { type: 'string' },
156
+ utmCampaign: { type: 'string' },
157
+ archived: { type: 'boolean', description: 'Set true to disable the link (410 Gone).' },
158
+ folderId: { type: 'string', description: 'Move into a folder (or null to remove).' },
159
+ },
160
+ required: ['linkId'],
161
+ additionalProperties: false,
162
+ },
163
+ run: ({ linkId, ...patch }) => api('PATCH', `/links/${linkId}`, patch),
164
+ },
165
+ {
166
+ name: 'delete_link',
167
+ description: 'Permanently delete a link by linkId. Click history is preserved separately.',
168
+ inputSchema: {
169
+ type: 'object',
170
+ properties: { linkId: { type: 'string' } },
171
+ required: ['linkId'],
172
+ additionalProperties: false,
173
+ },
174
+ run: ({ linkId }) => api('DELETE', `/links/${linkId}`),
175
+ },
176
+ {
177
+ name: 'get_link_stats',
178
+ description: 'Get analytics for a single link: clicks over time, geo breakdown, device, browser, referrer. Period is "24h" | "7d" | "30d" | "all" (default 30d).',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ linkId: { type: 'string' },
183
+ period: { type: 'string', enum: ['24h', '7d', '30d', 'all'] },
184
+ },
185
+ required: ['linkId'],
186
+ additionalProperties: false,
187
+ },
188
+ run: ({ linkId, ...q }) => api('GET', `/links/${linkId}/stats`, undefined, q),
189
+ },
190
+ {
191
+ name: 'bulk_create_links',
192
+ description: 'Create many links in one request. Pass `links: CreateLinkParams[]`. Useful for migrating from another shortener or batching from a CSV. Returns { links, errors }.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: {
196
+ links: {
197
+ type: 'array',
198
+ items: { type: 'object' },
199
+ description: 'Array of CreateLinkParams (same shape as create_link input).',
200
+ },
201
+ },
202
+ required: ['links'],
203
+ additionalProperties: false,
204
+ },
205
+ run: (args) => api('POST', '/links/bulk', args),
206
+ },
207
+ // ── QR ────────────────────────────────────────────────────────────────
208
+ {
209
+ name: 'get_qr_url',
210
+ description: 'Returns a URL to the QR-code image PNG for a link. ' +
211
+ 'You can embed the URL directly (`<img src=...>`) or fetch it. The endpoint requires the same API key. ' +
212
+ 'Customise size (px) and dark/light colors via params.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ linkId: { type: 'string' },
217
+ size: { type: 'number', description: 'PNG size in pixels (default 256).' },
218
+ color: { type: 'string', description: 'Foreground hex (e.g. #000000).' },
219
+ background: { type: 'string', description: 'Background hex (e.g. #ffffff).' },
220
+ },
221
+ required: ['linkId'],
222
+ additionalProperties: false,
223
+ },
224
+ run: ({ linkId, ...q }) => {
225
+ const sp = new URLSearchParams();
226
+ for (const [k, v] of Object.entries(q)) {
227
+ if (v !== undefined && v !== null && v !== '')
228
+ sp.set(k, String(v));
229
+ }
230
+ const qs = sp.toString();
231
+ return Promise.resolve({
232
+ qrUrl: `${BASE_URL}/links/${linkId}/qr${qs ? `?${qs}` : ''}`,
233
+ note: 'Send the x-api-key header when fetching this URL.',
234
+ });
235
+ },
236
+ },
237
+ // ── Folders ──────────────────────────────────────────────────────────
238
+ {
239
+ name: 'list_folders',
240
+ description: 'List all folders for the authenticated user. Returns { folders } with link counts.',
241
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
242
+ run: () => api('GET', '/folders'),
243
+ },
244
+ {
245
+ name: 'create_folder',
246
+ description: 'Create a folder. Color is optional hex (default indigo).',
247
+ inputSchema: {
248
+ type: 'object',
249
+ properties: {
250
+ name: { type: 'string' },
251
+ color: { type: 'string', description: 'Hex color, e.g. #6366f1.' },
252
+ },
253
+ required: ['name'],
254
+ additionalProperties: false,
255
+ },
256
+ run: (args) => api('POST', '/folders', args),
257
+ },
258
+ // ── Tags ──────────────────────────────────────────────────────────────
259
+ {
260
+ name: 'list_tags',
261
+ description: 'List all tags for the authenticated user.',
262
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
263
+ run: () => api('GET', '/tags'),
264
+ },
265
+ {
266
+ name: 'create_tag',
267
+ description: 'Create a tag. 409 if a tag with the same name exists.',
268
+ inputSchema: {
269
+ type: 'object',
270
+ properties: {
271
+ name: { type: 'string' },
272
+ color: { type: 'string', description: 'Hex color.' },
273
+ },
274
+ required: ['name'],
275
+ additionalProperties: false,
276
+ },
277
+ run: (args) => api('POST', '/tags', args),
278
+ },
279
+ // ── Analytics ────────────────────────────────────────────────────────
280
+ {
281
+ name: 'analytics_overview',
282
+ description: 'Account-wide analytics: today/week/month/all-time clicks, unique visitors, growth rate, top links, plan tier.',
283
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
284
+ run: () => api('GET', '/analytics/overview'),
285
+ },
286
+ {
287
+ name: 'analytics_geo',
288
+ description: 'Geographic breakdown of clicks across all the user\'s links.',
289
+ inputSchema: {
290
+ type: 'object',
291
+ properties: {
292
+ period: { type: 'string', enum: ['24h', '7d', '30d', 'all'] },
293
+ },
294
+ additionalProperties: false,
295
+ },
296
+ run: (args) => api('GET', '/analytics/geo', undefined, args),
297
+ },
298
+ // ── Conversion goals ──────────────────────────────────────────────────
299
+ {
300
+ name: 'list_conversion_goals',
301
+ description: 'List conversion goals + per-goal totals (count, revenue).',
302
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
303
+ run: () => api('GET', '/conversions'),
304
+ },
305
+ {
306
+ name: 'track_conversion',
307
+ description: 'Server-side conversion event for a goal. Pass linkId or sessionId to attribute. ' +
308
+ 'Use this in your webhook from Stripe, Paddle, etc. to record purchase/signup events.',
309
+ inputSchema: {
310
+ type: 'object',
311
+ properties: {
312
+ goalId: { type: 'string' },
313
+ linkId: { type: 'string', description: 'The yhn link that drove this conversion.' },
314
+ sessionId: { type: 'string', description: 'Visitor session ID (alternative to linkId).' },
315
+ revenue: { type: 'number', description: 'Optional revenue attributable to this conversion.' },
316
+ metadata: { type: 'object', description: 'Free-form JSON metadata.' },
317
+ },
318
+ required: ['goalId'],
319
+ additionalProperties: false,
320
+ },
321
+ run: ({ goalId, ...payload }) => api('POST', `/conversions/${goalId}/track`, payload),
322
+ },
323
+ // ── Webhooks ──────────────────────────────────────────────────────────
324
+ {
325
+ name: 'list_webhooks',
326
+ description: 'List configured webhooks for this account.',
327
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
328
+ run: () => api('GET', '/webhooks'),
329
+ },
330
+ {
331
+ name: 'create_webhook',
332
+ description: 'Register a webhook URL. y.hn POSTs JSON events with HMAC-SHA256 signature (x-yhn-signature header). ' +
333
+ 'Events: "create" (link created), "click" (visitor clicked), "expire", "delete".',
334
+ inputSchema: {
335
+ type: 'object',
336
+ properties: {
337
+ url: { type: 'string', description: 'Your endpoint that will receive events.' },
338
+ events: {
339
+ type: 'array',
340
+ items: { type: 'string', enum: ['create', 'click', 'expire', 'delete'] },
341
+ description: 'Subscribe to these event types.',
342
+ },
343
+ },
344
+ required: ['url', 'events'],
345
+ additionalProperties: false,
346
+ },
347
+ run: (args) => api('POST', '/webhooks', args),
348
+ },
349
+ // ── Domains ───────────────────────────────────────────────────────────
350
+ {
351
+ name: 'list_domains',
352
+ description: 'List custom domains for this account (Pro+ only).',
353
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
354
+ run: () => api('GET', '/domains'),
355
+ },
356
+ ];
357
+ const toolByName = new Map(tools.map((t) => [t.name, t]));
358
+ const server = new Server({ name: 'yhn-mcp', version: '0.1.0' }, { capabilities: { tools: {} } });
359
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
360
+ tools: tools.map((t) => ({
361
+ name: t.name,
362
+ description: t.description,
363
+ inputSchema: t.inputSchema,
364
+ })),
365
+ }));
366
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
367
+ const tool = toolByName.get(req.params.name);
368
+ if (!tool) {
369
+ return {
370
+ isError: true,
371
+ content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }],
372
+ };
373
+ }
374
+ try {
375
+ const result = await tool.run((req.params.arguments ?? {}));
376
+ return {
377
+ content: [
378
+ {
379
+ type: 'text',
380
+ text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
381
+ },
382
+ ],
383
+ };
384
+ }
385
+ catch (err) {
386
+ const msg = err instanceof Error ? err.message : String(err);
387
+ return {
388
+ isError: true,
389
+ content: [{ type: 'text', text: msg }],
390
+ };
391
+ }
392
+ });
393
+ const transport = new StdioServerTransport();
394
+ await server.connect(transport);
395
+ process.stderr.write(`yhn-mcp connected. base=${BASE_URL}\n`);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "yhn-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for y.hn — lets Claude Code, Cursor, Cline and other AI agents create and manage short links.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "yhn-mcp": "dist/index.js"
9
+ },
10
+ "main": "dist/index.js",
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "prepublishOnly": "npm run build"
19
+ },
20
+ "keywords": [
21
+ "mcp",
22
+ "model-context-protocol",
23
+ "y.hn",
24
+ "yhn",
25
+ "url-shortener",
26
+ "short-link",
27
+ "claude",
28
+ "cursor",
29
+ "ai-agent"
30
+ ],
31
+ "homepage": "https://y.hn",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/97wow/yhn",
35
+ "directory": "sdk/mcp"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.0.4"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "typescript": "^5.6.0"
46
+ }
47
+ }