UNPKG

@offorte/mcp-server

Version:

Offorte MCP Server

858 lines (805 loc) 28.3 kB
#!/usr/bin/env node // src/server.ts import { FastMCP } from "fastmcp"; // src/tools/context/initial-context-guard.ts var initialContextSet = false; function initialContextGuard(tool) { if (tool.name === "get_initial_context") { return { ...tool, execute: async (args, context) => { initialContextSet = true; return tool.execute(args, context); } }; } return { ...tool, execute: async (args, context) => { if (!initialContextSet) { throw new Error("Initial context has not been set. You must call get_initial_context before using this tool."); } return tool.execute(args, context); } }; } // src/tools/context/get-initial-context.ts import { outdent } from "outdent"; // src/config/env.ts import dotenv from "dotenv"; import { z } from "zod"; dotenv.config(); var ConfiguredSchema = z.object({ OFFORTE_ACCOUNT_NAME: z.string().describe("Offorte Account Name"), OFFORTE_API_KEY: z.string().describe("Offorte API key") }); var EnvSchema = z.object({ OFFORTE_API_HOST: z.string().optional().default("https://connect.offorte.com/api/v2/").describe("Offorte API host"), TRANSPORT_TYPE: z.enum(["stdio", "httpStream", "sse"]).optional().default("stdio").transform((val) => { if (val === "sse") { console.warn('[DEPRECATED] TRANSPORT_TYPE="sse" is deprecated. Use "httpStream" instead.'); return "httpStream"; } return val; }).describe("Transport type for MCP server") }).merge(ConfiguredSchema); var env = EnvSchema.safeParse(process.env); if (!env.success) { console.error("Invalid environment variables", env.error.format()); process.exit(1); } var config = env.data; // src/config/offorte.ts if (!env.success) { throw new Error("Environment variables are not properly configured"); } var config2 = { accountName: env.data.OFFORTE_ACCOUNT_NAME, apiUrl: `${env.data.OFFORTE_API_HOST}${env.data.OFFORTE_ACCOUNT_NAME}`, apiKey: env.data.OFFORTE_API_KEY }; // src/instructions.ts var INSTRUCTIONS = `You are a helpful assistant integrated with Offorte Proposal Software via the Model Context Protocol (MCP). IMPORTANT FIRST STEP: - Always call get_initial_context tool first to initialize the session before using any other tools - This is mandatory for all operations and will give you essential information about the current Offorte environment WHAT YOU CAN DO You can help users by: - Answering questions about their Offorte account - Retrieving proposal or contact data - Creating and sending proposals to customers OFFORTE BASICS - A proposal is an online, interactive document that outlines a business offer to a customer - Proposals are sent via email with a link and can be signed online - A proposal can be signed online for approval by the contact - A contact can be a person (individual) or an organisation - An organisation may have multiple people - A person without an organisation is treated as a private individual CREATING PROPOSALS - When creating a proposal, always ask for one required field at a time. Never list all required fields or IDs up front. Guide the user step-by-step, gathering each piece of information in sequence. - Always gather proposal information step-by-step: ask for one field at a time, confirm, then proceed to the next. Do not present a summary or list of all required fields or IDs at once. This approach prevents overwhelming the user and ensures a smooth, guided experience. - A proposal is created based on a proposal template, language text template, design template and a contact - When an organisation contains multiple people, you need to ask the user which persons he/she wants to assign to the proposal - A proposal template can contain custom fields which are used to insert content into the proposal - If the selected proposal template has custom fields, go through all fields with the user and fill them in. To create a proposal, you need: 1. Contact (organisation + person) 2. Proposal template 3. Language text template 4. Design template Use: \u2022 search_contact_organisation and search_contact_people to find contacts \u2022 create_contact to create one if not found (ask first if user wants to use existing) \u2022 get_proposal_templates, get_text_templates, and get_design_templates to fetch templates SENDING PROPOSALS: - After creating a proposal, you can send it to its assigned contacts using the send_proposal tool - Use send_proposal to send it via: offorte (email sent by system) or self (user sends it manually) - Use get_email_templates to fetch available templates - Either use send_message_id or ask the user for a custom send_message CREATING CONTACTS - Always search for a contact before creating a new one, if found ask the user if it wants to use the existing contact - Use the create_contact tool to create a new contact - The full name and email address are the most important fields - Only ask for optional fields if the user asks for it WRITING TEXTS - When writing proposal texts, use a professional yet conversational tone - Focus on value proposition and benefits rather than just features - Make content engaging, persuasive and customer-centric - Include clear calls-to-action and next steps - Highlight unique selling points and competitive advantages - Use active voice and positive language - Keep paragraphs concise and scannable - Address potential objections proactively - Maintain a balance between being informative and sales-oriented - Use industry-specific terminology appropriately - Include social proof, case studies, or testimonials when relevant - Focus on ROI and business outcomes - End with a strong closing that encourages action - Avoid generic content - make each proposal feel tailored RESPONSE FORMAT: - When listing items, only mention the most relevant field per item, e.g. name, label, etc. - Always use human understandable language and avoid technical jargon - When a list of items is longer than 3 items, only mention the first 3 items and then say "and more" - Never mention ID's, unless the user asks for it - Keep your responses concise but thorough - Confirm actions, give next steps, and avoid overexplaining ACTION-FIRST APPROACH: - When a user asks for something (e.g. create/send proposal), do it immediately - After performing the action, provide a short confirmation, key details and logical next steps ERROR HANDLING: - If you encounter an error, clearly explain what went wrong - Suggest how to fix or proceed - Always validate required fields and contact existence BEST PRACTICES: - Be clear, concise, and direct - Never introduce yourself or write long greetings - Provide guidance only when needed - Always focus on helping users achieve their goals quickly - Never ask the user for optional fields unless they ask for it You have access to powerful tools that can help you work with Offorte Proposal Software. Start with get_initial_context and then use the appropriate tools based on the user's needs. `; // src/utils/schema.ts import { z as z2 } from "zod"; var emptyObject = z2.object({}); var optionalId = z2.union([z2.number(), z2.null()]).optional(); var stringOrNumber = z2.union([z2.number(), z2.string()]); // src/tools/context/get-initial-context.ts var getInitialContextTool = { name: "get_initial_context", description: `IMPORTANT: This tool must be called before using any other tools. It will get usage instructions & Offorte context for this MCP server.`, parameters: emptyObject, annotations: { title: "Get MCP Server Instructions", openWorldHint: false }, async execute() { const currentDate = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US"); const context = outdent` ${INSTRUCTIONS} Context for your Offorte instance: <context> Account Name: ${config2.accountName} Date today: ${currentDate} </content> `; return context; } }; // src/utils/requests.ts import axios from "axios"; import { UserError } from "fastmcp"; function buildUrl(url) { if (/^https?:\/\//i.test(url)) { return url; } return `${config2.apiUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`; } async function request({ url, method = "GET", data, params, headers = {} }) { const conf = { url: buildUrl(url), method, data, params, headers: { Authorization: config2.apiKey, Accept: "application/json", ...headers } }; try { const response = await axios(conf); return response.data; } catch (err) { const isAxiosError = axios.isAxiosError(err); const errorsString = isAxiosError && err.response?.data?.errors ? ` | Errors: ${JSON.stringify(err.response.data.errors)}` : ""; throw new UserError( `Request failed: ${err["message"] || "Unknown error"}${errorsString}`, isAxiosError ? { method, url: conf.url, status: err.response?.status, statusText: err.response?.statusText, data: err.response?.data, code: err.code, errors: err.response?.data?.errors } : { err } ); } } function get(url, options = {}) { return request({ url, method: "GET", ...options }); } function post(url, data, options = {}) { return request({ url, method: "POST", data, ...options }); } // src/schemas/favorites.ts import { z as z4 } from "zod"; // src/schemas/shared.ts import { z as z3 } from "zod"; var contactType = z3.enum(["organisation", "person"]); var proposalStatus = z3.enum(["edit", "open", "won", "lost", "closed"]); var customFieldSchema = z3.object({ label: z3.string(), name: z3.string(), required: z3.boolean(), type: z3.enum(["text", "textarea", "html"]) }).passthrough(); var tagsSchema = z3.array(z3.union([z3.string(), z3.object({ id: z3.number(), name: z3.string() })])); // src/schemas/favorites.ts var proposalTemplateSchema = z4.object({ automations_set_id: optionalId, custom_fields: z4.array(customFieldSchema).optional(), design_template_id: optionalId, id: z4.number(), name: z4.string(), text_template_id: optionalId }).passthrough(); var proposalTemplatesSchema = z4.array(proposalTemplateSchema); // src/utils/errors.ts import { UserError as UserError2 } from "fastmcp"; var MESSAGES = { invalidApiResponse: "The API response did not match the expected format." }; var message = (type) => { return `Error: ${MESSAGES[type]}`; }; var throwApiInvalidResponseError = (error) => { const msg = message("invalidApiResponse"); throw new UserError2(`${msg}: ${String(error)}`); }; // src/tools/favorites/get-proposal-templates.ts var getProposalTemplatesTool = { name: "get_proposal_templates", description: `Lists proposal templates which are used as starting points to create new proposals`, parameters: emptyObject, annotations: { title: "Get Proposal Templates", openWorldHint: true }, async execute() { const result = await get("/favorites/proposals"); const parsed = proposalTemplatesSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/schemas/automations.ts import { z as z5 } from "zod"; var automationSetSchema = z5.object({ id: z5.number(), name: z5.string() }).passthrough(); var automationSetsSchema = z5.array(automationSetSchema); // src/tools/automations/get-automation-sets.ts var getAutomationSetsTool = { name: "get_automation_sets", description: "Lists automation sets which are used as an optional input to create a new proposal", parameters: emptyObject, annotations: { title: "Get Automation Sets", openWorldHint: true }, async execute() { const result = await get("/automations/sets"); const parsed = automationSetsSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/schemas/settings.ts import { z as z6 } from "zod"; var designTemplateSchema = z6.object({ id: z6.number(), name: z6.string() }).passthrough(); var designTemplatesSchema = z6.array(designTemplateSchema); var emailTemplateSchema = z6.object({ id: z6.number(), name: z6.string() }).passthrough(); var emailTemplatesSchema = z6.array(emailTemplateSchema); var textTemplateSchema = z6.object({ id: z6.number(), name: z6.string() }).passthrough(); var textTemplatesSchema = z6.array(textTemplateSchema); var tagSchema = z6.object({ id: z6.number(), name: z6.string() }).passthrough(); var tagsSchema2 = z6.array(tagSchema); // src/tools/settings/get-design-templates.ts var getDesignTemplatesTool = { name: "get_design_templates", description: "Lists available design templates which are used to create new proposals", parameters: emptyObject, annotations: { title: "Get Design Templates", openWorldHint: true }, async execute() { const result = await get("/settings/design-templates"); const parsed = designTemplatesSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/settings/get-email-templates.ts var getEmailTemplatesTool = { name: "get_email_templates", description: "Lists available email templates which are used to send proposals", parameters: emptyObject, annotations: { title: "Get Email Templates", openWorldHint: true }, async execute() { const result = await get("/settings/email-templates"); const parsed = emailTemplatesSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/settings/get-text-templates.ts var getTextTemplatesTool = { name: "get_text_templates", description: "Lists available language text templates which are used to create new proposals", parameters: emptyObject, annotations: { title: "Get Language Text Templates", openWorldHint: true }, async execute() { const result = await get("/settings/text-templates"); const parsed = textTemplatesSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/schemas/account.ts import { z as z7 } from "zod"; var accountUserSchema = z7.object({ id: z7.number(), email: z7.string(), firstname: z7.string(), lastname: z7.string(), phone: z7.string().nullable(), jobtitle: z7.string().nullable(), date_lastlogin: z7.string() }).passthrough(); var accountUsersSchema = z7.array(accountUserSchema); // src/tools/account/get-users.ts var getAccountUsersTool = { name: "get_account_users", description: "Lists all account users for the current account", parameters: emptyObject, annotations: { title: "Get Account Users", openWorldHint: true }, async execute() { const result = await get("/account/users"); const parsed = accountUsersSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/contacts/get-contact-details.ts import { z as z10 } from "zod"; // src/schemas/contacts.ts import { z as z9 } from "zod"; // src/schemas/proposals.ts import { z as z8 } from "zod"; var proposalListSchema = z8.object({ id: z8.number(), name: z8.string(), account_user_id: optionalId, contact_id: optionalId, contact_name: z8.string().nullable().optional(), contact_person_fullname: z8.string().nullable().optional(), contact_type: contactType.optional(), date_created: z8.string(), date_modified: z8.string().nullable().optional(), date_viewed: z8.string().nullable().optional(), date_won: z8.string().nullable().optional(), directory_id: optionalId, price_total: z8.string(), proposal_nr: stringOrNumber, status: proposalStatus, total_price: z8.string().nullable().optional(), total_price_override: z8.string().nullable().optional(), version_id: z8.number() }).passthrough(); var proposalsListSchema = z8.array(proposalListSchema); var proposalDirectorySchema = z8.object({ id: z8.number(), name: z8.string() }); var proposalDirectoriesSchema = z8.object({ closed: z8.array(proposalDirectorySchema).optional(), edit: z8.array(proposalDirectorySchema).optional(), lost: z8.array(proposalDirectorySchema).optional(), open: z8.array(proposalDirectorySchema).optional(), won: z8.array(proposalDirectorySchema).optional() }); var proposalContentRowSchema = z8.object({ content: z8.string(), discount_type: z8.string().nullable().optional(), discount_value: z8.number().nullable().optional(), hide_price: z8.boolean().nullable().optional(), hide_quantity: z8.boolean().nullable().optional(), price: z8.number().nullable().optional(), product_id: z8.number().nullable().optional(), quantity: z8.number().nullable().optional(), recurring_include_in_totals: z8.boolean().nullable().optional(), recurring_type: z8.string().nullable().optional(), selectable: z8.string().nullable().optional(), sku: z8.union([z8.string(), z8.number()]).nullable().optional(), type: z8.string(), unique_id: z8.string().nullable().optional(), user_quantity: z8.boolean().nullable().optional(), user_selected: z8.boolean().nullable().optional(), vat_percentage: z8.number().nullable().optional() }); var proposalContentPricetableSchema = z8.object({ id: z8.string(), rows: z8.array(proposalContentRowSchema) }); var proposalContentSchema = z8.object({ pricetables: z8.array(proposalContentPricetableSchema) }); var createProposalSchema = z8.object({ id: z8.number(), version_id: z8.number() }); var sendProposalReceiverSchema = z8.object({ email: z8.string(), fullname: z8.string(), id: z8.number(), proposal_link: z8.string() }); var sendProposalSchema = z8.object({ receivers: z8.array(sendProposalReceiverSchema) }); // src/schemas/contacts.ts var addressFields = { city: z9.string().nullable().optional(), country: z9.string().nullable().optional(), state: z9.string().nullable().optional(), street: z9.string().nullable().optional(), zipcode: z9.string().nullable().optional() }; var socialFields = { facebook: z9.string().nullable().optional(), instagram: z9.string().nullable().optional(), internet: z9.string().nullable().optional(), linkedin: z9.string().nullable().optional(), twitter: z9.string().nullable().optional() }; var contactFields = { email: z9.string().nullable().optional(), phone: z9.string().nullable().optional(), mobile: z9.string().nullable().optional(), fax: z9.string().nullable().optional() }; var personFields = { firstname: z9.string().nullable().optional(), lastname: z9.string().nullable().optional(), fullname: z9.string().nullable().optional(), salutation: z9.string().nullable().optional(), total_proposals: z9.number().nullable().optional() }; var organisationFields = { name: z9.string(), coc_number: z9.string().nullable().optional(), vat_number: z9.string().nullable().optional(), account_user_id: optionalId, account_user_name: z9.string().nullable().optional(), date_created: z9.string(), proposals_open: z9.number().nullable().optional(), proposals_won: z9.number().nullable().optional(), people: z9.array(z9.lazy(() => personSchema)).optional(), type: contactType }; var personSchema = z9.object({ id: z9.number(), ...addressFields, ...socialFields, ...contactFields, ...personFields }).passthrough(); var organisationSchema = z9.object({ id: z9.number(), ...addressFields, ...socialFields, ...contactFields, ...organisationFields }).passthrough(); var contactOrganisationsListSchema = z9.array(organisationSchema); var personOrOrganisationSchema = z9.object({ id: z9.number(), contact_id: optionalId, type: contactType, account_user_id: optionalId, account_user_name: z9.string().nullable().optional(), organisation: z9.string(), date_created: z9.string(), proposals_open: z9.number().nullable().optional(), proposals_won: z9.number().nullable().optional(), ...addressFields, ...socialFields, ...contactFields, firstname: z9.string(), lastname: z9.string(), fullname: z9.string(), salutation: z9.string() }).passthrough(); var contactPeopleListSchema = z9.array(personOrOrganisationSchema); var contactDetailsSchema = z9.object({ id: z9.number(), ...addressFields, ...socialFields, ...contactFields, ...organisationFields, people: z9.array(personSchema).optional(), proposals: proposalsListSchema.optional(), tags: tagsSchema2.optional() }).passthrough(); var contactCreateSchema = z9.object({ type: contactType, name: z9.string(), street: z9.string().optional(), zipcode: z9.string().optional(), city: z9.string().optional(), state: z9.string().optional(), country: z9.string().optional(), phone: z9.string().optional(), email: z9.string().optional(), internet: z9.string().optional(), linkedin: z9.string().optional(), facebook: z9.string().optional(), twitter: z9.string().optional(), instagram: z9.string().optional(), coc_number: z9.string().optional(), vat_number: z9.string().optional(), tags: tagsSchema2.optional(), firstname: z9.string().optional(), lastname: z9.string().optional(), salutation: z9.string().optional(), mobile: z9.string().optional() }).passthrough(); // src/tools/contacts/get-contact-details.ts var parameters = z10.object({ contact_id: z10.string() }); var getContactDetailsTool = { name: "get_contact_details", description: "Get all details for a contact by id", parameters, annotations: { title: "Get Contact Details", openWorldHint: true }, async execute({ contact_id }) { const result = await get(`/contacts/${contact_id}`); const parsed = contactDetailsSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/contacts/search-contact-organisations.ts import { z as z11 } from "zod"; var parameters2 = z11.object({ search: z11.string() }); var searchContactOrganisationsTool = { name: "search_contact_organisations", description: "Search for organisations by name in the contacts", parameters: parameters2, annotations: { title: "Search Contact Organisations", openWorldHint: true }, async execute({ search }) { const result = await get(`/contacts/organisations/?query=${encodeURIComponent(search)}`); const parsed = contactOrganisationsListSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/contacts/search-contact-people.ts import { z as z12 } from "zod"; var parameters3 = z12.object({ search: z12.string() }); var searchContactPeopleTool = { name: "search_contact_people", description: `Search for people by name in the contacts`, parameters: parameters3, annotations: { title: "Search Contact People", openWorldHint: true }, async execute({ search }) { const result = await get(`/contacts/people/?query=${encodeURIComponent(search)}`); const parsed = contactPeopleListSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/contacts/create-contact.ts var parameters4 = contactCreateSchema; var createContactTool = { name: "create_contact", description: "Create a new contact (organisation or person/individual)", parameters: parameters4, annotations: { title: "Create Contact", openWorldHint: true }, async execute(params) { const parsed = contactCreateSchema.safeParse(params); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } const result = await post("/contacts/", parsed.data); return JSON.stringify(result); } }; // src/tools/proposals/search-proposals.ts import { z as z13 } from "zod"; var parameters5 = z13.object({ search: z13.string() }); var searchProposalsTool = { name: "search_proposals", description: "Search for proposals by query", parameters: parameters5, annotations: { title: "Search Proposals", openWorldHint: true }, async execute({ search }) { const result = await get(`/proposals/open/?query=${encodeURIComponent(search)}`); const parsed = proposalsListSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/proposals/get-proposal-directories.ts var getProposalDirectoriesTool = { name: "get_proposal_directories", description: "Get all proposal directories grouped by status (edit, open, won, lost, closed)", parameters: emptyObject, annotations: { title: "Get Proposal Directories", openWorldHint: true }, async execute() { const result = await get("/proposal-directories/"); const parsed = proposalDirectoriesSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/proposals/create-proposal.ts import { z as z14 } from "zod"; var parameters6 = z14.object({ account_user_id: z14.number().optional(), contact_id: z14.number(), contact_people: z14.array(z14.number()), design_template_id: z14.number(), name: z14.string(), proposal_template_id: z14.number(), text_template_id: z14.number(), custom_fields: z14.array(z14.object({ name: z14.string(), value: z14.string() })).optional() // automations_set_id: z.number().optional(), // content: proposalContentSchema.optional(), // directory_id: z.number().optional(), // tags: z.array(z.union([z.string(), z.object({ id: z.number(), name: z.string() })])).optional(), }); var createProposalTool = { name: "create_proposal", description: "Create a new proposal", parameters: parameters6, annotations: { title: "Create Proposal", openWorldHint: true }, async execute(params) { const result = await post("/proposals/", params); const parsed = createProposalSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/proposals/send-proposal.ts import { z as z15 } from "zod"; var parameters7 = z15.object({ proposal_id: z15.number(), send_message_id: z15.number().optional(), send_method: z15.enum(["offorte", "self"]).default("offorte").optional(), send_message: z15.string().optional(), password_reset: z15.boolean().optional() }); var sendProposalTool = { name: "send_proposal", description: "Send a proposal to its assigned contacts", parameters: parameters7, annotations: { title: "Send Proposal", openWorldHint: true }, async execute({ proposal_id, ...body }) { const result = await post(`/proposals/${proposal_id}/send/`, body); const parsed = sendProposalSchema.safeParse(result); if (!parsed.success) { throwApiInvalidResponseError(parsed.error); } return JSON.stringify(parsed.data); } }; // src/tools/register.ts var tools = [ getInitialContextTool, getAccountUsersTool, getAutomationSetsTool, getContactDetailsTool, getDesignTemplatesTool, getEmailTemplatesTool, getProposalDirectoriesTool, getProposalTemplatesTool, getTextTemplatesTool, searchContactOrganisationsTool, searchContactPeopleTool, searchProposalsTool, createContactTool, createProposalTool, sendProposalTool ]; function registerTools({ server: server2 }) { tools.map(initialContextGuard).forEach((tool) => server2.addTool(tool)); } // src/server.ts var { TRANSPORT_TYPE } = config; var server = new FastMCP({ name: "Offorte Proposals", version: process.env.npm_package_version || "0.0.0" }); registerTools({ server }); if (TRANSPORT_TYPE === "httpStream") { server.start({ transportType: "httpStream", httpStream: { port: 3e3 } }); } else { server.start({ transportType: "stdio" }); } //# sourceMappingURL=server.js.map