Skip to content
Merged
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
65 changes: 65 additions & 0 deletions .github/workflows/publish-mcp.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Publishes the docs MCP server: nyuchi-docs-mcp to npm, then the
# server record (server.json) to the official MCP registry
# (registry.modelcontextprotocol.io) using GitHub OIDC — no registry
# credentials needed for the io.github.nyuchi/* namespace.
#
# One-time setup: add the NPM_TOKEN repo secret (npm automation token
# with publish rights on `nyuchi-docs-mcp`). Keep the versions in
# nyuchi-docs-mcp/package.json and server.json in lockstep — this
# workflow refuses to publish if they drift.
#
# Trigger: publish a GitHub release (tag e.g. mcp-v0.1.0), or run
# manually via workflow_dispatch.
name: Publish MCP

on:
release:
types: [published]
workflow_dispatch:

permissions:
contents: read
id-token: write # OIDC for mcp-publisher

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 10.33.0

- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://registry.npmjs.org

- name: Check versions are in lockstep
run: |
PKG=$(node -p "require('./nyuchi-docs-mcp/package.json').version")
SRV=$(node -p "require('./server.json').version")
SRVPKG=$(node -p "require('./server.json').packages[0].version")
echo "package=$PKG server.json=$SRV server.json.packages[0]=$SRVPKG"
test "$PKG" = "$SRV" && test "$PKG" = "$SRVPKG"

- name: Install + test
run: |
pnpm install --frozen-lockfile
pnpm --filter nyuchi-docs-mcp test

- name: Publish to npm
working-directory: nyuchi-docs-mcp
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Install mcp-publisher
run: |
curl -sL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m).tar.gz" | tar xz mcp-publisher

- name: Publish to the MCP registry
run: |
./mcp-publisher login github-oidc
./mcp-publisher publish
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@ packages that are shared with the companion repo
| -------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `site` | `site/` | The docs website — Astro 6 + Starlight + Svelte 5. Private. |
| `nyuchi-docs-search` | `nyuchi-docs-search/` | Publishable npm package (MIT): a Mintlify-style `⌘K`/`Ctrl+K` search modal (Pagefind-backed) with an "Ask AI" tab. Consumed here via `workspace:*` and by `bundu-docs` from the registry — this shared package is the reason the monorepo exists. |
| `shamwari-docs-ai` | `shamwari-docs-ai/` | Cloudflare Worker — thin proxy in front of Cloudflare **AI Search** that powers the Ask-AI tab for both docs sites. Keeps the API token server-side, adds CORS, streams SSE. |
| `shamwari-docs-ai` | `shamwari-docs-ai/` | Cloudflare Worker — thin proxy in front of Cloudflare **AI Search** that powers the Ask-AI tab for both docs sites (CORS, SSE), **plus the docs MCP server at `docs.nyuchi.com/mcp`** (read tools over the same AI Search index; feedback/issue write tools into the `FEEDBACK` KV namespace, optional `GITHUB_TOKEN` secret files real issues). |

A fourth package, `nyuchi-docs-mcp/`, is the published npm stdio
bridge to the hosted MCP endpoint (MCP registry name
`io.github.nyuchi/nyuchi-docs`; manifest `server.json` at the repo
root; released via `.github/workflows/publish-mcp.yml` — npm publish
needs the `NPM_TOKEN` secret, registry publish uses GitHub OIDC, and
the workflow enforces version lockstep between `package.json` and
`server.json`).

