@chriswhiterocks/sushimcp
Version:
SushiMCP a dev tools model context protocol server that serves context on a roll.
12 lines (11 loc) • 15.3 kB
JavaScript
import{StdioServerTransport as ce}from"@modelcontextprotocol/sdk/server/stdio.js";import{McpServer as le}from"@modelcontextprotocol/sdk/server/mcp.js";import{URL as C,fileURLToPath as A}from"url";import E from"fs/promises";import g from"path";import{readFileSync as G}from"fs";var u={Reset:"\x1B[0m",Red:"\x1B[31m",Yellow:"\x1B[33m",Blue:"\x1B[94m",Cyan:"\x1B[36m",Gray:"\x1B[90m",White:"\x1B[37m"};function V(e,t){let r=new Date().toISOString(),s=`${u.Gray}${r}${u.Reset}`,o,c;switch(e){case"ERROR":c=u.Red,o=`${c}${e.padEnd(5)}${u.Reset}`;break;case"WARN":c=u.Yellow,o=`${c}${e.padEnd(5)}${u.Reset}`;break;case"INFO":c=u.Cyan,o=`${c}${e.padEnd(5)}${u.Reset}`;break;case"DEBUG":c=u.Gray,o=`${c}${e.padEnd(5)}${u.Reset}`;break;default:c=u.White,o=String(e).padEnd(5)}return`${s} ${o} ${c}${t}${u.Reset}
`}function x(e,...t){let r=t.map(o=>{if(o instanceof Error)return`${o.message}${o.stack?`
${o.stack}`:""}`;if(typeof o=="object"&&o!==null)try{return JSON.stringify(o,null,2)}catch{return String(o)}return String(o)}).join(" "),s=V(e,r);process.stderr.write(s)}var n={info:(...e)=>x("INFO",...e),warn:(...e)=>x("WARN",...e),error:(...e)=>x("ERROR",...e),debug:(...e)=>x("DEBUG",...e)};process.on("unhandledRejection",e=>{n.error("Unhandled Promise Rejection:",e)});function I(e){try{let t=new C(e);if(t.protocol==="http:"||t.protocol==="https:")return t.hostname.toLowerCase()}catch(t){let r=t instanceof Error?t.message:"Unknown error";n.error(`Invalid URL format: ${r}. Only http, https, and file protocols are supported.`)}return null}function y(e,t){if(e.type==="remote"){if(!t.has("*")&&!t.has(e.hostname))throw n.error(`Domain '${e.hostname}' is not in the allowed domains list.`),new Error(`Access denied: Fetching from domain '${e.hostname}' is not allowed by server configuration. Ask user to add domain to allow list.`);n.info(`Domain '${e.hostname}' is allowed.`)}else if(e.type==="localFileUrl"||e.type==="localPath")n.warn("Local file access permitted.");else throw new Error(`Internal error: Unsupported target type '${e.type}' during access check.`)}async function R(e){try{let t=new C(e);if(t.protocol==="http:"||t.protocol==="https:")return{type:"remote",url:t,hostname:t.hostname.toLowerCase()};if(t.protocol==="file:")try{return{type:"localFileUrl",filePath:g.resolve(A(t)),url:t}}catch(r){return{type:"unsupported",originalInput:e,reason:`Failed to convert file: URL to path: ${r.message}`}}else return{type:"unsupported",originalInput:e,reason:`Unsupported URL protocol: ${t.protocol}`}}catch(t){try{let r=g.resolve(e);try{await E.stat(r)}catch{throw new Error(`Path does not exist or is inaccessible: ${r}`)}return{type:"localPath",resolvedPath:r,originalInput:e}}catch(r){let s=t instanceof Error?t.message:String(t);return{type:"unsupported",originalInput:e,reason:`Invalid URL ('${s}') and failed to resolve as path: ${r.message}`}}}}async function S(e){switch(e.type){case"remote":{n.info(`Fetching remote URL: ${e.url.toString()}`);let t=await fetch(e.url.toString());if(!t.ok)throw new Error(`HTTP error ${t.status}`);return await t.text()}case"localFileUrl":return n.info(`Reading local file path from file: URL: ${e.filePath}`),await E.readFile(e.filePath,"utf-8");case"localPath":return n.info(`Reading local file path directly: ${e.resolvedPath}`),await E.readFile(e.resolvedPath,"utf-8");case"unsupported":throw new Error(`Cannot fetch content for unsupported target type: ${e.reason}`);default:{let t=e;throw new Error(`Internal error: Unhandled target info type: ${JSON.stringify(t)}`)}}}var v=e=>Array.isArray(e)?e.map(t=>typeof t=="string"?{url:t}:t):[typeof e=="string"?{url:e}:e];function w(){let e=A(import.meta.url),t=g.dirname(e);for(let r=0;r<4;r++){let s=g.join(t,"package.json");try{let o=JSON.parse(G(s,"utf-8"));if(o.version)return o.version}catch{}t=g.dirname(t)}return"unknown"}import{Command as Y}from"commander";import*as F from"node:fs";import*as U from"node:path";import{fileURLToPath as K}from"node:url";import{URL as J}from"url";import*as O from"node:path";function $(e,t=":"){let r=e.split(t);if(r.length<2)return n.error(`Invalid format: '${e}'. Expected 'name${t}value'. Skipping.`),null;let s=r[0].trim(),o=r.slice(1).join(t).trim();return!s||!o?(n.error(`Invalid format: '${e}'. Name or value part is empty. Skipping.`),null):{name:s,urlValue:o}}function B(e,t,r,s){try{new J(r),e[t]=r}catch(o){if(r.startsWith("/")||r.startsWith(".")){let c=O.resolve(r);e[t]=c}else n.error(`(${s}) Invalid URL or Path format for '${t}': '${r}'. Skipping. Error: ${o instanceof Error?o.message:o}`)}}function b(e,t,r){B(t,e.name,e.urlValue,r)}function h(e,t,r){let s=I(e.trim());s&&(t.add(s),n.info(`Added domain ${s} from ${r}`))}function T(e){n.info(`
--- SushiMCP Configuration Summary ---`),n.info(`Documentation Sources: ${JSON.stringify(e.docSources,null,2)}`),n.info(`OpenAPI Specs: ${JSON.stringify(e.openApiSpecs,null,2)}`),n.info(`Allowed Fetch Domains: ${[...e.allowedDomains].join(", ")||"(None - local only?)"}`),n.info(`------------------------------------
`)}var Q=w(),X=K(import.meta.url),Z=U.dirname(X);function ee(e){let t={};try{let s=F.readFileSync(e,"utf-8").split(`
`);for(let o of s){let c=o.trim();if(c.startsWith("-")){let a=c.substring(1).trim(),i=$(a);i&&b(i,t,"defaults")}}}catch(r){n.error(`Failed to load default sources: ${r instanceof Error?r.message:r}`)}return t}function L(e,t,r,s,o){if(t&&t.length>0)for(let c of t){let a=$(c);a&&b(a,e,r)}if(s){let c=s.split(/\s+/);for(let a of c){if(!a.trim())continue;let i=$(a.trim());i&&b(i,e,o)}}return e}function te(e,t,r,s,o,c){let a=new Set,i=new Set,l=!1;return e&&e.length>0&&(l=!0,e.forEach(p=>{h(p,a,"--allow-domain")})),t&&(l=!0,t.split(/\s+/).forEach(_=>{h(_,a,"--allow-domains")})),l&&n.info("Using explicitly allowed domains from CLI flags"),N(c,a),N(o,a),r&&r.length>0&&(l=!0,r.forEach(p=>{h(p,i,"--deny-domain")})),s&&(l=!0,s.split(/\s+/).forEach(_=>{h(_,i,"--deny-domains")})),i.forEach(p=>{a.delete(p)}),a}function N(e,t){Object.values(e).forEach(r=>{try{if(r.startsWith("http:")||r.startsWith("https:")){let o=new URL(r).hostname.toLowerCase();t.add(o)}}catch(s){n.error(`Failed to parse source URL '${r}' for domain inference. Error: ${s instanceof Error?s.message:String(s)}. Skipping.`)}}),t.size===0&&Object.keys(e).length>0?n.warn("Warning: No remote URLs configured or parsed, and no explicit domains allowed. Fetching might be restricted to local files only."):t.size===0&&n.warn("No domains could be inferred from sources. Only local file access will be allowed.")}function re(e,t){let r={},s={};if(t){let o=U.resolve(Z,"static/defaults.md");r=ee(o)}return r=L(r,e.llmsTxtSource,"--llms-txt-source",e.llmsTxtSources,"--llms-txt-sources"),(e?.url?.length>0||e?.urls?.length>0)&&n.warn("Warning: The --url and --urls options are deprecated. Use --llms-txt-source and --llms-txt-sources instead."),s=L(s,e.url,"--url",e.urls,"--urls"),Object.assign(r,s),Object.keys(r).length===0&&n.warn("No documentation sources provided. Use --source to add sources."),r}function oe(e){let t={};return t=L(t,e.openapiSpecSource,"--openapi-spec-source",e.openapiSpecSources,"--openapi-spec-sources"),Object.keys(t).length===0&&n.warn("No OpenAPI specs provided. Use --openapi to add OpenAPI specs."),t}function k(){let e=new Y;e.name("SushiMCP").description("Starts SushiMCP, a dev tools model context protocol server that serves context on a roll.").version(Q).option("--url <name:url>","Specify a single documentation source (repeatable)",(a,i=[])=>i.concat(a),[]).option("--urls <string>","Specify a list of llms.txt sources as a single space-separated string (e.g., 'name1:url1 name2:url2')").option("--llms-txt-source <name:url>","Specify a single documentation source (repeatable)",(a,i=[])=>i.concat(a),[]).option("--llms-txt-sources <string>","Specify a list of llms.txt sources as a single space-separated string (e.g., 'name1:url1 name2:url2')").option("--openapi-spec-source <name:url>","Specify a single OpenAPI spec source (repeatable)",(a,i=[])=>i.concat(a),[]).option("--openapi-spec-sources <string>","Specify a list of OpenAPI spec sources as a single space-separated string (e.g., 'name1:url1 name2:url2')").option("--no-defaults","Do NOT include default documentation sources from src/defaults.md").option("--allow-domain <domain>","Allow fetching from a specific domain (repeatable, use '*' for all)",(a,i=[])=>i.concat(a),[]).option("--allow-domains <string>","Allow fetching from a list of domains as a single space-separated string (e.g., 'domain1 domain2')").option("--deny-domain <domain>","Deny fetching from a specific domain (repeatable, use '*' for all)",(a,i=[])=>i.concat(a),[]).option("--deny-domains <string>","Deny fetching from a list of domains as a single space-separated string (e.g., 'domain1 domain2')"),e.parse(process.argv);let t=e.opts(),r=re(t,t.defaults),s=oe(t),c={allowedDomains:te(t.allowDomain,t.allowDomains,t.denyDomain,t.denyDomains,r,s),docSources:r,openApiSpecs:s};return T(c),c}var D=async(e,t,r)=>{n.debug("Processing fetch_llms_txt request with input:",e);let s=v(e),o=[];for(let c of s){let a=c.url;try{n.debug(`Processing URL: ${a}`);let i=await R(a);if(i.type==="unsupported"){let p=`Unsupported URL format: ${i.reason}`;throw n.error(p),new Error(p)}n.debug("Target info:",i),y(i,r),n.debug(`Fetching content from: ${a}`);let l=await S(i);o.push({type:"text",text:l}),n.debug(`Successfully fetched ${l.length} bytes from ${a}`)}catch(i){let l=`Failed to process fetch request for ${a}: ${i instanceof Error?i.message:String(i)}`;throw n.error(l),new Error(l)}}return n.debug(`Successfully processed ${o.length} URLs`),{content:o}};var H=async(e,t)=>{if(Object.keys(t).length===0)return{content:[{type:"text",text:"No llms.txt sources configured."}]};let r=`Available llms.txt sources:
`;for(let o in t)r+=`- ${o}: ${t[o]}
`;return{content:[{type:"text",text:r.trim()}]}};var q=async(e,t,r)=>{n.debug("Processing fetch_openapi_spec request with input:",e);let s=v(e),o=[];for(let c of s){let a=c.url;try{n.debug(`Processing OpenAPI spec URL: ${a}`);let i=await R(a);if(i.type==="unsupported"){let p=`Unsupported URL format: ${i.reason}`;throw n.error(p),new Error(p)}n.debug("Target info:",i),y(i,r),n.debug(`Fetching OpenAPI spec from: ${a}`);let l=await S(i);o.push({type:"text",text:l}),n.debug(`Successfully fetched ${l.length} bytes from ${a}`)}catch(i){let l=`Failed to process OpenAPI spec request for ${a}: ${i instanceof Error?i.message:String(i)}`;throw n.error(l),new Error(l)}}return n.debug(`Successfully processed ${o.length} OpenAPI specs`),{content:o}};var j=async(e,t)=>{if(Object.keys(t).length===0)return{content:[{type:"text",text:"No OpenAPI specifications configured."}]};let r=`Available OpenAPI specifications:
`;for(let o in t)r+=`- ${o}: ${t[o]}
`;return{content:[{type:"text",text:r.trim()}]}};import{z as f}from"zod";var m=f.union([f.string().url("Input must be a valid URL string"),f.object({url:f.string().url("Input must contain a valid URL string under the 'url' key")}),f.array(f.union([f.string().url("Each array item must be a valid URL string"),f.object({url:f.string().url("Each array item must contain a valid URL string under the 'url' key")})]))]);import{fileURLToPath as ne}from"url";import{dirname as se,join as ie}from"path";import{readFileSync as ae}from"fs";var M=()=>{let e=se(ne(import.meta.url)),t=ie(e,"static","defaults.json"),r=JSON.parse(ae(t,"utf8"));return Object.entries(r).map(([s,o])=>{let c=s.split("_").map(i=>i.charAt(0).toUpperCase()+i.slice(1)).join(" ").replace(/Docs$/," Docs"),a=s.split("_").map((i,l)=>l===0?i.charAt(0).toUpperCase()+i.slice(1).toLowerCase():i.toLowerCase()).join(" ").concat(" llms.txt");return{id:s,name:c,description:a,mimetype:"text/plain",uri:o,handler:async i=>{let p=await(await fetch(i.href)).text();return{contents:[{uri:i.href,name:c,description:a,text:p,mimetype:"text/plain"}]}}}})};var{docSources:pe,allowedDomains:P,openApiSpecs:ue}=k(),fe=process.argv.slice(2),W=fe.includes("--sse");process.env.MCP_STDIO_MODE=String(!W);W?(n.info("Running in SSE mode - using console methods for logging"),n.debug("Debug logging is enabled")):n.info("Running in stdio mode - using JSON logging");var z=w(),d=new le({name:"sushimcp",version:z,displayName:"SushiMCP",description:"SushiMCP a dev tools model context protocol server that serves context on a roll.",publisher:"Chris White <chris@chriswhite.rocks> https://chriswhite.rocks"},{capabilities:{resources:{},tools:{list_llms_txt_sources:{name:"list_llms_txt_sources",description:"List the source urls where an llms.txt can be fetched.",annotations:{title:"List llms.txt sources",readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1}},list_openapi_spec_sources:{name:"list_openapi_spec_sources",description:"List the source urls where an OpenAPI spec can be fetched.",annotations:{title:"List OpenAPI spec sources",readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!1}},fetch_llms_txt:{name:"fetch_llms_txt",description:"Fetches the content of a llms.txt url.",inputSchema:m,annotations:{title:"Fetch llms.txt content",readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0}},fetch_openapi_spec:{name:"fetch_openapi_spec",description:"Fetches the content of a OpenAPI spec url.",inputSchema:m,annotations:{title:"Fetch OpenAPI spec content",readOnlyHint:!0,destructiveHint:!1,idempotentHint:!0,openWorldHint:!0}}}}});d.tool("list_llms_txt_sources","This tool lists all available source urls where an llms.txt can be fetched. After reading the listed sources, use fetch_llms_txt to fetch any source that matches a technology in the instructions you received. Prefer llms.txt, but if llms.txt proves inadequate, check to see if other llms-full.txt or llms-mini.txt exist. When done, ask the user if they want to use other tools to search for documentation on any sources this tool could not find.",e=>H(e,pe));d.tool("list_openapi_spec_sources","This tool lists all available source urls where an OpenAPI spec can be fetched.",e=>j(e,ue));d.tool("fetch_llms_txt","Fetches the content of one or more llms.txt URLs. Some llms.txt files compile a list of urls to other llms.txt file locations because listing their full documentation would bloat context. If the documentation you're looking for does not exist in the llms.txt, look for reference links to other llms.txt files and follow those.",{input:m.describe("URL string, URL object, or array of URL/objects to fetch llms.txt from")},async(e,t)=>{let r=e?.input??e;if(!r)throw new Error("No input provided to fetch_llms_txt");return D(r,t,P)});d.tool("fetch_openapi_spec","Fetches the content of one or more OpenAPI spec URLs.",{input:m.describe("URL string, URL object, or array of URL/objects to fetch OpenAPI specs from")},async(e,t)=>{let r=e?.input??e;if(!r)throw new Error("No input provided to fetch_openapi_spec");return q(r,t,P)});var me=M();me.forEach(({id:e,uri:t,name:r,description:s,mimetype:o,handler:c})=>{d.resource(e,t,{name:r,description:s,mimetype:o},c)});try{let e=new ce;await d.connect(e),n.info(`SushiMCP server v${z} started successfully`),P.size>0&&n.debug(`Allowed domains: ${Array.from(P).join(", ")}`)}catch(e){let t=e instanceof Error?e.message:"Unknown error",r=e instanceof Error?e.stack:"";n.error(`Failed to start MCP server: ${t}`),r&&n.debug(r),process.exit(1)}