diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 0000000..836ecd8 --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 25312a4..71c2788 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** @@ -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 diff --git a/README.md b/README.md index fe3bc1e..5b4e9c2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nyuchi-docs-mcp/README.md b/nyuchi-docs-mcp/README.md new file mode 100644 index 0000000..1652254 --- /dev/null +++ b/nyuchi-docs-mcp/README.md @@ -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 `) 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. diff --git a/nyuchi-docs-mcp/bin/nyuchi-docs-mcp.js b/nyuchi-docs-mcp/bin/nyuchi-docs-mcp.js new file mode 100644 index 0000000..dc72935 --- /dev/null +++ b/nyuchi-docs-mcp/bin/nyuchi-docs-mcp.js @@ -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); +}); diff --git a/nyuchi-docs-mcp/lib/bridge.js b/nyuchi-docs-mcp/lib/bridge.js new file mode 100644 index 0000000..e6f663a --- /dev/null +++ b/nyuchi-docs-mcp/lib/bridge.js @@ -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'); + } +} diff --git a/nyuchi-docs-mcp/package.json b/nyuchi-docs-mcp/package.json new file mode 100644 index 0000000..2855036 --- /dev/null +++ b/nyuchi-docs-mcp/package.json @@ -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" + ] +} diff --git a/nyuchi-docs-mcp/tests/bridge.test.js b/nyuchi-docs-mcp/tests/bridge.test.js new file mode 100644 index 0000000..a2fb1a7 --- /dev/null +++ b/nyuchi-docs-mcp/tests/bridge.test.js @@ -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); + }); +}); diff --git a/nyuchi-docs-mcp/vitest.config.js b/nyuchi-docs-mcp/vitest.config.js new file mode 100644 index 0000000..d09e4d2 --- /dev/null +++ b/nyuchi-docs-mcp/vitest.config.js @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node' }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece2605..455084b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,12 @@ importers: specifier: ^5.6.3 version: 5.9.3 + nyuchi-docs-mcp: + devDependencies: + vitest: + specifier: ^2.1.4 + version: 2.1.9(@types/node@24.12.4)(jsdom@25.0.1) + nyuchi-docs-search: dependencies: '@astrojs/starlight': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1dc8520..e7f925f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - site - nyuchi-docs-search - shamwari-docs-ai + - nyuchi-docs-mcp diff --git a/server.json b/server.json new file mode 100644 index 0000000..e7a94e7 --- /dev/null +++ b/server.json @@ -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" + } + } + ] +} diff --git a/shamwari-docs-ai/src/mcp.ts b/shamwari-docs-ai/src/mcp.ts new file mode 100644 index 0000000..d266b39 --- /dev/null +++ b/shamwari-docs-ai/src/mcp.ts @@ -0,0 +1,374 @@ +// MCP endpoint for docs.nyuchi.com — a stateless Model Context Protocol +// server (Streamable HTTP transport, JSON-RPC 2.0 over POST /mcp). +// +// Read tools ride the same Cloudflare AI Search instance the Ask-AI tab +// uses; write tools land in the FEEDBACK KV namespace and (when a +// GITHUB_TOKEN secret is configured) file real GitHub issues, so agents +// reading the docs can also report problems and raise requests. + +import { + normaliseCitations, + type AiSearchInstance, + type ChatMessage, + type Env, +} from './worker.js'; + +const PROTOCOL_VERSION = '2025-06-18'; +const SERVER_INFO = { + name: 'nyuchi-docs', + title: 'Nyuchi Docs', + version: '1.0.0', +}; +const INSTRUCTIONS = + 'Read and search the Nyuchi engineering + product documentation at docs.nyuchi.com, ' + + 'and send feedback or raise issues about it. Prefer search_docs to locate pages, ' + + 'read_page for full page text, ask_docs for synthesized answers with citations. ' + + 'Use submit_feedback for corrections/comments and raise_issue for actionable problems.'; + +const DOCS_ORIGIN = 'https://docs.nyuchi.com'; +const GITHUB_REPO = 'nyuchi/nyuchi-docs'; +const MAX_PAGE_CHARS = 24_000; +const MAX_WRITE_CHARS = 8_000; + +interface JsonRpcRequest { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: Record; +} + +type ToolResult = { content: Array<{ type: 'text'; text: string }>; isError?: boolean }; + +const TOOLS = [ + { + name: 'search_docs', + title: 'Search the docs', + description: + 'Keyword/semantic search over docs.nyuchi.com. Returns matching pages with titles, URLs and snippets.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'What to search for' }, + top_k: { type: 'number', description: 'Max results (default 5, max 10)' }, + }, + required: ['query'], + }, + }, + { + name: 'ask_docs', + title: 'Ask the docs', + description: + 'Ask a question and get a synthesized answer grounded in docs.nyuchi.com, with source citations.', + inputSchema: { + type: 'object', + properties: { + question: { type: 'string', description: 'The question to answer' }, + }, + required: ['question'], + }, + }, + { + name: 'read_page', + title: 'Read a docs page', + description: + 'Fetch a docs.nyuchi.com page and return its readable text content. Accepts a path (e.g. /kweli/verification/) or a full docs.nyuchi.com URL.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string', description: 'Page path or full docs.nyuchi.com URL' }, + }, + required: ['path'], + }, + }, + { + name: 'submit_feedback', + title: 'Submit docs feedback', + description: + 'Send feedback about the documentation — a correction, something confusing, something missing. Stored for the docs team.', + inputSchema: { + type: 'object', + properties: { + message: { type: 'string', description: 'The feedback itself' }, + page: { type: 'string', description: 'Page path or URL the feedback is about' }, + kind: { + type: 'string', + enum: ['correction', 'confusing', 'missing', 'praise', 'other'], + description: 'What kind of feedback this is', + }, + contact: { type: 'string', description: 'Optional contact (email/handle) for follow-up' }, + }, + required: ['message'], + }, + }, + { + name: 'raise_issue', + title: 'Raise a docs issue', + description: + 'Raise an actionable issue against the documentation (wrong instructions, broken example, outdated page). Files a GitHub issue on nyuchi/nyuchi-docs when configured, otherwise queues it for the docs team.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Short issue title' }, + body: { type: 'string', description: 'What is wrong and where' }, + page: { type: 'string', description: 'Page path or URL the issue is about' }, + }, + required: ['title', 'body'], + }, + }, +] as const; + +function rpcResult(id: JsonRpcRequest['id'], result: unknown) { + return { jsonrpc: '2.0', id: id ?? null, result }; +} + +function rpcError(id: JsonRpcRequest['id'], code: number, message: string) { + return { jsonrpc: '2.0', id: id ?? null, error: { code, message } }; +} + +function textResult(text: string, isError = false): ToolResult { + return { content: [{ type: 'text', text }], isError: isError || undefined }; +} + +function str(params: Record, key: string): string { + const v = params[key]; + return typeof v === 'string' ? v.trim() : ''; +} + +async function callSearch(env: Env, query: string, topK: number) { + const res = await env.NYUCHI_DOCS.search({ + messages: [{ role: 'user', content: query }], + ai_search_options: { retrieval: { max_num_results: topK } }, + }); + return normaliseCitations(res.chunks ?? []); +} + +async function toolSearchDocs(env: Env, params: Record): Promise { + const query = str(params, 'query'); + if (!query) return textResult('search_docs: query is required', true); + const topK = Math.min(Math.max(Number(params.top_k) || Number(env.TOP_K ?? '5'), 1), 10); + const hits = await callSearch(env, query, topK); + if (hits.length === 0) return textResult(`No documentation matches for "${query}".`); + const lines = hits.map( + (h) => `${h.index}. ${h.title}\n ${h.url}${h.snippet ? `\n ${h.snippet}` : ''}` + ); + return textResult(lines.join('\n\n')); +} + +async function toolAskDocs(env: Env, params: Record): Promise { + const question = str(params, 'question'); + if (!question) return textResult('ask_docs: question is required', true); + const messages: ChatMessage[] = [{ role: 'user', content: question }]; + const opts = { + messages, + ai_search_options: { retrieval: { max_num_results: Number(env.TOP_K ?? '5') } }, + }; + const instance: AiSearchInstance = env.NYUCHI_DOCS; + const [searchRes, chatRes] = await Promise.all([ + instance.search(opts), + instance.chatCompletions(opts), + ]); + const answer = chatRes.choices?.[0]?.message?.content ?? ''; + const sources = normaliseCitations(searchRes.chunks ?? []) + .map((c) => `[${c.index}] ${c.title} — ${c.url}`) + .join('\n'); + if (!answer) return textResult('The docs assistant returned no answer for that question.', true); + return textResult(sources ? `${answer}\n\nSources:\n${sources}` : answer); +} + +function resolveDocsUrl(pathOrUrl: string): URL | null { + try { + const url = pathOrUrl.startsWith('http') + ? new URL(pathOrUrl) + : new URL(pathOrUrl.startsWith('/') ? pathOrUrl : `/${pathOrUrl}`, DOCS_ORIGIN); + if (url.origin !== DOCS_ORIGIN) return null; + return url; + } catch { + return null; + } +} + +// Crude but dependency-free HTML → text: keep the
region, drop +// script/style/nav/svg, unwrap tags, collapse whitespace. +function htmlToText(html: string): string { + const main = html.match(/][\s\S]*?<\/main>/i)?.[0] ?? html; + return main + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<(h[1-6]|p|li|tr|pre|br)[^>]*>/gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/[ \t]+/g, ' ') + .replace(/\n\s+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +async function toolReadPage(params: Record): Promise { + const raw = str(params, 'path'); + if (!raw) return textResult('read_page: path is required', true); + const url = resolveDocsUrl(raw); + if (!url) return textResult(`read_page: only ${DOCS_ORIGIN} pages can be read`, true); + const res = await fetch(url.toString(), { + headers: { 'user-agent': 'nyuchi-docs-mcp/1.0' }, + }); + if (!res.ok) return textResult(`read_page: ${url.pathname} responded ${res.status}`, true); + const text = htmlToText(await res.text()); + const clipped = + text.length > MAX_PAGE_CHARS ? `${text.slice(0, MAX_PAGE_CHARS)}\n\n[truncated]` : text; + return textResult(`# ${url.pathname}\n\n${clipped}`); +} + +function feedbackKey(prefix: string): string { + return `${prefix}:${new Date().toISOString()}:${crypto.randomUUID().slice(0, 8)}`; +} + +async function toolSubmitFeedback(env: Env, params: Record): Promise { + const message = str(params, 'message').slice(0, MAX_WRITE_CHARS); + if (!message) return textResult('submit_feedback: message is required', true); + if (!env.FEEDBACK) { + return textResult('Feedback store is not configured on this deployment.', true); + } + const key = feedbackKey('feedback'); + await env.FEEDBACK.put( + key, + JSON.stringify({ + message, + page: str(params, 'page') || undefined, + kind: str(params, 'kind') || 'other', + contact: str(params, 'contact') || undefined, + receivedAt: new Date().toISOString(), + via: 'mcp', + }) + ); + return textResult(`Feedback recorded for the docs team (ref ${key}). Thank you.`); +} + +async function toolRaiseIssue(env: Env, params: Record): Promise { + const title = str(params, 'title').slice(0, 256); + const body = str(params, 'body').slice(0, MAX_WRITE_CHARS); + if (!title || !body) return textResult('raise_issue: title and body are required', true); + const page = str(params, 'page'); + const fullBody = `${body}${page ? `\n\n**Page:** ${page}` : ''}\n\n---\n_Raised via the docs.nyuchi.com/mcp endpoint._`; + + if (env.GITHUB_TOKEN) { + const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/issues`, { + method: 'POST', + headers: { + authorization: `Bearer ${env.GITHUB_TOKEN}`, + accept: 'application/vnd.github+json', + 'content-type': 'application/json', + 'user-agent': 'nyuchi-docs-mcp/1.0', + }, + body: JSON.stringify({ title, body: fullBody, labels: ['docs-feedback', 'via-mcp'] }), + }); + if (res.ok) { + const issue = (await res.json()) as { html_url?: string; number?: number }; + return textResult(`Issue #${issue.number} created: ${issue.html_url}`); + } + // fall through to the KV queue on API failure so the report is not lost + } + + if (!env.FEEDBACK) { + return textResult('Issue queue is not configured on this deployment.', true); + } + const key = feedbackKey('issue'); + await env.FEEDBACK.put( + key, + JSON.stringify({ title, body: fullBody, receivedAt: new Date().toISOString(), via: 'mcp' }) + ); + return textResult(`Issue queued for the docs team (ref ${key}).`); +} + +async function callTool(env: Env, name: string, args: Record): Promise { + switch (name) { + case 'search_docs': + return toolSearchDocs(env, args); + case 'ask_docs': + return toolAskDocs(env, args); + case 'read_page': + return toolReadPage(args); + case 'submit_feedback': + return toolSubmitFeedback(env, args); + case 'raise_issue': + return toolRaiseIssue(env, args); + default: + return textResult(`Unknown tool: ${name}`, true); + } +} + +async function handleMessage(env: Env, msg: JsonRpcRequest): Promise { + const { id, method, params = {} } = msg; + + // Notifications (no id) get no response body. + if (id === undefined && method?.startsWith('notifications/')) return null; + + switch (method) { + case 'initialize': + return rpcResult(id, { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: { listChanged: false } }, + serverInfo: SERVER_INFO, + instructions: INSTRUCTIONS, + }); + case 'ping': + return rpcResult(id, {}); + case 'tools/list': + return rpcResult(id, { tools: TOOLS }); + case 'tools/call': { + const name = typeof params.name === 'string' ? params.name : ''; + const args = (params.arguments ?? {}) as Record; + try { + return rpcResult(id, await callTool(env, name, args)); + } catch (err) { + const msg = err instanceof Error ? err.message : 'tool execution failed'; + return rpcResult(id, textResult(`${name}: ${msg}`, true)); + } + } + default: + return rpcError(id, -32601, `Method not found: ${method}`); + } +} + +export async function handleMcp( + req: Request, + env: Env, + cors: Record +): Promise { + const jsonHeaders = { 'content-type': 'application/json', ...cors }; + + if (req.method !== 'POST') { + return new Response(JSON.stringify({ error: 'POST JSON-RPC 2.0 messages to this endpoint' }), { + status: 405, + headers: { allow: 'POST,OPTIONS', ...jsonHeaders }, + }); + } + + let parsed: unknown; + try { + parsed = await req.json(); + } catch { + return new Response(JSON.stringify(rpcError(null, -32700, 'Parse error')), { + status: 400, + headers: jsonHeaders, + }); + } + + const messages = Array.isArray(parsed) ? (parsed as JsonRpcRequest[]) : [parsed as JsonRpcRequest]; + const responses = (await Promise.all(messages.map((m) => handleMessage(env, m)))).filter( + (r): r is Record => r !== null + ); + + // Pure notification batch → 202 with no body, per Streamable HTTP. + if (responses.length === 0) { + return new Response(null, { status: 202, headers: cors }); + } + + const body = Array.isArray(parsed) ? responses : responses[0]; + return new Response(JSON.stringify(body), { headers: jsonHeaders }); +} diff --git a/shamwari-docs-ai/src/worker.ts b/shamwari-docs-ai/src/worker.ts index c945557..d0ad5ee 100644 --- a/shamwari-docs-ai/src/worker.ts +++ b/shamwari-docs-ai/src/worker.ts @@ -1,15 +1,20 @@ // shamwari-docs-ai: Cloudflare Worker that proxies the docs Ask-AI tab to // Cloudflare AI Search. Routes: // POST /chat — emits SSE frames the docs-search client already understands +// POST /mcp — Model Context Protocol endpoint (docs.nyuchi.com/mcp) // GET /health — liveness probe // // Crawl, chunk, embed, retrieve, and generate are all handled by the bound // AI Search instance(s). This worker exists to (a) keep the API token // server-side, (b) add CORS, (c) route between per-corpus instances by the // `source` query field, and (d) translate the AI Search response into the -// SSE wire shape the docs-search client consumes. +// SSE wire shape the docs-search client consumes. The /mcp endpoint +// (src/mcp.ts) reuses the same AI Search binding for read tools and a +// FEEDBACK KV namespace (+ optional GITHUB_TOKEN) for write tools. -interface ChatMessage { +import { handleMcp } from './mcp.js'; + +export interface ChatMessage { role: 'user' | 'assistant' | 'system'; content: string; } @@ -19,7 +24,7 @@ interface ChatRequestBody { source?: 'nyuchi' | 'bundu'; } -interface AiSearchInstance { +export interface AiSearchInstance { search(options: { messages: ChatMessage[]; ai_search_options?: { @@ -55,11 +60,19 @@ interface AiSearchChunk { }; } +export interface FeedbackStore { + put(key: string, value: string): Promise; +} + export interface Env { NYUCHI_DOCS: AiSearchInstance; BUNDU_DOCS?: AiSearchInstance; TOP_K?: string; ALLOWED_ORIGINS?: string; + /** KV namespace for MCP feedback + queued issues. */ + FEEDBACK?: FeedbackStore; + /** Optional secret — when set, raise_issue files real GitHub issues. */ + GITHUB_TOKEN?: string; } const WILDCARD_PATTERNS = [/^https:\/\/[a-z0-9-]+\.vercel\.app$/i]; @@ -107,7 +120,7 @@ function splitFrontmatter(text: string): { meta: Record; body: s return { meta, body: match[2].trim() }; } -function normaliseCitations(chunks: AiSearchChunk[]): Array<{ +export function normaliseCitations(chunks: AiSearchChunk[]): Array<{ index: number; url: string; title: string; @@ -144,6 +157,11 @@ export default { return handleChat(req, env, cors); } + // MCP endpoint — docs.nyuchi.com/mcp routes here (see wrangler.toml) + if (url.pathname === '/mcp' || url.pathname === '/mcp/') { + return handleMcp(req, env, cors); + } + return new Response('Not found', { status: 404, headers: cors }); }, } satisfies ExportedHandler; diff --git a/shamwari-docs-ai/tests/mcp.test.ts b/shamwari-docs-ai/tests/mcp.test.ts new file mode 100644 index 0000000..aeeab60 --- /dev/null +++ b/shamwari-docs-ai/tests/mcp.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from 'vitest'; +import worker, { type Env } from '../src/worker.js'; + +function makeEnv(partial: Partial = {}): Env { + return { + NYUCHI_DOCS: { + search: vi.fn().mockResolvedValue({ chunks: [] }), + chatCompletions: vi.fn().mockResolvedValue({ choices: [] }), + } as never, + TOP_K: '5', + ALLOWED_ORIGINS: 'https://docs.nyuchi.com', + ...partial, + } as Env; +} + +function rpc(body: unknown): Request { + return new Request('https://worker/mcp', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /mcp', () => { + it('initialize returns protocol version, tools capability and server info', async () => { + const res = await worker.fetch(rpc({ jsonrpc: '2.0', id: 1, method: 'initialize' }), makeEnv()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { + protocolVersion: string; + capabilities: { tools: object }; + serverInfo: { name: string }; + }; + }; + expect(body.result.protocolVersion).toBe('2025-06-18'); + expect(body.result.capabilities.tools).toBeDefined(); + expect(body.result.serverInfo.name).toBe('nyuchi-docs'); + }); + + it('notifications/initialized returns 202 with no body', async () => { + const res = await worker.fetch( + rpc({ jsonrpc: '2.0', method: 'notifications/initialized' }), + makeEnv() + ); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + }); + + it('tools/list exposes the five docs tools', async () => { + const res = await worker.fetch(rpc({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), makeEnv()); + const body = (await res.json()) as { result: { tools: Array<{ name: string }> } }; + expect(body.result.tools.map((t) => t.name)).toEqual([ + 'search_docs', + 'ask_docs', + 'read_page', + 'submit_feedback', + 'raise_issue', + ]); + }); + + it('tools/call search_docs formats AI Search hits', async () => { + const env = makeEnv({ + NYUCHI_DOCS: { + search: vi.fn().mockResolvedValue({ + chunks: [ + { + text: '---\ntitle: Verification\n---\nThe four tiers…', + item: { key: 'k', attributes: { url: 'https://docs.nyuchi.com/kweli/verification/', title: 'Verification' } }, + }, + ], + }), + chatCompletions: vi.fn(), + } as never, + }); + const res = await worker.fetch( + rpc({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'search_docs', arguments: { query: 'verification tiers' } }, + }), + env + ); + const body = (await res.json()) as { result: { content: Array<{ text: string }> } }; + expect(body.result.content[0].text).toContain('Verification'); + expect(body.result.content[0].text).toContain('https://docs.nyuchi.com/kweli/verification/'); + }); + + it('tools/call submit_feedback writes to the FEEDBACK store', async () => { + const put = vi.fn().mockResolvedValue(undefined); + const env = makeEnv({ FEEDBACK: { put } }); + const res = await worker.fetch( + rpc({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'submit_feedback', + arguments: { message: 'The Kweli overview is missing the wallet phase.', kind: 'missing' }, + }, + }), + env + ); + const body = (await res.json()) as { result: { content: Array<{ text: string }>; isError?: boolean } }; + expect(body.result.isError).toBeUndefined(); + expect(put).toHaveBeenCalledOnce(); + expect(put.mock.calls[0][0]).toMatch(/^feedback:/); + expect(JSON.parse(put.mock.calls[0][1]).kind).toBe('missing'); + expect(body.result.content[0].text).toContain('Feedback recorded'); + }); + + it('tools/call raise_issue queues to KV when no GITHUB_TOKEN is configured', async () => { + const put = vi.fn().mockResolvedValue(undefined); + const env = makeEnv({ FEEDBACK: { put } }); + const res = await worker.fetch( + rpc({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'raise_issue', + arguments: { title: 'Broken example', body: 'The curl example 404s', page: '/platform/api/' }, + }, + }), + env + ); + const body = (await res.json()) as { result: { content: Array<{ text: string }> } }; + expect(put).toHaveBeenCalledOnce(); + expect(put.mock.calls[0][0]).toMatch(/^issue:/); + expect(body.result.content[0].text).toContain('queued'); + }); + + it('rejects non-POST with 405', async () => { + const res = await worker.fetch(new Request('https://worker/mcp'), makeEnv()); + expect(res.status).toBe(405); + }); + + it('unknown method returns JSON-RPC -32601', async () => { + const res = await worker.fetch(rpc({ jsonrpc: '2.0', id: 6, method: 'resources/list' }), makeEnv()); + const body = (await res.json()) as { error: { code: number } }; + expect(body.error.code).toBe(-32601); + }); +}); diff --git a/shamwari-docs-ai/wrangler.toml b/shamwari-docs-ai/wrangler.toml index daf9327..2d0d793 100644 --- a/shamwari-docs-ai/wrangler.toml +++ b/shamwari-docs-ai/wrangler.toml @@ -24,3 +24,17 @@ instance_name = "nyuchi-docs" [observability] enabled = true + +# Serve the MCP endpoint on the docs domain itself. A path route takes +# precedence over the site worker's custom domain for /mcp* only; the +# worker also stays reachable at shamwari-docs-ai.nyuchi.workers.dev/mcp. +routes = [ + { pattern = "docs.nyuchi.com/mcp*", zone_name = "nyuchi.com" } +] + +# MCP write tools: submit_feedback + queued raise_issue land here. +# Set the optional GITHUB_TOKEN secret (wrangler secret put GITHUB_TOKEN) +# to have raise_issue file real GitHub issues on nyuchi/nyuchi-docs. +[[kv_namespaces]] +binding = "FEEDBACK" +id = "9c5af0b28d5c4706840c492b2e3f47e8" diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 0e75f1d..ddd2b50 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -109,6 +109,7 @@ export default defineConfig({ 'integrations/quickstart', 'integrations/authentication', 'integrations/api-gateway', + 'integrations/docs-mcp', ], }, { @@ -158,6 +159,7 @@ export default defineConfig({ 'kweli/cross-app-verification', 'kweli/data-quality', 'kweli/design-system', + 'kweli/competitive-audit', ], }, { diff --git a/site/public/.well-known/mcp/server-card.json b/site/public/.well-known/mcp/server-card.json new file mode 100644 index 0000000..b347d64 --- /dev/null +++ b/site/public/.well-known/mcp/server-card.json @@ -0,0 +1,18 @@ +{ + "name": "nyuchi-docs", + "title": "Nyuchi Docs", + "description": "Read, search, and ask the Nyuchi engineering + product documentation, and send feedback or raise issues about it.", + "version": "1.0.0", + "endpoint": { + "type": "streamable-http", + "url": "https://docs.nyuchi.com/mcp" + }, + "capabilities": ["tools"], + "tools": ["search_docs", "ask_docs", "read_page", "submit_feedback", "raise_issue"], + "authentication": { "type": "none" }, + "documentation": "https://docs.nyuchi.com/integrations/docs-mcp/", + "provider": { + "name": "Nyuchi Africa", + "url": "https://www.nyuchi.com" + } +} diff --git a/site/public/llms.txt b/site/public/llms.txt index dc24c09..931fff9 100644 --- a/site/public/llms.txt +++ b/site/public/llms.txt @@ -48,3 +48,9 @@ - Bundu Foundation product docs: https://docs.bundu.org - Nyuchi Africa: https://www.nyuchi.com - Sitemap: https://docs.nyuchi.com/sitemap-index.xml + +## Agents + +- MCP endpoint (read docs + send feedback/raise issues): https://docs.nyuchi.com/mcp +- Server card: https://docs.nyuchi.com/.well-known/mcp/server-card.json +- How to connect: https://docs.nyuchi.com/integrations/docs-mcp/ diff --git a/site/src/content/docs/integrations/docs-mcp.mdx b/site/src/content/docs/integrations/docs-mcp.mdx new file mode 100644 index 0000000..d7669c6 --- /dev/null +++ b/site/src/content/docs/integrations/docs-mcp.mdx @@ -0,0 +1,87 @@ +--- +title: Docs MCP server +description: Read, search, and send feedback to the Nyuchi docs from any MCP-capable agent via docs.nyuchi.com/mcp. +--- + +The documentation site is itself an MCP server. Any Model Context +Protocol client (Claude, IDE agents, custom tooling) can connect to +**`https://docs.nyuchi.com/mcp`** (Streamable HTTP transport, JSON-RPC +2.0 over `POST`) to read these docs and — crucially — write back: +feedback, corrections, and issues flow straight to the docs team. + +The endpoint is served by the `shamwari-docs-ai` Cloudflare Worker (a +path route on the docs domain) and rides the same Cloudflare AI Search +index that powers the Ask-AI tab, so agents and humans read from one +corpus. A server card is published at +`/.well-known/mcp/server-card.json` for discovery. + +## Tools + +| Tool | Kind | What it does | +| --- | --- | --- | +| `search_docs` | read | Keyword/semantic search; returns titles, URLs and snippets | +| `ask_docs` | read | Synthesized answer grounded in the docs, with citations | +| `read_page` | read | Full readable text of any `docs.nyuchi.com` page | +| `submit_feedback` | write | Correction / confusing / missing / praise — stored for the docs team | +| `raise_issue` | write | Actionable problem; files a GitHub issue on `nyuchi/nyuchi-docs` when the `GITHUB_TOKEN` secret is configured, otherwise queued | + +Write tools are unauthenticated by design (feedback friction kills +feedback) but bounded: message sizes are capped, everything is tagged +`via: mcp`, and queued items land in the `shamwari-docs-feedback` KV +namespace for triage. + +## Connecting + +Stdio clients — via the published npm bridge +([`nyuchi-docs-mcp`](https://www.npmjs.com/package/nyuchi-docs-mcp)): + +```bash +claude mcp add nyuchi-docs -- npx nyuchi-docs-mcp +``` + +The server is listed on the official MCP registry as +**`io.github.nyuchi/nyuchi-docs`** (manifest: `server.json` at the +repo root), so registry-aware clients can install it by name. + +Claude (or any MCP client that supports Streamable HTTP) can skip the +package and connect directly: + +```json +{ + "mcpServers": { + "nyuchi-docs": { + "type": "http", + "url": "https://docs.nyuchi.com/mcp" + } + } +} +``` + +Or probe it by hand: + +```bash +curl -s https://docs.nyuchi.com/mcp \ + -H 'content-type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +## Operations + +- **Code:** `shamwari-docs-ai/src/mcp.ts` (tests in `tests/mcp.test.ts`); + the npm stdio bridge lives in `nyuchi-docs-mcp/`. +- **Releases:** publish a GitHub release (tag `mcp-vX.Y.Z`) — the + `publish-mcp.yml` workflow tests the bridge, publishes + `nyuchi-docs-mcp` to npm (`NPM_TOKEN` secret), and publishes + `server.json` to the MCP registry via GitHub OIDC. Keep the version + in `nyuchi-docs-mcp/package.json` and `server.json` in lockstep — + the workflow enforces it. +- **Routing:** `wrangler.toml` routes `docs.nyuchi.com/mcp*` to the + worker; the static docs site worker keeps every other path. The + worker is also reachable at + `shamwari-docs-ai.nyuchi.workers.dev/mcp`. +- **Feedback triage:** `wrangler kv key list --namespace-id 9c5af0b28d5c4706840c492b2e3f47e8` + (keys are `feedback::` and `issue::`). +- **GitHub issues:** set the optional secret once — + `wrangler secret put GITHUB_TOKEN` (needs `issues:write` on + `nyuchi/nyuchi-docs`) — and `raise_issue` files real issues labeled + `docs-feedback` + `via-mcp`. diff --git a/site/src/content/docs/kweli/competitive-audit.mdx b/site/src/content/docs/kweli/competitive-audit.mdx new file mode 100644 index 0000000..7af86e5 --- /dev/null +++ b/site/src/content/docs/kweli/competitive-audit.mdx @@ -0,0 +1,224 @@ +--- +title: Competitive audit +description: Mukoko Kweli vs Trustpilot, Google Business Profile, Yelp, Foursquare, TripAdvisor and the African directories — what we do well, what's missing, what's broken, and what to steal. +--- + +Full-site audit of `kweli.mukoko.com` against the platforms we are +actually competing with — Trustpilot, Google Business Profile (GBP) / +Maps, Yelp, Foursquare, TripAdvisor, the legacy African directories +(Brabys, Yellosa, Yellow Pages Kenya/SA), and Jiji's classifieds +encroachment. Four lenses: **doing well**, **missing**, **bugs**, and +**steal**. Audited July 2026 against the live codebase (branch +`claude/claude-md-docs-n16yru`) and current competitor feature sets. + +## The verdict in one paragraph + +Kweli's *strategic position* is genuinely unoccupied: no incumbent +offers one canonical, claimable, verifiable page for **every** entity +type (schools, NGOs, parks, government offices — not just businesses), +a verification ladder that works **without formal premises** (GBP's +video-default verification requires signage and a till), a trust badge +that **travels across an ecosystem**, African-language locales, or +agent discoverability for African entities. But the *execution* today +is far behind the position: the review loop is a disabled button, the +browse hierarchy (city × category pages — the incumbents' entire SEO +engine) doesn't exist, owners can't edit their venues, and the +verification product has no visible output because no entity carries a +tier yet. The moat is real; the castle is scaffolding. + +## What we're doing well + +- **Any-entity canon.** One canonical page per place — park, school, + NGO, tech company — backed by the knowledge graph's + single-source-of-truth rule. Foursquare proved POI canonicalization + is the durable asset; GBP still fights duplicate listings; nobody + covers non-commercial entities. This is the core differentiator. +- **A verification model built for African reality.** The four-tier + ladder (community/Terracotta → OTP/Cobalt → government/Gold → + licensed/Tanzanite) with community vouching at tier 1 is the only + model in the set that meets informal commerce (market stalls, + home-based services) where it is. +- **Portable trust.** The kweli travels with the entity across the + Mukoko ecosystem (nhimbe events, news, marketplace). Every + incumbent's badge is confined to its own surface. +- **Agent readiness.** OAuth discovery (RFC 9728/8414), `/auth.md`, + WebMCP tools. Only Yelp has anything comparable (its open-source MCP + server) — and Yelp has no African data. +- **The technical spine.** Server-component-first rendering (cheap on + metered data), resilience/observability/rate-limiting primitives, + architecture rules enforced by tests, Mzizi design-system contracts, + real health probes, inline maps on every located venue. +- **Sound trust posture by construction.** Reads from a shared graph, + writes through a platform API, verification decoupled from payment — + the structural opposite of the "extortion" (Trustpilot/Yelp + narratives) and "pay-to-rank" (TripAdvisor placements, directory + pay-to-list) reputations that dog this industry. + +## The scoreboard + +Condensed matrix (● strong ◐ partial ○ weak/none). "AfrDir" = legacy +African directories composite. + +| Capability | GBP | Yelp | Trustpilot | TripAdvisor | AfrDir | Jiji | **Kweli today** | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Search + filters (city/category/open-now/price) | ● | ● | ○ | ● | ◐ | ● | ◐ (search, tags, city, nearest) | +| City × category landing pages (SEO engine) | — | ●● | ◐ | ●● | ◐ | ● | **○ none** | +| Profile anatomy (photos/hours/menu/Q&A/posts/booking) | ● | ● | ○ | ● | ○ | ◐ | ◐ (thin: no hours/phone render, no Q&A/booking) | +| Reviews written by users | ● | ● | ● | ● | ○ | ◐ | **○ disabled button** | +| Fake-review defense (published program) | ◐ | ● | ● | ●● | ○ | ○ | ○ (designed, not live) | +| Owner claim → verify | ◐ video-default | ◐ | ◐ domain | ◐ | pay≈verified | phone | ● 4-tier (no visible output yet) | +| Badge portable across ecosystem | ○ | ○ | ○ | ○ | ○ | ○ | **●● unique** | +| Non-business entities | ◐ pins only | ○ | ○ | ◐ tourism | ○ | ○ | **●● unique** | +| Owner dashboard + analytics | ● | ● | ● | ● | ○ | ● | ◐ (unreachable: roles empty) | +| Embeddable badge/review widget | ◐ | ◐ | ●● TrustBox | ● | ○ | ○ | ○ | +| Agent/MCP surface | ◐ Gemini-gated | ●● open MCP | ○ | ○ | ○ | ○ | ● WebMCP + discovery | +| African coverage/languages | ◐ / ○ | ○ | ○ | ◐ tourism | ◐ stale | ● / ○ | ● graph / ◐ (sn/nd routes serve English) | + +## What's missing (table stakes, ranked by impact) + +1. **Working reviews.** The "Vibe Check" submit button is permanently + disabled and review bodies are mapped to `null` — a review platform + where nobody can review. The platform's E2E block was lifted; the + app never caught up. +2. **The browse hierarchy.** `/en/zw/harare/restaurants` does not + exist. No country, region, or category pages at all — this is both + the #1 discovery gap and the #1 SEO gap (city×category pages are + how Yelp/TripAdvisor own the SERPs). The route plan, breadcrumb + builders, and taxonomy service all exist as dead code. +3. **Owner venue editing.** `PATCH /api/venues` exists, but the only + edit UI is a card telling owners to visit the abandoned + `business.mukoko.com`. Hours and phone can't render anyway (bugs + below). +4. **A working new-user funnel.** With `ALLOW_PERSON_AUTOPROVISION=false` + (the default), a fresh WorkOS sign-up loops forever between + dashboard and login; the settings page shows no email/name. +5. **Visible verification.** No entity carries `bundu.verificationTier` + yet, so the badge — the product — renders nowhere. Backfilling even + tier-1 for claimed/vouched entities would make the kweli visible. +6. **Real i18n.** `sn`/`nd` routes serve English; message files are + orphaned; 40+ links hardcode `/en`; no language switcher renders. + The African-languages differentiator is currently a URL prefix. +7. **Retention primitives.** No saved lists, no follow (subscribe is a + "coming soon" orphan), no notifications, no user profile. Nothing + brings a visitor back. +8. **Q&A and attributes.** GBP's accessibility/identity attributes + (wheelchair access, women-owned) and community Q&A are cheap, + high-trust profile modules nobody offers for Africa. +9. **Photos at scale.** Most OSM-seeded venues have no imagery; + upload exists but there's no contribution prompt or mission loop. +10. **An embeddable kweli badge.** Trustpilot's TrustBox is its whole + growth engine: businesses embed the widget, advertising the + platform on their own sites and feeding SERP stars. + +## Bugs (broken today, verified in code) + +**Severity 1 — user-facing, every venue:** +- JSON-LD `url`/`@id` omits the locale segment → every venue's + structured-data URL 404s (`lib/schema/bar.schema.ts:6`). +- Telephone never renders: `getVenueById` hardcodes + `organization: null, local_business: null` while the page reads only + those; the doc's real `telephone` is dropped. The call action is + dead for all 1,567 venues. +- Opening hours never render: the mapper hardcodes + `opening_hours_text: null` even though the seed writes OSM hours. +- Sync buttons in the mobile nav and footer are shown to **every + visitor** but require admin → guaranteed "Sync failed" toast; the + admin Google-sync page 410s on every click. + +**Severity 2 — funnel and locale:** +- New-user auth redirect loop (autoprovision default) — see above. +- Hardcoded `/en` in 40+ links ejects `sn`/`nd` users from their + locale; `BarCard` links `/en/bars/…` from every locale. +- WebMCP `open_venue` navigates by slug but the route only resolves by + UUID → agent deep-links 404. There is no slug route at all. +- `/verify?entity=` filters `isActive: true` strictly, so places + missing the flag 404 on cross-app deep links. + +**Severity 3 — trust and polish:** +- SearchAction JSON-LD advertises `?q=` which the home page ignores. +- Breadcrumbs violate the no-Home doctrine and emit degenerate + JSON-LD (three items pointing at the home URL). +- Default schema type `BarOrRestaurant` is not a valid schema.org type. +- The venue page `Promise.all`s side-collection reads without + per-section catches — one engagement-DB hiccup 500s the whole page. +- Legacy `mukoko_token` fallback decodes an **unverified** JWT and + trusts its `sub` as a person UUID — an auth-bypass seam; the cookie + path should be deleted, not kept as fallback. +- Help page documents features that don't exist ("Write a Review" + button); 404 page uses a beer icon; sitemap lists `sn`/`nd` + duplicates that canonicalize to `/en`; no `loading.tsx` anywhere. + +## What to steal (per competitor) + +- **Trustpilot →** the TrustBox growth loop (build an embeddable + kweli-badge widget), Bayesian time-decayed TrustScore so 3 reviews ≠ + a 5.0, and publishing fake-review removal stats as marketing. +- **GBP →** the attribute system (wheelchair access, women-owned — + map onto place sub-objects), free-forever owner tier as + distribution, "What's Happening" module (our blasts/events), Local + Guides points/levels as the blueprint for ubuntu missions, and + video-evidence UX ideas for tier-2/3 verification. +- **Yelp →** the agent stack: an open **`/mcp` endpoint for + kweli.mukoko.com** modeled on `yelp-mcp` (NL request in, NL answer + + structured venue data out), aspect-level AI review insights, and + response-quality badges that reward owner engagement, not payment. +- **TripAdvisor →** the annual public **Transparency Report** (fraud + blocked, reviews removed) — a perfect ritual for a product named + "truth" — plus per-dimension sub-ratings (our `engagement.ratings` + is already per-dimension) and community Q&A. +- **Foursquare →** FSQ Open Source Places (100M POIs, Apache 2.0) as a + conflation/seed source for African coverage beyond OSM, and check-in + gamification mechanics. +- **Jiji →** the liquidity playbook: free listing, phone-number-light + onboarding, WhatsApp-adjacent chat behavior, boosts that never gate + baseline visibility. +- **African directories →** their surviving revenue insight: SMEs pay + for *presence services* (site, WhatsApp setup, SEO). An honest "get + verified + agent-discoverable" service tier monetizes the same + demand without pay-to-rank. + +## The Africa whitespace (nobody serves this) + +1. Claimable, verifiable pages for the non-commercial entity universe + (schools, clinics, NGOs, churches, government). +2. Verification without formal premises — community vouching laddering + to OTP/government/licensed. +3. Trust that ports across surfaces (the kweli). +4. Content-level local languages (sn/nd now; Swahili is the obvious + 4th locale for KE/TZ). +5. Low-data delivery: SSR-first, light pages, offline tolerance, + USSD/SMS fallback for OTP verification. +6. **WhatsApp-first workflows** — click-to-WhatsApp as a first-class + profile action, claim/verify OTP over WhatsApp, review collection + via WhatsApp links. GBP *removed* messaging in 2024; the seat is + empty. +7. **Mobile-money attributes** — "accepts EcoCash / M-Pesa / MoMo" as + structured data (`commerce.paymentAccepted`), later deposits via + the wallet schema. +8. Agent discoverability for African entities — as assistants become + the front door, African venues are invisible to them today. +9. Documented honest monetization: "verification ≠ payment, ranking ≠ + payment," backed by a transparency report. + +## Priority order + +**P0 — make the core loop real:** review submission + plaintext +bodies; fix telephone/hours/JSON-LD-URL mappers; kill the +visitor-facing sync buttons and the 410 sync page; fix the +autoprovision funnel loop. + +**P1 — build the engine:** the `[country]/[region]/[category]/[slug]` +browse hierarchy with slug routes and 301s from `/bars/[id]`; owner +venue editing on the existing PATCH API; backfill tier-1 verification +so badges render; real i18n (locale-aware links + rendered switcher). + +**P2 — compound the moat:** WhatsApp CTA + mobile-money attributes; +embeddable kweli badge; Q&A + attributes; `kweli.mukoko.com/mcp` +modeled on this docs site's endpoint; saved lists/follows; transparency +report; FSQ OS Places conflation for coverage beyond Zimbabwe. + +**Biggest external threats:** Google Maps' Gemini layer passively +improving African coverage, and Jiji/WhatsApp absorbing the "just find +me a business" job informally. Both are beatable only by owning what +they structurally can't: verified identity for every entity type, in +local languages, portable across an ecosystem.