MCP Resource Endpoints: File Systems and Databases
Complete guide to building MCP resource endpoints for file systems and databases, covering resource URI design, file system browsing, database query resources, streaming large resources, caching strategies, and building production-ready resource providers.
MCP Resource Endpoints: File Systems and Databases
Overview
MCP resources give AI models structured access to your data. Unlike tools that perform actions, resources expose data that models can read and reason about — file contents, database records, configuration files, log entries. Getting resources right means the AI has the context it needs without exposing more than it should. I have built MCP servers that provide file system and database access across several production environments, and the patterns here reflect what actually works when models need to understand your codebase or query your data.
Prerequisites
- Node.js 16 or later
- Understanding of the MCP protocol basics (server, tools, resources)
@modelcontextprotocol/sdkpackage installed- A file system or database to expose (examples use local files and PostgreSQL)
- Basic understanding of URI schemes and MIME types
- Familiarity with streaming patterns in Node.js
Understanding MCP Resources
Resources in MCP are identified by URIs and return content that models can read. Unlike tools, resources are declarative — the model requests a specific resource by URI, and the server returns it.
// Resource structure
// URI: file:///path/to/file.js
// Response: { uri, mimeType, text }
// Resources can be:
// 1. Static - known at server startup (list of config files)
// 2. Dynamic - generated on demand (database queries)
// 3. Templated - URI patterns with parameters (file:///{path})
Resource vs Tool: When to Use Which
Use RESOURCES when:
- Providing data for the model to read and reason about
- Content is relatively static or cacheable
- The model needs context (file contents, DB schema, configs)
- You want the model to browse and discover what's available
Use TOOLS when:
- Performing an action (create file, run query, deploy)
- The operation has side effects
- The result depends on runtime parameters
- You need complex input validation
File System Resource Provider
Basic File System Server
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var fs = require("fs");
var path = require("path");
var BASE_DIR = process.env.MCP_BASE_DIR || process.cwd();
var MAX_FILE_SIZE = 1024 * 1024; // 1MB limit for resource responses
var server = new Server(
{ name: "filesystem-resources", version: "1.0.0" },
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
);
// List available resources - returns files in the base directory
server.setRequestHandler("resources/list", function(request) {
var resources = [];
function walkDirectory(dir, prefix) {
var entries = fs.readdirSync(dir, { withFileTypes: true });
entries.forEach(function(entry) {
var fullPath = path.join(dir, entry.name);
var relativePath = path.relative(BASE_DIR, fullPath);
// Skip hidden files and node_modules
if (entry.name.startsWith(".") || entry.name === "node_modules") return;
if (entry.isDirectory()) {
walkDirectory(fullPath, prefix + entry.name + "/");
} else {
var mimeType = getMimeType(entry.name);
resources.push({
uri: "file:///" + relativePath.replace(/\\/g, "/"),
name: entry.name,
description: prefix + entry.name + " (" + formatSize(fs.statSync(fullPath).size) + ")",
mimeType: mimeType
});
}
});
}
walkDirectory(BASE_DIR, "");
return { resources: resources };
});
// Read a specific resource by URI
server.setRequestHandler("resources/read", function(request) {
var uri = request.params.uri;
var filePath = uriToPath(uri);
if (!filePath) {
throw new Error("Invalid URI: " + uri);
}
// Security: ensure path is within base directory
var resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(BASE_DIR))) {
throw new Error("Access denied: path outside base directory");
}
if (!fs.existsSync(resolved)) {
throw new Error("File not found: " + uri);
}
var stats = fs.statSync(resolved);
if (stats.size > MAX_FILE_SIZE) {
throw new Error("File too large: " + formatSize(stats.size) + " (max: " + formatSize(MAX_FILE_SIZE) + ")");
}
var mimeType = getMimeType(resolved);
var content;
if (mimeType.startsWith("text/") || isTextFile(resolved)) {
content = fs.readFileSync(resolved, "utf8");
return {
contents: [{
uri: uri,
mimeType: mimeType,
text: content
}]
};
} else {
// Binary files returned as base64
content = fs.readFileSync(resolved).toString("base64");
return {
contents: [{
uri: uri,
mimeType: mimeType,
blob: content
}]
};
}
});
// Resource templates for dynamic URIs
server.setRequestHandler("resources/templates/list", function() {
return {
resourceTemplates: [
{
uriTemplate: "file:///{path}",
name: "File by path",
description: "Read any file by its relative path",
mimeType: "application/octet-stream"
},
{
uriTemplate: "directory:///{path}",
name: "Directory listing",
description: "List contents of a directory",
mimeType: "application/json"
}
]
};
});
function uriToPath(uri) {
if (uri.startsWith("file:///")) {
return path.join(BASE_DIR, uri.slice(8));
}
if (uri.startsWith("directory:///")) {
return path.join(BASE_DIR, uri.slice(13));
}
return null;
}
function getMimeType(filePath) {
var ext = path.extname(filePath).toLowerCase();
var types = {
".js": "text/javascript",
".ts": "text/typescript",
".json": "application/json",
".md": "text/markdown",
".html": "text/html",
".css": "text/css",
".yaml": "text/yaml",
".yml": "text/yaml",
".xml": "text/xml",
".sql": "text/x-sql",
".sh": "text/x-shellscript",
".py": "text/x-python",
".rb": "text/x-ruby",
".go": "text/x-go",
".rs": "text/x-rust",
".java": "text/x-java",
".txt": "text/plain",
".log": "text/plain",
".env": "text/plain",
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf"
};
return types[ext] || "application/octet-stream";
}
function isTextFile(filePath) {
var textExtensions = [".js", ".ts", ".json", ".md", ".html", ".css", ".yaml", ".yml",
".xml", ".sql", ".sh", ".py", ".rb", ".go", ".rs", ".java", ".txt", ".log",
".env", ".toml", ".ini", ".cfg", ".pug", ".ejs", ".hbs"];
return textExtensions.indexOf(path.extname(filePath).toLowerCase()) !== -1;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
// Start server
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("Filesystem MCP server running, base: " + BASE_DIR);
});
File Watching and Change Notifications
var chokidar = require("chokidar");
// Watch for file changes and notify subscribed clients
var watchers = {};
server.setRequestHandler("resources/subscribe", function(request) {
var uri = request.params.uri;
var filePath = uriToPath(uri);
if (!filePath || !fs.existsSync(filePath)) {
throw new Error("Cannot watch: " + uri);
}
if (!watchers[uri]) {
var watcher = chokidar.watch(filePath, {
persistent: true,
ignoreInitial: true
});
watcher.on("change", function() {
console.error("File changed: " + uri);
server.notification({
method: "notifications/resources/updated",
params: { uri: uri }
});
});
watcher.on("unlink", function() {
console.error("File deleted: " + uri);
server.notification({
method: "notifications/resources/list_changed"
});
});
watchers[uri] = watcher;
}
return {};
});
server.setRequestHandler("resources/unsubscribe", function(request) {
var uri = request.params.uri;
if (watchers[uri]) {
watchers[uri].close();
delete watchers[uri];
}
return {};
});
// Notify when new files are created
var dirWatcher = chokidar.watch(BASE_DIR, {
persistent: true,
ignoreInitial: true,
ignored: /node_modules|\.git/,
depth: 5
});
dirWatcher.on("add", function() {
server.notification({
method: "notifications/resources/list_changed"
});
});
Filtered File Browsing
// Advanced file listing with filtering and search
server.setRequestHandler("resources/list", function(request) {
var cursor = request.params && request.params.cursor;
var pageSize = 50;
var allFiles = [];
function collectFiles(dir) {
var entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return; // Skip unreadable directories
}
entries.forEach(function(entry) {
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") return;
var fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectFiles(fullPath);
} else {
var stats;
try {
stats = fs.statSync(fullPath);
} catch (e) {
return;
}
var relativePath = path.relative(BASE_DIR, fullPath).replace(/\\/g, "/");
allFiles.push({
uri: "file:///" + relativePath,
name: entry.name,
description: relativePath + " | " + formatSize(stats.size) + " | Modified: " + stats.mtime.toISOString().slice(0, 10),
mimeType: getMimeType(entry.name)
});
}
});
}
collectFiles(BASE_DIR);
// Sort by modification time (newest first)
allFiles.sort(function(a, b) {
return b.description.localeCompare(a.description);
});
// Pagination
var startIndex = cursor ? parseInt(cursor) : 0;
var page = allFiles.slice(startIndex, startIndex + pageSize);
var nextCursor = startIndex + pageSize < allFiles.length
? String(startIndex + pageSize)
: undefined;
return {
resources: page,
nextCursor: nextCursor
};
});
Database Resource Provider
PostgreSQL Resource Server
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var pg = require("pg");
var pool = new pg.Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
idleTimeoutMillis: 30000
});
var server = new Server(
{ name: "database-resources", version: "1.0.0" },
{ capabilities: { resources: {} } }
);
// List database resources (schemas, tables, views)
server.setRequestHandler("resources/list", function() {
return pool.query(
"SELECT table_schema, table_name, table_type " +
"FROM information_schema.tables " +
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') " +
"ORDER BY table_schema, table_name"
).then(function(result) {
var resources = [];
// Add schema overview resource
resources.push({
uri: "db://schema/overview",
name: "Database Schema Overview",
description: "Complete schema with all tables, columns, and relationships",
mimeType: "application/json"
});
// Add individual table resources
result.rows.forEach(function(row) {
resources.push({
uri: "db://table/" + row.table_schema + "/" + row.table_name,
name: row.table_name,
description: row.table_type + " in " + row.table_schema + " schema",
mimeType: "application/json"
});
});
return { resources: resources };
});
});
// Read database resources
server.setRequestHandler("resources/read", function(request) {
var uri = request.params.uri;
if (uri === "db://schema/overview") {
return getSchemaOverview();
}
var tableMatch = uri.match(/^db:\/\/table\/([^/]+)\/([^/]+)$/);
if (tableMatch) {
return getTableResource(tableMatch[1], tableMatch[2]);
}
var queryMatch = uri.match(/^db:\/\/query\/(.+)$/);
if (queryMatch) {
return getQueryResource(decodeURIComponent(queryMatch[1]));
}
throw new Error("Unknown resource URI: " + uri);
});
// Resource templates for dynamic queries
server.setRequestHandler("resources/templates/list", function() {
return {
resourceTemplates: [
{
uriTemplate: "db://table/{schema}/{table}",
name: "Table details",
description: "Schema, sample data, and statistics for a database table",
mimeType: "application/json"
},
{
uriTemplate: "db://query/{encoded_sql}",
name: "Query result",
description: "Results of a read-only SQL query (SELECT only)",
mimeType: "application/json"
}
]
};
});
function getSchemaOverview() {
return pool.query(
"SELECT c.table_schema, c.table_name, c.column_name, c.data_type, " +
"c.is_nullable, c.column_default, " +
"tc.constraint_type, kcu.referenced_table_name " +
"FROM information_schema.columns c " +
"LEFT JOIN information_schema.key_column_usage kcu " +
" ON c.table_schema = kcu.table_schema " +
" AND c.table_name = kcu.table_name " +
" AND c.column_name = kcu.column_name " +
"LEFT JOIN information_schema.table_constraints tc " +
" ON kcu.constraint_name = tc.constraint_name " +
" AND kcu.table_schema = tc.table_schema " +
"WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema') " +
"ORDER BY c.table_schema, c.table_name, c.ordinal_position"
).then(function(result) {
var tables = {};
result.rows.forEach(function(row) {
var key = row.table_schema + "." + row.table_name;
if (!tables[key]) {
tables[key] = { schema: row.table_schema, name: row.table_name, columns: [] };
}
tables[key].columns.push({
name: row.column_name,
type: row.data_type,
nullable: row.is_nullable === "YES",
default: row.column_default,
constraint: row.constraint_type || null
});
});
var overview = Object.values(tables);
return {
contents: [{
uri: "db://schema/overview",
mimeType: "application/json",
text: JSON.stringify(overview, null, 2)
}]
};
});
}
function getTableResource(schema, table) {
// Validate table name to prevent SQL injection
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(schema) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {
throw new Error("Invalid schema or table name");
}
var queries = [
// Column details
pool.query(
"SELECT column_name, data_type, is_nullable, column_default " +
"FROM information_schema.columns " +
"WHERE table_schema = $1 AND table_name = $2 " +
"ORDER BY ordinal_position",
[schema, table]
),
// Row count
pool.query("SELECT COUNT(*) as count FROM " + schema + "." + table),
// Sample rows
pool.query("SELECT * FROM " + schema + "." + table + " LIMIT 5"),
// Indexes
pool.query(
"SELECT indexname, indexdef FROM pg_indexes " +
"WHERE schemaname = $1 AND tablename = $2",
[schema, table]
)
];
return Promise.all(queries).then(function(results) {
var tableInfo = {
schema: schema,
table: table,
columns: results[0].rows,
rowCount: parseInt(results[1].rows[0].count),
sampleRows: results[2].rows,
indexes: results[3].rows
};
return {
contents: [{
uri: "db://table/" + schema + "/" + table,
mimeType: "application/json",
text: JSON.stringify(tableInfo, null, 2)
}]
};
});
}
function getQueryResource(encodedSql) {
var sql = decodeURIComponent(encodedSql);
// Security: only allow SELECT statements
var normalized = sql.trim().toUpperCase();
if (!normalized.startsWith("SELECT") && !normalized.startsWith("WITH")) {
throw new Error("Only SELECT queries are allowed as resources");
}
// Prevent destructive statements hidden in CTEs
var forbidden = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "GRANT", "REVOKE"];
var hasForbidden = forbidden.some(function(keyword) {
return normalized.indexOf(keyword) !== -1 && normalized.indexOf("SELECT") < normalized.indexOf(keyword);
});
if (hasForbidden) {
throw new Error("Query contains forbidden statements");
}
// Add row limit for safety
if (normalized.indexOf("LIMIT") === -1) {
sql = sql + " LIMIT 100";
}
return pool.query(sql).then(function(result) {
return {
contents: [{
uri: "db://query/" + encodedSql,
mimeType: "application/json",
text: JSON.stringify({
query: sql,
rowCount: result.rows.length,
columns: result.fields.map(function(f) { return { name: f.name, type: f.dataTypeID }; }),
rows: result.rows
}, null, 2)
}]
};
});
}
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("Database MCP server running");
});
Streaming Large Resources
Large files and query results need streaming to avoid memory exhaustion.
// Streaming resource handler for large files
server.setRequestHandler("resources/read", function(request) {
var uri = request.params.uri;
var filePath = uriToPath(uri);
if (!filePath) throw new Error("Invalid URI");
var stats = fs.statSync(filePath);
// For files under 1MB, return directly
if (stats.size < 1024 * 1024) {
return {
contents: [{
uri: uri,
mimeType: getMimeType(filePath),
text: fs.readFileSync(filePath, "utf8")
}]
};
}
// For large files, return a summary with chunks
var lineCount = 0;
var content = fs.readFileSync(filePath, "utf8");
var lines = content.split("\n");
lineCount = lines.length;
// Return first and last portions with metadata
var HEAD_LINES = 100;
var TAIL_LINES = 50;
var head = lines.slice(0, HEAD_LINES).join("\n");
var tail = lines.slice(-TAIL_LINES).join("\n");
var summary = "=== FILE INFO ===\n"
+ "Path: " + filePath + "\n"
+ "Size: " + formatSize(stats.size) + "\n"
+ "Lines: " + lineCount + "\n"
+ "Modified: " + stats.mtime.toISOString() + "\n"
+ "\n=== FIRST " + HEAD_LINES + " LINES ===\n"
+ head
+ "\n\n=== ... " + (lineCount - HEAD_LINES - TAIL_LINES) + " lines omitted ===\n"
+ "\n=== LAST " + TAIL_LINES + " LINES ===\n"
+ tail;
return {
contents: [{
uri: uri,
mimeType: "text/plain",
text: summary
}]
};
});
Database Cursor-Based Streaming
// Stream large query results with cursor pagination
function getQueryResourceStreaming(sql, pageSize, cursor) {
pageSize = pageSize || 50;
var offset = cursor ? parseInt(cursor) : 0;
var pagedSql = sql.replace(/;?\s*$/, "") + " LIMIT " + pageSize + " OFFSET " + offset;
return Promise.all([
pool.query(pagedSql),
pool.query("SELECT COUNT(*) as total FROM (" + sql.replace(/;?\s*$/, "") + ") as subquery")
]).then(function(results) {
var rows = results[0].rows;
var total = parseInt(results[1].rows[0].total);
var hasMore = offset + rows.length < total;
return {
contents: [{
uri: "db://query/paged",
mimeType: "application/json",
text: JSON.stringify({
query: sql,
page: Math.floor(offset / pageSize) + 1,
pageSize: pageSize,
totalRows: total,
returnedRows: rows.length,
hasMore: hasMore,
rows: rows
}, null, 2)
}],
nextCursor: hasMore ? String(offset + pageSize) : undefined
};
});
}
Caching Strategies
// Resource cache with TTL and invalidation
var cache = {};
function CacheEntry(content, ttlMs) {
this.content = content;
this.expiresAt = Date.now() + ttlMs;
this.hits = 0;
}
CacheEntry.prototype.isExpired = function() {
return Date.now() > this.expiresAt;
};
function getCached(uri, ttlMs, fetchFn) {
var entry = cache[uri];
if (entry && !entry.isExpired()) {
entry.hits++;
return Promise.resolve(entry.content);
}
return fetchFn().then(function(content) {
cache[uri] = new CacheEntry(content, ttlMs);
return content;
});
}
function invalidateCache(uriPattern) {
Object.keys(cache).forEach(function(key) {
if (key.indexOf(uriPattern) !== -1 || uriPattern === "*") {
delete cache[key];
}
});
}
// Cache stats for monitoring
function getCacheStats() {
var stats = { entries: 0, hits: 0, expired: 0, sizeEstimate: 0 };
Object.keys(cache).forEach(function(key) {
var entry = cache[key];
stats.entries++;
stats.hits += entry.hits;
if (entry.isExpired()) stats.expired++;
stats.sizeEstimate += JSON.stringify(entry.content).length;
});
return stats;
}
// Usage in resource handler
server.setRequestHandler("resources/read", function(request) {
var uri = request.params.uri;
if (uri.startsWith("db://")) {
// Cache database resources for 5 minutes
return getCached(uri, 5 * 60 * 1000, function() {
return fetchDatabaseResource(uri);
});
}
if (uri.startsWith("file:///")) {
// Cache file resources for 30 seconds
return getCached(uri, 30 * 1000, function() {
return fetchFileResource(uri);
});
}
throw new Error("Unknown URI scheme");
});
Complete Working Example: Combined File + Database MCP Server
var Server = require("@modelcontextprotocol/sdk/server/index.js").Server;
var StdioTransport = require("@modelcontextprotocol/sdk/server/stdio.js").StdioServerTransport;
var fs = require("fs");
var path = require("path");
var pg = require("pg");
// ============================================================
// Combined File System + Database MCP Resource Server
// Provides AI models with structured access to project files
// and database content through the MCP resource protocol
// ============================================================
var config = {
baseDir: process.env.MCP_BASE_DIR || process.cwd(),
databaseUrl: process.env.DATABASE_URL,
maxFileSize: 1024 * 1024, // 1MB
maxQueryRows: 100,
cacheTtlMs: 60000 // 1 minute
};
var pool = config.databaseUrl ? new pg.Pool({
connectionString: config.databaseUrl,
max: 3,
idleTimeoutMillis: 30000
}) : null;
var resourceCache = {};
var server = new Server(
{ name: "project-resources", version: "1.0.0" },
{ capabilities: { resources: { subscribe: true, listChanged: true } } }
);
// ---- Resource Listing ----
server.setRequestHandler("resources/list", function() {
var resources = [];
// File resources
collectFileResources(config.baseDir, "", resources);
// Database resources (if configured)
if (pool) {
return pool.query(
"SELECT table_schema, table_name FROM information_schema.tables " +
"WHERE table_schema = 'public' ORDER BY table_name"
).then(function(result) {
resources.push({
uri: "db://schema",
name: "Database Schema",
description: "Complete database schema overview",
mimeType: "application/json"
});
result.rows.forEach(function(row) {
resources.push({
uri: "db://table/" + row.table_name,
name: "Table: " + row.table_name,
description: "Schema and sample data for " + row.table_name,
mimeType: "application/json"
});
});
return { resources: resources };
});
}
return { resources: resources };
});
function collectFileResources(dir, prefix, resources) {
var entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return;
}
entries.forEach(function(entry) {
if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") return;
var fullPath = path.join(dir, entry.name);
var relativePath = path.relative(config.baseDir, fullPath).replace(/\\/g, "/");
if (entry.isDirectory()) {
collectFileResources(fullPath, prefix + entry.name + "/", resources);
} else {
var stats;
try { stats = fs.statSync(fullPath); } catch (e) { return; }
resources.push({
uri: "file:///" + relativePath,
name: entry.name,
description: relativePath + " (" + formatSize(stats.size) + ")",
mimeType: getMimeType(entry.name)
});
}
});
}
// ---- Resource Reading ----
server.setRequestHandler("resources/read", function(request) {
var uri = request.params.uri;
// Check cache first
var cached = resourceCache[uri];
if (cached && Date.now() < cached.expiresAt) {
return cached.content;
}
var result;
if (uri.startsWith("file:///")) {
result = readFileResource(uri);
} else if (uri === "db://schema") {
result = readSchemaResource();
} else if (uri.startsWith("db://table/")) {
var tableName = uri.replace("db://table/", "");
result = readTableResource(tableName);
} else {
throw new Error("Unknown URI scheme: " + uri);
}
// Cache the result
return Promise.resolve(result).then(function(content) {
resourceCache[uri] = {
content: content,
expiresAt: Date.now() + config.cacheTtlMs
};
return content;
});
});
function readFileResource(uri) {
var relativePath = uri.slice(8); // Remove "file:///"
var fullPath = path.join(config.baseDir, relativePath);
var resolved = path.resolve(fullPath);
// Path traversal protection
if (!resolved.startsWith(path.resolve(config.baseDir))) {
throw new Error("Access denied: path outside project directory");
}
if (!fs.existsSync(resolved)) {
throw new Error("File not found: " + relativePath);
}
var stats = fs.statSync(resolved);
if (stats.size > config.maxFileSize) {
// Return truncated content for large files
var content = fs.readFileSync(resolved, "utf8");
var lines = content.split("\n");
var truncated = lines.slice(0, 200).join("\n")
+ "\n\n... (" + (lines.length - 200) + " more lines, "
+ formatSize(stats.size) + " total)";
return {
contents: [{
uri: uri,
mimeType: getMimeType(resolved),
text: truncated
}]
};
}
var text = fs.readFileSync(resolved, "utf8");
return {
contents: [{
uri: uri,
mimeType: getMimeType(resolved),
text: text
}]
};
}
function readSchemaResource() {
if (!pool) throw new Error("Database not configured");
return pool.query(
"SELECT c.table_name, c.column_name, c.data_type, c.is_nullable, " +
"c.column_default, c.character_maximum_length " +
"FROM information_schema.columns c " +
"WHERE c.table_schema = 'public' " +
"ORDER BY c.table_name, c.ordinal_position"
).then(function(result) {
var tables = {};
result.rows.forEach(function(row) {
if (!tables[row.table_name]) {
tables[row.table_name] = { name: row.table_name, columns: [] };
}
tables[row.table_name].columns.push({
name: row.column_name,
type: row.data_type + (row.character_maximum_length ? "(" + row.character_maximum_length + ")" : ""),
nullable: row.is_nullable === "YES",
default: row.column_default
});
});
return {
contents: [{
uri: "db://schema",
mimeType: "application/json",
text: JSON.stringify(Object.values(tables), null, 2)
}]
};
});
}
function readTableResource(tableName) {
if (!pool) throw new Error("Database not configured");
// Validate table name
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
throw new Error("Invalid table name");
}
return Promise.all([
pool.query(
"SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
"WHERE table_schema = 'public' AND table_name = $1 ORDER BY ordinal_position",
[tableName]
),
pool.query("SELECT COUNT(*) as count FROM public." + tableName),
pool.query("SELECT * FROM public." + tableName + " LIMIT 10")
]).then(function(results) {
var info = {
table: tableName,
columns: results[0].rows,
totalRows: parseInt(results[1].rows[0].count),
sampleRows: results[2].rows,
sampleRowCount: results[2].rows.length
};
return {
contents: [{
uri: "db://table/" + tableName,
mimeType: "application/json",
text: JSON.stringify(info, null, 2)
}]
};
});
}
// ---- Resource Templates ----
server.setRequestHandler("resources/templates/list", function() {
var templates = [
{
uriTemplate: "file:///{path}",
name: "Project file",
description: "Read any file in the project by relative path"
}
];
if (pool) {
templates.push({
uriTemplate: "db://table/{name}",
name: "Database table",
description: "Schema and sample data for a database table"
});
}
return { resourceTemplates: templates };
});
// ---- Helper Functions ----
function getMimeType(filePath) {
var ext = path.extname(filePath).toLowerCase();
var types = {
".js": "text/javascript", ".ts": "text/typescript", ".json": "application/json",
".md": "text/markdown", ".html": "text/html", ".css": "text/css",
".yaml": "text/yaml", ".yml": "text/yaml", ".sql": "text/x-sql",
".py": "text/x-python", ".sh": "text/x-shellscript", ".txt": "text/plain",
".pug": "text/x-pug", ".xml": "text/xml", ".log": "text/plain"
};
return types[ext] || "text/plain";
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / 1048576).toFixed(1) + " MB";
}
// ---- Server Startup ----
var transport = new StdioTransport();
server.connect(transport).then(function() {
console.error("Project Resources MCP Server");
console.error(" Files: " + config.baseDir);
console.error(" Database: " + (pool ? "connected" : "not configured"));
console.error(" Cache TTL: " + (config.cacheTtlMs / 1000) + "s");
});
// Cleanup on exit
process.on("SIGINT", function() {
if (pool) pool.end();
process.exit(0);
});
Server Configuration
{
"mcpServers": {
"project-resources": {
"command": "node",
"args": ["./mcp-servers/project-resources.js"],
"env": {
"MCP_BASE_DIR": "/home/user/projects/myapp",
"DATABASE_URL": "postgresql://user:pass@localhost:5432/myapp"
}
}
}
}
Common Issues & Troubleshooting
"File not found" for Files That Exist
Error: File not found: src\utils\helper.js
URI path separators must be forward slashes, even on Windows. When constructing URIs from file paths:
// Wrong: file:///src\utils\helper.js
// Right: file:///src/utils/helper.js
var uri = "file:///" + relativePath.replace(/\\/g, "/");
Database Resource Returns Empty Schema
{ "tables": [] }
The query filters by table_schema = 'public'. If your tables are in a different schema, adjust the filter:
// Check which schemas have tables
pool.query("SELECT DISTINCT table_schema FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema')")
Resource Response Too Large — Client Timeout
Large resources (database tables with millions of rows, huge log files) cause timeouts:
Error: MCP request timed out after 30000ms
Always limit resource size. For files, truncate to the first 200 lines with a size note. For database tables, return only sample rows with a total count. Never return unbounded results.
Cache Becomes Stale After External File Changes
If files are modified outside the MCP server (by editors, build tools, or git), the cache serves stale content. Use file watching to invalidate:
var chokidar = require("chokidar");
chokidar.watch(config.baseDir, { ignoreInitial: true, ignored: /node_modules/ })
.on("all", function(event, changedPath) {
var relativePath = path.relative(config.baseDir, changedPath).replace(/\\/g, "/");
var uri = "file:///" + relativePath;
delete resourceCache[uri];
});
SQL Injection Through Resource URIs
If you accept SQL in resource URIs (like db://query/{sql}), you must sanitize strictly:
// NEVER do this:
var sql = decodeURIComponent(uri.replace("db://query/", ""));
pool.query(sql); // SQL injection vulnerability
// Instead, whitelist allowed queries or use parameterized templates:
var ALLOWED_QUERIES = {
"recent-users": "SELECT id, name, created_at FROM users ORDER BY created_at DESC LIMIT 20",
"active-sessions": "SELECT count(*) FROM sessions WHERE expires_at > NOW()"
};
var queryName = uri.replace("db://query/", "");
var sql = ALLOWED_QUERIES[queryName];
if (!sql) throw new Error("Unknown query: " + queryName);
Best Practices
- Always enforce path traversal protection — Resolve the full path and verify it starts with your base directory. Without this check, a URI like
file:///../../../etc/passwdexposes your entire filesystem. - Limit resource response sizes — Set hard limits on file sizes and query row counts. Models work well with representative samples; they do not need every row in a million-record table.
- Use resource templates for parameterized access — Templates like
file:///{path}anddb://table/{name}let models discover what is available without hardcoding every resource. - Cache aggressively, invalidate precisely — File contents rarely change second-to-second. Cache with a 30-60 second TTL and invalidate on file change events for subscribed resources.
- Return metadata alongside content — Include file size, line count, modification date, and row counts in your responses. This context helps models make better decisions about what to request next.
- Validate table and column names strictly — Even for read-only operations, validate that identifiers match
^[a-zA-Z_][a-zA-Z0-9_]*$. Never interpolate unsanitized input into SQL. - Separate read and write concerns — Resources should be read-only. If the model needs to modify files or data, provide tools for that. This separation makes security review straightforward.
- Log resource access for auditing — Track which resources are accessed, how often, and by whom. This data helps you understand usage patterns and detect anomalous access.