Skip to content

feat(api): AllTrails OG preview endpoint and MCP tool#2310

Merged
andrew-bierman merged 3 commits into
developmentfrom
feature/alltrails-preview
Apr 26, 2026
Merged

feat(api): AllTrails OG preview endpoint and MCP tool#2310
andrew-bierman merged 3 commits into
developmentfrom
feature/alltrails-preview

Conversation

@andrew-bierman
Copy link
Copy Markdown
Collaborator

@andrew-bierman andrew-bierman commented Apr 26, 2026

Summary

  • Adds POST /api/alltrails/preview — scrapes og:title, og:description, og:image from an AllTrails trail page
  • Validates HTTPS + alltrails.com hostname (regex, covers subdomains, blocks lookalikes)
  • Handles timeouts (504), upstream errors (502), redirect-outside-domain (400), missing og:title (422)
  • Exposes preview_alltrails_url MCP tool
  • 13 route tests covering all validation paths and upstream error cases

Test plan

  • cd packages/api && bun run test -- test/alltrails.test.ts — 13 tests pass
  • POST a valid AllTrails URL and confirm OG metadata is returned
  • POST an http:// URL → 400
  • POST a non-alltrails URL → 400

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added AllTrails URL preview endpoint that extracts and returns page metadata including title, description, and image.
    • New MCP tool for previewing AllTrails URLs.
  • Tests

    • Comprehensive test suite for AllTrails preview functionality covering validation, metadata extraction, and error handling.

POST /api/alltrails/preview scrapes og:title, og:description, og:image
from AllTrails URLs server-side. MCP tool preview_alltrails_url exposes
this to agents for trip/pack enrichment.
13 tests covering request validation, OG metadata extraction, timeout (504),
network failure (502), upstream 4xx (502), missing og:title (422), and
redirect-outside-alltrails.com (400).
Copilot AI review requested due to automatic review settings April 26, 2026 19:57
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb23ba24-a26d-407b-b642-490522935b43

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new AllTrails preview endpoint (POST /alltrails/preview) that validates HTTPS URLs pointing to alltrails.com, fetches the page with a custom User-Agent and 8-second timeout, extracts OpenGraph metadata via regex, and returns extracted title, description, image, and resolved URL.

Changes

Cohort / File(s) Summary
API Endpoint Implementation
packages/api/src/routes/alltrails.ts
New route group with POST /preview handler; validates URL format and domain, enforces HTTPS, fetches remote page with 8s timeout, extracts OG metadata via regex (requires og:title), verifies redirect destination stays on alltrails.com, returns title/description/image/url or appropriate error status (400/422/502/504).
Route Registration
packages/api/src/routes/index.ts
Imports alltrailsRoutes and registers it in the main Elysia route group after knowledgeBaseRoutes.
Test Coverage
packages/api/test/alltrails.test.ts
Comprehensive test suite covering request validation (missing URL, invalid format, non-HTTPS, non-AllTrails domain, lookalikes), successful OG metadata extraction (title/description/image with null for missing optional tags), subdomain support, and error handling (timeout → 504, network error → 502, missing og:title → 422, redirect outside domain → 400).
MCP Tool Integration
packages/mcp/src/index.ts, packages/mcp/src/tools/trails.ts
Registers new MCP tool preview_alltrails_url that validates URL input and makes HTTP POST to /alltrails/preview, returning result wrapper (ok or err).

Sequence Diagram

sequenceDiagram
    participant MCP as MCP Client
    participant Tool as Trail Tools
    participant API as API Endpoint
    participant AllTrails as AllTrails Host
    
    MCP->>Tool: preview_alltrails_url(url)
    Tool->>Tool: Validate URL format
    Tool->>API: POST /alltrails/preview {url}
    API->>API: Validate domain (alltrails.com)
    API->>API: Enforce HTTPS
    API->>AllTrails: GET page (custom User-Agent, 8s timeout)
    AllTrails-->>API: HTML response
    API->>API: Extract OG metadata via regex
    API->>API: Verify og:title exists
    API->>API: Verify redirect URL on alltrails.com
    API-->>Tool: {title, description, image, url}
    Tool-->>MCP: ok({...metadata}) or err(exception)
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Hopping through AllTrails with whiskers held high,
We peek at the pages beneath the blue sky,
OpenGraph treasures and images glow,
URLs we validate, letting no preview go!
A scraper so speedy with error-checks true,
Now trails preview awaits you! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: adding an AllTrails OG preview endpoint to the API and exposing it as an MCP tool, which aligns with the changeset across multiple packages.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/alltrails-preview

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added dependencies Pull requests that update a dependency file api labels Apr 26, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 26, 2026

