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
4 changes: 3 additions & 1 deletion nyuchi-docs-mcp-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"deploy": "wrangler deploy",
"test": "vitest run"
},
"dependencies": {},
"dependencies": {
"jose": "^5.9.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260728.1",
"typescript": "^6.0.3",
Expand Down
67 changes: 67 additions & 0 deletions nyuchi-docs-mcp-worker/src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Verifies a caller's own WorkOS-issued bearer token (Authorization:
// Bearer <token> on the incoming MCP JSON-RPC request) so this worker
// knows *who is asking* before deciding whether to surface
// `visibility: internal` content (see nyuchi-docs's
// site/src/content.config.ts and scripts/generate-internal-paths.mjs).
//
// Deliberately checks only signature + issuer + expiry, not audience —
// any token from the Nyuchi Identity WorkOS project proves identity
// regardless of which app requested it, which is what "is this a real
// Nyuchi person" needs here. Contrast with agentgateway's OIDC policy,
// which *does* pin a specific client_id because it's terminating a
// browser login for one particular app, not accepting bearer tokens
// minted for arbitrary Nyuchi-internal clients.

import { createRemoteJWKSet, jwtVerify } from 'jose';

let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;
let jwksIssuer: string | undefined;

export async function verifyBearerAuth(
req: Request,
issuer: string | undefined
): Promise<{ authorized: boolean; subject?: string }> {
if (!issuer) return { authorized: false };
const header = req.headers.get('authorization') ?? '';
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return { authorized: false };

try {
if (!jwks || jwksIssuer !== issuer) {
jwks = createRemoteJWKSet(new URL(`${issuer}/oauth2/jwks`));
jwksIssuer = issuer;
}
const { payload } = await jwtVerify(match[1], jwks, { issuer });
return { authorized: true, subject: typeof payload.sub === 'string' ? payload.sub : undefined };
} catch {
return { authorized: false };
}
}

// The manifest is generated at nyuchi-docs build time (see
// scripts/generate-internal-paths.mjs) and served statically at
// /internal-paths.json — this worker is a separate deploy from that
// site, so it reads the list over HTTP rather than importing it, with a
// short in-isolate cache since it rarely changes.
let cachedPaths: readonly string[] | undefined;
let cachedAt = 0;
const CACHE_TTL_MS = 5 * 60 * 1000;

export async function getInternalPaths(docsOrigin: string): Promise<readonly string[]> {
if (cachedPaths && Date.now() - cachedAt < CACHE_TTL_MS) return cachedPaths;
try {
const res = await fetch(`${docsOrigin}/internal-paths.json`);
if (!res.ok) return cachedPaths ?? [];
const data = (await res.json()) as { internalPaths?: string[] };
cachedPaths = data.internalPaths ?? [];
cachedAt = Date.now();
return cachedPaths;
} catch {
return cachedPaths ?? [];
}
}

export function isInternalPath(paths: readonly string[], pathname: string): boolean {
const normalised = pathname.endsWith('/') ? pathname : `${pathname}/`;
return paths.some((p) => normalised === p || normalised.startsWith(p));
}
93 changes: 77 additions & 16 deletions nyuchi-docs-mcp-worker/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
type ChatMessage,
type Env,
} from './worker.js';
import { getInternalPaths, isInternalPath } from './auth.js';

export interface CallerAuth {
authorized: boolean;
subject?: string;
}

// MCP spec revisions this server speaks, newest first. Initialize
// negotiates: a supported requested version is echoed back; anything
Expand Down Expand Up @@ -147,19 +153,48 @@ async function callSearch(env: Env, query: string, topK: number) {
return normaliseCitations(res.chunks ?? []);
}

