@pay2s/mcp 0.4.0 → 0.5.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 +7 -2
- package/bin/pay2s-mcp.mjs +1 -1
- package/package.json +13 -13
- package/src/auth.mjs +24 -7
- package/src/proxy.mjs +22 -22
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
> **0.5.0 security migration:** OAuth is now owned by `https://api.pay2s.vn`. Upgrade backend, gateway, frontend and npm together; existing users must log in again. See `../pay2s-mcp/UPGRADE-0.5.0.md`. Older installation instructions below are historical and do not override that guide.
|
|
2
|
+
|
|
1
3
|
# Pay2S MCP
|
|
2
4
|
|
|
3
5
|
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`.
|
|
@@ -49,10 +51,11 @@ Tham khảo cấu hình stdio: https://learn.chatgpt.com/docs/extend/mcp?surface
|
|
|
49
51
|
|
|
50
52
|
- 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
53
|
- Tạo link thanh toán khi được cấp quyền riêng.
|
|
52
|
-
-
|
|
54
|
+
- Bật/tắt ngân hàng và webhook trực tiếp bằng `set_bank_status`, `set_webhook_status` khi được cấp quyền tương ứng. AI trả trạng thái thực tế ngay, không tạo đề nghị chờ xác nhận.
|
|
55
|
+
- Đề nghị tạo webhook mới trả đường dẫn để chủ tài khoản kiểm tra địa chỉ nhận trên Pay2S.
|
|
53
56
|
- `--docs`: chỉ đọc tài liệu công khai, không đăng nhập.
|
|
54
57
|
|
|
55
|
-
Kết nối
|
|
58
|
+
Kết nối đã có `banks:manage` và `webhooks:manage` dùng ngay công cụ bật/tắt sau khi máy chủ cập nhật 0.4.1 và AI tải lại danh sách công cụ. Chỉ đăng nhập lại nếu chưa cấp hai quyền này. Ngân hàng đang tắt vẫn xuất hiện để 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
59
|
|
|
57
60
|
## Phiên đăng nhập
|
|
58
61
|
|
|
@@ -74,3 +77,5 @@ pay2s-mcp login --profile codex
|
|
|
74
77
|
```
|
|
75
78
|
|
|
76
79
|
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.
|
|
80
|
+
|
|
81
|
+
Máy chủ 0.4.2 bổ sung `create_webhook`: tạo ngay trong quyền `webhooks:request` đã cấp, không quay lại Pay2S duyệt. Gói npm hiện tại tương thích; khởi động lại kết nối để cập nhật danh sách công cụ.
|
package/bin/pay2s-mcp.mjs
CHANGED
|
@@ -30,6 +30,6 @@ try {
|
|
|
30
30
|
else await close();
|
|
31
31
|
} catch (error) {
|
|
32
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.
|
|
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 and API backend 0.5 and account access. Run login with the same --profile again.';
|
|
34
34
|
process.stderr.write(`${safe}\n`); await close(); process.exitCode = 1;
|
|
35
35
|
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pay2s/mcp",
|
|
3
|
-
"version": "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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@pay2s/mcp",
|
|
3
|
+
"version": "0.5.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
CHANGED
|
@@ -2,15 +2,31 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { execFile } from 'node:child_process';
|
|
4
4
|
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
|
|
5
|
-
export const ISSUER = 'https://
|
|
6
|
-
export const
|
|
5
|
+
export const ISSUER = 'https://api.pay2s.vn';
|
|
6
|
+
export const MCP_ORIGIN = 'https://mcp.pay2s.vn';
|
|
7
|
+
export const RESOURCE = `${MCP_ORIGIN}/mcp`;
|
|
8
|
+
export const AUTHORIZE = `${ISSUER}/api/v1/mcp/oauth/authorize`;
|
|
9
|
+
export const TOKEN = `${ISSUER}/api/v1/mcp/oauth/token`;
|
|
7
10
|
export const REDIRECT = 'http://127.0.0.1:14572/callback';
|
|
8
11
|
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 = {}) {
|
|
12
|
+
export async function trustedFetch(input, init = {}, transport = fetch) {
|
|
10
13
|
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
11
|
-
|
|
14
|
+
const method=(init.method || input.method || 'GET').toUpperCase();
|
|
15
|
+
const headers=new Headers(init.headers || input.headers);
|
|
16
|
+
const publicMetadata=url.origin===MCP_ORIGIN && ['/.well-known/oauth-protected-resource','/.well-known/oauth-protected-resource/mcp'].includes(url.pathname);
|
|
17
|
+
const authMetadata=url.origin===ISSUER && url.pathname==='/.well-known/oauth-authorization-server';
|
|
18
|
+
const resource=url.origin===MCP_ORIGIN && ['/mcp','/docs'].includes(url.pathname);
|
|
19
|
+
const token=url.href===TOKEN;
|
|
20
|
+
if(url.username||url.password||url.search||url.hash||!((method==='GET'&&(publicMetadata||authMetadata))||(method==='POST'&&resource&&headers.get('content-type')?.startsWith('application/json'))||(method==='POST'&&token&&headers.get('content-type')?.startsWith('application/x-www-form-urlencoded'))))throw Error('Unexpected Pay2S server or endpoint');
|
|
21
|
+
if(headers.has('authorization') && !(resource && url.pathname==='/mcp'))throw Error('Unexpected credential destination');
|
|
12
22
|
const signal = init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(45000)]) : AbortSignal.timeout(45000);
|
|
13
|
-
|
|
23
|
+
const response=await transport(input, { ...init, redirect: 'error', signal });
|
|
24
|
+
if(response.ok && (publicMetadata||authMetadata)){
|
|
25
|
+
const data=await response.clone().json();
|
|
26
|
+
if(publicMetadata && (data.resource!==RESOURCE||!Array.isArray(data.authorization_servers)||data.authorization_servers.length!==1||data.authorization_servers[0]!==ISSUER))throw Error('Unexpected authorization server');
|
|
27
|
+
if(authMetadata && (data.issuer!==ISSUER||data.authorization_endpoint!==AUTHORIZE||data.token_endpoint!==TOKEN||!data.code_challenge_methods_supported?.includes('S256')))throw Error('Unexpected authorization endpoints');
|
|
28
|
+
}
|
|
29
|
+
return response;
|
|
14
30
|
}
|
|
15
31
|
export function callbackHandler(state, accept) {
|
|
16
32
|
let used = false;
|
|
@@ -36,7 +52,7 @@ export function callbackHandler(state, accept) {
|
|
|
36
52
|
function openBrowser(url) {
|
|
37
53
|
// Fixed HTTPS origin and URL serialization; no shell command interpolation.
|
|
38
54
|
const target = new URL(url);
|
|
39
|
-
if (target.origin !== ISSUER || target.pathname !== '/oauth/authorize') throw Error('Unexpected authorization URL');
|
|
55
|
+
if (target.origin !== ISSUER || target.pathname !== '/api/v1/mcp/oauth/authorize') throw Error('Unexpected authorization URL');
|
|
40
56
|
const command = process.platform === 'win32' ? 'rundll32.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
41
57
|
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', target.href] : [target.href];
|
|
42
58
|
return new Promise(resolve => execFile(command, args, { windowsHide: true }, () => resolve()));
|
|
@@ -47,13 +63,14 @@ export class Provider {
|
|
|
47
63
|
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
64
|
clientInformation() { return { client_id: 'pay2s-cli', issuer: ISSUER }; }
|
|
49
65
|
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 }); }
|
|
66
|
+
async saveTokens(tokens) { if(tokens.issuer!==ISSUER)throw Error('Unexpected token issuer');await this.store.save({ tokens, expires_at: Date.now() + Math.max(0, Number(tokens.expires_in || 0)) * 1000 }); }
|
|
51
67
|
async invalidateCredentials(scope) { if (['all','tokens'].includes(scope)) await this.store.save(undefined); if (['all','verifier'].includes(scope)) this.verifier = undefined; }
|
|
52
68
|
saveCodeVerifier(value) { this.verifier = value; }
|
|
53
69
|
codeVerifier() { if (!this.verifier) throw Error('Missing code verifier'); return this.verifier; }
|
|
54
70
|
validateResourceURL(_url, resource) { if (resource !== RESOURCE) throw Error('Unexpected resource'); return Promise.resolve(new URL(RESOURCE)); }
|
|
55
71
|
state() { this.nonce = randomBytes(32).toString('hex'); return this.nonce; }
|
|
56
72
|
async redirectToAuthorization(url) {
|
|
73
|
+
if(url.origin!==ISSUER||url.pathname!=='/api/v1/mcp/oauth/authorize')throw Error('Unexpected authorization URL');
|
|
57
74
|
if (!this.interactive) throw Error('Login required. Run npx -y @pay2s/mcp login with the same --profile.');
|
|
58
75
|
this.callback = new Promise(resolve => { this.receive = resolve; });
|
|
59
76
|
this.server = createServer(callbackHandler(this.nonce, this.receive));
|
package/src/proxy.mjs
CHANGED
|
@@ -1,22 +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 {
|
|
7
|
-
export async function proxy(provider, docs = false) {
|
|
8
|
-
const transport = new StreamableHTTPClientTransport(new URL(`${
|
|
9
|
-
return connectProxy(transport, new StdioServerTransport());
|
|
10
|
-
}
|
|
11
|
-
export async function connectProxy(transport, localTransport) {
|
|
12
|
-
const remote = new Client({ name: '@pay2s/mcp', version: '0.
|
|
13
|
-
await remote.connect(transport);
|
|
14
|
-
const local = new Server({ name: 'pay2s', version: '0.
|
|
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
|
-
}
|
|
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 { MCP_ORIGIN, trustedFetch } from './auth.mjs';
|
|
7
|
+
export async function proxy(provider, docs = false) {
|
|
8
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${MCP_ORIGIN}/${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.5.0' });
|
|
13
|
+
await remote.connect(transport);
|
|
14
|
+
const local = new Server({ name: 'pay2s', version: '0.5.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
|
+
}
|