@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/README.md
CHANGED
|
@@ -8,16 +8,24 @@ npx -y @kitae9999/openlog-cli whoami
|
|
|
8
8
|
npx -y @kitae9999/openlog-cli mcp
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
MCP
|
|
11
|
+
Local MCP permissions:
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
```bash
|
|
14
|
+
openlog mcp permissions
|
|
15
|
+
openlog mcp permissions set read-only
|
|
16
|
+
openlog mcp permissions set safe-write
|
|
17
|
+
openlog mcp permissions set full
|
|
18
|
+
openlog mcp permissions reset
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`safe-write` is the default. Permission changes apply after the MCP server is restarted or reloaded.
|
|
22
|
+
|
|
23
|
+
MCP tool groups:
|
|
24
|
+
|
|
25
|
+
- Account and posts: authentication, notifications, authored/liked posts, image upload, post detail, and confirmed publishing
|
|
26
|
+
- Workspace context: workspace discovery, activity, working brief, tasks, logs, todos, memories, outputs, and graph links
|
|
27
|
+
- Safe writes: create/update, todo completion, link creation, image upload, and confirmed output/post publishing
|
|
28
|
+
- Full-only writes: individual workspace-document/link deletes and working-brief clear
|
|
21
29
|
|
|
22
30
|
MCP client configuration:
|
|
23
31
|
|
|
@@ -34,3 +42,5 @@ MCP client configuration:
|
|
|
34
42
|
|
|
35
43
|
Set `OPENLOG_API_BASE_URL` to point at a non-production API. Set
|
|
36
44
|
`OPENLOG_WEB_BASE_URL` to control absolute post URLs returned by `publish_post`.
|
|
45
|
+
Set `OPENLOG_MCP_CONFIG_FILE` to replace the default
|
|
46
|
+
`~/.openlog/mcp-config.json` permission file.
|
package/dist/api-client.js
CHANGED
|
@@ -12,9 +12,14 @@ export class ApiError extends Error {
|
|
|
12
12
|
export class OpenLogApiClient {
|
|
13
13
|
apiBaseUrl;
|
|
14
14
|
accessToken;
|
|
15
|
+
refreshToken;
|
|
16
|
+
onTokenRefresh;
|
|
17
|
+
refreshPromise;
|
|
15
18
|
constructor(options = {}) {
|
|
16
19
|
this.apiBaseUrl = (options.apiBaseUrl ?? getApiBaseUrl()).replace(/\/$/, "");
|
|
17
20
|
this.accessToken = options.accessToken;
|
|
21
|
+
this.refreshToken = options.refreshToken;
|
|
22
|
+
this.onTokenRefresh = options.onTokenRefresh;
|
|
18
23
|
}
|
|
19
24
|
async get(path) {
|
|
20
25
|
return this.request(path, { method: "GET" });
|
|
@@ -26,6 +31,20 @@ export class OpenLogApiClient {
|
|
|
26
31
|
body: body ? JSON.stringify(body) : undefined,
|
|
27
32
|
});
|
|
28
33
|
}
|
|
34
|
+
async put(path, body) {
|
|
35
|
+
return this.request(path, {
|
|
36
|
+
method: "PUT",
|
|
37
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
38
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async patch(path, body) {
|
|
42
|
+
return this.request(path, {
|
|
43
|
+
method: "PATCH",
|
|
44
|
+
headers: body ? { "content-type": "application/json" } : undefined,
|
|
45
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
29
48
|
async postNoContent(path, body) {
|
|
30
49
|
await this.request(path, {
|
|
31
50
|
method: "POST",
|
|
@@ -40,7 +59,10 @@ export class OpenLogApiClient {
|
|
|
40
59
|
body: body ? JSON.stringify(body) : undefined,
|
|
41
60
|
});
|
|
42
61
|
}
|
|
43
|
-
async
|
|
62
|
+
async deleteNoContent(path) {
|
|
63
|
+
await this.request(path, { method: "DELETE" });
|
|
64
|
+
}
|
|
65
|
+
async request(path, init, allowRefresh = true) {
|
|
44
66
|
const url = `${this.apiBaseUrl}${path}`;
|
|
45
67
|
const response = await fetch(url, {
|
|
46
68
|
...init,
|
|
@@ -51,15 +73,54 @@ export class OpenLogApiClient {
|
|
|
51
73
|
: {}),
|
|
52
74
|
},
|
|
53
75
|
});
|
|
76
|
+
if (response.status === 401 && allowRefresh && this.refreshToken) {
|
|
77
|
+
await this.refreshAccessToken();
|
|
78
|
+
return this.request(path, init, false);
|
|
79
|
+
}
|
|
54
80
|
if (!response.ok) {
|
|
55
81
|
throw new ApiError(response.status, url, await readErrorMessage(response));
|
|
56
82
|
}
|
|
57
83
|
if (response.status === 204) {
|
|
58
84
|
return undefined;
|
|
59
85
|
}
|
|
60
|
-
|
|
86
|
+
const responseBody = await response.text();
|
|
87
|
+
if (!responseBody) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return JSON.parse(responseBody);
|
|
91
|
+
}
|
|
92
|
+
async refreshAccessToken() {
|
|
93
|
+
if (!this.refreshPromise) {
|
|
94
|
+
this.refreshPromise = this.performTokenRefresh().finally(() => {
|
|
95
|
+
this.refreshPromise = undefined;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
await this.refreshPromise;
|
|
99
|
+
}
|
|
100
|
+
async performTokenRefresh() {
|
|
101
|
+
const response = await fetch(`${this.apiBaseUrl}/auth/device/refresh`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
throw new ApiError(response.status, `${this.apiBaseUrl}/auth/device/refresh`, await readErrorMessage(response));
|
|
108
|
+
}
|
|
109
|
+
const tokens = (await response.json());
|
|
110
|
+
if (!isRefreshedTokens(tokens)) {
|
|
111
|
+
throw new Error("OpenLog refresh response is incomplete.");
|
|
112
|
+
}
|
|
113
|
+
this.accessToken = tokens.accessToken;
|
|
114
|
+
this.refreshToken = tokens.refreshToken;
|
|
115
|
+
await this.onTokenRefresh?.(tokens);
|
|
61
116
|
}
|
|
62
117
|
}
|
|
118
|
+
function isRefreshedTokens(value) {
|
|
119
|
+
return Boolean(value.accessToken &&
|
|
120
|
+
value.refreshToken &&
|
|
121
|
+
Number.isFinite(value.expiresIn) &&
|
|
122
|
+
Number.isFinite(value.refreshExpiresIn));
|
|
123
|
+
}
|
|
63
124
|
async function readErrorMessage(response) {
|
|
64
125
|
try {
|
|
65
126
|
const body = (await response.json());
|
package/dist/auth-store.js
CHANGED
|
@@ -10,8 +10,11 @@ export async function readAuthFile() {
|
|
|
10
10
|
}
|
|
11
11
|
return {
|
|
12
12
|
accessToken: parsed.accessToken,
|
|
13
|
+
refreshToken: parsed.refreshToken,
|
|
13
14
|
apiBaseUrl: parsed.apiBaseUrl,
|
|
14
15
|
createdAt: parsed.createdAt,
|
|
16
|
+
accessTokenExpiresAt: parsed.accessTokenExpiresAt,
|
|
17
|
+
refreshTokenExpiresAt: parsed.refreshTokenExpiresAt,
|
|
15
18
|
};
|
|
16
19
|
}
|
|
17
20
|
catch (error) {
|
|
@@ -24,20 +27,40 @@ export async function readAuthFile() {
|
|
|
24
27
|
throw error;
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
|
-
export async function writeAuthFile(
|
|
30
|
+
export async function writeAuthFile(tokens) {
|
|
28
31
|
const authFile = {
|
|
29
|
-
accessToken,
|
|
32
|
+
accessToken: tokens.accessToken,
|
|
33
|
+
refreshToken: tokens.refreshToken,
|
|
30
34
|
apiBaseUrl: getApiBaseUrl(),
|
|
31
35
|
createdAt: new Date().toISOString(),
|
|
36
|
+
accessTokenExpiresAt: expiresAt(tokens.expiresIn),
|
|
37
|
+
refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
|
|
32
38
|
};
|
|
39
|
+
await persistAuthFile(authFile);
|
|
40
|
+
return authFile;
|
|
41
|
+
}
|
|
42
|
+
export async function updateAuthFileTokens(authFile, tokens) {
|
|
43
|
+
const updatedAuthFile = {
|
|
44
|
+
...authFile,
|
|
45
|
+
accessToken: tokens.accessToken,
|
|
46
|
+
refreshToken: tokens.refreshToken,
|
|
47
|
+
accessTokenExpiresAt: expiresAt(tokens.expiresIn),
|
|
48
|
+
refreshTokenExpiresAt: expiresAt(tokens.refreshExpiresIn),
|
|
49
|
+
};
|
|
50
|
+
await persistAuthFile(updatedAuthFile);
|
|
51
|
+
return updatedAuthFile;
|
|
52
|
+
}
|
|
53
|
+
async function persistAuthFile(authFile) {
|
|
33
54
|
const authFilePath = getAuthFilePath();
|
|
34
55
|
await mkdir(path.dirname(authFilePath), { recursive: true, mode: 0o700 });
|
|
35
56
|
await writeFile(authFilePath, `${JSON.stringify(authFile, null, 2)}\n`, {
|
|
36
57
|
mode: 0o600,
|
|
37
58
|
});
|
|
38
59
|
await chmod(authFilePath, 0o600);
|
|
39
|
-
return authFile;
|
|
40
60
|
}
|
|
41
61
|
export async function deleteAuthFile() {
|
|
42
62
|
await rm(getAuthFilePath(), { force: true });
|
|
43
63
|
}
|
|
64
|
+
function expiresAt(expiresIn) {
|
|
65
|
+
return new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { OpenLogApiClient } from "./api-client.js";
|
|
2
|
+
import { readAuthFile, updateAuthFileTokens, } from "./auth-store.js";
|
|
3
|
+
let cachedClient;
|
|
4
|
+
export async function createAuthenticatedApiClient() {
|
|
5
|
+
const authFile = await readAuthFile();
|
|
6
|
+
if (!authFile) {
|
|
7
|
+
throw new Error("Run `openlog login` before using OpenLog.");
|
|
8
|
+
}
|
|
9
|
+
const fingerprint = authFingerprint(authFile);
|
|
10
|
+
if (cachedClient?.fingerprint === fingerprint) {
|
|
11
|
+
return cachedClient.client;
|
|
12
|
+
}
|
|
13
|
+
const client = new OpenLogApiClient({
|
|
14
|
+
accessToken: authFile.accessToken,
|
|
15
|
+
refreshToken: authFile.refreshToken,
|
|
16
|
+
apiBaseUrl: authFile.apiBaseUrl,
|
|
17
|
+
onTokenRefresh: async (tokens) => {
|
|
18
|
+
const updatedAuthFile = await updateAuthFileTokens(authFile, tokens);
|
|
19
|
+
cachedClient = {
|
|
20
|
+
fingerprint: authFingerprint(updatedAuthFile),
|
|
21
|
+
client,
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
cachedClient = { fingerprint, client };
|
|
26
|
+
return client;
|
|
27
|
+
}
|
|
28
|
+
function authFingerprint(authFile) {
|
|
29
|
+
return [
|
|
30
|
+
authFile.apiBaseUrl,
|
|
31
|
+
authFile.accessToken,
|
|
32
|
+
authFile.refreshToken ?? "",
|
|
33
|
+
].join("\u0000");
|
|
34
|
+
}
|
package/dist/config.js
CHANGED
|
@@ -12,3 +12,7 @@ export function getAuthFilePath() {
|
|
|
12
12
|
return (process.env.OPENLOG_AUTH_FILE ??
|
|
13
13
|
path.join(os.homedir(), ".openlog", "auth.json"));
|
|
14
14
|
}
|
|
15
|
+
export function getMcpConfigFilePath() {
|
|
16
|
+
return (process.env.OPENLOG_MCP_CONFIG_FILE ??
|
|
17
|
+
path.join(os.homedir(), ".openlog", "mcp-config.json"));
|
|
18
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,8 @@ import { login } from "./login.js";
|
|
|
4
4
|
import { runMcpServer } from "./mcp-server.js";
|
|
5
5
|
import { installMcp, printMcpInstallHelp } from "./mcp-install.js";
|
|
6
6
|
import { ApiError, OpenLogApiClient } from "./api-client.js";
|
|
7
|
+
import { createAuthenticatedApiClient } from "./authenticated-client.js";
|
|
8
|
+
import { runMcpPermissionsCommand } from "./mcp-permissions-command.js";
|
|
7
9
|
const command = process.argv[2] ?? "help";
|
|
8
10
|
const subcommand = process.argv[3];
|
|
9
11
|
try {
|
|
@@ -11,8 +13,7 @@ try {
|
|
|
11
13
|
await login();
|
|
12
14
|
}
|
|
13
15
|
else if (command === "logout") {
|
|
14
|
-
await
|
|
15
|
-
console.log("Logged out.");
|
|
16
|
+
await logout();
|
|
16
17
|
}
|
|
17
18
|
else if (command === "whoami") {
|
|
18
19
|
await whoami();
|
|
@@ -21,10 +22,12 @@ try {
|
|
|
21
22
|
if (subcommand === "install") {
|
|
22
23
|
await installMcpCommand();
|
|
23
24
|
}
|
|
25
|
+
else if (subcommand === "permissions") {
|
|
26
|
+
await runMcpPermissionsCommand(process.argv.slice(4));
|
|
27
|
+
}
|
|
24
28
|
else if (subcommand === undefined ||
|
|
25
29
|
subcommand === "serve" ||
|
|
26
30
|
subcommand === "start") {
|
|
27
|
-
printMcpStartupHint();
|
|
28
31
|
await runMcpServer();
|
|
29
32
|
}
|
|
30
33
|
else {
|
|
@@ -56,46 +59,27 @@ async function installMcpCommand() {
|
|
|
56
59
|
});
|
|
57
60
|
}
|
|
58
61
|
async function whoami() {
|
|
59
|
-
const
|
|
60
|
-
if (!authFile) {
|
|
61
|
-
throw new Error("Not logged in. Run `openlog login` first.");
|
|
62
|
-
}
|
|
63
|
-
const apiClient = new OpenLogApiClient({
|
|
64
|
-
accessToken: authFile.accessToken,
|
|
65
|
-
apiBaseUrl: authFile.apiBaseUrl,
|
|
66
|
-
});
|
|
62
|
+
const apiClient = await createAuthenticatedApiClient();
|
|
67
63
|
const me = await apiClient.get("/auth/me");
|
|
68
64
|
console.log(JSON.stringify(me, null, 2));
|
|
69
65
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"args": ["mcp"]
|
|
66
|
+
async function logout() {
|
|
67
|
+
const authFile = await readAuthFile();
|
|
68
|
+
if (authFile?.refreshToken) {
|
|
69
|
+
try {
|
|
70
|
+
const apiClient = new OpenLogApiClient({
|
|
71
|
+
apiBaseUrl: authFile.apiBaseUrl,
|
|
72
|
+
});
|
|
73
|
+
await apiClient.postNoContent("/auth/device/revoke", {
|
|
74
|
+
refreshToken: authFile.refreshToken,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error(`Warning: remote session revoke failed: ${formatCliError(error)}`);
|
|
79
|
+
}
|
|
85
80
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Available tools:
|
|
90
|
-
get_auth_status
|
|
91
|
-
get_me
|
|
92
|
-
list_my_notifications
|
|
93
|
-
list_my_posts
|
|
94
|
-
list_my_liked_posts
|
|
95
|
-
upload_post_image
|
|
96
|
-
publish_post
|
|
97
|
-
get_post_detail
|
|
98
|
-
`);
|
|
81
|
+
await deleteAuthFile();
|
|
82
|
+
console.log("Logged out.");
|
|
99
83
|
}
|
|
100
84
|
function printHelp() {
|
|
101
85
|
console.log(`OpenLog CLI
|
|
@@ -109,11 +93,19 @@ Usage:
|
|
|
109
93
|
Register OpenLog MCP with Codex
|
|
110
94
|
openlog mcp install claude-code
|
|
111
95
|
Register OpenLog MCP with Claude Code
|
|
96
|
+
openlog mcp permissions
|
|
97
|
+
Show the active local MCP permission profile
|
|
98
|
+
openlog mcp permissions set read-only|safe-write|full
|
|
99
|
+
Change the local MCP permission profile
|
|
100
|
+
openlog mcp permissions reset
|
|
101
|
+
Restore the default safe-write profile
|
|
112
102
|
|
|
113
103
|
Environment:
|
|
114
104
|
OPENLOG_API_BASE_URL Override the OpenLog API base URL
|
|
115
105
|
OPENLOG_WEB_BASE_URL Override the OpenLog web base URL for published post links
|
|
116
106
|
OPENLOG_AUTH_FILE Override the local auth file path
|
|
107
|
+
OPENLOG_MCP_CONFIG_FILE
|
|
108
|
+
Override the local MCP permission config path
|
|
117
109
|
`);
|
|
118
110
|
}
|
|
119
111
|
function formatCliError(error) {
|
package/dist/login.js
CHANGED
|
@@ -14,18 +14,18 @@ export async function login() {
|
|
|
14
14
|
? "The approval page was opened in your browser."
|
|
15
15
|
: "Open the URL above in your browser.");
|
|
16
16
|
console.log("Waiting for approval...");
|
|
17
|
-
const
|
|
18
|
-
await writeAuthFile(
|
|
17
|
+
const tokens = await pollForTokens(apiClient, deviceLogin);
|
|
18
|
+
await writeAuthFile(tokens);
|
|
19
19
|
console.log("Login successful.");
|
|
20
20
|
}
|
|
21
|
-
async function
|
|
21
|
+
async function pollForTokens(apiClient, deviceLogin) {
|
|
22
22
|
const deadline = Date.now() + deviceLogin.expiresIn * 1000;
|
|
23
23
|
let interval = deviceLogin.interval;
|
|
24
24
|
while (Date.now() < deadline) {
|
|
25
25
|
await sleep(interval * 1000);
|
|
26
26
|
const response = await apiClient.post("/auth/device/token", { deviceCode: deviceLogin.deviceCode });
|
|
27
27
|
if (response.status === "APPROVED") {
|
|
28
|
-
return response
|
|
28
|
+
return response;
|
|
29
29
|
}
|
|
30
30
|
interval = response.interval ?? interval;
|
|
31
31
|
}
|
package/dist/mcp-install.js
CHANGED
|
@@ -28,7 +28,8 @@ Aliases:
|
|
|
28
28
|
claude Same as claude-code
|
|
29
29
|
|
|
30
30
|
Notes:
|
|
31
|
-
If OPENLOG_API_BASE_URL or
|
|
31
|
+
If OPENLOG_API_BASE_URL, OPENLOG_WEB_BASE_URL, or OPENLOG_MCP_CONFIG_FILE is set,
|
|
32
|
+
it is added to the client config.
|
|
32
33
|
`);
|
|
33
34
|
}
|
|
34
35
|
function buildServerConfig() {
|
|
@@ -41,6 +42,9 @@ function buildServerConfig() {
|
|
|
41
42
|
if (webBaseUrl !== DEFAULT_WEB_BASE_URL) {
|
|
42
43
|
env.OPENLOG_WEB_BASE_URL = webBaseUrl;
|
|
43
44
|
}
|
|
45
|
+
if (process.env.OPENLOG_MCP_CONFIG_FILE) {
|
|
46
|
+
env.OPENLOG_MCP_CONFIG_FILE = process.env.OPENLOG_MCP_CONFIG_FILE;
|
|
47
|
+
}
|
|
44
48
|
return {
|
|
45
49
|
command: "openlog",
|
|
46
50
|
args: ["mcp"],
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isMcpPermissionProfile, readMcpPermissions, resetMcpPermissions, writeMcpPermissionProfile, } from "./mcp-permissions.js";
|
|
2
|
+
export async function runMcpPermissionsCommand(args, writeOutput = console.log) {
|
|
3
|
+
const action = args[0] ?? "show";
|
|
4
|
+
if ((action === "show" && args.length === 1) || args.length === 0) {
|
|
5
|
+
writeOutput(formatPermissions(await readMcpPermissions()));
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
if (action === "set" && args.length === 2) {
|
|
9
|
+
const profile = args[1];
|
|
10
|
+
if (!isMcpPermissionProfile(profile)) {
|
|
11
|
+
throw new Error(permissionUsage());
|
|
12
|
+
}
|
|
13
|
+
const permissions = await writeMcpPermissionProfile(profile);
|
|
14
|
+
writeOutput(formatPermissions(permissions));
|
|
15
|
+
// 실행 중인 MCP server는 시작 시 권한을 확정하므로 재시작 안내가 필요하다.
|
|
16
|
+
writeOutput("Restart or reload the OpenLog MCP server to apply this profile.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (action === "reset" && args.length === 1) {
|
|
20
|
+
const permissions = await resetMcpPermissions();
|
|
21
|
+
writeOutput(formatPermissions(permissions));
|
|
22
|
+
writeOutput("Restart or reload the OpenLog MCP server to apply this profile.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
throw new Error(permissionUsage());
|
|
26
|
+
}
|
|
27
|
+
export function permissionUsage() {
|
|
28
|
+
return `OpenLog MCP permissions
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
openlog mcp permissions
|
|
32
|
+
openlog mcp permissions show
|
|
33
|
+
openlog mcp permissions set read-only
|
|
34
|
+
openlog mcp permissions set safe-write
|
|
35
|
+
openlog mcp permissions set full
|
|
36
|
+
openlog mcp permissions reset`;
|
|
37
|
+
}
|
|
38
|
+
function formatPermissions(permissions) {
|
|
39
|
+
return [
|
|
40
|
+
`Profile: ${permissions.profile}`,
|
|
41
|
+
`Capabilities: ${permissions.capabilities.join(", ")}`,
|
|
42
|
+
`Source: ${permissions.configured ? "local config" : "default"}`,
|
|
43
|
+
...(permissions.updatedAt ? [`Updated: ${permissions.updatedAt}`] : []),
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getMcpConfigFilePath } from "./config.js";
|
|
4
|
+
export const MCP_PERMISSION_PROFILES = [
|
|
5
|
+
"read-only",
|
|
6
|
+
"safe-write",
|
|
7
|
+
"full",
|
|
8
|
+
];
|
|
9
|
+
export const MCP_CAPABILITIES = ["read", "write", "publish", "delete"];
|
|
10
|
+
export const DEFAULT_MCP_PERMISSION_PROFILE = "safe-write";
|
|
11
|
+
const PROFILE_CAPABILITIES = {
|
|
12
|
+
// 프로필은 상위 프로필이 하위 capability를 모두 포함하도록 누적 구성한다.
|
|
13
|
+
"read-only": ["read"],
|
|
14
|
+
"safe-write": ["read", "write", "publish"],
|
|
15
|
+
full: ["read", "write", "publish", "delete"],
|
|
16
|
+
};
|
|
17
|
+
export async function readMcpPermissions() {
|
|
18
|
+
const filePath = getMcpConfigFilePath();
|
|
19
|
+
let content;
|
|
20
|
+
try {
|
|
21
|
+
content = await readFile(filePath, "utf8");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
25
|
+
// 설정 파일이 없는 최초 실행은 기존 동작과 호환되는 safe-write를 사용한다.
|
|
26
|
+
return resolvePermissions(DEFAULT_MCP_PERMISSION_PROFILE, false, null);
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(content);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw invalidConfigError(filePath, "file is not valid JSON");
|
|
36
|
+
}
|
|
37
|
+
const config = parsePermissionConfig(parsed, filePath);
|
|
38
|
+
return resolvePermissions(config.profile, true, config.updatedAt);
|
|
39
|
+
}
|
|
40
|
+
export async function writeMcpPermissionProfile(profile) {
|
|
41
|
+
const filePath = getMcpConfigFilePath();
|
|
42
|
+
const config = {
|
|
43
|
+
version: 1,
|
|
44
|
+
profile,
|
|
45
|
+
updatedAt: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
48
|
+
await writeFile(filePath, `${JSON.stringify(config, null, 2)}\n`, {
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
// 기존 파일을 덮어쓸 때도 소유자만 읽고 쓸 수 있도록 권한을 다시 고정한다.
|
|
52
|
+
await chmod(filePath, 0o600);
|
|
53
|
+
return resolvePermissions(profile, true, config.updatedAt);
|
|
54
|
+
}
|
|
55
|
+
export async function resetMcpPermissions() {
|
|
56
|
+
await rm(getMcpConfigFilePath(), { force: true });
|
|
57
|
+
return resolvePermissions(DEFAULT_MCP_PERMISSION_PROFILE, false, null);
|
|
58
|
+
}
|
|
59
|
+
export function isMcpPermissionProfile(value) {
|
|
60
|
+
return MCP_PERMISSION_PROFILES.some((profile) => profile === value);
|
|
61
|
+
}
|
|
62
|
+
export function hasMcpCapability(permissions, capability) {
|
|
63
|
+
return permissions.capabilities.includes(capability);
|
|
64
|
+
}
|
|
65
|
+
function resolvePermissions(profile, configured, updatedAt) {
|
|
66
|
+
return {
|
|
67
|
+
profile,
|
|
68
|
+
capabilities: [...PROFILE_CAPABILITIES[profile]],
|
|
69
|
+
configured,
|
|
70
|
+
updatedAt,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function parsePermissionConfig(value, filePath) {
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
throw invalidConfigError(filePath, "root value must be an object");
|
|
76
|
+
}
|
|
77
|
+
const config = value;
|
|
78
|
+
if (config.version !== 1) {
|
|
79
|
+
throw invalidConfigError(filePath, "version must be 1");
|
|
80
|
+
}
|
|
81
|
+
if (typeof config.profile !== "string" ||
|
|
82
|
+
!isMcpPermissionProfile(config.profile)) {
|
|
83
|
+
throw invalidConfigError(filePath, `profile must be one of: ${MCP_PERMISSION_PROFILES.join(", ")}`);
|
|
84
|
+
}
|
|
85
|
+
if (typeof config.updatedAt !== "string" ||
|
|
86
|
+
!Number.isFinite(Date.parse(config.updatedAt))) {
|
|
87
|
+
throw invalidConfigError(filePath, "updatedAt must be an ISO date-time");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
version: 1,
|
|
91
|
+
profile: config.profile,
|
|
92
|
+
updatedAt: config.updatedAt,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function invalidConfigError(filePath, reason) {
|
|
96
|
+
return new Error(`Invalid OpenLog MCP config at ${filePath}: ${reason}. Run \`openlog mcp permissions reset\` to restore the safe-write default.`);
|
|
97
|
+
}
|
|
98
|
+
function hasErrorCode(error, code) {
|
|
99
|
+
return Boolean(error &&
|
|
100
|
+
typeof error === "object" &&
|
|
101
|
+
"code" in error &&
|
|
102
|
+
error.code === code);
|
|
103
|
+
}
|