Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
# Windows (core.autocrlf=true, the Git for Windows default) would put a `\r` at
# the end of every line, which bash reports as a command not found.
*.sh text eol=lf

# Golden outputs are compared byte for byte.
tests/fixtures/mcp/golden/*.json text eol=lf
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
#
# Jobs:
# typecheck — astro check (TypeScript / Astro type errors)
# lint — MCP server: tsc, ESLint, Prettier (npm run lint)
# lint — MCP server: tsc, ESLint, Prettier (make lint)
# build — headless build from the vendored fixture; uploads dist/
# e2e — Playwright: embedded web-fragment harness + standalone layer
# image — docker build + container integration tests against real nginx + Trivy scan
Expand Down Expand Up @@ -60,7 +60,7 @@ jobs:
node-version: '24'
cache: npm
- run: npm ci
- run: npm run lint
- run: make lint

# ── 2. Build (headless, hermetic) ──────────────────────────────────────────
build:
Expand Down
100 changes: 100 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Front door for the common tasks. The npm scripts stay the source of truth; every
# target here delegates to one, so CI and `npm run` keep working without make.
#
# Windows: make is not part of Git for Windows. Install it (`winget install
# ezwinports.make`) and run it from Git Bash. Recipes are POSIX sh.

SHELL := /bin/sh
.DEFAULT_GOAL := help

SEARCH_INDEX := dist/_mcp/search-index.json
IMAGE ?= knowledge-base
PORT ?= 8080
# `make prod HEADLESS=true` builds web-fragment output; KB_REGISTRY in the
# environment points the build at a deployment repo's registry.
HEADLESS ?= false

.PHONY: help install dev dev-headless mcp-dev mcp-stdio build build-local build-headless \
fixture-build typecheck lint format test test-mcp test-standalone test-container \
size image run prod clean

help: ## List targets
@awk 'BEGIN { FS = ":.*## " } /^[a-zA-Z_-]+:.*## / { printf " \033[1m%-16s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST)

install: ## Install dependencies from the lockfiles (root + actions/)
npm ci
npm ci --prefix actions

# ── Develop ────────────────────────────────────────────────────────────────

dev: ## Astro dev server, standalone (needs artifacts in apps/)
npm run dev

dev-headless: ## Astro dev server, web-fragment mode
npm run dev:headless

mcp-dev: $(SEARCH_INDEX) ## MCP over HTTP from source on :8081, restarting on change
npm run mcp:dev

mcp-stdio: $(SEARCH_INDEX) ## MCP over stdio from source
npm run mcp:stdio

# ── Build ──────────────────────────────────────────────────────────────────

build: ## Fetch Release artifacts and build (needs GITHUB_TOKEN or gh auth)
npm run build

build-local: ## Build from local repos, no GitHub fetch
npm run build:local

build-headless: ## Headless build from the current registry
npm run build:headless

fixture-build: ## Hermetic headless build from the vendored test fixtures
node scripts/setup-test-apps.mjs
npm run build:headless

$(SEARCH_INDEX):
$(MAKE) fixture-build

# ── Quality ────────────────────────────────────────────────────────────────

typecheck: ## Type-check the MCP server
npm run typecheck

lint: ## Type-check, lint and check formatting
npm run lint

format: ## Apply formatting and autofixable lint fixes
npm run format

test: ## Full Playwright suite (builds the fixture itself)
npm test

test-mcp: $(SEARCH_INDEX) ## MCP suites only, no browser harness (fast loop)
npm run test:mcp

test-standalone: ## Fragment server suite
npx playwright test --config=playwright.config.ci.js

test-container: ## Real nginx + Node image (needs Docker)
npm run test:container

size: $(SEARCH_INDEX) ## Check the MCP server bundle against its size budget
npm run test:mcp -- --grep "bundle budget"

# ── Ship ───────────────────────────────────────────────────────────────────

image: ## Build the production image from dist/
docker build -t $(IMAGE) .

run: ## Serve the image on localhost (PORT=8080)
docker run --rm -p $(PORT):8080 $(IMAGE)

prod: clean install ## Clean, install, strict production build, size check, image
KB_STRICT=true node scripts/build-vite.js $(if $(filter true,$(HEADLESS)),--headless)
$(MAKE) size
$(MAKE) image

clean: ## Remove build output and test reports
rm -rf dist apps public tmp test-results playwright-report .astro
46 changes: 46 additions & 0 deletions mcp/build/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Bundles the server into one minified, dependency-free ESM file for the image.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
import type { Metafile, Plugin } from 'esbuild';

const MCP_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');

/**
* zod (classic API and core) re-exports every locale as `z.locales`, and the namespace import keeps
* all of them: 258 KB minified, for error messages the server only ever renders in English.
*/
const englishOnlyZodLocales: Plugin = {
name: 'zod-english-only',
setup(context) {
context.onResolve({ filter: /^\.\.\/locales\/index\.js$/ }, (args) =>
/[\\/]zod[\\/]v4[\\/](?:classic|core)[\\/]/.test(args.importer)
? { path: join(dirname(args.importer), '..', 'locales', 'index.js'), namespace: 'zod-locales' }
: undefined,
);
context.onLoad({ filter: /.*/, namespace: 'zod-locales' }, (args) => ({
contents: `export { default as en } from ${JSON.stringify(join(dirname(args.path), 'en.js'))};`,
loader: 'js',
resolveDir: dirname(args.path),
}));
},
};

