mcp.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. /*
  2. * Copyright 2010-2025 Gildas Lormeau
  3. * contact : gildas.lormeau <at> gmail.com
  4. *
  5. * This file is part of SingleFile.
  6. *
  7. * The code in this file is free software: you can redistribute it and/or
  8. * modify it under the terms of the GNU Affero General Public License
  9. * (GNU AGPL) as published by the Free Software Foundation, either version 3
  10. * of the License, or (at your option) any later version.
  11. *
  12. * The code in this file is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
  15. * General Public License for more details.
  16. *
  17. * As additional permission under GNU AGPL version 3 section 7, you may
  18. * distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU
  19. * AGPL normally required by section 4, provided you include this license
  20. * notice and a URL through which recipients can access the Corresponding
  21. * Source.
  22. */
  23. /* global fetch, Blob, AbortController */
  24. const EMPTY_STRING = "";
  25. const CONFLICT_ACTION_SKIP = "skip";
  26. const CONFLICT_ACTION_UNIQUIFY = "uniquify";
  27. const CONFLICT_ACTION_OVERWRITE = "overwrite";
  28. const CONFLICT_ACTION_PROMPT = "prompt";
  29. const EXTENSION_SEPARATOR = ".";
  30. const INDEX_FILENAME_PREFIX = " (";
  31. const INDEX_FILENAME_SUFFIX = ")";
  32. const INDEX_FILENAME_REGEXP = /\s\((\d+)\)$/;
  33. const ABORT_ERROR_NAME = "AbortError";
  34. const CONTENT_TYPE_HEADER = "Content-Type";
  35. const JSON_CONTENT_TYPE = "application/json";
  36. const MCP_JSONRPC_VERSION = "2.0";
  37. export { MCP };
  38. class MCP {
  39. constructor(serverUrl, authToken) {
  40. this.serverUrl = serverUrl;
  41. this.authToken = authToken;
  42. this.requestId = 0;
  43. }
  44. async upload(path, content, options) {
  45. this.controller = new AbortController();
  46. options.signal = this.controller.signal;
  47. options.serverUrl = this.serverUrl;
  48. options.authToken = this.authToken;
  49. options.getRequestId = () => ++this.requestId;
  50. let textContent;
  51. if (content instanceof Blob) {
  52. textContent = await content.text();
  53. } else {
  54. textContent = content;
  55. }
  56. return upload(path, textContent, options);
  57. }
  58. abort() {
  59. if (this.controller) {
  60. this.controller.abort();
  61. }
  62. }
  63. }
  64. async function upload(path, content, options) {
  65. const { filenameConflictAction, prompt, signal, serverUrl, authToken, getRequestId } = options;
  66. try {
  67. const existsResponse = await checkFileExists(serverUrl, authToken, path, signal, getRequestId);
  68. if (existsResponse.exists) {
  69. if (filenameConflictAction == CONFLICT_ACTION_SKIP) {
  70. return { url: path, skipped: true };
  71. } else if (filenameConflictAction == CONFLICT_ACTION_OVERWRITE) {
  72. // Continue to write
  73. } else if (filenameConflictAction == CONFLICT_ACTION_UNIQUIFY) {
  74. const { filenameWithoutExtension, extension, indexFilename } = splitFilename(path);
  75. options.indexFilename = indexFilename + 1;
  76. path = getFilename(filenameWithoutExtension, extension, options.indexFilename);
  77. return await upload(path, content, options);
  78. } else if (filenameConflictAction == CONFLICT_ACTION_PROMPT) {
  79. if (prompt) {
  80. path = await prompt(path);
  81. if (path) {
  82. return await upload(path, content, options);
  83. } else {
  84. return { url: path, skipped: true };
  85. }
  86. } else {
  87. options.filenameConflictAction = CONFLICT_ACTION_UNIQUIFY;
  88. return await upload(path, content, options);
  89. }
  90. }
  91. }
  92. const writeResponse = await writeFile(serverUrl, authToken, path, content, signal, getRequestId);
  93. if (writeResponse.success) {
  94. return { url: path };
  95. } else {
  96. throw new Error(writeResponse.error || "Failed to write file via MCP");
  97. }
  98. } catch (error) {
  99. if (error.name != ABORT_ERROR_NAME) {
  100. throw error;
  101. }
  102. }
  103. }
  104. async function checkFileExists(serverUrl, authToken, path, signal, getRequestId) {
  105. const requestBody = {
  106. jsonrpc: MCP_JSONRPC_VERSION,
  107. id: getRequestId(),
  108. method: "tools/call",
  109. params: {
  110. name: "get_file_info",
  111. arguments: {
  112. path: path
  113. }
  114. }
  115. };
  116. const headers = {
  117. [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE,
  118. "Accept": "application/json, text/event-stream"
  119. };
  120. if (authToken) {
  121. headers.Authorization = `Bearer ${authToken}`;
  122. }
  123. const response = await fetch(serverUrl, {
  124. method: "POST",
  125. headers,
  126. body: JSON.stringify(requestBody),
  127. signal
  128. });
  129. if (!response.ok) {
  130. throw new Error(`MCP server error: ${response.status} ${response.statusText}`);
  131. }
  132. const data = await response.json();
  133. if (data.error) {
  134. return { exists: false };
  135. }
  136. if (data.result && data.result.isError) {
  137. return { exists: false };
  138. }
  139. if (data.result) {
  140. return { exists: true };
  141. }
  142. return { exists: false };
  143. }
  144. async function writeFile(serverUrl, authToken, path, content, signal, getRequestId) {
  145. const requestBody = {
  146. jsonrpc: MCP_JSONRPC_VERSION,
  147. id: getRequestId(),
  148. method: "tools/call",
  149. params: {
  150. name: "write_file",
  151. arguments: {
  152. path: path,
  153. content: content
  154. }
  155. }
  156. };
  157. const headers = {
  158. [CONTENT_TYPE_HEADER]: JSON_CONTENT_TYPE,
  159. "Accept": "application/json, text/event-stream"
  160. };
  161. if (authToken) {
  162. headers.Authorization = `Bearer ${authToken}`;
  163. }
  164. const response = await fetch(serverUrl, {
  165. method: "POST",
  166. headers,
  167. body: JSON.stringify(requestBody),
  168. signal
  169. });
  170. if (!response.ok) {
  171. throw new Error(`MCP server error: ${response.status} ${response.statusText}`);
  172. }
  173. const data = await response.json();
  174. if (data.error) {
  175. throw new Error(data.error.message);
  176. }
  177. return { success: true };
  178. }
  179. function splitFilename(filename) {
  180. let filenameWithoutExtension = filename;
  181. let extension = EMPTY_STRING;
  182. const indexExtensionSeparator = filename.lastIndexOf(EXTENSION_SEPARATOR);
  183. if (indexExtensionSeparator > -1) {
  184. filenameWithoutExtension = filename.substring(0, indexExtensionSeparator);
  185. extension = filename.substring(indexExtensionSeparator + 1);
  186. }
  187. let indexFilename;
  188. ({ filenameWithoutExtension, indexFilename } = extractIndexFilename(filenameWithoutExtension));
  189. return { filenameWithoutExtension, extension, indexFilename };
  190. }
  191. function extractIndexFilename(filenameWithoutExtension) {
  192. const indexFilenameMatch = filenameWithoutExtension.match(INDEX_FILENAME_REGEXP);
  193. let indexFilename = 0;
  194. if (indexFilenameMatch && indexFilenameMatch.length > 1) {
  195. const parsedIndexFilename = Number(indexFilenameMatch[indexFilenameMatch.length - 1]);
  196. if (!Number.isNaN(parsedIndexFilename)) {
  197. indexFilename = parsedIndexFilename;
  198. filenameWithoutExtension = filenameWithoutExtension.replace(INDEX_FILENAME_REGEXP, EMPTY_STRING);
  199. }
  200. }
  201. return { filenameWithoutExtension, indexFilename };
  202. }
  203. function getFilename(filenameWithoutExtension, extension, indexFilename) {
  204. return filenameWithoutExtension +
  205. INDEX_FILENAME_PREFIX + indexFilename + INDEX_FILENAME_SUFFIX +
  206. (extension ? EXTENSION_SEPARATOR + extension : EMPTY_STRING);
  207. }