import { Injectable } from '@nestjs/common';
import { Tool, type Context } from '@rekog/mcp-nest';
import { z } from 'zod';
import { LogseqClient } from '../logseq/logseq.client';
import { LogseqFormatter } from '../logseq/logseq.formatter';
/**
* Page Tool - Page 관리
*
* User Flow: Page Management
* - Page 생성, 조회, 삭제
* - Page 내용 조회
*/
@Injectable()
export class PageTool {
constructor(
private readonly logseq: LogseqClient,
private readonly formatter: LogseqFormatter,
) {}
@Tool({
name: 'page-create',
description:
'새로운 Page를 생성합니다. 프로젝트, 주제, 개념 등을 정리할 새 페이지를 만들 때 사용하세요. 내용은 자동으로 Logseq 아웃라이너 포맷으로 변환됩니다.',
parameters: z.object({
name: z.string().describe('Page 이름 (고유해야 함)'),
content: z
.string()
.optional()
.describe(
'Page에 추가할 초기 내용 (Markdown 또는 Logseq 포맷). 자동으로 Logseq 아웃라이너 포맷으로 변환됩니다.',
),
properties: z
.record(z.unknown())
.optional()
.describe('Page 속성 (예: tags, alias 등)'),
}),
annotations: {
title: 'Page 생성',
readOnlyHint: false,
idempotentHint: false,
},
})
async createPage(
{
name,
content,
properties,
}: { name: string; content?: string; properties?: Record<string, unknown> },
context: Context,
) {
await context.reportProgress({ progress: 20, total: 100 });
// 이미 존재하는지 확인
const existing = await this.logseq.getPage(name);
if (existing) {
return {
content: [
{
type: 'text',
text: `⚠️ "${name}" 페이지가 이미 존재합니다.`,
},
],
};
}
await context.reportProgress({ progress: 50, total: 100 });
const page = await this.logseq.createPage(name, { properties });
if (!page) {
return {
content: [
{
type: 'text',
text: `❌ Page 생성 실패. Logseq이 실행 중인지 확인해주세요.`,
},
],
};
}
// 초기 내용이 있으면 파싱하여 개별 블록으로 추가
// ⚠️ Logseq는 하나의 블록에 하나의 내용만 지원
// safeParseToBlocks는 여러 리스트/헤딩이 하나의 블록에 있으면 자동 분리
if (content) {
const parsedBlocks = this.formatter.safeParseToBlocks(content);
await this.logseq.appendBlocksInPage(name, parsedBlocks);
}
await context.reportProgress({ progress: 100, total: 100 });
return {
content: [
{
type: 'text',
text: `✅ Page 생성 완료: 📄 ${name}${content ? '\n\n초기 내용이 추가되었습니다.' : ''}`,
},
],
};
}
@Tool({
name: 'page-get',
description:
'Page의 내용을 조회합니다. 특정 주제에 대해 기록한 내용을 확인할 때 사용하세요.',
parameters: z.object({
name: z.string().describe('조회할 Page 이름'),
}),
annotations: {
title: 'Page 조회',
readOnlyHint: true,
idempotentHint: true,
},
})
async getPage({ name }: { name: string }, context: Context) {
await context.reportProgress({ progress: 20, total: 100 });
const page = await this.logseq.getPage(name);
if (!page) {
return {
content: [
{
type: 'text',
text: `❌ "${name}" 페이지를 찾을 수 없습니다.`,
},
],
};
}
await context.reportProgress({ progress: 50, total: 100 });
const blocks = await this.logseq.getPageBlocksTree(name);
await context.reportProgress({ progress: 100, total: 100 });
const contents = this.formatBlocksTree(blocks);
const propsText = page.properties
? Object.entries(page.properties)
.map(([k, v]) => `${k}:: ${String(v)}`)
.join('\n')
: '';
return {
content: [
{
type: 'text',
text: `📄 ${page.name}\n${propsText ? '\n속성:\n' + propsText + '\n' : ''}\n${contents || '(내용 없음)'}`,
},
],
};
}
@Tool({
name: 'page-list',
description:
'Graph 내 모든 Page 목록을 조회합니다. 어떤 페이지가 있는지 확인할 때 사용하세요.',
parameters: z.object({
filter: z.string().optional().describe('페이지 이름 필터 (포함된 것만)'),
limit: z.number().optional().default(20).describe('조회할 최대 개수'),
}),
annotations: {
title: 'Page 목록 조회',
readOnlyHint: true,
idempotentHint: true,
},
})
async listPages(
{ filter, limit }: { filter?: string; limit?: number },
context: Context,
) {
await context.reportProgress({ progress: 30, total: 100 });
const allPages = await this.logseq.getAllPages();
await context.reportProgress({ progress: 80, total: 100 });
let pages = allPages.filter((p) => !p['journal?']); // Journal 제외
if (filter) {
pages = pages.filter((p) =>
p.name.toLowerCase().includes(filter.toLowerCase()),
);
}
const limitedPages = pages.slice(0, limit || 20);
await context.reportProgress({ progress: 100, total: 100 });
const pageList = limitedPages.map((p) => `• ${p.name}`).join('\n');
return {
content: [
{
type: 'text',
text: `📚 Page 목록 (${limitedPages.length}/${pages.length})\n\n${pageList || '(페이지 없음)'}`,
},
],
};
}
@Tool({
name: 'page-delete',
description:
'Page를 삭제합니다. ⚠️ 주의: 삭제된 페이지는 복구할 수 없습니다.',
parameters: z.object({
name: z.string().describe('삭제할 Page 이름'),
confirm: z.boolean().describe('삭제 확인 (true로 설정해야 삭제됨)'),
}),
annotations: {
title: 'Page 삭제',
readOnlyHint: false,
destructiveHint: true,
idempotentHint: false,
},
})
async deletePage(
{ name, confirm }: { name: string; confirm: boolean },
context: Context,
) {
if (!confirm) {
// 삭제 전 정보 제공
const page = await this.logseq.getPage(name);
if (!page) {
return {
content: [
{
type: 'text',
text: `❌ "${name}" 페이지를 찾을 수 없습니다.`,
},
],
};
}
const blocks = await this.logseq.getPageBlocksTree(name);
const refs = await this.logseq.getPageLinkedReferences(name);
return {
content: [
{
type: 'text',
text: `⚠️ "${name}" 페이지를 삭제하시겠습니까?\n\n• Block 수: ${blocks.length}\n• 참조하는 곳: ${refs.length}곳\n\n삭제하려면 confirm: true로 다시 요청해주세요.`,
},
],
};
}
await context.reportProgress({ progress: 50, total: 100 });
await this.logseq.deletePage(name);
await context.reportProgress({ progress: 100, total: 100 });
return {
content: [
{
type: 'text',
text: `✅ "${name}" 페이지가 삭제되었습니다.`,
},
],
};
}
@Tool({
name: 'page-get-references',
description:
'특정 Page를 참조하는 모든 곳(Backlinks)을 조회합니다. 연결된 내용을 탐색할 때 사용하세요.',
parameters: z.object({
name: z.string().describe('조회할 Page 이름'),
}),
annotations: {
title: 'Page 역참조 조회',
readOnlyHint: true,
idempotentHint: true,
},
})
async getReferences({ name }: { name: string }, context: Context) {
await context.reportProgress({ progress: 30, total: 100 });
const refs = await this.logseq.getPageLinkedReferences(name);
await context.reportProgress({ progress: 100, total: 100 });
if (!refs || refs.length === 0) {
return {
content: [
{
type: 'text',
text: `🔗 "${name}" 역참조\n\n참조하는 곳이 없습니다.`,
},
],
};
}
const refsList = refs
.map(([page, blocks]) => {
const blockContents = blocks
.slice(0, 3)
.map((b) => ` "${b.content.substring(0, 50)}..."`)
.join('\n');
return `• ${page.name}\n${blockContents}`;
})
.join('\n\n');
return {
content: [
{
type: 'text',
text: `🔗 "${name}" 역참조 (${refs.length}곳)\n\n${refsList}`,
},
],
};
}
private formatBlocksTree(
blocks: Array<{
content: string;
children?: Array<{ content: string; children?: unknown[] }>;
}>,
indent = 0,
): string {
return blocks
.map((block) => {
const prefix = ' '.repeat(indent) + (indent > 0 ? '• ' : '');
let text = prefix + block.content;
if (block.children && block.children.length > 0) {
text +=
'\n' +
this.formatBlocksTree(
block.children as Array<{
content: string;
children?: Array<{ content: string; children?: unknown[] }>;
}>,
indent + 1,
);
}
return text;
})
.join('\n');
}
}