Coverage Report for API Unit Tests Coverage (./packages/api)

Status Category Percentage Covered / Total
🔵 Lines 74.9% 582 / 777
🔵 Statements 74.9% (🎯 65%) 582 / 777
🔵 Functions 95.91% 47 / 49
🔵 Branches 88.23% 270 / 306
File CoverageNo changed files found.
Generated in workflow #798 for commit 953a903 by the Vitest Coverage Report Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 26, 2026

Coverage Report for Expo Unit Tests Coverage (./apps/expo)

Status Category Percentage Covered / Total
🔵 Lines 81.38% 516 / 634
🔵 Statements 81.38% (🎯 75%) 516 / 634
🔵 Functions 92.85% 52 / 56
🔵 Branches 92.55% 199 / 215
File CoverageNo changed files found.
Generated in workflow #798 for commit 953a903 by the Vitest Coverage Report Action

@andrew-bierman
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an API + MCP surface for generating OpenGraph previews from AllTrails trail pages, intended to enrich PackRat entities (trips/packs) when a user shares an AllTrails link.

Changes:

  • Added POST /api/alltrails/preview route to fetch and parse og:title, og:description, and og:image from AllTrails HTML.
  • Added MCP tool preview_alltrails_url that calls the new API endpoint.
  • Added Vitest coverage for validation, success cases, and upstream error handling.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
packages/api/src/routes/alltrails.ts Implements the new AllTrails OG preview endpoint (validation, fetch, HTML parsing, error mapping).
packages/api/src/routes/index.ts Registers alltrailsRoutes under the aggregated /api router.
packages/api/test/alltrails.test.ts Adds route-level tests covering input validation, successful parsing, and failure modes.
packages/mcp/src/tools/trails.ts Introduces preview_alltrails_url MCP tool to call the API preview route.
packages/mcp/src/index.ts Registers the new trail tools during MCP agent initialization.
bun.lock Updates lockfile/workspace versions and records dependency graph changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +36 to +39
response = await fetch(url, {
headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(8000),
});
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect check happens after fetch() completes, but fetch() will follow redirects by default. That means a crafted alltrails.com URL could still cause the worker to make an outbound request to a non-AllTrails host before you return the 400, which defeats the intent of the redirect guard. Consider using redirect: 'manual' and explicitly validating/following 3xx Location targets only when they remain within *.alltrails.com (or disable redirects entirely).

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +28
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return status(400, { error: 'Invalid URL' });
}
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the route already declares body: z.object({ url: z.string().url() }), Elysia validation should reject invalid URLs before the handler runs (via the app-level onError handler). This try/catch + Invalid URL branch is likely dead code and the manual parse can be simplified to const parsed = new URL(url) to avoid redundant validation paths and inconsistent error messaging.

Suggested change
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return status(400, { error: 'Invalid URL' });
}
const parsed = new URL(url);

Copilot uses AI. Check for mistakes.
Comment on lines +18 to +22
export const alltrailsRoutes = new Elysia({ prefix: '/alltrails' }).post(
'/preview',
async ({ body }) => {
const { url } = body;

Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint proxies/scrapes a third-party site and currently appears to be callable without authentication (no isAuthenticated: true). That makes it easy to abuse for unauthenticated scraping/egress, even if the hostname is restricted. Consider gating it behind Bearer auth (e.g. add isAuthenticated: true + security: [{ bearerAuth: [] }], and ensure the auth macro is available for this router) to align with other external-fetch endpoints like /knowledge-base/reader/extract.

Copilot uses AI. Check for mistakes.
import { Elysia, status } from 'elysia';
import { z } from 'zod';

const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)?alltrails\.com$/;
Copy link

Copilot AI Apr 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ALLTRAILS_HOSTNAME_RE only allows at most one subdomain level ((?:...\.)?). That rejects valid multi-level subdomains like foo.bar.alltrails.com even though the PR description says subdomains are supported. Consider changing this to allow any number of subdomain labels (e.g. (?:[a-z0-9-]+\.)*alltrails\.com).

Suggested change
const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)?alltrails\.com$/;
const ALLTRAILS_HOSTNAME_RE = /^(?:[a-z0-9-]+\.)*alltrails\.com$/;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/mcp/src/tools/trails.ts (1)

11-13: Tighten the input description to reflect accepted subdomains.

The route accepts https://(*.)alltrails.com/... (e.g., www., es.), but the agent-facing hint reads "must be https://alltrails.com/...", which may cause the model to reject otherwise valid links a user shares.

