@zxkane/quip-mcp-server
Version:
MCP server for interacting with Quip spreadsheets
661 lines • 29.8 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.main = main;
/**
* MCP Server implementation for Quip
*/
const fs = __importStar(require("fs-extra"));
const path = __importStar(require("path"));
const dotenv = __importStar(require("dotenv"));
const url_1 = require("url");
// Import MCP SDK
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const express_1 = __importDefault(require("express"));
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
// Import types from our local definitions
const tools_1 = require("./tools");
// Import error handling
const errors_1 = require("./errors");
// Import logger
const logger_1 = require("./logger");
const cli_1 = require("./cli");
const version_1 = require("./version");
const storage_1 = require("./storage");
/**
* Format response data as SSE (Server-Sent Events) payload
* @param data Response data to format
* @returns SSE formatted string
*/
function formatSSE(data) {
const jsonData = JSON.stringify(data);
return `data: ${jsonData}\n\n`;
}
/**
* Determine response format based on Accept header and SSE support
* @param req Express request object
* @param sseEnabled Whether SSE is enabled in options
* @returns Whether to use SSE format
*/
function shouldUseSSE(req, sseEnabled) {
if (!sseEnabled) {
// If SSE is disabled in options, never use SSE
return false;
}
const acceptHeader = req.get('Accept') || '';
logger_1.logger.debug(`Accept header: ${acceptHeader}`);
// Check if client specifically requests SSE
const supportsSSE = acceptHeader.includes('text/event-stream');
const supportsJSON = acceptHeader.includes('application/json') || acceptHeader.includes('*/*');
// If client supports both, prefer SSE when enabled
// If client only supports JSON, use JSON even if SSE is enabled
if (supportsSSE) {
return true;
}
else if (supportsJSON) {
return false;
}
// Default behavior when no specific Accept header
return sseEnabled;
}
/**
* Send SSE response
* @param res Express response object
* @param data Response data
*/
function sendSSEResponse(res, data) {
// Check if response is still writable and headers haven't been sent
if (res.headersSent || res.writableEnded || res.destroyed) {
logger_1.logger.debug('Response already sent or connection closed, skipping SSE response');
return;
}
try {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control'
});
res.write(formatSSE(data));
res.end();
}
catch (error) {
logger_1.logger.error('Error sending SSE response:', { error: error instanceof Error ? error.message : String(error) });
}
}
// Load environment variables
dotenv.config();
// Global storage instance
let storageInstance = null;
/**
* Generate available resource templates
*
* @returns Array of resource templates
*/
function generateResourceTemplates() {
logger_1.logger.info("Generating resource templates");
// Create template for Quip spreadsheet with thread ID and optional sheet name
const spreadsheetTemplate = {
uriTemplate: "quip://{thread_id}?sheet={sheet_name}",
name: "Quip Spreadsheet",
description: "Access a specific sheet within a Quip spreadsheet document by thread ID and sheet name",
parameterDefinitions: [
{
name: "thread_id",
description: "The Quip document thread ID",
required: true
},
{
name: "sheet_name",
description: "The name of the sheet (if omitted, will use the first sheet)",
required: false
}
]
};
// Add S3 template for S3 storage
const s3SpreadsheetTemplate = {
uriTemplate: "s3://{bucket}/{prefix}{thread_id}-{sheet_name}.csv",
name: "S3 Spreadsheet",
description: "Access a specific sheet within a Quip spreadsheet document stored in S3 by bucket, prefix, thread ID and sheet name",
parameterDefinitions: [
{
name: "bucket",
description: "The S3 bucket name",
required: true
},
{
name: "prefix",
description: "The S3 object key prefix",
required: false
},
{
name: "thread_id",
description: "The Quip document thread ID",
required: true
},
{
name: "sheet_name",
description: "The name of the sheet (if omitted, will use the first sheet)",
required: false
}
]
};
return [spreadsheetTemplate, s3SpreadsheetTemplate];
}
/**
* Discover available resources by scanning the storage
*
* @param isFileProtocol Whether to use file protocol for resource URIs
* @returns Promise resolving to list of available resources
*/
async function discoverResources(isFileProtocol) {
logger_1.logger.info("Discovering resources");
if (!storageInstance) {
logger_1.logger.error("Storage not initialized");
return [];
}
const resources = [];
// Check the actual storage type, not just property presence
const storageType = process.env.STORAGE_TYPE || 'local';
if (storageType === 'local') {
logger_1.logger.info("Local storage detected, scanning for resources");
// Local storage - scan directory for CSV files
const storagePath = storageInstance.storagePath;
try {
// Scan storage directory for CSV files
const files = await fs.readdir(storagePath);
for (const filename of files) {
if (filename.endsWith(".csv") && !filename.endsWith(".meta")) {
// Parse filename to get thread_id and sheet_name
const filePath = path.join(storagePath, filename);
// Extract thread_id and sheet_name from filename
let threadId;
let sheetName;
if (filename.includes("-")) {
// Format: {thread_id}-{sheet_name}.csv
threadId = filename.split("-")[0];
sheetName = filename.split("-").slice(1).join("-").replace(".csv", "");
}
else {
// Format: {thread_id}.csv
threadId = filename.replace(".csv", "");
sheetName = undefined;
}
// Get metadata
const metadata = await storageInstance.getMetadata(threadId, sheetName);
// Create resource URI
let resourceUri;
if (isFileProtocol) {
resourceUri = `file://${filePath}`;
}
else {
// Use resource template to create resource URI
resourceUri = storageInstance.getResourceURI(threadId, sheetName);
}
// Create resource name
let resourceName = `Quip Thread(Spreadsheet): ${threadId}`;
if (sheetName) {
resourceName += ` (Sheet: ${sheetName})`;
}
if (isFileProtocol) {
resourceName += ` You can access the file at: ${filePath}`;
}
// Create resource description
const description = `CSV data from Quip spreadsheet. ${metadata.total_rows || 0} rows, ${metadata.total_size || 0} bytes.`;
// Create resource
const resource = {
uri: resourceUri,
name: resourceName,
description: description,
mime_type: "text/csv"
};
resources.push(resource);
logger_1.logger.info(`Discovered resource: ${resourceUri}`);
}
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger_1.logger.error(`Error discovering resources`, { error: errorMessage });
}
}
else if (storageType === 's3') {
// S3 storage - we can't easily list objects, so we'll just log a message
logger_1.logger.info("S3 storage detected - resource discovery not supported for S3 storage");
logger_1.logger.info("Resources will be accessible via s3:// or https:// URIs when accessed directly");
return []; // Return empty array for S3 to prevent any accidental local resource discovery
}
else {
logger_1.logger.warn("Unknown storage type, cannot discover resources");
}
logger_1.logger.info(`Discovered ${resources.length} resources`);
return resources;
}
/**
* Handle resource access requests
*
* @param uri Resource URI
* @returns Promise resolving to sequence of content objects
*/
async function accessResource(uri) {
logger_1.logger.info(`Handling resource access: ${uri}`);
// Parse the URI
const parsedUri = new url_1.URL(uri);
if (parsedUri.protocol !== 'quip:' && parsedUri.protocol !== 'file:' && parsedUri.protocol !== 's3:') {
logger_1.logger.error(`Unsupported URI scheme: ${parsedUri.protocol}`);
throw new errors_1.ResourceNotFoundError(uri);
}
// Extract thread_id and sheet_name
let threadId;
let sheetName;
if (parsedUri.protocol === 'file:') {
const filename = path.basename(parsedUri.pathname).replace(".csv", "").split("-");
threadId = filename[0];
sheetName = filename.length > 1 ? filename.slice(1).join("-") : undefined;
}
else if (parsedUri.protocol === 's3:') {
// Extract from S3 URI format: s3://{bucket}/{prefix}{threadId}-{sheetName}.csv
// or s3+https://{bucket}/{prefix}{threadId}-{sheetName}.csv
// The path will be /{bucket}/{prefix}{threadId}-{sheetName}.csv
// We need to extract the threadId and sheetName from the key
const pathParts = parsedUri.pathname.split('/');
// The last part is the filename: {threadId}-{sheetName}.csv
const filename = pathParts[pathParts.length - 1].replace(".csv", "").split("-");
threadId = filename[0];
sheetName = filename.length > 1 ? filename.slice(1).join("-") : undefined;
}
else {
// quip:// protocol
threadId = parsedUri.hostname;
const searchParams = new URLSearchParams(parsedUri.search);
sheetName = searchParams.get('sheet') || undefined;
}
logger_1.logger.info(`Accessing resource for thread_id: ${threadId}, sheet_name: ${sheetName || 'default'}`);
// Get the CSV content from storage
if (!storageInstance) {
logger_1.logger.error("Storage not initialized");
throw new errors_1.ResourceNotFoundError(uri);
}
const csvContent = await storageInstance.getCSV(threadId, sheetName);
if (!csvContent) {
logger_1.logger.error(`Resource not found: ${uri}`);
throw new errors_1.ResourceNotFoundError(uri);
}
// Return the full CSV content with URI as required by MCP schema
return [{
type: "text",
text: csvContent,
uri: uri // Add the URI field to match the expected schema
}];
}
/**
* Main entry point for the Quip MCP server
*/
async function main() {
try {
// Parse command line arguments
const options = (0, cli_1.parseCommandLineArgs)();
// Configure logging based on options
(0, cli_1.configureLogging)(options);
// Check for required environment variables if not in mock mode
if (!options.mock) {
const quipToken = process.env.QUIP_TOKEN;
if (!quipToken) {
logger_1.logger.error("QUIP_TOKEN environment variable is not set");
logger_1.logger.error("Please set the QUIP_TOKEN environment variable to your Quip API token or use --mock mode");
process.exit(1);
}
}
else {
logger_1.logger.info("Running in mock mode - no Quip token required");
}
// Get storage path from options or environment
const storagePath = (0, cli_1.getStoragePath)(options);
// Get storage configuration
const storageConfig = (0, cli_1.getStorageConfig)(options);
// Initialize storage based on storage type
const storageOptions = {
storagePath,
isFileProtocol: options.fileProtocol,
s3Bucket: storageConfig.s3Bucket,
s3Region: storageConfig.s3Region,
s3Prefix: storageConfig.s3Prefix,
s3UrlExpiration: storageConfig.s3UrlExpiration
};
// No matter what, create the storage path directory because some functions might assume it exists
try {
if (storageConfig.storageType === 's3') {
// For S3 storage, log but don't create local storage path
logger_1.logger.info(`Using S3 storage, local path "${storagePath}" will not be used for storage`);
}
else {
// For local storage, ensure the directory exists
fs.mkdirpSync(storagePath);
}
}
catch (error) {
logger_1.logger.warn(`Could not ensure storage path exists: ${error instanceof Error ? error.message : String(error)}`);
}
logger_1.logger.info(`Initializing ${storageConfig.storageType} storage`);
storageInstance = (0, storage_1.createStorage)(storageConfig.storageType, storageOptions);
// Create a minimal MCP server
const server = new index_js_1.Server({
name: "quip-mcp-server",
version: version_1.version
});
// Register capabilities
logger_1.logger.info("Registering capabilities");
server.registerCapabilities({
tools: {},
resources: {
templates: true // Indicate that this server supports resource templates
}
});
// Register tools
logger_1.logger.info("Registering tools");
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
logger_1.logger.info("Handling tools/list request");
const tools = (0, tools_1.getQuipTools)();
return { tools };
});
// Register tool call handler
logger_1.logger.info("Registering tool call handler");
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
logger_1.logger.info(`Handling tools/call request for tool: ${request.params.name}`);
try {
if (request.params.name === "quip_read_spreadsheet") {
return {
content: await (0, tools_1.handleQuipReadSpreadsheet)(request.params.arguments || {}, storageInstance, options.mock)
};
}
else {
logger_1.logger.error(`Unknown tool: ${request.params.name}`);
// Return error as content instead of throwing to ensure response is sent
return {
content: [{
type: "text",
text: JSON.stringify({
error: {
code: -32601,
message: `Method not found: ${request.params.name}`
}
})
}]
};
}
}
catch (error) {
// Log the error for debugging
const errorMessage = error instanceof Error ? error.message : String(error);
logger_1.logger.error(`Error in tool call handler for ${request.params.name}: ${errorMessage}`);
// Instead of throwing, return error as content to ensure response is sent
let errorCode = -32603; // Internal error default
let errorMsg = 'Internal server error';
if (error instanceof errors_1.QuipMCPError) {
errorCode = error.code;
errorMsg = error.message;
}
else if (error instanceof Error) {
errorMsg = error.message;
}
return {
content: [{
type: "text",
text: JSON.stringify({
error: {
code: errorCode,
message: errorMsg
}
})
}]
};
}
});
// Discover resources
logger_1.logger.info("Discovering resources");
const resources = await discoverResources(options.fileProtocol);
// Register resources list handler
logger_1.logger.info("Registering resources list handler");
server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
logger_1.logger.info("Handling resources/list request");
return { resources };
});
// Register resource read handler
logger_1.logger.info("Registering resource read handler");
server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
logger_1.logger.info(`Handling resources/read request for URI: ${request.params.uri}`);
return {
contents: await accessResource(request.params.uri)
};
});
// Register resource templates list handler
logger_1.logger.info("Registering resource templates list handler");
server.setRequestHandler(types_js_1.ListResourceTemplatesRequestSchema, async () => {
logger_1.logger.info("Handling resources/list_templates request");
const resourceTemplates = generateResourceTemplates();
return { resourceTemplates };
});
// Choose transport based on options
// Check if we're being launched by the client (stdin is a pipe)
const isStdinPipe = !process.stdin.isTTY;
logger_1.logger.info(`Stdin is pipe: ${isStdinPipe}, process.stdin.isTTY: ${process.stdin.isTTY}`);
logger_1.logger.info(`Environment MCP_PORT: ${process.env.MCP_PORT}, options.port: ${options.port}`);
// Choose transport based on options
if (options.port || process.env.MCP_PORT) {
// Using Streamable HTTP transport with Express
const port = options.port || parseInt(process.env.MCP_PORT || '3000', 10);
logger_1.logger.info(`Using HTTP transport on port ${port}`);
// Create Express app
const app = (0, express_1.default)();
app.use(express_1.default.json());
// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
logger_1.logger.info('Received POST MCP request');
logger_1.logger.debug('Request body:', req.body);
// Set request timeout to prevent hanging connections
const requestTimeout = setTimeout(() => {
if (!res.headersSent) {
logger_1.logger.error('Request timeout - forcing response');
const timeoutResponse = {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Request timeout'
},
id: req.body?.id || null
};
const useSSE = shouldUseSSE(req, options.sse);
if (useSSE) {
logger_1.logger.debug('Response status code: 200 (SSE timeout)');
sendSSEResponse(res, timeoutResponse);
}
else {
logger_1.logger.debug('Response status code: 408 (timeout)');
res.status(408).json(timeoutResponse);
}
}
}, 30000); // 30 second timeout
try {
// Each request gets its own transport and server instance for stateless mode
const useSSE = shouldUseSSE(req, options.sse);
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // No sessions in stateless mode
enableJsonResponse: !useSSE // Disable JSON response for SSE mode
});
// Clean up on request close or completion
const cleanup = () => {
clearTimeout(requestTimeout);
transport.close();
};
res.on('close', () => {
logger_1.logger.debug(`Request closed with final response status code: ${res.statusCode}`);
cleanup();
});
res.on('finish', () => {
logger_1.logger.debug(`Request finished with final response status code: ${res.statusCode}`);
cleanup();
});
await server.connect(transport);
// Let the transport handle the request normally
logger_1.logger.debug('Processing request with transport, SSE mode:', { sse: useSSE });
await transport.handleRequest(req, res, req.body);
// Clear timeout on successful completion
clearTimeout(requestTimeout);
// Log actual response status code from transport
logger_1.logger.debug(`Response status code: ${res.statusCode} (from transport)`);
}
catch (error) {
// Clear timeout on error
clearTimeout(requestTimeout);
const errorMessage = error instanceof Error ? error.message : String(error);
logger_1.logger.error('Error handling MCP request:', { error: errorMessage });
if (!res.headersSent) {
// Determine appropriate error code and message based on error type
let errorCode = -32603; // Internal error default
let errorMsg = 'Internal server error';
// Handle specific error types
if (error instanceof Error) {
const err = error;
if (err.code && typeof err.code === 'number') {
errorCode = err.code;
errorMsg = error.message;
}
}
const errorResponse = {
jsonrpc: '2.0',
error: {
code: errorCode,
message: errorMsg
},
id: req.body?.id || null
};
const useSSE = shouldUseSSE(req, options.sse);
if (useSSE) {
logger_1.logger.debug('Response status code: 200 (SSE error)');
sendSSEResponse(res, errorResponse);
}
else {
logger_1.logger.debug('Response status code: 500 (error)');
res.status(500).json(errorResponse);
}
}
}
});
// Stateless mode doesn't support GET (server-sent events) or DELETE (session termination)
app.get('/mcp', async (req, res) => {
logger_1.logger.info('Received GET MCP request');
const errorResponse = {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed in stateless mode.'
},
id: null
};
const useSSE = shouldUseSSE(req, options.sse);
if (useSSE) {
logger_1.logger.debug('Response status code: 200 (SSE method not allowed)');
sendSSEResponse(res, errorResponse);
}
else {
logger_1.logger.debug('Response status code: 405 (method not allowed)');
res.status(405).json(errorResponse);
}
});
app.delete('/mcp', async (req, res) => {
logger_1.logger.info('Received DELETE MCP request');
const errorResponse = {
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Method not allowed in stateless mode.'
},
id: null
};
const useSSE = shouldUseSSE(req, options.sse);
if (useSSE) {
logger_1.logger.debug('Response status code: 200 (SSE method not allowed)');
sendSSEResponse(res, errorResponse);
}
else {
logger_1.logger.debug('Response status code: 405 (method not allowed)');
res.status(405).json(errorResponse);
}
});
// Start HTTP server
const httpServer = app.listen(port, () => {
logger_1.logger.info(`MCP server listening on port ${port}`);
});
// Keep the process alive
return new Promise((resolve) => {
process.on('SIGINT', async () => {
logger_1.logger.info('Shutting down server');
httpServer.close();
await server.close();
resolve();
process.exit(0);
});
});
}
else {
// Using stdio transport
logger_1.logger.info("Using stdio transport");
const transport = new stdio_js_1.StdioServerTransport();
// Connect to the transport - this will block for stdio
logger_1.logger.info("Connecting to transport");
try {
await server.connect(transport);
logger_1.logger.info("Server connected successfully");
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger_1.logger.error(`Error connecting to transport: ${errorMessage}`);
throw error;
}
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger_1.logger.error(`Error in main`, { error: errorMessage });
process.exit(1);
}
}
//# sourceMappingURL=server.js.map