genaiscript
Version:
A CLI for GenAIScript, a generative AI scripting framework.
343 lines • 15.9 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { CHANGE, CORE_VERSION, RESOURCE_CHANGE, TOOL_ID, SERVER_PORT, deleteUndefinedValues, ensureDotGenaiscriptPath, errorMessage, genaiscriptDebug, logVerbose, logWarn, normalizeInt, resolveRuntimeHost, setConsoleColors, splitMarkdownTextImageParts, toStrictJSONSchema, mcpRequestSample, } from "@genaiscript/core";
import { run } from "@genaiscript/api";
import { ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
import { applyRemoteOptions } from "./remote.js";
import { startProjectWatcher } from "./watch.js";
import { findOpenPort } from "./port.js";
const dbg = genaiscriptDebug("mcp:server");
/**
* Starts the MCP server.
*
* @param options - Configuration options for the server that may include script filtering options, remote settings, and startup script.
* - `options.scriptFilter` - Defines filters to apply to script discovery.
* - `options.remote` - Configuration for remote execution and related options.
* - `options.startup` - Specifies a startup script to run after the server starts.
*
* Initializes and sets up the server with appropriate request handlers for listing tools, executing specific tool commands, listing resources, and reading resource contents. Monitors project changes through a watcher and updates the tool list and resource list when changes occur. Uses a transport layer to handle server communication over standard I/O.
*/
export async function startMcpServer(options) {
setConsoleColors(false);
logVerbose(`mcp server: starting...`);
const runtimeHost = resolveRuntimeHost();
await ensureDotGenaiscriptPath();
await applyRemoteOptions(options);
const { startup, http, port: portStr, network } = options || {};
let samplingSupported = false;
const watcher = await startProjectWatcher(options);
logVerbose(`mcp server: watching ${watcher.cwd}`);
const { Server } = await import("@modelcontextprotocol/sdk/server/index.js");
const { CallToolRequestSchema, ListToolsRequestSchema } = await import("@modelcontextprotocol/sdk/types.js");
const server = new Server({
name: TOOL_ID,
version: CORE_VERSION,
}, {
capabilities: {
tools: {
listChanged: true,
},
resources: {
listChanged: true,
},
},
});
watcher.addEventListener("change", async () => {
logVerbose(`mcp server: tools changed`);
await server.sendToolListChanged();
}, false);
const onMessage = async (data, postMessage) => {
if (data.type === RESOURCE_CHANGE) {
await runtimeHost.resources.upsertResource(data.reference, data.content);
}
else if (data.type === "chatCompletion") {
if (!samplingSupported)
throw new Error("Sampling not supported by client");
// Handle chat completion messages if needed
dbg(`chatCompletion message received: %O`, data);
const { request, ...rest } = data;
const response = await mcpRequestSample(server, data.request);
const msg = { ...rest, response };
dbg(`chatCompletion response: %O`, msg);
postMessage(msg);
}
else {
dbg(`unknown message type: ${data.type}`);
}
};
server.setRequestHandler(ListToolsRequestSchema, async () => {
dbg(`fetching scripts from watcher`);
const scripts = await watcher.scripts();
const tools = scripts
.map((script) => {
const { id, title, description, inputSchema, accept, annotations = {}, responseSchema, } = script;
const scriptSchema = inputSchema?.properties.script || {
type: "object",
properties: {},
};
const outputSchema = responseSchema ? toStrictJSONSchema(responseSchema) : undefined;
if (accept !== "none") {
scriptSchema.properties.files = {
type: "array",
items: {
type: "string",
description: `Filename or globs relative to the workspace used by the script.${accept ? ` Accepts: ${accept}` : ""}`,
},
};
}
if (!description)
logWarn(`script ${id} has no description`);
return deleteUndefinedValues({
name: id,
description,
inputSchema: scriptSchema,
outputSchema,
annotations: {
...annotations,
title,
},
});
})
.filter((t) => !!t);
dbg(`returning tool list with ${tools.length} tools`);
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (req) => {
dbg(`received CallToolRequest with name: ${req.params?.name}`);
const { name, arguments: args } = req.params;
try {
const { files, ...vars } = args || {};
dbg(`executing tool: ${name} with files: ${files} and vars: ${JSON.stringify(vars)}`);
const res = (await run(name, files, {
vars: vars,
runTrace: false,
outputTrace: false,
parentLanguageModel: samplingSupported,
onMessage,
})) || { status: "error", error: { message: "run failed" } };
dbg(`res: %s`, res.status);
if (res.error)
dbg(`error: %O`, res.error);
const isError = res.status !== "success" || !!res.error;
const text = res?.error?.message || (res.json ? JSON.stringify(res.json) : res.text) || "";
dbg(`inlining images`);
const parts = await splitMarkdownTextImageParts(text, {
dir: res.env?.runDir,
convertToDataUri: true,
});
dbg(`parts: %O`, parts);
return {
isError,
content: parts,
};
}
catch (err) {
dbg("%O", err);
return {
isError: true,
content: [
{
type: "text",
text: errorMessage(err),
},
],
};
}
});
server.setRequestHandler(ListResourcesRequestSchema, async () => {
dbg(`list resources`);
const resources = await runtimeHost.resources.resources();
dbg(`found ${resources.length} resources`);
return {
resources: resources.map((r) => r),
};
});
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
dbg(`list resource templates - not supported`);
return { resourceTemplates: [] };
});
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
const { uri } = req.params;
dbg(`read resource: ${uri}`);
const resource = await runtimeHost.resources.readResource(uri);
if (!resource)
dbg(`resource not found: ${uri}`);
return resource;
});
runtimeHost.resources.addEventListener(CHANGE, async () => {
await server.sendResourceListChanged();
}, false);
runtimeHost.resources.addEventListener(RESOURCE_CHANGE, async (e) => {
const ev = e;
await server.sendResourceUpdated({
uri: ev.detail.reference.uri,
});
}, false);
server.oninitialized = async () => {
dbg(`server/client connection initialized`);
// Check if client supports sampling
const clientCapabilities = server.getClientCapabilities();
dbg(`client capabilities: %O`, clientCapabilities);
samplingSupported = !!clientCapabilities?.sampling;
if (startup) {
logVerbose(`startup script: ${startup}`);
await run(startup, [], {
vars: {},
parentLanguageModel: samplingSupported,
onMessage,
});
}
};
// Set up transport based on options
if (http) {
dbg(`setting up HTTP transport with Fastify`);
// HTTP transport setup
const port = await findOpenPort(portStr ? normalizeInt(portStr) : SERVER_PORT, options);
const host = network ? "0.0.0.0" : "127.0.0.1";
dbg(`resolved HTTP server config: host=${host}, port=${port}, network=${network}`);
logVerbose(`mcp server: starting HTTP server on ${host}:${port}`);
const createFastify = (await import("fastify")).default;
const fastifyCors = (await import("@fastify/cors")).default;
const { StreamableHTTPServerTransport } = await import("@modelcontextprotocol/sdk/server/streamableHttp.js");
// Store transports for session management
const transports = {};
dbg(`creating Fastify server with proxy support`);
// Create Fastify server with proxy trust configuration
const fastify = createFastify({
logger: false,
trustProxy: true, // Enable proxy support for X-Forwarded-* headers
});
// Register CORS support with proxy-aware configuration
await fastify.register(fastifyCors, {
origin: true, // Allow dynamic origin based on request headers (proxy-friendly)
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"X-Forwarded-For",
"X-Forwarded-Proto",
"X-Forwarded-Host",
],
credentials: false, // Keep false for security when using dynamic origin
});
// MCP endpoint handler with proxy support
dbg(`registering MCP endpoint handler with proxy awareness`);
fastify.all("/mcp", async (request, reply) => {
// Log client information (proxy-aware)
const clientIP = request.ip; // Fastify automatically uses X-Forwarded-For when trustProxy is enabled
dbg(`received HTTP request: ${request.method} ${request.url} from ${clientIP} (${request.protocol}://${request.host})`);
// Handle OPTIONS preflight requests
if (request.method === "OPTIONS") {
dbg(`handling OPTIONS request from ${clientIP}`);
reply.status(200).send();
return;
}
dbg(`handling MCP endpoint request from ${clientIP}`);
try {
// Get raw Node.js request and response objects for MCP transport
const req = request.raw;
const res = reply.raw;
// Get or create session ID from headers
const existingSessionId = req.headers["mcp-session-id"];
let transport;
if (existingSessionId && transports[existingSessionId]) {
// Reuse existing transport for this session
transport = transports[existingSessionId];
dbg(`reusing existing transport for session: ${existingSessionId} (client: ${clientIP})`);
}
else {
// Create new transport for new session or initialization
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
onsessioninitialized: (sessionId) => {
dbg(`session initialized: ${sessionId} for client ${clientIP}`);
transports[sessionId] = transport;
},
onsessionclosed: (sessionId) => {
dbg(`session closed: ${sessionId} for client ${clientIP}`);
delete transports[sessionId];
},
});
// Set up transport close handler
transport.onclose = () => {
const sessionId = transport.sessionId;
if (sessionId && transports[sessionId]) {
dbg(`transport closed for session ${sessionId}, cleaning up`);
delete transports[sessionId];
}
};
// Connect the transport to the MCP server only for new transports
dbg(`connecting new transport to server for client ${clientIP}`);
await server.connect(transport);
}
// Handle the HTTP request using the transport
dbg(`request from ${clientIP}`);
await transport.handleRequest(req, res, request.body);
}
catch (error) {
dbg(`HTTP transport error for client ${clientIP}: ${errorMessage(error)}`);
reply.status(500).send({ error: errorMessage(error) });
}
});
// Health check endpoint for proxies and load balancers
dbg(`registering health check endpoint`);
fastify.get("/health", async (request, reply) => {
const clientIP = request.ip;
dbg(`health check request from ${clientIP}`);
reply.status(200).send({
status: "ok",
service: "genaiscript-mcp-server",
version: CORE_VERSION,
transport: "http",
});
});
// 404 handler for other paths
fastify.setNotFoundHandler((request, reply) => {
const clientIP = request.ip;
dbg(`request to unknown path: ${request.url} from ${clientIP}`);
reply.status(404).send({
error: "Not found. Use /mcp endpoint for MCP protocol or /health for health checks.",
});
});
// Global error handler
fastify.setErrorHandler((error, request, reply) => {
dbg(`Fastify error: ${errorMessage(error)}`);
logVerbose(`HTTP server error: ${errorMessage(error)}`);
reply.status(error.statusCode || 500).send({
error: errorMessage(error),
});
});
// Start Fastify server
dbg(`starting Fastify server on ${host}:${port}`);
await fastify.listen({
port,
host,
});
dbg(`Fastify server listening on ${host}:${port} with proxy support`);
console.log(`GenAIScript MCP server v${CORE_VERSION}`);
console.log(`│ Transport: HTTP (proxy-aware)`);
console.log(`│ Endpoint: http://${host}:${port}/mcp`);
console.log(`│ Health: http://${host}:${port}/health`);
console.log(`│ Access: ${network ? "Network (0.0.0.0)" : "Local (127.0.0.1)"}`);
console.log(`│ Proxy: Trusted (X-Forwarded-* headers supported)`);
}
else {
dbg(`using stdio transport`);
// Stdio transport (default)
logVerbose(`mcp server: using stdio transport`);
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
const transport = new StdioServerTransport();
dbg(`connecting server with stdio transport`);
await server.connect(transport);
}
if (startup) {
dbg(`running startup script: ${startup}`);
logVerbose(`startup script: ${startup}`);
await run(startup, [], {
vars: {},
parentLanguageModel: samplingSupported,
onMessage,
});
}
}
//# sourceMappingURL=mcpserver.js.map