Skip to content

MCP tools - get_seo and get_rules

The head every page needs

A page with no og:image shares as a row of grey text. A description of 195 characters is cut at 160 and the last 35 were written for nobody. Neither is a judgement call, so the bare.style MCP server does not describe them: it hands back the whole head with your values stamped in, and it measures the values on the way past.

Tags returned
22
Description ceiling
160
The card every platform crops to
1200 x 630

Add the bare.style MCP server to your AI editor and it can ask for this head rather than remember one.

One call, one head

Coalstack is a log search product and this is its pricing page. Four arguments go in. Everything below them comes back filled: the canonical, the whole Open Graph set with the image dimensions and the alt text, and the four Twitter tags with the large card.

The call

get_seo
{
  "title": "Pricing - Coalstack log search",
  "description": "Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling. Start free, move to a paid plan when your ingest does.",
  "url": "https://coalstack.io/pricing",
  "siteName": "Coalstack"
}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing - Coalstack log search</title>
<meta name="description" content="Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling. Start free, move to a paid plan when your ingest does.">
<link rel="canonical" href="https://coalstack.io/pricing">

<meta property="og:type" content="website">
<meta property="og:site_name" content="Coalstack">
<meta property="og:url" content="https://coalstack.io/pricing">
<meta property="og:title" content="Pricing - Coalstack log search">
<meta property="og:description" content="Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling.">
<meta property="og:locale" content="en_GB">
<meta property="og:image" content="https://coalstack.io/og/pricing.png">
<meta property="og:image:secure_url" content="https://coalstack.io/og/pricing.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Pricing - Coalstack log search">

<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Pricing - Coalstack log search">
<meta name="twitter:description" content="Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling.">
<meta name="twitter:image" content="https://coalstack.io/og/pricing.png">
<meta name="twitter:image:alt" content="Pricing - Coalstack log search">

Most of what came back was never typed. It is derived from the canonical URL, which is the one argument that already carries the answer, and deriving it is what stops a second copy of a string existing in order to go stale against the first.

og:image
The path becomes the file name, so a page that moves cannot keep a card pointing at where it used to be. /pricing gives /og/pricing.png, absolute on the origin the page is served from.
og:site_name
The hostname, when you do not pass one. Here it was passed, so it was used as given.
og:description
A different field from the page description and a shorter one. This one was cut at the last sentence end before 125 characters, which is where feed clients cut, and the page kept the long one for search.
og:locale
Defaults to en_GB, and its language half is what the graph below carries as inLanguage.

It measures what you pass it

A budget nothing measures is a suggestion. The same page, with the description a marketing document supplied instead: one sentence, 195 characters, and no part of it looks wrong.

The call, with a longer description

195 characters
{
  "title": "Pricing - Coalstack log search",
  "description": "Coalstack is the log search platform for engineering teams who want answers fast, with pricing that scales from a free tier for side projects, right up to enterprise plans with dedicated support.",
  "url": "https://coalstack.io/pricing",
  "siteName": "Coalstack"
}
YOUR VALUES
title: 30 characters, within 30 to 60.
description: 195 characters, 35 over the 160 budget. Shorten it.
og:description: derived from your description and cut to 80 characters, because feed clients cut at 125. The page keeps the long one for search.
Fill these before shipping: {{description}}
{{description}} is blank because what you supplied is past the 160 character ceiling. Shorten it and put it in yourself.
head, 24 lines, which run to the end of this message:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing - Coalstack log search</title>
<meta name="description" content="{{description}}">
<link rel="canonical" href="https://coalstack.io/pricing">

The value is not stamped in. The field keeps its token, the reason travels with the token, and the page that gets pasted has a visible hole in it.

Why withhold it rather than warn about it

The first version of this tool did stamp the long value in and put the warning at the top of the reply, three thousand bytes above the block that gets pasted. That is a warning arranged to lose. A page with a visible hole in its head gets fixed; a page with a 195-character description in it does not, because nothing about it looks wrong.

Under the floor it says so too, and in the same place. A description of 96 characters is reported as 54 under the 150 floor, because a description the engine decides to ignore gets replaced by a snippet assembled out of the page body, which is the one outcome the tag exists to prevent.

