pineapples.dev
pineapples.dev
Back to Blog

AI Strategy

search_guides: What the Tool Returns

Published Oct 14, 2026. Updated Oct 14, 2026

search_guides matches titles, excerpts, and tags in a build-time index. Each hit is a title, a URL, and one line. It does not fetch the site.

search_guides: What the Tool Returns
search_guides reads title, excerpt, and tags and returns title, url, and summary. It does not fetch. Source: lib/webmcp/guide-search.ts and lib/webmcp/tools.ts.

search_guides: What the Tool Returns

search_guides searches a build-time JSON index of guide titles, excerpts, and tags. It does not fetch the site, and it does not return the article. The limit defaults to 5 and caps at 10. Each match is a title, a https://pineapples.dev URL, and a one-line summary.

The problem

An agent that can already see the blog still has to decide which URL to open. The index page is a list. The essay is behind another fetch. I did not want search_guides to crawl pineapples.dev, and I did not want the client bundle to carry every markdown body.

The titles, excerpts, and tags are already in frontmatter. The tool searches those three fields in memory and returns the URL. If the agent wants the essay, it opens that URL itself.

How the three tools are registered is the overview, WebMCP: How We Made pineapples.dev Usable by AI Agents. The cost tool is estimate_fractional_cto_cost: What the Tool Returns. This page is search_guides only.

How it is built

package.json runs tooling/webmcp/build-guide-index.mjs before next build. The comment at the top of that script says it reads blog frontmatter only, and that the client never fetches the site and never receives markdown bodies.

The script lists content/blog, keeps files that end in .md or .mdx, and sorts the names. It takes the first frontmatter block and reads lines that look like key: value. A value in square brackets is parsed as JSON. A value in double quotes is parsed as JSON. Other lines are skipped, so a nested FAQ does not become part of the index. title and excerpt are required. A file missing either one throws. tags are optional. plain() replaces U+2014 with a spaced hyphen and U+2013 with a hyphen, then collapses whitespace. The url is https://pineapples.dev/blog/ plus the filename slug. The script writes lib/webmcp/guides.json. lib/webmcp/guide-index.ts imports that JSON as GUIDE_INDEX.

The builder does not import NOINDEX_BLOG_SLUGS from lib/blog.ts. A markdown file still becomes a row when the sitemap leaves the slug out. web3-dapp-development-nextjs is that case.

The overview says the index that shipped with the tools held 128 guides. The script does not store that number. It writes one object per file and prints guides.length. The file in this commit has 85 rows.

Frontmatter is written to guides.json, then imported by search_guides
build-guide-index.mjs writes guides.json from blog frontmatter before next build. search_guides imports that file and does not fetch. Source: tooling/webmcp/build-guide-index.mjs, lib/webmcp/guide-index.ts, package.json.

createWebMcpTools in lib/webmcp/tools.ts builds the object the page registers. readOnlyHint is true. required is query. limit is an optional integer. The description says the default is 5 and the maximum is 10.

execute records the call, then searches. If query is not a string, it becomes an empty string. A throw inside the try becomes one sentence.

  const searchGuidesTool: WebMcpTool = {
    name: "search_guides",
    title: "Search guides",
    description:
      "Search Pineapples guide titles, excerpts, and tags. Returns matching titles, full https://pineapples.dev URLs, and a one-line summary. Does not fetch the site.",
    inputSchema: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "Words to match against guide titles, excerpts, and tags.",
        },
        limit: {
          type: "integer",
          minimum: 1,
          maximum: 10,
          description: "Maximum matches to return. Defaults to 5 and cannot exceed 10.",
        },
      },
      required: ["query"],
    },
    annotations: { readOnlyHint: true },
    execute: async (input) => {
      pushWebmcpToolCall("search_guides")
      try {
        const query = typeof input.query === "string" ? input.query : ""
        const matches = searchGuides(GUIDE_INDEX, query, input.limit)
        return textContent(formatGuideMatches(query, matches))
      } catch {
        return textContent("The guide search could not be completed.")
      }
    },
  }

pushWebmcpToolCall sits outside the try. lib/webmcp/analytics.ts returns immediately when window is missing or dataLayer is missing. When dataLayer exists, it pushes webmcp_tool_call with tool_name and page_path. I have no call count. I will not publish one.

The search itself is searchGuides in lib/webmcp/guide-search.ts.

tokenize lowercases the query and splits on every run of characters that are not ASCII letters or digits. A token of one character is dropped. The remaining tokens joined with spaces are the normalized query.

