codepen-mcp 0.0.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.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # CodePen MCP
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that gives Cursor (and other MCP clients) tools to **ingest and inspect** CodePen pens: metadata, full source (HTML/CSS/JS), and embed snippet.
4
+
5
+ ## What’s available
6
+
7
+ CodePen has **no public REST/GraphQL API** for reading pen data. This server uses:
8
+
9
+ - **oEmbed (official)** – `https://codepen.io/api/oembed?format=json&url=...` for title, author, thumbnail, and embed iframe. Stable and supported by CodePen.
10
+ - **Pen page parsing (best-effort)** – Fetches the public pen page and extracts the embedded `__item` JSON to get full source (HTML, CSS, JS), tags, resources, and preprocessors. This can break if CodePen changes their front-end.
11
+
12
+ ## Tools
13
+
14
+ | Tool | Description |
15
+ |------|-------------|
16
+ | `get_pen_metadata` | Fetch metadata via oEmbed: title, author, thumbnail, embed HTML. No source code. |
17
+ | `get_pen` | Fetch full pen (source + metadata) by parsing the pen page. Use for ingest/inspect/understand. |
18
+ | `get_pen_embed_html` | Get the iframe embed HTML via oEmbed, with optional height. |
19
+
20
+ ## Setup
21
+
22
+ ### Install and build
23
+
24
+ ```bash
25
+ npm install
26
+ npm run build
27
+ ```
28
+
29
+ ### Run the server (stdio)
30
+
31
+ The server uses stdio transport so Cursor can spawn it as a subprocess:
32
+
33
+ ```bash
34
+ node dist/index.js
35
+ ```
36
+
37
+ ### Configure Cursor
38
+
39
+ Add the CodePen MCP server in Cursor (e.g. **Settings → MCP** or your MCP config file). Example:
40
+
41
+ ```json
42
+ {
43
+ "mcpServers": {
44
+ "codepen": {
45
+ "command": "node",
46
+ "args": ["/absolute/path/to/codepen-mcp/dist/index.js"]
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Use the path to your cloned repo (e.g. `/Users/you/Projects/codepen-mcp/dist/index.js`).
53
+
54
+ ## Optional: Agent Skill
55
+
56
+ The repo includes a Cursor Agent Skill at [.cursor/skills/codepen-ingest/](.cursor/skills/codepen-ingest/) that tells the agent when and how to use the CodePen tools (e.g. when the user pastes a pen URL). It’s scoped to this project; you can copy it to `~/.cursor/skills/` for use in all projects.
57
+
58
+ ## Example pen
59
+
60
+ Example used for design and testing: [Responsive Sidenotes V2](https://codepen.io/johndjameson/pen/DwxMqa) (`johndjameson/pen/DwxMqa`).
61
+
62
+ ## Limitations
63
+
64
+ - **Full source** depends on parsing the public pen page; no official API. If CodePen changes their HTML/JS, `get_pen` may need updates.
65
+ - **oEmbed and pen page** may return 403 or be rate-limited in some environments (e.g. strict Cloudflare or automated networks). In normal browser-like or Cursor usage they usually work.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { createServer } from "./server.js";
4
+ const server = createServer();
5
+ const transport = new StdioServerTransport();
6
+ await server.connect(transport);
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Normalize a CodePen URL or slug to a full pen URL.
3
+ * Accepts: full URL, or "username/pen/slug" or "username/pen/slug/"
4
+ */
5
+ export declare function normalizePenUrl(input: string): string;
6
+ export interface OEmbedResult {
7
+ success: boolean;
8
+ type: string;
9
+ version: string;
10
+ provider_name: string;
11
+ provider_url: string;
12
+ title: string;
13
+ author_name: string;
14
+ author_url: string;
15
+ height: string;
16
+ width: string;
17
+ thumbnail_url?: string;
18
+ thumbnail_width?: string;
19
+ thumbnail_height?: string;
20
+ html: string;
21
+ }
22
+ /**
23
+ * Fetch pen metadata from CodePen oEmbed API (official, stable).
24
+ */
25
+ export declare function fetchPenMetadata(penUrl: string, options?: {
26
+ height?: number;
27
+ }): Promise<OEmbedResult>;
28
+ /** Normalized pen data extracted from the pen page (best-effort scraping). */
29
+ export interface NormalizedPen {
30
+ title: string;
31
+ description: string;
32
+ html: string;
33
+ css: string;
34
+ js: string;
35
+ tags: string[];
36
+ resources: Array<{
37
+ url: string;
38
+ type: string;
39
+ order: number;
40
+ }>;
41
+ html_pre_processor: string;
42
+ css_pre_processor: string;
43
+ js_pre_processor: string;
44
+ author?: {
45
+ username: string;
46
+ name: string;
47
+ url: string;
48
+ };
49
+ pen_url: string;
50
+ hashid: string;
51
+ }
52
+ /**
53
+ * Fetch the pen page and extract full source from embedded __item (best-effort).
54
+ * May break if CodePen changes their front-end.
55
+ */
56
+ export declare function fetchPen(penUrl: string): Promise<NormalizedPen>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Normalize a CodePen URL or slug to a full pen URL.
3
+ * Accepts: full URL, or "username/pen/slug" or "username/pen/slug/"
4
+ */
5
+ export function normalizePenUrl(input) {
6
+ const trimmed = input.trim();
7
+ if (trimmed.startsWith("https://codepen.io/")) {
8
+ try {
9
+ const u = new URL(trimmed);
10
+ const path = u.pathname.replace(/\/$/, "");
11
+ const match = path.match(/^\/([^/]+)\/pen\/([^/]+)/);
12
+ if (match)
13
+ return `https://codepen.io/${match[1]}/pen/${match[2]}`;
14
+ }
15
+ catch {
16
+ // fall through
17
+ }
18
+ }
19
+ if (trimmed.includes("/pen/")) {
20
+ const withoutLeadingSlash = trimmed.replace(/^\//, "");
21
+ if (/^[^/]+\/pen\/[^/]+/.test(withoutLeadingSlash)) {
22
+ return `https://codepen.io/${withoutLeadingSlash.replace(/\/$/, "")}`;
23
+ }
24
+ }
25
+ throw new Error(`Invalid CodePen URL or slug: ${input}. Expected format: https://codepen.io/username/pen/slug or username/pen/slug`);
26
+ }
27
+ const OEMBED_BASE = "https://codepen.io/api/oembed";
28
+ /**
29
+ * Fetch pen metadata from CodePen oEmbed API (official, stable).
30
+ */
31
+ export async function fetchPenMetadata(penUrl, options) {
32
+ const url = new URL(OEMBED_BASE);
33
+ url.searchParams.set("format", "json");
34
+ url.searchParams.set("url", penUrl);
35
+ if (options?.height != null) {
36
+ url.searchParams.set("height", String(options.height));
37
+ }
38
+ const res = await fetch(url.toString(), {
39
+ headers: { Accept: "application/json" },
40
+ });
41
+ if (!res.ok) {
42
+ const text = await res.text();
43
+ throw new Error(`oEmbed request failed (${res.status}): ${text || res.statusText}`);
44
+ }
45
+ const data = (await res.json());
46
+ if (!data.success) {
47
+ throw new Error("CodePen oEmbed returned success: false");
48
+ }
49
+ return data;
50
+ }
51
+ /**
52
+ * Unescape a JSON string value (content only, no surrounding quotes).
53
+ * Handles \\, \", \n, \r, \t per JSON spec. Order matters: \\ first.
54
+ */
55
+ function unescapeJsonString(s) {
56
+ return s
57
+ .replace(/\\\\/g, "\\")
58
+ .replace(/\\"/g, '"')
59
+ .replace(/\\n/g, "\n")
60
+ .replace(/\\r/g, "\r")
61
+ .replace(/\\t/g, "\t");
62
+ }
63
+ /**
64
+ * Extract the __item JSON string from the pen page HTML.
65
+ * The page embeds a script with a global object containing "__item":"{\"key\":...}".
66
+ */
67
+ function extractItemJson(html) {
68
+ // Match "__item":"..." where the value is a JSON string (escaped quotes and backslashes inside).
69
+ const match = html.match(/"__item"\s*:\s*"((?:[^"\\]|\\.)*)"/);
70
+ if (match) {
71
+ return unescapeJsonString(match[1]);
72
+ }
73
+ throw new Error("Could not find __item in pen page. CodePen may have changed their page structure.");
74
+ }
75
+ /**
76
+ * Fetch the pen page and extract full source from embedded __item (best-effort).
77
+ * May break if CodePen changes their front-end.
78
+ */
79
+ export async function fetchPen(penUrl) {
80
+ const url = normalizePenUrl(penUrl);
81
+ const res = await fetch(url, {
82
+ headers: {
83
+ "User-Agent": "CodePen-MCP/1.0 (ingest tool)",
84
+ Accept: "text/html",
85
+ },
86
+ });
87
+ if (!res.ok) {
88
+ throw new Error(`Failed to fetch pen page (${res.status}): ${res.statusText}`);
89
+ }
90
+ const html = await res.text();
91
+ const itemJson = extractItemJson(html);
92
+ let item;
93
+ try {
94
+ item = JSON.parse(itemJson);
95
+ }
96
+ catch (e) {
97
+ throw new Error(`Failed to parse pen __item JSON: ${e instanceof Error ? e.message : String(e)}`);
98
+ }
99
+ if (!item || typeof item !== "object") {
100
+ throw new Error("Pen __item is not an object");
101
+ }
102
+ // Optional: extract __profiled for author (username, name). It may be in the same blob.
103
+ let author;
104
+ const profiledMatch = html.match(/"__profiled"\s*:\s*(\{[^}]+\})/);
105
+ if (profiledMatch) {
106
+ try {
107
+ const profiled = JSON.parse(profiledMatch[1]);
108
+ if (profiled?.username) {
109
+ author = {
110
+ username: profiled.username,
111
+ name: profiled.name ?? profiled.username,
112
+ url: `https://codepen.io/${profiled.username}`,
113
+ };
114
+ }
115
+ }
116
+ catch {
117
+ // ignore
118
+ }
119
+ }
120
+ const slug = url.match(/\/([^/]+)\/pen\/([^/]+)/);
121
+ const username = slug ? slug[1] : "";
122
+ if (!author && username) {
123
+ author = {
124
+ username,
125
+ name: username,
126
+ url: `https://codepen.io/${username}`,
127
+ };
128
+ }
129
+ return {
130
+ title: item.title ?? "Untitled",
131
+ description: item.description ?? "",
132
+ html: item.html ?? "",
133
+ css: item.css ?? "",
134
+ js: item.js ?? "",
135
+ tags: Array.isArray(item.tags) ? item.tags : [],
136
+ resources: (item.resources ?? []).map((r) => ({
137
+ url: r.url ?? "",
138
+ type: r.resource_type ?? "js",
139
+ order: typeof r.order === "number" ? r.order : 0,
140
+ })),
141
+ html_pre_processor: item.html_pre_processor ?? "none",
142
+ css_pre_processor: item.css_pre_processor ?? "none",
143
+ js_pre_processor: item.js_pre_processor ?? "none",
144
+ author,
145
+ pen_url: url,
146
+ hashid: item.hashid ?? "",
147
+ };
148
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ export declare function createServer(): McpServer;
package/dist/server.js ADDED
@@ -0,0 +1,88 @@
1
+ import * as z from "zod";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { fetchPenMetadata, fetchPen, normalizePenUrl } from "./pen-utils.js";
4
+ export function createServer() {
5
+ const server = new McpServer({
6
+ name: "codepen-mcp",
7
+ version: "1.0.0",
8
+ description: "Ingest and inspect CodePen pens via oEmbed and pen page parsing",
9
+ });
10
+ server.registerTool("get_pen_metadata", {
11
+ title: "Get Pen Metadata",
12
+ description: "Fetch CodePen pen metadata from the official oEmbed API. Returns title, author, thumbnail, and embed iframe HTML. Does not include source code. Use when you only need metadata or embed snippet.",
13
+ inputSchema: {
14
+ pen_url: z
15
+ .string()
16
+ .describe("Full CodePen URL (e.g. https://codepen.io/johndjameson/pen/DwxMqa) or slug (e.g. johndjameson/pen/DwxMqa)"),
17
+ },
18
+ }, async ({ pen_url }) => {
19
+ try {
20
+ const url = normalizePenUrl(pen_url);
21
+ const data = await fetchPenMetadata(url);
22
+ const out = {
23
+ pen_url: url,
24
+ title: data.title,
25
+ author_name: data.author_name,
26
+ author_url: data.author_url,
27
+ thumbnail_url: data.thumbnail_url,
28
+ height: data.height,
29
+ width: data.width,
30
+ embed_html: data.html,
31
+ };
32
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
33
+ }
34
+ catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
37
+ }
38
+ });
39
+ server.registerTool("get_pen", {
40
+ title: "Get Pen (Full Source)",
41
+ description: "Fetch a CodePen pen's full source (HTML, CSS, JS), metadata, tags, external resources, and preprocessors by parsing the public pen page. Use when you need to ingest, inspect, or understand the code. Note: This parses the pen page and may break if CodePen changes their front-end.",
42
+ inputSchema: {
43
+ pen_url: z
44
+ .string()
45
+ .describe("Full CodePen URL (e.g. https://codepen.io/johndjameson/pen/DwxMqa) or slug (e.g. johndjameson/pen/DwxMqa)"),
46
+ },
47
+ }, async ({ pen_url }) => {
48
+ try {
49
+ const pen = await fetchPen(pen_url);
50
+ return { content: [{ type: "text", text: JSON.stringify(pen, null, 2) }] };
51
+ }
52
+ catch (err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
55
+ }
56
+ });
57
+ server.registerTool("get_pen_embed_html", {
58
+ title: "Get Pen Embed HTML",
59
+ description: "Get the iframe embed HTML for a CodePen pen via oEmbed. Optionally specify height. Use when the user wants embed code to paste into a blog or page.",
60
+ inputSchema: {
61
+ pen_url: z
62
+ .string()
63
+ .describe("Full CodePen URL or slug (e.g. johndjameson/pen/DwxMqa)"),
64
+ height: z
65
+ .number()
66
+ .optional()
67
+ .describe("Optional iframe height in pixels (default from oEmbed)"),
68
+ },
69
+ }, async ({ pen_url, height }) => {
70
+ try {
71
+ const url = normalizePenUrl(pen_url);
72
+ const data = await fetchPenMetadata(url, height != null ? { height } : undefined);
73
+ return {
74
+ content: [
75
+ {
76
+ type: "text",
77
+ text: JSON.stringify({ pen_url: url, title: data.title, embed_html: data.html }, null, 2),
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ catch (err) {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
85
+ }
86
+ });
87
+ return server;
88
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "codepen-mcp",
3
+ "version": "0.0.1",
4
+ "description": "MCP server for ingesting and inspecting CodePen pens",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "codepen-mcp": "dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "start": "node dist/index.js",
13
+ "dev": "tsc && node dist/index.js",
14
+ "prepare": "npm run build",
15
+ "test": "ava",
16
+ "changeset": "changeset",
17
+ "version": "changeset version",
18
+ "release": "npm run build && changeset publish"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18"
25
+ },
26
+ "dependencies": {
27
+ "@modelcontextprotocol/sdk": "^1.0.0",
28
+ "zod": "^3.23.0"
29
+ },
30
+ "devDependencies": {
31
+ "@changesets/cli": "^2.29.8",
32
+ "@types/node": "^20.0.0",
33
+ "ava": "^6.0.0",
34
+ "tsx": "^4.0.0",
35
+ "typescript": "^5.0.0"
36
+ },
37
+ "ava": {
38
+ "files": [
39
+ "test/**/*.test.ts"
40
+ ],
41
+ "extensions": [
42
+ "ts"
43
+ ],
44
+ "nodeArguments": [
45
+ "--import=tsx/esm"
46
+ ],
47
+ "workerThreads": false
48
+ }
49
+ }