Skip to content
Open
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
142 changes: 3 additions & 139 deletions bin/server.js
Original file line number Diff line number Diff line change
@@ -1,148 +1,12 @@
#!/usr/bin/env node

import express from 'express';
import cors from 'cors';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath, URL } from 'url';

// Helper to determine __dirname in ES modules.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();

// Enable CORS and JSON parsing middleware.
app.use(cors());
app.use(express.json());

// Helper functions for JSON-RPC responses.
function createResponse (id, result) {
return {
jsonrpc: "2.0",
id: id,
result: result
};
}

function createErrorResponse (id, code, message) {
return {
jsonrpc: "2.0",
id: id,
error: {
code: code,
message: message
}
};
}

// Load all plugins from both the plugins and private-plugins folders.
const plugins = new Map();
const pluginDirs = [
new URL('../plugins/', import.meta.url),
new URL('../private-plugins/', import.meta.url)
];

try {
for (const pluginsDir of pluginDirs) {
try {
const files = await fs.readdir(pluginsDir);
for (const file of files) {
if (file.endsWith('.js')) {
const moduleUrl = new URL(file, pluginsDir);
const plugin = await import(moduleUrl.href);
if (typeof plugin.tool === 'function') {
const toolConfig = plugin.tool();
plugins.set(toolConfig.name, {
handler: toolConfig.fn,
capability: {
description: toolConfig.description,
params: {
type: "object",
properties: Object.fromEntries(
toolConfig.inputs?.map(input => [
input.name,
{
type: input.type,
description: input.description
}
]) || []
)
}
}
});
console.log(`Loaded plugin: ${toolConfig.name}`);
} else {
console.warn(`Skipping plugin file ${file} (missing 'tool' function)`);
}
}
}
} catch (err) {
// If directory doesn't exist, just continue
if (err.code === 'ENOENT') {
console.log(`Optional plugin directory not found: ${pluginsDir}`);
continue;
}
throw err;
}
}
} catch (err) {
console.error("Error loading plugins:", err);
}

// Handle POST requests to the root endpoint.
app.post('/', async (req, res) => {
try {
const message = req.body;
console.log("Received message:", message);

// Validate JSON-RPC request.
if (!message.jsonrpc || message.jsonrpc !== "2.0") {
return res.json(createErrorResponse(message.id, -32600, "Invalid JSON-RPC request"));
}
if (!message.method) {
return res.json(createErrorResponse(message.id, -32600, "Method is required"));
}

// Special handling for the "initialize" method:
// List available capabilities from the loaded plugins.
if (message.method === "initialize") {
const capabilities = {};
plugins.forEach((plugin, method) => {
if (plugin.capability) {
capabilities[method] = plugin.capability;
}
});
const response = {
capabilities,
serverInfo: {
name: "Pluggable MCP Server",
version: "1.0.0"
}
};
return res.json(createResponse(message.id, response));
}

// Lookup the plugin for the requested method.
const plugin = plugins.get(message.method);
if (!plugin) {
return res.json(createErrorResponse(message.id, -32601, "Method not found"));
}

// Execute the plugin's handler.
// The handler may be synchronous or return a Promise.
const result = await plugin.handler(message.params);
res.json(createResponse(message.id, result));
} catch (err) {
console.error("Error processing request:", err);
res.status(500).json(createErrorResponse(null, -32603, "Internal server error"));
}
});
import { createMcpApp } from '../index.js';

const DEFAULT_PORT = 4333;

// Listen on port 4333.
const PORT = process.env.PORT || DEFAULT_PORT;
const app = await createMcpApp();

app.listen(PORT, '0.0.0.0', () => {
console.log(`Server listening on port ${PORT}`);
});
132 changes: 132 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import cors from 'cors';
import express from 'express';
import fs from 'fs/promises';

function createResponse(id, result) {
return {
jsonrpc: "2.0",
id,
result
};
}

function createErrorResponse(id, code, message) {
return {
jsonrpc: "2.0",
id,
error: {
code,
message
}
};
}

async function loadPlugins(pluginDirs, logger) {
const plugins = new Map();

for (const pluginsDir of pluginDirs) {
try {
const files = await fs.readdir(pluginsDir);
for (const file of files) {
if (!file.endsWith('.js')) {
continue;
}

const moduleUrl = new URL(file, pluginsDir);
const plugin = await import(moduleUrl.href);
if (typeof plugin.tool !== 'function') {
logger.warn(`Skipping plugin file ${file} (missing 'tool' function)`);
continue;
}

const toolConfig = plugin.tool();
plugins.set(toolConfig.name, {
handler: toolConfig.fn,
capability: {
description: toolConfig.description,
params: {
type: "object",
properties: Object.fromEntries(
toolConfig.inputs?.map((input) => [
input.name,
{
type: input.type,
description: input.description
}
]) || []
)
}
}
});
logger.log(`Loaded plugin: ${toolConfig.name}`);
}
} catch (err) {
if (err.code === 'ENOENT') {
logger.log(`Optional plugin directory not found: ${pluginsDir}`);
continue;
}
throw err;
}
}

return plugins;
}

export async function createMcpApp(options = {}) {
const logger = options.logger ?? console;
const pluginDirs = options.pluginDirs ?? [
new URL('./plugins/', import.meta.url),
new URL('./private-plugins/', import.meta.url)
];
const plugins = await loadPlugins(pluginDirs, logger);
const app = express();

app.use(cors());
app.use(express.json());

app.post('/', async (req, res) => {
try {
const message = req.body;
logger.log("Received message:", message);

if (!message.jsonrpc || message.jsonrpc !== "2.0") {
return res.json(createErrorResponse(message.id, -32600, "Invalid JSON-RPC request"));
}
if (!message.method) {
return res.json(createErrorResponse(message.id, -32600, "Method is required"));
}

if (message.method === "initialize") {
const capabilities = {};
plugins.forEach((plugin, method) => {
if (plugin.capability) {
capabilities[method] = plugin.capability;
}
});
const response = {
capabilities,
serverInfo: {
name: "Pluggable MCP Server",
version: "1.0.0"
}
};
return res.json(createResponse(message.id, response));
}

const plugin = plugins.get(message.method);
if (!plugin) {
return res.json(createErrorResponse(message.id, -32601, "Method not found"));
}

const result = await plugin.handler(message.params);
return res.json(createResponse(message.id, result));
} catch (err) {
logger.error("Error processing request:", err);
return res.status(500).json(createErrorResponse(null, -32603, "Internal server error"));
}
});

return app;
}

export { createErrorResponse, createResponse };
7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "mcp server",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test"
},
"bin": {
"mcp-server": "bin/server.js"
Expand Down
43 changes: 43 additions & 0 deletions test/entrypoint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMcpApp } from '../index.js';

const silentLogger = {
error() {},
log() {},
warn() {}
};

test('root entrypoint exports an app factory', async () => {
assert.equal(typeof createMcpApp, 'function');
const app = await createMcpApp({ logger: silentLogger });
assert.equal(typeof app.listen, 'function');
});

test('created app exposes loaded plugin capabilities', async (t) => {
const app = await createMcpApp({ logger: silentLogger });
const server = app.listen(0, '127.0.0.1');
t.after(() => server.close());

await new Promise((resolve) => server.once('listening', resolve));
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize'
})
});
const body = await response.json();

assert.equal(response.status, 200);
assert.equal(body.jsonrpc, '2.0');
assert.equal(body.id, 1);
assert.equal(body.result.serverInfo.name, 'Pluggable MCP Server');
assert.ok(body.result.capabilities.echo);
assert.ok(body.result.capabilities.helloworld);
});