_Shamwari_ = "friend" in Shona. Nyuchi is part of the **Bundu
Foundation** ecosystem; the design system is **Mzizi**
Expand Down Expand Up @@ -129,7 +137,12 @@ docs.nyuchi.com (site)
- Worker API: `POST /chat` with
`{ messages: ChatMessage[], source?: 'nyuchi' | 'bundu' }`,
responding with SSE frames `citations` / `token` / `done` /
`error`; `GET /health`.
`error`; `GET /health`; `POST /mcp` — MCP Streamable HTTP
(JSON-RPC) with tools `search_docs` / `ask_docs` / `read_page` /
`submit_feedback` / `raise_issue` (`src/mcp.ts`, routed from
`docs.nyuchi.com/mcp*` via `wrangler.toml` routes; server card at
`site/public/.well-known/mcp/server-card.json`, guide at
`integrations/docs-mcp.mdx`).
- Worker config (`shamwari-docs-ai/wrangler.toml`): vars `TOP_K`,
`ALLOWED_ORIGINS`; AI Search binding `NYUCHI_DOCS`. The
`BUNDU_DOCS` binding is **commented out** (blocked on
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This repo is a **pnpm workspace** with three packages:
| ---------------------- | --------------------- | ----------------------------------------------------------------------------------------- |
| `site` | `site/` | The Astro + [Starlight](https://starlight.astro.build) docs site itself. Ships as a Cloudflare Worker with Static Assets. |
| `nyuchi-docs-search` | `nyuchi-docs-search/` | Publishable npm package: cmdk-style search modal + Ask-AI tab for Starlight sites. |
| `shamwari-docs-ai` | `shamwari-docs-ai/` | Cloudflare Worker — thin proxy in front of Cloudflare AI Search instances. |
| `shamwari-docs-ai` | `shamwari-docs-ai/` | Cloudflare Worker — Ask-AI proxy + the docs MCP server at docs.nyuchi.com/mcp. |

## Companion site

Expand Down
61 changes: 61 additions & 0 deletions nyuchi-docs-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# nyuchi-docs-mcp

MCP server for the [Nyuchi docs](https://docs.nyuchi.com) — search,
read, and ask the documentation, and send feedback or raise issues
about it, from any Model Context Protocol client.

This package is a dependency-free stdio bridge to the hosted
Streamable HTTP endpoint at **`https://docs.nyuchi.com/mcp`**. Clients
that speak Streamable HTTP can skip the package and connect to the URL
directly.

## Use

```bash
npx nyuchi-docs-mcp
```

Claude Code:

```bash
claude mcp add nyuchi-docs -- npx nyuchi-docs-mcp
```

Or any client config:

```json
{
"mcpServers": {
"nyuchi-docs": { "command": "npx", "args": ["nyuchi-docs-mcp"] }
}
}
```

Direct HTTP (no package needed):

```json
{
"mcpServers": {
"nyuchi-docs": { "type": "http", "url": "https://docs.nyuchi.com/mcp" }
}
}
```

## Tools

| Tool | Kind | What it does |
| --- | --- | --- |
| `search_docs` | read | Search the docs; titles, URLs, snippets |
| `ask_docs` | read | Synthesized answer with citations |
| `read_page` | read | Full readable text of a docs page |
| `submit_feedback` | write | Send a correction / comment to the docs team |
| `raise_issue` | write | File an actionable docs issue |

The endpoint can be overridden with an argument
(`nyuchi-docs-mcp <url>`) or `NYUCHI_DOCS_MCP_URL` — useful against a
preview deployment.

MCP registry name: `io.github.nyuchi/nyuchi-docs`. Source lives in
[`nyuchi/nyuchi-docs`](https://github.com/nyuchi/nyuchi-docs)
(`nyuchi-docs-mcp/` for this bridge, `shamwari-docs-ai/src/mcp.ts` for
the hosted server). MIT.
12 changes: 12 additions & 0 deletions nyuchi-docs-mcp/bin/nyuchi-docs-mcp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env node
// nyuchi-docs-mcp — stdio MCP server bridging to docs.nyuchi.com/mcp.
// Usage: nyuchi-docs-mcp [endpoint] (or NYUCHI_DOCS_MCP_URL env var)

import { runBridge, DEFAULT_ENDPOINT } from '../lib/bridge.js';

const endpoint = process.argv[2] || process.env.NYUCHI_DOCS_MCP_URL || DEFAULT_ENDPOINT;

runBridge({ stdin: process.stdin, stdout: process.stdout, endpoint }).catch((err) => {
process.stderr.write(`nyuchi-docs-mcp: ${err?.message ?? err}\n`);
process.exit(1);
});
65 changes: 65 additions & 0 deletions nyuchi-docs-mcp/lib/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// stdio ⇄ Streamable-HTTP bridge for the hosted nyuchi-docs MCP server.
//
// MCP stdio framing is newline-delimited JSON-RPC. The hosted server at
// docs.nyuchi.com/mcp is stateless (each POST carries one message), so
// the bridge is a straight relay: one stdin line → one POST → one
// stdout line (or none, for notifications answered with 202).

export const DEFAULT_ENDPOINT = 'https://docs.nyuchi.com/mcp';

/**
* Forward a single JSON-RPC line to the HTTP endpoint.
* Returns the response body text to emit, or null for no output.
*/
export async function forwardLine(line, endpoint = DEFAULT_ENDPOINT, fetchImpl = fetch) {
const trimmed = line.trim();
if (!trimmed) return null;

let res;
try {
res = await fetchImpl(endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: trimmed,
});
} catch (err) {
return transportError(trimmed, `network error: ${err?.message ?? err}`);
}

if (res.status === 202) return null; // notification accepted, no body
const text = (await res.text()).trim();
if (!text) return null;
return text;
}

function transportError(requestLine, message) {
let id = null;
try {
id = JSON.parse(requestLine).id ?? null;
} catch {
// unparseable request — respond with a null-id error anyway
}
if (id === null) return null; // notification (or garbage): stay silent
return JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32000, message } });
}

