node-server #10
1
banner.base64
Normal file
1
banner.base64
Normal file
File diff suppressed because one or more lines are too long
BIN
banner.png
Normal file
BIN
banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
1
banner0.base64
Normal file
1
banner0.base64
Normal file
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@
|
|||||||
"server": "node dist/http-server.js",
|
"server": "node dist/http-server.js",
|
||||||
"validate-manifest": "mcpb validate manifest.json",
|
"validate-manifest": "mcpb validate manifest.json",
|
||||||
"pack": "npm run build && mcpb pack",
|
"pack": "npm run build && mcpb pack",
|
||||||
|
"inspect": "npx @modelcontextprotocol/inspector node dist/index.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"dev": "tsc --watch",
|
"dev": "tsc --watch",
|
||||||
"validate-tools": "node scripts/validate-tools.js"
|
"validate-tools": "node scripts/validate-tools.js"
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
* User Story 1 (P1 - MVP): Enable basic note-taking workflows
|
* User Story 1 (P1 - MVP): Enable basic note-taking workflows
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { ObsidianMCPServer, createToolHandler } from '../server.js';
|
import { ObsidianMCPServer, createToolHandler } from '../server.js';
|
||||||
import { executeObsidianCommand, executeObsidianCommandBinary } from '../cli/executor.js';
|
import { executeObsidianCommand } from '../cli/executor.js';
|
||||||
import { formatForMCP } from '../cli/parser.js';
|
import { formatForMCP } from '../cli/parser.js';
|
||||||
import { handleCLIResult } from '../utils/error-handler.js';
|
import { handleCLIResult } from '../utils/error-handler.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
@@ -68,6 +70,52 @@ function isImageMimeType(mimeType: string): boolean {
|
|||||||
return mimeType.startsWith('image/');
|
return mimeType.startsWith('image/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the MIME type is a known binary format.
|
||||||
|
* application/octet-stream (unknown extension) returns false so we fall
|
||||||
|
* through to the CLI and let isBinaryContent() decide at runtime.
|
||||||
|
*/
|
||||||
|
function isBinaryMimeType(mimeType: string): boolean {
|
||||||
|
return !mimeType.startsWith('text/') && mimeType !== 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the vault's root filesystem path via `obsidian vault info=path`.
|
||||||
|
*/
|
||||||
|
async function getVaultRootPath(): Promise<string> {
|
||||||
|
const result = await executeObsidianCommand('vault', ['info=path']);
|
||||||
|
if (result.exitCode !== 0 || !result.stdout) {
|
||||||
|
throw new Error(`Failed to get vault filesystem path: ${result.stderr || 'no output'}`);
|
||||||
|
}
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the vault-relative path for a note.
|
||||||
|
* - When `path` is provided it is already vault-relative, so return it directly.
|
||||||
|
* - When `file` (wikilink-style name) is provided, run `obsidian file file=<name>`
|
||||||
|
* and parse the `path` field from its tab-separated output.
|
||||||
|
*/
|
||||||
|
async function resolveVaultRelativePath(file?: string, path?: string): Promise<string> {
|
||||||
|
if (path) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (file) {
|
||||||
|
const result = await executeObsidianCommand('file', [formatParam('file', file)]);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`File "${file}" not found in vault: ${result.stderr}`);
|
||||||
|
}
|
||||||
|
for (const line of result.stdout.split('\n')) {
|
||||||
|
const [key, value] = line.split('\t');
|
||||||
|
if (key?.trim() === 'path' && value?.trim()) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Could not resolve vault path for file "${file}"`);
|
||||||
|
}
|
||||||
|
throw new Error('Either file or path must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all file operation tools
|
* Register all file operation tools
|
||||||
*/
|
*/
|
||||||
@@ -179,7 +227,7 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
// T031: Read note tool
|
// T031: Read note tool
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'obsidian_read_note',
|
'obsidian_read_note',
|
||||||
'Read the content of a note from the Obsidian vault. Specify either the note name (file) or full path (path). For large files (e.g. PDFs), use max_chars and offset to read in chunks and avoid exceeding context limits. Binary files are automatically detected: images (PNG, JPG, GIF, WEBP, SVG) are returned as MCP image content (type: "image") with base64-encoded data and mimeType; all other binary files (ZIP, PDF, DOCX, etc.) are returned as MCP embedded resource content (type: "resource") with uri, mimeType, and base64-encoded blob.',
|
'Read the content of a note from the Obsidian vault. Specify either the note name (file) or full path (path). For large text files, use max_chars and offset to read in chunks and avoid exceeding context limits. Binary files are read natively from the filesystem (bypassing the CLI): images (PNG, JPG, GIF, WEBP, SVG) are returned as MCP image content (type: "image") with base64-encoded data and mimeType; all other binary files (ZIP, PDF, DOCX, etc.) are returned as MCP embedded resource content (type: "resource") with uri, mimeType, and base64-encoded blob.',
|
||||||
{
|
{
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -228,46 +276,62 @@ export async function registerFileOperationTools(server: ObsidianMCPServer): Pro
|
|||||||
const validated = readNoteSchema.parse(args) as any;
|
const validated = readNoteSchema.parse(args) as any;
|
||||||
const sanitized = sanitizeParameters(validated) as any;
|
const sanitized = sanitizeParameters(validated) as any;
|
||||||
|
|
||||||
const cmdArgs: string[] = [];
|
const identifier = (sanitized.file || sanitized.path) as string;
|
||||||
if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string));
|
|
||||||
if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string));
|
|
||||||
|
|
||||||
// Use binary-safe executor so stdout is collected as a raw Buffer,
|
|
||||||
// preventing UTF-8 decoding from corrupting binary file content.
|
|
||||||
const result = await executeObsidianCommandBinary('read', cmdArgs);
|
|
||||||
handleCLIResult(result, { operation: 'read_note', identifier: sanitized.file || sanitized.path });
|
|
||||||
|
|
||||||
// Detect binary content from the raw buffer and return as spec-appropriate MCP content
|
|
||||||
if (result.stdoutBuffer && isBinaryContent(result.stdoutBuffer)) {
|
|
||||||
const identifier = sanitized.file || sanitized.path as string;
|
|
||||||
const mimeType = getMimeType(identifier);
|
const mimeType = getMimeType(identifier);
|
||||||
const base64 = result.stdoutBuffer.toString('base64');
|
|
||||||
|
|
||||||
// Images use the MCP image content type per spec
|
// For known binary MIME types, bypass the CLI entirely and read via native FS.
|
||||||
|
// The Obsidian CLI does not support binary file output.
|
||||||
|
if (isBinaryMimeType(mimeType)) {
|
||||||
|
logger.debug('Binary file detected by extension, using native FS', { identifier, mimeType });
|
||||||
|
const vaultRoot = await getVaultRootPath();
|
||||||
|
const relPath = await resolveVaultRelativePath(sanitized.file, sanitized.path);
|
||||||
|
const fullPath = join(vaultRoot, relPath);
|
||||||
|
const buf = await readFile(fullPath);
|
||||||
|
const base64 = buf.toString('base64');
|
||||||
|
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
||||||
|
|
||||||
if (isImageMimeType(mimeType)) {
|
if (isImageMimeType(mimeType)) {
|
||||||
|
return {
|
||||||
|
content: [{ type: 'image' as const, data: base64, mimeType }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(relPath)}`;
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'image' as const,
|
type: 'resource' as const,
|
||||||
data: base64,
|
resource: { uri, mimeType, blob: base64 },
|
||||||
mimeType,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other binary files use the embedded resource content type per spec
|
// Text file — use the CLI
|
||||||
|
const cmdArgs: string[] = [];
|
||||||
|
if (sanitized.file) cmdArgs.push(formatParam('file', sanitized.file as string));
|
||||||
|
if (sanitized.path) cmdArgs.push(formatParam('path', sanitized.path as string));
|
||||||
|
|
||||||
|
const result = await executeObsidianCommand('read', cmdArgs);
|
||||||
|
handleCLIResult(result, { operation: 'read_note', identifier });
|
||||||
|
|
||||||
|
// Fallback: CLI returned binary bytes for an unknown-extension file
|
||||||
|
const rawBuf = Buffer.from(result.stdout, 'utf8');
|
||||||
|
if (isBinaryContent(rawBuf)) {
|
||||||
|
logger.debug('Binary content detected at runtime, using native FS fallback', { identifier });
|
||||||
|
const vaultRoot = await getVaultRootPath();
|
||||||
|
const relPath = await resolveVaultRelativePath(sanitized.file, sanitized.path);
|
||||||
|
const fullPath = join(vaultRoot, relPath);
|
||||||
|
const buf = await readFile(fullPath);
|
||||||
|
const base64 = buf.toString('base64');
|
||||||
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
const vaultName = process.env.OBSIDIAN_VAULT ?? 'vault';
|
||||||
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(identifier)}`;
|
const fallbackMime = getMimeType(relPath);
|
||||||
|
const uri = `obsidian://${encodeURIComponent(vaultName)}/${encodeURIComponent(relPath)}`;
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'resource' as const,
|
type: 'resource' as const,
|
||||||
resource: {
|
resource: { uri, mimeType: fallbackMime, blob: base64 },
|
||||||
uri,
|
|
||||||
mimeType,
|
|
||||||
blob: base64,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user