# URL Shortneer

> 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.

- **Type:** MCP server
- **Install:** `agentstack add mcp-subhm2004-url-shortneer`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [subhm2004](https://agentstack.voostack.com/s/subhm2004)
- **Installs:** 0
- **Category:** [Databases](https://agentstack.voostack.com/c/databases)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [subhm2004](https://github.com/subhm2004)
- **Source:** https://github.com/subhm2004/URL_Shortneer
- **Website:** https://trunc-plum.vercel.app

## Install

```sh
agentstack add mcp-subhm2004-url-shortneer
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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

```mermaid
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.

```mermaid
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.

```mermaid
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

```mermaid
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](https://neon.tech),
[Supabase](https://supabase.com), Railway, or a local Postgres. Any of them.

### 1. Backend

```bash
cd app
npm install
cp .env.example .env
```

Fill in two values in `app/.env`:

```bash
DATABASE_URL=postgresql://user:pass@host/db?sslmode=require
JWT_SECRET=            # generate one:
```

```bash
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
```

```bash
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

```bash
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)

```bash
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

```bash
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

```js
// 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.

```js
// 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

```js
// 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

```js
// 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

```js
// 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:

```js
// 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

```js
// app/cache/NullCache.js
export default class NullCache {
  get()    { return undefined; }   // always a miss
  set()    {}                      // forget immediately
  delete() {}
}
```

```js
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.

- **Author:** [subhm2004](https://github.com/subhm2004)
- **Source:** [subhm2004/URL_Shortneer](https://github.com/subhm2004/URL_Shortneer)
- **License:** MIT
- **Homepage:** https://trunc-plum.vercel.app

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

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** yes
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-subhm2004-url-shortneer
- Seller: https://agentstack.voostack.com/s/subhm2004
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