-        url: z.string().url().describe('Full AllTrails URL (must be https://alltrails.com/...)'),
+        url: z
+          .string()
+          .url()
+          .describe('Full AllTrails URL (https://alltrails.com/... or any *.alltrails.com)'),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mcp/src/tools/trails.ts` around lines 11 - 13, The inputSchema's url
description is too strict; update the describe text for the url field inside
inputSchema to reflect accepted subdomains (e.g., allow https://*.alltrails.com/
like www., es.) instead of "must be https://alltrails.com/..."; adjust the
string in the url.describe(...) for the url property so the hint clearly states
"Full AllTrails URL (must be https://*.alltrails.com/...)" or similar phrasing
that mentions allowed subdomains, keeping the validation using z.string().url()
unchanged.
packages/api/src/routes/alltrails.ts (1)

7-16: Decoded HTML entities and name="og:..." variants are missed by the regex extractor.

OpenGraph values served by AllTrails (and most CMSs) frequently contain HTML entities (&amp;, &#x27;, &quot;, named entities for accents, etc.). extractOgTag returns the raw attribute substring, so callers — and ultimately UI components rendering the title/description — will receive literal &amp; rather than &. The regex also won't match <meta name="og:title" ...>, which some pages emit. linkedom is already a project dependency (see packages/api/src/routes/knowledgeBase/reader.ts); switching to it gives correct entity decoding and tolerates attribute ordering/casing.

♻️ Suggested approach using linkedom
-import { Elysia, status } from 'elysia';
-import { z } from 'zod';
+import { parseHTML } from 'linkedom';
+import { Elysia, status } from 'elysia';
+import { z } from 'zod';
@@
-function extractOgTag(html: string, property: string): string | null {
-  const match =
-    html.match(
-      new RegExp(`<meta[^>]+property=["']${property}["'][^>]+content=["']([^"']+)["']`, 'i'),
-    ) ??
-    html.match(
-      new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property}["']`, 'i'),
-    );
-  return match?.[1] ?? null;
-}
+function extractOgTag(document: Document, property: string): string | null {
+  const el =
+    document.querySelector(`meta[property="${property}"]`) ??
+    document.querySelector(`meta[name="${property}"]`);
+  return el?.getAttribute('content') ?? null;
+}

And at the call site:

-    const html = await response.text();
-
-    const title = extractOgTag(html, 'og:title');
+    const html = await response.text();
+    const { document } = parseHTML(html);
+
+    const title = extractOgTag(document, 'og:title');
@@
-    const description = extractOgTag(html, 'og:description');
-    const image = extractOgTag(html, 'og:image');
+    const description = extractOgTag(document, 'og:description');
+    const image = extractOgTag(document, 'og:image');

