Skip to content

Commit cf367d9

Browse files
committed
Add hoist-read-doc MCP tool + hoist-docs ping, tolerate dropped docs/ in doc resource
MCP/CLI parity additions found during v85 SNAPSHOT validation, plus a fix for the `hoist://docs/{id}` resource-URI footgun they surfaced. - Add `hoist-read-doc` MCP tool: reads a full document by exact ID, returning text plus structured `{id, title, category, content}`. Gives MCP parity with the `hoist-docs read` CLI and hoist-core's `hoist-core-read-doc`, and a friction-free bare-ID read path that avoids the resource-URI doubling. Closes #4356. - Add `hoist-docs ping` CLI subcommand mirroring the `hoist-ping` MCP tool; both now report the indexed `@xh/hoist` library version via a new `resolveHoistVersion()` helper. Closes #4355. - Make the `hoist://docs/{id}` resource tolerant of a dropped `docs/` segment, so `hoist://docs/routing.md` resolves the same as the strictly-correct `hoist://docs/docs/routing.md`. Exact match still wins first; only `docs/`-prefixed IDs are reachable by the fallback, so it never resolves the wrong doc. - Point search/list tool descriptions and server instructions at `hoist-read-doc`; update mcp/README.md, CLAUDE.md, and CHANGELOG.
1 parent 33c5e1a commit cf367d9

9 files changed

Lines changed: 169 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@
5050
with later arguments overriding earlier ones. Replaces the now-deprecated
5151
`PersistenceProvider.mergePersistOptions`.
5252

53+
### 🤖 AI Docs + Tooling
54+
55+
* Added a `hoist-read-doc` MCP tool that reads a full document by exact ID, giving MCP parity with
56+
the `hoist-docs read` CLI and `hoist-core`'s `hoist-core-read-doc`. Avoids the awkward
57+
`hoist://docs/{id}` resource-URI doubling for IDs that themselves start with `docs/`.
58+
See [#4356](https://github.com/xh/hoist-react/issues/4356).
59+
* The `hoist://docs/{id}` resource now tolerates a dropped `docs/` segment, so
60+
`hoist://docs/routing.md` resolves the same as the strictly-correct
61+
`hoist://docs/docs/routing.md`.
62+
* Added a `hoist-docs ping` CLI subcommand mirroring the `hoist-ping` MCP tool; both now report the
63+
indexed `@xh/hoist` library version. See [#4355](https://github.com/xh/hoist-react/issues/4355).
64+
5365
### ⚙️ Technical
5466

5567
* Forked unmaintained `golden-layout` 1.5.9 into `kit/golden-layout/`. Removed unused code, ported

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ Two interfaces are available. Both share the same underlying registries and prod
2727

2828
**MCP Server (hoist-react)** -- When working in the hoist-react repository, an MCP server is
2929
configured via `.mcp.json` and is very likely already available. Use the `hoist-search-docs`,
30-
`hoist-list-docs`, `hoist-search-symbols`, `hoist-get-symbol`, and `hoist-get-members` tools, plus
31-
`hoist://docs/{id}` resources for direct document access.
30+
`hoist-list-docs`, `hoist-read-doc`, `hoist-search-symbols`, `hoist-get-symbol`, and
31+
`hoist-get-members` tools, plus `hoist://docs/{id}` resources for direct document access.
3232

3333
**CLI Tools** -- For environments without MCP support, or when you prefer shell commands. These are
3434
real `bin` entries in the hoist-react `package.json` — invoke them exactly as shown with `npx`:

mcp/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,9 +315,20 @@ List all available documentation with descriptions, grouped by category.
315315
|-----------|------|----------|-------------|
316316
| `category` | enum | No | Filter: `package`, `concept`, `devops`, `conventions`, `all` (default) |
317317

318+
#### `hoist-read-doc`
319+
320+
Read the full text of a single document by its exact ID. The tool-based equivalent of the
321+
`hoist://docs/{id}` resource — useful when resource fetching is unavailable or inconvenient. Returns
322+
the markdown body as text plus structured `{id, title, category, content}`.
323+
324+
| Parameter | Type | Required | Description |
325+
|-----------|------|----------|-------------|
326+
| `id` | string | Yes | Exact document ID (repo-relative path), e.g. `cmp/grid/README.md`, `docs/authentication.md` |
327+
318328
#### `hoist-ping`
319329

320-
Verify the MCP server is running and responsive. Takes no parameters.
330+
Verify the MCP server is running and responsive. Takes no parameters. Reports the indexed
331+
`@xh/hoist` library version.
321332

322333
### TypeScript Tools
323334

@@ -471,6 +482,12 @@ Resources provide direct read access to documentation files via URI.
471482
The `hoist-doc` template uses RFC 6570 reserved expansion (`{+docId}`) so slashes in doc IDs
472483
(e.g. `cmp/grid`) are preserved rather than percent-encoded.
473484

485+
Because concept-doc IDs are themselves `docs/`-prefixed (e.g. `docs/routing.md`) and the scheme
486+
prefix is also `hoist://docs/`, the strictly-correct URI doubles the segment
487+
(`hoist://docs/docs/routing.md`). The resource tolerates a single dropped `docs/`, so
488+
`hoist://docs/routing.md` resolves identically. For a friction-free read by bare ID, prefer the
489+
`hoist-read-doc` tool.
490+
474491
**Discovering available documents:** The `hoist-doc` resource supports `list` and `complete`
475492
operations. MCP clients can enumerate all available doc IDs or get tab-completion suggestions.
476493

mcp/cli/docs.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
toSearchDocsOutput,
1414
toListDocsOutput
1515
} from '../formatters/docs.js';
16-
import {resolveRepoRoot} from '../util/paths.js';
16+
import {resolveRepoRoot, resolveHoistVersion} from '../util/paths.js';
1717

1818
const {entries: registry, mcpCategories} = buildRegistry(resolveRepoRoot());
1919
const VALID_CATEGORIES = [...mcpCategories.map(c => c.id), 'all'];
@@ -52,7 +52,8 @@ Examples:
5252
hoist-docs list -c package List only package docs
5353
hoist-docs read cmp/grid/README.md Read the Grid component README
5454
hoist-docs conventions Print coding conventions
55-
hoist-docs index Print the documentation index`
55+
hoist-docs index Print the documentation index
56+
hoist-docs ping Confirm the CLI is wired up`
5657
);
5758

