UNPKG

@getrember/mcp

Version:

A command line tool for setting up Rember MCP server

439 lines (431 loc) 19.2 kB
#!/usr/bin/env node 'use strict'; var cli = require('@effect/cli'); var platformNode = require('@effect/platform-node'); var NodeContext = require('@effect/platform-node/NodeContext'); var NodeRuntime = require('@effect/platform-node/NodeRuntime'); var effect = require('effect'); var Effect4 = require('effect/Effect'); var platform = require('@effect/platform'); var MCP = require('@modelcontextprotocol/sdk/server/index.js'); var MCPstdio = require('@modelcontextprotocol/sdk/server/stdio.js'); var types_js = require('@modelcontextprotocol/sdk/types.js'); var JsonSchema = require('effect/JSONSchema'); var AST = require('effect/SchemaAST'); var ai = require('@effect/ai'); function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var NodeContext__namespace = /*#__PURE__*/_interopNamespace(NodeContext); var NodeRuntime__namespace = /*#__PURE__*/_interopNamespace(NodeRuntime); var Effect4__namespace = /*#__PURE__*/_interopNamespace(Effect4); var MCP__namespace = /*#__PURE__*/_interopNamespace(MCP); var MCPstdio__namespace = /*#__PURE__*/_interopNamespace(MCPstdio); var JsonSchema__namespace = /*#__PURE__*/_interopNamespace(JsonSchema); var AST__namespace = /*#__PURE__*/_interopNamespace(AST); var layerLogger = effect.Logger.replace( effect.Logger.defaultLogger, effect.Logger.prettyLogger({ // We are currently using stdio as MCP transport, therefore we cannot log // on stdout, we have to log in stderr. stderr: true, colors: true, mode: "tty" }) ); var ApiKey = effect.Schema.String.pipe( effect.Schema.pattern(/^rember_[a-f0-9]{32}$/), effect.Schema.brand("ApiKey") ); var Note = effect.Schema.Struct({ text: effect.Schema.String.pipe(effect.Schema.maxLength(2e3)) }); var Notes = effect.Schema.Array(Note).pipe(effect.Schema.maxItems(50)); var ErrorApiKeyInvalid = class extends effect.Schema.TaggedError()( "Api/ApiKeyInvalid", { message: effect.Schema.String } ) { }; var ErrorReachedLimitRateLimiter = class extends effect.Schema.TaggedError()( "Api/ReachedLimitRateLimiter", { message: effect.Schema.String } ) { }; var ErrorReachedLimitUsageTracker = class extends effect.Schema.TaggedError()( "Api/ReachedLimitUsageTracker", { message: effect.Schema.String } ) { }; var ErrorReachedLimitQuantity = class extends effect.Schema.TaggedError()( "Api/ErrorReachedLimitQuantity", { message: effect.Schema.String } ) { }; var endpointGenerateCardsAndCreateRembs = platform.HttpApiEndpoint.post( "generateCardsAndCreateRembs", "/v1/generate-cards-and-create-rembs" ).setPayload( effect.Schema.Struct({ version: effect.Schema.Literal("1"), notes: Notes }) ).setHeaders( effect.Schema.Struct({ "x-api-key": ApiKey, "x-source": effect.Schema.String }) ).addSuccess( effect.Schema.Union( effect.Schema.Struct({ quantity: effect.Schema.Number, usageMonth: effect.Schema.Number, maxUsageMonth: effect.Schema.Number }) ) ).addError(ErrorApiKeyInvalid, { status: 401 }).addError(ErrorReachedLimitQuantity, { status: 400 }).addError(ErrorReachedLimitUsageTracker, { status: 403 }).addError(ErrorReachedLimitRateLimiter, { status: 429 }); var groupV1 = platform.HttpApiGroup.make("v1").add(endpointGenerateCardsAndCreateRembs); var apiRember = platform.HttpApi.make("Rember").add(groupV1).prefix("/api"); var Rember = class extends effect.Context.Tag("Rember")() { }; var makeRember = effect.Effect.gen(function* () { const client = yield* platform.HttpApiClient.make(apiRember, { baseUrl: "https://www.rember.com/", transformClient: platform.HttpClient.retryTransient({ times: 3, schedule: effect.Schedule.exponential("2 seconds") }) }); const apiKeyEnc = yield* effect.Config.string("REMBER_API_KEY"); const apiKey2 = yield* effect.pipe( apiKeyEnc, effect.Schema.decodeUnknown(ApiKey), effect.Effect.catchTag("ParseError", () => effect.Effect.dieMessage("Invalid API key")) ); const generateCardsAndCreateRembs = ({ notes }) => client.v1.generateCardsAndCreateRembs({ payload: { version: "1", notes }, headers: { "x-api-key": apiKey2, "x-source": "rember-mcp" } }); return { generateCardsAndCreateRembs }; }); var layerRember = effect.Layer.effect(Rember, makeRember); var ErrorServerMCP = class extends effect.Data.TaggedError("ErrorServerMCP") { }; var ErrorToolMCP = class extends effect.Schema.TaggedError()("ErrorToolMCP", { message: effect.Schema.String }) { }; var ServerMCP = class extends effect.Context.Tag("ServerMCP")() { }; var makeServerMCP = ({ name, tools, version }) => effect.Effect.gen(function* () { const runtime = yield* effect.Effect.runtime(); const server = yield* effect.Effect.try({ try: () => new MCP__namespace.Server({ name, version }), catch: (error) => new ErrorServerMCP({ message: "Failed to construct Server", error }) }); const transport = yield* effect.Effect.try({ try: () => new MCPstdio__namespace.StdioServerTransport(), catch: (error) => new ErrorServerMCP({ message: "Failed to construct StdioServerTransport", error }) }); for (const [, tool] of tools.toolkit.tools) { if (tool.success !== effect.Schema.String) { return yield* effect.Effect.dieMessage("All tool `success` schemas must be `Schema.String`"); } } yield* effect.Effect.try({ try: () => server.registerCapabilities({ tools: {} }), catch: (error) => new ErrorServerMCP({ message: "Failed to register tools capabilities", error }) }); const arrayTools = []; for (const [, tool] of tools.toolkit.tools) { arrayTools.push(convertTool(tool._tag, tool)); } yield* effect.Effect.try({ try: () => server.setRequestHandler( types_js.ListToolsRequestSchema, // NOTE: Casting to `any` because of incompatibility in how JSON Schema 7 // is represented at the type level between Effect and the MCP SDK. () => ({ tools: arrayTools }) ), catch: (error) => new ErrorServerMCP({ message: "Failed to register tools/list handler", error }) }); yield* effect.Effect.try({ try: () => server.setRequestHandler( types_js.CallToolRequestSchema, (request, { signal }) => effect.pipe( effect.Effect.gen(function* () { const tagTool = effect.String.snakeToPascal(request.params.name); const optionTool = effect.HashMap.get(tools.toolkit.tools, tagTool); if (effect.Option.isNone(optionTool)) { return yield* new ErrorServerMCP({ message: `Tool '${tagTool}' not found` }); } const tool = optionTool.value; const handler = effect.HashMap.unsafeGet(tools.handlers, tagTool); const params = yield* effect.pipe( effect.Schema.decodeUnknown(tool)({ ...request.params.arguments, _tag: tagTool }), effect.Effect.mapError( (error) => new ErrorServerMCP({ message: `Failed to decode tool call '${tagTool}' parameters`, error }) ) ); const result = yield* handler(params); const resultText = yield* effect.pipe( effect.Schema.encodeUnknown(tool.success)(result), effect.Effect.mapError( (error) => new ErrorServerMCP({ message: `Failed to encode tool call '${tagTool}' result`, error }) ) ); if (typeof resultText !== "string") { return yield* effect.Effect.dieMessage("Tool call result must be string"); } return { content: [{ type: "text", text: resultText }] }; }), // Report errors // Note that for `ErrorServerMCP` and `ErrorToolMCP` we report the // error message to the MCP client. For all other errors we report // the entire cause. effect.Effect.catchAllCause( (cause) => effect.Effect.gen(function* () { if (effect.Cause.isInterruptedOnly(cause)) { return { content: [{ type: "text", text: "The tool call was interruped" }], isError: true }; } if (effect.Cause.isFailType(cause) && cause.error instanceof ErrorServerMCP) { yield* effect.Effect.logError(cause.error); return { content: [{ type: "text", text: cause.error.message }], isError: true }; } if (effect.Cause.isFailType(cause) && cause.error instanceof ErrorToolMCP) { yield* effect.Effect.logError(cause.error); return { content: [{ type: "text", text: cause.error.message }], isError: true }; } yield* effect.Effect.logError(cause); return { content: [{ type: "text", text: effect.Cause.pretty(cause) }], isError: true }; }) ), // See TODO on `makeServerMCP` for why we cast `effect`. (effect$1) => effect.Runtime.runPromise(runtime)(effect$1, { signal }) ) ), catch: (error) => new ErrorServerMCP({ message: "Failed to register tool call handler", error }) }); yield* effect.Effect.acquireRelease( // `server.connect` starts the transport and starts listening for messages effect.Effect.promise(() => server.connect(transport)), // `server.close` closes the transport () => effect.Effect.promise(() => server.close()) ); return { server }; }); function layerServerMCP({ name, tools, version }) { return effect.Layer.scoped(ServerMCP, makeServerMCP({ name, version, tools })); } var convertTool = (name, schema) => ({ name: effect.String.pascalToSnake(name), description: getDescription(schema.ast), inputSchema: makeJsonSchema(AST__namespace.omit(schema.ast, ["_tag"])) }); var makeJsonSchema = (ast) => { const $defs = {}; const schema = JsonSchema__namespace.fromAST(ast, { definitions: $defs, topLevelReferenceStrategy: "skip" }); if (Object.keys($defs).length === 0) return schema; schema.$defs = $defs; return schema; }; var getDescription = (ast) => { const annotations = ast._tag === "Transformation" ? { ...ast.to.annotations, ...ast.annotations } : ast.annotations; return AST__namespace.DescriptionAnnotationId in annotations ? annotations[AST__namespace.DescriptionAnnotationId] : ""; }; var ToolCreateFlashcards = class extends effect.Schema.TaggedRequest()( "CreateFlashcards", { success: effect.Schema.String, failure: ErrorToolMCP, payload: { notes: effect.Schema.Array( effect.Schema.Struct({ text: effect.Schema.String.pipe(effect.Schema.maxLength(2e3)).annotations({ title: "Text", description: "The text content of the note" }) }).annotations({ title: "Note", description: "A little note about a concept or idea" }) ).pipe(effect.Schema.maxItems(50)).annotations({ title: "Notes", description: "A list of little notes" }), source: effect.Schema.String.pipe(effect.Schema.maxLength(100), effect.Schema.optional).annotations({ title: "Source", description: "The resource (e.g. article, book, pdf, webpage) the notes are about (e.g. 'Author - Title'). Omit this field unless the notes are about a specific concrete resource." }) } }, { description: effect.pipe( ` |A tool to generate spaced-repetition flashcards in Rember. | |What is Rember? |Rember is a modern spaced-repetition system based on *rembs*. |A remb is a concise note focused on a single concept or idea you want to remember, along with a few flashcards testing that concept or idea. |In Rember you can create rembs and review their flashcards, just like in Anki or other traditional spaced-repetition systems. |Rember also allows exporting rembs to Anki. |Rember can be found at https://rember.com. | |What is MCP? |MCP (Model Context Protocol) is Anthropic's open standard allowing Claude to connect with external tools and data sources through a standardized interface. |This tools is implemented and being called through MCP. | |Input and behavior: |The input is a list of notes, with optionally a source. Rember will turn each note into a remb, by generating flashcards using AI. |In particular, the notes are sent to the Rember API. The Rember API will generate the flashcards with our own custom AI prompts, independently from this conversation with you. |Rember will often generate 4-5 flashcards for each single note. |Rembs are the natural organizational unit for spaced-repetition flashcards, they allow users to quickly search, organize and interact with flashcards. |Note that if the user asks about Rember in general, keep things simple and avoid getting into lower level details by e.g. mentioning the Rember API. | |How to use this tool: |- The user might ask you to create a few flashcards about a topic: create one note with only the essential concepts of the topic |- After asking you a question the user might say something like "help me remember this": create one note synthesizing only the key points of the answer |- After chatting with you the user might ask for a few flashcards: create one or two notes capturing only the core insights from the conversation |- For working with PDFs or webpages: extract only the most important points as individual notes, make sure you include the 'source' in the tool input |- For follow-up requests about specific topics: create targeted notes focusing on the essential aspects of those concepts |- For working with a complex topic: create notes that break down difficult concepts into manageable chunks | |What the user might say to use this tool: |- "Help me remember that ..." |- "Create flashcards for ..." |- "Create rembs for ..." |- "Add this to Rember" |- "I want to study this later" |- "Turn this into spaced repetition material" | |Here are 10 rules for writing notes to send to the Rember API. | |Rules: |1. ALWAYS synthesize information to its essence, include only essential concepts or ideas by default |2. Keep the number of notes to a minimum, create more than one note only if the user is trying to remember different concepts or ideas |3. Include comprehensive details ONLY when the user explicitly requests them with phrases like "include all details" or "be comprehensive" |4. Notes should be atomic and mostly focused on a single concept or idea |5. Notes should be self-contained and make sense independently of other notes |6. Notes should be concise, get to the point and avoid unnecessary details or verbiage |7. Avoid repeating the same information across multiple notes |8. Use specific attributions when referencing sources (e.g., "Researcher Name states..." not "The article suggests...") |9. If the user asks something like "create N flashcards", explain: "I'll help you create notes on the key concepts you want to remember. Rember will automatically generate multiple flashcards from each note (typically 4-5 per note).". |10. Follow any other user indication `, effect.String.stripMargin, effect.String.trim ) } ) { }; var toolkit = ai.AiToolkit.empty.add(ToolCreateFlashcards); var layerTools = toolkit.implement( (handlers) => effect.Effect.gen(function* () { const rember = yield* Rember; return handlers.handle("CreateFlashcards", ({ notes, source }) => effect.pipe( effect.Effect.gen(function* () { const notesRember = effect.pipe( notes, effect.Array.map(({ text }) => ({ text: source == void 0 ? text : `${text} ${source}` })) ); const { quantity } = yield* rember.generateCardsAndCreateRembs({ notes: notesRember }); return `${quantity} rembs have been created. The number of created flashcards is unknown, report to the user something like "I've created ${quantity} rembs in Rember, each remb contains multiple flashcards. You can review your flashcards at https://rember.com/review".`; }), effect.Effect.catchTag("Api/ReachedLimitUsageTracker", (_) => effect.Effect.succeed( "The user reached the monthly limit for their Rember account. Instruct the user to visit https://rember.com/settings/account to upgrade to Rember Pro, which includes 1000 monthly rembs with high-quality AI generated flashcards." )), effect.Effect.catchTag("Api/ErrorReachedLimitQuantity", (_) => effect.Effect.succeed( "You can send at most 50 notes in a single request to the Rember API" )), effect.Effect.catchTag("Api/ApiKeyInvalid", (_) => effect.Effect.succeed( "The API key of the user is invalid, instruct the user to visit https://www.rember.com/settings/mcp-api and update the API key in your configs." )), effect.Effect.mapError((error) => new ErrorToolMCP({ message: error.message })) )); }) ); // src/bin.ts var apiKey = effect.pipe( cli.Options.text("api-key"), cli.Options.withSchema(ApiKey), cli.Options.map(effect.Redacted.make), cli.Options.optional ); var command = cli.Command.make("rember-mcp", { apiKey }, ({ apiKey: apiKey2 }) => effect.pipe( toolkit, Effect4__namespace.flatMap( (tools) => effect.Layer.launch(layerServerMCP({ name: "rember", version: "1.1.3", tools })) ), Effect4__namespace.provide(layerTools), Effect4__namespace.provide(layerRember), Effect4__namespace.provide( effect.pipe( effect.ConfigProvider.fromJson(effect.Option.isSome(apiKey2) ? { REMBER_API_KEY: effect.Redacted.value(apiKey2.value) } : {}), effect.ConfigProvider.orElse(() => effect.ConfigProvider.fromEnv()), (_) => effect.Layer.setConfigProvider(_) ) ) )); var run = cli.Command.run(command, { name: "Rember MCP server", version: "1.1.3" }); run(process.argv).pipe( // Report errors, this needs to happen: // - After the creation of our main layers, to report errors in the layer construction // - Before providing `layerLogger` so that the errors are reported with the correct // logger // Note that we set `disableErrorReporting: true` in `NodeRuntime.runMain`. Effect4__namespace.tapErrorCause((cause) => { if (effect.Cause.isInterruptedOnly(cause)) { return Effect4__namespace.void; } return Effect4__namespace.logError(cause); }), Effect4__namespace.provide(platformNode.NodeHttpClient.layerUndici), Effect4__namespace.provide(layerLogger), Effect4__namespace.provide(NodeContext__namespace.layer), NodeRuntime__namespace.runMain({ disableErrorReporting: true, disablePrettyLogger: true }) ); exports.run = run;