import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { noteApiRequest } from "../utils/api-client.js";
import { hasAuth } from "../utils/auth.js";
import fs from "fs";
import path from "path";
import { chromium } from "playwright";
/**
* 画像関連のツールを登録する
* @param server MCPサーバーインスタンス
*/
export function registerImageTools(server: McpServer): void {
/**
* 画像をアップロードするツール
* 注意: このツールはnote.comのS3に画像をアップロードしますが、
* 本文中に画像を挿入するには publish-from-obsidian または insert-images-to-note ツールを使用してください。
* note.comのAPIは本文中の<img>タグをサニタイズするため、APIだけでは本文画像挿入ができません。
*/
server.tool(
"upload-image",
"note.comに画像をアップロード(アイキャッチ画像用。本文画像は publish-from-obsidian を使用)",
{
imagePath: z.string().optional().describe("アップロードする画像ファイルのパス"),
imageUrl: z.string().optional().describe("アップロードする画像のURL(imagePathの代わりに使用可能)"),
imageBase64: z.string().optional().describe("Base64エンコードされた画像データ(imagePathの代わりに使用可能)"),
},
async ({ imagePath, imageUrl, imageBase64 }) => {
// 認証チェック
if (!hasAuth()) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "認証が必要です",
message: "画像アップロード機能を使用するには、.envファイルに認証情報を設定してください"
}, null, 2)
}
]
};
}
try {
let imageBuffer: Buffer;
let fileName: string;
let mimeType: string;
// 最大ファイルサイズ(10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// 画像データの取得方法を判定
if (imagePath) {
// ファイルパスから画像を読み込み
if (!fs.existsSync(imagePath)) {
throw new Error(`画像ファイルが見つかりません: ${imagePath}`);
}
// ファイルサイズをチェック
const stats = fs.statSync(imagePath);
if (stats.size > MAX_FILE_SIZE) {
throw new Error(`画像ファイルが大きすぎます: ${(stats.size / 1024 / 1024).toFixed(2)}MB(最大10MB)`);
}
imageBuffer = fs.readFileSync(imagePath);
fileName = path.basename(imagePath);
// MIMEタイプを拡張子から判定
const ext = path.extname(imagePath).toLowerCase();
const mimeTypes: { [key: string]: string } = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
};
mimeType = mimeTypes[ext];
if (!mimeType) {
throw new Error(`サポートされていない画像形式です: ${ext}(対応形式: jpg, png, gif, webp, svg)`);
}
} else if (imageUrl) {
// URLから画像をダウンロード
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`画像のダウンロードに失敗しました: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
// ファイルサイズをチェック
if (imageBuffer.length > MAX_FILE_SIZE) {
throw new Error(`画像ファイルが大きすぎます: ${(imageBuffer.length / 1024 / 1024).toFixed(2)}MB(最大10MB)`);
}
// URLからファイル名を取得
const urlPath = new URL(imageUrl).pathname;
fileName = path.basename(urlPath) || 'image.jpg';
// Content-Typeから判定
const contentType = response.headers.get('content-type');
if (contentType && contentType.startsWith('image/')) {
mimeType = contentType;
} else {
throw new Error(`URLから取得したファイルが画像ではありません: ${contentType}`);
}
} else if (imageBase64) {
// Base64データから画像を復元
imageBuffer = Buffer.from(imageBase64, 'base64');
// ファイルサイズをチェック
if (imageBuffer.length > MAX_FILE_SIZE) {
throw new Error(`画像ファイルが大きすぎます: ${(imageBuffer.length / 1024 / 1024).toFixed(2)}MB(最大10MB)`);
}
fileName = 'image.jpg';
mimeType = 'image/jpeg';
} else {
throw new Error('imagePath、imageUrl、またはimageBase64のいずれかを指定してください');
}
// Step 1: Presigned URLを取得(filenameのみ送信)
const boundary1 = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`;
const presignFormParts: Buffer[] = [];
// ファイル名パートのみ(ブラウザと同じ形式)
presignFormParts.push(Buffer.from(
`--${boundary1}\r\n` +
`Content-Disposition: form-data; name="filename"\r\n\r\n` +
`${fileName}\r\n`
));
// 終了境界
presignFormParts.push(Buffer.from(`--${boundary1}--\r\n`));
const presignFormData = Buffer.concat(presignFormParts);
// Presigned URL APIを呼び出し(API_BASE_URLが/apiを含むため、/v3から開始)
const presignResponse = await noteApiRequest(
'/v3/images/upload/presigned_post',
'POST',
presignFormData,
true,
{
'Content-Type': `multipart/form-data; boundary=${boundary1}`,
'Content-Length': presignFormData.length.toString(),
'X-Requested-With': 'XMLHttpRequest',
'Referer': 'https://editor.note.com/'
}
);
if (!presignResponse.data || !presignResponse.data.post) {
console.error("Presigned URLの取得に失敗:", JSON.stringify(presignResponse));
throw new Error("Presigned URLの取得に失敗しました");
}
const { url: finalImageUrl, action: s3Url, post: s3Params } = presignResponse.data;
// Step 2: S3に直接アップロード
const boundary2 = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`;
const s3FormParts: Buffer[] = [];
// S3パラメータを追加(順序が重要)
const paramOrder = ['key', 'acl', 'Expires', 'policy', 'x-amz-credential', 'x-amz-algorithm', 'x-amz-date', 'x-amz-signature'];
for (const key of paramOrder) {
if (s3Params[key]) {
s3FormParts.push(Buffer.from(
`--${boundary2}\r\n` +
`Content-Disposition: form-data; name="${key}"\r\n\r\n` +
`${s3Params[key]}\r\n`
));
}
}
// ファイルパート(最後に追加)
s3FormParts.push(Buffer.from(
`--${boundary2}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
`Content-Type: ${mimeType}\r\n\r\n`
));
s3FormParts.push(imageBuffer);
s3FormParts.push(Buffer.from('\r\n'));
// 終了境界
s3FormParts.push(Buffer.from(`--${boundary2}--\r\n`));
const s3FormData = Buffer.concat(s3FormParts);
// S3にアップロード
const s3Response = await fetch(s3Url, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary2}`,
'Content-Length': s3FormData.length.toString()
},
body: s3FormData
});
if (!s3Response.ok && s3Response.status !== 204) {
const errorText = await s3Response.text();
console.error("S3アップロードエラー:", s3Response.status, errorText);
throw new Error(`S3へのアップロードに失敗しました: ${s3Response.status}`);
}
const uploadedImageUrl = finalImageUrl;
return {
content: [
{
type: "text",
text: JSON.stringify({
success: true,
message: "画像のアップロードに成功しました",
imageUrl: uploadedImageUrl,
fileName: fileName,
fileSize: imageBuffer.length,
fileSizeMB: (imageBuffer.length / 1024 / 1024).toFixed(2),
mimeType: mimeType,
// デバッグ用に元のレスポンスも含める
_debug: {
presignResponse: presignResponse
}
}, null, 2)
}
]
};
} catch (error: any) {
console.error("画像アップロードエラー:", error);
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "画像アップロードに失敗しました",
message: error.message,
details: error.toString()
}, null, 2)
}
]
};
}
}
);
/**
* 複数の画像を一括アップロードするツール
*/
server.tool(
"upload-images-batch",
"note.comに複数の画像を一括アップロード",
{
imagePaths: z.array(z.string()).describe("アップロードする画像ファイルのパスの配列"),
},
async ({ imagePaths }) => {
// 認証チェック
if (!hasAuth()) {
return {
content: [
{
type: "text",
text: JSON.stringify({
error: "認証が必要です",
message: "画像アップロード機能を使用するには、.envファイルに認証情報を設定してください"
}, null, 2)
}
]
};
}
const results = [];
const errors = [];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
for (const imagePath of imagePaths) {
try {
if (!fs.existsSync(imagePath)) {
errors.push({
path: imagePath,
error: "ファイルが見つかりません"
});
continue;
}
// ファイルサイズをチェック
const stats = fs.statSync(imagePath);
if (stats.size > MAX_FILE_SIZE) {
errors.push({
path: imagePath,
error: `ファイルが大きすぎます: ${(stats.size / 1024 / 1024).toFixed(2)}MB(最大10MB)`
});
continue;
}
const imageBuffer = fs.readFileSync(imagePath);
const fileName = path.basename(imagePath);
const ext = path.extname(imagePath).toLowerCase();
const mimeTypes: { [key: string]: string } = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml'
};
const mimeType = mimeTypes[ext];
if (!mimeType) {
errors.push({
path: imagePath,
error: `サポートされていない画像形式: ${ext}`
});
continue;
}
// Step 1: Presigned URLを取得(filenameのみ送信)
const boundary1 = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`;
const presignFormParts: Buffer[] = [];
// ファイル名パートのみ(ブラウザと同じ形式)
presignFormParts.push(Buffer.from(
`--${boundary1}\r\n` +
`Content-Disposition: form-data; name="filename"\r\n\r\n` +
`${fileName}\r\n`
));
presignFormParts.push(Buffer.from(`--${boundary1}--\r\n`));
const presignFormData = Buffer.concat(presignFormParts);
const presignResponse = await noteApiRequest(
'/v3/images/upload/presigned_post',
'POST',
presignFormData,
true,
{
'Content-Type': `multipart/form-data; boundary=${boundary1}`,
'Content-Length': presignFormData.length.toString(),
'X-Requested-With': 'XMLHttpRequest',
'Referer': 'https://editor.note.com/'
}
);
if (!presignResponse.data || !presignResponse.data.post) {
throw new Error("Presigned URLの取得に失敗しました");
}
const { url: uploadedImageUrl, action: s3Url, post: s3Params } = presignResponse.data;
// Step 2: S3に直接アップロード
const boundary2 = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`;
const s3FormParts: Buffer[] = [];
const paramOrder = ['key', 'acl', 'Expires', 'policy', 'x-amz-credential', 'x-amz-algorithm', 'x-amz-date', 'x-amz-signature'];
for (const key of paramOrder) {
if (s3Params[key]) {
s3FormParts.push(Buffer.from(
`--${boundary2}\r\n` +
`Content-Disposition: form-data; name="${key}"\r\n\r\n` +
`${s3Params[key]}\r\n`
));
}
}
s3FormParts.push(Buffer.from(
`--${boundary2}\r\n` +
`Content-Disposition: form-data; name="file"; filename="${fileName}"\r\n` +
`Content-Type: ${mimeType}\r\n\r\n`
));
s3FormParts.push(imageBuffer);
s3FormParts.push(Buffer.from('\r\n'));
s3FormParts.push(Buffer.from(`--${boundary2}--\r\n`));
const s3FormData = Buffer.concat(s3FormParts);
const s3Response = await fetch(s3Url, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary2}`,
'Content-Length': s3FormData.length.toString()
},
body: s3FormData
});
if (!s3Response.ok && s3Response.status !== 204) {
throw new Error(`S3へのアップロードに失敗しました: ${s3Response.status}`);
}
results.push({
path: imagePath,
fileName: fileName,
imageUrl: uploadedImageUrl,
fileSize: imageBuffer.length,
fileSizeMB: (imageBuffer.length / 1024 / 1024).toFixed(2),
mimeType: mimeType,
success: true
});
} catch (error: any) {
errors.push({
path: imagePath,
error: error.message
});
}
}
return {
content: [
{
type: "text",
text: JSON.stringify({
success: errors.length === 0,
totalImages: imagePaths.length,
successCount: results.length,
errorCount: errors.length,
results: results,
errors: errors
}, null, 2)
}
]
};
}
);
}