@kitae9999/openlog-cli 0.2.0 → 1.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/README.md +19 -9
- package/dist/api-client.js +63 -2
- package/dist/auth-store.js +26 -3
- package/dist/authenticated-client.js +34 -0
- package/dist/config.js +4 -0
- package/dist/index.js +31 -39
- package/dist/login.js +4 -4
- package/dist/mcp-install.js +5 -1
- package/dist/mcp-permissions-command.js +45 -0
- package/dist/mcp-permissions.js +103 -0
- package/dist/mcp-server.js +91 -85
- package/dist/mcp-toolkit.js +115 -0
- package/dist/mcp-workspace-tools.js +645 -0
- package/package.json +3 -2
package/dist/mcp-server.js
CHANGED
|
@@ -1,84 +1,103 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { OpenLogApiClient } from "./api-client.js";
|
|
5
4
|
import { readAuthFile } from "./auth-store.js";
|
|
5
|
+
import { createAuthenticatedApiClient } from "./authenticated-client.js";
|
|
6
6
|
import { getApiBaseUrl, getWebBaseUrl } from "./config.js";
|
|
7
|
+
import { readMcpPermissions, } from "./mcp-permissions.js";
|
|
8
|
+
import { createContentPreview, McpToolRegistry, READ_TOOL_ANNOTATIONS, WRITE_TOOL_ANNOTATIONS, } from "./mcp-toolkit.js";
|
|
9
|
+
import { registerWorkspaceTools } from "./mcp-workspace-tools.js";
|
|
7
10
|
import { uploadPostImage } from "./post-image-upload.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export async function createOpenLogMcpServer(options = {}) {
|
|
12
|
+
// 의존성을 주입할 수 있는 factory로 구성해 실제 네트워크 없이 도구 목록과 호출을 테스트한다.
|
|
13
|
+
const permissions = options.permissions ?? (await readMcpPermissions());
|
|
14
|
+
const createClient = options.createAuthenticatedClient ?? createAuthenticatedApiClient;
|
|
15
|
+
const readAuth = options.readAuth ?? readAuthFile;
|
|
16
|
+
const uploadImage = options.uploadImage ?? uploadPostImage;
|
|
17
|
+
const apiBaseUrl = options.apiBaseUrl ?? getApiBaseUrl();
|
|
18
|
+
const webBaseUrl = options.webBaseUrl ?? getWebBaseUrl();
|
|
15
19
|
const server = new McpServer({
|
|
16
20
|
name: "openlog",
|
|
17
|
-
version: "0.
|
|
21
|
+
version: "1.0.0",
|
|
18
22
|
});
|
|
19
|
-
server
|
|
23
|
+
const registry = new McpToolRegistry(server, permissions, createClient);
|
|
24
|
+
registry.registerLocal("get_mcp_permissions", "read", {
|
|
25
|
+
title: "Get OpenLog MCP Permissions",
|
|
26
|
+
description: "Return the active local MCP permission profile and capabilities.",
|
|
27
|
+
inputSchema: {},
|
|
28
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
29
|
+
}, async () => ({
|
|
30
|
+
...permissions,
|
|
31
|
+
note: "This is a local agent safety policy, not a server-side authorization boundary.",
|
|
32
|
+
}));
|
|
33
|
+
registry.registerLocal("get_auth_status", "read", {
|
|
20
34
|
title: "Get OpenLog Auth Status",
|
|
21
35
|
description: "Check whether the local OpenLog CLI is authenticated.",
|
|
22
36
|
inputSchema: {},
|
|
37
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
23
38
|
}, async () => {
|
|
24
|
-
let
|
|
39
|
+
let resolvedApiBaseUrl = apiBaseUrl;
|
|
25
40
|
try {
|
|
26
|
-
const authFile = await
|
|
41
|
+
const authFile = await readAuth();
|
|
27
42
|
if (!authFile) {
|
|
28
|
-
return
|
|
43
|
+
return {
|
|
29
44
|
authenticated: false,
|
|
30
|
-
apiBaseUrl,
|
|
31
|
-
}
|
|
45
|
+
apiBaseUrl: resolvedApiBaseUrl,
|
|
46
|
+
};
|
|
32
47
|
}
|
|
33
|
-
|
|
34
|
-
const me = await
|
|
35
|
-
return
|
|
48
|
+
resolvedApiBaseUrl = authFile.apiBaseUrl;
|
|
49
|
+
const me = await createClient().then((client) => client.get("/auth/me"));
|
|
50
|
+
return {
|
|
36
51
|
authenticated: true,
|
|
37
52
|
apiBaseUrl: authFile.apiBaseUrl,
|
|
38
53
|
user: me,
|
|
39
|
-
}
|
|
54
|
+
};
|
|
40
55
|
}
|
|
41
56
|
catch (error) {
|
|
42
|
-
return
|
|
57
|
+
return {
|
|
43
58
|
authenticated: false,
|
|
44
|
-
apiBaseUrl,
|
|
59
|
+
apiBaseUrl: resolvedApiBaseUrl,
|
|
45
60
|
error: error instanceof Error ? error.message : String(error),
|
|
46
|
-
}
|
|
61
|
+
};
|
|
47
62
|
}
|
|
48
63
|
});
|
|
49
|
-
|
|
64
|
+
registry.registerAuthenticated("get_me", "read", {
|
|
50
65
|
title: "Get OpenLog Me",
|
|
51
66
|
description: "Return the currently authenticated OpenLog user.",
|
|
52
67
|
inputSchema: {},
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
69
|
+
}, (client) => client.get("/auth/me"));
|
|
70
|
+
registry.registerAuthenticated("list_my_notifications", "read", {
|
|
55
71
|
title: "List My OpenLog Notifications",
|
|
56
72
|
description: "Return notifications for the authenticated OpenLog user.",
|
|
57
73
|
inputSchema: {
|
|
58
74
|
size: z.number().int().min(1).max(20).default(20),
|
|
59
75
|
},
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
77
|
+
}, (client, { size }) => client.get(`/notifications?${new URLSearchParams({ size: String(size) })}`));
|
|
78
|
+
registry.registerAuthenticated("list_my_liked_posts", "read", {
|
|
62
79
|
title: "List My Liked OpenLog Posts",
|
|
63
80
|
description: "Return posts liked by the authenticated OpenLog user.",
|
|
64
81
|
inputSchema: {
|
|
65
82
|
cursor: z.string().optional(),
|
|
66
83
|
size: z.number().int().min(1).max(20).default(10),
|
|
67
84
|
},
|
|
68
|
-
|
|
85
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
86
|
+
}, (client, { cursor, size }) => {
|
|
69
87
|
const params = new URLSearchParams({ size: String(size) });
|
|
70
88
|
if (cursor) {
|
|
71
89
|
params.set("cursor", cursor);
|
|
72
90
|
}
|
|
73
|
-
return
|
|
91
|
+
return client.get(`/users/me/liked-posts?${params}`);
|
|
74
92
|
});
|
|
75
|
-
|
|
93
|
+
registry.registerAuthenticated("list_my_posts", "read", {
|
|
76
94
|
title: "List My OpenLog Posts",
|
|
77
95
|
description: "Return posts authored by the authenticated OpenLog user.",
|
|
78
96
|
inputSchema: {
|
|
79
97
|
size: z.number().int().min(1).max(100).default(20),
|
|
80
98
|
},
|
|
81
|
-
|
|
99
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
100
|
+
}, async (client, { size }) => {
|
|
82
101
|
const me = await client.get("/auth/me");
|
|
83
102
|
const username = me.username?.trim();
|
|
84
103
|
if (!username) {
|
|
@@ -92,23 +111,22 @@ export async function runMcpServer() {
|
|
|
92
111
|
hasMore: posts.length > size,
|
|
93
112
|
posts: posts.slice(0, size),
|
|
94
113
|
};
|
|
95
|
-
})
|
|
96
|
-
|
|
114
|
+
});
|
|
115
|
+
registry.registerAuthenticated("upload_post_image", "write", {
|
|
97
116
|
title: "Upload OpenLog Post Image",
|
|
98
117
|
description: "Convert a local image file to WebP, upload it to OpenLog, and return markdown for post content.",
|
|
99
|
-
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
100
118
|
inputSchema: {
|
|
101
119
|
filePath: z.string().min(1),
|
|
102
120
|
altText: z.string().optional(),
|
|
103
121
|
},
|
|
104
|
-
|
|
122
|
+
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
123
|
+
}, (client, { filePath, altText }) => uploadImage(client, {
|
|
105
124
|
filePath,
|
|
106
125
|
altText,
|
|
107
|
-
}))
|
|
108
|
-
|
|
126
|
+
}));
|
|
127
|
+
registry.registerAuthenticated("publish_post", "publish", {
|
|
109
128
|
title: "Publish OpenLog Post",
|
|
110
|
-
description: "Publish a new post
|
|
111
|
-
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
129
|
+
description: "Publish a new post. Requires confirm: true unless skipConfirmation: true is explicitly provided.",
|
|
112
130
|
inputSchema: {
|
|
113
131
|
title: z.string().min(1),
|
|
114
132
|
description: z.string().min(1),
|
|
@@ -123,7 +141,8 @@ export async function runMcpServer() {
|
|
|
123
141
|
confirm: z.boolean().optional(),
|
|
124
142
|
skipConfirmation: z.boolean().optional(),
|
|
125
143
|
},
|
|
126
|
-
|
|
144
|
+
annotations: WRITE_TOOL_ANNOTATIONS,
|
|
145
|
+
}, async (client, { title, description, content, topics, links, confirm, skipConfirmation, }) => {
|
|
127
146
|
const post = normalizePostInput({
|
|
128
147
|
title,
|
|
129
148
|
description,
|
|
@@ -131,6 +150,7 @@ export async function runMcpServer() {
|
|
|
131
150
|
topics,
|
|
132
151
|
links,
|
|
133
152
|
});
|
|
153
|
+
// 명시적 확인 전에는 POST를 실행하지 않고 실제 발행 입력의 미리보기만 반환한다.
|
|
134
154
|
if (confirm !== true && skipConfirmation !== true) {
|
|
135
155
|
return {
|
|
136
156
|
requiresConfirmation: true,
|
|
@@ -150,18 +170,30 @@ export async function runMcpServer() {
|
|
|
150
170
|
return {
|
|
151
171
|
...published,
|
|
152
172
|
path: postPath,
|
|
153
|
-
url: new URL(postPath, `${
|
|
173
|
+
url: new URL(postPath, `${webBaseUrl}/`).toString(),
|
|
154
174
|
};
|
|
155
|
-
})
|
|
156
|
-
|
|
175
|
+
});
|
|
176
|
+
registry.registerAuthenticated("get_post_detail", "read", {
|
|
157
177
|
title: "Get OpenLog Post Detail",
|
|
158
178
|
description: "Return a public OpenLog post detail by author username and post slug.",
|
|
159
179
|
inputSchema: {
|
|
160
180
|
username: z.string().min(1),
|
|
161
181
|
slug: z.string().min(1),
|
|
162
182
|
},
|
|
163
|
-
|
|
164
|
-
|
|
183
|
+
annotations: READ_TOOL_ANNOTATIONS,
|
|
184
|
+
}, (client, { username, slug }) => client.get(`/users/${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`));
|
|
185
|
+
// 워크스페이스 도구는 별도 catalog에서 등록해 서버 시작 안내와 테스트 목록을 일치시킨다.
|
|
186
|
+
registerWorkspaceTools(registry, webBaseUrl);
|
|
187
|
+
return {
|
|
188
|
+
server,
|
|
189
|
+
permissions,
|
|
190
|
+
toolNames: [...registry.toolNames],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
export async function runMcpServer() {
|
|
194
|
+
const created = await createOpenLogMcpServer();
|
|
195
|
+
printMcpStartupHint(created.permissions, created.toolNames);
|
|
196
|
+
await created.server.connect(new StdioServerTransport());
|
|
165
197
|
}
|
|
166
198
|
function normalizePostInput(input) {
|
|
167
199
|
const title = input.title.trim();
|
|
@@ -205,48 +237,22 @@ function normalizeLinks(links) {
|
|
|
205
237
|
}
|
|
206
238
|
return normalized;
|
|
207
239
|
}
|
|
208
|
-
function createContentPreview(content) {
|
|
209
|
-
const preview = content.replace(/\s+/g, " ").trim();
|
|
210
|
-
return preview.length > 500 ? `${preview.slice(0, 497)}...` : preview;
|
|
211
|
-
}
|
|
212
240
|
function buildPublicPostPath(username, slug) {
|
|
213
241
|
return `/@${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`;
|
|
214
242
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
throw new Error("Run `openlog login` before using OpenLog MCP tools.");
|
|
219
|
-
}
|
|
220
|
-
return new OpenLogApiClient({
|
|
221
|
-
accessToken: authFile.accessToken,
|
|
222
|
-
apiBaseUrl: authFile.apiBaseUrl,
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
async function withAuthenticatedClient(callback) {
|
|
226
|
-
try {
|
|
227
|
-
const client = await createAuthenticatedClient();
|
|
228
|
-
const result = await callback(client);
|
|
229
|
-
return textResult(result);
|
|
243
|
+
function printMcpStartupHint(permissions, toolNames) {
|
|
244
|
+
if (!process.stderr.isTTY) {
|
|
245
|
+
return;
|
|
230
246
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
function textResult(value) {
|
|
244
|
-
return {
|
|
245
|
-
content: [
|
|
246
|
-
{
|
|
247
|
-
type: "text",
|
|
248
|
-
text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
|
|
249
|
-
},
|
|
250
|
-
],
|
|
251
|
-
};
|
|
247
|
+
console.error(`OpenLog MCP server is running over stdio.
|
|
248
|
+
|
|
249
|
+
Profile: ${permissions.profile}
|
|
250
|
+
Capabilities: ${permissions.capabilities.join(", ")}
|
|
251
|
+
|
|
252
|
+
This terminal is now reserved for MCP protocol traffic.
|
|
253
|
+
Press Ctrl+C to stop it.
|
|
254
|
+
|
|
255
|
+
Available tools:
|
|
256
|
+
${toolNames.map((name) => ` ${name}`).join("\n")}
|
|
257
|
+
`);
|
|
252
258
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { hasMcpCapability, } from "./mcp-permissions.js";
|
|
2
|
+
export const READ_TOOL_ANNOTATIONS = {
|
|
3
|
+
readOnlyHint: true,
|
|
4
|
+
destructiveHint: false,
|
|
5
|
+
idempotentHint: true,
|
|
6
|
+
openWorldHint: true,
|
|
7
|
+
};
|
|
8
|
+
export const WRITE_TOOL_ANNOTATIONS = {
|
|
9
|
+
readOnlyHint: false,
|
|
10
|
+
destructiveHint: false,
|
|
11
|
+
idempotentHint: false,
|
|
12
|
+
openWorldHint: true,
|
|
13
|
+
};
|
|
14
|
+
export const IDEMPOTENT_WRITE_TOOL_ANNOTATIONS = {
|
|
15
|
+
...WRITE_TOOL_ANNOTATIONS,
|
|
16
|
+
idempotentHint: true,
|
|
17
|
+
};
|
|
18
|
+
export const DELETE_TOOL_ANNOTATIONS = {
|
|
19
|
+
readOnlyHint: false,
|
|
20
|
+
destructiveHint: true,
|
|
21
|
+
idempotentHint: false,
|
|
22
|
+
openWorldHint: true,
|
|
23
|
+
};
|
|
24
|
+
export class McpToolRegistry {
|
|
25
|
+
server;
|
|
26
|
+
permissions;
|
|
27
|
+
createAuthenticatedClient;
|
|
28
|
+
toolNames = [];
|
|
29
|
+
constructor(server, permissions, createAuthenticatedClient) {
|
|
30
|
+
this.server = server;
|
|
31
|
+
this.permissions = permissions;
|
|
32
|
+
this.createAuthenticatedClient = createAuthenticatedClient;
|
|
33
|
+
}
|
|
34
|
+
registerAuthenticated(name, capability, config, handler) {
|
|
35
|
+
// 허용되지 않은 도구는 MCP tools/list 응답에도 나타나지 않게 등록 자체를 생략한다.
|
|
36
|
+
if (!hasMcpCapability(this.permissions, capability)) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const callback = (async (args) => {
|
|
40
|
+
// 목록 노출 여부와 별개로 실행 직전에도 권한을 확인해 우회 호출을 막는다.
|
|
41
|
+
if (!hasMcpCapability(this.permissions, capability)) {
|
|
42
|
+
return permissionDeniedResult(name, capability);
|
|
43
|
+
}
|
|
44
|
+
return withAuthenticatedClient(this.createAuthenticatedClient, (client) => handler(client, args));
|
|
45
|
+
});
|
|
46
|
+
this.server.registerTool(name, config, callback);
|
|
47
|
+
this.toolNames.push(name);
|
|
48
|
+
}
|
|
49
|
+
registerLocal(name, capability, config, handler) {
|
|
50
|
+
// 인증이 필요 없는 로컬 도구도 동일한 capability 정책을 적용한다.
|
|
51
|
+
if (!hasMcpCapability(this.permissions, capability)) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const callback = (async (args) => {
|
|
55
|
+
if (!hasMcpCapability(this.permissions, capability)) {
|
|
56
|
+
return permissionDeniedResult(name, capability);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return textResult(await handler(args));
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return errorResult(error);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
this.server.registerTool(name, config, callback);
|
|
66
|
+
this.toolNames.push(name);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function withAuthenticatedClient(createAuthenticatedClient, callback) {
|
|
70
|
+
try {
|
|
71
|
+
const client = await createAuthenticatedClient();
|
|
72
|
+
return textResult(await callback(client));
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// API·인증 오류를 throw하지 않고 MCP 표준 isError 응답으로 정규화한다.
|
|
76
|
+
return errorResult(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function textResult(value) {
|
|
80
|
+
const serialized = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: serialized ?? "null",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function createContentPreview(content) {
|
|
91
|
+
const preview = content.replace(/\s+/g, " ").trim();
|
|
92
|
+
return preview.length > 500 ? `${preview.slice(0, 497)}...` : preview;
|
|
93
|
+
}
|
|
94
|
+
function permissionDeniedResult(name, capability) {
|
|
95
|
+
return {
|
|
96
|
+
content: [
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
text: `MCP tool ${name} requires the ${capability} capability. Change the profile with \`openlog mcp permissions set\` and restart the MCP server.`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
isError: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function errorResult(error) {
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: error instanceof Error ? error.message : String(error),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
isError: true,
|
|
114
|
+
};
|
|
115
|
+
}
|