The two ends are not held with the same force, and the difference is worth stating plainly rather than leaving somebody to discover it in an exit code. A ceiling is a fact: the engine cuts the string there, so the characters past it were written for nobody. A floor is a judgement, and it is ours.

What each end does to your build

Over the ceiling fails the build, on any machine. Under the floor writes a warning to stderr naming the page, the field, the measured length and the target, and the build succeeds with the card written. Set BARE_SEO_STRICT=1 to fail on the floor as well. On this repository the floor already fails and no value of that variable turns it off: a budget its own author publishes and does not keep is worth less than none.

What a link looks like when it lands

One page, two heads. The head on the left is what a page ships with when nobody was asked; the head on the right is the reply above, pasted. The mock at the top of each is what a feed client can build out of the tags under it.

Before

shares as a bare link

coalstack.io

Pricing | Coalstack

Coalstack is the log search platform for engineering teams who want answers fast, with pricing that scales from a free tier for side projects, right up to enterprise plans with dedicated support.

<meta charset="utf-8">
<title>Pricing | Coalstack</title>
<meta name="description" content="Coalstack is the log search platform for engineering teams who want answers fast, with pricing that scales from a free tier for side projects, right up to enterprise plans with dedicated support.">
<link rel="canonical" href="/pricing">

<meta property="og:title" content="Pricing | Coalstack">
<meta property="og:description" content="Coalstack is the log search platform for engineering teams who want answers fast, with pricing that scales from a free tier for side projects, right up to enterprise plans with dedicated support.">
<meta property="og:image" content="/img/social.png">

After

shares with a card

coalstack.io

Pricing - Coalstack log search

Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling.

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing - Coalstack log search</title>
<meta name="description" content="Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling. Start free, move to a paid plan when your ingest does.">
<link rel="canonical" href="https://coalstack.io/pricing">

<meta property="og:image" content="https://coalstack.io/og/pricing.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="Pricing - Coalstack log search">
<meta name="twitter:card" content="summary_large_image">

The tags that make the difference. The full 24 are in the reply above.

The card itself is generated, not drawn. The recipe the server returns reads the page's own title and description and composes the image from them, so the headline on the card and the headline of the page are one string and cannot drift apart. It is PNG rather than SVG because Facebook, LinkedIn, X, WhatsApp and Pinterest all reject image/svg+xml and render the link with no picture at all, which is the exact failure a card exists to prevent.

The part that says what the page is

Every other tag in the head describes how the page looks in somebody else's list. This one states what it is. Search engines read it for the knowledge panel and for sitelinks; assistants read it because it is the only part of a page that answers the question directly. Ask for it with section=jsonld and the same four values fill the same three nodes.

WebSite, Organization, WebPage

section=jsonld
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebSite",
      "@id": "https://coalstack.io/#website",
      "url": "https://coalstack.io/",
      "name": "Coalstack",
      "description": "Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling. Start free, move to a paid plan when your ingest does.",
      "publisher": { "@id": "https://coalstack.io/#org" },
      "inLanguage": "en"
    },
    {
      "@type": "Organization",
      "@id": "https://coalstack.io/#org",
      "name": "Coalstack",
      "url": "https://coalstack.io/",
      "logo": "https://coalstack.io/logo.png"
    },
    {
      "@type": "WebPage",
      "@id": "https://coalstack.io/pricing#page",
      "url": "https://coalstack.io/pricing",
      "name": "Pricing - Coalstack log search",
      "description": "Coalstack pricing: every plan keeps 30 days of hot logs, unlimited seats and a one second search ceiling. Start free, move to a paid plan when your ingest does.",
      "isPartOf": { "@id": "https://coalstack.io/#website" },
      "primaryImageOfPage": "https://coalstack.io/og/pricing.png",
      "inLanguage": "en"
    }
  ]
}
</script>

The rules that come with it are the ones that stop a graph being worse than no graph at all.

  • Every value has to be true of the rendered page Markup describing a page the reader does not get is what a manual action is for.
  • The @id values are the only thing joining the nodes Keep the fragment scheme fixed across the site.
  • Organization belongs on every page Not only the home page, or the nodes that reference it dangle.
  • Article, not WebPage, once there is an author and a date datePublished and dateModified stop being optional.
  • No SearchAction Google stopped rendering the sitelinks search box in 2024, so it is bytes that earn nothing.

The house rules come with it