async function toolSearchDocs(env: Env, params: Record<string, unknown>): Promise<ToolResult> {
// Best-effort: AI Search's index is built by crawling the public site, and
// the crawler gets the same OIDC gate any other unauthenticated visitor
// does, so an internal page landing in this index at all would already be
// a bug elsewhere. This filter is the belt-and-suspenders backstop, not
// the actual access-control boundary — don't rely on it alone.
async function filterCitations<T extends { url: string }>(
citations: T[],
auth: CallerAuth
): Promise<T[]> {
if (auth.authorized) return citations;
const internalPaths = await getInternalPaths(DOCS_ORIGIN);
if (internalPaths.length === 0) return citations;
return citations.filter((c) => {
try {
return !isInternalPath(internalPaths, new URL(c.url, DOCS_ORIGIN).pathname);
} catch {
return true;
}
});
}

async function toolSearchDocs(
env: Env,
params: Record<string, unknown>,
auth: CallerAuth
): Promise<ToolResult> {
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);
const hits = await filterCitations(await callSearch(env, query, topK), auth);
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<string, unknown>): Promise<ToolResult> {
async function toolAskDocs(
env: Env,
params: Record<string, unknown>,
auth: CallerAuth
): Promise<ToolResult> {
const question = str(params, 'question');
if (!question) return textResult('ask_docs: question is required', true);
const messages: ChatMessage[] = [{ role: 'user', content: question }];
Expand All @@ -173,7 +208,7 @@ async function toolAskDocs(env: Env, params: Record<string, unknown>): Promise<T
instance.chatCompletions(opts),
]);
const answer = chatRes.choices?.[0]?.message?.content ?? '';
const sources = normaliseCitations(searchRes.chunks ?? [])
const sources = (await filterCitations(normaliseCitations(searchRes.chunks ?? []), auth))
.map((c) => `[${c.index}] ${c.title} — ${c.url}`)
.join('\n');
if (!answer) return textResult('The docs assistant returned no answer for that question.', true);
Expand Down Expand Up @@ -214,14 +249,30 @@ function htmlToText(html: string): string {
.trim();
}

async function toolReadPage(params: Record<string, unknown>): Promise<ToolResult> {
async function toolReadPage(
env: Env,
params: Record<string, unknown>,
auth: CallerAuth
): Promise<ToolResult> {
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' },
});

const internalPaths = await getInternalPaths(DOCS_ORIGIN);
const internal = isInternalPath(internalPaths, url.pathname);
if (internal && !auth.authorized) {
return textResult(
`read_page: ${url.pathname} is internal-only. Provide a valid Authorization: Bearer <WorkOS token> to read it.`,
true
);
}

const headers: Record<string, string> = { 'user-agent': 'nyuchi-docs-mcp/1.0' };
if (internal && auth.authorized && env.INTERNAL_FETCH_KEY) {
headers['x-internal-fetch-key'] = env.INTERNAL_FETCH_KEY;
}
const res = await fetch(url.toString(), { headers });
if (!res.ok) return textResult(`read_page: ${url.pathname} responded ${res.status}`, true);
const text = htmlToText(await res.text());
const clipped =
Expand Down Expand Up @@ -290,14 +341,19 @@ async function toolRaiseIssue(env: Env, params: Record<string, unknown>): Promis
return textResult(`Issue queued for the docs team (ref ${key}).`);
}

