@esaio/esa-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +23 -0
- package/.dockerignore +36 -0
- package/.envrc +2 -0
- package/.github/dependabot.yml +18 -0
- package/.github/workflows/docker-publish.yml +120 -0
- package/.github/workflows/main.yml +41 -0
- package/.node-version +1 -0
- package/CLAUDE.md +94 -0
- package/Dockerfile +34 -0
- package/LICENSE +7 -0
- package/README.en.md +139 -0
- package/README.md +139 -0
- package/bin/index.js +30 -0
- package/biome.json +57 -0
- package/package.json +48 -0
- package/src/__tests__/fixtures/mock-comment.ts +90 -0
- package/src/__tests__/fixtures/mock-post.ts +79 -0
- package/src/__tests__/index.test.ts +209 -0
- package/src/api_client/__tests__/index.test.ts +149 -0
- package/src/api_client/__tests__/middleware.test.ts +119 -0
- package/src/api_client/__tests__/with-context.test.ts +98 -0
- package/src/api_client/index.ts +29 -0
- package/src/api_client/middleware.ts +21 -0
- package/src/api_client/with-context.ts +26 -0
- package/src/config/__tests__/index.test.ts +65 -0
- package/src/config/index.ts +20 -0
- package/src/context/mcp-context.ts +1 -0
- package/src/context/stdio-context.ts +6 -0
- package/src/errors/missing-team-name-error.ts +8 -0
- package/src/formatters/__tests__/mcp-response.test.ts +106 -0
- package/src/formatters/mcp-response.ts +95 -0
- package/src/generated/api-types.ts +2691 -0
- package/src/i18n/__tests__/index.test.ts +53 -0
- package/src/i18n/index.ts +39 -0
- package/src/index.ts +47 -0
- package/src/locales/en.json +13 -0
- package/src/locales/ja.json +13 -0
- package/src/prompts/__tests__/index.test.ts +47 -0
- package/src/prompts/__tests__/summarize-post.test.ts +291 -0
- package/src/prompts/index.ts +21 -0
- package/src/prompts/summarize-post.ts +94 -0
- package/src/resources/__tests__/index.test.ts +49 -0
- package/src/resources/__tests__/recent-posts-list.test.ts +91 -0
- package/src/resources/__tests__/recent-posts.test.ts +270 -0
- package/src/resources/index.ts +33 -0
- package/src/resources/recent-posts-list.ts +22 -0
- package/src/resources/recent-posts.ts +45 -0
- package/src/schemas/team-name-schema.ts +19 -0
- package/src/tools/__tests__/categories.test.ts +226 -0
- package/src/tools/__tests__/comments.test.ts +970 -0
- package/src/tools/__tests__/helps.test.ts +222 -0
- package/src/tools/__tests__/index.test.ts +47 -0
- package/src/tools/__tests__/post-actions.test.ts +445 -0
- package/src/tools/__tests__/posts.test.ts +917 -0
- package/src/tools/__tests__/search.test.ts +339 -0
- package/src/tools/__tests__/teams.test.ts +615 -0
- package/src/tools/categories.ts +93 -0
- package/src/tools/comments.ts +258 -0
- package/src/tools/helps.ts +50 -0
- package/src/tools/index.ts +324 -0
- package/src/tools/post-actions.ts +132 -0
- package/src/tools/posts.ts +179 -0
- package/src/tools/search.ts +98 -0
- package/src/tools/teams.ts +157 -0
- package/src/transformers/__tests__/category-transformer.test.ts +161 -0
- package/src/transformers/__tests__/comment-transformer.test.ts +129 -0
- package/src/transformers/__tests__/post-name-normalizer.test.ts +53 -0
- package/src/transformers/__tests__/post-transformer.test.ts +70 -0
- package/src/transformers/__tests__/query-normalizer.test.ts +98 -0
- package/src/transformers/__tests__/team-name-normalizer.test.ts +21 -0
- package/src/transformers/category-transformer.ts +36 -0
- package/src/transformers/comment-transformer.ts +34 -0
- package/src/transformers/post-name-normalizer.ts +30 -0
- package/src/transformers/post-transformer.ts +38 -0
- package/src/transformers/query-normalizer.ts +36 -0
- package/src/transformers/team-name-normalizer.ts +7 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +30 -0
- package/tsdown.config.ts +13 -0
- package/vitest.config.ts +24 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{McpServer as e,ResourceTemplate as t}from"@modelcontextprotocol/sdk/server/mcp.js";import{StdioServerTransport as n}from"@modelcontextprotocol/sdk/server/stdio.js";import r from"i18next";import i from"openapi-fetch";import{z as a}from"zod";var o={name:`@esaio/esa-mcp-server`,version:`0.1.0`,description:`Official MCP server for esa.io - STDIO transport version`,main:`bin/index.js`,type:`module`,bin:{"esa-mcp-server":`./bin/index.js`},scripts:{build:`tsdown`,test:`vitest`,"test:run":`vitest run`,"test:coverage":`vitest run --coverage`,lint:`biome check . --error-on-warnings`,"lint:fix":`biome check --write .`,"type-check":`tsc --noEmit`},keywords:[`mcp`,`model-context-protocol`,`esa`,`esa.io`,`documentation`,`knowledge-base`],author:`esa LLC`,license:`MIT`,dependencies:{"@modelcontextprotocol/sdk":`^1.17.5`,axios:`^1.11.0`,i18next:`^25.5.2`,"openapi-fetch":`^0.14.0`,zod:`^3.23.8`},devDependencies:{"@biomejs/biome":`^2.2.4`,"@types/node":`^20.19.13`,"@vitest/coverage-v8":`^3.2.4`,"openapi-typescript":`^7.9.1`,tsdown:`^0.15.0`,tsx:`^4.20.5`,typescript:`^5.9.2`,vitest:`^3.2.4`},engines:{node:`>=20.19.4`}};const s={esa:{apiAccessToken:process.env.ESA_ACCESS_TOKEN||``,apiBaseUrl:process.env.ESA_API_BASE_URL||`https://api.esa.io`},server:{name:`esa-mcp-server`,version:o.version,description:`Official MCP server for esa.io`}};function c(){if(!s.esa.apiAccessToken)throw Error(`ESA_ACCESS_TOKEN environment variable is required`)}var ee={prompts:{summarize_post:{title:`Summarize esa post`,description:`Summarize an esa post in various formats (bullet points, paragraph, or keywords)`,args:{team_name:`The name of the esa team`,post_number:`The post number to summarize`,format:`Summary format (bullet/paragraph/keywords)`}}}},te={prompts:{summarize_post:{title:`esaの記事の要約`,description:`esa記事を指定した形式で要約します(bullet: 箇条書き, paragraph: 文章, keywords: キーワード)`,args:{team_name:`esaチーム名`,post_number:`要約する記事番号`,format:`要約形式 (bullet/paragraph/keywords)`}}}};async function ne(){let e=process.env.LC_ALL?.split(/[-_.]/)[0]||process.env.LC_MESSAGES?.split(/[-_.]/)[0]||process.env.LANG?.split(/[-_.]/)[0]||process.env.LANGUAGE?.split(/[-_.]/)[0]||`en`;return await r.init({lng:e,fallbackLng:`en`,resources:{ja:{translation:te},en:{translation:ee}},interpolation:{escapeValue:!1}}),r}function l(e,t){return r.t(e,t)}function re(e){return{async onRequest({request:t}){return t.headers.set(`Authorization`,`Bearer ${e}`),t},async onResponse({response:e}){let t=e.headers.get(`x-ratelimit-limit`),n=e.headers.get(`x-ratelimit-remaining`);return t&&n&&console.error(`Rate limit: ${n}/${t}`),e},async onError({error:e}){console.error(`Network Error:`,e)}}}const ie=o.version;function u(e){return{async onRequest({request:t}){return t.headers.set(`User-Agent`,`esa-mcp-server/${e} (official)`),t}}}function d(e,t=`https://api.esa.io`){let n=i({baseUrl:t});return n.use(u(ie)),n.use(re(e)),n}async function f(e,t,...n){let r;if(`apiAccessToken`in e&&`apiBaseUrl`in e)r=d(e.apiAccessToken,e.apiBaseUrl);else throw Error(`Unsupported context type. Only StdioContext is currently supported.`);return t(r,...n)}var p=class extends Error{constructor(){super(`Missing required parameter 'teamName'. Use esa_get_teams to list available teams, then retry with teamName specified.`),this.name=`MissingTeamNameError`}};function m(e){return e instanceof Error?`Error: ${e.message}`:typeof e==`number`&&e!==null?`Error: API Response(status: ${e})`:typeof e==`object`&&e?`Error: ${JSON.stringify(e,null,2)}`:`Error: ${String(e)}`}function h(e){return{content:[{type:`text`,text:JSON.stringify(e,null,2)}]}}function g(e,t){return{contents:[{uri:t,mimeType:`application/json`,text:JSON.stringify(e,null,2)}]}}function _(e){return{messages:[{role:`user`,content:{type:`text`,text:e}}]}}function v(e){return{content:[{type:`text`,text:m(e)}]}}function y(e,t){return{contents:[{uri:t,mimeType:`application/json`,text:m(e)}]}}function b(e){return{messages:[{role:`user`,content:{type:`text`,text:m(e)}}]}}const x=()=>a.object({teamName:a.string().describe(l(`prompts.summarize_post.args.team_name`)),postNumber:a.string().describe(l(`prompts.summarize_post.args.post_number`)),format:a.enum([`bullet`,`paragraph`,`keywords`]).optional().describe(l(`prompts.summarize_post.args.format`))});async function ae(e,t){let{teamName:n,postNumber:r,format:i=`bullet`}=t;if(!n)throw new p;let a=Number.parseInt(r,10);if(Number.isNaN(a)||a<=0)return b(`Post number must be a positive integer`);try{let{data:t,error:r,response:o}=await e.GET(`/v1/teams/{team_name}/posts/{post_number}`,{params:{path:{team_name:n,post_number:a}}});if(r||!o.ok)return b(r||o.status);let s=t,c=`Please summarize the following post:
|
|
3
|
+
|
|
4
|
+
`;switch(c+=`Title: ${s.name}\n`,c+=`URL: ${s.url}\n`,c+=`Author: ${s.created_by.name}\n`,c+=`Created: ${s.created_at}\n`,c+=`Updated: ${s.updated_at}\n`,s.category&&(c+=`Category: ${s.category}\n`),s.tags&&s.tags.length>0&&(c+=`Tags: ${s.tags.join(`, `)}\n`),c+=`
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
`,s.body_md&&(c+=`Content:\n${s.body_md}\n\n---\n\n`),i){case`bullet`:c+=`Please provide a summary in bullet points (3-5 main points).`;break;case`paragraph`:c+=`Please provide a summary in 2-3 paragraphs.`;break;case`keywords`:c+=`Please extract and list 10-15 important keywords from this post.`;break}return _(c)}catch(e){return b(e)}}function oe(e,t){console.error(`Setting up MCP prompts...`),e.registerPrompt(`esa_summarize_post`,{title:l(`prompts.summarize_post.title`),description:l(`prompts.summarize_post.description`),argsSchema:x().shape},async e=>f(t,ae,e))}function S(e,t={}){let{truncateBody:n}=t,r=e.body_md;return n&&r&&r.length>n&&(r=`${r.slice(0,n)}...`),{url:e.url,wip:e.wip?`WIP`:`Shipped`,kind:e.kind,category_and_title_and_tags:e.full_name,body_md:r,created_at:e.created_at,updated_at:e.updated_at,created_by:e.created_by,updated_by:e.updated_by,stats:{tasks_count:e.tasks_count,done_tasks_count:e.done_tasks_count,comments_count:e.comments_count,stargazers_count:e.stargazers_count,watchers_count:e.watchers_count}}}const se={sort:`updated`,order:`desc`};async function ce(e,t){let{teamName:n,uri:r}=t;try{let{data:t,error:i,response:a}=await e.GET(`/v1/teams/{team_name}/posts`,{params:{path:{team_name:n},query:se}});if(i||!a.ok)return y(i||a.status,r);let o={...t,posts:t.posts?.map(e=>S(e,{truncateBody:500}))};return g(o,r)}catch(e){return y(e,r)}}function C(e){let t=e.indexOf(`.`);return t>=0?e.substring(0,t):e}const w=a.string().default(``).describe(`Team name (required). Use esa_get_teams first to see available teams.`).transform(C);function T(e){return a.object({teamName:w,...e})}const E=a.object({page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`),role:a.enum([`member`,`owner`]).optional().describe(`Filter by role`)});async function D(e,t={}){try{let{data:n,error:r,response:i}=await e.GET(`/v1/teams`,{params:{query:{page:t.page,per_page:t.perPage,role:t.role}}});if(r||!i.ok)return v(r||i.status);let a={...n,teams:n.teams?.map(e=>({url:e.url,name:e.name,description:e.description}))};return h(a)}catch(e){return v(e)}}const O=T({});async function k(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/stats`,{params:{path:{team_name:t.teamName}}});return r||!i.ok?v(r||i.status):h(n)}catch(e){return v(e)}}const A=T({page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`)});async function j(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/tags`,{params:{path:{team_name:t.teamName},query:{page:t.page,per_page:t.perPage}}});return r||!i.ok?v(r||i.status):h(n)}catch(e){return v(e)}}const M=T({page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`),sort:a.enum([`posts_count`,`joined`,`last_accessed`]).optional().describe(`Sort criteria`),order:a.enum([`desc`,`asc`]).optional().describe(`Sort order`)});async function N(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/members`,{params:{path:{team_name:t.teamName},query:{page:t.page,per_page:t.perPage,sort:t.sort,order:t.order}}});return r||!i.ok?v(r||i.status):h(n)}catch(e){return v(e)}}async function P(e){try{let t=await f(e,D,{});return JSON.parse(t.content[0].text).teams?.map(e=>({uri:`esa://teams/${e.name}/posts/recent`,name:`Recent posts from ${e.name}`,description:`Recent posts from ${e.name}${e.description?` (${e.description})`:``}`,mimeType:`application/json`}))||[]}catch(e){return console.error(`Failed to list teams:`,e),[]}}function F(e,n){console.error(`Setting up MCP resources...`),e.registerResource(`esa_recent_posts`,new t(`esa://teams/{teamName}/posts/recent`,{list:async()=>({resources:await P(n)})}),{title:`Recent Posts`,description:`Fetch recent updated posts from esa team`,mimeType:`application/json`},async(e,t)=>f(n,ce,{...t,uri:e.href}))}function I(e){return{full_name:e.full_name,count:e.count,has_child:e.has_child||!1}}function L(e){return{current_category:e.current_category,categories:e.categories?.map(I)||[],parent_categories:e.parent_categories?.map(e=>({current_category:e.current_category,categories:e.categories?.map(I)||[]})),readme:e.readme,no_category:e.no_category?I(e.no_category):void 0,descendant_posts:e.descendant_posts,posts:e.posts,total_count:e.total_count,per_page:e.per_page,page:e.page,prev_page:e.prev_page,next_page:e.next_page,max_per_page:e.max_per_page}}const R=T({select:a.string().describe(`Category path to retrieve`),include:a.enum([`posts`,`parent_categories`]).optional().describe(`Additional information to include`),descendantPosts:a.boolean().optional().describe(`Include descendant posts (only effective with include=posts)`),page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`)});async function z(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/categories`,{params:{path:{team_name:t.teamName},query:{select:t.select,include:t.include,descendant_posts:t.descendantPosts,page:t.page,per_page:t.perPage}}});if(r||!i.ok)return v(r||i.status);let a=L(n);return h(a)}catch(e){return v(e)}}const B=T({});async function V(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/categories/top`,{params:{path:{team_name:t.teamName}}});if(r||!i.ok)return v(r||i.status);let a=L(n);return h(a)}catch(e){return v(e)}}function H(e,t={}){let{truncateBody:n}=t,r=e.body_md;return n&&r&&r.length>n&&(r=`${r.slice(0,n)}...`),{id:e.id,post_number:e.post_number,url:e.url,body_md:r,created_at:e.created_at,updated_at:e.updated_at,created_by:e.created_by,stats:{stargazers_count:e.stargazers_count,star:e.star},stargazers:e.stargazers}}const U=T({commentId:a.number().describe(`The comment ID to retrieve`),include:a.enum([`stargazers`]).optional().describe(`Specify 'stargazers' to include stargazers in the response`)});async function W(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/comments/{comment_id}`,{params:{path:{team_name:t.teamName,comment_id:t.commentId},query:{include:t.include}}});if(r||!i.ok)return v(r||i.status);let a=H(n);return h(a)}catch(e){return v(e)}}const G=T({postNumber:a.number().describe(`The post number to comment on`),bodyMd:a.string().describe(`The comment content in Markdown format`),user:a.string().optional().describe(`Comment author's screen_name (owner permission required)`)});async function K(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.POST(`/v1/teams/{team_name}/posts/{post_number}/comments`,{params:{path:{team_name:t.teamName,post_number:t.postNumber}},body:{comment:{body_md:t.bodyMd,user:t.user}}});if(r||!i.ok)return v(r||i.status);let a=H(n,{truncateBody:300});return h(a)}catch(e){return v(e)}}const le=T({commentId:a.number().describe(`The comment ID to update`),bodyMd:a.string().describe(`The updated comment content in Markdown format`),user:a.string().optional().describe(`Comment author's screen_name (owner permission required)`)});async function ue(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.PATCH(`/v1/teams/{team_name}/comments/{comment_id}`,{params:{path:{team_name:t.teamName,comment_id:t.commentId}},body:{comment:{body_md:t.bodyMd,user:t.user}}});if(r||!i.ok)return v(r||i.status);let a=H(n);return h(a)}catch(e){return v(e)}}const de=T({commentId:a.number().describe(`The comment ID to delete`)});async function fe(e,t){try{if(!t.teamName)throw new p;let{error:n,response:r}=await e.DELETE(`/v1/teams/{team_name}/comments/{comment_id}`,{params:{path:{team_name:t.teamName,comment_id:t.commentId}}});return n||!r.ok?v(n||r.status):h({success:!0,message:`Comment deleted successfully`})}catch(e){return v(e)}}const pe=T({postNumber:a.number().describe(`The post number to get comments for`),page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`)});async function me(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/posts/{post_number}/comments`,{params:{path:{team_name:t.teamName,post_number:t.postNumber},query:{page:t.page,per_page:t.perPage}}});if(r||!i.ok)return v(r||i.status);let a=n.comments.map(e=>H(e,{truncateBody:300}));return h({...n,comments:a})}catch(e){return v(e)}}const he=T({page:a.number().optional().describe(`Page number (starts from 1)`),perPage:a.number().optional().describe(`Number of items per page`)});async function ge(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/comments`,{params:{path:{team_name:t.teamName},query:{page:t.page,per_page:t.perPage}}});if(r||!i.ok)return v(r||i.status);let a=n.comments.map(e=>H(e,{truncateBody:300}));return h({...n,comments:a})}catch(e){return v(e)}}function q(e,t){if(!e||t!==void 0)return{name:e,category:t};if(e.includes(`/`)){let t=e.split(`/`),n=t.pop(),r=t.join(`/`);return{name:n||void 0,category:r}}return{name:e,category:t}}const _e=T({postNumber:a.number().describe(`The post number to retrieve`),include:a.enum([`comments`]).optional().describe(`Specify 'comments' to include comments in the response`)});async function J(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/posts/{post_number}`,{params:{path:{team_name:t.teamName,post_number:t.postNumber},query:{include:t.include}}});if(r||!i.ok)return v(r||i.status);let a=S(n);return h(a)}catch(e){return v(e)}}const ve=T({name:a.string().describe(`The post name (title)`),bodyMd:a.string().optional().describe(`The post content in Markdown format`),tags:a.array(a.string()).optional().describe(`Tags for the post`),category:a.string().optional().describe(`Category path (e.g., 'dev/docs')`),wip:a.boolean().default(!0).describe(`Whether the post is Work In Progress. Set to false to ship it (mark as complete and ready to be published)`),message:a.string().optional().describe(`Update message for the post`)});async function Y(e,t){try{if(!t.teamName)throw new p;let{name:n,category:r}=q(t.name,t.category),{data:i,error:a,response:o}=await e.POST(`/v1/teams/{team_name}/posts`,{params:{path:{team_name:t.teamName}},body:{post:{name:n,body_md:t.bodyMd,tags:t.tags,category:r,wip:t.wip,message:t.message}}});if(a||!o.ok)return v(a||o.status);let s=S(i);return h(s)}catch(e){return v(e)}}const ye=T({postNumber:a.number().describe(`The post number to update`),name:a.string().optional().describe(`The post name (title)`),bodyMd:a.string().optional().describe(`The post content in Markdown format`),tags:a.array(a.string()).optional().describe(`Tags for the post`),category:a.string().optional().describe(`Category path (e.g., 'dev/docs')`),wip:a.boolean().optional().describe(`Whether the post is Work In Progress. Set to false to ship it (mark as complete and ready to be published)`),message:a.string().optional().describe(`Update message for the post`),originalRevision:a.object({bodyMd:a.string(),number:a.number(),user:a.string()}).optional().describe(`Original revision to check for conflicts`)});async function X(e,t){try{if(!t.teamName)throw new p;let{name:n,category:r}=q(t.name,t.category),{data:i,error:a,response:o}=await e.PATCH(`/v1/teams/{team_name}/posts/{post_number}`,{params:{path:{team_name:t.teamName,post_number:t.postNumber}},body:{post:{name:n,body_md:t.bodyMd,tags:t.tags,category:r,wip:t.wip,message:t.message,original_revision:t.originalRevision?{body_md:t.originalRevision.bodyMd,number:t.originalRevision.number,user:t.originalRevision.user}:void 0}}});if(a||!o.ok)return v(a||o.status);let s=S(i);return h(s)}catch(e){return v(e)}}function be(e){let t=e;return t===`*`&&(t=``),t=t.replace(/\bafter:(\d{4}-\d{2}-\d{2})\b/gi,`created:>$1`),t=t.replace(/\bbefore:(\d{4}-\d{2}-\d{2})\b/gi,`created:<$1`),t=t.replace(/\bsince:(\d{4}-\d{2}-\d{2})\b/gi,`created:>$1`),t=t.replace(/\buntil:(\d{4}-\d{2}-\d{2})\b/gi,`created:<$1`),t}const Z=T({query:a.string().describe(`Search query string. Use specific terms, not wildcards like "*". Empty string returns all posts.
|
|
8
|
+
## Important Note for Date Queries:
|
|
9
|
+
**WARNING: Do NOT use 'after:', 'before:', 'since:', or 'until:' syntax (these are from GitHub/Gmail/pplog).
|
|
10
|
+
Use esa-specific date syntax: created:>YYYY-MM-DD, created:<YYYY-MM-DD, updated:>YYYY-MM-DD, updated:<YYYY-MM-DD
|
|
11
|
+
|
|
12
|
+
## Important Note for Relative Date Queries:
|
|
13
|
+
**CRITICAL: Always get today's actual date from the system before processing
|
|
14
|
+
relative date queries (e.g., "today", "yesterday", "last week", "recent").
|
|
15
|
+
When searching, apply these strategies:
|
|
16
|
+
1. Convert concepts to technical terms (e.g., general descriptions → specific property names, method names, or technical keywords)
|
|
17
|
+
2. Translate between Japanese and English technical terms (e.g., Japanese concepts → English API/property names)
|
|
18
|
+
3. Expand to related technical elements (e.g., one concept → multiple implementation approaches, related technologies, or alternative solutions)
|
|
19
|
+
IMPORTANT: Space-separated terms are treated as AND conditions. Use "OR" operator for alternative terms: "word-break OR word-wrap OR overflow-wrap".
|
|
20
|
+
Advanced search: "tag:release", "category:dev", "wip:false", "keyword:API", "title:設計書".
|
|
21
|
+
Category search: "on:category" (posts directly in category), "in:category" (posts in category and subcategories), "on:/" (uncategorized posts).
|
|
22
|
+
For broader results, use OR between related terms rather than listing them with spaces.`).transform(be),sort:a.enum([`updated`,`created`,`number`,`stars`,`watches`,`comments`,`best_match`]).optional().describe(`Sort key`),order:a.enum([`desc`,`asc`]).optional().describe(`Sort direction`),page:a.number().int().positive().optional().describe(`Page number`),perPage:a.number().int().min(1).max(100).optional().describe(`Items per page`),include:a.enum([`comments`]).optional().describe(`Specify 'comments' to include comments in the response`)});async function Q(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/posts`,{params:{path:{team_name:t.teamName},query:{q:t.query,sort:t.sort,order:t.order,page:t.page,per_page:t.perPage,include:t.include}}});if(r||!i.ok)return v(r||i.status);let a=n.posts.map(e=>S(e,{truncateBody:500}));return h(a)}catch(e){return v(e)}}const $={TEAM:`docs`,SEARCH_OPTIONS_POST_ID:104,MARKDOWN_SYNTAX_POST_ID:49},xe=Z.omit({teamName:!0,order:!0,include:!0,sort:!0});async function Se(e,t){return J(e,{teamName:$.TEAM,postNumber:$.SEARCH_OPTIONS_POST_ID})}async function Ce(e,t){return J(e,{teamName:$.TEAM,postNumber:$.MARKDOWN_SYNTAX_POST_ID})}async function we(e,t){return Q(e,{teamName:$.TEAM,sort:`best_match`,...t})}const Te=T({postNumber:a.number().describe(`The post number to archive`),message:a.string().optional().describe(`Archive message for the post`)});async function Ee(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/posts/{post_number}`,{params:{path:{team_name:t.teamName,post_number:t.postNumber}}});if(r||!i.ok)return v(r||i.status);let a=n.category||``;if(a.startsWith(`Archived/`))return h({message:`Post is already archived`,category:a});let o=a===``?`Archived`:`Archived/${a}`;return await X(e,{teamName:t.teamName,postNumber:t.postNumber,category:o,message:t.message||`Archive post`})}catch(e){return v(e)}}const De=T({postNumber:a.number().describe(`The post number to ship`)});async function Oe(e,t){try{if(!t.teamName)throw new p;return await X(e,{teamName:t.teamName,postNumber:t.postNumber,wip:!1,message:`Ship It!`})}catch(e){return v(e)}}const ke=T({postNumber:a.number().describe(`The source post number to prepare for duplication`),targetTeamName:a.string().optional().describe(`The name of the esa team`).transform(e=>e?C(e):void 0)});async function Ae(e,t){try{if(!t.teamName)throw new p;let{data:n,error:r,response:i}=await e.GET(`/v1/teams/{team_name}/posts/new`,{params:{path:{team_name:t.teamName},query:{parent_post_id:t.postNumber}}});if(r||!i.ok)return v(r||i.status);let a=n.post;return Y(e,{teamName:t.targetTeamName||t.teamName,name:a.name,bodyMd:a.body_md,wip:!0})}catch(e){return v(e)}}function je(e,t){console.error(`Setting up MCP tools...`),e.registerTool(`esa_get_teams`,{title:`Get user's accessible esa teams`,description:`Retrieves a list of esa teams that the user has access to.`,inputSchema:E.shape},async e=>f(t,D,e)),e.registerTool(`esa_get_team_stats`,{title:`Get team statistics`,description:`Retrieves team statistics including member count, posts count (total/WIP/shipped), comments, stars, watches, and daily/weekly/monthly active users`,inputSchema:O.shape},async e=>f(t,k,e)),e.registerTool(`esa_get_team_tags`,{title:`Get team tags`,description:`Retrieves all tags used in posts within a team, along with the count of posts for each tag`,inputSchema:A.shape},async e=>f(t,j,e)),e.registerTool(`esa_get_team_members`,{title:`Get team members`,description:`Retrieves all members of a team with their roles and profile information`,inputSchema:M.shape},async e=>f(t,N,e)),e.registerTool(`esa_get_post`,{title:`Get a specific esa post`,description:`Retrieves a specific post from an esa team by post number, with optional comments included.`,inputSchema:_e.shape},async e=>f(t,J,e)),e.registerTool(`esa_search_posts`,{title:`Search Posts`,description:`Search for posts in esa.io`,inputSchema:Z.shape},async e=>f(t,Q,e)),e.registerTool(`esa_create_post`,{title:`Create a new esa post`,description:`Creates a new post in an esa team with optional tags, category, and WIP status.`,inputSchema:ve.shape},async e=>f(t,Y,e)),e.registerTool(`esa_update_post`,{title:`Update an existing esa post`,description:`Updates an existing post in an esa team by post number. You can update the title, content, tags, category, and WIP status. To ship a post (mark as complete), set wip to false - this is preferred over using esa_ship_post when updating other fields simultaneously.`,inputSchema:ye.shape},async e=>f(t,X,e)),e.registerTool(`esa_get_comment`,{title:`Get a specific comment`,description:`Retrieves a specific comment by comment ID, with optional stargazers included.`,inputSchema:U.shape},async e=>f(t,W,e)),e.registerTool(`esa_create_comment`,{title:`Create a new comment on a post`,description:`Creates a new comment on an existing post in an esa team.`,inputSchema:G.shape},async e=>f(t,K,e)),e.registerTool(`esa_update_comment`,{title:`Update an existing comment`,description:`Updates an existing comment in an esa team by comment ID.`,inputSchema:le.shape},async e=>f(t,ue,e)),e.registerTool(`esa_delete_comment`,{title:`Delete a comment`,description:`Deletes a comment from an esa team by comment ID.`,inputSchema:de.shape},async e=>f(t,fe,e)),e.registerTool(`esa_get_post_comments`,{title:`Get comments for a specific post`,description:`Retrieves a list of comments for a specific post with pagination support.`,inputSchema:pe.shape},async e=>f(t,me,e)),e.registerTool(`esa_get_team_comments`,{title:`Get team comments`,description:`Retrieves a list of comments in a team with pagination support.`,inputSchema:he.shape},async e=>f(t,ge,e)),e.registerTool(`esa_get_categories`,{title:`Get categories for a specific path`,description:`Retrieves category information and subcategories for a specific category path, with optional posts and parent categories included`,inputSchema:R.shape},async e=>f(t,z,e)),e.registerTool(`esa_get_top_categories`,{title:`Get top-level categories`,description:`Retrieves all top-level categories for a team`,inputSchema:B.shape},async e=>f(t,V,e)),e.registerTool(`esa_archive_post`,{title:`Archive a post`,description:`Archives a post by moving it to the Archived/ category. If the post is in 'dev/docs', it becomes 'Archived/dev/docs'. Posts without category go to 'Archived'.`,inputSchema:Te.shape},async e=>f(t,Ee,e)),e.registerTool(`esa_ship_post`,{title:`Ship a post`,description:`Ships a post by setting wip to false. This marks the post as complete and ready to be published. Use this only when you need to ship without making other changes - if you're also updating title, content, or other fields, use esa_update_post with wip: false instead.`,inputSchema:De.shape},async e=>f(t,Oe,e)),e.registerTool(`esa_duplicate_post`,{title:`Prepare a post for duplication`,description:`Prepares a post for duplication by retrieving its name and body_md content. Returns the name and body_md that can be used with esa_create_post to create a duplicate of the original post.`,inputSchema:ke.shape},async e=>f(t,Ae,e)),e.registerTool(`esa_get_search_options_help`,{title:`Get esa search options documentation`,description:`Get esa search syntax documentation when you need to construct complex
|
|
23
|
+
search queries. Use this BEFORE esa_search_posts if you're unsure how to
|
|
24
|
+
translate user's search requirements into proper esa query syntax (e.g., date
|
|
25
|
+
ranges, tag filters, category searches, advanced operators).`,inputSchema:{}},async e=>f(t,Se,e)),e.registerTool(`esa_get_markdown_syntax_help`,{title:`Get esa Markdown syntax documentation`,description:`Get esa Markdown and formatting documentation when unsure about syntax.
|
|
26
|
+
Use this BEFORE using any tools with *_md parameters (like esa_create_post,
|
|
27
|
+
esa_update_post, esa_create_comment, esa_update_comment) if you need
|
|
28
|
+
clarification on Markdown syntax, esa-specific extensions, or formatting options.`,inputSchema:{}},async e=>f(t,Ce,e)),e.registerTool(`esa_search_help`,{title:`Search esa documentation and help`,description:`Search esa documentation for features, terminology, and specifications.
|
|
29
|
+
Use this when users mention esa-specific terms, ask about esa functionality,
|
|
30
|
+
or request help with esa workflows that you're not familiar with.`,inputSchema:xe.shape},async e=>f(t,we,e))}try{c()}catch(e){console.error(`Configuration error:`,e),process.exit(1)}async function Me(){await ne();let t=new e({name:s.server.name,version:s.server.version});je(t,s.esa),F(t,s.esa),oe(t,s.esa);let r=new n;r.onclose=()=>{console.error(`Transport closed`)},r.onerror=e=>{console.error(`Transport error:`,e)},await t.connect(r),console.error(`${s.server.name} v${s.server.version} started`)}await Me().catch(e=>{console.error(`Server startup error:`,e),process.exit(1)});export{};
|
package/biome.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
|
3
|
+
"assist": {
|
|
4
|
+
"actions": {
|
|
5
|
+
"source": {
|
|
6
|
+
"organizeImports": "on"
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"linter": {
|
|
11
|
+
"enabled": true,
|
|
12
|
+
"rules": {
|
|
13
|
+
"recommended": true,
|
|
14
|
+
"complexity": {
|
|
15
|
+
"noForEach": "off"
|
|
16
|
+
},
|
|
17
|
+
"suspicious": {
|
|
18
|
+
"noExplicitAny": "error",
|
|
19
|
+
"noConsole": "off"
|
|
20
|
+
},
|
|
21
|
+
"style": {
|
|
22
|
+
"useFilenamingConvention": {
|
|
23
|
+
"level": "error",
|
|
24
|
+
"options": {
|
|
25
|
+
"filenameCases": ["kebab-case"]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"formatter": {
|
|
32
|
+
"enabled": true,
|
|
33
|
+
"formatWithErrors": false,
|
|
34
|
+
"indentStyle": "space",
|
|
35
|
+
"indentWidth": 2,
|
|
36
|
+
"lineWidth": 80,
|
|
37
|
+
"lineEnding": "lf"
|
|
38
|
+
},
|
|
39
|
+
"javascript": {
|
|
40
|
+
"formatter": {
|
|
41
|
+
"quoteStyle": "double",
|
|
42
|
+
"semicolons": "always",
|
|
43
|
+
"trailingCommas": "all"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"files": {
|
|
47
|
+
"includes": [
|
|
48
|
+
"**",
|
|
49
|
+
"!**/node_modules",
|
|
50
|
+
"!**/bin",
|
|
51
|
+
"!**/coverage",
|
|
52
|
+
"!**/*.config.js",
|
|
53
|
+
"!**/*.config.ts",
|
|
54
|
+
"!.claude"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@esaio/esa-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official MCP server for esa.io - STDIO transport version",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"esa-mcp-server": "./bin/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsdown",
|
|
12
|
+
"test": "vitest",
|
|
13
|
+
"test:run": "vitest run",
|
|
14
|
+
"test:coverage": "vitest run --coverage",
|
|
15
|
+
"lint": "biome check . --error-on-warnings",
|
|
16
|
+
"lint:fix": "biome check --write .",
|
|
17
|
+
"type-check": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"model-context-protocol",
|
|
22
|
+
"esa",
|
|
23
|
+
"esa.io",
|
|
24
|
+
"documentation",
|
|
25
|
+
"knowledge-base"
|
|
26
|
+
],
|
|
27
|
+
"author": "esa LLC",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
31
|
+
"i18next": "^25.5.2",
|
|
32
|
+
"openapi-fetch": "^0.14.0",
|
|
33
|
+
"zod": "^3.23.8"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.2.4",
|
|
37
|
+
"@types/node": "^20.19.14",
|
|
38
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
39
|
+
"openapi-typescript": "^7.9.1",
|
|
40
|
+
"tsdown": "^0.15.1",
|
|
41
|
+
"tsx": "^4.20.5",
|
|
42
|
+
"typescript": "^5.9.2",
|
|
43
|
+
"vitest": "^3.2.4"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=20.19.4"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { components } from "../../generated/api-types.js";
|
|
2
|
+
import { transformComment } from "../../transformers/comment-transformer.js";
|
|
3
|
+
|
|
4
|
+
export function createMockComment(
|
|
5
|
+
overrides?: Partial<components["schemas"]["Comment"]>,
|
|
6
|
+
): components["schemas"]["Comment"] {
|
|
7
|
+
return {
|
|
8
|
+
id: 123,
|
|
9
|
+
post_number: 456,
|
|
10
|
+
body_md: "This is a test comment content",
|
|
11
|
+
body_html: "<p>This is a test comment content</p>",
|
|
12
|
+
created_at: "2024-01-01T00:00:00+09:00",
|
|
13
|
+
updated_at: "2024-01-01T01:00:00+09:00",
|
|
14
|
+
url: "https://test-team.esa.example.com/posts/456#comment-123",
|
|
15
|
+
created_by: {
|
|
16
|
+
name: "Test User",
|
|
17
|
+
screen_name: "testuser",
|
|
18
|
+
icon: "https://example.com/icon.png",
|
|
19
|
+
myself: true,
|
|
20
|
+
},
|
|
21
|
+
stargazers_count: 5,
|
|
22
|
+
star: true,
|
|
23
|
+
stargazers: [
|
|
24
|
+
{
|
|
25
|
+
created_at: "2024-01-01T02:00:00+09:00",
|
|
26
|
+
body: "Great comment!",
|
|
27
|
+
user: {
|
|
28
|
+
name: "Stargazer User",
|
|
29
|
+
screen_name: "stargazer",
|
|
30
|
+
icon: "https://example.com/star-icon.png",
|
|
31
|
+
myself: false,
|
|
32
|
+
},
|
|
33
|
+
name: "Stargazer User",
|
|
34
|
+
screen_name: "stargazer",
|
|
35
|
+
icon: "https://example.com/star-icon.png",
|
|
36
|
+
myself: false,
|
|
37
|
+
email: "stargazer@example.com",
|
|
38
|
+
role: "member" as const,
|
|
39
|
+
posts_count: 10,
|
|
40
|
+
joined_at: "2024-01-01T00:00:00+09:00",
|
|
41
|
+
last_accessed_at: "2024-01-01T02:00:00+09:00",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createLongContentComment(
|
|
49
|
+
length: number,
|
|
50
|
+
): components["schemas"]["Comment"] {
|
|
51
|
+
const longContent = "a".repeat(length);
|
|
52
|
+
return createMockComment({
|
|
53
|
+
body_md: longContent,
|
|
54
|
+
body_html: `<p>${longContent}</p>`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createNullBodyComment(
|
|
59
|
+
overrides?: Partial<components["schemas"]["Comment"]>,
|
|
60
|
+
): components["schemas"]["Comment"] {
|
|
61
|
+
return createMockComment({
|
|
62
|
+
body_md: "",
|
|
63
|
+
body_html: "",
|
|
64
|
+
...overrides,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createExpectedTransformedComment(
|
|
69
|
+
comment: components["schemas"]["Comment"],
|
|
70
|
+
) {
|
|
71
|
+
return transformComment(comment);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createMockCommentList(
|
|
75
|
+
overrides?: Partial<components["schemas"]["CommentList"]>,
|
|
76
|
+
): components["schemas"]["CommentList"] {
|
|
77
|
+
return {
|
|
78
|
+
comments: [
|
|
79
|
+
createMockComment({ id: 1, post_number: 123, body_md: "First comment" }),
|
|
80
|
+
createMockComment({ id: 2, post_number: 124, body_md: "Second comment" }),
|
|
81
|
+
],
|
|
82
|
+
prev_page: null,
|
|
83
|
+
next_page: 2,
|
|
84
|
+
total_count: 10,
|
|
85
|
+
page: 1,
|
|
86
|
+
per_page: 20,
|
|
87
|
+
max_per_page: 100,
|
|
88
|
+
...overrides,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { components } from "../../generated/api-types.js";
|
|
2
|
+
import {
|
|
3
|
+
type PostTransformOptions,
|
|
4
|
+
transformPost,
|
|
5
|
+
} from "../../transformers/post-transformer.js";
|
|
6
|
+
|
|
7
|
+
export const createMockPost = (
|
|
8
|
+
overrides: Partial<components["schemas"]["Post"]> = {},
|
|
9
|
+
): components["schemas"]["Post"] => ({
|
|
10
|
+
number: 123,
|
|
11
|
+
name: "test-post.md",
|
|
12
|
+
tags: ["tag1", "tag2"],
|
|
13
|
+
category: "dev",
|
|
14
|
+
full_name: "dev/test-post.md #tag1 #tag2",
|
|
15
|
+
wip: false,
|
|
16
|
+
body_md: "# Test Post\n\nThis is a test post content.",
|
|
17
|
+
body_html: "<h1>Test Post</h1><p>This is a test post content.</p>",
|
|
18
|
+
created_at: "2024-01-01T00:00:00+09:00",
|
|
19
|
+
updated_at: "2024-01-02T00:00:00+09:00",
|
|
20
|
+
message: "Update test post",
|
|
21
|
+
url: "https://test-team.esa.example.com/posts/123",
|
|
22
|
+
revision_number: 3,
|
|
23
|
+
created_by: {
|
|
24
|
+
name: "user1",
|
|
25
|
+
screen_name: "user1",
|
|
26
|
+
icon: "https://example.com/icon1.png",
|
|
27
|
+
myself: false,
|
|
28
|
+
},
|
|
29
|
+
updated_by: {
|
|
30
|
+
name: "user2",
|
|
31
|
+
screen_name: "user2",
|
|
32
|
+
icon: "https://example.com/icon2.png",
|
|
33
|
+
myself: false,
|
|
34
|
+
},
|
|
35
|
+
kind: "stock",
|
|
36
|
+
comments_count: 5,
|
|
37
|
+
tasks_count: 3,
|
|
38
|
+
done_tasks_count: 2,
|
|
39
|
+
stargazers_count: 10,
|
|
40
|
+
watchers_count: 8,
|
|
41
|
+
star: true,
|
|
42
|
+
watch: false,
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const createWipPost = (
|
|
47
|
+
overrides: Partial<components["schemas"]["Post"]> = {},
|
|
48
|
+
): components["schemas"]["Post"] =>
|
|
49
|
+
createMockPost({
|
|
50
|
+
wip: true,
|
|
51
|
+
number: 456,
|
|
52
|
+
name: "wip-post.md",
|
|
53
|
+
full_name: "docs/wip-post.md #wip",
|
|
54
|
+
url: "https://test-team.esa.example.com/posts/456",
|
|
55
|
+
kind: "flow",
|
|
56
|
+
...overrides,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const createLongContentPost = (
|
|
60
|
+
contentLength = 600,
|
|
61
|
+
): components["schemas"]["Post"] =>
|
|
62
|
+
createMockPost({
|
|
63
|
+
body_md: "a".repeat(contentLength),
|
|
64
|
+
body_html: `<p>${"a".repeat(contentLength)}</p>`,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const createNullBodyPost = (
|
|
68
|
+
overrides: Partial<components["schemas"]["Post"]> = {},
|
|
69
|
+
): components["schemas"]["Post"] =>
|
|
70
|
+
createMockPost({
|
|
71
|
+
body_md: undefined,
|
|
72
|
+
body_html: undefined,
|
|
73
|
+
...overrides,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const createExpectedTransformed = (
|
|
77
|
+
post: components["schemas"]["Post"],
|
|
78
|
+
options: PostTransformOptions = {},
|
|
79
|
+
) => transformPost(post, options);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import type { MockInstance } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
describe("MCP Server", () => {
|
|
7
|
+
let consoleErrorSpy: MockInstance<typeof console.error>;
|
|
8
|
+
let processExitSpy: MockInstance<typeof process.exit>;
|
|
9
|
+
|
|
10
|
+
const createMockConfig = (behavior?: { shouldThrow?: boolean }) => ({
|
|
11
|
+
config: {
|
|
12
|
+
server: {
|
|
13
|
+
name: "test-server",
|
|
14
|
+
version: "1.0.0",
|
|
15
|
+
},
|
|
16
|
+
esa: {
|
|
17
|
+
apiAccessToken: "test-token",
|
|
18
|
+
apiBaseUrl: "https://api.esa.example.com",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
validateConfig: vi.fn().mockImplementation(() => {
|
|
22
|
+
if (behavior?.shouldThrow) {
|
|
23
|
+
throw new Error("Invalid config");
|
|
24
|
+
}
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const createMockServer = (behavior?: { shouldFailConnect?: boolean }) => {
|
|
29
|
+
const mockConnect = behavior?.shouldFailConnect
|
|
30
|
+
? vi.fn().mockRejectedValue(new Error("Connection failed"))
|
|
31
|
+
: vi.fn().mockResolvedValue(undefined);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
connect: mockConnect,
|
|
35
|
+
} as unknown as McpServer;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const createMockTransport = () =>
|
|
39
|
+
({
|
|
40
|
+
onclose: undefined as (() => void) | undefined,
|
|
41
|
+
onerror: undefined as ((error: Error) => void) | undefined,
|
|
42
|
+
}) as unknown as StdioServerTransport;
|
|
43
|
+
|
|
44
|
+
const setupServerMocks = (
|
|
45
|
+
mockConfig: ReturnType<typeof createMockConfig>,
|
|
46
|
+
mockServer: ReturnType<typeof createMockServer>,
|
|
47
|
+
mockTransport: ReturnType<typeof createMockTransport>,
|
|
48
|
+
setupFunctions?: {
|
|
49
|
+
setupTools?: ReturnType<typeof vi.fn>;
|
|
50
|
+
setupResources?: ReturnType<typeof vi.fn>;
|
|
51
|
+
setupPrompts?: ReturnType<typeof vi.fn>;
|
|
52
|
+
initI18n?: ReturnType<typeof vi.fn>;
|
|
53
|
+
},
|
|
54
|
+
) => {
|
|
55
|
+
const MockMcpServer = vi.fn().mockReturnValue(mockServer);
|
|
56
|
+
const MockStdioServerTransport = vi.fn().mockReturnValue(mockTransport);
|
|
57
|
+
|
|
58
|
+
vi.doMock("../config/index.js", () => mockConfig);
|
|
59
|
+
vi.doMock("@modelcontextprotocol/sdk/server/mcp.js", () => ({
|
|
60
|
+
McpServer: MockMcpServer,
|
|
61
|
+
}));
|
|
62
|
+
vi.doMock("@modelcontextprotocol/sdk/server/stdio.js", () => ({
|
|
63
|
+
StdioServerTransport: MockStdioServerTransport,
|
|
64
|
+
}));
|
|
65
|
+
vi.doMock("../tools/index.js", () => ({
|
|
66
|
+
setupTools: setupFunctions?.setupTools ?? vi.fn(),
|
|
67
|
+
}));
|
|
68
|
+
vi.doMock("../resources/index.js", () => ({
|
|
69
|
+
setupResources: setupFunctions?.setupResources ?? vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
vi.doMock("../prompts/index.js", () => ({
|
|
72
|
+
setupPrompts: setupFunctions?.setupPrompts ?? vi.fn(),
|
|
73
|
+
}));
|
|
74
|
+
vi.doMock("../i18n/index.js", () => ({
|
|
75
|
+
initI18n:
|
|
76
|
+
setupFunctions?.initI18n ?? vi.fn().mockResolvedValue(undefined),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
return { MockMcpServer, MockStdioServerTransport };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.resetModules();
|
|
84
|
+
vi.clearAllMocks();
|
|
85
|
+
|
|
86
|
+
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
87
|
+
processExitSpy = vi
|
|
88
|
+
.spyOn(process, "exit")
|
|
89
|
+
.mockImplementation(() => undefined as never);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
consoleErrorSpy.mockRestore();
|
|
94
|
+
processExitSpy.mockRestore();
|
|
95
|
+
vi.resetModules();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should validate config on startup", async () => {
|
|
99
|
+
const mockConfig = createMockConfig();
|
|
100
|
+
const mockServer = createMockServer();
|
|
101
|
+
const mockTransport = createMockTransport();
|
|
102
|
+
|
|
103
|
+
setupServerMocks(mockConfig, mockServer, mockTransport);
|
|
104
|
+
|
|
105
|
+
await import("../index.js");
|
|
106
|
+
|
|
107
|
+
expect(mockConfig.validateConfig).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should exit with error when config validation fails", async () => {
|
|
111
|
+
const mockConfig = createMockConfig({ shouldThrow: true });
|
|
112
|
+
|
|
113
|
+
vi.doMock("../config/index.js", () => mockConfig);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await import("../index.js");
|
|
117
|
+
} catch (_error) {
|
|
118
|
+
// Expected to throw due to config validation
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
122
|
+
"Configuration error:",
|
|
123
|
+
expect.any(Error),
|
|
124
|
+
);
|
|
125
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("should create MCP server and connect", async () => {
|
|
129
|
+
const mockConfig = createMockConfig();
|
|
130
|
+
const mockServer = createMockServer();
|
|
131
|
+
const mockTransport = createMockTransport();
|
|
132
|
+
|
|
133
|
+
// Track setup function calls
|
|
134
|
+
const mockSetupTools = vi.fn();
|
|
135
|
+
const mockSetupResources = vi.fn();
|
|
136
|
+
const mockSetupPrompts = vi.fn();
|
|
137
|
+
|
|
138
|
+
const { MockMcpServer } = setupServerMocks(
|
|
139
|
+
mockConfig,
|
|
140
|
+
mockServer,
|
|
141
|
+
mockTransport,
|
|
142
|
+
{
|
|
143
|
+
setupTools: mockSetupTools,
|
|
144
|
+
setupResources: mockSetupResources,
|
|
145
|
+
setupPrompts: mockSetupPrompts,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await import("../index.js");
|
|
150
|
+
|
|
151
|
+
expect(MockMcpServer).toHaveBeenCalledWith({
|
|
152
|
+
name: "test-server",
|
|
153
|
+
version: "1.0.0",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const expectedEsaConfig = {
|
|
157
|
+
apiAccessToken: "test-token",
|
|
158
|
+
apiBaseUrl: "https://api.esa.example.com",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
expect(mockSetupTools).toHaveBeenCalledWith(mockServer, expectedEsaConfig);
|
|
162
|
+
expect(mockSetupResources).toHaveBeenCalledWith(
|
|
163
|
+
mockServer,
|
|
164
|
+
expectedEsaConfig,
|
|
165
|
+
);
|
|
166
|
+
expect(mockSetupPrompts).toHaveBeenCalledWith(
|
|
167
|
+
mockServer,
|
|
168
|
+
expectedEsaConfig,
|
|
169
|
+
);
|
|
170
|
+
expect(mockServer.connect).toHaveBeenCalled();
|
|
171
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("test-server v1.0.0 started");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("should set up transport error handlers", async () => {
|
|
175
|
+
const mockConfig = createMockConfig();
|
|
176
|
+
const mockServer = createMockServer();
|
|
177
|
+
const mockTransport = createMockTransport();
|
|
178
|
+
|
|
179
|
+
setupServerMocks(mockConfig, mockServer, mockTransport);
|
|
180
|
+
|
|
181
|
+
await import("../index.js");
|
|
182
|
+
|
|
183
|
+
expect(mockTransport.onclose).toBeDefined();
|
|
184
|
+
expect(mockTransport.onerror).toBeDefined();
|
|
185
|
+
|
|
186
|
+
mockTransport.onclose?.();
|
|
187
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Transport closed");
|
|
188
|
+
|
|
189
|
+
const testError = new Error("Test error");
|
|
190
|
+
mockTransport.onerror?.(testError);
|
|
191
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith("Transport error:", testError);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should handle server startup errors", async () => {
|
|
195
|
+
const mockConfig = createMockConfig();
|
|
196
|
+
const mockServer = createMockServer({ shouldFailConnect: true });
|
|
197
|
+
const mockTransport = createMockTransport();
|
|
198
|
+
|
|
199
|
+
setupServerMocks(mockConfig, mockServer, mockTransport);
|
|
200
|
+
|
|
201
|
+
await import("../index.js");
|
|
202
|
+
|
|
203
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
204
|
+
"Server startup error:",
|
|
205
|
+
expect.any(Error),
|
|
206
|
+
);
|
|
207
|
+
expect(processExitSpy).toHaveBeenCalledWith(1);
|
|
208
|
+
});
|
|
209
|
+
});
|