/**
* Run the bridge over the given streams until stdin ends.
*/
export async function runBridge({ stdin, stdout, endpoint = DEFAULT_ENDPOINT, fetchImpl = fetch }) {
let buffer = '';
for await (const chunk of stdin) {
buffer += chunk.toString('utf8');
let nl;
while ((nl = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
const out = await forwardLine(line, endpoint, fetchImpl);
if (out) stdout.write(out + '\n');
}
}
if (buffer.trim()) {
const out = await forwardLine(buffer, endpoint, fetchImpl);
if (out) stdout.write(out + '\n');
}
}
38 changes: 38 additions & 0 deletions nyuchi-docs-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "nyuchi-docs-mcp",
"version": "0.1.0",
"description": "MCP server for the Nyuchi docs (docs.nyuchi.com) — search, read, ask, send feedback and raise issues. Stdio bridge to the hosted Streamable HTTP endpoint at docs.nyuchi.com/mcp.",
"license": "MIT",
"type": "module",
"bin": {
"nyuchi-docs-mcp": "bin/nyuchi-docs-mcp.js"
},
"files": [
"bin",
"lib",
"README.md"
],
"engines": {
"node": ">=18"
},
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.4"
},
"mcpName": "io.github.nyuchi/nyuchi-docs",
"repository": {
"type": "git",
"url": "git+https://github.com/nyuchi/nyuchi-docs.git",
"directory": "nyuchi-docs-mcp"
},
"homepage": "https://docs.nyuchi.com/integrations/docs-mcp/",
"keywords": [
"mcp",
"model-context-protocol",
"documentation",
"nyuchi",
"docs"
]
}
59 changes: 59 additions & 0 deletions nyuchi-docs-mcp/tests/bridge.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest';
import { Readable } from 'node:stream';
import { forwardLine, runBridge } from '../lib/bridge.js';

const ok = (body, status = 200) =>
Promise.resolve(new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }));

describe('forwardLine', () => {
it('POSTs the line and returns the response body', async () => {
const fetchImpl = vi.fn().mockReturnValue(ok({ jsonrpc: '2.0', id: 1, result: {} }));
const out = await forwardLine('{"jsonrpc":"2.0","id":1,"method":"ping"}', 'https://x/mcp', fetchImpl);
expect(fetchImpl).toHaveBeenCalledWith('https://x/mcp', expect.objectContaining({ method: 'POST' }));
expect(JSON.parse(out).id).toBe(1);
});

it('returns null for 202 notification responses', async () => {
const fetchImpl = vi.fn().mockReturnValue(ok('', 202));
const out = await forwardLine(
'{"jsonrpc":"2.0","method":"notifications/initialized"}',
'https://x/mcp',
fetchImpl
);
expect(out).toBeNull();
});

it('returns a JSON-RPC error on network failure for requests with an id', async () => {
const fetchImpl = vi.fn().mockRejectedValue(new Error('offline'));
const out = await forwardLine('{"jsonrpc":"2.0","id":7,"method":"ping"}', 'https://x/mcp', fetchImpl);
const parsed = JSON.parse(out);
expect(parsed.id).toBe(7);
expect(parsed.error.message).toContain('offline');
});

it('skips empty lines', async () => {
const fetchImpl = vi.fn();
expect(await forwardLine(' ', 'https://x/mcp', fetchImpl)).toBeNull();
expect(fetchImpl).not.toHaveBeenCalled();
});
});

describe('runBridge', () => {
it('relays newline-delimited messages in order', async () => {
const fetchImpl = vi
.fn()
.mockReturnValueOnce(ok({ jsonrpc: '2.0', id: 1, result: { a: 1 } }))
.mockReturnValueOnce(ok('', 202))
.mockReturnValueOnce(ok({ jsonrpc: '2.0', id: 2, result: { b: 2 } }));
const stdin = Readable.from([
'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n',
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n',
]);
const lines = [];
const stdout = { write: (s) => lines.push(s.trim()) };
await runBridge({ stdin, stdout, endpoint: 'https://x/mcp', fetchImpl });
expect(lines).toHaveLength(2);
expect(JSON.parse(lines[0]).id).toBe(1);
expect(JSON.parse(lines[1]).id).toBe(2);
});
});
5 changes: 5 additions & 0 deletions nyuchi-docs-mcp/vitest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: { environment: 'node' },
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ packages:
- site
- nyuchi-docs-search
- shamwari-docs-ai
- nyuchi-docs-mcp
29 changes: 29 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
"name": "io.github.nyuchi/nyuchi-docs",
"description": "Search, read, and ask the Nyuchi engineering + product docs (docs.nyuchi.com), and send feedback or raise issues about them.",
"repository": {
"url": "https://github.com/nyuchi/nyuchi-docs",
"source": "github"
},
"version": "0.1.0",
"websiteUrl": "https://docs.nyuchi.com/integrations/docs-mcp/",
"remotes": [
{
"type": "streamable-http",
"url": "https://docs.nyuchi.com/mcp"
}
],
"packages": [
{
"registryType": "npm",
"registryBaseUrl": "https://registry.npmjs.org",
"identifier": "nyuchi-docs-mcp",
"version": "0.1.0",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
}
}
]
}
Loading
Loading