@esaio/esa-mcp-server 0.1.0 → 0.2.1
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/.github/dependabot.yml +5 -0
- package/.github/workflows/docker-publish.yml +4 -4
- package/.github/workflows/main.yml +4 -4
- package/README.en.md +17 -5
- package/README.md +20 -8
- package/bin/index.js +6 -6
- package/biome.json +1 -1
- package/package.json +9 -7
- package/src/__tests__/index.test.ts +9 -2
- package/src/api_client/__tests__/middleware.test.ts +2 -1
- package/src/generated/api-types.ts +298 -21
- package/src/prompts/__tests__/index.test.ts +2 -1
- package/src/prompts/index.ts +1 -1
- package/src/resources/__tests__/index.test.ts +2 -1
- package/src/resources/__tests__/recent-posts-list.test.ts +2 -1
- package/src/tools/__tests__/attachments.test.ts +460 -0
- package/src/tools/__tests__/categories.test.ts +177 -1
- package/src/tools/__tests__/index.test.ts +5 -4
- package/src/tools/attachments.ts +167 -0
- package/src/tools/categories.ts +60 -0
- package/src/tools/index.ts +27 -0
- package/.claude/settings.local.json +0 -23
- package/.envrc +0 -2
- package/.node-version +0 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { createEsaClient } from "../api_client/index.js";
|
|
4
|
+
import { MissingTeamNameError } from "../errors/missing-team-name-error.js";
|
|
5
|
+
import { formatToolError } from "../formatters/mcp-response.js";
|
|
6
|
+
import { createSchemaWithTeamName } from "../schemas/team-name-schema.js";
|
|
7
|
+
|
|
8
|
+
// Maximum file size for base64 encoding (30MB)
|
|
9
|
+
const MAX_IMAGE_SIZE = 30 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
// Supported image MIME types for base64 encoding
|
|
12
|
+
const SUPPORTED_IMAGE_TYPES = [
|
|
13
|
+
"image/jpeg",
|
|
14
|
+
"image/png",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"image/webp",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export const getAttachmentSchema = createSchemaWithTeamName({
|
|
20
|
+
url: z
|
|
21
|
+
.string()
|
|
22
|
+
.describe(
|
|
23
|
+
"Attachment URL. Can be a full URL (https://files.esa.io/..., https://dl.esa.io/...) or a path (/uploads/...)",
|
|
24
|
+
),
|
|
25
|
+
forceSignedUrl: z
|
|
26
|
+
.boolean()
|
|
27
|
+
.optional()
|
|
28
|
+
.describe(
|
|
29
|
+
"If true, always return signed URLs instead of base64-encoded images. Default is false.",
|
|
30
|
+
),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts the path from a full URL or returns the input if it's already a path
|
|
35
|
+
*/
|
|
36
|
+
function normalizeUrl(url: string): string {
|
|
37
|
+
// If it's already a path (starts with /), return as-is
|
|
38
|
+
if (url.startsWith("/")) {
|
|
39
|
+
return url;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Try to parse as URL and extract pathname
|
|
44
|
+
const urlObj = new URL(url);
|
|
45
|
+
return urlObj.pathname;
|
|
46
|
+
} catch {
|
|
47
|
+
// If URL parsing fails, assume it's already a path
|
|
48
|
+
return url;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Checks if the MIME type is a supported image format
|
|
54
|
+
*/
|
|
55
|
+
function isSupportedImage(mimeType: string): boolean {
|
|
56
|
+
return SUPPORTED_IMAGE_TYPES.includes(
|
|
57
|
+
mimeType as (typeof SUPPORTED_IMAGE_TYPES)[number],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetches content and returns base64-encoded data if it's a supported image under size limit
|
|
63
|
+
*/
|
|
64
|
+
async function fetchAttachment(
|
|
65
|
+
signedUrl: string,
|
|
66
|
+
forceSignedUrl: boolean,
|
|
67
|
+
): Promise<
|
|
68
|
+
| { type: "image"; data: string; mimeType: string }
|
|
69
|
+
| { type: "text"; text: string }
|
|
70
|
+
> {
|
|
71
|
+
// If forceSignedUrl is true, always return signed URL
|
|
72
|
+
if (forceSignedUrl) {
|
|
73
|
+
return {
|
|
74
|
+
type: "text" as const,
|
|
75
|
+
text: signedUrl,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const response = await fetch(signedUrl);
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Failed to fetch attachment: ${response.status} ${response.statusText}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const contentType = response.headers.get("content-type") || "";
|
|
88
|
+
const contentLength = response.headers.get("content-length");
|
|
89
|
+
const size = contentLength ? Number.parseInt(contentLength, 10) : 0;
|
|
90
|
+
|
|
91
|
+
// Check if it's a supported image and within size limit
|
|
92
|
+
if (isSupportedImage(contentType) && size > 0 && size <= MAX_IMAGE_SIZE) {
|
|
93
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
94
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
95
|
+
const base64 = buffer.toString("base64");
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
type: "image" as const,
|
|
99
|
+
data: base64,
|
|
100
|
+
mimeType: contentType,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// For non-images or oversized images, return the signed URL
|
|
105
|
+
return {
|
|
106
|
+
type: "text" as const,
|
|
107
|
+
text: signedUrl,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getAttachment(
|
|
112
|
+
client: ReturnType<typeof createEsaClient>,
|
|
113
|
+
args: z.infer<typeof getAttachmentSchema>,
|
|
114
|
+
): Promise<CallToolResult> {
|
|
115
|
+
try {
|
|
116
|
+
if (!args.teamName) {
|
|
117
|
+
throw new MissingTeamNameError();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Normalize URL to path
|
|
121
|
+
const normalizedUrl = normalizeUrl(args.url);
|
|
122
|
+
|
|
123
|
+
// Get signed URL from esa API
|
|
124
|
+
const { data, error, response } = await client.GET(
|
|
125
|
+
"/v1/teams/{team_name}/signed_urls",
|
|
126
|
+
{
|
|
127
|
+
params: {
|
|
128
|
+
path: { team_name: args.teamName },
|
|
129
|
+
query: {
|
|
130
|
+
urls: normalizedUrl,
|
|
131
|
+
v: 2,
|
|
132
|
+
expires_in: 300, // 5 minutes
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
if (error || !response.ok) {
|
|
139
|
+
return formatToolError(error || response.status);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!data.signed_urls || data.signed_urls.length === 0) {
|
|
143
|
+
throw new Error("No signed URLs returned from API");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const [originalUrl, signedUrl] = data.signed_urls[0];
|
|
147
|
+
|
|
148
|
+
if (signedUrl === null) {
|
|
149
|
+
throw new Error(`File not found: ${originalUrl}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const forceSignedUrl = args.forceSignedUrl ?? false;
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const result = await fetchAttachment(signedUrl, forceSignedUrl);
|
|
156
|
+
return { content: [result] };
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Failed to fetch attachment for ${originalUrl}: ${
|
|
160
|
+
err instanceof Error ? err.message : String(err)
|
|
161
|
+
}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return formatToolError(err);
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/tools/categories.ts
CHANGED
|
@@ -91,3 +91,63 @@ export async function getTopCategories(
|
|
|
91
91
|
return formatToolError(error);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
|
+
|
|
95
|
+
export const getAllCategoryPathsSchema = createSchemaWithTeamName({
|
|
96
|
+
prefix: z
|
|
97
|
+
.string()
|
|
98
|
+
.optional()
|
|
99
|
+
.describe(
|
|
100
|
+
"Filter paths starting with specified string (e.g., 'dev' finds 'dev', 'dev/api', 'dev/docs')",
|
|
101
|
+
),
|
|
102
|
+
suffix: z
|
|
103
|
+
.string()
|
|
104
|
+
.optional()
|
|
105
|
+
.describe(
|
|
106
|
+
"Filter paths ending with specified string (e.g., 'api' finds 'dev/api', 'backend/api')",
|
|
107
|
+
),
|
|
108
|
+
match: z
|
|
109
|
+
.string()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
"Filter paths containing specified substring anywhere (e.g., 'doc' finds 'docs', 'dev/docs', 'documentation')",
|
|
113
|
+
),
|
|
114
|
+
exactMatch: z
|
|
115
|
+
.string()
|
|
116
|
+
.optional()
|
|
117
|
+
.describe(
|
|
118
|
+
"Filter paths matching exactly (e.g., 'dev/api' matches only 'dev/api', ignores leading/trailing slashes)",
|
|
119
|
+
),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export async function getAllCategoryPaths(
|
|
123
|
+
client: ReturnType<typeof createEsaClient>,
|
|
124
|
+
args: z.infer<typeof getAllCategoryPathsSchema>,
|
|
125
|
+
) {
|
|
126
|
+
try {
|
|
127
|
+
if (!args.teamName) {
|
|
128
|
+
throw new MissingTeamNameError();
|
|
129
|
+
}
|
|
130
|
+
const { data, error, response } = await client.GET(
|
|
131
|
+
"/v1/teams/{team_name}/categories/paths",
|
|
132
|
+
{
|
|
133
|
+
params: {
|
|
134
|
+
path: { team_name: args.teamName },
|
|
135
|
+
query: {
|
|
136
|
+
prefix: args.prefix,
|
|
137
|
+
suffix: args.suffix,
|
|
138
|
+
match: args.match,
|
|
139
|
+
exact_match: args.exactMatch,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (error || !response.ok) {
|
|
146
|
+
return formatToolError(error || response.status);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return formatToolResponse(data);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return formatToolError(error);
|
|
152
|
+
}
|
|
153
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import type { z } from "zod";
|
|
3
3
|
import { withContext } from "../api_client/with-context.js";
|
|
4
4
|
import type { MCPContext } from "../context/mcp-context.js";
|
|
5
|
+
import { getAttachment, getAttachmentSchema } from "./attachments.js";
|
|
5
6
|
import {
|
|
7
|
+
getAllCategoryPaths,
|
|
8
|
+
getAllCategoryPathsSchema,
|
|
6
9
|
getCategories,
|
|
7
10
|
getCategoriesSchema,
|
|
8
11
|
getTopCategories,
|
|
@@ -245,6 +248,18 @@ export function setupTools(server: McpServer, context: MCPContext): void {
|
|
|
245
248
|
withContext(context, getTopCategories, params),
|
|
246
249
|
);
|
|
247
250
|
|
|
251
|
+
server.registerTool(
|
|
252
|
+
"esa_get_all_category_paths",
|
|
253
|
+
{
|
|
254
|
+
title: "Get all category paths for organization and structure review",
|
|
255
|
+
description:
|
|
256
|
+
"Retrieves all category paths in a team at once to understand the overall category structure. Perfect for category organization, cleanup, migration planning, or finding similar categories. Returns a simple list of paths with post counts, sorted in lexicographic order. Supports filtering (prefix/suffix/match/exact_match) to find categories by pattern. No pagination - gets all categories in one call.",
|
|
257
|
+
inputSchema: getAllCategoryPathsSchema.shape,
|
|
258
|
+
},
|
|
259
|
+
async (params: z.infer<typeof getAllCategoryPathsSchema>) =>
|
|
260
|
+
withContext(context, getAllCategoryPaths, params),
|
|
261
|
+
);
|
|
262
|
+
|
|
248
263
|
server.registerTool(
|
|
249
264
|
"esa_archive_post",
|
|
250
265
|
{
|
|
@@ -321,4 +336,16 @@ or request help with esa workflows that you're not familiar with.`,
|
|
|
321
336
|
async (params: z.infer<typeof searchHelpSchema>) =>
|
|
322
337
|
withContext(context, searchHelp, params),
|
|
323
338
|
);
|
|
339
|
+
|
|
340
|
+
server.registerTool(
|
|
341
|
+
"esa_get_attachment",
|
|
342
|
+
{
|
|
343
|
+
title: "Get attachment file from esa",
|
|
344
|
+
description:
|
|
345
|
+
"Retrieves an attachment file from esa with signed URLs. For supported images (JPEG, PNG, GIF, WebP) under 30MB, returns base64-encoded data. For other file types, larger images, or when forceSignedUrl is true, returns signed URLs.",
|
|
346
|
+
inputSchema: getAttachmentSchema.shape,
|
|
347
|
+
},
|
|
348
|
+
async (params: z.infer<typeof getAttachmentSchema>) =>
|
|
349
|
+
withContext(context, getAttachment, params),
|
|
350
|
+
);
|
|
324
351
|
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(npm run build:*)",
|
|
5
|
-
"WebFetch(domain:openapi-ts.dev)",
|
|
6
|
-
"WebFetch(domain:docs.esa.io)",
|
|
7
|
-
"WebFetch(domain:raw.githubusercontent.com)",
|
|
8
|
-
"Bash(rm:*)",
|
|
9
|
-
"Bash(mv:*)",
|
|
10
|
-
"Bash(npm run test:run:*)",
|
|
11
|
-
"Bash(npm run type-check:*)",
|
|
12
|
-
"Bash(npm test:*)",
|
|
13
|
-
"WebFetch(domain:modelcontextprotocol.io)",
|
|
14
|
-
"Bash(git mv:*)",
|
|
15
|
-
"Bash(git add:*)",
|
|
16
|
-
"Bash(node:*)",
|
|
17
|
-
"Bash(npm run lint:*)",
|
|
18
|
-
"Bash(git commit:*)",
|
|
19
|
-
"Bash(grep:*)"
|
|
20
|
-
],
|
|
21
|
-
"deny": []
|
|
22
|
-
}
|
|
23
|
-
}
|
package/.envrc
DELETED
package/.node-version
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
24.4.1
|