Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions .github/workflows/mcpb.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: MCPB Extensions

on:
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/mcpb.yml"
- "scripts/build-mcpb.ts"
- "scripts/smoke-mcpb.ts"
- "src/build/**"
- "src/mcp/**"
- "src/mcpb.ts"
- "src/store/**"
- "vite.config.mcpb.ts"
- "package.json"
- "package-lock.json"
push:
tags:
- "v*"

permissions:
contents: read

jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- target: windows-x64
runner: windows-latest
- target: macos-x64
runner: macos-15-intel
- target: macos-arm64
runner: macos-15

steps:
- name: Checkout code
uses: actions/checkout@v6

- name: Set up Node.js 22
uses: actions/setup-node@v6
with:
node-version: "22.x"
cache: npm

- name: Install dependencies
run: npm ci

- name: Build application
run: npm run build:mcpb:server

- name: Build read-only MCPB
run: npm run build:mcpb -- --target ${{ matrix.target }}

- name: Smoke-test packaged MCP server
run: npm run smoke:mcpb -- artifacts

- name: Upload MCPB artifact
uses: actions/upload-artifact@v7
with:
name: lib-docs-${{ matrix.target }}
path: artifacts/*.mcpb
if-no-files-found: error
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.DS_Store
node_modules/
dist/
mcpb-dist/
artifacts/
.store/
public/assets/
public/index.html
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ npx @arabold/docs-mcp-server@latest

See **[Connecting Clients](docs/guides/mcp-clients.md)** for VS Code (Cline, Roo) and other setup options.

Claude Desktop users can also install a platform-specific, read-only `.mcpb` extension. It exposes indexed-document search tools only and uses the existing local Grounded Docs index. See **[Claude Desktop extension](docs/guides/mcp-clients.md#read-only-desktop-extension)** for supported artifacts and installation details.

`scrape_docs` also accepts `preserveHashes: true` for documentation sites that use hash-based client-side routing.
Use it only for hash-routed SPAs; normal sites typically use hash fragments for in-page anchors.

Expand Down
15 changes: 15 additions & 0 deletions docs/guides/mcp-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ Most clients support two connection modes:
## 🤖 Desktop Apps

### Claude Desktop

#### Read-only desktop extension

Platform-specific `.mcpb` artifacts install from **Settings → Extensions → Advanced settings → Install Extension**:

- `lib-docs-<version>-windows-x64.mcpb`
- `lib-docs-<version>-macos-x64.mcpb`
- `lib-docs-<version>-macos-arm64.mcpb`

The extension appears in Claude Desktop as `lib-docs`. It reads the existing system Grounded Docs index and exposes only `search_docs`, `list_libraries`, and `find_version`. It does not expose indexing, refresh, removal, job, or URL-fetching tools. Populate the local index through the CLI or Web UI before using the extension.

Maintainers build the native artifacts from **Actions → MCPB Extensions → Run workflow**. Each artifact is built on its matching Windows or macOS runner because the server includes native Node.js dependencies.

#### Manual configuration

Edit your configuration file:
* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"scripts": {
"prepare": "husky || true",
"build": "vite build --config vite.config.web.ts && vite build",
"build:mcpb:server": "vite build --config vite.config.mcpb.ts",
"build:mcpb": "vite-node scripts/build-mcpb.ts",
"smoke:mcpb": "vite-node scripts/smoke-mcpb.ts",
"start": "node --enable-source-maps dist/index.js",
"cli": "node --enable-source-maps dist/index.js",
"server": "node --enable-source-maps dist/index.ts",
Expand Down
145 changes: 145 additions & 0 deletions scripts/build-mcpb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import {
assertNativeTarget,
createMcpbArtifactName,
createMcpbManifest,
type McpbPackageMetadata,
MCPB_TARGETS,
} from "../src/build/mcpb";

const MCPB_CLI = "@anthropic-ai/mcpb@2.1.2";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

function readOption(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}

function run(command: string, args: string[], cwd: string): void {
execFileSync(command, args, {
cwd,
env: {
...process.env,
HUSKY: "0",
},
shell: process.platform === "win32",
stdio: "inherit",
});
}

function copyRequiredPath(relativePath: string, stageDirectory: string): void {
const source = path.join(projectRoot, relativePath);
if (!existsSync(source)) {
throw new Error(`Required build output is missing: ${relativePath}`);
}
cpSync(source, path.join(stageDirectory, relativePath), { recursive: true });
}

function readPackageMetadata(): McpbPackageMetadata & Record<string, unknown> {
return JSON.parse(
readFileSync(path.join(projectRoot, "package.json"), "utf8"),
) as McpbPackageMetadata & Record<string, unknown>;
}

function createRuntimePackage(
packageMetadata: McpbPackageMetadata & Record<string, unknown>,
lockfile: { packages?: Record<string, { version?: string }> },
): Record<string, unknown> {
const dependencyVersion = (name: string): string => {
const version = lockfile.packages?.[`node_modules/${name}`]?.version;
if (!version) throw new Error(`Missing locked version for ${name}`);
return version;
};
return {
name: packageMetadata.name,
version: packageMetadata.version,
private: true,
type: "module",
license: packageMetadata.license,
engines: packageMetadata.engines,
dependencies: {
"@langchain/aws": dependencyVersion("@langchain/aws"),
"@langchain/core": dependencyVersion("@langchain/core"),
"@langchain/google-genai": dependencyVersion("@langchain/google-genai"),
"@langchain/google-vertexai": dependencyVersion("@langchain/google-vertexai"),
"@langchain/openai": dependencyVersion("@langchain/openai"),
"@modelcontextprotocol/sdk": dependencyVersion("@modelcontextprotocol/sdk"),
"better-sqlite3": dependencyVersion("better-sqlite3"),
"env-paths": dependencyVersion("env-paths"),
"fuse.js": dependencyVersion("fuse.js"),
mime: dependencyVersion("mime"),
semver: dependencyVersion("semver"),
"sqlite-vec": dependencyVersion("sqlite-vec"),
yaml: dependencyVersion("yaml"),
zod: dependencyVersion("zod"),
},
};
}

function main(): void {
const targetLabel = readOption("--target");
if (!targetLabel || !Object.hasOwn(MCPB_TARGETS, targetLabel)) {
throw new Error(
`Use --target with one of: ${Object.keys(MCPB_TARGETS).join(", ")}.`,
);
}

const target = MCPB_TARGETS[targetLabel as keyof typeof MCPB_TARGETS];
assertNativeTarget(target);

const packageMetadata = readPackageMetadata();
const lockfile = JSON.parse(
readFileSync(path.join(projectRoot, "package-lock.json"), "utf8"),
) as { packages?: Record<string, { version?: string }> };
const outputDirectory = path.resolve(
readOption("--output-dir") ?? path.join(projectRoot, "artifacts"),
);
const artifactPath = path.join(
outputDirectory,
createMcpbArtifactName(packageMetadata.version, target),
);
const temporaryRoot = path.join(tmpdir(), `grounded-docs-mcpb-${process.pid}`);
const stageDirectory = path.join(temporaryRoot, "bundle");

rmSync(temporaryRoot, { recursive: true, force: true });
mkdirSync(stageDirectory, { recursive: true });
mkdirSync(outputDirectory, { recursive: true });

try {
for (const requiredPath of ["mcpb-dist", "LICENSE"]) {
copyRequiredPath(requiredPath, stageDirectory);
}

writeFileSync(
path.join(stageDirectory, "package.json"),
`${JSON.stringify(createRuntimePackage(packageMetadata, lockfile), null, 2)}\n`,
);
writeFileSync(
path.join(stageDirectory, "manifest.json"),
`${JSON.stringify(createMcpbManifest(packageMetadata, target), null, 2)}\n`,
);

run(
"npm",
["install", "--omit=dev", "--no-audit", "--no-fund", "--package-lock=false"],
stageDirectory,
);
run(
"npx",
["-y", MCPB_CLI, "validate", path.join(stageDirectory, "manifest.json")],
projectRoot,
);
run("npx", ["-y", MCPB_CLI, "pack", stageDirectory, artifactPath], projectRoot);
run("npx", ["-y", MCPB_CLI, "info", artifactPath], projectRoot);

console.log(`✅ MCPB artifact: ${artifactPath}`);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
}

main();
89 changes: 89 additions & 0 deletions scripts/smoke-mcpb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { DocumentStore } from "../src/store/DocumentStore";
import { type AppConfig, defaults } from "../src/utils/config";

const MCPB_CLI = "@anthropic-ai/mcpb@2.1.2";

function findArtifact(input: string): string {
const resolved = path.resolve(input);
if (resolved.endsWith(".mcpb")) return resolved;
const artifacts = readdirSync(resolved)
.filter((entry) => entry.endsWith(".mcpb"))
.map((entry) => path.join(resolved, entry));
if (artifacts.length !== 1) {
throw new Error(`Expected exactly one .mcpb in ${resolved}, found ${artifacts.length}.`);
}
return artifacts[0];
}

async function main(): Promise<void> {
const artifact = findArtifact(process.argv[2] ?? "artifacts");
const temporaryRoot = mkdtempSync(path.join(tmpdir(), "grounded-docs-mcpb-smoke-"));
const unpacked = path.join(temporaryRoot, "unpacked");
const storePath = path.join(temporaryRoot, "store");

try {
execFileSync(
"npx",
["-y", MCPB_CLI, "unpack", artifact, unpacked],
{ shell: process.platform === "win32", stdio: "inherit" },
);

const config: AppConfig = {
...defaults,
app: { ...defaults.app, storePath, embeddingModel: "" },
};
mkdirSync(storePath, { recursive: true });
const writableStore = new DocumentStore(path.join(storePath, "documents.db"), config);
await writableStore.initialize();
await writableStore.shutdown();

const transport = new StdioClientTransport({
command: process.execPath,
args: [path.join(unpacked, "mcpb-dist", "index.js")],
cwd: unpacked,
env: {
...process.env,
DOCS_MCP_STORE_PATH: storePath,
DOCS_MCP_TELEMETRY: "false",
},
stderr: "pipe",
});
let serverStderr = "";
transport.stderr?.on("data", (chunk: Buffer) => {
serverStderr += chunk.toString();
});
const client = new Client({ name: "mcpb-smoke", version: "1.0.0" });
let tools: Awaited<ReturnType<typeof client.listTools>>;
try {
await client.connect(transport);
tools = await client.listTools();
await client.close();
} catch (error) {
const details = serverStderr.trim();
throw new Error(
details.length > 0 ? `${error}\nPackaged server stderr:\n${details}` : String(error),
);
}

const names = tools.tools.map((tool) => tool.name).sort();
const expected = ["find_version", "list_libraries", "search_docs"];
if (JSON.stringify(names) !== JSON.stringify(expected)) {
throw new Error(`Unexpected MCPB tools: ${names.join(", ")}.`);
}

console.log(`✅ MCPB smoke passed: ${path.basename(artifact)} (${names.join(", ")})`);
} finally {
rmSync(temporaryRoot, { recursive: true, force: true });
}
}

main().catch((error) => {
console.error(`❌ MCPB smoke failed: ${error}`);
process.exit(1);
});
Loading
Loading