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
3 changes: 3 additions & 0 deletions components/mcp/adapters/fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ interface FastifyLikeRequest {
method: string;
headers: Record<string, string | string[] | undefined>;
body: unknown;
/** Client socket IP (Fastify's request.ip). */
ip?: string;
/**
* `authAndEnsureUserOnRequest` sets the full user (incl. role + permission
* tree) on `req.hdb_user`. Used for session binding (`username`) and
Expand Down Expand Up @@ -53,6 +55,7 @@ export function createFastifyHandler(profile: McpProfile) {
user: request.hdb_user?.username ?? '',
userObject: (request.hdb_user ?? undefined) as NormRequest['userObject'],
profile,
clientIp: request.ip,
};

const res = await handleMcpRequest(norm);
Expand Down
3 changes: 3 additions & 0 deletions components/mcp/adapters/harperHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ interface HarperHttpRequest {
*/
user?: { username?: string; role?: unknown };
isWebSocket?: boolean;
/** Client socket IP (Harper Request getter; present on Node and Bun). */
ip?: string;
}

interface HarperHttpResponse {
Expand All @@ -61,6 +63,7 @@ export function createHarperHttpHandler(profile: McpProfile) {
user: request.user?.username ?? '',
userObject: request.user as NormRequest['userObject'],
profile,
clientIp: request.ip,
};

const res = await handleMcpRequest(norm);
Expand Down
2 changes: 1 addition & 1 deletion components/mcp/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface AuditEntry {
tool: string;
user: string;
args: object;
status: 'ok' | 'isError' | 'rate_limited' | 'protocol_error';
status: 'ok' | 'isError' | 'rate_limited' | 'quota_exceeded' | 'protocol_error';
durationMs: number;
errorMessage?: string;
}
Expand Down
219 changes: 219 additions & 0 deletions components/mcp/customResourceRegistry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* MCP custom-resource registry — component-author content resources (#1609).
* Mirrors `toolRegistry.ts` / `promptRegistry.ts`.
*
* The discovered resource surface (`resources.ts`) exposes Resource *descriptors*
* and `harper://` metadata; it has no way to serve author-defined content (a docs
* page, a rendered report) under an author-chosen URI. Authors publish such
* content via `static mcpResources` on a Resource (see
* `registerCustomMcpResources` in `tools/application.ts`): each entry declares a
* fixed `uri` or an RFC-6570-style `uriTemplate` (`{name}` matches one path
* segment, `{+name}` matches across segments) and a `read` that returns `text`
* or `blob` content per MCP §server/resources (rev 2025-06-18).
*
* Like custom tools (#622), RBAC is delegated to the Resource: entries are
* listed to every session on the profile — including anonymous/unauthenticated
* sessions where the deployment allows them (the public-docs case #1609 is
* built around) — and the author's method enforces any access control it
* needs at read time.
*/
import type { McpProfile } from './transport.ts';
import type { AuthedUser } from './toolRegistry.ts';

/** Context passed to a custom resource `read` (subset of a tool call's context). */
export interface ResourceReadContext {
user: AuthedUser;
profile: McpProfile;
sessionId?: string;
}

/** What an author `read` may return; a bare string means text content. */
export interface CustomResourceContent {
text?: string;
/** Base64-encoded binary content. */
blob?: string;
mimeType?: string;
}
export type CustomResourceReadResult = string | CustomResourceContent | object;

export interface CustomResourceDef {
/** Fixed URI — exactly one of `uri` / `uriTemplate` is set. */
uri?: string;
/** URI template with `{name}` / `{+name}` placeholders. */
uriTemplate?: string;
name: string;
title?: string;
description?: string;
mimeType?: string;
profile: McpProfile;
/** Author-declared completion candidates per template parameter (#1349 §3.2). */
completions?: Readonly<Record<string, ReadonlyArray<string>>>;
read: (
params: Record<string, string>,
context: ResourceReadContext
) => CustomResourceReadResult | Promise<CustomResourceReadResult>;
}

interface CompiledDef {
def: CustomResourceDef;
/** Present for template entries only. */
regex?: RegExp;
paramNames?: string[];
}

const registry = new Map<McpProfile, CompiledDef[]>();

/**
* Compile a URI template into a matcher. `{name}` matches a single path
* segment (`[^/]+`); `{+name}` matches across segments (`.+`), mirroring
* RFC 6570 level-2 reserved expansion, which is how MCP clients construct
* URIs from templates. Throws on malformed templates so registration can
* warn-and-skip the entry.
*/
export function compileUriTemplate(template: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
let pattern = '';
let index = 0;
while (index < template.length) {
const open = template.indexOf('{', index);
if (open === -1) {
pattern += escapeRegex(template.slice(index));
break;
}
pattern += escapeRegex(template.slice(index, open));
const close = template.indexOf('}', open);
if (close === -1) throw new Error(`unterminated '{' in uriTemplate: ${template}`);
let name = template.slice(open + 1, close);
const reserved = name.startsWith('+');
if (reserved) name = name.slice(1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`invalid template parameter '{${template.slice(open + 1, close)}}' in uriTemplate: ${template}`);
}
if (paramNames.includes(name)) {
// a repeated name would silently overwrite the earlier captured value
throw new Error(`duplicate template parameter '{${name}}' in uriTemplate: ${template}`);
}
paramNames.push(name);
pattern += reserved ? '(.+)' : '([^/]+)';
index = close + 1;
}
if (paramNames.length === 0) throw new Error(`uriTemplate has no parameters (use \`uri\` instead): ${template}`);
return { regex: new RegExp(`^${pattern}$`), paramNames };
}

function escapeRegex(literal: string): string {
return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** Register a custom resource. Template entries are compiled here; a bad template throws. */
export function addCustomResource(def: CustomResourceDef): void {
const compiled: CompiledDef = { def };
if (def.uriTemplate) {
const { regex, paramNames } = compileUriTemplate(def.uriTemplate);
compiled.regex = regex;
compiled.paramNames = paramNames;
}
let list = registry.get(def.profile);
if (!list) {
list = [];
registry.set(def.profile, list);
}
list.push(compiled);
}

export function clearProfileCustomResources(profile: McpProfile): void {
registry.delete(profile);
}

/** Snapshot for restore-on-failure during re-registration (see registerApplicationTools). */
export function snapshotProfileCustomResources(profile: McpProfile): CustomResourceDef[] {
return (registry.get(profile) ?? []).map((c) => c.def);
}

/** Fixed-URI entries, shaped for `resources/list`. */
export function listCustomResources(
profile: McpProfile
): Array<{ uri: string; name: string; title?: string; description?: string; mimeType?: string }> {
const out: Array<{ uri: string; name: string; title?: string; description?: string; mimeType?: string }> = [];
for (const { def } of registry.get(profile) ?? []) {
if (!def.uri) continue;
out.push({
uri: def.uri,
name: def.name,
...(def.title ? { title: def.title } : {}),
...(def.description ? { description: def.description } : {}),
...(def.mimeType ? { mimeType: def.mimeType } : {}),
});
}
return out;
}

/** Template entries, shaped for `resources/templates/list`. */
export function listCustomResourceTemplates(
profile: McpProfile
): Array<{ uriTemplate: string; name: string; title?: string; description?: string; mimeType?: string }> {
const out: Array<{ uriTemplate: string; name: string; title?: string; description?: string; mimeType?: string }> = [];
for (const { def } of registry.get(profile) ?? []) {
if (!def.uriTemplate) continue;
out.push({
uriTemplate: def.uriTemplate,
name: def.name,
...(def.title ? { title: def.title } : {}),
...(def.description ? { description: def.description } : {}),
...(def.mimeType ? { mimeType: def.mimeType } : {}),
});
}
return out;
}

/**
* Match a `resources/read` URI against the profile's custom entries: fixed URIs
* first (exact string match), then templates in registration order. Registered
* custom URIs take precedence over the discovered surface, so this runs before
* the `harper://` / app-resource dispatch in `readResource`.
*/
export function matchCustomResource(
profile: McpProfile,
uri: string
): { def: CustomResourceDef; params: Record<string, string> } | undefined {
const list = registry.get(profile);
if (!list) return undefined;
for (const { def } of list) {
if (def.uri === uri) return { def, params: {} };
}
for (const { def, regex, paramNames } of list) {
if (!regex || !paramNames) continue;
const match = regex.exec(uri);
if (!match) continue;
const params: Record<string, string> = {};
for (let i = 0; i < paramNames.length; i++) {
params[paramNames[i]] = decodeURIComponentSafe(match[i + 1]);
}
return { def, params };
}
return undefined;
}

/** Author-declared completion candidates for a template parameter, if any. */
export function customResourceCompletionValues(
profile: McpProfile,
uriTemplate: string,
argName: string
): string[] | undefined {
for (const { def } of registry.get(profile) ?? []) {
if (def.uriTemplate !== uriTemplate) continue;
const values = def.completions?.[argName];
return values ? [...values] : undefined;
}
return undefined;
}

// Clients may or may not percent-encode template expansions; a stray '%' must
// not turn a read into a URIError.
function decodeURIComponentSafe(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
41 changes: 39 additions & 2 deletions components/mcp/listChanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* nothing.
*/
import harperLogger from '../../utility/logging/harper_logger.ts';
import { listResources } from './resources.ts';
import { listResources, listResourceTemplates } from './resources.ts';
import {
type RegisteredSession,
forEachSessionByProfile,
Expand Down Expand Up @@ -106,7 +106,16 @@ function toolsListNames(profile: McpProfile, session: RegisteredSession): Array<

function resourcesListUris(profile: McpProfile, session: RegisteredSession): Array<{ uri: string }> {
const result = listResources({ user: session.user, profile, limit: MAX_RESOURCES_PAGE });
return result.resources.map((r) => ({ uri: r.uri }));
const uris = result.resources.map((r) => ({ uri: r.uri }));
// Templates are part of the advertised resource surface too: a rebuild can
// add/remove a custom mcpResources uriTemplate while the fixed-URI set stays
// identical (#1609). Fold them into the diffed snapshot (prefixed so a
// template can't collide with a fixed URI of the same spelling).
const templates = listResourceTemplates(profile, undefined, MAX_RESOURCES_PAGE);
for (const t of templates.resourceTemplates) {
uris.push({ uri: `template:${t.uriTemplate}` });
}
return uris;
}

function sameSet(
Expand Down Expand Up @@ -155,6 +164,34 @@ function maybeNotifyResourcesChanged(record: RegisteredSession): void {
}
}

/**
* Re-diff every session's visible resource list on a profile and push
* `notifications/resources/list_changed` to the sessions whose list actually
* changed. Called by the application registration after a rebuild so custom
* `mcpResources` additions/removals propagate (#1609); the per-session diff in
* `maybeNotifyResourcesChanged` keeps no-op rebuilds silent.
*/
export function notifyResourcesListChanged(profile: McpProfile): void {
for (const record of snapshotSessions(profile)) {
maybeNotifyResourcesChanged(record);
}
}

/**
* Re-diff every session's visible tool list on a profile and push
* `notifications/tools/list_changed` to the sessions whose list actually
* changed. The schema-change handler already does this, but the lazy
* per-request rebuild (`ensureApplicationToolsFresh`, #1609) can add/remove
* custom `mcpTools` outside any schema event — without this, a session that
* initialized before a tableless component registered keeps a stale tool
* list until it happens to re-poll `tools/list`.
*/
export function notifyToolsListChanged(profile: McpProfile): void {
for (const record of snapshotSessions(profile)) {
maybeNotifyToolsChanged(record);
}
}

/**
* Push `notifications/prompts/list_changed` to every session on a profile.
* Prompts carry no per-user RBAC (they're generic templates, §3.5), so unlike
Expand Down
Loading
Loading