Skip to content

Commit bec6a53

Browse files
feat(server): expose resource and icon embedder hooks (#519)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
1 parent 086518b commit bec6a53

7 files changed

Lines changed: 311 additions & 16 deletions

File tree

‎patches/fastmcp@4.3.2.patch‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,61 @@
11
diff --git a/dist/FastMCP.d.cts b/dist/FastMCP.d.cts
2-
index 8eba099cc20f7ae5f70060bebb3871c387cfb2de..c3031da662933c366c7320171db47c246cd3191a 100644
2+
index 8eba099cc20f7ae5f70060bebb3871c387cfb2de..3bcd654a3f9f1a42d2f92d8a72daf0612fc58d52 100644
33
--- a/dist/FastMCP.d.cts
44
+++ b/dist/FastMCP.d.cts
5-
@@ -605,6 +605,8 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
5+
@@ -240,10 +240,12 @@ type Resource<T extends FastMCPSessionAuth> = {
6+
uri: string;
7+
};
8+
type ResourceResult = {
9+
+ _meta?: Record<string, unknown>;
10+
blob: string;
11+
mimeType?: string;
12+
uri?: string;
13+
} | {
14+
+ _meta?: Record<string, unknown>;
15+
mimeType?: string;
16+
text: string;
17+
uri?: string;
18+
@@ -605,7 +607,10 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
619
streamingHint?: boolean;
720
} & ToolAnnotations;
821
canAccess?: (auth: T) => boolean;
922
+ beforeValidate?: (args: unknown, auth: T) => ContentResult | Promise<ContentResult | undefined> | undefined;
1023
+ canList?: (auth: T) => boolean;
1124
description?: string;
25+
+ icons?: { src: string; mimeType?: string; sizes?: string[]; theme?: "light" | "dark" }[];
1226
execute: (args: StandardSchemaV1.InferOutput<Params>, context: Context<T>) => Promise<AudioContent | ContentResult | ImageContent | ResourceContent | ResourceLink | StandardSchemaV1.InferOutput<OutputParams> | string | TextContent | void>;
1327
name: string;
28+
outputSchema?: OutputParams;
1429
diff --git a/dist/FastMCP.d.ts b/dist/FastMCP.d.ts
15-
index 810df0c5b6511f34685a0651e6df83acd0239e7b..5c82680c32900c1ca39679e1be25e64c3408aee9 100644
30+
index 810df0c5b6511f34685a0651e6df83acd0239e7b..8f688f131ff0b99566a3179f882e95159d7ef9ab 100644
1631
--- a/dist/FastMCP.d.ts
1732
+++ b/dist/FastMCP.d.ts
18-
@@ -605,6 +605,8 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
33+
@@ -240,10 +240,12 @@ type Resource<T extends FastMCPSessionAuth> = {
34+
uri: string;
35+
};
36+
type ResourceResult = {
37+
+ _meta?: Record<string, unknown>;
38+
blob: string;
39+
mimeType?: string;
40+
uri?: string;
41+
} | {
42+
+ _meta?: Record<string, unknown>;
43+
mimeType?: string;
44+
text: string;
45+
uri?: string;
46+
@@ -605,7 +607,10 @@ type Tool<T extends FastMCPSessionAuth, Params extends ToolParameters = ToolPara
1947
streamingHint?: boolean;
2048
} & ToolAnnotations;
2149
canAccess?: (auth: T) => boolean;
2250
+ beforeValidate?: (args: unknown, auth: T) => ContentResult | Promise<ContentResult | undefined> | undefined;
2351
+ canList?: (auth: T) => boolean;
2452
description?: string;
53+
+ icons?: { src: string; mimeType?: string; sizes?: string[]; theme?: "light" | "dark" }[];
2554
execute: (args: StandardSchemaV1.InferOutput<Params>, context: Context<T>) => Promise<AudioContent | ContentResult | ImageContent | ResourceContent | ResourceLink | StandardSchemaV1.InferOutput<OutputParams> | string | TextContent | void>;
2655
name: string;
56+
outputSchema?: OutputParams;
2757
diff --git a/dist/chunk-LWU5CQGW.js b/dist/chunk-LWU5CQGW.js
28-
index 474670585c1fff7d9609d0f900d0743df14a7688..86d120ca21d74d6c335a8c3c9763e2f4ac835d10 100644
58+
index 474670585c1fff7d9609d0f900d0743df14a7688..74610d15a0a9392cf286e357995847f0ff1e6db7 100644
2959
--- a/dist/chunk-LWU5CQGW.js
3060
+++ b/dist/chunk-LWU5CQGW.js
3161
@@ -986,6 +986,9 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
@@ -38,7 +68,7 @@ index 474670585c1fff7d9609d0f900d0743df14a7688..86d120ca21d74d6c335a8c3c9763e2f4
3868
let cachedToolsList = null;
3969
this.#server.setRequestHandler(ListToolsRequestSchema, async () => {
4070
if (cachedToolsList) {
41-
@@ -994,9 +997,12 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
71+
@@ -994,9 +997,13 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
4272
};
4373
}
4474
cachedToolsList = await Promise.all(
@@ -49,10 +79,11 @@ index 474670585c1fff7d9609d0f900d0743df14a7688..86d120ca21d74d6c335a8c3c9763e2f4
4979
+ // Top-level Tool.title (MCP 2025-06-18). Clients such as Codex index this
5080
+ // for tool search and do not read annotations.title.
5181
+ ...tool.annotations?.title && { title: tool.annotations.title },
82+
+ ...tool.icons && { icons: tool.icons },
5283
description: tool.description,
5384
inputSchema: tool.parameters ? strictJsonSchema(await toJsonSchema(tool.parameters)) : {
5485
additionalProperties: false,
55-
@@ -1026,6 +1032,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
86+
@@ -1026,6 +1033,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
5687
`Unknown tool: ${request.params.name}`
5788
);
5889
}
@@ -69,7 +100,7 @@ index 474670585c1fff7d9609d0f900d0743df14a7688..86d120ca21d74d6c335a8c3c9763e2f4
69100
if (tool.parameters) {
70101
const parsed = await tool.parameters["~standard"].validate(
71102
diff --git a/dist/chunk-UYG7NPM6.cjs b/dist/chunk-UYG7NPM6.cjs
72-
index 3b695bf493c4f54fd970e145cf14fdd8effc9c6d..3e17b1d1f71f3bf41eb5e589fa272e670f27c150 100644
103+
index 3b695bf493c4f54fd970e145cf14fdd8effc9c6d..1b7ce74a69d3104f23da4e4a5b3218fcd84d71d5 100644
73104
--- a/dist/chunk-UYG7NPM6.cjs
74105
+++ b/dist/chunk-UYG7NPM6.cjs
75106
@@ -986,6 +986,9 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
@@ -82,7 +113,7 @@ index 3b695bf493c4f54fd970e145cf14fdd8effc9c6d..3e17b1d1f71f3bf41eb5e589fa272e67
82113
let cachedToolsList = null;
83114
this.#server.setRequestHandler(_typesjs.ListToolsRequestSchema, async () => {
84115
if (cachedToolsList) {
85-
@@ -994,9 +997,12 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
116+
@@ -994,9 +997,13 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
86117
};
87118
}
88119
cachedToolsList = await Promise.all(
@@ -93,10 +124,11 @@ index 3b695bf493c4f54fd970e145cf14fdd8effc9c6d..3e17b1d1f71f3bf41eb5e589fa272e67
93124
+ // Top-level Tool.title (MCP 2025-06-18). Clients such as Codex index this
94125
+ // for tool search and do not read annotations.title.
95126
+ ...tool.annotations?.title && { title: tool.annotations.title },
127+
+ ...tool.icons && { icons: tool.icons },
96128
description: tool.description,
97129
inputSchema: tool.parameters ? _xsschema.strictJsonSchema.call(void 0, await _xsschema.toJsonSchema.call(void 0, tool.parameters)) : {
98130
additionalProperties: false,
99-
@@ -1026,6 +1032,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
131+
@@ -1026,6 +1033,15 @@ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
100132
`Unknown tool: ${request.params.name}`
101133
);
102134
}

‎pnpm-lock.yaml‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/server.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,18 @@ export type ToolContext = {
180180
[key: string]: unknown;
181181
};
182182

183+
export type ToolIcon = {
184+
src: string;
185+
mimeType?: string;
186+
sizes?: string[];
187+
theme?: 'light' | 'dark';
188+
};
189+
183190
/** A tool as registered on the server. */
184191
export interface ToolDefinition {
185192
name: string;
186193
description?: string;
194+
icons?: ToolIcon[];
187195
/** A Standard Schema (for example a zod object) for the arguments. */
188196
parameters?: unknown;
189197
/** JSON Schema of the structured result. */
@@ -216,6 +224,24 @@ type RegisteredTool = Parameters<FastMCP<SessionData>['addTool']>[0];
216224
/** Registers a tool onto an instance. */
217225
export type ToolRegistrar = { addTool(tool: ToolDefinition): void };
218226

227+
export type ResourceContent = ({ text: string } | { blob: string }) & {
228+
uri?: string;
229+
mimeType?: string;
230+
_meta?: Record<string, unknown>;
231+
};
232+
233+
export interface ResourceDefinition {
234+
uri: string;
235+
name: string;
236+
description?: string;
237+
mimeType?: string;
238+
load(session?: Session): Promise<ResourceContent | ResourceContent[]>;
239+
}
240+
241+
export type ResourceRegistrar = {
242+
addResource(resource: ResourceDefinition): void;
243+
};
244+
219245
/** The HTTP request `authenticate` receives; absent for stdio. */
220246
export type AuthenticationRequest = {
221247
headers: IncomingHttpHeaders;
@@ -288,6 +314,7 @@ export interface FirecrawlMcpServerHooks {
288314
registrar: ToolRegistrar,
289315
context: ExtraToolsContext
290316
) => void;
317+
registerResources?: (registrar: ResourceRegistrar) => void;
291318
/** Credential and headers for each Firecrawl API request of a session. */
292319
outboundRequest?: (
293320
session: SessionData | undefined
@@ -2973,6 +3000,10 @@ Set \`redactPII\` to request redaction of personally identifiable information in
29733000
}
29743001
);
29753002

3003+
hooks.registerResources?.({
3004+
addResource: (resource) => server.addResource(resource),
3005+
});
3006+
29763007
return {
29773008
start: (args) =>
29783009
server.start(args as Parameters<typeof server.start>[0]),

‎tests/embed-package.test.mjs‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,24 @@ test('the packed package embeds through firecrawl-mcp/server', async (t) => {
8989
const auth = { authorization: 'Bearer embed-token' };
9090
const listed = await rpc(port, 'tools/list', {}, auth);
9191
assert.equal(listed.status, 200);
92-
const names = parseSseJson(await listed.text()).result.tools.map(
93-
(tool) => tool.name
94-
);
92+
const tools = parseSseJson(await listed.text()).result.tools;
93+
const names = tools.map((tool) => tool.name);
9594
assert.ok(names.includes('firecrawl_scrape'));
9695
assert.ok(names.includes('embed_echo'));
96+
assert.deepEqual(tools.find(({ name }) => name === 'embed_echo').icons, [
97+
{ src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml', sizes: ['any'] },
98+
]);
99+
const rejectedRead = await rpc(port, 'resources/read', { uri: 'ui://example/app.html' });
100+
assert.equal(rejectedRead.status, 401);
101+
const read = await rpc(port, 'resources/read', { uri: 'ui://example/app.html' }, auth);
102+
assert.equal(read.status, 200);
103+
assert.deepEqual(parseSseJson(await read.text()).result.contents, [{
104+
uri: 'ui://example/app.html',
105+
name: 'Example app',
106+
mimeType: 'text/html;profile=mcp-app',
107+
text: '<html>acme</html>',
108+
_meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
109+
}]);
97110

98111
const called = await rpc(
99112
port,

‎tests/fixtures/embed/embed-types.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,41 @@ import {
88
UserError,
99
type FirecrawlMcpServerHooks,
1010
type Session,
11+
type ResourceContent,
12+
type ResourceDefinition,
13+
type ResourceRegistrar,
1114
type ToolDefinition,
15+
type ToolIcon,
1216
type ToolResult,
1317
} from 'firecrawl-mcp/server';
1418

19+
const icon: ToolIcon = {
20+
src: 'https://example.com/icon.svg',
21+
mimeType: 'image/svg+xml',
22+
sizes: ['any'],
23+
theme: 'light',
24+
};
25+
26+
const resource: ResourceDefinition = {
27+
uri: 'ui://example/app.html',
28+
name: 'Example app',
29+
mimeType: 'text/html;profile=mcp-app',
30+
load: async (session): Promise<ResourceContent[]> => [
31+
{
32+
text: String(session?.tenant),
33+
_meta: { ui: { csp: { connectDomains: [] } } },
34+
},
35+
{
36+
blob: 'aGVsbG8=',
37+
uri: 'example://binary',
38+
mimeType: 'application/octet-stream',
39+
},
40+
],
41+
};
42+
1543
const tool: ToolDefinition = {
1644
name: 'embed_tenant',
45+
icons: [icon],
1746
description: 'Return the caller tenant.',
1847
execute: async (_args, { session }) => {
1948
if (!session?.tenant)
@@ -48,6 +77,9 @@ const hooks: FirecrawlMcpServerHooks = {
4877
const scrape = builtInTool('firecrawl_scrape');
4978
if (scrape) registrar.addTool({ ...scrape, name: 'embed_scrape' });
5079
},
80+
registerResources: (registrar: ResourceRegistrar) => {
81+
registrar.addResource(resource);
82+
},
5183
outboundRequest: (session) => ({
5284
headers: { 'x-tenant': String(session?.tenant) },
5385
}),
@@ -63,3 +95,9 @@ export const stop: () => Promise<void> = server.stop;
6395
// Content items must match one of the MCP content shapes.
6496
// @ts-expect-error a text item needs its text
6597
export const missingText: ToolResult = { content: [{ type: 'text' }] };
98+
99+
// @ts-expect-error a resource needs text or blob
100+
export const missingResourceContent: ResourceContent = { _meta: {} };
101+
102+
// @ts-expect-error icon themes must match the MCP light/dark variants
103+
export const invalidIcon: ToolIcon = { src: 'https://example.com/icon.svg', theme: 'other' };

‎tests/fixtures/embed/embed.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,27 @@ export async function startEmbedded(port) {
2121
registrar.addTool({
2222
name: 'embed_echo',
2323
description: 'Echo text with the caller tenant.',
24+
icons: [{
25+
src: 'https://example.com/icon.svg',
26+
mimeType: 'image/svg+xml',
27+
sizes: ['any'],
28+
}],
2429
parameters: z.object({ text: z.string() }),
2530
execute: async ({ text }, { session }) =>
2631
`${session?.tenant}: ${text}`,
2732
});
2833
},
34+
registerResources: ({ addResource }) => {
35+
addResource({
36+
uri: 'ui://example/app.html',
37+
name: 'Example app',
38+
mimeType: 'text/html;profile=mcp-app',
39+
load: async (session) => ({
40+
text: `<html>${session.tenant}</html>`,
41+
_meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
42+
}),
43+
});
44+
},
2945
},
3046
});
3147
await server.start({

0 commit comments

Comments
 (0)