picoli-mcp 1.0.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 ADDED
@@ -0,0 +1,117 @@
1
+ # picoli-mcp
2
+
3
+ MCP server for [picoli.site](https://picoli.site) — URL shortening and click analytics for AI agents.
4
+
5
+ Shorten URLs, track clicks, and analyze link performance directly from your AI assistant.
6
+
7
+ ## Setup
8
+
9
+ ### 1. Get your API key
10
+
11
+ Create an account at [picoli.site](https://picoli.site) and get your API key from the dashboard.
12
+
13
+ ### 2. Configure your MCP client
14
+
15
+ #### Claude Desktop / Claude Code
16
+
17
+ Add to your MCP configuration:
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "picoli": {
23
+ "command": "npx",
24
+ "args": ["-y", "picoli-mcp"],
25
+ "env": {
26
+ "PICOLI_API_KEY": "your-api-key-here"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ #### Cursor
34
+
35
+ Add to `.cursor/mcp.json`:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "picoli": {
41
+ "command": "npx",
42
+ "args": ["-y", "picoli-mcp"],
43
+ "env": {
44
+ "PICOLI_API_KEY": "your-api-key-here"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ ### `shorten_url`
54
+
55
+ Create a short URL with an optional custom slug.
56
+
57
+ **Example prompts:**
58
+ - "Shorten https://example.com/very-long-article-url"
59
+ - "Create a short link for https://my-site.com with slug 'launch'"
60
+
61
+ ### `shorten_urls`
62
+
63
+ Create multiple short URLs at once (up to 500).
64
+
65
+ **Example prompts:**
66
+ - "Shorten these 3 URLs: ..."
67
+
68
+ ### `get_link_stats`
69
+
70
+ Get click statistics for specific links (bot traffic excluded).
71
+
72
+ **Example prompts:**
73
+ - "How many clicks did my 'launch' link get?"
74
+ - "Show me stats for these slugs: launch, demo, blog-post"
75
+
76
+ ### `list_links`
77
+
78
+ List all shortened URLs with click counts and pagination.
79
+
80
+ **Example prompts:**
81
+ - "Show me all my short links"
82
+ - "List my links sorted by clicks"
83
+
84
+ ### `get_analytics`
85
+
86
+ Get analytics overview: top 10 links, daily click trends, and totals.
87
+
88
+ **Example prompts:**
89
+ - "Show me my link analytics for this week"
90
+ - "What are my top performing links?"
91
+ - "Give me click stats from 2026-01-01 to 2026-01-31"
92
+
93
+ ## Environment Variables
94
+
95
+ | Variable | Required | Default | Description |
96
+ |---|---|---|---|
97
+ | `PICOLI_API_KEY` | Yes | — | Your picoli.site API key |
98
+ | `PICOLI_BASE_URL` | No | `https://picoli.site` | API base URL (for self-hosted instances) |
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ git clone https://github.com/yun/picoli-mcp.git
104
+ cd picoli-mcp
105
+ npm install
106
+ npm run build
107
+ ```
108
+
109
+ Test locally:
110
+
111
+ ```bash
112
+ PICOLI_API_KEY=your-key npx tsx src/index.ts
113
+ ```
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,14 @@
1
+ import { z } from "zod";
2
+ declare const ConfigSchema: z.ZodObject<{
3
+ apiKey: z.ZodString;
4
+ baseUrl: z.ZodDefault<z.ZodString>;
5
+ }, "strip", z.ZodTypeAny, {
6
+ apiKey: string;
7
+ baseUrl: string;
8
+ }, {
9
+ apiKey: string;
10
+ baseUrl?: string | undefined;
11
+ }>;
12
+ export type Config = z.infer<typeof ConfigSchema>;
13
+ export declare function loadConfig(): Config;
14
+ export {};
package/dist/config.js ADDED
@@ -0,0 +1,19 @@
1
+ import { z } from "zod";
2
+ const ConfigSchema = z.object({
3
+ apiKey: z.string().min(1, "PICOLI_API_KEY is required. Get your API key at https://picoli.site"),
4
+ baseUrl: z.string().url().default("https://picoli.site"),
5
+ });
6
+ export function loadConfig() {
7
+ const result = ConfigSchema.safeParse({
8
+ apiKey: process.env.PICOLI_API_KEY,
9
+ baseUrl: process.env.PICOLI_BASE_URL,
10
+ });
11
+ if (!result.success) {
12
+ const errors = result.error.issues.map((i) => ` - ${i.message}`).join("\n");
13
+ console.error(`picoli-mcp: Configuration error:\n${errors}`);
14
+ console.error("\nSet PICOLI_API_KEY in your MCP server configuration.");
15
+ console.error("Get your API key at https://picoli.site");
16
+ process.exit(1);
17
+ }
18
+ return result.data;
19
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { registerTools } from "./tools.js";
6
+ const config = loadConfig();
7
+ const server = new McpServer({
8
+ name: "picoli-mcp",
9
+ version: "1.0.0",
10
+ });
11
+ registerTools(server, config);
12
+ async function main() {
13
+ const transport = new StdioServerTransport();
14
+ await server.connect(transport);
15
+ console.error("picoli-mcp server running on stdio");
16
+ }
17
+ main().catch((error) => {
18
+ console.error("Fatal error:", error);
19
+ process.exit(1);
20
+ });
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { Config } from "./config.js";
3
+ export declare function registerTools(server: McpServer, config: Config): void;
package/dist/tools.js ADDED
@@ -0,0 +1,241 @@
1
+ import { z } from "zod";
2
+ async function picoliRequest(config, path, options = {}) {
3
+ const url = `${config.baseUrl}${path}`;
4
+ return fetch(url, {
5
+ ...options,
6
+ headers: {
7
+ "Content-Type": "application/json",
8
+ "x-api-key": config.apiKey,
9
+ ...options.headers,
10
+ },
11
+ });
12
+ }
13
+ export function registerTools(server, config) {
14
+ // ── shorten_url ──────────────────────────────────────
15
+ server.registerTool("shorten_url", {
16
+ title: "Shorten URL",
17
+ description: "Create a short URL using picoli.site. Optionally specify a custom slug. Returns the shortened URL and slug.",
18
+ inputSchema: z.object({
19
+ url: z.string().url().describe("The destination URL to shorten"),
20
+ slug: z
21
+ .string()
22
+ .optional()
23
+ .describe("Optional custom slug (e.g. 'my-link'). If not provided, a random slug is generated."),
24
+ }),
25
+ }, async ({ url, slug }) => {
26
+ const link = { url };
27
+ if (slug)
28
+ link.slug = slug;
29
+ const res = await picoliRequest(config, "/api/links/bulk", {
30
+ method: "POST",
31
+ body: JSON.stringify({ links: [link] }),
32
+ });
33
+ if (!res.ok) {
34
+ const error = await res.text();
35
+ return {
36
+ content: [{ type: "text", text: `Error: ${res.status} - ${error}` }],
37
+ isError: true,
38
+ };
39
+ }
40
+ const data = await res.json();
41
+ if (data.errors?.length > 0) {
42
+ return {
43
+ content: [
44
+ { type: "text", text: `Error: ${data.errors[0].error}` },
45
+ ],
46
+ isError: true,
47
+ };
48
+ }
49
+ const created = data.links[0];
50
+ const shortUrl = `${config.baseUrl}/${created.slug}`;
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: JSON.stringify({
56
+ shortUrl,
57
+ slug: created.slug,
58
+ destinationUrl: created.destination_url,
59
+ createdAt: created.created_at,
60
+ }, null, 2),
61
+ },
62
+ ],
63
+ };
64
+ });
65
+ // ── shorten_urls ─────────────────────────────────────
66
+ server.registerTool("shorten_urls", {
67
+ title: "Shorten Multiple URLs",
68
+ description: "Create multiple short URLs at once (up to 500). Each URL can have an optional custom slug.",
69
+ inputSchema: z.object({
70
+ links: z
71
+ .array(z.object({
72
+ url: z.string().url().describe("The destination URL"),
73
+ slug: z.string().optional().describe("Optional custom slug"),
74
+ }))
75
+ .min(1)
76
+ .max(500)
77
+ .describe("Array of URLs to shorten"),
78
+ }),
79
+ }, async ({ links }) => {
80
+ const res = await picoliRequest(config, "/api/links/bulk", {
81
+ method: "POST",
82
+ body: JSON.stringify({ links }),
83
+ });
84
+ if (!res.ok) {
85
+ const error = await res.text();
86
+ return {
87
+ content: [{ type: "text", text: `Error: ${res.status} - ${error}` }],
88
+ isError: true,
89
+ };
90
+ }
91
+ const data = await res.json();
92
+ const results = (data.links || []).map((l) => ({
93
+ shortUrl: `${config.baseUrl}/${l.slug}`,
94
+ slug: l.slug,
95
+ destinationUrl: l.destination_url,
96
+ }));
97
+ const summary = {
98
+ created: data.created,
99
+ errors: data.errors?.length || 0,
100
+ links: results,
101
+ };
102
+ if (data.errors?.length > 0) {
103
+ summary.errorDetails = data.errors;
104
+ }
105
+ return {
106
+ content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
107
+ };
108
+ });
109
+ // ── get_link_stats ───────────────────────────────────
110
+ server.registerTool("get_link_stats", {
111
+ title: "Get Link Stats",
112
+ description: "Get click statistics for one or more short links by their slugs. Returns human click counts (bot traffic excluded).",
113
+ inputSchema: z.object({
114
+ slugs: z
115
+ .array(z.string())
116
+ .min(1)
117
+ .max(500)
118
+ .describe("Array of slugs to get stats for (e.g. ['my-link', 'abc123'])"),
119
+ }),
120
+ }, async ({ slugs }) => {
121
+ const res = await picoliRequest(config, "/api/stats/batch", {
122
+ method: "POST",
123
+ body: JSON.stringify({ slugs }),
124
+ });
125
+ if (!res.ok) {
126
+ const error = await res.text();
127
+ return {
128
+ content: [{ type: "text", text: `Error: ${res.status} - ${error}` }],
129
+ isError: true,
130
+ };
131
+ }
132
+ const data = await res.json();
133
+ const stats = (data.stats || []).map((s) => ({
134
+ slug: s.slug,
135
+ shortUrl: `${config.baseUrl}/${s.slug}`,
136
+ destinationUrl: s.destination_url,
137
+ clicks: s.human_clicks,
138
+ createdAt: s.created_at,
139
+ }));
140
+ // Identify slugs with no data
141
+ const foundSlugs = new Set(stats.map((s) => s.slug));
142
+ const notFound = slugs.filter((s) => !foundSlugs.has(s));
143
+ const result = { stats };
144
+ if (notFound.length > 0) {
145
+ result.notFound = notFound;
146
+ }
147
+ return {
148
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
149
+ };
150
+ });
151
+ // ── list_links ───────────────────────────────────────
152
+ server.registerTool("list_links", {
153
+ title: "List Links",
154
+ description: "List all shortened URLs with their click counts. Supports pagination.",
155
+ inputSchema: z.object({
156
+ page: z
157
+ .number()
158
+ .int()
159
+ .positive()
160
+ .default(1)
161
+ .describe("Page number (default: 1)"),
162
+ limit: z
163
+ .number()
164
+ .int()
165
+ .min(1)
166
+ .max(100)
167
+ .default(20)
168
+ .describe("Items per page (default: 20, max: 100)"),
169
+ }),
170
+ }, async ({ page, limit }) => {
171
+ const res = await picoliRequest(config, `/api/links?page=${page}&limit=${limit}`);
172
+ if (!res.ok) {
173
+ const error = await res.text();
174
+ return {
175
+ content: [{ type: "text", text: `Error: ${res.status} - ${error}` }],
176
+ isError: true,
177
+ };
178
+ }
179
+ const data = await res.json();
180
+ const links = (data.data || []).map((l) => ({
181
+ shortUrl: `${config.baseUrl}/${l.slug}`,
182
+ slug: l.slug,
183
+ destinationUrl: l.destination_url,
184
+ clicks: l.clicks,
185
+ createdAt: l.created_at,
186
+ }));
187
+ return {
188
+ content: [
189
+ {
190
+ type: "text",
191
+ text: JSON.stringify({ links, pagination: data.pagination }, null, 2),
192
+ },
193
+ ],
194
+ };
195
+ });
196
+ // ── get_analytics ────────────────────────────────────
197
+ server.registerTool("get_analytics", {
198
+ title: "Get Analytics",
199
+ description: "Get analytics overview: top 10 links by clicks, daily click stats, and total link count. Defaults to last 7 days.",
200
+ inputSchema: z.object({
201
+ start_date: z
202
+ .string()
203
+ .optional()
204
+ .describe("Start date in YYYY-MM-DD format (default: 7 days ago)"),
205
+ end_date: z
206
+ .string()
207
+ .optional()
208
+ .describe("End date in YYYY-MM-DD format (default: today)"),
209
+ }),
210
+ }, async ({ start_date, end_date }) => {
211
+ const params = new URLSearchParams();
212
+ if (start_date)
213
+ params.set("start_date", start_date);
214
+ if (end_date)
215
+ params.set("end_date", end_date);
216
+ const query = params.toString();
217
+ const path = `/api/stats${query ? `?${query}` : ""}`;
218
+ const res = await picoliRequest(config, path);
219
+ if (!res.ok) {
220
+ const error = await res.text();
221
+ return {
222
+ content: [{ type: "text", text: `Error: ${res.status} - ${error}` }],
223
+ isError: true,
224
+ };
225
+ }
226
+ const data = await res.json();
227
+ const result = {
228
+ dateRange: data.date_filter,
229
+ totalLinks: data.total_links,
230
+ top10: (data.top_10 || []).map((t) => ({
231
+ shortUrl: `${config.baseUrl}/${t.slug}`,
232
+ slug: t.slug,
233
+ clicks: t.human_clicks,
234
+ })),
235
+ dailyStats: data.daily_stats || [],
236
+ };
237
+ return {
238
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
239
+ };
240
+ });
241
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "picoli-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for picoli.site - URL shortening and click analytics",
5
+ "type": "module",
6
+ "bin": {
7
+ "picoli-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsx src/index.ts",
15
+ "prepublishOnly": "npm run build"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.26.0",
19
+ "zod": "^3.23.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^22.0.0",
23
+ "tsx": "^4.19.0",
24
+ "typescript": "^5.7.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "keywords": [
30
+ "mcp",
31
+ "model-context-protocol",
32
+ "url-shortener",
33
+ "analytics",
34
+ "picoli",
35
+ "ai-tools"
36
+ ],
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/amaterous/picoli-mcp"
41
+ }
42
+ }