/** Build `mcp/src/main.ts` into `outfile`; returns the esbuild metafile. */
export async function bundleServer(outfile: string): Promise<Metafile> {
const result = await build({
entryPoints: [join(MCP_ROOT, 'src', 'main.ts')],
outfile,
bundle: true,
packages: 'bundle',
platform: 'node',
format: 'esm',
target: 'node24',
minify: true,
legalComments: 'none',
metafile: true,
alias: { '@modelcontextprotocol/server/_shims': join(MCP_ROOT, 'src', 'sdk-shims.ts') },
plugins: [englishOnlyZodLocales],
});
return result.metafile;
}
81 changes: 81 additions & 0 deletions mcp/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Command line and environment → one validated, frozen Config. Nothing else in the
// server reads process.argv or process.env.
import { parseArgs } from 'node:util';
import { resolve } from 'node:path';

export type Backend = 'memory';
export const BACKENDS: readonly Backend[] = ['memory'];

export interface Config {
mode: 'http' | 'stdio';
backend: Backend;
indexPath: string;
host: string;
port: number;
/** `https://host` the site is served from, for absolute links. */
publicOrigin: string | undefined;
allowedOrigins: string[];
trustedProxies: number;
}

type Env = Record<string, string | undefined>;

function parsePublicOrigin(value: string | undefined): string | undefined {
if (!value) return undefined;
const url = new URL(value);
if (!/^https?:$/.test(url.protocol) || url.pathname !== '/' || url.search || url.hash) {
throw new Error('KB_MCP_PUBLIC_ORIGIN must be an http(s) origin without a path');
}
return value;
}

function parseInteger(name: string, value: string, { min, max }: { min: number; max: number }): number {
const number = Number(value);
if (!Number.isInteger(number) || number < min || number > max) {
throw new Error(`${name} must be an integer from ${min} to ${max}`);
}
return number;
}

function parseBackend(value: string): Backend {
const backend = BACKENDS.find((candidate) => candidate === value);
if (!backend) throw new Error(`KB_MCP_BACKEND must be one of: ${BACKENDS.join(', ')}`);
return backend;
}

const list = (value: string | undefined): string[] =>
(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);

