Skip to content

Commit e8b03d4

Browse files
committedDec 9, 2024
feat: add resources support
1 parent 9ca6a0f commit e8b03d4

File tree

4 files changed

+308
-21
lines changed

4 files changed

+308
-21
lines changed
 

Diff for: ‎src/core/MCPServer.ts

+102-21
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ import {
55
ListToolsRequestSchema,
66
ListPromptsRequestSchema,
77
GetPromptRequestSchema,
8+
ListResourcesRequestSchema,
9+
ReadResourceRequestSchema,
10+
SubscribeRequestSchema,
11+
UnsubscribeRequestSchema,
812
} from "@modelcontextprotocol/sdk/types.js";
913
import { ToolLoader } from "./toolLoader.js";
1014
import { PromptLoader } from "./promptLoader.js";
15+
import { ResourceLoader } from "./resourceLoader.js";
1116
import { ToolProtocol } from "../tools/BaseTool.js";
1217
import { PromptProtocol } from "../prompts/BasePrompt.js";
18+
import { ResourceProtocol } from "../resources/BaseResource.js";
1319
import { readFileSync } from "fs";
1420
import { join, dirname } from "path";
1521
import { logger } from "./Logger.js";
@@ -30,14 +36,19 @@ export type ServerCapabilities = {
3036
prompts?: {
3137
enabled: true;
3238
};
39+
resources?: {
40+
enabled: true;
41+
};
3342
};
3443

3544
export class MCPServer {
3645
private server: Server;
3746
private toolsMap: Map<string, ToolProtocol> = new Map();
3847
private promptsMap: Map<string, PromptProtocol> = new Map();
48+
private resourcesMap: Map<string, ResourceProtocol> = new Map();
3949
private toolLoader: ToolLoader;
4050
private promptLoader: PromptLoader;
51+
private resourceLoader: ResourceLoader;
4152
private serverName: string;
4253
private serverVersion: string;
4354
private basePath: string;
@@ -53,6 +64,7 @@ export class MCPServer {
5364

5465
this.toolLoader = new ToolLoader(this.basePath);
5566
this.promptLoader = new PromptLoader(this.basePath);
67+
this.resourceLoader = new ResourceLoader(this.basePath);
5668

5769
this.server = new Server(
5870
{
@@ -63,6 +75,7 @@ export class MCPServer {
6375
capabilities: {
6476
tools: { enabled: true },
6577
prompts: { enabled: false },
78+
resources: { enabled: false },
6679
},
6780
}
6881
);
@@ -162,6 +175,66 @@ export class MCPServer {
162175
messages: await prompt.getMessages(request.params.arguments),
163176
};
164177
});
178+
179+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
180+
return {
181+
resources: Array.from(this.resourcesMap.values()).map(
182+
(resource) => resource.resourceDefinition
183+
),
184+
};
185+
});
186+
187+
this.server.setRequestHandler(
188+
ReadResourceRequestSchema,
189+
async (request) => {
190+
const resource = this.resourcesMap.get(request.params.uri);
191+
if (!resource) {
192+
throw new Error(
193+
`Unknown resource: ${
194+
request.params.uri
195+
}. Available resources: ${Array.from(this.resourcesMap.keys()).join(
196+
", "
197+
)}`
198+
);
199+
}
200+
201+
return {
202+
contents: await resource.read(),
203+
};
204+
}
205+
);
206+
207+
this.server.setRequestHandler(SubscribeRequestSchema, async (request) => {
208+
const resource = this.resourcesMap.get(request.params.uri);
209+
if (!resource) {
210+
throw new Error(`Unknown resource: ${request.params.uri}`);
211+
}
212+
213+
if (!resource.subscribe) {
214+
throw new Error(
215+
`Resource ${request.params.uri} does not support subscriptions`
216+
);
217+
}
218+
219+
await resource.subscribe();
220+
return {};
221+
});
222+
223+
this.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => {
224+
const resource = this.resourcesMap.get(request.params.uri);
225+
if (!resource) {
226+
throw new Error(`Unknown resource: ${request.params.uri}`);
227+
}
228+
229+
if (!resource.unsubscribe) {
230+
throw new Error(
231+
`Resource ${request.params.uri} does not support subscriptions`
232+
);
233+
}
234+
235+
await resource.unsubscribe();
236+
return {};
237+
});
165238
}
166239

