Shlink MCP Server — 23 tools
contains code
The Shlink DADL turns Shlink's API into an MCP server that Claude, GPT or any MCP-compatible agent can consume directly. One YAML file declares all 23 tools — tag, short, domain, visit, orphan, redirect, and more — and ToolMesh serves them at runtime. No Python boilerplate, no per-endpoint code, no separate MCP server process.
Below: the endpoint coverage matrix, a two-block ToolMesh setup, the full tool reference grouped by Shlink feature area, required credential scopes.
Source: Shlink REST API
Which Shlink endpoints are covered?
88% (23 of ~26 endpoints)
Focus: Shlink REST API v3: short URLs (custom slugs, tags, expiry, visit limits, title, query forwarding), visits (per short URL, tag, domain, orphan visits, bot filtering, visit summaries), tags (stats, rename), redirect rules (device, language, query param, IP address, geolocation, date, browser), domains (not-found redirects), health.
Missing: The public redirect endpoint /{shortCode} and the tracking pixel /{shortCode}/track (visitor-facing, both create real visits), the deprecated GET /short-urls/shorten (API key in the query string), and multi-tag filters with tagsMode/excludeTagsMode (one tag per call). API key management, imports and GeoLite updates exist only in the Shlink CLI; QR codes were removed from Shlink in 5.0.
How do you configure the Shlink DADL?
- Shlink API keys are created with the Shlink CLI on the server, not in a UI. For the official Docker image run: docker exec -it <shlink-container> shlink api-key:generate --name=toolmesh
- Copy the printed key right away. Shlink stores keys hashed and cannot show the key again.
- A key without roles is an admin key and works with every tool here. For least privilege add --author-only (only short URLs this key created), --domain-only=s.example.com (one non-default domain) or --no-orphan-visits; such keys cannot rename or delete tags or set domain redirects.
- Optional expiry: --expiration-date=2027-12-31. Revoke a key later with shlink api-key:disable <name>.
- Fresh container without shell access: start it once with INITIAL_API_KEY=<key-of-your-choice> to seed an admin key.
- Put the key into the ToolMesh .env as CREDENTIAL_SHLINK_API_KEY and add the backends.yaml entry with your instance URL.
Environment variable: CREDENTIAL_SHLINK_API_KEY
Shlink is self-hosted: set url to the origin that serves /rest of your instance (usually the short domain, e.g. https://s.example.com) without a /rest suffix. Do not use the address of the Shlink web client (admin UI): it answers every request with its HTML start page and HTTP 200, so the tools return HTML instead of JSON. Check with https://<your-host>/rest/health, which must return JSON with status pass. Requires Shlink 3.0 or newer for REST API v3; redirect rules need 4.0, the date conditions 5.0 and the browser condition 5.1. Verified against Shlink 5.1.8. Geolocation fields and the geolocation redirect conditions need a GeoLite2 license key on the Shlink server. The health endpoint needs no key.
How do you install the Shlink MCP server with ToolMesh?
Add to your backends.yaml:
- name: shlink
transport: rest
dadl: shlink.dadl
url: "https://s.example.com"
Set the credential:
CREDENTIAL_SHLINK_API_KEY=your-token-hereWhat 23 tools does the Shlink DADL expose?
GETlist_short_urlsList short URLs, newest first by default. Returns {data: ShortUrl[], pagination: {currentPage, pagesCount, itemsPerPage, itemsInCurrentPage, totalItems}}; page through with page. Each ShortUrl has shortCode, shortUrl, longUrl, dateCreated, tags, meta {validSince, validUntil, maxVisits}, domain (null = default domain), title, crawlable, forwardQuery, hasRedirectRules and visitsSummary {total, nonBots, bots}. Expired or exhausted links are included unless the exclude* flags are set. Tag filters take ONE tag each (Shlink needs the PHP array key "tags[]"; call again for another tag).
POSTcreate_short_urlCreate a short URL and return the ShortUrl object (its shortUrl field is the link to hand out). Without customSlug Shlink generates a 5-character code (shortCodeLength >= 4). customSlug is slugified: spaces and "/" become "-". Tags are lowercased and slugified. A slug already used on that domain fails with 400 non-unique-slug; invalid input fails with 400 invalid-data listing invalidElements. Set findIfExists=true to get an existing short URL for the same long URL (and same optional fields) back instead of creating a duplicate.
GETget_short_urlGet one short URL by short code: longUrl, tags, meta {validSince, validUntil, maxVisits}, title, crawlable, forwardQuery, hasRedirectRules and visitsSummary {total, nonBots, bots}. Short URLs on a non-default domain need the domain parameter, otherwise 404 short-url-not-found.
PATCHupdate_short_urlEdit a short URL. True partial update: only the fields sent change, omitted fields stay as they are. null clears validSince, validUntil, maxVisits or title. tags REPLACES the whole tag list (send the full list; [] removes all tags). The short code and domain cannot be changed. Returns the updated ShortUrl. Short URLs on a non-default domain need the domain parameter.
DELETEdelete_short_urlDelete a short URL together with all of its visits. Irreversible; later hits on the code become orphan visits. Returns nothing (204). Fails with 422 when the server has DELETE_SHORT_URL_THRESHOLD set and the short URL has more visits than that. Short URLs on a non-default domain need the domain parameter.
GETlist_redirect_rulesList the dynamic redirect rules of one short URL. Returns {defaultLongUrl, redirectRules: [{longUrl, priority, conditions: [{type, matchKey, matchValue}]}]}; priority 1 is checked first and defaultLongUrl is used when no rule matches. Read this before set_redirect_rules when rules should be added rather than replaced.
POSTset_redirect_rulesReplace ALL redirect rules of a short URL (an empty array removes every rule). Priorities follow the array order. Each rule is {longUrl, conditions: [{type, matchKey, matchValue}]}; all conditions of a rule must match. Condition types: device (matchValue android, ios, mobile, windows, macos, linux, chromeos or desktop); language (matchValue such as en or en-US, from Accept-Language); query-param (matchKey = param name, matchValue = exact value); any-value-query-param (matchKey = param name, present with or without value); valueless-query-param (matchKey = param name, present without value); ip-address (matchValue exact IP, CIDR like 192.0.2.0/24 or wildcard like 192.0.2.*); geolocation-country-code (matchValue ISO 3166-1 alpha-2, e.g. DE); geolocation-city-name (matchValue city name); before-date and after-date (matchValue ISO-8601 date-time, Shlink 5.0+); browser (matchValue chrome, firefox, edge, safari, opera or android_browser, Shlink 5.1+). matchKey may be omitted or null where unused. Geolocation conditions need GeoLite2 on the server. Returns the resulting rule list like list_redirect_rules; an unknown value fails with 400 invalid-data whose invalidElements holds the index of the bad rule.
GETlist_tagsList tag names, alphabetically by default. Returns {data: string[], pagination}. Without itemsPerPage all tags come back in one page. Tags stay listed after their last short URL dropped them until delete_tags removes them; use list_tag_stats for usage counts.
GETlist_tag_statsList tags with usage statistics. Returns {data: [{tag, shortUrlsCount, visitsSummary: {total, nonBots, bots}}], pagination}. Ordering by shortUrlsCount or visits is as slow as loading the whole list, so omit itemsPerPage when sorting by those.
PUTrename_tagRename a tag on every short URL that carries it. Returns nothing (204). 404 tag-not-found when oldName does not exist, 409 tag-conflict when newName is taken (tags cannot be merged this way), 403 forbidden-tag-operation for non-admin API keys.
DELETEdelete_tagsDelete ONE tag (removes it from every short URL; the short URLs stay). Returns nothing (204), also for unknown names. Call once per tag. Non-admin API keys get 403 forbidden-tag-operation.
GETget_visit_statsInstance-wide visit counters: {nonOrphanVisits: {total, nonBots, bots}, orphanVisits: {total, nonBots, bots}}. Non-orphan visits hit an existing short URL; orphan visits hit the bare domain, an unknown or expired short code, or any other path. Cheap; use it before pulling visit lists.
GETlist_short_url_visitsList the visits of one short URL, newest first. Returns {data: Visit[], pagination}; a Visit is {referer, date, userAgent, visitLocation {countryCode, countryName, regionName, cityName, latitude, longitude, timezone} or null, potentialBot, visitedUrl, redirectUrl}. 100 per page unless itemsPerPage says otherwise. For counts per day, referrer or country use the summarize_visits composite instead of paging raw visits.
GETlist_tag_visitsList the visits of all short URLs carrying a tag, newest first. Returns {data: Visit[], pagination}, 100 per page by default. 404 tag-not-found for an unknown tag.
GETlist_domain_visitsList the visits of all short URLs on one domain, newest first. Returns {data: Visit[], pagination}, 20 per page by default. Use DEFAULT as domain for the default domain. 404 domain-not-found for an unknown domain. EXPENSIVE on instances with a large visits table: MySQL may walk the whole table to fill a page, and a 500-item page has exhausted a memory-limited database. Keep itemsPerPage small (<= 20) and prefer list_short_url_visits or list_tag_visits for analysis.
GETlist_non_orphan_visitsList visits to any existing short URL across the instance, newest first. Returns {data: Visit[], pagination}, 100 per page by default. Fast without the domain filter (index on the short URL reference). The domain filter adds the same join as list_domain_visits and can make MySQL walk the whole visits table; use small pages with it.
GETlist_orphan_visitsList orphan visits (requests that matched no valid short URL), newest first. Returns {data: OrphanVisit[], pagination}, 100 per page by default; each item is a Visit plus type: base_url (the bare domain), invalid_short_url (a single path segment that is no valid short code, i.e. unknown, expired or exhausted codes and scanner probes such as /wp-login.php) or regular_404 (any other path, e.g. several segments). Scanners and bots usually dominate here. API keys with the NO_ORPHAN_VISITS role always get an empty list. Every page runs a COUNT over all matching orphan visits; the domain filter (a substring match on visitedUrl), excludeBots and type turn that into a table scan. On instances with many orphan visits take totals from get_visit_stats.
DELETEdelete_short_url_visitsDelete ALL visits of one short URL (the short URL itself stays and its counters restart at 0). Irreversible. Returns {deletedVisits: number}.
DELETEdelete_orphan_visitsDelete ALL orphan visits of the instance (bot and scanner noise on unknown paths). Irreversible; there is no date or type filter. Returns {deletedVisits: number}; keys with the NO_ORPHAN_VISITS role get 0.
GETlist_domainsList the domains in use: {data: [{domain, isDefault, redirects: {baseUrlRedirect, regular404Redirect, invalidShortUrlRedirect}}], defaultRedirects}. defaultRedirects apply to every domain without its own setting; null means Shlink shows its 404 page. A domain appears once a short URL or a redirect setting uses it.
PATCHset_domain_redirectsSet where visitors of a domain go when nothing matches: the bare domain (baseUrlRedirect), a single path segment that is no valid short code, e.g. an unknown or expired one (invalidShortUrlRedirect), or any other path (regular404Redirect). Merges with the current values: omitted fields stay, null clears one. Creates the domain entry if needed. Needs the real authority (DEFAULT is rejected) and an admin API key. Returns the resulting redirects.
GETget_mercure_infoGet the Mercure hub URL and a short-lived subscriber JWT for real-time updates (new visits, new short URLs): {mercureHubUrl, jwt, jwtExpiration}. The JWT is a credential; do not log or share it. 501 mercure-not-configured when the instance has no Mercure hub.
GETget_healthHealth check of the instance including its database connection: {status: pass|fail, version, links}. HTTP 503 with status fail when Shlink cannot reach its database. Needs no API key; version shows the Shlink release. An HTML page instead of JSON (here and from every other tool) means the backend url points at the Shlink web client, not at the Shlink server.
What composite workflows does the Shlink DADL provide? ⚠ contains code
FNsummarize_visitsAggregate visits instead of paging raw rows: counts per day, top referrer hosts, countries, cities, visited and redirect URLs, bots and orphan types. scope selects the source: short_url (needs shortCode, plus domain for a non-default domain), tag (needs tag, optional domain), non_orphan (all short URLs) or orphan. Reads newest first in pages of 500, at most maxVisits rows (default 5000, max 20000); truncated=true means the counts cover only the newest analyzed visits of total_matching. There is deliberately no per-domain scope: that query can make MySQL walk the whole visits table. On instances with many orphan visits every orphan page counts all of them, so keep maxVisits low there and take totals from get_visit_stats.