A framework that has an opinion about punctuation and writes it in a README has an opinion nobody reads. These travel with the server, so a project that wires bare.style up inherits them without anybody writing them down again, and get_rules checks text rather than describing the rule.

A launch headline, checked

get_rules
YOUR TEXT, 5 problems:
character 11: left curly double quote (U+201C) - use "
character 20: right curly double quote (U+201D) - use "
character 22: em-dash (U+2014) - use hyphen-minus
character 54: right curly single quote (U+2019) - use '
character 74: emoji (U+1F525) - icons are inline SVG, call get_icon

The list it is checked against is not a second copy of anything. The server publishes the same thirteen code points that scripts/charcheck.ts fails this repository's build on, and a test fails the moment the two lists differ by a single entry.

Punctuation
em-dash, en-dash, both curly single quotes, both curly double quotes and the ellipsis character. Hyphen-minus, straight quotes and three dots instead.
Invisible
Non-breaking space, zero-width space, zero-width non-joiner, zero-width joiner and the byte order mark. These travel with a paste and none of them can be seen.
Emoji
Every pictographic code point, and the variation selector that follows one. An icon is inline SVG on one grid at one stroke weight, sized and coloured by tokens.
Typefaces
No Space Mono, in any weight, for any purpose. The mono stack is --font-mono, which leads with the framework's own face and degrades to ui-monospace.
Shipped text
No generated-by line, no co-author trailer, no assistant voice left in a comment. Comments say why, because a comment restating the line under it is the first thing to go stale.
Motion
Transform, opacity, clip-path and filter only, and prefers-reduced-motion honoured in every animated rule.

Before you ship

The reply ends with this. Eight lines, and every one of them is a thing a deploy can be made to fail on rather than a thing somebody remembers to look at.

  • Title 30 to 60 characters, unique to this page Site name last, separated by a hyphen-minus
  • Description 150 to 160 characters, unique to this page Two pages sharing one compete and neither wins
  • One canonical, absolute Dropping index.html and the .html extension
  • og:image and twitter:image that actually resolve Absolute https, 1200 by 630, PNG
  • Alt text on both, and twitter:card summary_large_image Read out when the image fetch fails
  • One ld+json graph whose values match the rendered page One script element, one @graph, one identity per node
  • sitemap.xml and robots.txt agreeing with every canonical Generate both from one list
  • Previews, fixtures and iframes carry noindex And stay out of the sitemap

A generator that writes the cards and injects the whole block ships inside the package, so the recipe is not left as an exercise. Point it at your own origin and your own build directory and it reads each page's title and description and composes a card per page from them. Point it at your own mark, name and foot line as well: a card is the part of a shared link people look at, and left unset it draws none of the three rather than borrowing ours.

BARE_ORIGIN=https://your.site BARE_OG_WORDMARK=Acme BARE_OG_MARK=brand/mark.svg BARE_OG_FOOT='acme.example  /  since 1946' bun run node_modules/@barebase/style/scripts/build-og.ts dist

It measures the same two budgets on the way through, and it exits 1 on the things that are facts rather than the things that are ours: a missing title or description, a duplicate of either, a title or a description past its ceiling, a build machine with no browser to rasterise a card. A page under a floor is a warning on stderr and a card that still gets written. Add BARE_SEO_STRICT=1 to the line above to be held to the floor too, which is what this site is held to.

Add the server by hand

This configuration is on your clipboard. Paste it into the file your client reads, then restart the client.

{
      "mcpServers": {
      "bare-style": {
      "command": "npx",
      "args": ["-y", "-p", "@barebase/style", "bare-style-mcp"]
      }
      }
      }
Claude Code
Run claude mcp add bare-style -- npx -y -p @barebase/style bare-style-mcp. Or paste the block above into .mcp.json in the root of the project, which is the file Claude Code reads for a project-scoped server.
Claude Desktop
Settings, then Developer, then Edit Config. That opens claude_desktop_config.json. Paste the block above and restart the application, because the config is read once at launch.
Windsurf
Settings, then Cascade, then Manage MCP servers, then Edit raw config. That is ~/.codeium/windsurf/mcp_config.json. Paste the block above and refresh the server list.
Anything else
Any client that reads an mcpServers object takes the block above unchanged. A client that wants the command on its own runs npx -y -p @barebase/style bare-style-mcp and speaks MCP over stdio.