@eightstate/escli 0.7.1 → 0.8.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/dist/commands/mcp/index.js +23 -0
- package/dist/commands/mcp/register.js +91 -0
- package/dist/commands/mcp/revoke.js +40 -0
- package/dist/commands/mcp/serve.js +75 -0
- package/dist/commands/mcp/status.js +67 -0
- package/dist/commands/notion/comments/add.js +7 -3
- package/dist/commands/notion/comments/reply.js +7 -3
- package/dist/commands/notion/db/create.js +26 -5
- package/dist/commands/notion/ds/create.js +3 -3
- package/dist/commands/notion/page/replace-content.js +1 -4
- package/dist/commands/notion/page/replace-text.js +1 -4
- package/dist/commands/notion/upload/index.js +20 -3
- package/dist/commands/notion/view/create.js +3 -1
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/notion/comments/shared.js +127 -3
- package/dist/lib/notion/page/content-common.js +1 -1
- package/dist/lib/registry.js +36 -0
- package/dist/services/mcp/audit.js +18 -0
- package/dist/services/mcp/cloudflare.js +91 -0
- package/dist/services/mcp/cloudflared.js +59 -0
- package/dist/services/mcp/common.js +65 -0
- package/dist/services/mcp/config.js +131 -0
- package/dist/services/mcp/gate-client.js +86 -0
- package/dist/services/mcp/protocol.js +68 -0
- package/dist/services/mcp/server.js +131 -0
- package/dist/services/mcp/tools.js +228 -0
- package/dist/services/notion.js +55 -3
- package/oclif.manifest.json +607 -1
- package/package.json +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ErrorCode } from '@eightstate/contracts/errors';
|
|
3
|
+
import { ExitCodes } from '@eightstate/contracts/exit-codes';
|
|
4
|
+
import { EscliError } from '../../lib/escli-error.js';
|
|
5
|
+
import { resolveCliToken } from '../credentials.js';
|
|
6
|
+
import { authRequired, gateUrl, parseJsonResponse } from './common.js';
|
|
7
|
+
const McpMachineCreateResponseSchema = z.object({
|
|
8
|
+
machine_id: z.number(),
|
|
9
|
+
machine_secret: z.string(),
|
|
10
|
+
hostname: z.string(),
|
|
11
|
+
resource: z.string(),
|
|
12
|
+
});
|
|
13
|
+
const McpMachineStatusResponseSchema = z.object({
|
|
14
|
+
machine_id: z.number(),
|
|
15
|
+
slug: z.string(),
|
|
16
|
+
hostname: z.string(),
|
|
17
|
+
resource: z.string(),
|
|
18
|
+
port: z.number(),
|
|
19
|
+
label: z.string().nullable(),
|
|
20
|
+
created_at: z.string(),
|
|
21
|
+
revoked_at: z.string().nullable(),
|
|
22
|
+
});
|
|
23
|
+
const IntrospectionResponseSchema = z.discriminatedUnion('active', [
|
|
24
|
+
z.object({ active: z.literal(true), user_id: z.number(), machine_id: z.number(), scopes: z.array(z.string()), expires_at: z.string() }),
|
|
25
|
+
z.object({ active: z.literal(false) }),
|
|
26
|
+
]);
|
|
27
|
+
export class McpGateClient {
|
|
28
|
+
baseUrl;
|
|
29
|
+
constructor(baseUrl = gateUrl()) {
|
|
30
|
+
this.baseUrl = baseUrl;
|
|
31
|
+
}
|
|
32
|
+
async createMachine(input) {
|
|
33
|
+
const data = await this.request('/api/mcp/machines', { method: 'POST', body: input, userAuth: true });
|
|
34
|
+
return McpMachineCreateResponseSchema.parse(data);
|
|
35
|
+
}
|
|
36
|
+
async getMachineStatus(slug) {
|
|
37
|
+
const data = await this.request(`/api/mcp/machines/${encodeURIComponent(slug)}`, { method: 'GET', userAuth: true });
|
|
38
|
+
return McpMachineStatusResponseSchema.parse(data);
|
|
39
|
+
}
|
|
40
|
+
async revokeMachine(slug) {
|
|
41
|
+
const data = await this.request(`/api/mcp/machines/${encodeURIComponent(slug)}/revoke`, { method: 'POST', userAuth: true });
|
|
42
|
+
const parsed = McpMachineStatusResponseSchema.safeParse(data);
|
|
43
|
+
return parsed.success ? parsed.data : data;
|
|
44
|
+
}
|
|
45
|
+
async introspectToken(machineSecret, token, resource) {
|
|
46
|
+
const data = await this.request('/api/mcp/introspect', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
body: { token, resource },
|
|
49
|
+
bearer: machineSecret,
|
|
50
|
+
});
|
|
51
|
+
return IntrospectionResponseSchema.parse(data);
|
|
52
|
+
}
|
|
53
|
+
async request(path, options) {
|
|
54
|
+
const bearer = options.bearer ?? (options.userAuth ? resolveCliToken() : undefined);
|
|
55
|
+
if (!bearer)
|
|
56
|
+
throw authRequired();
|
|
57
|
+
let response;
|
|
58
|
+
try {
|
|
59
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
60
|
+
method: options.method,
|
|
61
|
+
headers: {
|
|
62
|
+
authorization: `Bearer ${bearer}`,
|
|
63
|
+
accept: 'application/json',
|
|
64
|
+
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
65
|
+
},
|
|
66
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
throw new EscliError('gate unavailable', { code: ErrorCode.GateUnavailable, exitCode: ExitCodes.Transient, details: error instanceof Error ? error.message : error });
|
|
71
|
+
}
|
|
72
|
+
const data = await parseJsonResponse(response);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new EscliError(`gate request failed (${response.status})`, {
|
|
75
|
+
code: response.status === 401 ? ErrorCode.AuthInvalid : ErrorCode.GateUnavailable,
|
|
76
|
+
exitCode: response.status === 401 || response.status === 403 ? ExitCodes.Auth : ExitCodes.Transient,
|
|
77
|
+
details: data,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return data;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function createGateClient() {
|
|
84
|
+
return new McpGateClient();
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=gate-client.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export const JSON_RPC_PARSE_ERROR = -32700;
|
|
2
|
+
export const JSON_RPC_INVALID_REQUEST = -32600;
|
|
3
|
+
export const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
4
|
+
export const JSON_RPC_INVALID_PARAMS = -32602;
|
|
5
|
+
export const JSON_RPC_INTERNAL_ERROR = -32603;
|
|
6
|
+
export class JsonRpcProtocolError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
data;
|
|
9
|
+
constructor(code, message, data) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'JsonRpcProtocolError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.data = data;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function handleJsonRpc(raw, handlers) {
|
|
17
|
+
let parsed;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(raw);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return jsonRpcError(null, JSON_RPC_PARSE_ERROR, 'Parse error');
|
|
23
|
+
}
|
|
24
|
+
const request = parseRequest(parsed);
|
|
25
|
+
if (!request.valid)
|
|
26
|
+
return jsonRpcError(request.id, JSON_RPC_INVALID_REQUEST, 'Invalid Request');
|
|
27
|
+
if (!request.value.id && request.value.id !== 0 && request.value.id !== null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const handler = handlers[request.value.method];
|
|
30
|
+
if (!handler)
|
|
31
|
+
return jsonRpcError(request.value.id ?? null, JSON_RPC_METHOD_NOT_FOUND, 'Method not found');
|
|
32
|
+
try {
|
|
33
|
+
const result = await handler(request.value.params, request.value);
|
|
34
|
+
return { jsonrpc: '2.0', id: request.value.id ?? null, result };
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error instanceof JsonRpcProtocolError)
|
|
38
|
+
return jsonRpcError(request.value.id ?? null, error.code, error.message, error.data);
|
|
39
|
+
return jsonRpcError(request.value.id ?? null, JSON_RPC_INTERNAL_ERROR, error instanceof Error ? error.message : 'Internal error');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function invalidParams(message, data) {
|
|
43
|
+
return new JsonRpcProtocolError(JSON_RPC_INVALID_PARAMS, message, data);
|
|
44
|
+
}
|
|
45
|
+
export function jsonRpcError(id, code, message, data) {
|
|
46
|
+
return { jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } };
|
|
47
|
+
}
|
|
48
|
+
function parseRequest(value) {
|
|
49
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
50
|
+
return { valid: false, id: null };
|
|
51
|
+
const record = value;
|
|
52
|
+
const id = parseId(record.id);
|
|
53
|
+
if (record.id !== undefined && id === undefined)
|
|
54
|
+
return { valid: false, id: null };
|
|
55
|
+
if (record.jsonrpc !== '2.0' || typeof record.method !== 'string')
|
|
56
|
+
return { valid: false, id: id ?? null };
|
|
57
|
+
return { valid: true, value: { jsonrpc: '2.0', id, method: record.method, params: record.params } };
|
|
58
|
+
}
|
|
59
|
+
function parseId(value) {
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return undefined;
|
|
62
|
+
if (value === null || typeof value === 'string')
|
|
63
|
+
return value;
|
|
64
|
+
if (typeof value === 'number' && Number.isInteger(value))
|
|
65
|
+
return value;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { McpGateClient } from './gate-client.js';
|
|
4
|
+
import { MCP_AUTH_SERVER } from './common.js';
|
|
5
|
+
import { handleJsonRpc, invalidParams } from './protocol.js';
|
|
6
|
+
import { executeToolCall, toolDefinitions } from './tools.js';
|
|
7
|
+
const MCP_PROTOCOL_VERSION = '2025-06-18';
|
|
8
|
+
const MCP_SCOPES = ['mcp:read', 'mcp:write'];
|
|
9
|
+
export async function startMcpServer(options) {
|
|
10
|
+
const resolvedRoot = await realpath(options.root);
|
|
11
|
+
const gateClient = options.gateClient ?? new McpGateClient();
|
|
12
|
+
const server = createServer((request, response) => {
|
|
13
|
+
void handleRequest(request, response, { ...options, root: resolvedRoot, gateClient });
|
|
14
|
+
});
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
const onError = (error) => {
|
|
17
|
+
server.off('listening', onListening);
|
|
18
|
+
reject(error);
|
|
19
|
+
};
|
|
20
|
+
const onListening = () => {
|
|
21
|
+
server.off('error', onError);
|
|
22
|
+
resolve();
|
|
23
|
+
};
|
|
24
|
+
server.once('error', onError);
|
|
25
|
+
server.once('listening', onListening);
|
|
26
|
+
server.listen(options.port, '127.0.0.1');
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
server,
|
|
30
|
+
url: `http://127.0.0.1:${options.port}/mcp`,
|
|
31
|
+
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async function handleRequest(request, response, options) {
|
|
35
|
+
try {
|
|
36
|
+
const method = request.method ?? 'GET';
|
|
37
|
+
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
|
|
38
|
+
if (method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') {
|
|
39
|
+
writeJson(response, 200, {
|
|
40
|
+
resource: options.config.resource,
|
|
41
|
+
authorization_servers: [process.env.MCP_AUTH_ISSUER ?? MCP_AUTH_SERVER],
|
|
42
|
+
scopes_supported: [...MCP_SCOPES],
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (url.pathname !== '/mcp') {
|
|
47
|
+
writeJson(response, 404, { error: 'not found' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (method === 'GET') {
|
|
51
|
+
response.writeHead(405, { 'allow': 'POST', 'content-type': 'application/json' });
|
|
52
|
+
response.end(JSON.stringify({ error: 'method not allowed' }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (method !== 'POST') {
|
|
56
|
+
response.writeHead(405, { 'allow': 'POST', 'content-type': 'application/json' });
|
|
57
|
+
response.end(JSON.stringify({ error: 'method not allowed' }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const bearer = bearerToken(request);
|
|
61
|
+
if (!bearer) {
|
|
62
|
+
unauthorized(response, options.config.resource);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const body = await readBody(request);
|
|
66
|
+
const rpc = await handleJsonRpc(body, handlers(options, bearer));
|
|
67
|
+
if (!rpc) {
|
|
68
|
+
response.writeHead(204);
|
|
69
|
+
response.end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
writeJson(response, 200, rpc);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
writeJson(response, 500, { error: error instanceof Error ? error.message : 'internal error' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function handlers(options, bearer) {
|
|
79
|
+
return {
|
|
80
|
+
initialize: () => ({
|
|
81
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
82
|
+
capabilities: { tools: {} },
|
|
83
|
+
serverInfo: { name: 'escli-mcp', version: '0.1.0' },
|
|
84
|
+
}),
|
|
85
|
+
'tools/list': () => ({ tools: toolDefinitions }),
|
|
86
|
+
'tools/call': async (params) => {
|
|
87
|
+
const parsed = parseToolCallParams(params);
|
|
88
|
+
return executeToolCall({
|
|
89
|
+
root: options.root,
|
|
90
|
+
config: options.config,
|
|
91
|
+
gateClient: options.gateClient,
|
|
92
|
+
bearerToken: bearer,
|
|
93
|
+
verbose: options.verbose,
|
|
94
|
+
}, parsed.name, parsed.arguments);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function parseToolCallParams(value) {
|
|
99
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
100
|
+
throw invalidParams('tools/call params must be an object');
|
|
101
|
+
const record = value;
|
|
102
|
+
if (typeof record.name !== 'string')
|
|
103
|
+
throw invalidParams('tools/call params.name must be a string');
|
|
104
|
+
return { name: record.name, arguments: record.arguments ?? {} };
|
|
105
|
+
}
|
|
106
|
+
function bearerToken(request) {
|
|
107
|
+
const header = request.headers.authorization;
|
|
108
|
+
if (!header)
|
|
109
|
+
return undefined;
|
|
110
|
+
const match = /^Bearer\s+(.+)$/iu.exec(header);
|
|
111
|
+
return match?.[1];
|
|
112
|
+
}
|
|
113
|
+
function unauthorized(response, resource) {
|
|
114
|
+
const metadata = `${resource}/.well-known/oauth-protected-resource`;
|
|
115
|
+
response.writeHead(401, {
|
|
116
|
+
'content-type': 'application/json',
|
|
117
|
+
'www-authenticate': `Bearer resource_metadata="${metadata}", scope="${MCP_SCOPES.join(' ')}"`,
|
|
118
|
+
});
|
|
119
|
+
response.end(JSON.stringify({ error: 'unauthorized' }));
|
|
120
|
+
}
|
|
121
|
+
async function readBody(request) {
|
|
122
|
+
const chunks = [];
|
|
123
|
+
for await (const chunk of request)
|
|
124
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
125
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
126
|
+
}
|
|
127
|
+
function writeJson(response, status, value) {
|
|
128
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
129
|
+
response.end(JSON.stringify(value));
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { writeAuditRecord } from './audit.js';
|
|
7
|
+
export const BASH_OUTPUT_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const READ_MAX_BYTES = 10 * 1024 * 1024;
|
|
9
|
+
const SAFE_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin';
|
|
10
|
+
export const toolDefinitions = [
|
|
11
|
+
{
|
|
12
|
+
name: 'read',
|
|
13
|
+
description: 'Read a UTF-8 text file under the configured root. Use when you need file contents before editing.',
|
|
14
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'], additionalProperties: false },
|
|
15
|
+
annotations: { readOnlyHint: true },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'write',
|
|
19
|
+
description: 'Create or replace a UTF-8 text file under the configured root. Requires user confirmation in ChatGPT.',
|
|
20
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'], additionalProperties: false },
|
|
21
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'edit',
|
|
25
|
+
description: 'Replace one exact text occurrence in a UTF-8 file under root. Fails unless oldText matches exactly once.',
|
|
26
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'], additionalProperties: false },
|
|
27
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'bash',
|
|
31
|
+
description: 'Run a shell command with cwd clamped under root, empty environment except a safe PATH, timeout, and output cap. Use for tests, builds, git status, and repo discovery.',
|
|
32
|
+
inputSchema: { type: 'object', properties: { command: { type: 'string' }, cwd: { type: 'string' }, timeoutSeconds: { type: 'integer', minimum: 1, maximum: 300 } }, required: ['command'], additionalProperties: false },
|
|
33
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
const ReadArgs = z.object({ path: z.string() }).strict();
|
|
37
|
+
const WriteArgs = z.object({ path: z.string(), content: z.string() }).strict();
|
|
38
|
+
const EditArgs = z.object({ path: z.string(), oldText: z.string(), newText: z.string() }).strict();
|
|
39
|
+
const BashArgs = z.object({ command: z.string(), cwd: z.string().optional(), timeoutSeconds: z.number().int().min(1).max(300).optional() }).strict();
|
|
40
|
+
export async function executeToolCall(context, name, args) {
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
let target;
|
|
43
|
+
let bytes = 0;
|
|
44
|
+
let truncated = false;
|
|
45
|
+
let status = 'error';
|
|
46
|
+
try {
|
|
47
|
+
const active = await context.gateClient.introspectToken(context.config.machine_secret, context.bearerToken, context.config.resource);
|
|
48
|
+
if (!active.active)
|
|
49
|
+
return toolError('unauthorized: inactive token');
|
|
50
|
+
let result;
|
|
51
|
+
switch (name) {
|
|
52
|
+
case 'read': {
|
|
53
|
+
const parsed = ReadArgs.parse(args);
|
|
54
|
+
target = parsed.path;
|
|
55
|
+
result = await readTool(context.root, parsed.path);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
case 'write': {
|
|
59
|
+
const parsed = WriteArgs.parse(args);
|
|
60
|
+
target = parsed.path;
|
|
61
|
+
result = await writeTool(context.root, parsed.path, parsed.content);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'edit': {
|
|
65
|
+
const parsed = EditArgs.parse(args);
|
|
66
|
+
target = parsed.path;
|
|
67
|
+
result = await editTool(context.root, parsed.path, parsed.oldText, parsed.newText);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'bash': {
|
|
71
|
+
const parsed = BashArgs.parse(args);
|
|
72
|
+
const cwd = await resolveCwd(context.root, parsed.cwd);
|
|
73
|
+
target = relative(context.root, cwd) || '.';
|
|
74
|
+
result = await bashTool(context.root, parsed.command, cwd, parsed.timeoutSeconds ?? 60);
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
return toolError(`unknown tool: ${name}`);
|
|
79
|
+
}
|
|
80
|
+
const text = result.content.map((item) => item.text).join('');
|
|
81
|
+
bytes = Buffer.byteLength(text);
|
|
82
|
+
truncated = result.truncated === true;
|
|
83
|
+
status = result.isError ? 'error' : 'ok';
|
|
84
|
+
context.verbose?.(`${name} ${target ?? ''} ${status} ${Date.now() - start}ms ${bytes}B${truncated ? ' truncated' : ''}`.trim());
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const result = toolError(error instanceof Error ? error.message : String(error));
|
|
89
|
+
bytes = Buffer.byteLength(result.content[0]?.text ?? '');
|
|
90
|
+
context.verbose?.(`${name} ${target ?? ''} error ${Date.now() - start}ms ${bytes}B`.trim());
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await writeAuditRecord(context.config.slug, { tool: name, target, status, duration_ms: Date.now() - start, bytes, truncated }).catch(() => { });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function readTool(root, path) {
|
|
98
|
+
const file = await resolveExistingFile(root, path);
|
|
99
|
+
const info = await stat(file);
|
|
100
|
+
if (info.isDirectory())
|
|
101
|
+
return toolError('path is a directory');
|
|
102
|
+
if (info.size > READ_MAX_BYTES)
|
|
103
|
+
return toolError('file exceeds 10MB read limit');
|
|
104
|
+
return toolOk(await readFile(file, 'utf8'));
|
|
105
|
+
}
|
|
106
|
+
async function writeTool(root, path, content) {
|
|
107
|
+
const file = await resolveNewPath(root, path);
|
|
108
|
+
await writeFile(file, content, 'utf8');
|
|
109
|
+
return toolOk(`wrote ${Buffer.byteLength(content, 'utf8')} bytes`);
|
|
110
|
+
}
|
|
111
|
+
async function editTool(root, path, oldText, newText) {
|
|
112
|
+
const file = await resolveExistingFile(root, path);
|
|
113
|
+
const current = await readFile(file, 'utf8');
|
|
114
|
+
const first = current.indexOf(oldText);
|
|
115
|
+
if (first === -1)
|
|
116
|
+
return toolError('oldText did not match');
|
|
117
|
+
if (current.indexOf(oldText, first + oldText.length) !== -1)
|
|
118
|
+
return toolError('oldText matched more than once');
|
|
119
|
+
await writeFile(file, `${current.slice(0, first)}${newText}${current.slice(first + oldText.length)}`, 'utf8');
|
|
120
|
+
return toolOk('edited 1 occurrence');
|
|
121
|
+
}
|
|
122
|
+
async function bashTool(root, command, cwd, timeoutSeconds) {
|
|
123
|
+
const home = await mkdtemp(join(tmpdir(), 'escli-mcp-home-'));
|
|
124
|
+
return new Promise((resolvePromise) => {
|
|
125
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
126
|
+
cwd,
|
|
127
|
+
env: { PATH: SAFE_PATH, HOME: home, PWD: cwd },
|
|
128
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
129
|
+
});
|
|
130
|
+
const chunks = [];
|
|
131
|
+
let collected = 0;
|
|
132
|
+
let omitted = 0;
|
|
133
|
+
let truncated = false;
|
|
134
|
+
let settled = false;
|
|
135
|
+
let timedOut = false;
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
child.kill('SIGTERM');
|
|
139
|
+
setTimeout(() => child.kill('SIGKILL'), 1000).unref();
|
|
140
|
+
}, Math.min(Math.max(timeoutSeconds, 1), 300) * 1000);
|
|
141
|
+
const collect = (chunk) => {
|
|
142
|
+
if (collected < BASH_OUTPUT_LIMIT_BYTES) {
|
|
143
|
+
const remaining = BASH_OUTPUT_LIMIT_BYTES - collected;
|
|
144
|
+
if (chunk.length <= remaining) {
|
|
145
|
+
chunks.push(chunk);
|
|
146
|
+
collected += chunk.length;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
150
|
+
collected += remaining;
|
|
151
|
+
omitted += chunk.length - remaining;
|
|
152
|
+
truncated = true;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
omitted += chunk.length;
|
|
157
|
+
truncated = true;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
child.stdout?.on('data', collect);
|
|
161
|
+
child.stderr?.on('data', collect);
|
|
162
|
+
child.on('error', (error) => {
|
|
163
|
+
if (settled)
|
|
164
|
+
return;
|
|
165
|
+
settled = true;
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
resolvePromise(toolError(error.message));
|
|
168
|
+
});
|
|
169
|
+
child.on('close', (code, signal) => {
|
|
170
|
+
if (settled)
|
|
171
|
+
return;
|
|
172
|
+
settled = true;
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
if (timedOut) {
|
|
175
|
+
resolvePromise(toolError(`command timed out after ${timeoutSeconds} seconds`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let output = Buffer.concat(chunks).toString('utf8');
|
|
179
|
+
if (truncated)
|
|
180
|
+
output += `\n[escli:mcp output truncated at ${BASH_OUTPUT_LIMIT_BYTES} bytes; omitted ${omitted} bytes]\n`;
|
|
181
|
+
const exitCode = code ?? (signal ? 128 : 1);
|
|
182
|
+
resolvePromise({ content: [{ type: 'text', text: output }], isError: exitCode !== 0, truncated });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async function resolveExistingFile(root, input) {
|
|
187
|
+
const candidate = resolveCandidate(root, input);
|
|
188
|
+
const real = await realpath(candidate);
|
|
189
|
+
assertUnderRoot(root, real);
|
|
190
|
+
return real;
|
|
191
|
+
}
|
|
192
|
+
async function resolveNewPath(root, input) {
|
|
193
|
+
const candidate = resolveCandidate(root, input);
|
|
194
|
+
assertUnderRoot(root, candidate);
|
|
195
|
+
const parentReal = await realpath(dirname(candidate));
|
|
196
|
+
assertUnderRoot(root, parentReal);
|
|
197
|
+
return candidate;
|
|
198
|
+
}
|
|
199
|
+
async function resolveCwd(root, input) {
|
|
200
|
+
if (!input)
|
|
201
|
+
return root;
|
|
202
|
+
const candidate = resolveCandidate(root, input);
|
|
203
|
+
const real = await realpath(candidate);
|
|
204
|
+
assertUnderRoot(root, real);
|
|
205
|
+
const info = await stat(real);
|
|
206
|
+
if (!info.isDirectory())
|
|
207
|
+
throw new Error('cwd is not a directory');
|
|
208
|
+
return real;
|
|
209
|
+
}
|
|
210
|
+
function resolveCandidate(root, input) {
|
|
211
|
+
if (input.includes('\0'))
|
|
212
|
+
throw new Error('path contains NUL byte');
|
|
213
|
+
const candidate = isAbsolute(input) ? resolve(input) : resolve(root, input);
|
|
214
|
+
return candidate;
|
|
215
|
+
}
|
|
216
|
+
function assertUnderRoot(root, candidate) {
|
|
217
|
+
const rel = relative(root, candidate);
|
|
218
|
+
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)))
|
|
219
|
+
return;
|
|
220
|
+
throw new Error('path escapes root');
|
|
221
|
+
}
|
|
222
|
+
function toolOk(text) {
|
|
223
|
+
return { content: [{ type: 'text', text }], isError: false };
|
|
224
|
+
}
|
|
225
|
+
function toolError(text) {
|
|
226
|
+
return { content: [{ type: 'text', text }], isError: true };
|
|
227
|
+
}
|
|
228
|
+
//# sourceMappingURL=tools.js.map
|
package/dist/services/notion.js
CHANGED
|
@@ -65,9 +65,10 @@ export function notionFileUploadCreate(body) {
|
|
|
65
65
|
return proxy('POST', '/v1/file_uploads', { body });
|
|
66
66
|
}
|
|
67
67
|
export function notionFileUploadSend(fileUploadId, options) {
|
|
68
|
-
return
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
return gateMultipartRequest('POST', `/v1/file_uploads/${encodeURIComponent(fileUploadId)}/send`, [
|
|
69
|
+
{ name: 'file', filename: options.filename, contentType: options.contentType, value: bytesFromFile(options.file) },
|
|
70
|
+
...(options.partNumber === undefined ? [] : [{ name: 'part_number', value: String(options.partNumber) }]),
|
|
71
|
+
]);
|
|
71
72
|
}
|
|
72
73
|
export function notionFileUploadComplete(fileUploadId) {
|
|
73
74
|
return proxy('POST', `/v1/file_uploads/${encodeURIComponent(fileUploadId)}/complete`);
|
|
@@ -87,6 +88,9 @@ export function notionPagesTrash(pageId, inTrash) {
|
|
|
87
88
|
export function notionUsersMe() {
|
|
88
89
|
return proxy('GET', '/v1/users/me');
|
|
89
90
|
}
|
|
91
|
+
export function notionUsersList(nextCursor, pageSize = 100) {
|
|
92
|
+
return proxy('GET', '/v1/users', { query: withDefined({ start_cursor: nextCursor, page_size: pageSize }) });
|
|
93
|
+
}
|
|
90
94
|
export function notionBlocksGet(blockId, nextCursor) {
|
|
91
95
|
return proxy('GET', `/v1/blocks/${encodeURIComponent(blockId)}/children`, { query: withDefined({ start_cursor: nextCursor }) });
|
|
92
96
|
}
|
|
@@ -99,6 +103,47 @@ export function notionBlocksDelete(blockId) {
|
|
|
99
103
|
export function notionBlocksAppendChildren(blockId, children, position) {
|
|
100
104
|
return proxy('PATCH', `/v1/blocks/${encodeURIComponent(blockId)}/children`, { body: withDefined({ children, position }) });
|
|
101
105
|
}
|
|
106
|
+
async function gateMultipartRequest(method, path, parts) {
|
|
107
|
+
const token = resolveCliToken();
|
|
108
|
+
if (!token) {
|
|
109
|
+
throw new EscliError('not authenticated', {
|
|
110
|
+
code: ErrorCode.AuthRequired,
|
|
111
|
+
exitCode: ExitCodes.Auth,
|
|
112
|
+
remediation: { command: 'escli auth login' },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const form = new FormData();
|
|
116
|
+
for (const part of parts) {
|
|
117
|
+
if (part.value instanceof Uint8Array) {
|
|
118
|
+
form.append(part.name, new File([part.value], part.filename ?? part.name, part.contentType ? { type: part.contentType } : undefined));
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
form.append(part.name, part.value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(new URL('/v1/notion/proxy/multipart', gateUrl()), {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'authorization': `Bearer ${token}`, 'accept': 'application/json', 'x-notion-proxy-method': method, 'x-notion-proxy-path': path },
|
|
130
|
+
body: form,
|
|
131
|
+
signal: controller.signal,
|
|
132
|
+
});
|
|
133
|
+
const payload = await parseJson(response);
|
|
134
|
+
if (!response.ok)
|
|
135
|
+
throw mapGateFailure(response.status, payload);
|
|
136
|
+
return normalizeGatePayload(payload);
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (error instanceof EscliError)
|
|
140
|
+
throw error;
|
|
141
|
+
throw mapNetworkError(error);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
clearTimeout(timeout);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
102
147
|
async function gateRequest(method, path, body) {
|
|
103
148
|
const token = resolveCliToken();
|
|
104
149
|
if (!token) {
|
|
@@ -259,6 +304,13 @@ function extractErrorMessage(body) {
|
|
|
259
304
|
const root = recordValue(body);
|
|
260
305
|
return stringValue(root?.message) ?? stringValue(root?.error) ?? stringValue(recordValue(root?.error)?.message);
|
|
261
306
|
}
|
|
307
|
+
function bytesFromFile(file) {
|
|
308
|
+
if (file instanceof Uint8Array)
|
|
309
|
+
return file;
|
|
310
|
+
if (file instanceof ArrayBuffer)
|
|
311
|
+
return new Uint8Array(file);
|
|
312
|
+
throw new EscliError('unsupported file upload payload', { code: ErrorCode.UsageInvalid, exitCode: ExitCodes.Usage });
|
|
313
|
+
}
|
|
262
314
|
function withDefined(input) {
|
|
263
315
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
264
316
|
}
|