AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
MCP verified MIT Self-run

URL Shortneer

mcp-subhm2004-url-shortneer · by subhm2004

A URL shortener with click analytics and an MCP server — built on Postgres with 12 design patterns, each one load-bearing. The landing page animates the real request path as it runs.

No reviews yet
0 installs
35 views
0.0% view→install

Install

$ agentstack add mcp-subhm2004-url-shortneer

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access No
  • Filesystem access No
  • Shell / process execution Used
  • Environment & secrets Used
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/mcp-subhm2004-url-shortneer)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
2mo ago

Declared compatibility

Claude CodeClaude DesktopCursorWindsurf

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of URL Shortneer? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

A URL shortener that shows you its own machinery.

Paste a link and watch every layer of the request execute, in order, as it happens. Then ask an AI assistant about your links — over MCP, the same protocol Claude Desktop speaks.

PostgreSQL · Express · Next.js 15 · TypeScript · MCP · Groq

[](https://github.com/subhm2004/URL_Shortneer/actions/workflows/ci.yml) [](LICENSE) [](https://nodejs.org) [](https://postgresql.org) [](#design-patterns)


Contents

  • [What this is](#what-this-is)
  • [Architecture](#architecture)
  • [Quick start](#quick-start)
  • [Design patterns](#design-patterns) — twelve, with the code and the reason
  • [MCP](#mcp-model-context-protocol) — two clients, one protocol
  • [The AI assistant](#the-ai-assistant)
  • [Authentication](#authentication)
  • [What the rewrite fixed](#what-the-rewrite-fixed)
  • [API reference](#api-reference)
  • [Testing and CI](#testing-and-ci)
  • [Deployment](#deployment) — Render + Vercel + Neon
  • [Configuration](#configuration)
  • [Project layout](#project-layout)

What this is

Most URL shorteners hide the machinery. Trunc shows it to you.

Paste a link on the landing page and a terminal prints the work as it happens — the validation chain, the code generation, the database write, the event dispatch. Not a mock: those are the layers the request actually passes through, in the order it passes them.

It is also a complete MCP integration. The same four tools are reachable two ways — from Claude Desktop over stdio or HTTP, and from a chat built into the app itself, which is a genuine MCP client rather than a chat bolted on beside one.

Everything below is production-shaped: a layered backend, a typed frontend, real migrations, real CI that spins up a Postgres and drives the API over HTTP, and twelve design patterns that each exist because something was unsafe, slow, or impossible to change without them.


Architecture

The system

graph TB
    subgraph Client
        Browser["🌐 Browser"]
        Claude["🤖 Claude Desktop"]
    end

    subgraph Vercel["▲ Vercel"]
        Next["Next.js 15App Router · TypeScript"]
        ChatAPI["/api/chatroute handler — server only"]
    end

    subgraph Render["☁️ Render"]
        API["Express APIredirects · auth · links"]
        MCP["MCP Serverstdio + HTTP transports"]
    end

    subgraph External
        Neon[("🐘 NeonPostgreSQL")]
        Groq["⚡ Groqllama-3.3-70b"]
        Google["🔑 GoogleOAuth 2.0"]
    end

    Browser --> Next
    Browser -.->|"NEXT_PUBLIC_API_URL"| API
    Next --> ChatAPI
    ChatAPI -->|"tool calls · JSON-RPC"| MCP
    ChatAPI -->|"tool definitions"| Groq
    Claude -->|"MCP protocol"| MCP
    MCP -->|"SHORTENER_API_BASE"| API
    API --> Neon
    API |"authorization code"| Google

    classDef vercel fill:#111,stroke:#666,color:#fff
    classDef render fill:#111,stroke:#666,color:#fff
    classDef ext fill:#0d0d0d,stroke:#37f712,color:#fff
    class Next,ChatAPI vercel
    class API,MCP render
    class Neon,Groq,Google ext

Note the two paths into the MCP server. Claude Desktop and the in-app chat are both MCP clients, speaking the same protocol to the same endpoint, calling the same tools. If one works and the other doesn't, that's a bug in the server — and having both is how you find out.

The backend, in layers

Requests only ever call downward. Every class receives its collaborators through its constructor and imports none of them — which is what makes them testable without a database, and what lets the cache be swapped for Redis by editing one line.

graph TD
    R["routes/HTTP surface · auth middleware"]
    C["controllers/transport only — read, call, shape"]
    S["services/business logic · knows nothing of HTTP or SQL"]
    RE["repositories/all the SQL lives here"]
    DB["db/pool + migrations"]

    R --> C --> S --> RE --> DB

    CORE["core/errors · logger · ApiResponse · EventBus"]
    ST["strategies/short-code generation"]
    V["validation/the URL rule chain"]
    O["observers/post-response side-effects"]
    CT["container.jscomposition root"]

    S -.-> CORE
    S -.-> ST
    S -.-> V
    S -.-> O
    CT -.->|"wires everything"| S

    classDef layer fill:#111,stroke:#37f712,color:#fff
    classDef support fill:#0d0d0d,stroke:#444,color:#aaa
    class R,C,S,RE,DB layer
    class CORE,ST,V,O,CT support

Nothing above repositories/ imports a database driver. That boundary is why migrating this app from MongoDB to PostgreSQL rewrote one directory rather than the whole codebase.

What happens when you shorten a link

This is the sequence the landing page animates — and it is the real one.

sequenceDiagram
    autonumber
    participant B as Browser
    participant C as UrlController
    participant S as UrlService
    participant V as UrlValidatorchain of responsibility
    participant G as ShortCodeStrategystrategy + factory
    participant R as UrlRepositoryrepository + decorator
    participant E as EventBusobserver
    participant P as PostgreSQL

    B->>C: POST /api/shorten
    C->>S: shorten({ longUrl, userId })

    S->>V: validate(longUrl)
    Note over V: Required → MaxLength → Parsable→ Protocol → PublicHost → NoSelfRef
    V-->>S: normalised url

    S->>G: generate()
    G-->>S: "Yw3dcSQK"

    S->>R: create({ urlCode, longUrl })
    R->>P: INSERT INTO urls
    Note over P: unique(url_code) arbitratescollisions — service retries
    P-->>R: row
    R-->>S: url

    S->>E: publish(link.created)
    S-->>C: { url, created: true }
    C-->>B: 201 { url }

    Note over E,P: observers run AFTER the response.The caller never waits on them.

That last note is the point of the Observer seam. The old code awaited two database writes before sending the redirect — every visitor to every link paid for our analytics, on the one code path where latency is the product.

The data model

erDiagram
    USERS ||--o{ URLS : owns
    URLS ||--o{ CLICKS : receives

    USERS {
        uuid id PK
        text name
        text email UK "unique on lower(email)"
        text password_hash "NULL for Google accounts"
        text google_id UK "Google's sub — never the email"
        text avatar_url
        timestamptz created_at
    }

    URLS {
        uuid id PK
        text url_code UK "the redirect key — UNIQUE"
        text long_url
        uuid user_id FK "NULL for anonymous"
        bigint click_count "atomic increment"
        timestamptz created_at
    }

    CLICKS {
        bigserial id PK
        uuid url_id FK
        timestamptz clicked_at
        text referer
        text user_agent
    }

Three decisions worth defending:

  • url_code is UNIQUE. It is the redirect key. Without the constraint, two

links could be handed the same generated code and one of them would send visitors to the wrong site.

  • Clicks are their own table, not an array on the URL row. An array grows the

row on every single click, eventually hits Postgres' tuple limits, and cannot be aggregated in SQL without unnesting it first.

  • google_id keys on Google's sub, never the email. A Google user can change

their email address; keying on it would either lose them their account or hand it to whoever later picks up their old address.


Quick start

You need: Node 20+, and a PostgreSQL connection string — Neon, Supabase, Railway, or a local Postgres. Any of them.

1. Backend

cd app
npm install
cp .env.example .env

Fill in two values in app/.env:

DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
JWT_SECRET=            # generate one:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
npm run dev            # → http://localhost:5050

Tables are created on first boot — migrations run automatically. (By hand: npm run migrate.)

> [!NOTE] > The backend runs on 5050, not 5000. On macOS, port 5000 is held by the AirPlay > Receiver, which accepts connections and silently swallows them — you get empty > responses with no error anywhere. Leave the port alone, or turn AirPlay Receiver > off in System Settings → General → AirDrop & Handoff.

2. Frontend

cd frontend
npm install
cp .env.example .env.local
npm run dev            # → http://localhost:3000

Open http://localhost:3000 and shorten something.

> NEXT_PUBLIC_API_URL is deliberately unset in development. The browser then > fetches the relative path /api/… — same origin — and next.config.ts forwards > it to the backend. Nothing crosses an origin, so CORS never enters the picture > locally. See [Configuration](#configuration) for why production is different.

3. MCP server (optional)

cd mcp-server
npm install
cp .env.example .env
npm run dev:http       # → http://localhost:3001/mcp

Needed for the in-app assistant and for connecting Claude Desktop.

With Docker

make up          # uses DATABASE_URL from the root .env (e.g. Neon)
make up-local    # runs a Postgres container too — no external DB needed

| | | |---|---| | make logs | Follow logs | | make down | Stop everything | | make psql | psql shell (local-db only) | | make reset | Stop and delete the local DB volume |


Design patterns

Twelve patterns. Every one of them load-bearing.

Most projects list design patterns like trophies. These are here because specific things were unsafe, slow, or impossible to change without them — and the code says which. Read the "without it" column first, because that's the whole argument.

| # | Pattern | Where | Without it | |:--|:--|:--|:--| | 01 | [Singleton](#01--singleton) | config/ · db/pool.js · core/logger.js | A connection pool per request, exhausting Postgres' limit | | 02 | [Repository](#02--repository) | repositories/ | SQL smeared through the business logic | | 03 | [Template Method](#03--template-method) | BaseRepository | Every repository re-implementing connection handling | | 04 | [Decorator](#04--decorator) | CachedUrlRepository | Cache checks tangled into the service that uses it | | 05 | [Null Object](#05--null-object) | NullCache | if (cache) at every call site — where cache bugs come from | | 06 | [Strategy](#06--strategy) | strategies/shortcode/ | nanoid(8) hardcoded in the middle of a controller | | 07 | [Factory](#07--factory) | ShortCodeStrategyFactory | Every caller knowing the concrete class names | | 08 | [Chain of Responsibility](#08--chain-of-responsibility) | validation/ | One long if/else that nobody dares reorder | | 09 | [Observer](#09--observer) | core/EventBus | Every visitor waiting on a DB write before being redirected | | 10 | [Builder](#10--builder) | core/ApiResponse | Five hand-rolled response shapes that drifted apart | | 11 | [Dependency Injection](#11--dependency-injection) | container.js | Unit tests that need a live database | | 12 | [Facade](#12--facade) | frontend/src/lib/api.ts | The same twenty lines of fetch in three files |


01 · Singleton

// app/db/pool.js
let pool = null;

export function getPool() {
  if (pool) return pool;

  pool = new Pool({
    connectionString: config.db.connectionString,
    ssl: config.db.ssl,
    max: config.db.max,
  });

  return pool;
}

Why. A Pool opens and reuses TCP connections. One per request would open a fresh connection every time and blow through Postgres' connection limit under any real traffic — on Neon's free tier, within seconds.

The config Singleton does something subtler: it validates the whole environment at boot, so a missing DATABASE_URL fails on startup rather than on the first request unlucky enough to need it.

// app/config/index.js
export function assertConfigValid() {
  const missing = REQUIRED.filter((name) => !process.env[name]);
  if (missing.length) {
    throw new Error(`Missing required environment variable(s): ${missing.join(", ")}`);
  }
}

02 · Repository

// app/repositories/UrlRepository.js
export default class UrlRepository extends BaseRepository {
  findByCode(urlCode) {
    return this.one(`SELECT * FROM urls WHERE url_code = $1`, [urlCode]);
  }

  toDomain(row) {
    return {
      id: row.id,
      urlCode: row.url_code,
      longUrl: row.long_url,
      shortUrl: `${config.baseUrl}/${row.url_code}`,   // derived, never stored
      clickCount: row.click_count,
      createdAt: row.created_at,
    };
  }
}

Why. UrlService asks for a URL by its code. It doesn't know there's a table, or a SELECT, or Postgres at all. That boundary is what makes the service testable with a fake repository — and it's why moving this app off MongoDB rewrote one directory instead of the whole codebase.

> shortUrl is derived from config, not stored. The old schema persisted the > full short URL on every row — so moving the app to a new domain silently broke > every link ever created.


03 · Template Method

// app/repositories/BaseRepository.js
export default class BaseRepository {
  toDomain(row) { return row; }          // ← subclasses override this

  async one(text, params = []) {
    const { rows } = await this.query(text, params);
    return rows.length ? this.toDomain(rows[0]) : null;
  }

  async withTransaction(fn) {
    const client = await getPool().connect();
    try {
      await client.query("BEGIN");
      const scoped = new this.constructor(client);   // same repo, bound to this client
      const result = await fn(scoped, client);
      await client.query("COMMIT");
      return result;
    } catch (err) {
      await client.query("ROLLBACK").catch(() => {});
      throw err;
    } finally {
      client.release();
    }
  }
}

Why. Connection checkout, BEGIN/COMMIT/ROLLBACK, and releasing the client are easy to get subtly wrong — a missed client.release() leaks a connection and you find out days later when the pool runs dry. Writing it once means four repositories can't each get it wrong in their own way.


04 · Decorator

// app/repositories/CachedUrlRepository.js
export default class CachedUrlRepository {
  async findByCode(urlCode) {
    const key = `code:${urlCode}`;

    const hit = this.#cache.get(key);
    if (hit !== undefined) return hit;

    const url = await this.#inner.findByCode(urlCode);

    // Misses are cached too (as null) — otherwise a bot hammering nonexistent
    // codes would hit Postgres on every request.
    this.#cache.set(key, url);
    return url;
  }

  // everything else forwards straight through
  findByUser(userId) { return this.#inner.findByUser(userId); }
}

It implements the same interface as the thing it wraps, so this is the only line that changes to turn caching on:

// app/container.js
const urlRepository = new CachedUrlRepository(new UrlRepository(), cache);

Why. Every redirect performs findByCode — that's the hot path. The alternative is cache checks smeared through UrlService, which is exactly how you end up serving stale data and not knowing why.

Note what it deliberately does not cache: findByLongUrlAndUser, which runs on the write path, where a stale answer would mean minting a duplicate row.


05 · Null Object

// app/cache/NullCache.js
export default class NullCache {
  get()    { return undefined; }   // always a miss
  set()    {}                      // forget immediately
  delete() {}
}
const cache = config.cache.enabled
  ? new InMemoryCache({ maxEntries: 1000, ttlMs: 60_000 })
  : new NullCache();

Why. The alternative is if (this.cache) guarding every read and every write inside the decorator — four branches that all have to be right, and cache bugs live in exactly those branches. With a Null Object, CACHE_ENABLED=false is a

Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.