/**
* Flags win over environment variables. `defaultIndex` is where the build puts the search index
* relative to the server bundle.
*/
export function loadConfig(argv: string[], env: Env, defaultIndex: string): Readonly<Config> {
const { values } = parseArgs({
args: argv,
options: {
stdio: { type: 'boolean', default: false },
http: { type: 'boolean', default: false },
index: { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
},
strict: true,
});
return Object.freeze({
mode: values.stdio ? 'stdio' : 'http',
backend: parseBackend(env.KB_MCP_BACKEND ?? 'memory'),
indexPath: resolve(values.index ?? env.KB_MCP_SEARCH_INDEX ?? defaultIndex),
host: values.host ?? env.KB_MCP_HOST ?? '127.0.0.1',
port: parseInteger('KB_MCP_PORT', values.port ?? env.KB_MCP_PORT ?? '8081', { min: 0, max: 65_535 }),
publicOrigin: parsePublicOrigin(env.KB_MCP_PUBLIC_ORIGIN),
allowedOrigins: list(env.KB_MCP_ALLOWED_ORIGINS),
trustedProxies: parseInteger('KB_MCP_TRUSTED_PROXIES', env.KB_MCP_TRUSTED_PROXIES ?? '1', {
min: 0,
max: 16,
}),
});
}
30 changes: 30 additions & 0 deletions mcp/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Entry point: `node mcp/src/main.ts [--http|--stdio] [--index file] [--host h] [--port p]`.
// Bundled to dist/_mcp/server.mjs, which finds search-index.json beside itself by default.
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { loadSearchIndex } from './adapters/memory/search-index-repository.ts';
import { memoryKnowledgeBase, versionOf } from './compose.ts';
import { loadConfig } from './config.ts';
import { createServerFactory } from './protocol/server.ts';
import { createMcpHttpHandler } from './transport/http.ts';
import { createListener } from './transport/listener.ts';

const here = dirname(fileURLToPath(import.meta.url));
const config = loadConfig(process.argv.slice(2), process.env, join(here, 'search-index.json'));

const searchIndex = await loadSearchIndex(config.indexPath);
const kb = memoryKnowledgeBase(searchIndex, { publicOrigin: config.publicOrigin });
const createServer = createServerFactory(kb, { version: versionOf(searchIndex) });

if (config.mode === 'stdio') {
await createServer().connect(new StdioServerTransport());
} else {
const handler = createMcpHttpHandler(createServer, {
allowedOrigins: config.allowedOrigins,
trustedProxies: config.trustedProxies,
});
createListener(handler).listen(config.port, config.host, () => {
process.stderr.write(`kb-mcp listening http://${config.host}:${config.port}/mcp\n`);
});
}
68 changes: 68 additions & 0 deletions mcp/src/protocol/resources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Every document as a `kb://` resource: a cursor-paginated list and bounded reads.
import type { McpServer } from '@modelcontextprotocol/server';
import type { StoredDocument } from '../domain.ts';
import type { KnowledgeBase } from '../knowledge-base.ts';

/** `_meta` namespace for the provenance attached to each resource. */
const META_KEY = 'io.github.absaoss/knowledge-base';
/** JSON-RPC error code the MCP spec assigns to an unknown resource. */
const RESOURCE_NOT_FOUND = -32002;
/** Characters a resources/read returns, header included; read_document pages beyond it. */
const MAX_RESOURCE_CHARS = 100_000;

export interface ResourceOptions {
pageSize: number;
}

const nameFor = (document: StoredDocument) => document.uri.slice('kb://'.length);

/** Title, app, link and version ahead of the text, so a bare read is still citable. */
function readableText(kb: KnowledgeBase, document: StoredDocument): string {
const header =
`Title: ${document.title}\n` +
`App: ${kb.appName(document)} (${document.app})\n` +
`Link: ${kb.link(document)}\n` +
`Source version: ${String(document.sourceVersion)}\n---\n`;
// read_document offsets count document characters only, so the header comes off the cut.
const shown = Math.max(0, MAX_RESOURCE_CHARS - header.length);
if (document.text.length <= shown) return header + document.text;
return (
`${header}${document.text.slice(0, shown)}\n\n` +
`[truncated: ${document.text.length} document chars; continue with read_document {uri, offset: ${shown}}]`
);
}

export function registerResources(server: McpServer, kb: KnowledgeBase, { pageSize }: ResourceOptions): void {
server.server.setRequestHandler('resources/list', async (request) => {
const page = await kb.browse(request.params?.cursor, pageSize);
const resources = page.items.map((document) => ({
uri: document.uri,
name: nameFor(document),
title: document.title,
description: document.description,
mimeType: document.mimeType,
size: document.size,
annotations: { audience: ['user' as const, 'assistant' as const] },
_meta: { [META_KEY]: kb.metadata(document) },
}));
return { resources, ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}) };
});

server.server.setRequestHandler('resources/templates/list', async () => ({ resourceTemplates: [] }));

server.server.setRequestHandler('resources/read', async (request) => {
const { uri } = request.params;
const document = await kb.get(uri);
if (!document) throw Object.assign(new Error('Resource not found'), { code: RESOURCE_NOT_FOUND, data: { uri } });
return {
contents: [
{
uri: document.uri,
mimeType: 'text/markdown',
text: readableText(kb, document),
_meta: { [META_KEY]: kb.metadata(document) },
},
],
};
});
}
Loading
Loading