function tokenize(query: string): string[] {
  return query
    .toLowerCase()
    .split(/[^a-z0-9]+/)
    .filter((term) => term.length > 1)
}

function scoreEntry(entry: GuideIndexEntry, terms: string[], normalizedQuery: string): number {
  const title = entry.title.toLowerCase()
  const excerpt = entry.excerpt.toLowerCase()
  const tags = entry.tags.map((tag) => tag.toLowerCase())
  let score = 0
  if (normalizedQuery.length > 1 && title.includes(normalizedQuery)) score += 8
  for (const term of terms) {
    if (title.includes(term)) score += 5
    if (tags.some((tag) => tag.includes(term))) score += 3
    if (excerpt.includes(term)) score += 1
  }
  return score
}

includes is a substring check. The token guide matches inside guides and inside search_guides. A row with score 0 is dropped. The rest sort by score descending, then title.localeCompare. slice uses clampGuideLimit. The summary is oneLineSummary of the excerpt, or the title if that line is empty.

  return index
    .map((entry) => ({ entry, score: scoreEntry(entry, terms, normalizedQuery) }))
    .filter((row) => row.score > 0)
    .sort((a, b) => b.score - a.score || a.entry.title.localeCompare(b.entry.title))
    .slice(0, cap)
    .map(({ entry }) => ({
      title: entry.title,
      url: entry.url,
      summary: oneLineSummary(entry.excerpt) || entry.title,
    }))

clampGuideLimit does not trust the schema. A number is used as a number. A non-empty string goes through Number. Anything that is not finite becomes 5. Math.floor runs next. A whole number below 1 becomes 1. A whole number above 10 becomes 10.

export function clampGuideLimit(limit: unknown): number {
  const numeric =
    typeof limit === "number" ? limit : typeof limit === "string" && limit.trim() !== "" ? Number(limit) : Number.NaN
  if (!Number.isFinite(numeric)) return DEFAULT_LIMIT
  const whole = Math.floor(numeric)
  if (whole < 1) return 1
  if (whole > MAX_LIMIT) return MAX_LIMIT
  return whole
}

DEFAULT_LIMIT is 5. MAX_LIMIT is 10. I ran the function on this tree. undefined, "", " ", and "nope" return 5. 0, 0.9, and -3 return 1. 1.9 returns 1. "8" returns 8. 100 returns 10.

oneLineSummary does the same dash replacement as the builder, then collapses whitespace, including newlines. formatGuideMatches runs that on the query before it echoes it. No rows produces No guides matched "<query>". One row says guide. More than one says guides. Each row is three lines: the number and the title, the url, the summary.

export function formatGuideMatches(query: string, matches: readonly GuideMatch[]): string {
  const shown = oneLineSummary(query)
  if (matches.length === 0) return `No guides matched "${shown}".`
  const lines = [`${matches.length} ${matches.length === 1 ? "guide" : "guides"} matched "${shown}".`]
  matches.forEach((match, index) => {
    lines.push(`${index + 1}. ${match.title}`, match.url, match.summary)
  })
  return lines.join("\n")
}
scoreEntry adds four weights, then the rows are sorted and clamped
scoreEntry adds 8 when the title contains the joined query, then 5, 3, and 1 per term. Sort uses the score, then title.localeCompare. clampGuideLimit defaults to 5 and caps at 10. Source: lib/webmcp/guide-search.ts.

A whitespace query has no tokens, so searchGuides returns an empty list, and the formatter still runs. A one-character query does the same, because tokenize drops it. These two calls are the text the function returned when I ran them:

No guides matched "".
No guides matched "a".

The first call was three spaces. The second call was the letter a.

What a real query returns

I ran searchGuides on lib/webmcp/guides.json with the query fractional cto and limit 2. Passing 2 is why the first line says 2. Omitting limit returns 5. Passing 10 returns 10. 18 rows scored above 0.

2 guides matched "fractional cto".
1. Fractional CTO for Mid-Market and PE Portcos
https://pineapples.dev/blog/fractional-cto-services-mid-market-guide
A fractional CTO is the same senior operator week to week. Architecture, vendor, hiring, board-ready tech. No full-time hire. Not a rotating bench.
2. Fractional CTO Rates: What the Seat Returns
https://pineapples.dev/blog/fractional-cto-roi-business-case-mid-market
Fractional CTO rates depend on the seat, the days, and the scope. The return is the decision that gets made, and the spend that does not.

Both of those titles contain the joined query fractional cto, so each gets 8. Each token hits the title (5, twice), a tag (3, twice), and the excerpt (1, twice). That is 26. They tie. localeCompare puts Fractional CTO for Mid-Market and PE Portcos first.