async function callTool(env: Env, name: string, args: Record<string, unknown>): Promise<ToolResult> {
async function callTool(
env: Env,
name: string,
args: Record<string, unknown>,
auth: CallerAuth
): Promise<ToolResult> {
switch (name) {
case 'search_docs':
return toolSearchDocs(env, args);
return toolSearchDocs(env, args, auth);
case 'ask_docs':
return toolAskDocs(env, args);
return toolAskDocs(env, args, auth);
case 'read_page':
return toolReadPage(args);
return toolReadPage(env, args, auth);
case 'submit_feedback':
return toolSubmitFeedback(env, args);
case 'raise_issue':
Expand All @@ -307,7 +363,11 @@ async function callTool(env: Env, name: string, args: Record<string, unknown>):
}
}

async function handleMessage(env: Env, msg: JsonRpcRequest): Promise<unknown | null> {
async function handleMessage(
env: Env,
msg: JsonRpcRequest,
auth: CallerAuth
): Promise<unknown | null> {
const { id, method, params = {} } = msg;

// Notifications (no id) get no response body.
Expand All @@ -333,7 +393,7 @@ async function handleMessage(env: Env, msg: JsonRpcRequest): Promise<unknown | n
const name = typeof params.name === 'string' ? params.name : '';
const args = (params.arguments ?? {}) as Record<string, unknown>;
try {
return rpcResult(id, await callTool(env, name, args));
return rpcResult(id, await callTool(env, name, args, auth));
} catch (err) {
const msg = err instanceof Error ? err.message : 'tool execution failed';
return rpcResult(id, textResult(`${name}: ${msg}`, true));
Expand All @@ -347,7 +407,8 @@ async function handleMessage(env: Env, msg: JsonRpcRequest): Promise<unknown | n
export async function handleMcp(
req: Request,
env: Env,
cors: Record<string, string>
cors: Record<string, string>,
auth: CallerAuth
): Promise<Response> {
const jsonHeaders = { 'content-type': 'application/json', ...cors };

Expand All @@ -369,7 +430,7 @@ export async function handleMcp(
}

const messages = Array.isArray(parsed) ? (parsed as JsonRpcRequest[]) : [parsed as JsonRpcRequest];
const responses = (await Promise.all(messages.map((m) => handleMessage(env, m)))).filter(
const responses = (await Promise.all(messages.map((m) => handleMessage(env, m, auth)))).filter(
(r): r is Record<string, unknown> => r !== null
);

Expand Down
8 changes: 7 additions & 1 deletion nyuchi-docs-mcp-worker/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// file real GitHub issues on nyuchi/nyuchi-docs).

import { handleMcp } from './mcp.js';
import { verifyBearerAuth } from './auth.js';

export interface ChatMessage {
role: 'user' | 'assistant' | 'system';
Expand Down Expand Up @@ -68,6 +69,10 @@ export interface Env {
FEEDBACK?: FeedbackStore;
/** Optional secret — when set, raise_issue files real GitHub issues. */
GITHUB_TOKEN?: string;
/** WorkOS issuer used to verify a caller's own bearer token (see src/auth.ts). Unset = every caller is treated as unauthenticated (public-only). */
WORKOS_ISSUER?: string;
/** Shared with nyuchi-docs's site worker — sent on internal-page fetches once a caller is verified, so the read skips the browser OIDC flow. */
INTERNAL_FETCH_KEY?: string;
}

const WILDCARD_PATTERNS = [/^https:\/\/[a-z0-9-]+\.vercel\.app$/i];
Expand Down Expand Up @@ -139,7 +144,8 @@ export default {
}

if (url.pathname === '/mcp' || url.pathname === '/mcp/') {
return handleMcp(req, env, cors);
const auth = await verifyBearerAuth(req, env.WORKOS_ISSUER);
return handleMcp(req, env, cors, auth);
}

return new Response('Not found', { status: 404, headers: cors });
Expand Down
14 changes: 14 additions & 0 deletions nyuchi-docs-mcp-worker/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ routes = [
[vars]
TOP_K = "5"
ALLOWED_ORIGINS = "https://docs.nyuchi.com,https://docs.bundu.org"
# The "Nyuchi Docs" WorkOS Connect OAuth Application's issuer — used only
# to verify a caller's own bearer token (src/auth.ts), never to originate
# a login flow itself. Not a secret: an OIDC issuer URL is public by
# design (it's how discovery/JWKS work), but keep it in sync with the
# same value the site worker uses.
WORKOS_ISSUER = "https://identity.nyuchi.com"

# Same AI Search instance the Ask-AI tab uses — read tools ride the
# existing nyuchi-docs corpus; this worker adds no ingestion of its own.
Expand All @@ -31,3 +37,11 @@ enabled = true
[[kv_namespaces]]
binding = "FEEDBACK"
id = "9c5af0b28d5c4706840c492b2e3f47e8"

# Secret (wrangler secret put INTERNAL_FETCH_KEY): shared with
# nyuchi-docs's site worker. Once this worker has verified a caller's own
# bearer token (WORKOS_ISSUER above), it sends this key on the internal
# fetch for read_page so that already-authorized read skips the site's
# browser OIDC flow. Any long random string; must match on both workers.
# Unset on either side = internal reads degrade to "always denied" rather
# than an open bypass.
Loading