monacloud-mcp 0.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.
@@ -0,0 +1,157 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readLinks, writeLinks } from './auth.js';
3
+ import { CloudError } from './errors.js';
4
+ import { requestJson, unwrapData } from './http.js';
5
+ const asObject = (value) => (value && typeof value === 'object' ? value : {});
6
+ function pickString(objects, keys) {
7
+ for (const object of objects) {
8
+ for (const key of keys) {
9
+ const value = object[key];
10
+ if (typeof value === 'string' && value)
11
+ return value;
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+ export class CloudClients {
17
+ config;
18
+ auth;
19
+ fetchImpl;
20
+ constructor(config, auth, fetchImpl = fetch) {
21
+ this.config = config;
22
+ this.auth = auth;
23
+ this.fetchImpl = fetchImpl;
24
+ }
25
+ async billing(path, options = {}) {
26
+ return requestJson(`${this.config.billingUrl}${path}`, {
27
+ ...options,
28
+ token: await this.auth.accessToken(),
29
+ fetchImpl: this.fetchImpl,
30
+ });
31
+ }
32
+ balance() {
33
+ return this.billing('/v1/balance');
34
+ }
35
+ ledger(cursor, limit = 50) {
36
+ return this.billing('/v1/ledger', { query: { cursor, limit } });
37
+ }
38
+ usage(period, product) {
39
+ return this.billing('/v1/usage', { query: { period, product } });
40
+ }
41
+ topup(amount, idempotencyKey) {
42
+ return this.billing('/v1/topups', {
43
+ method: 'POST',
44
+ headers: { 'Idempotency-Key': idempotencyKey || `mcp-topup-${randomUUID()}` },
45
+ body: { amount_vnd: amount },
46
+ });
47
+ }
48
+ budgetSet(body) {
49
+ return this.billing('/v1/budgets', { method: 'POST', body });
50
+ }
51
+ budgetGet() {
52
+ return this.billing('/v1/budgets');
53
+ }
54
+ async tokenLimit(spendLimitVnd, period, tokenId) {
55
+ const token = await this.auth.accessToken();
56
+ const id = tokenId || this.auth.tokenId(token);
57
+ return requestJson(`${this.config.billingUrl}/v1/tokens/${encodeURIComponent(id)}/limit`, {
58
+ method: 'PUT',
59
+ token,
60
+ body: { spend_limit_vnd: spendLimitVnd, period },
61
+ fetchImpl: this.fetchImpl,
62
+ });
63
+ }
64
+ async spendGuard() {
65
+ const current = asObject(await this.balance());
66
+ const balance = current.balance_vnd;
67
+ if (typeof balance === 'number' && balance <= 0) {
68
+ throw new CloudError('insufficient_funds', 'Ví thiếu tiền: số dư hiện tại là 0 đ.', `Nạp ví tại ${this.config.consoleUrl} rồi gọi lại tool.`);
69
+ }
70
+ return current;
71
+ }
72
+ async vibecloudToken() {
73
+ if (this.auth.env.VIBECLOUD_API_TOKEN)
74
+ return this.auth.env.VIBECLOUD_API_TOKEN;
75
+ const links = await readLinks(this.config);
76
+ if (links.vibecloud?.token
77
+ && (!links.vibecloud.expires_at || links.vibecloud.expires_at > Date.now() + 30_000)) {
78
+ return links.vibecloud.token;
79
+ }
80
+ return this.auth.accessToken();
81
+ }
82
+ async vibecloud(path, options = {}) {
83
+ return requestJson(`${this.config.vibecloudApi}${path}`, {
84
+ ...options,
85
+ token: await this.vibecloudToken(),
86
+ fetchImpl: this.fetchImpl,
87
+ });
88
+ }
89
+ async vibecloudLink() {
90
+ if (this.auth.env.VIBECLOUD_API_TOKEN) {
91
+ return { linked: true, product: 'vibecloud', mode: 'legacy_env', next_step: 'Dùng các tool vibecloud_*.' };
92
+ }
93
+ const monaIdToken = await this.auth.accessToken();
94
+ try {
95
+ await requestJson(`${this.config.vibecloudApi}/api/services`, {
96
+ token: monaIdToken,
97
+ fetchImpl: this.fetchImpl,
98
+ });
99
+ return {
100
+ linked: true,
101
+ product: 'vibecloud',
102
+ mode: 'direct_mona_id',
103
+ next_step: 'VibeCloud đã nhận MONA ID trực tiếp; không cần cache vc_live token.',
104
+ };
105
+ }
106
+ catch (error) {
107
+ if (!(error instanceof CloudError) || ![401, 403].includes(error.status || 0))
108
+ throw error;
109
+ }
110
+ const value = await requestJson(`${this.config.vibecloudApi}${this.config.vibecloudLinkPath}`, {
111
+ method: 'POST',
112
+ token: monaIdToken,
113
+ body: { source: 'monacloud-mcp' },
114
+ fetchImpl: this.fetchImpl,
115
+ });
116
+ const root = asObject(value);
117
+ const data = asObject(unwrapData(value));
118
+ const token = pickString([data, root], ['api_token', 'access_token', 'token', 'vibecloud_token']);
119
+ if (!token) {
120
+ throw new CloudError('invalid_link_response', 'VibeCloud không trả automation token sau khi liên kết.', 'Kiểm tra endpoint chuyển tiếp MONA ID của VibeCloud rồi gọi lại vibecloud_link.');
121
+ }
122
+ const expiresIn = Number(data.expires_in ?? root.expires_in);
123
+ const links = await readLinks(this.config);
124
+ links.vibecloud = {
125
+ token,
126
+ ...(Number.isFinite(expiresIn) ? { expires_at: Date.now() + expiresIn * 1000 } : {}),
127
+ linked_at: new Date().toISOString(),
128
+ };
129
+ await writeLinks(this.config, links);
130
+ return { linked: true, product: 'vibecloud', next_step: 'Dùng các tool vibecloud_* bằng cùng MONA ID.' };
131
+ }
132
+ async monapayLink() {
133
+ const value = await requestJson(`${this.config.monapayApi}${this.config.monapayLinkPath}`, {
134
+ method: 'POST',
135
+ token: await this.auth.accessToken(),
136
+ body: { source: 'monacloud-mcp' },
137
+ fetchImpl: this.fetchImpl,
138
+ });
139
+ const root = asObject(value);
140
+ const data = asObject(unwrapData(value));
141
+ const clientId = pickString([data, root], ['client_id', 'clientId']);
142
+ const clientSecret = pickString([data, root], ['client_secret', 'clientSecret']);
143
+ if (!clientId || !clientSecret) {
144
+ throw new CloudError('invalid_link_response', 'MONA Pay chưa trả đủ client_id/client_secret sau khi liên kết.', 'Nếu tài khoản cũ cần OTP liên kết, hoàn tất OTP theo hướng dẫn MONA Pay rồi gọi lại monapay_link.');
145
+ }
146
+ const links = await readLinks(this.config);
147
+ links.monapay = { client_id: clientId, client_secret: clientSecret, linked_at: new Date().toISOString() };
148
+ await writeLinks(this.config, links);
149
+ return { linked: true, product: 'monapay', next_step: 'Dùng các tool monapay_* bằng cùng MONA ID.' };
150
+ }
151
+ prices() {
152
+ return requestJson(`${this.config.vibecloudApi}/api/prices`, { fetchImpl: this.fetchImpl });
153
+ }
154
+ packages() {
155
+ return requestJson(`${this.config.vibecloudApi}/api/packages`, { fetchImpl: this.fetchImpl });
156
+ }
157
+ }
@@ -0,0 +1,17 @@
1
+ export type Config = {
2
+ issuer: string;
3
+ billingUrl: string;
4
+ monapayApi: string;
5
+ vibecloudApi: string;
6
+ consoleUrl: string;
7
+ clientId: string;
8
+ scope: string;
9
+ configDir: string;
10
+ tokenFile: string;
11
+ linksFile: string;
12
+ templatesDir: string;
13
+ templatesUrl?: string;
14
+ monapayLinkPath: string;
15
+ vibecloudLinkPath: string;
16
+ };
17
+ export declare function readConfig(env?: NodeJS.ProcessEnv): Config;
package/dist/config.js ADDED
@@ -0,0 +1,25 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ const cleanBaseUrl = (value) => value.replace(/\/+$/, '');
4
+ export function readConfig(env = process.env) {
5
+ const configDir = env.MONACLOUD_CONFIG_DIR
6
+ || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'monacloud');
7
+ return {
8
+ issuer: cleanBaseUrl(env.MONACLOUD_ISSUER || 'https://id.monacloud.vn/realms/mona'),
9
+ billingUrl: cleanBaseUrl(env.MONACLOUD_BILLING_URL || 'https://billing.monacloud.vn'),
10
+ monapayApi: cleanBaseUrl(env.MONAPAY_API || env.MONAPAY_BASE_URL || 'https://api.monapay.vn'),
11
+ vibecloudApi: cleanBaseUrl(env.VIBECLOUD_API || env.VIBECLOUD_API_URL || 'https://api.vibecloud.vn'),
12
+ consoleUrl: cleanBaseUrl(env.MONACLOUD_CONSOLE_URL || 'https://monacloud.vn/console'),
13
+ clientId: env.MONACLOUD_CLIENT_ID || 'monacloud-mcp',
14
+ scope: env.MONACLOUD_SCOPE || 'openid profile email product billing-api offline_access',
15
+ configDir,
16
+ tokenFile: join(configDir, 'token.json'),
17
+ linksFile: join(configDir, 'links.json'),
18
+ templatesDir: env.MONACLOUD_TEMPLATES_DIR || join(homedir(), 'monacloud', 'templates'),
19
+ templatesUrl: env.MONACLOUD_TEMPLATES_URL
20
+ ? cleanBaseUrl(env.MONACLOUD_TEMPLATES_URL)
21
+ : undefined,
22
+ monapayLinkPath: env.MONAPAY_LINK_PATH || '/api/v1/client/oauth/mona-id/link',
23
+ vibecloudLinkPath: env.VIBECLOUD_LINK_PATH || '/api/auth/monaid/link',
24
+ };
25
+ }
@@ -0,0 +1,38 @@
1
+ export type AgentErrorPayload = {
2
+ code: string;
3
+ message: string;
4
+ next_step: string;
5
+ request_id?: string;
6
+ };
7
+ export declare class CloudError extends Error {
8
+ readonly code: string;
9
+ readonly nextStep: string;
10
+ readonly status?: number;
11
+ readonly details?: unknown;
12
+ readonly requestId?: string;
13
+ constructor(code: string, message: string, nextStep: string, options?: {
14
+ status?: number;
15
+ details?: unknown;
16
+ requestId?: string;
17
+ });
18
+ }
19
+ export declare function toAgentError(error: unknown): AgentErrorPayload;
20
+ export declare const textResult: (value: unknown) => {
21
+ content: {
22
+ type: 'text';
23
+ text: string;
24
+ }[];
25
+ };
26
+ export declare const errorResult: (error: unknown) => {
27
+ isError: boolean;
28
+ content: {
29
+ type: 'text';
30
+ text: string;
31
+ }[];
32
+ };
33
+ export declare function runTool(fn: () => Promise<unknown> | unknown): Promise<{
34
+ content: {
35
+ type: 'text';
36
+ text: string;
37
+ }[];
38
+ }>;
package/dist/errors.js ADDED
@@ -0,0 +1,51 @@
1
+ export class CloudError extends Error {
2
+ code;
3
+ nextStep;
4
+ status;
5
+ details;
6
+ requestId;
7
+ constructor(code, message, nextStep, options = {}) {
8
+ super(message);
9
+ this.name = 'CloudError';
10
+ this.code = code;
11
+ this.nextStep = nextStep;
12
+ this.status = options.status;
13
+ this.details = options.details;
14
+ this.requestId = options.requestId;
15
+ }
16
+ }
17
+ function safeMessage(error) {
18
+ if (error instanceof Error)
19
+ return error.message;
20
+ return typeof error === 'string' ? error : 'Lỗi không xác định';
21
+ }
22
+ export function toAgentError(error) {
23
+ if (error instanceof CloudError) {
24
+ return {
25
+ code: error.code,
26
+ message: error.message,
27
+ next_step: error.nextStep,
28
+ ...(error.requestId ? { request_id: error.requestId } : {}),
29
+ };
30
+ }
31
+ return {
32
+ code: 'internal_error',
33
+ message: safeMessage(error),
34
+ next_step: 'Thử lại. Nếu lỗi lặp lại, kiểm tra cấu hình MCP và trạng thái tại monacloud://status.',
35
+ };
36
+ }
37
+ export const textResult = (value) => ({
38
+ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
39
+ });
40
+ export const errorResult = (error) => ({
41
+ isError: true,
42
+ content: [{ type: 'text', text: JSON.stringify(toAgentError(error), null, 2) }],
43
+ });
44
+ export async function runTool(fn) {
45
+ try {
46
+ return textResult(await fn());
47
+ }
48
+ catch (error) {
49
+ return errorResult(error);
50
+ }
51
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type HttpOptions = {
2
+ method?: string;
3
+ token?: string;
4
+ body?: unknown;
5
+ query?: Record<string, string | number | boolean | undefined>;
6
+ headers?: Record<string, string>;
7
+ fetchImpl?: typeof fetch;
8
+ timeoutMs?: number;
9
+ };
10
+ export declare function requestJson<T = unknown>(url: string, options?: HttpOptions): Promise<T>;
11
+ export declare function unwrapData<T = unknown>(value: unknown): T;
package/dist/http.js ADDED
@@ -0,0 +1,76 @@
1
+ import { CloudError } from './errors.js';
2
+ const asRecord = (value) => (value && typeof value === 'object' ? value : {});
3
+ const nestedString = (body, key) => {
4
+ const value = body[key];
5
+ if (typeof value === 'string' && value)
6
+ return value;
7
+ const detail = asRecord(body.detail);
8
+ const nested = detail[key];
9
+ return typeof nested === 'string' && nested ? nested : undefined;
10
+ };
11
+ function apiError(response, body) {
12
+ const parsed = asRecord(body);
13
+ const requestId = response.headers.get('x-request-id')
14
+ || nestedString(parsed, 'request_id');
15
+ const detail = parsed.detail;
16
+ const code = nestedString(parsed, 'code')
17
+ || (typeof detail === 'string' && /^[a-z0-9_]+$/i.test(detail) ? detail : undefined)
18
+ || (response.status === 401 ? 'auth_required' : `http_${response.status}`);
19
+ const rawMessage = nestedString(parsed, 'message')
20
+ || (typeof detail === 'string' ? detail : undefined)
21
+ || `HTTP ${response.status}`;
22
+ if (response.status === 402 || code === 'budget_exceeded' || code === 'insufficient_funds') {
23
+ const shortage = parsed.shortage_vnd ?? parsed.missing_vnd ?? asRecord(detail).shortage_vnd;
24
+ const missing = typeof shortage === 'number' ? `${shortage.toLocaleString('vi-VN')} đ` : 'tiền';
25
+ return new CloudError(code === 'budget_exceeded' ? 'budget_exceeded' : 'insufficient_funds', `Ví thiếu ${missing} hoặc đã chạm giới hạn chi tiêu. ${rawMessage}`, 'Nạp ví hoặc tăng ngân sách tại https://monacloud.vn/console rồi gọi lại tool.', { status: response.status, details: body, requestId });
26
+ }
27
+ if (response.status === 401 || response.status === 403) {
28
+ return new CloudError(code, `Token không có quyền hoặc đã hết hạn. ${rawMessage}`, 'Chạy `monacloud-mcp login` rồi thử lại; kiểm tra audience/scope nếu lỗi vẫn còn.', { status: response.status, details: body, requestId });
29
+ }
30
+ return new CloudError(code, rawMessage, response.status >= 500
31
+ ? 'Kiểm tra monacloud://status rồi thử lại.'
32
+ : 'Kiểm tra tham số theo mô tả tool rồi gọi lại.', { status: response.status, details: body, requestId });
33
+ }
34
+ export async function requestJson(url, options = {}) {
35
+ const parsedUrl = new URL(url);
36
+ for (const [key, value] of Object.entries(options.query || {})) {
37
+ if (value !== undefined && value !== '')
38
+ parsedUrl.searchParams.set(key, String(value));
39
+ }
40
+ const headers = {
41
+ Accept: 'application/json',
42
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
43
+ ...options.headers,
44
+ };
45
+ if (options.body !== undefined)
46
+ headers['Content-Type'] = 'application/json';
47
+ let response;
48
+ try {
49
+ response = await (options.fetchImpl || fetch)(parsedUrl, {
50
+ method: options.method || 'GET',
51
+ headers,
52
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
53
+ ...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
54
+ });
55
+ }
56
+ catch (error) {
57
+ throw new CloudError('network_error', `Không kết nối được ${parsedUrl.origin}: ${error instanceof Error ? error.message : String(error)}`, 'Kiểm tra mạng, URL cấu hình và monacloud://status rồi thử lại.');
58
+ }
59
+ const text = await response.text();
60
+ let body = {};
61
+ if (text) {
62
+ try {
63
+ body = JSON.parse(text);
64
+ }
65
+ catch {
66
+ body = { message: text.slice(0, 1000) };
67
+ }
68
+ }
69
+ if (!response.ok)
70
+ throw apiError(response, body);
71
+ return body;
72
+ }
73
+ export function unwrapData(value) {
74
+ const body = asRecord(value);
75
+ return (body.data === undefined ? value : body.data);
76
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { AuthManager } from './auth.js';
4
+ import { readConfig } from './config.js';
5
+ import { toAgentError } from './errors.js';
6
+ import { createServer } from './server.js';
7
+ const command = process.argv[2];
8
+ const config = readConfig();
9
+ const auth = new AuthManager(config);
10
+ async function main() {
11
+ if (command === 'login') {
12
+ await auth.login();
13
+ return;
14
+ }
15
+ if (command === 'logout') {
16
+ const removed = await auth.logout();
17
+ console.log(removed ? 'Đã đăng xuất MONA Cloud.' : 'Máy chưa có token MONA Cloud.');
18
+ return;
19
+ }
20
+ if (command === 'whoami') {
21
+ console.log(JSON.stringify(await auth.userinfo(), null, 2));
22
+ return;
23
+ }
24
+ if (command === '--version' || command === '-v') {
25
+ console.log('0.1.0');
26
+ return;
27
+ }
28
+ if (command === '--help' || command === '-h' || command === 'help') {
29
+ console.log('Usage: monacloud-mcp [login|logout|whoami|--version]\nKhông có command: chạy MCP server qua stdio.');
30
+ return;
31
+ }
32
+ if (command) {
33
+ throw new Error(`Command không hỗ trợ: ${command}`);
34
+ }
35
+ await createServer().connect(new StdioServerTransport());
36
+ }
37
+ main().catch((error) => {
38
+ console.error(JSON.stringify(toAgentError(error)));
39
+ process.exitCode = 1;
40
+ });
@@ -0,0 +1,16 @@
1
+ import { MonaPayClient } from 'monapay-mcp/dist/client.js';
2
+ import type { Config } from './config.js';
3
+ type ImportedTool = {
4
+ title?: string;
5
+ description?: string;
6
+ inputSchema?: unknown;
7
+ outputSchema?: unknown;
8
+ annotations?: unknown;
9
+ _meta?: Record<string, unknown>;
10
+ handler: (...args: unknown[]) => unknown;
11
+ enabled: boolean;
12
+ };
13
+ export declare function createMonaPayClient(config: Config, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): MonaPayClient;
14
+ export declare function importedMonaPayTools(config: Config, env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Record<string, ImportedTool>;
15
+ export declare function normalizeImportedResult(result: unknown): Promise<unknown>;
16
+ export {};
@@ -0,0 +1,49 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { createServer as createMonaPayServer } from 'monapay-mcp';
3
+ import { MonaPayClient } from 'monapay-mcp/dist/client.js';
4
+ import { CloudError, errorResult } from './errors.js';
5
+ function linkedCredentials(config) {
6
+ try {
7
+ const parsed = JSON.parse(readFileSync(config.linksFile, 'utf8'));
8
+ if (parsed.monapay?.client_id && parsed.monapay.client_secret) {
9
+ return { client_id: parsed.monapay.client_id, client_secret: parsed.monapay.client_secret };
10
+ }
11
+ }
12
+ catch (error) {
13
+ if (error.code !== 'ENOENT') {
14
+ throw new CloudError('invalid_link_store', 'Không đọc được credential MONA Pay đã liên kết.', 'Xoá links.json trong thư mục cấu hình rồi gọi monapay_link lại.');
15
+ }
16
+ }
17
+ return undefined;
18
+ }
19
+ export function createMonaPayClient(config, env = process.env, fetchImpl = fetch) {
20
+ const clientId = env.MONAPAY_CLIENT_ID;
21
+ const clientSecret = env.MONAPAY_CLIENT_SECRET;
22
+ if (clientId && clientSecret) {
23
+ return new MonaPayClient({ clientId, clientSecret, baseUrl: config.monapayApi, fetchImpl });
24
+ }
25
+ const linked = linkedCredentials(config);
26
+ if (linked) {
27
+ return new MonaPayClient({
28
+ clientId: linked.client_id,
29
+ clientSecret: linked.client_secret,
30
+ baseUrl: config.monapayApi,
31
+ fetchImpl,
32
+ });
33
+ }
34
+ throw new CloudError('monapay_link_required', 'MONA Pay đang ở giai đoạn chuyển tiếp và chưa được liên kết với MONA ID này.', 'Gọi monapay_link một lần; nếu hệ thống yêu cầu OTP thì hoàn tất OTP rồi thử lại.');
35
+ }
36
+ export function importedMonaPayTools(config, env = process.env, fetchImpl = fetch) {
37
+ const imported = createMonaPayServer(() => createMonaPayClient(config, env, fetchImpl));
38
+ return imported._registeredTools;
39
+ }
40
+ export async function normalizeImportedResult(result) {
41
+ const value = await result;
42
+ if (!value || typeof value !== 'object')
43
+ return value;
44
+ const response = value;
45
+ if (!response.isError)
46
+ return response;
47
+ const message = response.content?.find((item) => item.type === 'text')?.text || 'MONA Pay trả lỗi.';
48
+ return errorResult(new CloudError('monapay_error', message, 'Kiểm tra tham số và trạng thái liên kết MONA Pay; gọi monapay_link nếu credential chuyển tiếp đã hết hiệu lực.'));
49
+ }
@@ -0,0 +1,8 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Config } from './config.js';
3
+ export type ServerDependencies = {
4
+ config?: Config;
5
+ env?: NodeJS.ProcessEnv;
6
+ fetchImpl?: typeof fetch;
7
+ };
8
+ export declare function createServer(dependencies?: ServerDependencies): McpServer;