The next row is estimate_fractional_cto_cost: What the Tool Returns. Its title contains each token. It does not contain the joined query, because an underscore sits between fractional and cto. The 8 stays off, and the score is 18.

The same scorer on the query vendor finds 6 rows above 0. The top two both score 17: 8 for the word in the title, 5 for the title token, 3 for a tag, and 1 for the excerpt. They tie. Locale order puts Software Vendor Management for Non-Technical Executives: How to Stop Bleeding Budget on Development Partners ahead of Vendor Due Diligence for PE and Mid-Market Buyers.

What I held the function to

The client never fetches the site. GUIDE_INDEX is a static import of guides.json. execute does not call fetch. The tool description says the same thing in the sentence the agent reads.

The article stays on the page. The builder comment says the client never receives markdown bodies. A match is a title, a url, and one line. The line is the excerpt after oneLineSummary, or the title when the excerpt collapses to nothing.

A tie is locale order. The comparator is score descending, then title.localeCompare. When two rows share a score, the title order is the whole decision. The vendor pair above is that case. The points did not separate them.

The schema does not replace the clamp. inputSchema says minimum 1 and maximum 10, and the description says the default is 5. clampGuideLimit still runs on whatever the host passes. "nope" becomes 5. 100 becomes 10. 0 becomes 1. "8" becomes 8.

A throw inside the search is one sentence. The catch returns The guide search could not be completed. The analytics push is outside that catch. A missing dataLayer skips the push and the search still runs.

readOnlyHint is true. This tool does not navigate and does not write storage. request_call is the tool that navigates, and that path is on the overview.

Lessons

Substring points will rank a title that contains the tokens above an essay whose words live only in the body. The body is not in the index. That is the trade I accepted so the client would not ship every article.

Dropping one-character tokens keeps a from scoring every excerpt. It also means the a inside zzzz-not-a-guide does nothing. tokenize splits on the hyphens, drops a, and scores not and guide. zzzz matches nothing.

lib/webmcp/guide-search.test.mjs has a test named for an empty or unknown query. The unknown string is zzzz-not-a-guide, and the fixture has three guides. None of those three contain not or guide, so the list is empty. On guides.json the same string is not empty. I counted 46 rows with a score above 0. This post's own row scores 10, which is the top score on this index, so it sorts first. The title contains the substring guide, the tag Guides contains guide, and the excerpt contains not and guide.

I strip U+2014 and U+2013 twice. The builder does it when it writes the file. oneLineSummary does it again when it formats a summary or the echoed query. An em dash becomes a space, a hyphen, and a space. An en dash becomes a hyphen. The test that reads guides.json rejects both characters in stored titles and excerpts, and it rejects a newline in the excerpt.

guide-search.test.mjs holds 6 tests. A run of that file reports 6 passed and 0 failed. They check that a title hit leads the fixture, that an excerpt hit and a tag hit resolve, that the summary is one line and the url is on the apex, that the limit defaults to 5 and caps at 10, that the fixture returns nothing for blank input and for zzzz-not-a-guide, and that every object in guides.json has excerpt, tags, title, and url, with an apex blog url and neither dash character. lib/webmcp/registration.test.mjs pushes search_guides onto a fake dataLayer and checks tool_name and page_path.

I have no usage number for this tool. The event is webmcp_tool_call. The payload is the tool name and the path. The count is not in the source, so it is not in this post.

The cost numbers live in the other tool, estimate_fractional_cto_cost: What the Tool Returns. The registration that mounts all three is WebMCP: How We Made pineapples.dev Usable by AI Agents.

Frequently asked questions

Does search_guides fetch the site?

No. execute calls searchGuides on GUIDE_INDEX, a static import of lib/webmcp/guides.json. lib/webmcp/guide-search.ts does not call fetch. The tool description says it does not fetch the site.

How many results does search_guides return?

The limit defaults to 5. clampGuideLimit floors the value, turns a non-finite value into 5, raises anything below 1 to 1, and caps anything above 10 at 10. The inputSchema describes the same bounds. The function still clamps.

What does each search_guides match contain?

A title, a full https://pineapples.dev/blog URL, and a one-line summary. The summary is the excerpt after dash replacement and whitespace collapse, or the title if that line is empty. The markdown body is not in the index.

Is there a usage count for search_guides?

No. execute calls pushWebmcpToolCall, which pushes webmcp_tool_call with tool_name and page_path when dataLayer already exists. If window or dataLayer is missing, it returns. There is no count in the repo.

Written by Anthony Wentzel

Keep reading