@pay2s/mcp 0.4.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 ADDED
@@ -0,0 +1,76 @@
1
+ # Pay2S MCP
2
+
3
+ Kết nối Codex, Cursor và ứng dụng hỗ trợ MCP stdio với tài khoản Pay2S. Cần Node.js 22 trở lên trên máy chạy AI và máy chủ Pay2S MCP 0.4 đã đăng ký client `pay2s-cli`.
4
+
5
+ ## Đăng nhập
6
+
7
+ Sau khi gói được xuất bản lên npm:
8
+
9
+ ```sh
10
+ npx -y @pay2s/mcp login --profile codex
11
+ ```
12
+
13
+ Trình duyệt mở Pay2S. Đăng nhập, chọn quyền và ngân hàng rồi xác nhận. Mỗi ứng dụng dùng một profile riêng: `codex`, `cursor`, `claude`. Tắt phiên AI đang dùng profile trước khi đăng nhập lại profile đó. Tài khoản cần nằm trong danh sách thử nghiệm do Pay2S mở.
14
+
15
+ ## Codex
16
+
17
+ Trong `~/.codex/config.toml` (Windows: `%USERPROFILE%\.codex\config.toml`), thay riêng mục Pay2S cũ bằng:
18
+
19
+ ```toml
20
+ [mcp_servers.pay2s]
21
+ command = "npx"
22
+ args = ["-y", "@pay2s/mcp", "--profile", "codex"]
23
+ startup_timeout_sec = 180
24
+ tool_timeout_sec = 60
25
+ ```
26
+
27
+ Giữ các cấu hình khác. Bỏ `url`, `scopes` và mục `[mcp_servers.pay2s.oauth]` cũ của cùng kết nối; gói npm tự xử lý đăng nhập. Trên Windows nếu ứng dụng không tìm được npx, dùng đường dẫn đầy đủ đến `npx.cmd` từ `Get-Command npx.cmd`.
28
+
29
+ Tham khảo cấu hình stdio: https://learn.chatgpt.com/docs/extend/mcp?surface=cli
30
+
31
+ ## Cursor / Claude Desktop
32
+
33
+ Đăng nhập trước bằng `npx -y @pay2s/mcp login --profile cursor` hoặc `--profile claude`. Thêm vào cấu hình MCP của ứng dụng, giữ các kết nối khác:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "pay2s": {
39
+ "command": "npx",
40
+ "args": ["-y", "@pay2s/mcp", "--profile", "cursor"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ Đổi `cursor` thành `claude` khi dùng Claude Desktop. Khởi động lại ứng dụng rồi hỏi trong khung chat: “Kiểm tra tài khoản và quyền kết nối Pay2S của tôi”. Các mẫu này dùng giao thức MCP stdio; cần kiểm tra trên phiên bản ứng dụng thực tế của bạn.
47
+
48
+ ## Chức năng
49
+
50
+ - Xem ngân hàng, giao dịch, thu chi, đơn và trạng thái thanh toán trong phạm vi được cấp.
51
+ - Tạo link thanh toán khi được cấp quyền riêng.
52
+ - Đề nghị thêm webhook; đề nghị bật/tắt ngân hàng hoặc webhook. AI trả đường dẫn để chủ tài khoản xem và xác nhận trên Pay2S. Gửi đề nghị chưa làm thay đổi trạng thái.
53
+ - `--docs`: chỉ đọc tài liệu công khai, không đăng nhập.
54
+
55
+ Kết nối cũ cần đăng nhập lại để chọn quyền mới. Ngân hàng đang tắt vẫn xuất hiện để đề nghị bật lại; truy vấn giao dịch vẫn cần ngân hàng hoạt động. Bật webhook không gửi lại giao dịch cũ.
56
+
57
+ ## Phiên đăng nhập
58
+
59
+ Thông tin phiên được lưu trong thư mục người dùng `~/.pay2s-mcp/<profile>/`, Unix quyền 0700/0600, Windows giới hạn ACL cho người dùng hiện tại và SYSTEM. Không đưa thư mục này vào dự án hoặc gửi cho người khác. Gói không đọc mã nguồn dự án hoặc mật khẩu đăng nhập Pay2S.
60
+
61
+ ```sh
62
+ npx -y @pay2s/mcp logout --profile codex
63
+ ```
64
+
65
+ Lệnh trên xóa phiên tại máy này. Thu hồi kết nối trên https://pay2s.vn/client/ai-connections để chặn quyền truy cập phía máy chủ. Dữ liệu đã gửi đến AI không bị xóa khi thu hồi.
66
+
67
+ Nếu profile đang được sử dụng, đóng phiên AI đó hoặc chọn profile khác. Sau khi tiến trình bị đóng cưỡng bức, chỉ xóa `process.lock` trong profile khi chắc chắn không còn phiên dùng profile đó. Nếu cổng 14572 bận, hoàn tất lần đăng nhập trước rồi thử lại. Không tự đổi cổng vì callback đã đăng ký cố định.
68
+
69
+ ## Chạy bản đóng gói trước khi xuất bản
70
+
71
+ ```powershell
72
+ npm install -g "C:\laragon\www\FEP2S\artifacts\pay2s-mcp-0.4.0.tgz"
73
+ pay2s-mcp login --profile codex
74
+ ```
75
+
76
+ Cấu hình ứng dụng lúc thử bản này dùng `command = "pay2s-mcp"`, `args = ["--profile", "codex"]`. Không chạy `npx @pay2s/mcp` trước khi package đã xuất bản.
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { openStore } from '../src/store.mjs';
3
+ import { Provider } from '../src/auth.mjs';
4
+ import { proxy } from '../src/proxy.mjs';
5
+ const args = process.argv.slice(2);
6
+ const command = ['login','logout'].includes(args[0]) ? args.shift() : 'serve';
7
+ let profile = 'default', docs = false;
8
+ for (let i=0; i<args.length; i++) {
9
+ if (args[i] === '--profile') profile = args[++i];
10
+ else if (args[i] === '--docs') docs = true;
11
+ else if (['--help','-h'].includes(args[i])) { process.stdout.write('Pay2S MCP\n npx -y @pay2s/mcp login --profile codex\n npx -y @pay2s/mcp --profile codex\n npx -y @pay2s/mcp --docs\n npx -y @pay2s/mcp logout --profile codex\nNode.js 22+. Each running AI app needs its own profile.\n'); process.exit(0); }
12
+ else { process.stderr.write('Unknown option. Use --help.\n'); process.exit(1); }
13
+ }
14
+ let store, provider, connection, closing = false;
15
+ async function close() { if (closing) return; closing = true; provider?.close(); await connection?.close().catch(() => {}); await store?.close().catch(() => {}); }
16
+ for (const signal of ['SIGINT','SIGTERM']) process.once(signal, () => { close().finally(() => process.exit(0)); });
17
+ try {
18
+ if (!docs) {
19
+ store = await openStore(profile);
20
+ if (command === 'logout') {
21
+ await store.save(undefined);
22
+ process.stderr.write('Local credentials removed. Revoke the connection at https://pay2s.vn/client/ai-connections to end server access.\n');
23
+ } else {
24
+ provider = new Provider(store);
25
+ await provider.login(command === 'login');
26
+ if (command === 'login') process.stderr.write('Pay2S connected. Start your AI app using the same --profile.\n');
27
+ }
28
+ }
29
+ if (command === 'serve') { connection = await proxy(provider, docs); process.stdin.once('end', () => close()); }
30
+ else await close();
31
+ } catch (error) {
32
+ // SDK/HTTP exceptions may contain response bodies; never print them or credentials.
33
+ const safe = error.message?.startsWith('Profile ') ? error.message : error.code === 'EADDRINUSE' ? 'Port 14572 is busy. Finish the other Pay2S login and retry.' : 'Cannot connect to Pay2S. Check Node 22+, network, deployed MCP 0.4 and account access. Run login with the same --profile again.';
34
+ process.stderr.write(`${safe}\n`); await close(); process.exitCode = 1;
35
+ }
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@pay2s/mcp",
3
+ "version": "0.4.0",
4
+ "description": "Connect AI tools to Pay2S through OAuth and MCP",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "bin": { "pay2s-mcp": "bin/pay2s-mcp.mjs" },
8
+ "files": ["bin/", "src/", "README.md"],
9
+ "engines": { "node": ">=22" },
10
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
11
+ "scripts": { "test": "node --test test/*.test.mjs" },
12
+ "dependencies": { "@modelcontextprotocol/sdk": "1.31.0" }
13
+ }
package/src/auth.mjs ADDED
@@ -0,0 +1,79 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { execFile } from 'node:child_process';
4
+ import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
5
+ export const ISSUER = 'https://mcp.pay2s.vn';
6
+ export const RESOURCE = `${ISSUER}/mcp`;
7
+ export const REDIRECT = 'http://127.0.0.1:14572/callback';
8
+ export const SCOPES = 'accounts:read transactions:read payments:read payments:create webhooks:read webhooks:request banks:manage webhooks:manage';
9
+ export async function trustedFetch(input, init = {}) {
10
+ const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
11
+ if (url.origin !== ISSUER || url.username || url.password) throw Error('Unexpected Pay2S server');
12
+ const signal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(45000)]) : AbortSignal.timeout(45000);
13
+ return fetch(input, { ...init, redirect: 'error', signal });
14
+ }
15
+ export function callbackHandler(state, accept) {
16
+ let used = false;
17
+ return (req, res) => {
18
+ res.setHeader('Cache-Control', 'no-store');
19
+ res.setHeader('Referrer-Policy', 'no-referrer');
20
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
21
+ res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
22
+ const url = new URL(req.url, REDIRECT);
23
+ const received = url.searchParams.get('state') || '';
24
+ const valid = Buffer.byteLength(received) === Buffer.byteLength(state) && timingSafeEqual(Buffer.from(received), Buffer.from(state));
25
+ const duplicate = [...url.searchParams.keys()].some(k => url.searchParams.getAll(k).length !== 1);
26
+ if (used || req.method !== 'GET' || req.headers.host !== '127.0.0.1:14572' || url.pathname !== '/callback' || duplicate || !valid || url.searchParams.get('iss') !== ISSUER) {
27
+ res.writeHead(400); res.end('Invalid or expired callback.'); return;
28
+ }
29
+ const code = url.searchParams.get('code');
30
+ if (!code || !/^[a-f0-9]{64}$/.test(code) || url.searchParams.has('error')) {
31
+ used = true; res.writeHead(400); res.end('Connection was not approved. Return to your terminal.'); accept(null); return;
32
+ }
33
+ used = true; res.end('Authorization received. Return to your AI app or terminal to see the connection result.'); accept(code);
34
+ };
35
+ }
36
+ function openBrowser(url) {
37
+ // Fixed HTTPS origin and URL serialization; no shell command interpolation.
38
+ const target = new URL(url);
39
+ if (target.origin !== ISSUER || target.pathname !== '/oauth/authorize') throw Error('Unexpected authorization URL');
40
+ const command = process.platform === 'win32' ? 'rundll32.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
41
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', target.href] : [target.href];
42
+ return new Promise(resolve => execFile(command, args, { windowsHide: true }, () => resolve()));
43
+ }
44
+ export class Provider {
45
+ constructor(store, { fetchFn = trustedFetch, browser = openBrowser } = {}) { this.store = store; this.fetch = fetchFn; this.browser = browser; }
46
+ get redirectUrl() { return REDIRECT; }
47
+ get clientMetadata() { return { client_name: 'Pay2S MCP CLI', redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none', grant_types: ['authorization_code','refresh_token'], response_types: ['code'], scope: SCOPES }; }
48
+ clientInformation() { return { client_id: 'pay2s-cli', issuer: ISSUER }; }
49
+ tokens() { const t = this.store.read()?.tokens; return t?.issuer === ISSUER ? t : undefined; }
50
+ async saveTokens(tokens) { await this.store.save({ tokens, expires_at: Date.now() + Math.max(0, Number(tokens.expires_in || 0)) * 1000 }); }
51
+ async invalidateCredentials(scope) { if (['all','tokens'].includes(scope)) await this.store.save(undefined); if (['all','verifier'].includes(scope)) this.verifier = undefined; }
52
+ saveCodeVerifier(value) { this.verifier = value; }
53
+ codeVerifier() { if (!this.verifier) throw Error('Missing code verifier'); return this.verifier; }
54
+ validateResourceURL(_url, resource) { if (resource !== RESOURCE) throw Error('Unexpected resource'); return Promise.resolve(new URL(RESOURCE)); }
55
+ state() { this.nonce = randomBytes(32).toString('hex'); return this.nonce; }
56
+ async redirectToAuthorization(url) {
57
+ if (!this.interactive) throw Error('Login required. Run npx -y @pay2s/mcp login with the same --profile.');
58
+ this.callback = new Promise(resolve => { this.receive = resolve; });
59
+ this.server = createServer(callbackHandler(this.nonce, this.receive));
60
+ await new Promise((resolve, reject) => { this.server.once('error', reject); this.server.listen(14572, '127.0.0.1', resolve); });
61
+ this.timer = setTimeout(() => this.receive(null), 180000);
62
+ process.stderr.write(`Open Pay2S to authorize this connection:\n${url.href}\n`);
63
+ await this.browser(url);
64
+ }
65
+ async login(force = false) {
66
+ this.interactive = true;
67
+ try {
68
+ if (force) await this.invalidateCredentials('tokens');
69
+ if (!force && this.tokens() && this.store.read().expires_at > Date.now() + 30000) return;
70
+ const result = await auth(this, { serverUrl: RESOURCE, scope: SCOPES, fetchFn: this.fetch });
71
+ if (result === 'REDIRECT') {
72
+ const code = await this.callback;
73
+ if (!code) throw Error('Login cancelled or timed out');
74
+ if (await auth(this, { serverUrl: RESOURCE, authorizationCode: code, scope: SCOPES, fetchFn: this.fetch }) !== 'AUTHORIZED') throw Error('Login failed');
75
+ }
76
+ } finally { this.interactive = false; this.verifier = undefined; clearTimeout(this.timer); this.server?.close(); this.server?.closeAllConnections(); }
77
+ }
78
+ close() { clearTimeout(this.timer); this.receive?.(null); this.server?.close(); this.server?.closeAllConnections(); }
79
+ }
package/src/proxy.mjs ADDED
@@ -0,0 +1,22 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { ListToolsRequestSchema, CallToolRequestSchema, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
6
+ import { ISSUER, trustedFetch } from './auth.mjs';
7
+ export async function proxy(provider, docs = false) {
8
+ const transport = new StreamableHTTPClientTransport(new URL(`${ISSUER}/${docs ? 'docs' : 'mcp'}`), { authProvider: provider, fetch: trustedFetch });
9
+ return connectProxy(transport, new StdioServerTransport());
10
+ }
11
+ export async function connectProxy(transport, localTransport) {
12
+ const remote = new Client({ name: '@pay2s/mcp', version: '0.4.0' });
13
+ await remote.connect(transport);
14
+ const local = new Server({ name: 'pay2s', version: '0.4.0' }, { capabilities: { tools: {} }, instructions: remote.getInstructions() });
15
+ // Serialize remote calls, including SDK token refresh. Never share a refresh token across processes.
16
+ let tail = Promise.resolve();
17
+ const enqueue = fn => { const next = tail.then(fn).catch(() => { throw Error('Pay2S request failed. Check your connection or sign in again.'); }); tail = next.catch(() => {}); return next; };
18
+ local.setRequestHandler(ListToolsRequestSchema, request => enqueue(() => remote.listTools(request.params)));
19
+ local.setRequestHandler(CallToolRequestSchema, request => enqueue(() => remote.callTool(request.params, CallToolResultSchema, { timeout: 60000 })));
20
+ await local.connect(localTransport);
21
+ return { async close() { await local.close(); await remote.close(); } };
22
+ }
package/src/store.mjs ADDED
@@ -0,0 +1,50 @@
1
+ import { mkdir, lstat, chmod, readFile, writeFile, rename, unlink, open } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ const exec = promisify(execFile);
7
+ export async function openStore(profile, base = join(homedir(), '.pay2s-mcp')) {
8
+ if (!/^[a-z0-9_-]{1,32}$/.test(profile)) throw Error('Invalid profile');
9
+ await mkdir(base, { recursive: true, mode: 0o700 });
10
+ if (!(await lstat(base)).isDirectory() || (await lstat(base)).isSymbolicLink()) throw Error('Unsafe credential directory');
11
+ const directory = join(base, profile);
12
+ await mkdir(directory, { mode: 0o700, recursive: true });
13
+ const stat = await lstat(directory);
14
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('Unsafe profile directory');
15
+ if (process.platform === 'win32') {
16
+ const { stdout } = await exec('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value'], { windowsHide: true });
17
+ const sid = stdout.trim();
18
+ if (!/^S-1-[0-9-]+$/.test(sid)) throw Error('Cannot secure credential directory');
19
+ // Replace the DACL rather than retaining any pre-existing explicit grants.
20
+ const script = '$ErrorActionPreference="Stop"; $item=Get-Item -LiteralPath $env:PAY2S_MCP_PROFILE_DIRECTORY; $section=[System.Security.AccessControl.AccessControlSections]::Access; $acl=$item.GetAccessControl($section); $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; $acl.SetSecurityDescriptorSddlForm("D:P(A;OICI;FA;;;$sid)(A;OICI;FA;;;SY)",$section); $item.SetAccessControl($acl)';
21
+ await exec('powershell.exe', ['-NoProfile','-NonInteractive','-Command',script], { windowsHide: true, env: { ...process.env, PAY2S_MCP_PROFILE_DIRECTORY: directory } });
22
+ } else {
23
+ if (stat.uid !== process.getuid()) throw Error('Profile belongs to another user');
24
+ await chmod(directory, 0o700);
25
+ }
26
+ const lockPath = join(directory, 'process.lock');
27
+ // An exclusive profile prevents simultaneous clients rotating the same refresh token.
28
+ // A stale lock is not silently removed: this avoids a check/delete race with another client.
29
+ let lock;
30
+ try { lock = await open(lockPath, 'wx', 0o600); }
31
+ catch (e) { if (e.code === 'EEXIST') throw Error(`Profile ${profile} is in use. Use a separate --profile for each AI app. After a crash, remove ${lockPath} only when no client uses it.`); throw e; }
32
+ await lock.writeFile(String(process.pid));
33
+ const path = join(directory, 'oauth.json');
34
+ let saved;
35
+ try {
36
+ const s = await lstat(path);
37
+ if (!s.isFile() || s.isSymbolicLink() || (process.platform !== 'win32' && (s.uid !== process.getuid() || (s.mode & 0o077)))) throw Error('Unsafe credential file permissions');
38
+ saved = JSON.parse(await readFile(path, 'utf8'));
39
+ } catch (e) { if (e.code !== 'ENOENT') { await lock.close(); await unlink(lockPath); throw e; } }
40
+ return {
41
+ read: () => saved,
42
+ async save(value) {
43
+ if (!value) { await unlink(path).catch(e => { if (e.code !== 'ENOENT') throw e; }); saved = undefined; return; }
44
+ const temp = join(directory, `oauth-${process.pid}.tmp`);
45
+ await writeFile(temp, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
46
+ await rename(temp, path); saved = value;
47
+ },
48
+ async close() { await lock.close(); await unlink(lockPath); }
49
+ };
50
+ }