@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.
@@ -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
- const WRITE_TOOL_ANNOTATIONS = {
9
- readOnlyHint: false,
10
- destructiveHint: false,
11
- idempotentHint: false,
12
- openWorldHint: true,
13
- };
14
- export async function runMcpServer() {
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.2.0",
21
+ version: "1.0.0",
18
22
  });
19
- server.registerTool("get_auth_status", {
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 apiBaseUrl = getApiBaseUrl();
39
+ let resolvedApiBaseUrl = apiBaseUrl;
25
40
  try {
26
- const authFile = await readAuthFile();
41
+ const authFile = await readAuth();
27
42
  if (!authFile) {
28
- return textResult({
43
+ return {
29
44
  authenticated: false,
30
- apiBaseUrl,
31
- });
45
+ apiBaseUrl: resolvedApiBaseUrl,
46
+ };
32
47
  }
33
- apiBaseUrl = authFile.apiBaseUrl;
34
- const me = await createAuthenticatedClient().then((client) => client.get("/auth/me"));
35
- return textResult({
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 textResult({
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
- server.registerTool("get_me", {
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
- }, async () => withAuthenticatedClient((client) => client.get("/auth/me")));
54
- server.registerTool("list_my_notifications", {
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
- }, async ({ size }) => withAuthenticatedClient((client) => client.get(`/notifications?${new URLSearchParams({ size: String(size) })}`)));
61
- server.registerTool("list_my_liked_posts", {
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
- }, async ({ cursor, size }) => {
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 withAuthenticatedClient((client) => client.get(`/users/me/liked-posts?${params}`));
91
+ return client.get(`/users/me/liked-posts?${params}`);
74
92
  });
75
- server.registerTool("list_my_posts", {
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
- }, async ({ size }) => withAuthenticatedClient(async (client) => {
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
- server.registerTool("upload_post_image", {
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
- }, async ({ filePath, altText }) => withAuthenticatedClient((client) => uploadPostImage(client, {
122
+ annotations: WRITE_TOOL_ANNOTATIONS,
123
+ }, (client, { filePath, altText }) => uploadImage(client, {
105
124
  filePath,
106
125
  altText,
107
- })));
108
- server.registerTool("publish_post", {
126
+ }));
127
+ registry.registerAuthenticated("publish_post", "publish", {
109
128
  title: "Publish OpenLog Post",
110
- description: "Publish a new post to the authenticated OpenLog account. Requires confirm: true unless skipConfirmation: true is explicitly provided.",
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
- }, async ({ title, description, content, topics, links, confirm, skipConfirmation, }) => withAuthenticatedClient(async (client) => {
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, `${getWebBaseUrl()}/`).toString(),
173
+ url: new URL(postPath, `${webBaseUrl}/`).toString(),
154
174
  };
155
- }));
156
- server.registerTool("get_post_detail", {
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
- }, async ({ username, slug }) => withAuthenticatedClient((client) => client.get(`/users/${encodeURIComponent(username)}/posts/${encodeURIComponent(slug)}`)));
164
- await server.connect(new StdioServerTransport());
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
- async function createAuthenticatedClient() {
216
- const authFile = await readAuthFile();
217
- if (!authFile) {
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
- catch (error) {
232
- return {
233
- content: [
234
- {
235
- type: "text",
236
- text: error instanceof Error ? error.message : String(error),
237
- },
238
- ],
239
- isError: true,
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
+ }