-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathcustomResourceRegistry.ts
More file actions
240 lines (223 loc) · 8.72 KB
/
Copy pathcustomResourceRegistry.ts
File metadata and controls
240 lines (223 loc) · 8.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
/**
* 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;
params?: TemplateParam[];
}
interface TemplateParam {
name: string;
/** `{+name}` — reserved expansion, may span segments. */
reserved: boolean;
}
const registry = new Map<McpProfile, CompiledDef[]>();
/**
* Compile a URI template into a matcher. `{name}` matches a single path
* segment (`[^/]+`), and an encoded separator — `%2F`/`%5C` — in the capture
* rejects the match, so the one-segment contract survives percent-decoding);
* `{+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; params: TemplateParam[] } {
const params: TemplateParam[] = [];
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 (params.some((p) => p.name === name)) {
// a repeated name would silently overwrite the earlier captured value
throw new Error(`duplicate template parameter '{${name}}' in uriTemplate: ${template}`);
}
params.push({ name, reserved });
pattern += reserved ? '(.+)' : '([^/]+)';
index = close + 1;
}
if (params.length === 0) throw new Error(`uriTemplate has no parameters (use \`uri\` instead): ${template}`);
return { regex: new RegExp(`^${pattern}$`), params };
}
// `{name}` captures run against the still-encoded URI, so a client can smuggle
// a separator through the `[^/]+` class as %2F (or %5C) and have it decode to
// a real slash/backslash AFTER the segment boundary was checked — defeating
// the one-segment contract authors rely on for path construction. Reject the
// match instead (the URI simply doesn't fit a single-segment slot).
const ENCODED_SEPARATOR = /%2f|%5c/i;
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, params } = compileUriTemplate(def.uriTemplate);
compiled.regex = regex;
compiled.params = params;
}
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, params: templateParams } of list) {
if (!regex || !templateParams) continue;
const match = regex.exec(uri);
if (!match) continue;
const params: Record<string, string> = {};
let separatorSmuggled = false;
for (let i = 0; i < templateParams.length; i++) {
const raw = match[i + 1];
if (!templateParams[i].reserved && ENCODED_SEPARATOR.test(raw)) {
separatorSmuggled = true;
break;
}
params[templateParams[i].name] = decodeURIComponentSafe(raw);
}
if (separatorSmuggled) continue;
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;
}
}