diff --git a/.changeset/calm-component-text-sizes.md b/.changeset/calm-component-text-sizes.md
new file mode 100644
index 0000000000..d5e66b0ab0
--- /dev/null
+++ b/.changeset/calm-component-text-sizes.md
@@ -0,0 +1,5 @@
+---
+'@primer/brand-mcp': patch
+---
+
+Updated `primer_brand_page_design` tool to recommend component text defaults and `primer_brand_review` to warn about unnecessary `size` props.
diff --git a/.changeset/connect-gridlines-to-page-edges.md b/.changeset/connect-gridlines-to-page-edges.md
new file mode 100644
index 0000000000..98b53fb364
--- /dev/null
+++ b/.changeset/connect-gridlines-to-page-edges.md
@@ -0,0 +1,8 @@
+---
+'@primer/brand-mcp': patch
+---
+
+Updated MCP gridline tools to prevent disconnected, centered border boxes:
+
+- Updated `primer_brand_page_design` to prefer built-in gridline APIs and scope custom page-width frames to grouped Cards and Pillars.
+- Updated `primer_brand_examples` to preserve canonical Card/Pillar companion CSS and surface the focused Statistic gridline example.
diff --git a/.changeset/remove-card-skew-effect.md b/.changeset/remove-card-skew-effect.md
new file mode 100644
index 0000000000..1b4b20dd4d
--- /dev/null
+++ b/.changeset/remove-card-skew-effect.md
@@ -0,0 +1,5 @@
+---
+'@primer/react-brand': minor
+---
+
+⚠️ `CardSkewEffect` has been removed from `@primer/react-brand`. The effect has been retired, and no replacement will be issued.
diff --git a/.changeset/route-agents-to-primer-brand.md b/.changeset/route-agents-to-primer-brand.md
new file mode 100644
index 0000000000..2cb8192250
--- /dev/null
+++ b/.changeset/route-agents-to-primer-brand.md
@@ -0,0 +1,9 @@
+---
+'@primer/brand-mcp': minor
+---
+
+Updated `primer_brand_setup` to create or update `AGENTS.md` beside a `package.json` that declares `@primer/react-brand`.
+
+- Preserves existing instructions and appends/replaces only the Primer Brand instructions.
+- Requires `projectDir` when several workspace packages declare Primer Brand.
+- Routes future agents to the MCP tools and version-matched local documentation.
diff --git a/apps/next-docs/content/components/Card/react.mdx b/apps/next-docs/content/components/Card/react.mdx
index 79e05f61c5..aa33ea186f 100644
--- a/apps/next-docs/content/components/Card/react.mdx
+++ b/apps/next-docs/content/components/Card/react.mdx
@@ -169,37 +169,45 @@ When an image should stretch to fill the card width, set `padding="none"` on `Ca
```jsx live
- {[
- {
- heading: 'Build with GitHub Copilot',
- description: 'Write, review, and understand code with AI-powered assistance.',
- image: '/images/placeholder-1.png',
- },
- {
- heading: 'Automate your workflow',
- description: 'Keep projects moving with tools that fit the way your team works.',
- image: '/images/placeholder-2.png',
- },
- {
- heading: 'Ship secure software',
- description: 'Find and fix vulnerabilities throughout the development lifecycle.',
- image: '/images/placeholder-3.png',
- },
- ].map(card => (
-
-
- {card.heading}
- {card.description}
-
-
-
- ))}
+
+
+ Build with GitHub Copilot
+ Write, review, and understand code with AI-powered assistance.
+
+
+
+
+
+ Automate your workflow
+ Keep projects moving with tools that fit the way your team works.
+
+
+
+
+
+ Ship secure software
+ Find and fix vulnerabilities throughout the development lifecycle.
+
+
+
```
diff --git a/apps/next-docs/content/introduction/mcp.mdx b/apps/next-docs/content/introduction/mcp.mdx
index d2dd28fde1..436104ba0d 100644
--- a/apps/next-docs/content/introduction/mcp.mdx
+++ b/apps/next-docs/content/introduction/mcp.mdx
@@ -17,7 +17,7 @@ The server exposes a set of tools that AI agents can call:
| Tool | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `primer_brand_setup` | Configures Primer Brand first-time. Includes `ThemeProvider`, Mona Sans and RSC-directive configuration among other things |
+| `primer_brand_setup` | Configures Primer Brand first-time and maintains a Primer Brand pointer beside the declaring `package.json`. |
| `primer_brand_page_design` | Page-design conventions (header/footer, hero media, gridline aesthetic, card grids, labels) and the current-brand reference templates to start from. |
| `primer_brand_component` | Get a component API information like props, allowed values and named sub-components. |
| `primer_brand_examples` | Reference examples taken from our Storybook. |
@@ -42,6 +42,8 @@ Add the server to your workspace's `.vscode/mcp.json` (or your user `mcp.json`):
}
```
+When an agent calls `primer_brand_setup`, the tool updates `AGENTS.md` only in a package whose `package.json` declares `@primer/react-brand`. Existing instructions are preserved. If several workspace packages declare the dependency, call setup with `projectDir` set to the intended workspace-relative package folder. The pointer routes future agents to the MCP tools and version-matched docs bundled with the installed package.
+
## Set up in other clients
For GitHub Copilot CLI and other `stdio`-enabled MCP clients, run the server directly:
diff --git a/packages/mcp/README.md b/packages/mcp/README.md
index b53ce49746..8e8652a535 100644
--- a/packages/mcp/README.md
+++ b/packages/mcp/README.md
@@ -8,7 +8,7 @@ It is a version-aware, `stdio` (local) server which reads the docs and metadata
| Tool | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `primer_brand_setup` | Configures Primer Brand first-time. Includes `ThemeProvider`, Mona Sans and RSC-directive configuration among other things |
+| `primer_brand_setup` | Configures Primer Brand first-time and maintains a Primer Brand pointer beside the declaring `package.json`. |
| `primer_brand_page_design` | Page-design conventions (header/footer, hero media, gridline aesthetic, card grids, labels) and the current-brand reference templates to start from. |
| `primer_brand_component` | Get a component API information like props, allowed values and named sub-components. |
| `primer_brand_examples` | Reference examples taken from our Storybook. |
@@ -35,6 +35,8 @@ Requires Node.js >= 24.
}
```
+When an agent calls `primer_brand_setup`, the tool updates `AGENTS.md` only in a package whose `package.json` declares `@primer/react-brand`. Existing instructions are preserved. If several workspace packages declare the dependency, call setup with `projectDir` set to the intended workspace-relative package folder. The pointer routes future agents to the MCP tools and version-matched docs bundled with the installed package.
+
### Copilot CLI / other stdio clients
Run the server with `npx @primer/brand-mcp@latest`.
diff --git a/packages/mcp/content/page-design.md b/packages/mcp/content/page-design.md
index 8c1448e700..e32255281c 100644
--- a/packages/mcp/content/page-design.md
+++ b/packages/mcp/content/page-design.md
@@ -19,7 +19,9 @@ Pull adaptable component snippets with `primer_brand_examples`.
`@primer/react-brand` spans multiple generations of GitHub's design language, and many components expose that choice as a variant or prop. **Gridline** is the current brand direction. It's a connected, ruled-line aesthetic, as seen throughout the Flexsuite templates. Bias toward it.
- **Do** — when a component offers a gridline-family option, choose it over the older or default look. "Expressive" gridline options are a more pronounced sub-variant of the same aesthetic, not a competing style; reach for them where a section wants extra emphasis.
+- **Do** — use a component's built-in `gridline` variant, gridline-family variant, or `hasGridLines` prop when available.
- **Don't** — settle for a component's plainest or legacy variant, or borrow section layouts from an older brand generation, when a gridline equivalent exists.
+- **Don't** — recreate built-in gridlines with custom `Box` wrappers, border props, or CSS.
The gridline look is crucial and literal, not just a set of variants. Gridlines are thin ruled lines that frame the content column and separate sections (see "Contain content within the grid" section below).
@@ -36,17 +38,22 @@ Every full page should be framed top and bottom, unless the user has requested a
### Contain content within the grid
-Body content must sit in one centered, max-width grid framed by the brand's gridlines — it should rarely stretch edge-to-edge. Treat gridlines as one continuous page structure: every line endpoint must reach the viewport or shared page-grid boundary, or intersect another gridline. Never let a border stop in empty space.
+Body content must sit in one centered, max-width grid framed by the brand's gridlines — it should rarely stretch edge-to-edge. Treat gridlines as one continuous page structure. A gridline group must not look like an isolated four-sided box floating inside the page gutters.
Examples of this are in the Flexsuite recipes.
- **Do** — keep heroes, `River`s, `ComparisonTable` / `PricingOptions`, connected Card or Pillar groups, forms, `CTABanner`s, and prose on the same `Grid` / `Section` column. Draw thin side rules on the column and full-bleed horizontal rules between major sections.
-- **Do** — for connected panels, put block rules on the outer frame, outer inline rules on the centered content container, and internal dividers on zero-gap cell wrappers. Preserve these intersections when max-width or column count changes at responsive breakpoints.
-- **Do** — give a panel frame's block-start and block-end rules the same extent. They may differ only when one rule is also a page-wide section boundary that intersects the frame's inline rules.
+- **Do** — for connected `Card` and `Pillar` groups, put block-start and block-end rules on a page-width outer frame outside the centered, max-width `Grid` / `Section`. Put outer inline rules on the centered content container and internal dividers on zero-gap cell wrappers.
+- **Do** — for these custom group frames, make every horizontal rule reach both viewport edges. It may instead end at vertical page-grid rules only when those rules continue beyond the group into adjacent sections.
+- **Do** — preserve rule intersections when the max-width or column count changes at responsive breakpoints.
+- **Don't** — put all four outer borders for a `Card` or `Pillar` group on a centered, max-width container. This creates a disconnected box with empty margins around it.
+- **Don't** — assume `width: 100%` makes a custom group frame full bleed when it is inside a constrained parent. The outer frame must sit outside that parent or use the page's established full-bleed breakout.
- **Don't** — give sections different widths, add gaps between borders that should connect, or put borders on inset wrappers where their endpoints stop in empty space.
- **Don't** — stretch content edge-to-edge or let a section bleed full-width unless it is a deliberate background band behind the shared grid.
+Retrieve the canonical grouped `Card` or `Pillar` structure with `primer_brand_examples` and carry its companion CSS with the JSX. Do not reproduce that structure from this prose.
+
- **Mobile** — use a modest, consistent side inset and make sure nothing overflows the viewport.
Resolve gutter, inset, and gridline (border) values with `primer_brand_tokens`; don't hardcode hex or pixel values.
@@ -63,7 +70,9 @@ Generous spacing is what lets a layout breathe; the Flexsuite recipes are a good
A page has one clear headline and a calm step-down from there.
+- **Do** — use the default `size` of component-owned text such as `Card.Heading`, `Hero.Heading`, and `FAQ.Heading`. Set `size` on standalone `Heading` and `Text`.
- **Do** — use a single hero heading, make secondary section headings a clear step smaller, and keep body copy regular weight. Left-align long-form copy; reserve centering for short hero or section intros.
+- **Don't** — pass `size` to a component-owned heading, description, label, or other text unless the brief requires an override. Changing `as` for semantic heading order does not require changing `size`.
- **Don't** — size secondary headings close to the hero, set body text in heavy weights, or center long paragraphs.
Resolve exact sizes and weights with `primer_brand_tokens`.
@@ -126,14 +135,15 @@ Resolve exact sizes and weights with `primer_brand_tokens`.
**Do**
-- Default to placing `Card`, `Pillar`, `Box`, or custom items in a square frame using ``.
+- Default to placing connected `Card` or `Pillar` groups in a page-width outer frame around a centered zero-gap `Grid`. Retrieve the current structure and companion CSS with `primer_brand_examples`.
- When Card images need to stretch to fill the Card width, set `padding="none"` on every `Card.Image` and `hasBorder` on every `Card` to provide visual containment. Place these full-bleed Cards in a regular `Grid` with a clear gap between them; this is a separate visual treatment from gridline Cards. Keep image position and aspect ratio consistent across the group, and retrieve Card examples with `primer_brand_examples` for reference.
- Draw shared dividers on custom frame/cell wrappers and use `border-radius: 0` there.
**Don't**
- Put native-bordered, full-bleed Cards inside a shared gridline frame, or mix gridline and native-border treatments within one group.
-- Render other repeated panels as separate rounded cards, double their shared borders, or override `Card` / `Pillar` internals.
+- Apply the custom Card/Pillar frame pattern to components that already provide a gridline variant or prop.
+- Box a repeated panel group inside the centered content width, render panels as separate rounded cards, double their shared borders, or override `Card` / `Pillar` internals.
- `Pillar` has no grid variant; don't invent one.
### CTABanner
diff --git a/packages/mcp/scripts/generate-catalog.mjs b/packages/mcp/scripts/generate-catalog.mjs
index 6d88a7ad14..3f88a38ad9 100644
--- a/packages/mcp/scripts/generate-catalog.mjs
+++ b/packages/mcp/scripts/generate-catalog.mjs
@@ -264,7 +264,7 @@ function bestStoryExample(directory, name, byName) {
const declarationBody = source.slice(exportMatches[index].index, exportMatches[index + 1]?.index ?? source.length)
for (const match of declarationBody.matchAll(/(?:=>|\breturn)\s*\(/g)) {
const capturedJsx = captureBalanced(declarationBody, match.index + match[0].length - 1, '(', ')')
- if (capturedJsx && capturedJsx.includes('<')) snippets.push(capturedJsx)
+ if (capturedJsx && capturedJsx.includes('<')) snippets.push({capturedJsx, exportName: exportMatches[index][1]})
}
}
return snippets
@@ -299,9 +299,11 @@ function bestStoryExample(directory, name, byName) {
}
// Must render the component; sub-composition helps, plumbing/length hurt. -1 rejects.
- const scoreSnippet = (code, fromExamplesFile) => {
+ const scoreSnippet = (code, fromExamplesFile, exportName) => {
if (!new RegExp(`<${name}(?:\\b|\\.)`).test(code)) return -1
let total = fromExamplesFile ? 3 : 0
+ // An exact `Gridline` story is the component's focused canonical composition, not a mixed showcase.
+ if (/^gridline$/i.test(exportName)) total += 10
total += (code.match(new RegExp(`<${name}\\.`, 'g')) || []).length
for (const pattern of EXAMPLE_NOISE) total -= (code.match(pattern) || []).length
if (code.length < 40) total -= 5
@@ -317,10 +319,10 @@ function bestStoryExample(directory, name, byName) {
const source = readFileOrNull(file)
if (!source) continue
const fromExamplesFile = /\.examples?\.stories\.tsx$/i.test(file)
- for (const capturedJsx of jsxSnippets(source)) {
+ for (const {capturedJsx, exportName} of jsxSnippets(source)) {
const code = dedentJsx(capturedJsx)
if (code.length > 4000 || exampleContradictsCatalog(code, byName)) continue
- const relevanceScore = scoreSnippet(code, fromExamplesFile)
+ const relevanceScore = scoreSnippet(code, fromExamplesFile, exportName)
if (relevanceScore >= 0 && (!bestExample || relevanceScore > bestExample.score))
bestExample = {code, styles: referencedStyles(file, source, code), score: relevanceScore}
}
diff --git a/packages/mcp/scripts/smoke.mjs b/packages/mcp/scripts/smoke.mjs
index 040e46e4ba..db803bb462 100644
--- a/packages/mcp/scripts/smoke.mjs
+++ b/packages/mcp/scripts/smoke.mjs
@@ -6,12 +6,27 @@
*/
import {Client} from '@modelcontextprotocol/sdk/client/index.js'
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'
+import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {dirname, join} from 'node:path'
+import {fileURLToPath} from 'node:url'
function assert(condition, message) {
if (!condition) throw new Error(message)
}
-const transport = new StdioClientTransport({command: 'node', args: ['dist/index.js']})
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const smokeProject = mkdtempSync(join(tmpdir(), 'primer-brand-mcp-smoke-'))
+writeFileSync(
+ join(smokeProject, 'package.json'),
+ JSON.stringify({dependencies: {vite: '^7.0.0', '@primer/react-brand': '^0.73.0'}}),
+)
+
+const transport = new StdioClientTransport({
+ command: 'node',
+ args: [join(packageDir, 'dist', 'index.js')],
+ cwd: smokeProject,
+})
const client = new Client({name: 'primer-brand-mcp-smoke', version: '0.0.0'})
try {
@@ -32,7 +47,15 @@ try {
return text
}
- await call('primer_brand_setup', {framework: 'next-app'})
+ const setup = await call('primer_brand_setup', {framework: 'next-app'})
+ assert(
+ setup.includes('Created `AGENTS.md` beside the selected package.json'),
+ 'primer_brand_setup did not report creating AGENTS.md',
+ )
+ assert(
+ readFileSync(join(smokeProject, 'AGENTS.md'), 'utf8').includes(''),
+ 'primer_brand_setup did not create the managed AGENTS.md block',
+ )
await call('primer_brand_page_design', {})
await call('primer_brand_component', {name: 'Hero'})
await call('primer_brand_examples', {goal: 'education landing page'})
@@ -60,4 +83,5 @@ try {
} catch {
// ignore errors while closing the client
}
+ rmSync(smokeProject, {recursive: true, force: true})
}
diff --git a/packages/mcp/src/brand/detect-asset-generator.test.ts b/packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.test.ts
similarity index 100%
rename from packages/mcp/src/brand/detect-asset-generator.test.ts
rename to packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.test.ts
diff --git a/packages/mcp/src/brand/detect-asset-generator.ts b/packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.ts
similarity index 100%
rename from packages/mcp/src/brand/detect-asset-generator.ts
rename to packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.ts
diff --git a/packages/mcp/src/brand/detect-framework.test.ts b/packages/mcp/src/brand/detect-framework/detect-framework.test.ts
similarity index 100%
rename from packages/mcp/src/brand/detect-framework.test.ts
rename to packages/mcp/src/brand/detect-framework/detect-framework.test.ts
diff --git a/packages/mcp/src/brand/detect-framework.ts b/packages/mcp/src/brand/detect-framework/detect-framework.ts
similarity index 100%
rename from packages/mcp/src/brand/detect-framework.ts
rename to packages/mcp/src/brand/detect-framework/detect-framework.ts
diff --git a/packages/mcp/src/brand/docs-source.ts b/packages/mcp/src/brand/docs-source/docs-source.ts
similarity index 96%
rename from packages/mcp/src/brand/docs-source.ts
rename to packages/mcp/src/brand/docs-source/docs-source.ts
index edf9f68091..3dd71de009 100644
--- a/packages/mcp/src/brand/docs-source.ts
+++ b/packages/mcp/src/brand/docs-source/docs-source.ts
@@ -1,8 +1,8 @@
import {existsSync, readFileSync, realpathSync} from 'node:fs'
import {dirname, join, normalize, resolve, sep} from 'node:path'
-import type {Logger} from '../logger.js'
-import type {BrandInstall} from './resolve-install.js'
+import type {Logger} from '../../logger.js'
+import type {BrandInstall} from '../resolve-install/resolve-install.js'
/**
* One entry in the documentation table of contents (parsed from `llms.txt`).
diff --git a/packages/mcp/src/brand/resolve-assets.test.ts b/packages/mcp/src/brand/resolve-assets/resolve-assets.test.ts
similarity index 100%
rename from packages/mcp/src/brand/resolve-assets.test.ts
rename to packages/mcp/src/brand/resolve-assets/resolve-assets.test.ts
diff --git a/packages/mcp/src/brand/resolve-assets.ts b/packages/mcp/src/brand/resolve-assets/resolve-assets.ts
similarity index 98%
rename from packages/mcp/src/brand/resolve-assets.ts
rename to packages/mcp/src/brand/resolve-assets/resolve-assets.ts
index 9c4cd21fae..dd470782c8 100644
--- a/packages/mcp/src/brand/resolve-assets.ts
+++ b/packages/mcp/src/brand/resolve-assets/resolve-assets.ts
@@ -1,7 +1,7 @@
import {existsSync, readdirSync, readFileSync} from 'node:fs'
import {dirname, join} from 'node:path'
-import type {CatalogAsset} from '../catalog/types.js'
+import type {CatalogAsset} from '../../catalog/types.js'
type AssetPackage = {
module: string
diff --git a/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts
new file mode 100644
index 0000000000..76afc825cf
--- /dev/null
+++ b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts
@@ -0,0 +1,87 @@
+import {mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+
+import {resolveBrandProject} from './resolve-brand-project.js'
+
+describe('resolveBrandProject', () => {
+ const directories: string[] = []
+ const temporaryWorkspace = () => {
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), 'primer-brand-project-')))
+ directories.push(directory)
+ return directory
+ }
+ const writePackage = (directory: string, dependencies: Record = {}) => {
+ mkdirSync(directory, {recursive: true})
+ writeFileSync(join(directory, 'package.json'), JSON.stringify({dependencies}))
+ }
+
+ afterEach(() => {
+ for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true})
+ })
+
+ it('finds the only nested package declaring Primer Brand', () => {
+ const workspace = temporaryWorkspace()
+ writePackage(workspace, {react: '^19.0.0'})
+ writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'})
+ writePackage(join(workspace, 'apps', 'api'), {express: '^5.0.0'})
+
+ expect(resolveBrandProject(workspace)).toEqual({
+ projectDir: join(workspace, 'apps', 'site'),
+ candidates: [join(workspace, 'apps', 'site')],
+ })
+ })
+
+ it('reports ambiguity when several packages declare Primer Brand', () => {
+ const workspace = temporaryWorkspace()
+ writePackage(join(workspace, 'apps', 'docs'), {'@primer/react-brand': '^0.73.0'})
+ writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'})
+
+ expect(resolveBrandProject(workspace)).toMatchObject({
+ projectDir: null,
+ reason: 'ambiguous-brand-project',
+ candidates: [join(workspace, 'apps', 'docs'), join(workspace, 'apps', 'site')],
+ })
+ })
+
+ it('uses an explicitly requested declaring package', () => {
+ const workspace = temporaryWorkspace()
+ writePackage(join(workspace, 'apps', 'docs'), {'@primer/react-brand': '^0.73.0'})
+ writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'})
+
+ expect(resolveBrandProject(workspace, 'apps/site')).toEqual({
+ projectDir: join(workspace, 'apps', 'site'),
+ candidates: [join(workspace, 'apps', 'site')],
+ })
+ })
+
+ it('rejects requested folders outside the workspace or without the dependency', () => {
+ const workspace = temporaryWorkspace()
+ writePackage(join(workspace, 'apps', 'site'), {react: '^19.0.0'})
+
+ expect(resolveBrandProject(workspace, 'apps/site').reason).toBe('invalid-brand-project')
+ expect(resolveBrandProject(workspace, '..').reason).toBe('invalid-brand-project')
+ expect(resolveBrandProject(workspace, join(workspace, 'apps', 'site')).reason).toBe('invalid-brand-project')
+ })
+
+ it('rejects a requested package symlink that resolves outside the workspace', () => {
+ const workspace = temporaryWorkspace()
+ const externalPackage = temporaryWorkspace()
+ writePackage(externalPackage, {'@primer/react-brand': '^0.73.0'})
+ mkdirSync(join(workspace, 'apps'), {recursive: true})
+ symlinkSync(externalPackage, join(workspace, 'apps', 'site'), 'dir')
+
+ expect(resolveBrandProject(workspace, 'apps/site')).toMatchObject({
+ projectDir: null,
+ reason: 'invalid-brand-project',
+ })
+ })
+
+ it('reports when no declaring package or workspace root is available', () => {
+ const workspace = temporaryWorkspace()
+ writePackage(workspace, {react: '^19.0.0'})
+
+ expect(resolveBrandProject(workspace).reason).toBe('brand-package-not-found')
+ expect(resolveBrandProject(null).reason).toBe('no-workspace-root')
+ })
+})
diff --git a/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts
new file mode 100644
index 0000000000..4659e8b0f5
--- /dev/null
+++ b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts
@@ -0,0 +1,88 @@
+import {readdirSync, readFileSync, realpathSync} from 'node:fs'
+import {isAbsolute, join, relative, resolve, sep} from 'node:path'
+
+export type BrandProjectResolution = {
+ projectDir: string | null
+ candidates: string[]
+ reason?: 'no-workspace-root' | 'brand-package-not-found' | 'ambiguous-brand-project' | 'invalid-brand-project'
+}
+
+/** Resolve the package folder whose package.json directly declares @primer/react-brand. */
+export function resolveBrandProject(workspaceDir: string | null, requestedProjectDir?: string): BrandProjectResolution {
+ const dependencyName = '@primer/react-brand'
+ const ignoredDirectories = new Set([
+ '.git',
+ '.next',
+ 'build',
+ 'coverage',
+ 'dist',
+ 'node_modules',
+ 'out',
+ 'storybook-static',
+ ])
+
+ const declaresBrand = (directory: string): boolean => {
+ try {
+ const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as Record<
+ string,
+ Record | undefined
+ >
+ return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some(group =>
+ Boolean(manifest[group]?.[dependencyName]),
+ )
+ } catch {
+ return false
+ }
+ }
+
+ if (!workspaceDir) return {projectDir: null, candidates: [], reason: 'no-workspace-root'}
+
+ let canonicalWorkspaceDir: string
+ try {
+ canonicalWorkspaceDir = realpathSync(workspaceDir)
+ } catch {
+ return {projectDir: null, candidates: [], reason: 'no-workspace-root'}
+ }
+
+ const isWithinWorkspace = (candidate: string): boolean => {
+ const relativeCandidate = relative(canonicalWorkspaceDir, candidate)
+ return relativeCandidate !== '..' && !relativeCandidate.startsWith(`..${sep}`) && !isAbsolute(relativeCandidate)
+ }
+
+ if (requestedProjectDir) {
+ if (isAbsolute(requestedProjectDir)) {
+ return {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
+ }
+ let canonicalCandidate: string
+ try {
+ canonicalCandidate = realpathSync(resolve(canonicalWorkspaceDir, requestedProjectDir))
+ } catch {
+ return {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
+ }
+ return isWithinWorkspace(canonicalCandidate) && declaresBrand(canonicalCandidate)
+ ? {projectDir: canonicalCandidate, candidates: [canonicalCandidate]}
+ : {projectDir: null, candidates: [], reason: 'invalid-brand-project'}
+ }
+
+ const candidates: string[] = []
+ const visit = (directory: string): void => {
+ if (declaresBrand(directory)) candidates.push(directory)
+
+ let entries
+ try {
+ entries = readdirSync(directory, {withFileTypes: true})
+ } catch {
+ return
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory() || ignoredDirectories.has(entry.name)) continue
+ visit(join(directory, entry.name))
+ }
+ }
+ visit(canonicalWorkspaceDir)
+ candidates.sort()
+
+ if (candidates.length === 1) return {projectDir: candidates[0] ?? null, candidates}
+ if (candidates.length > 1) return {projectDir: null, candidates, reason: 'ambiguous-brand-project'}
+ return {projectDir: null, candidates, reason: 'brand-package-not-found'}
+}
diff --git a/packages/mcp/src/brand/resolve-install.ts b/packages/mcp/src/brand/resolve-install/resolve-install.ts
similarity index 100%
rename from packages/mcp/src/brand/resolve-install.ts
rename to packages/mcp/src/brand/resolve-install/resolve-install.ts
diff --git a/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts b/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts
new file mode 100644
index 0000000000..16c5a77495
--- /dev/null
+++ b/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts
@@ -0,0 +1,104 @@
+import {existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+
+import {updateAgentsMd} from './update-agents-md.js'
+
+describe('updateAgentsMd', () => {
+ const directories: string[] = []
+ const temporaryProject = () => {
+ const directory = mkdtempSync(join(tmpdir(), 'primer-brand-agents-'))
+ directories.push(directory)
+ return directory
+ }
+
+ afterEach(() => {
+ for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true})
+ })
+
+ it('creates AGENTS.md with a lightweight MCP and local-docs pointer', () => {
+ const projectDir = temporaryProject()
+ expect(updateAgentsMd(projectDir).action).toBe('created')
+
+ const content = readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')
+ expect(content).toContain('')
+ expect(content).toContain('`primer_brand_docs`')
+ expect(content).toContain('version-matched documentation bundled with the installed package')
+ expect(content).toContain('Start page work with `primer_brand_page_design` and `primer_brand_examples`')
+ expect(content).not.toContain('`primer_brand_setup`')
+ expect(content).not.toContain('skill')
+ })
+
+ it('preserves existing content and appends the managed block', () => {
+ const projectDir = temporaryProject()
+ writeFileSync(join(projectDir, 'AGENTS.md'), '# Existing instructions\n\nKeep this.\n')
+
+ expect(updateAgentsMd(projectDir).action).toBe('updated')
+ const content = readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')
+ expect(content.startsWith('# Existing instructions\n\nKeep this.\n')).toBe(true)
+ expect(content.match(/BEGIN:primer-brand-mcp/g)).toHaveLength(1)
+ })
+
+ it('updates only its managed block and is idempotent', () => {
+ const projectDir = temporaryProject()
+ const file = join(projectDir, 'AGENTS.md')
+ writeFileSync(
+ file,
+ '# Existing\n\n\nOld instructions\n\n\n# End\n',
+ )
+
+ expect(updateAgentsMd(projectDir).action).toBe('updated')
+ expect(updateAgentsMd(projectDir).action).toBe('unchanged')
+ const content = readFileSync(file, 'utf8')
+ expect(content).toContain('# Existing')
+ expect(content).toContain('# End')
+ expect(content).not.toContain('Old instructions')
+ expect(content.match(/BEGIN:primer-brand-mcp/g)).toHaveLength(1)
+ })
+
+ it('preserves CRLF line endings', () => {
+ const projectDir = temporaryProject()
+ const file = join(projectDir, 'AGENTS.md')
+ writeFileSync(file, '# Existing\r\n')
+
+ updateAgentsMd(projectDir)
+ const content = readFileSync(file, 'utf8')
+ expect(content).toContain('\r\n\r\n')
+ expect(content.replaceAll('\r\n', '')).not.toContain('\n')
+ })
+
+ it('skips malformed blocks and incorrectly cased files', () => {
+ const malformedDir = temporaryProject()
+ writeFileSync(join(malformedDir, 'AGENTS.md'), '\nDo not replace')
+ expect(updateAgentsMd(malformedDir)).toMatchObject({action: 'skipped', reason: 'malformed-managed-block'})
+
+ const wrongCaseDir = temporaryProject()
+ writeFileSync(join(wrongCaseDir, 'agents.md'), '# Existing')
+ expect(updateAgentsMd(wrongCaseDir)).toMatchObject({action: 'skipped', reason: 'incorrect-filename-case'})
+ })
+
+ it('does not read or write AGENTS.md through symbolic links', () => {
+ const projectDir = temporaryProject()
+ const externalDir = temporaryProject()
+ const externalFile = join(externalDir, 'instructions.md')
+ writeFileSync(externalFile, '# External instructions\n')
+ symlinkSync(externalFile, join(projectDir, 'AGENTS.md'))
+
+ expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'})
+ expect(readFileSync(externalFile, 'utf8')).toBe('# External instructions\n')
+ })
+
+ it('does not create an external file through a dangling AGENTS.md symlink', () => {
+ const projectDir = temporaryProject()
+ const externalDir = temporaryProject()
+ const externalFile = join(externalDir, 'future-instructions.md')
+ symlinkSync(externalFile, join(projectDir, 'AGENTS.md'))
+
+ expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'})
+ expect(existsSync(externalFile)).toBe(false)
+ })
+
+ it('skips when no project root is available', () => {
+ expect(updateAgentsMd(null)).toEqual({action: 'skipped', path: null, reason: 'no-project-root'})
+ })
+})
diff --git a/packages/mcp/src/brand/update-agents-md/update-agents-md.ts b/packages/mcp/src/brand/update-agents-md/update-agents-md.ts
new file mode 100644
index 0000000000..ff1dd6decd
--- /dev/null
+++ b/packages/mcp/src/brand/update-agents-md/update-agents-md.ts
@@ -0,0 +1,88 @@
+import {lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync} from 'node:fs'
+import {isAbsolute, join, relative, sep} from 'node:path'
+
+export type AgentsMdUpdateResult = {
+ action: 'created' | 'updated' | 'unchanged' | 'skipped'
+ path: string | null
+ reason?: 'no-project-root' | 'incorrect-filename-case' | 'malformed-managed-block' | 'unsafe-agents-path'
+}
+
+/** Create or update the managed Primer Brand instructions in a project's root AGENTS.md. */
+export function updateAgentsMd(projectDir: string | null): AgentsMdUpdateResult {
+ const filename = 'AGENTS.md'
+ const startMarker = ''
+ const endMarker = ''
+ const block = `${startMarker}
+## Primer Brand
+
+When building brand-led marketing or landing pages with \`@primer/react-brand\`, use the available \`primer_brand_*\` MCP tools.
+
+Prefer \`primer_brand_docs\` over fetching primer.style/brand directly. It reads version-matched documentation bundled with the installed package first and only falls back to the live site when necessary.
+
+Start page work with \`primer_brand_page_design\` and \`primer_brand_examples\`. Use \`primer_brand_component\` for exact APIs and \`primer_brand_review\` before finishing.
+${endMarker}`
+
+ if (!projectDir) return {action: 'skipped', path: null, reason: 'no-project-root'}
+
+ let canonicalProjectDir: string
+ try {
+ canonicalProjectDir = realpathSync(projectDir)
+ } catch {
+ return {action: 'skipped', path: null, reason: 'no-project-root'}
+ }
+
+ const agentMdCustomSetupPath = join(canonicalProjectDir, filename)
+ const caseInsensitiveMatch = readdirSync(canonicalProjectDir).find(
+ entry => entry.toLowerCase() === filename.toLowerCase(),
+ )
+ if (caseInsensitiveMatch && caseInsensitiveMatch !== filename) {
+ return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'incorrect-filename-case'}
+ }
+
+ const fileStats = lstatSync(agentMdCustomSetupPath, {throwIfNoEntry: false})
+ if (fileStats?.isSymbolicLink()) {
+ return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
+ }
+
+ if (!fileStats) {
+ writeFileSync(agentMdCustomSetupPath, `${block}\n`, 'utf8')
+ return {action: 'created', path: agentMdCustomSetupPath}
+ }
+
+ let canonicalAgentsPath: string
+ try {
+ canonicalAgentsPath = realpathSync(agentMdCustomSetupPath)
+ } catch {
+ return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
+ }
+ const relativeAgentsPath = relative(canonicalProjectDir, canonicalAgentsPath)
+ if (relativeAgentsPath === '..' || relativeAgentsPath.startsWith(`..${sep}`) || isAbsolute(relativeAgentsPath)) {
+ return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'}
+ }
+
+ const existing = readFileSync(agentMdCustomSetupPath, 'utf8')
+ const eol = existing.includes('\r\n') ? '\r\n' : '\n'
+ const normalizedBlock = block.replaceAll('\n', eol)
+ const startIndex = existing.indexOf(startMarker)
+ const endIndex = existing.indexOf(endMarker)
+ const hasStartMarker = startIndex !== -1
+ const hasEndMarker = endIndex !== -1
+ const hasDuplicateMarkers =
+ startIndex !== existing.lastIndexOf(startMarker) || endIndex !== existing.lastIndexOf(endMarker)
+
+ if (hasStartMarker !== hasEndMarker || hasDuplicateMarkers || (hasStartMarker && endIndex < startIndex)) {
+ return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'malformed-managed-block'}
+ }
+
+ let updated: string
+ if (hasStartMarker) {
+ updated = `${existing.slice(0, startIndex)}${normalizedBlock}${existing.slice(endIndex + endMarker.length)}`
+ } else {
+ const separator = existing.length === 0 || /\r?\n$/.test(existing) ? eol : `${eol}${eol}`
+ updated = `${existing}${separator}${normalizedBlock}${eol}`
+ }
+
+ if (updated === existing) return {action: 'unchanged', path: agentMdCustomSetupPath}
+ writeFileSync(agentMdCustomSetupPath, updated, 'utf8')
+ return {action: 'updated', path: agentMdCustomSetupPath}
+}
diff --git a/packages/mcp/src/review/rules.test.ts b/packages/mcp/src/review/rules.test.ts
index df30bb1838..8ec85aac11 100644
--- a/packages/mcp/src/review/rules.test.ts
+++ b/packages/mcp/src/review/rules.test.ts
@@ -214,6 +214,30 @@ describe('primer_brand_review rules', () => {
expect(ruleIds(review('Big'))).not.toContain('heading-explicit-size')
})
+ it('flags size overrides on component-owned text', () => {
+ expect(ruleIds(review('Infrastructure'))).toContain(
+ 'component-text-default-size',
+ )
+ expect(ruleIds(review('Description'))).toContain(
+ 'component-text-default-size',
+ )
+ })
+
+ it('allows component-owned text defaults and standalone typography sizes', () => {
+ expect(ruleIds(review('Questions'))).not.toContain('component-text-default-size')
+ expect(ruleIds(review('Heading'))).not.toContain(
+ 'component-text-default-size',
+ )
+ expect(ruleIds(review('Description'))).not.toContain('component-text-default-size')
+ expect(ruleIds(review('Heading'))).not.toContain('component-text-default-size')
+ })
+
+ it('does not apply component-owned text guidance to application components', () => {
+ expect(ruleIds(review('Pro'))).not.toContain(
+ 'component-text-default-size',
+ )
+ })
+
it('reports which approved brand components were imported', () => {
const used = brandComponentsUsed("import {Hero, CTABanner} from '@primer/react-brand'", makeCatalog())
expect(used.map(component => component.name).sort()).toEqual(['CTABanner', 'Hero'])
@@ -264,6 +288,19 @@ describe('primer_brand_review over generated canonical examples', () => {
expect(example?.code).not.toContain('color="purple"')
})
+ it('uses the page-width gridline Statistic example with its companion styles', () => {
+ const statistic = catalog.components.find(component => component.name === 'Statistic')
+ const example = statistic?.examples[0]
+ expect(example?.code).toContain('')
+ expect(example?.code).toContain('columnGap="none"')
+ expect(example?.code).toContain('rowGap="none"')
+ expect(example?.code).toContain('enableGutters={false}')
+ expect(example?.styles).toContain('.gridFrame')
+ expect(example?.styles).toContain('border-block-start')
+ expect(example?.styles).toContain('.gridContent')
+ expect(example?.styles).toContain('margin-inline: auto')
+ })
+
it('produces no errors on any catalog example', () => {
expect(examples.length).toBeGreaterThan(0)
for (const example of examples) {
diff --git a/packages/mcp/src/review/rules.ts b/packages/mcp/src/review/rules.ts
index c27deb19e2..ecbb943072 100644
--- a/packages/mcp/src/review/rules.ts
+++ b/packages/mcp/src/review/rules.ts
@@ -438,6 +438,28 @@ const headingExplicitSize: Rule = {
},
}
+/** Compound components provide context-appropriate text sizes; explicit overrides should be exceptional. */
+const componentTextDefaultSize: Rule = {
+ id: 'component-text-default-size',
+ run(code, catalog) {
+ const knownSubcomponents = new Set(catalog.components.flatMap(component => component.subcomponents))
+ const findings: Finding[] = []
+ for (const match of code.matchAll(
+ /<([A-Z][A-Za-z0-9]*)\.(Heading|Subheading|Description|Label|Eyebrow|Text)\b[^>]*(?:^|\s)size\s*=/g,
+ )) {
+ const qualifiedName = `${match[1]}.${match[2]}`
+ if (!knownSubcomponents.has(qualifiedName)) continue
+ findings.push({
+ severity: 'warning',
+ rule: this.id,
+ message: `\`${qualifiedName}\` already has the right default \`size\` for its component. Remove \`size\` unless the brief requires an override.`,
+ evidence: evidence(match[0]),
+ })
+ }
+ return findings
+ },
+}
+
/** Credit for actually importing approved brand components — surfaced as guidance, not a failure. */
export function brandComponentsUsed(code: string, catalog: Catalog): CatalogComponent[] {
const imported = new Set()
@@ -467,4 +489,5 @@ export const allRules: Rule[] = [
hardcodedValues,
offBrandTells,
headingExplicitSize,
+ componentTextDefaultSize,
]
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index e1c809fa72..4c76a00e04 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -3,10 +3,11 @@ import {fileURLToPath} from 'node:url'
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
-import {detectAssetGenerator} from './brand/detect-asset-generator.js'
-import {createDocsSource} from './brand/docs-source.js'
-import {resolveInstalledAssets} from './brand/resolve-assets.js'
-import {looksLikeProductProject, resolveBrandInstall} from './brand/resolve-install.js'
+import {detectAssetGenerator} from './brand/detect-asset-generator/detect-asset-generator.js'
+import {detectFramework} from './brand/detect-framework/detect-framework.js'
+import {createDocsSource} from './brand/docs-source/docs-source.js'
+import {resolveInstalledAssets} from './brand/resolve-assets/resolve-assets.js'
+import {looksLikeProductProject, resolveBrandInstall} from './brand/resolve-install/resolve-install.js'
import {loadCatalog} from './catalog/load.js'
import {createLogger} from './logger.js'
import {registerTools} from './tools/register.js'
@@ -48,6 +49,7 @@ export function createServer(): McpServer {
const catalog = loadCatalog(logger)
const brand = resolveBrandInstall()
+ const framework = detectFramework()
if (brand.found) {
logger.info(`using @primer/react-brand@${brand.version ?? 'unknown'} from ${brand.packageDir}`)
} else if (looksLikeProductProject()) {
@@ -69,6 +71,16 @@ export function createServer(): McpServer {
}
const docs = createDocsSource(brand, logger)
- registerTools(server, {catalog, brand, docs, logger, assets, assetsOrigin, assetGenerator: detectAssetGenerator()})
+ registerTools(server, {
+ catalog,
+ brand,
+ workspaceDir: process.cwd(),
+ framework,
+ docs,
+ logger,
+ assets,
+ assetsOrigin,
+ assetGenerator: detectAssetGenerator(),
+ })
return server
}
diff --git a/packages/mcp/src/test-utils/catalog.ts b/packages/mcp/src/test-utils/catalog.ts
index 853402462a..71f919d451 100644
--- a/packages/mcp/src/test-utils/catalog.ts
+++ b/packages/mcp/src/test-utils/catalog.ts
@@ -1,6 +1,6 @@
import {createLogger} from '../logger.js'
-import type {BrandInstall} from '../brand/resolve-install.js'
-import type {DocsSource} from '../brand/docs-source.js'
+import type {BrandInstall} from '../brand/resolve-install/resolve-install.js'
+import type {DocsSource} from '../brand/docs-source/docs-source.js'
import type {Catalog} from '../catalog/types.js'
import type {ToolContext} from '../tools/types.js'
@@ -61,6 +61,13 @@ export function makeCatalog(overrides: Partial = {}): Catalog {
},
],
},
+ {
+ name: 'Card',
+ module: '@primer/react-brand',
+ subcomponents: ['Card.Heading', 'Card.Description'],
+ props: [],
+ examples: [],
+ },
{name: 'Pillar', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},
{name: 'SectionIntro', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},
{name: 'River', module: '@primer/react-brand', subcomponents: [], props: [], examples: []},
@@ -196,6 +203,8 @@ export function makeContext(overrides: Partial = {}): ToolContext {
return {
catalog,
brand: installedBrand,
+ workspaceDir: null,
+ framework: {id: 'unknown', label: 'a React project', rsc: false, projectDir: null},
docs: noopDocs,
logger: createLogger(false),
assets: catalog.assets,
diff --git a/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts b/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts
index 1a28289ac5..4e85d606b7 100644
--- a/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts
+++ b/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts
@@ -14,7 +14,9 @@ const inputSchema = z.object({
type Input = z.infer
-const description = `Find approved Primer Brand visuals as code imports: Octicons (@primer/octicons-react) and Octovisuals (@primer/octovisuals-react). Use these instead of emoji, clip art, or random icon sets - mismatched or emoji icons are off-brand.`
+const description = `Find approved Primer Brand visuals as code imports:
+Octicons (@primer/octicons-react) and Octovisuals (@primer/octovisuals-react).
+Use these instead of emoji, clip art, or random icon sets - mismatched or emoji icons are off-brand.`
export const primerBrandAssetTool: ToolModule = {
name: 'primer_brand_asset',
diff --git a/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts b/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts
index cf49485e69..4fb1d02aa9 100644
--- a/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts
+++ b/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts
@@ -14,7 +14,13 @@ const inputSchema = z.object({
type Input = z.infer
-const description = `Primer Brand (@primer/react-brand) component reference for GitHub marketing and landing pages. Omit \`name\` to list every approved component; pass a \`name\` for its import, sub-components, props with allowed values, and a canonical example. The example is a real story snippet, so it may include Storybook \`{...args}\` spreads or imported demo assets to adapt rather than copy verbatim. Use this to avoid invented components or props. This is Primer Brand, not @primer/react product UI.`
+const description = `Primer Brand (@primer/react-brand) component reference for GitHub marketing and landing pages.
+Omit \`name\` to list every approved component.
+Pass a \`name\` for its import, sub-components, props with allowed values, and a canonical example.
+The example is a real story snippet, so it may include Storybook \`{...args}\` spreads
+or imported demo assets to adapt rather than copy verbatim.
+Use this to avoid invented components or props.
+This is Primer Brand, not @primer/react product UI.`
function listComponents(ctx: ToolContext): ToolResult {
const {components} = ctx.catalog
diff --git a/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts b/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts
index ecebbfc2cd..8326c01c67 100644
--- a/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts
+++ b/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts
@@ -11,7 +11,10 @@ const inputSchema = z.object({
type Input = z.infer
-const description = `Search and read Primer Brand guidance — principles, accessibility, content, and getting started. Prefers the version-pinned docs bundled with the installed @primer/react-brand, falling back to the live site (https://primer.style/brand) for general guidance. This is brand concepts and how-to, not component APIs (use primer_brand_component for those).`
+const description = `Search and read Primer Brand guidance — principles, accessibility, content, and getting started.
+Prefers the version-pinned docs bundled with the installed @primer/react-brand,
+falling back to the live site (https://primer.style/brand) for general guidance.
+This is brand concepts and how-to, not component APIs (use primer_brand_component for those).`
async function readPath(path: string, ctx: ToolContext): Promise {
const doc = await ctx.docs.read(path)
diff --git a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts
index 7d0135dc63..dcfdf9fe9e 100644
--- a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts
+++ b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts
@@ -2,6 +2,15 @@ import {makeContext} from '../../test-utils/catalog.js'
import {primerBrandExamplesTool} from './primer-brand-examples.js'
describe('primer_brand_examples', () => {
+ it('describes when to call the tool without duplicating adaptation guidance', () => {
+ expect(primerBrandExamplesTool.description).toContain('ranked, tested Primer Brand examples')
+ expect(primerBrandExamplesTool.description).toContain('component or page goal')
+ expect(primerBrandExamplesTool.description).toContain('primer_brand_component')
+ expect(primerBrandExamplesTool.description).not.toContain('demo scaffolding')
+ expect(primerBrandExamplesTool.description).not.toContain('gridline')
+ expect(primerBrandExamplesTool.description).not.toContain('Card or Pillar')
+ })
+
it('falls back to the default foundational set when nothing matches the goal', async () => {
const result = await primerBrandExamplesTool.run({goal: 'zzzz nonexistent zzzz'}, makeContext())
expect(result.isError).toBeFalsy()
@@ -9,6 +18,9 @@ describe('primer_brand_examples', () => {
expect(result.text).toContain('Hero')
expect(result.text.toLowerCase()).toContain('adapt')
expect(result.text).toContain('.heroFrame')
+ expect(result.text).toContain('## How to use these examples')
+ expect(result.text).toContain('Use built-in gridline variants or props')
+ expect(result.text).toContain('grouped Card or Pillar example')
})
it('ranks examples by goal', async () => {
diff --git a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts
index c94707b41c..ab92425809 100644
--- a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts
+++ b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts
@@ -16,9 +16,9 @@ const inputSchema = z.object({
type Input = z.infer
-const description = `Get ranked, copy-and-adapt examples of correct Primer Brand usage for a goal. Page goals include a current-brand full-page recipe for overall composition, while independently ranked component examples provide goal-specific detail.
-Pass a target use-case like "pricing section", "category page", or "education landing page"; unmatched page types use the general overview recipe, while unmatched component goals use a foundational set.
-Examples are real source, so they may carry demo scaffolding (a \`content\` object, internal fixture imports, CSS-module class names, repo-relative imports, \`{...args}\` spreads). Preserve the composition and gridline geometry; adapt the content, assets, and imports. When companion CSS is included, carry its frame and cell rules with the JSX.`
+const description = `Get ranked, tested Primer Brand examples for a component or page goal.
+Use when you need a starting composition; page goals can include a current-brand full-page reference.
+Use \`primer_brand_component\` for exact component APIs.`
/** Foundational sections that anchor almost every GitHub landing page, in composition order. */
const DEFAULT_COMPONENTS = ['Hero', 'SectionIntro', 'River', 'Pillar', 'CTABanner']
@@ -42,6 +42,9 @@ export const primerBrandExamplesTool: ToolModule = {
inputShape: inputSchema.shape,
annotations: {readOnlyHint: true},
run(input, ctx): ToolResult {
+ const adaptationGuidance = `## How to use these examples
+These are real source and may include demo scaffolding such as content objects, fixture imports, CSS-module class names, repo-relative imports, or Storybook argument spreads. Adapt the copy, assets, and imports while preserving component composition and gridline geometry. Use built-in gridline variants or props when available. When a grouped Card or Pillar example includes companion CSS, carry its page-width frame and cell rules with the JSX.`
+
const goal = input.goal?.trim() || 'landing page'
const genericPageTerms = new Set([
'feature',
@@ -112,7 +115,7 @@ export const primerBrandExamplesTool: ToolModule = {
specificRecipes.length > 0 ? specificRecipes : useDefaultRecipe && defaultRecipe ? [defaultRecipe] : []
const topRecipe = matchedRecipes[0]
- const sections = [`# Examples for "${goal}"`]
+ const sections = [`# Examples for "${goal}"`, adaptationGuidance]
if (topRecipe) {
const otherRecipes = matchedRecipes.slice(1)
const also = otherRecipes.length
@@ -123,7 +126,7 @@ export const primerBrandExamplesTool: ToolModule = {
`## Full-page template — ${
useDefaultRecipe ? 'default FlexSuite overview recipe source' : 'goal-matched recipe source'
}: ${topRecipe.title}`,
- `This is the **actual current-brand recipe source** from \`@primer/react-brand\`, wired for our demo harness: a \`content\` object supplies the copy, imagery comes from internal fixtures, styling uses internal CSS-module class names, and imports are repo-relative. Ignore that scaffolding — mirror the page structure and gridline composition, then use the goal-specific component examples below for deeper context.${also}`,
+ `This is the **actual current-brand recipe source** from \`@primer/react-brand\`. Use it for overall page composition, then use the goal-specific component examples below for deeper context.${also}`,
`\`\`\`tsx\n${topRecipe.source}\n\`\`\``,
].join('\n\n'),
)
diff --git a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts
index 4c10d8058d..3c8759f417 100644
--- a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts
+++ b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts
@@ -2,6 +2,17 @@ import {makeContext} from '../../test-utils/catalog.js'
import {primerPageDesignTool} from './primer-brand-page-design.js'
describe('primer_brand_page_design', () => {
+ it('describes when to call the tool without duplicating its guidance', () => {
+ expect(primerPageDesignTool.description).toContain('Read before building')
+ expect(primerPageDesignTool.description).toContain('page-level composition guidance')
+ expect(primerPageDesignTool.description).toContain('current-brand reference templates')
+ expect(primerPageDesignTool.description).toContain('primer_brand_component')
+ expect(primerPageDesignTool.description).toContain('primer_brand_review')
+ expect(primerPageDesignTool.description).not.toContain('SubdomainNavBar')
+ expect(primerPageDesignTool.description).not.toContain('gridline')
+ expect(primerPageDesignTool.description).not.toContain('Card/Pillar')
+ })
+
it('returns the bundled page-design guidance', async () => {
const result = await primerPageDesignTool.run({}, makeContext())
expect(result.isError).toBeFalsy()
@@ -38,9 +49,17 @@ describe('primer_brand_page_design', () => {
expect(result.text).toContain('**Do**')
expect(result.text).toContain("**Don't**")
expect(result.text).toContain('### Repeated panels')
- expect(result.text).toContain('Never let a border stop in empty space')
- expect(result.text).toContain('block-start and block-end rules the same extent')
- expect(result.text).toContain('`Card`, `Pillar`, `Box`, or custom items')
+ expect(result.text).toContain('must not look like an isolated four-sided box')
+ expect(result.text).toContain("component's built-in `gridline` variant")
+ expect(result.text).toContain('recreate built-in gridlines with custom `Box` wrappers')
+ expect(result.text).toContain('for connected `Card` and `Pillar` groups')
+ expect(result.text).toContain('make every horizontal rule reach both viewport edges')
+ expect(result.text).toContain('assume `width: 100%` makes a custom group frame full bleed')
+ expect(result.text).toContain('Retrieve the canonical grouped `Card` or `Pillar` structure')
+ expect(result.text).not.toContain('')
+ expect(result.text).not.toContain('.gridFrame {')
+ expect(result.text).toContain('connected `Card` or `Pillar` groups')
+ expect(result.text).toContain('components that already provide a gridline variant or prop')
expect(result.text).toContain('border-radius: 0')
expect(result.text).toContain('')
expect(result.text).toContain('multiple meaningful categories')
@@ -56,6 +75,9 @@ describe('primer_brand_page_design', () => {
expect(result.text).toContain('token-backed maximum height')
expect(result.text).toContain('place custom media after `Hero`')
expect(result.text).toContain('use `trailingComponent`')
+ expect(result.text).toContain('use the default `size` of component-owned text')
+ expect(result.text).toContain('Set `size` on standalone `Heading` and `Text`')
+ expect(result.text).toContain('Changing `as` for semantic heading order does not require changing `size`')
expect(result.text).toContain('catches some code-level mistakes automatically')
expect(result.text).not.toContain('152px')
})
diff --git a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts
index 4be2a93fa8..ceabd6efc0 100644
--- a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts
+++ b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts
@@ -10,7 +10,10 @@ const inputSchema = z.object({})
type Input = z.infer
-const description = `Page-design patterns to read before building a page: how to structure and style a full marketing/landing page and use components together correctly (frame with \`SubdomainNavBar\` + \`MinimalFooter\`, give heroes media, group cards inside connected gridlines, keep labels auto-width, bias to the gridline aesthetic) plus the current-brand reference templates to start from. Learn individual component APIs with \`primer_brand_docs\` / \`primer_brand_component\` — this is the page-design layer above them. These conventions \`primer_brand_review\` does not enforce.`
+const description = `Read before building a Primer Brand marketing or landing page.
+Returns page-level composition guidance and current-brand reference templates for combining components.
+Use \`primer_brand_docs\` or \`primer_brand_component\` for individual component APIs.
+These visual conventions are not fully enforced by \`primer_brand_review\`.`
const guidancePath = fileURLToPath(new URL('../../../content/page-design.md', import.meta.url))
diff --git a/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts b/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts
index ae1143e0d0..c35327d22a 100644
--- a/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts
+++ b/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts
@@ -16,12 +16,14 @@ const inputSchema = z.object({
type Input = z.infer
-const description = `The final on-brand gate. Before you finish, paste your COMPLETE output in one call — the JSX/TSX AND
-every stylesheet (App.css, *.module.css, styled blocks) together — because hardcoded sizes/colors and raw HTML most often
-hide in CSS. Flags non-compliant components or sub-components, invalid prop values, headings left at their oversized default
-(a \`Heading\` with no explicit \`size\`), raw HTML where a brand component exists, hardcoded colors/sizes that should be tokens,
-and off-brand visual tells (purple gradients, pill buttons, glassmorphism, placeholder copy). Run it on everything you wrote and
-fix what it reports rather than guessing.`
+const description = `The final on-brand gate.
+Before you finish, paste your COMPLETE output in one call — the JSX/TSX AND every stylesheet
+(App.css, *.module.css, styled blocks) together — because hardcoded sizes/colors and raw HTML most often hide in CSS.
+Flags non-compliant components or sub-components, invalid prop values,
+headings left at their oversized default (a \`Heading\` with no explicit \`size\`),
+raw HTML where a brand component exists, hardcoded colors/sizes that should be tokens,
+and off-brand visual tells (purple gradients, pill buttons, glassmorphism, placeholder copy).
+Run it on everything you wrote and fix what it reports rather than guessing.`
export const primerBrandReviewTool: ToolModule = {
name: 'primer_brand_review',
diff --git a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts
index 76130c46d9..79cfac2b9c 100644
--- a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts
+++ b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts
@@ -1,7 +1,17 @@
+import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
+import {tmpdir} from 'node:os'
+import {join} from 'node:path'
+
import {makeContext} from '../../test-utils/catalog.js'
import {primerBrandSetupTool} from './primer-brand-setup.js'
describe('primer_brand_setup', () => {
+ it('discloses its AGENTS.md side effect without managed-block implementation detail', () => {
+ expect(primerBrandSetupTool.description).toContain('package that declares `@primer/react-brand`')
+ expect(primerBrandSetupTool.description).toContain('Primer Brand tools and local docs')
+ expect(primerBrandSetupTool.description).not.toContain('managed')
+ })
+
it('uses the ESM import path + fonts for Vite, with no use-client boundary or lib css import', async () => {
const result = await primerBrandSetupTool.run({framework: 'vite'}, makeContext())
expect(result.text).toContain("from '@primer/react-brand/esm'")
@@ -40,4 +50,70 @@ describe('primer_brand_setup', () => {
expect(result.text).toContain('Wrap the very root')
expect(result.text).toContain('primer_brand_review')
})
+
+ it('creates managed agent instructions and reports subsequent calls as unchanged', async () => {
+ const projectDir = mkdtempSync(join(tmpdir(), 'primer-brand-setup-'))
+ writeFileSync(
+ join(projectDir, 'package.json'),
+ JSON.stringify({dependencies: {vite: '^7.0.0', '@primer/react-brand': '^0.73.0'}}),
+ )
+ const context = makeContext({
+ workspaceDir: projectDir,
+ framework: {id: 'vite', label: 'Vite + React', rsc: false, projectDir},
+ })
+
+ try {
+ const created = await primerBrandSetupTool.run({framework: 'auto'}, context)
+ expect(created.text).toContain('## 7. Add/update AGENTS.md pointer')
+ expect(created.text).toContain('Created `AGENTS.md` beside the selected package.json')
+ expect(created.text).toContain('src/main.tsx')
+ expect(readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')).toContain('`primer_brand_docs`')
+
+ const unchanged = await primerBrandSetupTool.run({framework: 'auto'}, context)
+ expect(unchanged.text).toContain('already has the current managed Primer Brand instructions')
+ } finally {
+ rmSync(projectDir, {recursive: true, force: true})
+ }
+ })
+
+ it('requires projectDir when several workspace packages declare Primer Brand', async () => {
+ const workspaceDir = mkdtempSync(join(tmpdir(), 'primer-brand-monorepo-'))
+ const docsDir = join(workspaceDir, 'apps', 'docs')
+ const siteDir = join(workspaceDir, 'apps', 'site')
+ for (const projectDir of [docsDir, siteDir]) {
+ mkdirSync(projectDir, {recursive: true})
+ writeFileSync(
+ join(projectDir, 'package.json'),
+ JSON.stringify({dependencies: {'@primer/react-brand': '^0.73.0'}}),
+ )
+ }
+ const context = makeContext({
+ workspaceDir,
+ framework: {id: 'unknown', label: 'a React project', rsc: false, projectDir: workspaceDir},
+ })
+
+ try {
+ const ambiguous = await primerBrandSetupTool.run({framework: 'auto'}, context)
+ expect(ambiguous.text).toContain('multiple package.json files declare `@primer/react-brand`')
+ expect(ambiguous.text).toContain('`projectDir`')
+ expect(existsSync(join(docsDir, 'AGENTS.md'))).toBe(false)
+ expect(existsSync(join(siteDir, 'AGENTS.md'))).toBe(false)
+
+ const selected = await primerBrandSetupTool.run({framework: 'auto', projectDir: 'apps/site'}, context)
+ expect(selected.text).toContain('Created `AGENTS.md` beside the selected package.json')
+ expect(existsSync(join(siteDir, 'AGENTS.md'))).toBe(true)
+ expect(existsSync(join(docsDir, 'AGENTS.md'))).toBe(false)
+ } finally {
+ rmSync(workspaceDir, {recursive: true, force: true})
+ }
+ })
+
+ it('declares its managed workspace update as non-destructive and idempotent', () => {
+ expect(primerBrandSetupTool.annotations).toEqual({
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: false,
+ })
+ })
})
diff --git a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts
index 102928d693..024c29ab05 100644
--- a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts
+++ b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts
@@ -1,6 +1,12 @@
import {z} from 'zod'
-
-import {detectFramework, type FrameworkId} from '../../brand/detect-framework.js'
+import {relative} from 'node:path'
+
+import {detectFramework, type FrameworkId} from '../../brand/detect-framework/detect-framework.js'
+import {
+ resolveBrandProject,
+ type BrandProjectResolution,
+} from '../../brand/resolve-brand-project/resolve-brand-project.js'
+import {updateAgentsMd, type AgentsMdUpdateResult} from '../../brand/update-agents-md/update-agents-md.js'
import {versionNote} from '../format.js'
import type {ToolContext, ToolModule, ToolResult} from '../types.js'
@@ -12,11 +18,20 @@ const inputSchema = z.object({
.optional()
.default('auto')
.describe('Target framework. "auto" detects it from the project; override if detection is wrong.'),
+ projectDir: z
+ .string()
+ .min(1)
+ .optional()
+ .describe(
+ 'Workspace-relative package folder whose package.json declares @primer/react-brand. Set this when several workspace packages declare it.',
+ ),
})
type Input = z.infer
-const description = `Set up the Primer Brand (@primer/react-brand) foundation that agents routinely forget: the ThemeProvider at the app root, the Mona Sans font import, the correct style import, and the \`'use client'\` boundary for React Server Components. Detects the framework (Next App/Pages, Vite, Astro, Remix) and returns tailored, copy-ready setup. Call this once before building a Primer Brand page.`
+const description = `Set up Primer Brand in an existing React project.
+Call once before building a page; detects or accepts the framework and returns tailored root setup.
+Also updates \`AGENTS.md\` in the package that declares \`@primer/react-brand\` so future agents use Primer Brand tools and local docs.`
const STATIC: Record = {
'next-app': {label: 'Next.js (App Router)', rsc: true},
@@ -106,7 +121,14 @@ import {ThemeProvider} from '@primer/react-brand/esm'
`
-function build(id: FrameworkId, ctx: ToolContext): string {
+type SetupAgentsMdResult = {
+ action: AgentsMdUpdateResult['action']
+ path: string | null
+ reason?: AgentsMdUpdateResult['reason'] | BrandProjectResolution['reason']
+ candidates?: string[]
+}
+
+function build(id: FrameworkId, ctx: ToolContext, agentsMd: SetupAgentsMdResult): string {
const {label, rsc} = STATIC[id]
const snippet = id === 'unknown' ? GENERIC_SNIPPET : ROOT_SNIPPETS[id]
@@ -114,6 +136,45 @@ function build(id: FrameworkId, ctx: ToolContext): string {
? `\n\n> **RSC boundary:** \`ThemeProvider\` uses React context, so it must live in a \`'use client'\` component (the \`Providers\` wrapper above). It cannot go directly in the server-rendered \`layout.tsx\`.`
: ''
+ const agentsMdResult = (() => {
+ switch (agentsMd.action) {
+ case 'created':
+ return 'Created `AGENTS.md` beside the selected package.json with local docs routing instructions.'
+ case 'updated':
+ return 'Updated the managed Primer Brand instructions beside the selected package.json; existing content was preserved.'
+ case 'unchanged':
+ return 'The selected package already has the current managed Primer Brand instructions in `AGENTS.md`.'
+ case 'skipped':
+ if (agentsMd.reason === 'ambiguous-brand-project') {
+ const candidates = agentsMd.candidates
+ ?.map(candidate => {
+ const relativeCandidate = ctx.workspaceDir ? relative(ctx.workspaceDir, candidate) : candidate
+ return relativeCandidate || '.'
+ })
+ .join(', ')
+ return `Skipped \`AGENTS.md\`: multiple package.json files declare \`@primer/react-brand\`${
+ candidates ? ` (${candidates})` : ''
+ }. Rerun \`primer_brand_setup\` with \`projectDir\` set to the intended package folder.`
+ }
+ if (agentsMd.reason === 'invalid-brand-project') {
+ return 'Skipped `AGENTS.md`: `projectDir` must identify a folder inside the workspace whose package.json declares `@primer/react-brand`.'
+ }
+ if (agentsMd.reason === 'brand-package-not-found') {
+ return 'Skipped `AGENTS.md`: no package.json declaring `@primer/react-brand` was found in the workspace.'
+ }
+ if (agentsMd.reason === 'incorrect-filename-case') {
+ return 'Skipped `AGENTS.md`: rename the existing case-insensitive match to exactly `AGENTS.md`, then rerun setup.'
+ }
+ if (agentsMd.reason === 'malformed-managed-block') {
+ return 'Skipped `AGENTS.md`: the Primer Brand managed markers are malformed or duplicated; repair or remove that block, then rerun setup.'
+ }
+ if (agentsMd.reason === 'unsafe-agents-path') {
+ return 'Skipped `AGENTS.md`: the existing path is a symbolic link or resolves outside the selected package. Replace it with a regular file inside the package, then rerun setup.'
+ }
+ return 'Skipped `AGENTS.md`: no project root was detected.'
+ }
+ })()
+
return [
`# Set up Primer Brand — ${label}`,
`## 1. Install\n${fence}bash\nnpm install @primer/react-brand\n${fence}`,
@@ -122,6 +183,7 @@ function build(id: FrameworkId, ctx: ToolContext): string {
`## 4. Styles\nImporting components from \`@primer/react-brand/esm\` auto-includes each component's styles. **Do not also import \`@primer/react-brand/lib/css/main.css\`** — that is the non-ESM path, and mixing the two double-loads styles.`,
`## 5. Build the page\n- \`primer_brand_page_design\` first for page-design patterns and the current-brand reference templates to start from\n- \`primer_brand_examples\` for a correct starting composition, then \`primer_brand_component\` for exact props\n- \`primer_brand_tokens\` / \`primer_brand_asset\` for colors, spacing, and icons\n- \`primer_brand_review\` on your complete output — JSX and CSS together — before you finish`,
`## 6. Header & footer\nFor a global header use \`SubdomainNavBar\`; for the footer use \`MinimalFooter\`. Don't hand-roll a \`\`, \`