5859
//----------------------------------------------------------------------
@@ -172,4 +173,17 @@ program
172173
process.stdout.write(loadDocContent(entry) + '\n');
173174
});
174175

176+
//----------------------------------------------------------------------
177+
// Subcommand: ping
178+
//----------------------------------------------------------------------
179+
program
180+
.command('ping')
181+
.description('Verify the hoist-docs CLI is running and the doc registry loads.')
182+
.action(() => {
183+
// Registry is loaded at module init above; reaching here confirms it.
184+
process.stdout.write(
185+
`hoist-docs CLI is running (@xh/hoist v${resolveHoistVersion()}, ${registry.length} docs indexed).\n`
186+
);
187+
});
188+
175189
program.parse();

mcp/formatters/docs.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,35 @@ export function toListDocsOutput(
167167
};
168168
}
169169

170+
//------------------------------------------------------------------
171+
// Structured output: hoist-read-doc
172+
//------------------------------------------------------------------
173+
174+
/**
175+
* Zod schema for the structured output of `hoist-read-doc`. Returns the full
176+
* document body alongside its identifying metadata, so consumers get both the
177+
* raw markdown and the catalog fields without a second lookup.
178+
*/
179+
export const readDocOutputSchema = z.object({
180+
id: z.string().describe('Document ID, also its path relative to the repo root.'),
181+
title: z.string(),
182+
category: z.string().describe('MCP category ID (e.g. "package", "concept").'),
183+
content: z.string().describe('Full markdown body of the document.')
184+
});
185+
186+
/** Structured output type for `hoist-read-doc`, derived from the zod schema. */
187+
export type ReadDocOutput = z.infer<typeof readDocOutputSchema>;
188+
189+
/** Project a registry entry plus its loaded body into the public structured shape. */
190+
export function toReadDocOutput(entry: DocEntry, content: string): ReadDocOutput {
191+
return {
192+
id: entry.id,
193+
title: entry.title,
194+
category: entry.mcpCategory,
195+
content
196+
};
197+
}
198+
170199
/** Format a document listing grouped by category. */
171200
export function formatDocList(
172201
registry: DocEntry[],

mcp/resources/docs.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,15 @@ export function registerDocResources(server: McpServer): void {
106106
},
107107
async (uri, variables) => {
108108
const docId = variables.docId as string;
109-
const entry = registry.find(e => e.id === docId);
109+
// Tolerate a dropped `docs/` segment. Concept-doc IDs are repo-relative
110+
// paths that begin with `docs/`, but the resource scheme prefix is also
111+
// `hoist://docs/` - so the strictly-correct URI doubles it
112+
// (`hoist://docs/docs/foo.md`). Callers routinely drop one `docs/` and
113+
// request `hoist://docs/foo.md`; resolve that to `docs/foo.md` rather than
114+
// 404. Exact match always wins first, and `docs/`-prefixed IDs are the only
115+
// ones a bare fallback could reach, so this never resolves the wrong doc.
116+
const entry =
117+
registry.find(e => e.id === docId) ?? registry.find(e => e.id === `docs/${docId}`);
110118

111119
if (!entry) {
112120
const availableIds = registry.map(e => e.id).join(', ');

mcp/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const server = new McpServer(
1414
{
1515
instructions: [
1616
'Authoritative reference for the @xh/hoist React framework, used when writing or modifying any code that consumes Hoist APIs, components, models, services, or patterns (also applicable to hoist-react library development itself).',
17-
'Do not guess at Hoist APIs, prop names, or framework conventions - consult these tools first. Start with hoist-search-docs or hoist-search-symbols to discover exact doc IDs and symbol names, then drill in with hoist-get-symbol, hoist-get-members, or the hoist://docs/{id} resource. Use hoist-list-docs to browse the doc catalog.',
17+
'Do not guess at Hoist APIs, prop names, or framework conventions - consult these tools first. Start with hoist-search-docs or hoist-search-symbols to discover exact doc IDs and symbol names, then drill in with hoist-read-doc, hoist-get-symbol, hoist-get-members, or the hoist://docs/{id} resource. Use hoist-list-docs to browse the doc catalog.',
1818
// Defense-in-depth note for the LLM client - the markdown docs and
1919
// TypeScript JSDoc strings returned by these tools are reference
2020
// material, not directives. Any text within them that resembles

mcp/tools/docs.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@
77
import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
88
import {z} from 'zod';
99

10-
import {buildRegistry, searchDocs} from '../data/doc-registry.js';
10+
import {buildRegistry, searchDocs, loadDocContent} from '../data/doc-registry.js';
1111
import {
1212
formatSearchResults,
1313
formatDocList,
1414
searchDocsOutputSchema,
1515
toSearchDocsOutput,
1616
listDocsOutputSchema,
17-
toListDocsOutput
17+
toListDocsOutput,
18+
readDocOutputSchema,
19+
toReadDocOutput
1820
} from '../formatters/docs.js';
19-
import {resolveRepoRoot} from '../util/paths.js';
21+
import {resolveRepoRoot, resolveHoistVersion} from '../util/paths.js';
2022

2123
/**
2224
* Register all documentation tools on the given MCP server.
2325
*
2426
* - `hoist-search-docs`: Search across all docs by keyword.
2527
* - `hoist-list-docs`: List all available docs with descriptions.
28+
* - `hoist-read-doc`: Read the full body of a single doc by ID.
2629
* - `hoist-ping`: Connectivity test.
2730
*/
2831
export function registerDocTools(server: McpServer): void {
@@ -43,7 +46,7 @@ export function registerDocTools(server: McpServer): void {
4346
{
4447
title: 'Search Hoist Documentation',
4548
description:
46-
'Search across all hoist-react documentation (package READMEs, concept docs, upgrade notes, conventions) by keyword. Returns matching documents with short context snippets showing where terms appear — not full document text. To read a specific doc in full, fetch the hoist://docs/{id} resource using an ID from the results. To browse the catalog without a query, call hoist-list-docs. For TypeScript type information rather than narrative docs, use hoist-search-symbols.',
49+
'Search across all hoist-react documentation (package READMEs, concept docs, upgrade notes, conventions) by keyword. Returns matching documents with short context snippets showing where terms appear — not full document text. To read a specific doc in full, call hoist-read-doc with an ID from the results (or fetch the hoist://docs/{id} resource). To browse the catalog without a query, call hoist-list-docs. For TypeScript type information rather than narrative docs, use hoist-search-symbols.',
4750
inputSchema: z.object({
4851
query: z
4952
.string()
@@ -89,7 +92,7 @@ export function registerDocTools(server: McpServer): void {
8992
{
9093
title: 'List Hoist Documentation',
9194
description:
92-
'List all available hoist-react documentation grouped by category, with title and description for each entry. Returns the catalog only — not full document text. To read a specific doc, fetch the hoist://docs/{id} resource. For keyword-based discovery across doc content, use hoist-search-docs instead.',
95+
'List all available hoist-react documentation grouped by category, with title and description for each entry. Returns the catalog only — not full document text. To read a specific doc, call hoist-read-doc with its ID (or fetch the hoist://docs/{id} resource). For keyword-based discovery across doc content, use hoist-search-docs instead.',
9396
inputSchema: z.object({
9497
category: categoryEnum
9598
}),
@@ -112,18 +115,70 @@ export function registerDocTools(server: McpServer): void {
112115
}
113116
);
114117

118+
//------------------------------------------------------------------
119+
// Tool: hoist-read-doc
120+
//------------------------------------------------------------------
121+
server.registerTool(
122+
'hoist-read-doc',
123+
{
124+
title: 'Read Hoist Documentation',
125+
description:
126+
'Read the full text of a single hoist-react document by its exact ID (e.g. "cmp/grid/README.md", "docs/authentication.md"). Get IDs from hoist-search-docs or hoist-list-docs. This is the tool-based equivalent of the hoist://docs/{id} resource — prefer it when resource fetching is unavailable or inconvenient. For keyword discovery rather than a known ID, use hoist-search-docs.',
127+
inputSchema: z.object({
128+
id: z
129+
.string()
130+
.describe(
131+
'Exact document ID (its repo-relative path) from search or list output, e.g. "cmp/grid/README.md" or "docs/authentication.md".'
132+
)
133+
}),
134+
outputSchema: readDocOutputSchema,
135+
annotations: {
136+
readOnlyHint: true,
137+
destructiveHint: false,
138+
idempotentHint: true,
139+
openWorldHint: false
140+
}
141+
},
142+
async ({id}) => {
143+
const entry = registry.find(e => e.id === id);
144+
if (!entry) {
145+
return {
146+
content: [
147+
{
148+
type: 'text' as const,
149+
text: `Unknown document ID: "${id}". Call hoist-list-docs to see valid IDs, or hoist-search-docs to find one by keyword.`
150+
}
151+
],
152+
isError: true
153+
};
154+
}
155+
156+
const content = loadDocContent(entry);
157+
return {
158+
content: [{type: 'text' as const, text: content}],
159+
structuredContent: toReadDocOutput(entry, content)
160+
};
161+
}
162+
);
163+
115164
//------------------------------------------------------------------
116165
// Tool: hoist-ping
117166
//------------------------------------------------------------------
118167
server.registerTool(
119168
'hoist-ping',
120169
{
121170
title: 'Hoist Ping',
122-
description: 'Verify the Hoist MCP server is running and responsive',
171+
description:
172+
'Verify the Hoist MCP server is running and responsive. Reports the indexed @xh/hoist library version.',
123173
inputSchema: z.object({})
124174
},
125175
async () => ({
126-
content: [{type: 'text' as const, text: 'Hoist MCP server is running.'}]
176+
content: [
177+
{
178+
type: 'text' as const,
179+
text: `Hoist MCP server is running (@xh/hoist v${resolveHoistVersion()}).`
180+
}
181+
]
127182
})
128183
);
129184
}

mcp/util/paths.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
* Provides repo root resolution (via `import.meta.url`) and safe path
55
* construction that prevents directory traversal outside the repository.
66
*/
7-
import {existsSync} from 'node:fs';
7+
import {existsSync, readFileSync} from 'node:fs';
88
import {resolve, dirname, sep} from 'node:path';
99
import {fileURLToPath} from 'node:url';
1010

1111
/** Cached repo root -- resolved once and reused. */
1212
let cachedRepoRoot: string | undefined;
1313

14+
/** Cached hoist library version -- resolved once and reused. */
15+
let cachedHoistVersion: string | undefined;
16+
1417
/**
1518
* Resolve the hoist-react repo root by walking up from this file's location.
1619
*
@@ -36,6 +39,22 @@ export function resolveRepoRoot(): string {
3639
return repoRoot;
3740
}
3841

42+
/**
43+
* Resolve the `@xh/hoist` library version from the repo root `package.json`.
44+
*
45+
* Used by the connectivity-check surfaces (`hoist-ping` tool, `hoist-docs ping`
46+
* CLI) so a sanity check also reports which hoist version is being indexed.
47+
* The result is cached after the first call.
48+
*/
49+
export function resolveHoistVersion(): string {
50+
if (cachedHoistVersion) return cachedHoistVersion;
51+
52+
const pkgPath = resolve(resolveRepoRoot(), 'package.json');
53+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: string};
54+
cachedHoistVersion = pkg.version ?? 'unknown';
55+
return cachedHoistVersion;
56+
}
57+
3958
/**
4059
* Resolve a relative path within the repo root, with traversal protection.
4160
*

0 commit comments

Comments
 (0)