Note: the static-analysis ReDoS warning on these lines is a false positive — property is only ever called with hardcoded og:title/og:description/og:image — but switching to a parser side-steps it entirely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 7 - 16, extractOgTag
currently uses regex that misses name="og:..." and returns raw HTML entities;
replace its implementation to parse the HTML with linkedom's DOMParser (imported
from linkedom), query for a meta element whose property or name equals the
provided property (case-insensitive and tolerant of attribute order), and return
the meta's content attribute (linkedom will decode HTML entities) or null if not
found; update references to extractOgTag to keep the same signature (string ->
string|null).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/api/src/routes/alltrails.ts`:
- Around line 30-32: Update the 400 error message in the alltrails route to
accurately reflect allowed hosts: when checking parsed.protocol and
ALLTRAILS_HOSTNAME_RE, change the returned message from "URL must be an
https://alltrails.com URL" to something that mentions https and allows
alltrails.com and its single-level subdomains (e.g., "URL must be an
https://alltrails.com or subdomain URL"); locate the check that uses parsed and
ALLTRAILS_HOSTNAME_RE and update the status(400, { error: ... }) string
accordingly.
- Around line 73-83: This route currently uses Elysia-style route metadata and
must be rewritten to use OpenAPIHono's createRoute with the existing Zod body
schema; replace the current Elysia route definition in alltrails.ts with a
createRoute call that declares method (POST), path, and the Zod body schema
(z.object({ url: z.string().url() })) as the request schema, move the
tags/summary/description into the OpenAPI metadata for createRoute, and ensure
the handler function signature matches OpenAPIHono expectations and returns the
same OG preview response; import createRoute from OpenAPIHono and keep the Zod
schema identifier so type-safe validation and generated docs work.

---

Nitpick comments:
In `@packages/api/src/routes/alltrails.ts`:
- Around line 7-16: extractOgTag currently uses regex that misses name="og:..."
and returns raw HTML entities; replace its implementation to parse the HTML with
linkedom's DOMParser (imported from linkedom), query for a meta element whose
property or name equals the provided property (case-insensitive and tolerant of
attribute order), and return the meta's content attribute (linkedom will decode
HTML entities) or null if not found; update references to extractOgTag to keep
the same signature (string -> string|null).

In `@packages/mcp/src/tools/trails.ts`:
- Around line 11-13: The inputSchema's url description is too strict; update the
describe text for the url field inside inputSchema to reflect accepted
subdomains (e.g., allow https://*.alltrails.com/ like www., es.) instead of
"must be https://alltrails.com/..."; adjust the string in the url.describe(...)
for the url property so the hint clearly states "Full AllTrails URL (must be
https://*.alltrails.com/...)" or similar phrasing that mentions allowed
subdomains, keeping the validation using z.string().url() unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec1d7943-13c8-4de9-b7e1-d9a6c6992d9e

📥 Commits

Reviewing files that changed from the base of the PR and between 9d169ab and a702b3a.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • packages/api/src/routes/alltrails.ts
  • packages/api/src/routes/index.ts
  • packages/api/test/alltrails.test.ts
  • packages/mcp/src/index.ts
  • packages/mcp/src/tools/trails.ts

Comment on lines +30 to +32
if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://alltrails.com URL' });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error message understates what's accepted.

The host regex permits any single-level subdomain (www., es., …) but the message implies only the bare apex. A user posting https://es.alltrails.com/... that fails parsing will see a misleading error. Minor wording fix:

-      return status(400, { error: 'URL must be an https://alltrails.com URL' });
+      return status(400, { error: 'URL must be an https://(*.)alltrails.com URL' });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://alltrails.com URL' });
}
if (parsed.protocol !== 'https:' || !ALLTRAILS_HOSTNAME_RE.test(parsed.hostname)) {
return status(400, { error: 'URL must be an https://(*.)alltrails.com URL' });
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 30 - 32, Update the 400
error message in the alltrails route to accurately reflect allowed hosts: when
checking parsed.protocol and ALLTRAILS_HOSTNAME_RE, change the returned message
from "URL must be an https://alltrails.com URL" to something that mentions https
and allows alltrails.com and its single-level subdomains (e.g., "URL must be an
https://alltrails.com or subdomain URL"); locate the check that uses parsed and
ALLTRAILS_HOSTNAME_RE and update the status(400, { error: ... }) string
accordingly.

Comment on lines +73 to +83
{
body: z.object({
url: z.string().url(),
}),
detail: {
tags: ['AllTrails'],
summary: 'Fetch AllTrails OG preview',
description:
'Scrapes OpenGraph metadata (title, description, image) from an AllTrails trail page.',
},
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

Does Elysia 1.4 support Zod schemas directly for the body validator via Standard Schema, or is a plugin/adapter required?

💡 Result:

Yes, Elysia 1.4 supports Zod schemas directly for the body validator via Standard Schema out of the box. No plugin or adapter is required.

Citations:


🏁 Script executed:

head -100 packages/api/src/routes/alltrails.ts

Repository: PackRat-AI/PackRat

Length of output: 2588


Use OpenAPIHono with createRoute per API guidelines instead of Elysia. Elysia 1.4 validates Zod schemas directly via Standard Schema out of the box, so the original concern about unvalidated bodies is not applicable—the schema will be honored. However, this file violates the mandatory requirement that API routes in packages/api/src/**/*.ts must use OpenAPIHono with createRoute and Zod schemas for type-safe, documented endpoints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routes/alltrails.ts` around lines 73 - 83, This route
currently uses Elysia-style route metadata and must be rewritten to use
OpenAPIHono's createRoute with the existing Zod body schema; replace the current
Elysia route definition in alltrails.ts with a createRoute call that declares
method (POST), path, and the Zod body schema (z.object({ url: z.string().url()
})) as the request schema, move the tags/summary/description into the OpenAPI
metadata for createRoute, and ensure the handler function signature matches
OpenAPIHono expectations and returns the same OG preview response; import
createRoute from OpenAPIHono and keep the Zod schema identifier so type-safe
validation and generated docs work.

@andrew-bierman andrew-bierman merged commit 4a3b3ae into development Apr 26, 2026
10 checks passed
@andrew-bierman andrew-bierman deleted the feature/alltrails-preview branch April 26, 2026 20:54
andrew-bierman added a commit that referenced this pull request May 14, 2026
feat(api): AllTrails OG preview endpoint and MCP tool
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants