Install
$ agentstack add mcp-subhm2004-url-shortneer ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo 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.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
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 →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_codeisUNIQUE. 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_idkeys on Google'ssub, 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.
- Author: subhm2004
- Source: subhm2004/URL_Shortneer
- License: MIT
- Homepage: https://trunc-plum.vercel.app
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.