From 5589a154179e0559442f2551f595831c48444719 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Wed, 1 Jul 2026 13:01:15 -0400 Subject: [PATCH 01/14] feat(landing): add blog content collection --- apps/landing/src/content.config.ts | 16 ++ .../why-giant-mcp-tool-walls-dont-scale.md | 92 ++++++ apps/landing/test/blog-content.test.ts | 34 +++ ...6-07-01-003-feat-landing-blog-path-plan.md | 264 ++++++++++++++++++ 4 files changed, 406 insertions(+) create mode 100644 apps/landing/src/content.config.ts create mode 100644 apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md create mode 100644 apps/landing/test/blog-content.test.ts create mode 100644 docs/plans/2026-07-01-003-feat-landing-blog-path-plan.md diff --git a/apps/landing/src/content.config.ts b/apps/landing/src/content.config.ts new file mode 100644 index 00000000..f69f5ae0 --- /dev/null +++ b/apps/landing/src/content.config.ts @@ -0,0 +1,16 @@ +import { defineCollection, z } from "astro:content"; + +export const collections = { + blog: defineCollection({ + type: "content", + schema: z.object({ + title: z.string(), + description: z.string(), + date: z.coerce.date(), + canonicalPath: z.string().regex(/^\/blog\/[a-z0-9-]+\/$/u), + tags: z.array(z.string()).default([]), + draft: z.boolean().default(false), + ogImage: z.string().optional(), + }), + }), +}; diff --git a/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md b/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md new file mode 100644 index 00000000..d4cb33df --- /dev/null +++ b/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md @@ -0,0 +1,92 @@ +--- +title: Why Giant MCP Tool Walls Don’t Scale +description: MCP made it easy to connect agents to tools. Caplets tackles the next scaling problem: keeping agents effective when those tools become a wall of hundreds of operations. +date: 2026-07-01 +canonicalPath: /blog/why-giant-mcp-tool-walls-dont-scale/ +tags: + - MCP + - agents + - benchmarks + - Code Mode +--- + +MCP made it easy to give coding agents tools. +That created the next problem: once every server exposes every operation up front, the agent starts each task staring at a giant tool wall. + +Caplets exists because tool access is not the same thing as usable capability. +A coding agent should be able to start with a focused capability, inspect only what matters, call the exact backend operation it needs, and return compact evidence without carrying the whole backend universe in its prompt. + +## The tool wall problem + +Flat MCP aggregation works well while the tool count is small. +It starts to break down when useful servers expose broad surfaces with generic names like `get`, `search`, and `list_recent_changes`. +The model has to reason over too much surface too early, schemas compete with the user's task for context, and multi-step discovery often turns into repeated model/tool round trips. + +Caplets changes that initial shape. +Instead of flattening every downstream operation into the first tool list, each backend becomes a named capability handle. +The agent can inspect the capability, search its operations, describe the schema it actually needs, call the operation, filter the result, and summarize the answer in one bounded workflow. + +## What the deterministic benchmark shows + +The deterministic benchmark in this repo compares direct flat MCP exposure with Caplets capability exposure over local mock MCP metadata. +It is intentionally reproducible: it does not call external APIs, depend on network access, or require model credentials. + +In that fixture, Caplets shows: + +- 96.7% fewer initially visible tools: 215 direct flat tools became 7 Caplets top-level handles. +- 79.9% lower initial serialized tool payload. +- 12,633 fewer approximate initial context tokens. +- 0 top-level duplicate tool-name collisions, compared with repeated direct collisions for generic names such as `get` and `search`. + +The Code Mode workflow fixture also showed 80.5% fewer model/tool round trips versus equivalent progressive-disclosure sequences, with required evidence fields preserved. + +These are deterministic context-surface and workflow-shape claims, not a universal live model win-rate claim. +Real MCP servers vary in schema quality, latency, operation count, and error behavior. +Live benchmark runs are useful for product direction, but they are model-dependent and belong in local result artifacts rather than deterministic product claims. + +## Why Code Mode is the wedge + +Progressive discovery is useful because it hides downstream operations until the agent asks for them. +Code Mode goes further: it lets the agent do discovery, inspection, execution, filtering, joining, and synthesis inside one bounded TypeScript workflow. + +That matters because many backend tasks are not one tool call. +They are small investigations: find the right operation, inspect the schema, fetch candidate records, preserve evidence fields, filter noise, and return a decision-ready answer. +Code Mode keeps bulky exploration inside the script and returns only the compact result the user needs. + +## Try it + +Install Caplets and wire it into your agent: + +```sh +npm install -g caplets +caplets setup +``` + +Then install a no-auth example Caplet: + +```sh +caplets install spiritledsoftware/caplets osv +``` + +Ask your coding agent to use Caplets Code Mode to query OSV for a package version and return compact JSON. +A successful run should use the visible `caplets__code_mode` tool and inspect a handle such as `caplets.osv`. + +## Reproduce the benchmark + +The committed benchmark report is in `docs/benchmarks/coding-agent.md`. +To regenerate it locally from the repository, run: + +```sh +pnpm benchmark +``` + +The benchmark is useful precisely because it is narrow. +It measures the initial tool surface, serialized payload size, approximate context-token proxy, duplicate-name pressure, and deterministic workflow shape. +It does not pretend to prove that every model, server, or task is faster in every environment. + +## The claim + +MCP made tool connection easy. +Caplets focuses on the next layer: making connected tools usable by coding agents without turning the prompt into a giant tool wall. + +Give your agent capabilities, not giant tool walls. diff --git a/apps/landing/test/blog-content.test.ts b/apps/landing/test/blog-content.test.ts new file mode 100644 index 00000000..f61b7f32 --- /dev/null +++ b/apps/landing/test/blog-content.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = join(import.meta.dirname, "../../.."); +const contentConfigPath = join(repoRoot, "apps/landing/src/content.config.ts"); +const launchPostPath = join( + repoRoot, + "apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md", +); + +describe("landing blog content", () => { + it("defines a typed blog content collection with required launch metadata", () => { + const source = readFileSync(contentConfigPath, "utf8"); + + expect(source).toContain("blog: defineCollection"); + expect(source).toContain("title:"); + expect(source).toContain("description:"); + expect(source).toContain("date:"); + expect(source).toContain("canonicalPath:"); + expect(source).toContain("tags:"); + }); + + it("ships the tool-wall launch essay as the first canonical blog post", () => { + const post = readFileSync(launchPostPath, "utf8"); + + expect(post).toContain("title: Why Giant MCP Tool Walls Don’t Scale"); + expect(post).toContain("canonicalPath: /blog/why-giant-mcp-tool-walls-dont-scale/"); + expect(post).toContain("96.7% fewer initially visible tools"); + expect(post).toContain("79.9% lower initial serialized tool payload"); + expect(post).toContain("deterministic benchmark"); + expect(post).toContain("pnpm benchmark"); + }); +}); diff --git a/docs/plans/2026-07-01-003-feat-landing-blog-path-plan.md b/docs/plans/2026-07-01-003-feat-landing-blog-path-plan.md new file mode 100644 index 00000000..0bc1a6ef --- /dev/null +++ b/docs/plans/2026-07-01-003-feat-landing-blog-path-plan.md @@ -0,0 +1,264 @@ +--- +title: Landing Blog Path - Plan +type: feat +date: 2026-07-01 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +product_contract_source: ce-plan-bootstrap +execution: code +--- + +# Landing Blog Path - Plan + +## Goal Capsule + +- **Objective:** Add a canonical `/blog` surface to the Caplets landing site so long-form launch and proof content lives on `caplets.dev` and can be syndicated elsewhere. +- **Product authority:** The blog supports the strategy in `STRATEGY.md`: public proof, reproducible claims, and trust in the Code Mode-first capability model. +- **Execution profile:** Standard software plan; implement in small units across the Astro landing app and shared web-observability package. +- **Stop conditions:** Stop and ask if implementation would require a CMS, user accounts, comments, dynamic server rendering, or raw per-post telemetry. +- **Tail ownership:** The implementation owns the initial static blog surface and first launch essay; ongoing editorial cadence remains marketing work outside this plan. + +--- + +## Product Contract + +### Summary + +Add a minimal, static, repo-owned blog to `apps/landing` with a `/blog` index and canonical post pages. +The first post should be the benchmark-backed “tool wall” launch essay, with strong metadata and CTAs into install, docs, catalog, GitHub, and the reproducible benchmark. +X Articles and social posts remain distribution channels that link back to the canonical Caplets URL. + +### Problem Frame + +Caplets’ strongest awareness asset is a technical argument backed by reproducible benchmark evidence. +If the launch essay lives only on X Articles, it will be platform-dependent, harder to cite from PR outreach, weaker for search and AI discovery, and disconnected from install/docs/catalog conversion paths. +The landing app already presents the core proof and activation story, so a small static blog path is the right owned-media home without introducing CMS carrying cost. + +### Requirements + +**Canonical owned media** + +- R1. The landing site exposes `/blog` as the canonical home for Caplets long-form marketing and technical proof posts. +- R2. Individual posts have stable slug URLs under `/blog//` and include canonical metadata that points to the owned URL. +- R3. X Articles, social posts, and community submissions are treated as syndication that link back to the canonical blog post. + +**Static content and editorial scope** + +- R4. Blog posts are repo-owned static Markdown/MDX-style content with typed frontmatter and no CMS dependency. +- R5. The initial content set includes the “Why Giant MCP Tool Walls Don’t Scale” launch essay with benchmark claims, limitations, and reproduction links. +- R6. Future recurring content such as “Caplet of the week” is deferred unless it only requires adding more posts to the same static collection. + +**Reader experience and conversion** + +- R7. The blog index lists posts with title, description, date, category/tag metadata, and a clear path to the canonical post. +- R8. Post pages preserve the landing site visual language while making long-form reading comfortable on mobile and desktop. +- R9. Post pages include calls to action for install/setup, docs, catalog, GitHub, npm, and benchmark reproduction where relevant. + +**Search, sharing, and privacy-safe measurement** + +- R10. Blog pages include title, description, canonical URL, Open Graph, and social-card metadata suitable for launch sharing. +- R11. Public-site observability classifies blog traffic and blog CTAs categorically without preserving raw slugs, raw URLs, browser identities, or hidden user identifiers. +- R12. Install-copy attribution from blog CTAs uses the existing landing-surface attribution model unless a future product decision creates a distinct blog marker. + +### Scope Boundaries + +#### In scope + +- Static blog collection for `apps/landing`. +- `/blog` index and `/blog//` detail route. +- Initial launch essay content. +- Landing navigation/footer links to the blog. +- Privacy-safe route and CTA categorization for blog traffic. +- Source-level tests plus Astro build/typecheck coverage. + +#### Deferred to Follow-Up Work + +- RSS feed, sitemap customization, and structured Article JSON-LD. +- Dedicated generated Open Graph images per post. +- “Caplet of the week” editorial system or recurring templates beyond adding ordinary posts. +- Search, tags archive, pagination, author pages, comments, newsletter capture, or CMS integration. + +#### Outside this product's identity + +- Treating X Articles as the canonical source of truth. +- Turning the catalog site into a blog or mixing editorial content into catalog discovery. +- Collecting raw article URLs, raw referrers, or user-identifying marketing telemetry. + +### Acceptance Examples + +- AE1. Given a visitor opens `/blog`, when the page renders, then they see the launch essay card with title, description, date, and a link to the canonical post. +- AE2. Given a visitor opens `/blog/why-giant-mcp-tool-walls-dont-scale/`, when the page renders, then the article has readable long-form layout, benchmark caveats, reproduction links, and install/docs/catalog CTAs. +- AE3. Given a social crawler previews the launch post URL, when it reads metadata, then it receives the post-specific title, description, canonical URL, and share image fallback. +- AE4. Given PostHog is configured, when a visitor opens or clicks through blog pages, then events use categorical `blog` route/page data and never include the raw slug. +- AE5. Given a post is missing required frontmatter, when the landing app typecheck/build runs, then the content validation fails before deployment. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Use Astro content collections for blog posts. Astro’s current content collection pattern supports typed static Markdown content, `getCollection('blog')`, and static paths without adding a CMS or runtime server. +- KTD2. Keep blog rendering inside `apps/landing`. The blog is owned marketing/proof content and should inherit landing styling, CTAs, and observability rather than living in Starlight docs or the catalog app. +- KTD3. Extend `LandingLayout` for page-specific SEO and social metadata. A shared layout prop surface prevents index and post pages from hand-rolling inconsistent head tags. +- KTD4. Add a categorical `blog` route/page family to `@caplets/web-observability`. Classifying `/blog` as `other` would weaken the public-site intent loop; capturing raw slugs would violate the existing privacy model. +- KTD5. Ship one polished launch post before broader content infrastructure. The first post proves the publishing path and supports the awareness campaign; larger editorial features are deferred until content volume justifies them. + +### High-Level Technical Design + +```mermaid +flowchart TB + Content[apps/landing/src/content/blog/*.md] --> Config[apps/landing/src/content.config.ts] + Config --> Index[apps/landing/src/pages/blog/index.astro] + Config --> Post[apps/landing/src/pages/blog/[slug].astro] + Layout[apps/landing/src/layouts/LandingLayout.astro] --> Index + Layout --> Post + Nav[Header and Footer links] --> Index + Obs[packages/web-observability blog categories] --> LandingObs[apps/landing/src/scripts/observability.ts] + LandingObs --> Index + LandingObs --> Post +``` + +The static content collection feeds both the index and post routes. +The shared layout supplies canonical and social metadata, while the observability package constrains blog measurements to categorical values. + +### Assumptions + +- The canonical domain for generated links is `https://caplets.dev` unless the existing deployment configuration exposes a more specific public origin during implementation. +- The initial share image can reuse an existing landing icon or generic social fallback; bespoke per-post OG images are deferred. +- The launch essay can be written directly in repo content during implementation from the approved marketing plan and benchmark docs. +- Blog install-copy attribution should stay under the existing `landing_install` category for now. + +### Sources and Research + +- `STRATEGY.md` frames public proof, docs, and reproducible benchmark confidence as an active strategic track. +- `apps/landing/src/pages/index.astro`, `apps/landing/src/layouts/LandingLayout.astro`, and `apps/landing/src/components/landing/*` show the current one-page landing composition and visual conventions. +- `apps/docs/src/content.config.ts` shows this repo already uses Astro content configuration in the docs app. +- `packages/web-observability/src/events.ts` and `packages/web-observability/src/privacy.ts` define the current categorical telemetry contract. +- Astro documentation research confirmed the current static blog pattern: collection content, `getCollection('blog')`, `getStaticPaths()`, and dynamic post routes. +- `docs/plans/2026-07-01-002-marketing-developer-awareness-plan.md` supplies the campaign direction and first-post topic. + +--- + +## Implementation Units + +### U1. Define blog content collection and launch post + +- **Goal:** Create the repo-owned static content source for landing blog posts and add the initial launch essay content. +- **Requirements:** R1, R2, R4, R5, R10, AE1, AE2, AE5 +- **Dependencies:** None +- **Files:** + - `apps/landing/src/content.config.ts` + - `apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md` + - `apps/landing/test/blog-content.test.ts` +- **Approach:** Define a `blog` content collection with required frontmatter for title, description, date, draft/publication state if needed, tags/category, canonical slug, and optional share metadata. Write the first post from the developer-awareness plan and benchmark docs, including benchmark limitations and reproduction links rather than only headline claims. +- **Patterns to follow:** Mirror the repo's existing Astro content configuration shape in `apps/docs/src/content.config.ts`; keep marketing claims aligned with `docs/benchmarks/coding-agent.md` and `STRATEGY.md`. +- **Test scenarios:** + - Covers AE5. A post without required title, description, date, or slug metadata fails content validation during typecheck/build. + - The launch post source includes the deterministic benchmark numbers, caveat language that the benchmark is deterministic, and a reproduction reference to `pnpm benchmark` or the benchmark document. + - The launch post frontmatter slug matches the intended canonical URL segment. +- **Verification:** The content collection is typed, the launch essay is present as the first non-draft post, and malformed required metadata cannot pass the landing app checks. + +### U2. Add blog index and post routes + +- **Goal:** Render `/blog` and `/blog//` from the static blog collection. +- **Requirements:** R1, R2, R7, R8, AE1, AE2 +- **Dependencies:** U1 +- **Files:** + - `apps/landing/src/pages/blog/index.astro` + - `apps/landing/src/pages/blog/[slug].astro` + - `apps/landing/src/lib/blog.ts` + - `apps/landing/test/blog-routes.test.ts` +- **Approach:** Add a small blog helper for sorting public posts newest-first and resolving post URLs. The index should render public posts as cards with date, title, description, tags/category, and link. The dynamic route should use static path generation from collection entries and render article content through Astro’s content rendering API. +- **Patterns to follow:** Follow the landing page's `w-[min(1180px,calc(100vw_-_32px))]` container rhythm and existing card/button components where useful. +- **Test scenarios:** + - Covers AE1. The blog index source or rendered build includes the launch post title, description, date, and `/blog/why-giant-mcp-tool-walls-dont-scale/` link. + - Covers AE2. The post route is generated by the static build and renders the launch essay body rather than only metadata. + - Draft or future-hidden posts are excluded from the index and static paths if the schema includes such a state. + - Unknown slugs rely on Astro’s static routing behavior and do not create a catch-all route. +- **Verification:** Static build output contains `/blog/index.html` and the launch post route, with no runtime server dependency. + +### U3. Extend layout metadata and article presentation + +- **Goal:** Give blog index and post pages first-class SEO/share metadata and readable long-form presentation. +- **Requirements:** R8, R9, R10, AE2, AE3 +- **Dependencies:** U1, U2 +- **Files:** + - `apps/landing/src/layouts/LandingLayout.astro` + - `apps/landing/src/components/landing/BlogArticle.astro` + - `apps/landing/src/components/landing/BlogCta.astro` + - `apps/landing/src/styles/global.css` + - `apps/landing/test/blog-metadata.test.ts` +- **Approach:** Extend `LandingLayout` with optional canonical URL, Open Graph title/description/type/image, and article date props. Add a blog article component or section classes that make Markdown content readable without diluting the landing aesthetic. Include a reusable CTA block for install/setup, docs, catalog, GitHub, npm, and benchmark reproduction. +- **Patterns to follow:** Reuse `Button`, `Card`, and current muted/foreground/accent token patterns; preserve `skip-link`, header/footer slots, and mobile responsiveness. +- **Test scenarios:** + - Covers AE3. The launch post output includes post-specific ``, meta description, canonical link, `og:title`, `og:description`, `og:url`, and `og:type` values. + - Covers AE2. Article layout keeps one primary `<h1>`, visible publication date, and content width suitable for long-form reading. + - CTA links point to install/docs/catalog/GitHub/npm/benchmark targets without broken relative URLs. + - Default layout metadata remains valid for the home page when blog-specific props are absent. +- **Verification:** Home page metadata remains unchanged except for intentional layout enhancements, and blog pages produce shareable metadata from post frontmatter. + +### U4. Add blog navigation and conversion paths + +- **Goal:** Make the blog discoverable from the landing site without disrupting the current single-page conversion flow. +- **Requirements:** R1, R3, R7, R9, AE1, AE2 +- **Dependencies:** U2, U3 +- **Files:** + - `apps/landing/src/components/landing/Header.astro` + - `apps/landing/src/components/landing/Footer.astro` + - `apps/landing/src/components/landing/Hero.astro` + - `apps/landing/test/blog-navigation.test.ts` +- **Approach:** Add a `/blog` link to desktop and mobile navigation plus footer. Consider one lightweight home-page link from the benchmark/proof area or hero secondary action only if it does not crowd the existing install/docs/catalog actions. +- **Patterns to follow:** Match existing header link classes, mobile sheet behavior, footer link style, and external-link icon conventions only for off-site links. +- **Test scenarios:** + - The desktop header, mobile sheet, and footer contain a first-party `/blog` link. + - `/blog` links do not use `target="_blank"` or external-link icons. + - Existing catalog, docs, GitHub, npm, and hash-section links remain present. + - If a home-page proof link is added, it points to the launch post or blog index and does not replace benchmark reproduction links. +- **Verification:** Visitors can navigate from home to blog and back, while existing landing section navigation still works. + +### U5. Preserve privacy-safe observability for blog traffic + +- **Goal:** Classify blog pageviews and blog CTAs categorically without raw post slugs or URLs. +- **Requirements:** R11, R12, AE4 +- **Dependencies:** U2, U4 +- **Files:** + - `packages/web-observability/src/events.ts` + - `packages/web-observability/src/privacy.ts` + - `packages/web-observability/test/web-observability.test.ts` + - `apps/landing/src/scripts/observability.ts` + - `apps/landing/test/observability.test.ts` +- **Approach:** Add `blog` as an allowed route/page family and navigation/CTA category where needed. Classify `/blog` and `/blog/<slug>` as `blog`, but never pass the actual slug into event properties. Keep install copy attribution under the existing landing surface unless a later product decision adds a separate blog marker. +- **Patterns to follow:** Follow the categorical allowlist design in `packages/web-observability/src/privacy.ts` and existing route tests in `packages/web-observability/test/web-observability.test.ts`. +- **Test scenarios:** + - Covers AE4. `classifyRouteFamily('/blog')` and `classifyRouteFamily('/blog/why-giant-mcp-tool-walls-dont-scale')` return `blog` without preserving slug values. + - Blog links in landing observability produce categorical blog navigation/CTA values. + - Unsafe raw URLs and unknown telemetry properties remain rejected after adding blog categories. + - Blog install-copy CTAs still emit `landing_install` attribution and do not introduce a raw article identifier. +- **Verification:** Blog observability enriches public-site intent with categorical data while preserving the Anonymous Telemetry and Anonymous Install Attribution constraints in `CONCEPTS.md`. + +--- + +## Verification Contract + +| Gate | Scope | Done signal | +| ---------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | +| `pnpm --filter @caplets/landing test` | U1-U4 and landing observability pieces of U5 | New and existing landing tests pass. | +| `pnpm --filter @caplets/landing typecheck` | U1-U4 | Astro content collection, routes, and layout props typecheck. | +| `pnpm --filter @caplets/landing build` | U1-U4 | Static `/blog` and launch-post pages build successfully. | +| `pnpm --filter @caplets/web-observability test` | U5 | Shared route/privacy contract accepts `blog` and still rejects unsafe values. | +| `pnpm --filter @caplets/web-observability typecheck` | U5 | Shared observability type changes compile. | +| `pnpm format:check` | All units | New Markdown, Astro, CSS, and TypeScript files match repo formatting. | +| `pnpm lint` | All units | No lint regressions from route, component, or telemetry changes. | + +--- + +## Definition of Done + +- `/blog` and `/blog/why-giant-mcp-tool-walls-dont-scale/` are generated by the landing build. +- The launch post is the canonical source for the tool-wall essay and includes benchmark evidence, limitations, reproduction path, and conversion CTAs. +- Home, header, mobile menu, and footer navigation still work and expose the blog intentionally. +- Blog metadata is post-specific and suitable for social sharing. +- Observability classifies blog traffic categorically and does not preserve raw slugs, URLs, identities, or hidden identifiers. +- All Verification Contract gates pass. +- Any abandoned prototype/CMS/RSS/OG-image experimentation is removed from the final diff. From 9e4c94ef987db841a082325c5702f6a3371810a6 Mon Sep 17 00:00:00 2001 From: Ian Pascoe <ian.g.pascoe@gmail.com> Date: Wed, 1 Jul 2026 13:04:31 -0400 Subject: [PATCH 02/14] feat(landing): render static blog routes --- apps/landing/src/content.config.ts | 6 +- .../why-giant-mcp-tool-walls-dont-scale.md | 2 +- apps/landing/src/lib/blog.ts | 19 +++++ apps/landing/src/pages/blog/[slug].astro | 56 ++++++++++++++ apps/landing/src/pages/blog/index.astro | 74 +++++++++++++++++++ apps/landing/test/blog-routes.test.ts | 42 +++++++++++ 6 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 apps/landing/src/lib/blog.ts create mode 100644 apps/landing/src/pages/blog/[slug].astro create mode 100644 apps/landing/src/pages/blog/index.astro create mode 100644 apps/landing/test/blog-routes.test.ts diff --git a/apps/landing/src/content.config.ts b/apps/landing/src/content.config.ts index f69f5ae0..889b0c4e 100644 --- a/apps/landing/src/content.config.ts +++ b/apps/landing/src/content.config.ts @@ -1,8 +1,10 @@ -import { defineCollection, z } from "astro:content"; +import { defineCollection } from "astro:content"; +import { glob } from "astro/loaders"; +import { z } from "astro/zod"; export const collections = { blog: defineCollection({ - type: "content", + loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }), schema: z.object({ title: z.string(), description: z.string(), diff --git a/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md b/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md index d4cb33df..c31e639e 100644 --- a/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md +++ b/apps/landing/src/content/blog/why-giant-mcp-tool-walls-dont-scale.md @@ -1,6 +1,6 @@ --- title: Why Giant MCP Tool Walls Don’t Scale -description: MCP made it easy to connect agents to tools. Caplets tackles the next scaling problem: keeping agents effective when those tools become a wall of hundreds of operations. +description: "MCP made it easy to connect agents to tools. Caplets tackles the next scaling problem: keeping agents effective when those tools become a wall of hundreds of operations." date: 2026-07-01 canonicalPath: /blog/why-giant-mcp-tool-walls-dont-scale/ tags: diff --git a/apps/landing/src/lib/blog.ts b/apps/landing/src/lib/blog.ts new file mode 100644 index 00000000..891aff36 --- /dev/null +++ b/apps/landing/src/lib/blog.ts @@ -0,0 +1,19 @@ +export type BlogPostLike = { + slug: string; + data: { + date: Date; + draft?: boolean; + }; +}; + +export function blogPostUrl(slug: string): string { + return `/blog/${slug}/`; +} + +export function sortBlogPostsNewestFirst<const TPost extends BlogPostLike>( + posts: TPost[], +): TPost[] { + return posts + .filter((post) => !post.data.draft) + .toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime()); +} diff --git a/apps/landing/src/pages/blog/[slug].astro b/apps/landing/src/pages/blog/[slug].astro new file mode 100644 index 00000000..f771eea3 --- /dev/null +++ b/apps/landing/src/pages/blog/[slug].astro @@ -0,0 +1,56 @@ +--- +import { getCollection, render } from "astro:content"; + +import Footer from "../../components/landing/Footer.astro"; +import Header from "../../components/landing/Header.astro"; +import LandingLayout from "../../layouts/LandingLayout.astro"; +import { blogPostUrl, sortBlogPostsNewestFirst } from "../../lib/blog"; + +export async function getStaticPaths() { + const posts = sortBlogPostsNewestFirst( + (await getCollection("blog")).map((entry) => ({ + ...entry, + slug: entry.id, + })), + ); + + return posts.map((entry) => ({ + params: { slug: entry.slug }, + props: { entry }, + })); +} + +const { entry } = Astro.props; +const { Content } = await render(entry); +--- + +<LandingLayout title={`${entry.data.title} | Caplets Blog`} description={entry.data.description}> + <Header slot="header" /> + <article class="mx-auto w-[min(840px,calc(100vw_-_32px))] py-20 md:py-28"> + <a class="text-sm font-medium text-primary-accent hover:text-foreground" href="/blog/">Blog</a> + <h1 class="mt-4 text-4xl leading-tight font-semibold tracking-[-0.03em] text-balance text-foreground md:text-6xl"> + {entry.data.title} + </h1> + <p class="mt-5 text-xl leading-8 text-pretty text-muted-foreground">{entry.data.description}</p> + <time class="mt-6 block text-sm text-muted-foreground" datetime={entry.data.date.toISOString()}> + {entry.data.date.toLocaleDateString("en", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + })} + </time> + <div class="blog-prose mt-12"> + <Content /> + </div> + <p class="mt-12 text-sm text-muted-foreground"> + Canonical URL: <a class="text-foreground underline decoration-border underline-offset-4 hover:text-primary-accent" href={blogPostUrl(entry.slug)}>{entry.data.canonicalPath}</a> + </p> + </article> + <Footer slot="footer" /> +</LandingLayout> + +<script> + import "../../scripts/observability"; + import "../../scripts/header-collapse"; +</script> diff --git a/apps/landing/src/pages/blog/index.astro b/apps/landing/src/pages/blog/index.astro new file mode 100644 index 00000000..387ad761 --- /dev/null +++ b/apps/landing/src/pages/blog/index.astro @@ -0,0 +1,74 @@ +--- +import { getCollection } from "astro:content"; +import ArrowUpRight from "@tabler/icons/outline/arrow-up-right.svg"; + +import Footer from "../../components/landing/Footer.astro"; +import Header from "../../components/landing/Header.astro"; +import { Badge } from "../../components/starwind/badge"; +import { Card, CardContent } from "../../components/starwind/card"; +import LandingLayout from "../../layouts/LandingLayout.astro"; +import { blogPostUrl, sortBlogPostsNewestFirst } from "../../lib/blog"; + +const posts = sortBlogPostsNewestFirst( + (await getCollection("blog")).map((entry) => ({ + ...entry, + slug: entry.id, + })), +); +const featuredTitle = "Why Giant MCP Tool Walls Don’t Scale"; +--- + +<LandingLayout title="Caplets Blog" description="Long-form notes and benchmark-backed proof for Caplets."> + <Header slot="header" /> + <section class="mx-auto w-[min(960px,calc(100vw_-_32px))] py-20 md:py-28" aria-labelledby="blog-title"> + <p class="text-sm font-medium text-primary-accent">Blog</p> + <h1 id="blog-title" class="mt-3 max-w-3xl text-4xl leading-tight font-semibold tracking-[-0.03em] text-balance text-foreground md:text-6xl"> + Notes on agents, capabilities, and the tool wall problem. + </h1> + <p class="mt-6 max-w-2xl text-lg leading-8 text-pretty text-muted-foreground"> + Start with the launch essay, <span class="text-foreground">{featuredTitle}</span>, then syndicate the canonical URL wherever developers already talk about MCP. + </p> + </section> + + <section class="mx-auto w-[min(960px,calc(100vw_-_32px))] pb-24" aria-label="Blog posts"> + <div class="grid gap-4"> + { + posts.map((post) => ( + <Card class="overflow-hidden"> + <CardContent class="grid gap-5 p-6 md:grid-cols-[1fr_auto] md:items-start"> + <div> + <div class="mb-4 flex flex-wrap items-center gap-2"> + <time class="text-sm text-muted-foreground" datetime={post.data.date.toISOString()}> + {post.data.date.toLocaleDateString("en", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + })} + </time> + {post.data.tags.map((tag) => <Badge variant="outline">{tag}</Badge>)} + </div> + <h2 class="text-2xl font-semibold tracking-[-0.02em] text-foreground"> + <a class="rounded-sm hover:text-primary-accent focus-visible:ring-3 focus-visible:ring-outline/50 focus-visible:outline-none" href={blogPostUrl(post.slug)}> + {post.data.title} + </a> + </h2> + <p class="mt-3 max-w-2xl leading-7 text-muted-foreground">{post.data.description}</p> + </div> + <a class="inline-flex min-h-11 items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground transition hover:bg-muted focus-visible:ring-3 focus-visible:ring-outline/50 focus-visible:outline-none" href={blogPostUrl(post.slug)}> + Read post + <ArrowUpRight class="size-4" aria-hidden="true" /> + </a> + </CardContent> + </Card> + )) + } + </div> + </section> + <Footer slot="footer" /> +</LandingLayout> + +<script> + import "../../scripts/observability"; + import "../../scripts/header-collapse"; +</script> diff --git a/apps/landing/test/blog-routes.test.ts b/apps/landing/test/blog-routes.test.ts new file mode 100644 index 00000000..452cb56c --- /dev/null +++ b/apps/landing/test/blog-routes.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { blogPostUrl, sortBlogPostsNewestFirst } from "../src/lib/blog"; + +const repoRoot = join(import.meta.dirname, "../../.."); + +describe("landing blog routes", () => { + it("builds canonical post URLs with trailing slashes", () => { + expect(blogPostUrl("why-giant-mcp-tool-walls-dont-scale")).toBe( + "/blog/why-giant-mcp-tool-walls-dont-scale/", + ); + }); + + it("sorts public posts newest first", () => { + const posts = sortBlogPostsNewestFirst([ + { slug: "older", data: { date: new Date("2026-01-01"), draft: false } }, + { slug: "draft", data: { date: new Date("2026-12-01"), draft: true } }, + { slug: "newer", data: { date: new Date("2026-07-01"), draft: false } }, + ]); + + expect(posts.map((post) => post.slug)).toEqual(["newer", "older"]); + }); + + it("defines a blog index and static post route from the blog collection", () => { + const indexSource = readFileSync( + join(repoRoot, "apps/landing/src/pages/blog/index.astro"), + "utf8", + ); + const postSource = readFileSync( + join(repoRoot, "apps/landing/src/pages/blog/[slug].astro"), + "utf8", + ); + + expect(indexSource).toContain('getCollection("blog")'); + expect(indexSource).toContain("Why Giant MCP Tool Walls Don’t Scale"); + expect(postSource).toContain("getStaticPaths"); + expect(postSource).toContain('getCollection("blog")'); + expect(postSource).toContain("render(entry)"); + }); +}); From c8456ca619cf9e0b6763588cb43634f1a5f61f27 Mon Sep 17 00:00:00 2001 From: Ian Pascoe <ian.g.pascoe@gmail.com> Date: Wed, 1 Jul 2026 13:06:23 -0400 Subject: [PATCH 03/14] feat(landing): add blog article metadata --- .../src/components/landing/BlogArticle.astro | 34 ++++++++++ .../src/components/landing/BlogCta.astro | 34 ++++++++++ apps/landing/src/layouts/LandingLayout.astro | 17 ++++- apps/landing/src/pages/blog/[slug].astro | 36 ++++------ apps/landing/src/styles/global.css | 68 +++++++++++++++++++ apps/landing/test/blog-metadata.test.ts | 45 ++++++++++++ 6 files changed, 211 insertions(+), 23 deletions(-) create mode 100644 apps/landing/src/components/landing/BlogArticle.astro create mode 100644 apps/landing/src/components/landing/BlogCta.astro create mode 100644 apps/landing/test/blog-metadata.test.ts diff --git a/apps/landing/src/components/landing/BlogArticle.astro b/apps/landing/src/components/landing/BlogArticle.astro new file mode 100644 index 00000000..60464cb3 --- /dev/null +++ b/apps/landing/src/components/landing/BlogArticle.astro @@ -0,0 +1,34 @@ +--- +interface Props { + canonicalPath: string; + date: Date; + description: string; + title: string; +} + +const { canonicalPath, date, description, title } = Astro.props; +--- + +<article class="mx-auto w-[min(840px,calc(100vw_-_32px))] py-20 md:py-28"> + <a class="text-sm font-medium text-primary-accent hover:text-foreground" href="/blog/">Blog</a> + <h1 class="mt-4 text-4xl leading-tight font-semibold tracking-[-0.03em] text-balance text-foreground md:text-6xl"> + {title} + </h1> + <p class="mt-5 text-xl leading-8 text-pretty text-muted-foreground">{description}</p> + <time class="mt-6 block text-sm text-muted-foreground" datetime={date.toISOString()}> + { + date.toLocaleDateString("en", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }) + } + </time> + <div class="blog-prose mt-12"> + <slot /> + </div> + <p class="mt-12 text-sm text-muted-foreground"> + Canonical URL: <a class="text-foreground underline decoration-border underline-offset-4 hover:text-primary-accent" href={canonicalPath}>{canonicalPath}</a> + </p> +</article> diff --git a/apps/landing/src/components/landing/BlogCta.astro b/apps/landing/src/components/landing/BlogCta.astro new file mode 100644 index 00000000..3eab6cc9 --- /dev/null +++ b/apps/landing/src/components/landing/BlogCta.astro @@ -0,0 +1,34 @@ +--- +import ArrowUpRight from "@tabler/icons/outline/arrow-up-right.svg"; + +import { Button } from "@/components/starwind/button"; +import { Card, CardContent } from "@/components/starwind/card"; +--- + +<Card class="mt-14 overflow-hidden border-primary-accent/35 bg-card/85"> + <CardContent class="grid gap-6 p-6 md:grid-cols-[1fr_auto] md:items-center"> + <div> + <p class="text-sm font-medium text-primary-accent">Try Caplets</p> + <h2 class="mt-2 text-2xl font-semibold tracking-[-0.02em] text-foreground">Give your agent capabilities, not giant tool walls.</h2> + <p class="mt-3 max-w-2xl leading-7 text-muted-foreground"> + Install the CLI, wire up your agent, browse the catalog, or read the benchmark method behind the launch claim. + </p> + <code class="mt-5 block overflow-x-auto rounded-lg bg-foreground p-4 font-mono text-sm leading-6 whitespace-pre text-background">npm install -g caplets +caplets setup</code> + </div> + <div class="flex flex-col gap-3 sm:flex-row md:flex-col"> + <Button href="https://docs.caplets.dev" variant="outline"> + Read docs + <ArrowUpRight class="size-4" aria-hidden="true" /> + </Button> + <Button href="https://catalog.caplets.dev" variant="outline"> + Browse catalog + <ArrowUpRight class="size-4" aria-hidden="true" /> + </Button> + <Button href="https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md" variant="outline"> + Benchmark method + <ArrowUpRight class="size-4" aria-hidden="true" /> + </Button> + </div> + </CardContent> +</Card> diff --git a/apps/landing/src/layouts/LandingLayout.astro b/apps/landing/src/layouts/LandingLayout.astro index f36e6621..1cfd1fa1 100644 --- a/apps/landing/src/layouts/LandingLayout.astro +++ b/apps/landing/src/layouts/LandingLayout.astro @@ -4,13 +4,19 @@ import "../styles/global.css"; import { themeColor } from "../data/landing"; interface Props { - title?: string; + canonicalUrl?: string; description?: string; + ogImage?: string; + ogType?: "article" | "website"; + title?: string; } const { + canonicalUrl, title = "Caplets, capabilities instead of giant tool walls", description = "Caplets gives coding agents focused capability modes instead of giant MCP tool walls.", + ogImage = "/icon.png", + ogType = "website", } = Astro.props; --- @@ -22,6 +28,15 @@ const { <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="generator" content={Astro.generator} /> <meta name="description" content={description} /> + {canonicalUrl && <link rel="canonical" href={canonicalUrl} />} + <meta property="og:title" content={title} /> + <meta property="og:description" content={description} /> + {canonicalUrl && <meta property="og:url" content={canonicalUrl} />} + <meta property="og:type" content={ogType} /> + <meta property="og:image" content={ogImage} /> + <meta name="twitter:card" content="summary_large_image" /> + <meta name="twitter:title" content={title} /> + <meta name="twitter:description" content={description} /> <meta name="theme-color" content={themeColor} /> <title>{title} diff --git a/apps/landing/src/pages/blog/[slug].astro b/apps/landing/src/pages/blog/[slug].astro index f771eea3..a4fd1046 100644 --- a/apps/landing/src/pages/blog/[slug].astro +++ b/apps/landing/src/pages/blog/[slug].astro @@ -1,6 +1,8 @@ --- import { getCollection, render } from "astro:content"; +import BlogArticle from "../../components/landing/BlogArticle.astro"; +import BlogCta from "../../components/landing/BlogCta.astro"; import Footer from "../../components/landing/Footer.astro"; import Header from "../../components/landing/Header.astro"; import LandingLayout from "../../layouts/LandingLayout.astro"; @@ -22,31 +24,21 @@ export async function getStaticPaths() { const { entry } = Astro.props; const { Content } = await render(entry); +const canonicalUrl = `https://caplets.dev${blogPostUrl(entry.slug)}`; --- - +
-
- Blog -

- {entry.data.title} -

-

{entry.data.description}

- -
- -
-

- Canonical URL: {entry.data.canonicalPath} -

-
+ + + +