167240
private async detectCapabilities(): Promise<ServerCapabilities> {
@@ -170,15 +243,16 @@ export class MCPServer {
170243
if (await this.toolLoader.hasTools()) {
171244
capabilities.tools = { enabled: true };
172245
logger.debug("Tools capability enabled");
173-
} else {
174-
logger.debug("No tools found, tools capability disabled");
175246
}
176247

177248
if (await this.promptLoader.hasPrompts()) {
178249
capabilities.prompts = { enabled: true };
179250
logger.debug("Prompts capability enabled");
180-
} else {
181-
logger.debug("No prompts found, prompts capability disabled");
251+
}
252+
253+
if (await this.resourceLoader.hasResources()) {
254+
capabilities.resources = { enabled: true };
255+
logger.debug("Resources capability enabled");
182256
}
183257

184258
return capabilities;
@@ -196,30 +270,37 @@ export class MCPServer {
196270
prompts.map((prompt: PromptProtocol) => [prompt.name, prompt])
197271
);
198272

199-
this.detectCapabilities();
273+
const resources = await this.resourceLoader.loadResources();
274+
this.resourcesMap = new Map(
275+
resources.map((resource: ResourceProtocol) => [resource.uri, resource])
276+
);
277+
278+
await this.detectCapabilities();
200279

201280
const transport = new StdioServerTransport();
202281
await this.server.connect(transport);
203282

204-
if (tools.length > 0 || prompts.length > 0) {
283+
logger.info(`Started ${this.serverName}@${this.serverVersion}`);
284+
285+
if (tools.length > 0) {
205286
logger.info(
206-
`Started ${this.serverName}@${this.serverVersion} with ${tools.length} tools and ${prompts.length} prompts`
287+
`Tools (${tools.length}): ${Array.from(this.toolsMap.keys()).join(
288+
", "
289+
)}`
207290
);
208-
if (tools.length > 0) {
209-
logger.info(
210-
`Available tools: ${Array.from(this.toolsMap.keys()).join(", ")}`
211-
);
212-
}
213-
if (prompts.length > 0) {
214-
logger.info(
215-
`Available prompts: ${Array.from(this.promptsMap.keys()).join(
216-
", "
217-
)}`
218-
);
219-
}
220-
} else {
291+
}
292+
if (prompts.length > 0) {
293+
logger.info(
294+
`Prompts (${prompts.length}): ${Array.from(
295+
this.promptsMap.keys()
296+
).join(", ")}`
297+
);
298+
}
299+
if (resources.length > 0) {
221300
logger.info(
222-
`Started ${this.serverName}@${this.serverVersion} with no tools or prompts`
301+
`Resources (${resources.length}): ${Array.from(
302+
this.resourcesMap.keys()
303+
).join(", ")}`
223304
);
224305
}
225306
} catch (error) {

Diff for: ‎src/core/resourceLoader.ts

+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ResourceProtocol } from "../resources/BaseResource.js";
2+
import { join, dirname } from "path";
3+
import { promises as fs } from "fs";
4+
import { logger } from "./Logger.js";
5+
6+
export class ResourceLoader {
7+
private readonly RESOURCES_DIR: string;
8+
private readonly EXCLUDED_FILES = [
9+
"BaseResource.js",
10+
"*.test.js",
11+
"*.spec.js",
12+
];
13+
14+
constructor(basePath?: string) {
15+
const mainModulePath = basePath || process.argv[1];
16+
this.RESOURCES_DIR = join(dirname(mainModulePath), "resources");
17+
logger.debug(
18+
`Initialized ResourceLoader with directory: ${this.RESOURCES_DIR}`
19+
);
20+
}
21+
22+
async hasResources(): Promise<boolean> {
23+
try {
24+
const stats = await fs.stat(this.RESOURCES_DIR);
25+
if (!stats.isDirectory()) {
26+
logger.debug("Resources path exists but is not a directory");
27+
return false;
28+
}
29+
30+
const files = await fs.readdir(this.RESOURCES_DIR);
31+
const hasValidFiles = files.some((file) => this.isResourceFile(file));
32+
logger.debug(`Resources directory has valid files: ${hasValidFiles}`);
33+
return hasValidFiles;
34+
} catch (error) {
35+
logger.debug("No resources directory found");
36+
return false;
37+
}
38+
}
39+
40+
private isResourceFile(file: string): boolean {
41+
if (!file.endsWith(".js")) return false;
42+
const isExcluded = this.EXCLUDED_FILES.some((pattern) => {
43+
if (pattern.includes("*")) {
44+
const regex = new RegExp(pattern.replace("*", ".*"));
45+
return regex.test(file);
46+
}
47+
return file === pattern;
48+
});
49+
50+
logger.debug(
51+
`Checking file ${file}: ${isExcluded ? "excluded" : "included"}`
52+
);
53+
return !isExcluded;
54+
}
55+
56+
private validateResource(resource: any): resource is ResourceProtocol {
57+
const isValid = Boolean(
58+
resource &&
59+
typeof resource.uri === "string" &&
60+
typeof resource.name === "string" &&
61+
resource.resourceDefinition &&
62+
typeof resource.read === "function"
63+
);
64+
65+
if (isValid) {
66+
logger.debug(`Validated resource: ${resource.name}`);
67+
} else {
68+
logger.warn(`Invalid resource found: missing required properties`);
69+
}
70+
71+
return isValid;
72+
}
73+
74+
async loadResources(): Promise<ResourceProtocol[]> {
75+
try {
76+
logger.debug(`Attempting to load resources from: ${this.RESOURCES_DIR}`);
77+
78+
let stats;
79+
try {
80+
stats = await fs.stat(this.RESOURCES_DIR);
81+
} catch (error) {
82+
logger.debug("No resources directory found");
83+
return [];
84+
}
85+
86+
if (!stats.isDirectory()) {
87+
logger.error(`Path is not a directory: ${this.RESOURCES_DIR}`);
88+
return [];
89+
}
90+
91+
const files = await fs.readdir(this.RESOURCES_DIR);
92+
logger.debug(`Found files in directory: ${files.join(", ")}`);
93+
94+
const resources: ResourceProtocol[] = [];
95+
96+
for (const file of files) {
97+
if (!this.isResourceFile(file)) {
98+
continue;
99+
}
100+
101+
try {
102+
const fullPath = join(this.RESOURCES_DIR, file);
103+
logger.debug(`Attempting to load resource from: ${fullPath}`);
104+
105+
const importPath = `file://${fullPath}`;
106+
const { default: ResourceClass } = await import(importPath);
107+
108+
if (!ResourceClass) {
109+
logger.warn(`No default export found in ${file}`);
110+
continue;
111+
}
112+
113+
const resource = new ResourceClass();
114+
if (this.validateResource(resource)) {
115+
resources.push(resource);
116+
}
117+
} catch (error) {
118+
logger.error(`Error loading resource ${file}: ${error}`);
119+
}
120+
}
121+
122+
logger.debug(
123+
`Successfully loaded ${resources.length} resources: ${resources
124+
.map((r) => r.name)
125+
.join(", ")}`
126+
);
127+
return resources;
128+
} catch (error) {
129+
logger.error(`Failed to load resources: ${error}`);
130+
return [];
131+
}
132+
}
133+
}

Diff for: ‎src/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,13 @@ export {
1111
type PromptArgumentSchema,
1212
type PromptArguments,
1313
} from "./prompts/BasePrompt.js";
14+
export {
15+
MCPResource,
16+
type ResourceProtocol,
17+
type ResourceContent,
18+
type ResourceDefinition,
19+
type ResourceTemplateDefinition,
20+
} from "./resources/BaseResource.js";
1421
export { ToolLoader } from "./core/toolLoader.js";
1522
export { PromptLoader } from "./core/promptLoader.js";
23+
export { ResourceLoader } from "./core/resourceLoader.js";

Diff for: ‎src/resources/BaseResource.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
export type ResourceContent = {
2+
uri: string;
3+
mimeType?: string;
4+
text?: string;
5+
blob?: string;
6+
};
7+
8+
export type ResourceDefinition = {
9+
uri: string;
10+
name: string;
11+
description?: string;
12+
mimeType?: string;
13+
};
14+
15+
export type ResourceTemplateDefinition = {
16+
uriTemplate: string;
17+
name: string;
18+
description?: string;
19+
mimeType?: string;
20+
};
21+
22+
export interface ResourceProtocol {
23+
uri: string;
24+
name: string;
25+
description?: string;
26+
mimeType?: string;
27+
resourceDefinition: ResourceDefinition;
28+
read(): Promise<ResourceContent[]>;
29+
subscribe?(): Promise<void>;
30+
unsubscribe?(): Promise<void>;
31+
}
32+
33+
export abstract class MCPResource implements ResourceProtocol {
34+
abstract uri: string;
35+
abstract name: string;
36+
description?: string;
37+
mimeType?: string;
38+
39+
get resourceDefinition(): ResourceDefinition {
40+
return {
41+
uri: this.uri,
42+
name: this.name,
43+
description: this.description,
44+
mimeType: this.mimeType,
45+
};
46+
}
47+
48+
abstract read(): Promise<ResourceContent[]>;
49+
50+
async subscribe?(): Promise<void> {
51+
throw new Error("Subscription not implemented for this resource");
52+
}
53+
54+
async unsubscribe?(): Promise<void> {
55+
throw new Error("Unsubscription not implemented for this resource");
56+
}
57+
58+
protected async fetch<T>(url: string, init?: RequestInit): Promise<T> {
59+
const response = await fetch(url, init);
60+
if (!response.ok) {
61+
throw new Error(`HTTP error! status: ${response.status}`);
62+
}
63+
return response.json();
64+
}
65+
}

0 commit comments

Comments
 (0)
Please sign in to comment.