@oobe-protocol-labs/sap-mcp-server 0.2.1 → 0.7.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/.env.example +3 -0
- package/CHANGELOG.md +238 -1
- package/README.md +64 -1
- package/USER_DOCS/00_START_HERE.md +23 -1
- package/USER_DOCS/01_HOSTED_REMOTE_MCP.md +55 -0
- package/USER_DOCS/03_PAYMENTS_X402_PAYSH.md +63 -7
- package/USER_DOCS/04_CLIENT_CONFIGS.md +164 -11
- package/USER_DOCS/06_DESKTOP_GUI_WIZARD.md +262 -0
- package/dist/adapters/mcp/sdk-compat.d.ts.map +1 -1
- package/dist/adapters/mcp/sdk-compat.js +32 -18
- package/dist/adapters/mcp/sdk-compat.js.map +1 -1
- package/dist/config/mcp-client-injection.d.ts +72 -2
- package/dist/config/mcp-client-injection.d.ts.map +1 -1
- package/dist/config/mcp-client-injection.js +691 -15
- package/dist/config/mcp-client-injection.js.map +1 -1
- package/dist/config/setup.js +1 -1
- package/dist/config/setup.js.map +1 -1
- package/dist/config/wizard.d.ts.map +1 -1
- package/dist/config/wizard.js +164 -85
- package/dist/config/wizard.js.map +1 -1
- package/dist/core/constants.d.ts +1 -1
- package/dist/core/constants.js +1 -1
- package/dist/core/logger.js +1 -1
- package/dist/payments/facilitator-rpc-fallback.d.ts +43 -0
- package/dist/payments/facilitator-rpc-fallback.d.ts.map +1 -0
- package/dist/payments/facilitator-rpc-fallback.js +228 -0
- package/dist/payments/facilitator-rpc-fallback.js.map +1 -0
- package/dist/payments/json-rpc.d.ts.map +1 -1
- package/dist/payments/json-rpc.js +2 -1
- package/dist/payments/json-rpc.js.map +1 -1
- package/dist/payments/monetization-gate.d.ts +43 -2
- package/dist/payments/monetization-gate.d.ts.map +1 -1
- package/dist/payments/monetization-gate.js +618 -9
- package/dist/payments/monetization-gate.js.map +1 -1
- package/dist/payments/oobe-facilitator-server.d.ts +12 -1
- package/dist/payments/oobe-facilitator-server.d.ts.map +1 -1
- package/dist/payments/oobe-facilitator-server.js +109 -8
- package/dist/payments/oobe-facilitator-server.js.map +1 -1
- package/dist/payments/pay-sh-spec.js +1 -1
- package/dist/payments/pricing.d.ts.map +1 -1
- package/dist/payments/pricing.js +1 -0
- package/dist/payments/pricing.js.map +1 -1
- package/dist/payments/usage-ledger.d.ts +10 -0
- package/dist/payments/usage-ledger.d.ts.map +1 -1
- package/dist/payments/usage-ledger.js +51 -0
- package/dist/payments/usage-ledger.js.map +1 -1
- package/dist/payments/x402-paid-call.d.ts +53 -0
- package/dist/payments/x402-paid-call.d.ts.map +1 -0
- package/dist/payments/x402-paid-call.js +370 -0
- package/dist/payments/x402-paid-call.js.map +1 -0
- package/dist/prompts/context/sap-agent-context.prompt.js +33 -6
- package/dist/prompts/context/sap-agent-context.prompt.js.map +1 -1
- package/dist/prompts/developer/debug-sap-error.prompt.js +1 -1
- package/dist/prompts/developer/debug-sap-error.prompt.js.map +1 -1
- package/dist/prompts/developer/generate-sap-integration.prompt.js +6 -2
- package/dist/prompts/developer/generate-sap-integration.prompt.js.map +1 -1
- package/dist/prompts/execution-proof/explain-proof-of-execution.prompt.js +1 -1
- package/dist/prompts/execution-proof/explain-proof-of-execution.prompt.js.map +1 -1
- package/dist/prompts/execution-proof/verify-execution-proof.prompt.js +1 -1
- package/dist/prompts/execution-proof/verify-execution-proof.prompt.js.map +1 -1
- package/dist/prompts/payments/create-paid-api.prompt.js +1 -1
- package/dist/prompts/payments/create-paid-api.prompt.js.map +1 -1
- package/dist/prompts/payments/explain-x402-settlement.prompt.js +1 -1
- package/dist/prompts/payments/explain-x402-settlement.prompt.js.map +1 -1
- package/dist/prompts/register-prompts.js +2 -2
- package/dist/prompts/register-prompts.js.map +1 -1
- package/dist/prompts/registry/analyze-sap-agent.prompt.js +1 -1
- package/dist/prompts/registry/analyze-sap-agent.prompt.js.map +1 -1
- package/dist/prompts/registry/register-sap-agent.prompt.js +2 -2
- package/dist/prompts/registry/register-sap-agent.prompt.js.map +1 -1
- package/dist/remote/server.d.ts +150 -1
- package/dist/remote/server.d.ts.map +1 -1
- package/dist/remote/server.js +1176 -84
- package/dist/remote/server.js.map +1 -1
- package/dist/resources/current/sap-current-config.resource.d.ts.map +1 -1
- package/dist/resources/current/sap-current-config.resource.js +22 -2
- package/dist/resources/current/sap-current-config.resource.js.map +1 -1
- package/dist/resources/execution-proof/sap-execution-record.resource.js +1 -1
- package/dist/resources/execution-proof/sap-execution-record.resource.js.map +1 -1
- package/dist/resources/memory/sap-memory.resource.js +1 -1
- package/dist/resources/memory/sap-memory.resource.js.map +1 -1
- package/dist/resources/profile/sap-active-profile.resource.js +1 -1
- package/dist/resources/profile/sap-active-profile.resource.js.map +1 -1
- package/dist/resources/register-resources.js +2 -2
- package/dist/resources/register-resources.js.map +1 -1
- package/dist/resources/registry/sap-agent.resource.js +1 -1
- package/dist/resources/registry/sap-agent.resource.js.map +1 -1
- package/dist/resources/registry/sap-global-registry.resource.js +1 -1
- package/dist/resources/registry/sap-global-registry.resource.js.map +1 -1
- package/dist/resources/reputation/sap-reputation.resource.js +1 -1
- package/dist/resources/reputation/sap-reputation.resource.js.map +1 -1
- package/dist/resources/stats/sap-network-stats.resource.js +1 -1
- package/dist/resources/stats/sap-network-stats.resource.js.map +1 -1
- package/dist/resources/tool-schema/sap-tool-schema.resource.js +1 -1
- package/dist/resources/tool-schema/sap-tool-schema.resource.js.map +1 -1
- package/dist/sap/sap-client-manager.js +6 -6
- package/dist/sap/sap-client-manager.js.map +1 -1
- package/dist/security/tool-permissions.d.ts.map +1 -1
- package/dist/security/tool-permissions.js +1 -0
- package/dist/security/tool-permissions.js.map +1 -1
- package/dist/server/create-server.js +5 -5
- package/dist/server/create-server.js.map +1 -1
- package/dist/server/register-capabilities.js +4 -4
- package/dist/server/register-capabilities.js.map +1 -1
- package/dist/server/server-metadata.js +1 -1
- package/dist/signer/signer-resolver.js +12 -5
- package/dist/signer/signer-resolver.js.map +1 -1
- package/dist/tools/client-sdk-tools.js +7 -7
- package/dist/tools/client-sdk-tools.js.map +1 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/magicblock-tools.d.ts +28 -0
- package/dist/tools/magicblock-tools.d.ts.map +1 -0
- package/dist/tools/magicblock-tools.js +578 -0
- package/dist/tools/magicblock-tools.js.map +1 -0
- package/dist/tools/profile-tools.d.ts.map +1 -1
- package/dist/tools/profile-tools.js +44 -0
- package/dist/tools/profile-tools.js.map +1 -1
- package/dist/tools/register-tools.d.ts.map +1 -1
- package/dist/tools/register-tools.js +11 -2
- package/dist/tools/register-tools.js.map +1 -1
- package/dist/tools/sap-sdk-tools.js +2 -2
- package/dist/tools/sap-sdk-tools.js.map +1 -1
- package/dist/tools/sap-sns-tools.js +2 -2
- package/dist/tools/sap-sns-tools.js.map +1 -1
- package/dist/tools/skills-tools.d.ts +1 -1
- package/dist/tools/skills-tools.d.ts.map +1 -1
- package/dist/tools/skills-tools.js +10 -1
- package/dist/tools/skills-tools.js.map +1 -1
- package/dist/tools/tool-aliases.d.ts +10 -0
- package/dist/tools/tool-aliases.d.ts.map +1 -0
- package/dist/tools/tool-aliases.js +23 -0
- package/dist/tools/tool-aliases.js.map +1 -0
- package/dist/tools/x402-paid-call-tool.d.ts +12 -0
- package/dist/tools/x402-paid-call-tool.d.ts.map +1 -0
- package/dist/tools/x402-paid-call-tool.js +118 -0
- package/dist/tools/x402-paid-call-tool.js.map +1 -0
- package/dist/tui/config-wizard.js +2 -2
- package/dist/tui/config-wizard.js.map +1 -1
- package/dist/wizard-core/desktop-flow.d.ts +82 -0
- package/dist/wizard-core/desktop-flow.d.ts.map +1 -0
- package/dist/wizard-core/desktop-flow.js +181 -0
- package/dist/wizard-core/desktop-flow.js.map +1 -0
- package/docs/00_README.md +3 -0
- package/docs/03_CONFIGURATION_AND_WIZARD.md +31 -5
- package/docs/06_PAYMENTS_X402_AND_PAYSH.md +70 -7
- package/docs/07_ENDPOINTS_AND_CLIENTS.md +56 -1
- package/docs/10_OPERATIONS_RELEASE_AND_PM2.md +7 -6
- package/docs/13_BOUNTY_PROGRAM_PROPOSAL.md +469 -0
- package/docs/14_DESKTOP_WIZARD_RELEASE.md +264 -0
- package/docs/README.md +104 -0
- package/docs/_sidebar.md +31 -0
- package/docs/magicblock-tools.md +166 -0
- package/docs/x402-protocol-spec.md +984 -0
- package/package.json +23 -2
- package/server.json +71 -0
- package/skills/README.md +3 -2
- package/skills/sap-defi/SKILL.md +8 -0
- package/skills/sap-mcp/SKILL.md +124 -2
- package/skills/sap-payments-x402/SKILL.md +54 -2
package/dist/remote/server.js
CHANGED
|
@@ -4,22 +4,34 @@
|
|
|
4
4
|
* @description Streamable HTTP entrypoint for remote SAP MCP deployments.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
|
-
import { readFileSync } from 'fs';
|
|
7
|
+
import { existsSync, readFileSync } from 'fs';
|
|
8
8
|
import * as http from 'http';
|
|
9
|
-
import { dirname, join } from 'path';
|
|
9
|
+
import { dirname, join, posix } from 'path';
|
|
10
10
|
import { fileURLToPath } from 'url';
|
|
11
11
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
12
|
-
import { loadConfig } from '../config/env.js';
|
|
12
|
+
import { getDataDir, loadConfig } from '../config/env.js';
|
|
13
13
|
import { MCP_SERVER_VERSION } from '../core/constants.js';
|
|
14
14
|
import { logger, initLogger } from '../core/logger.js';
|
|
15
15
|
import { createSapMcpServer } from '../server/create-server.js';
|
|
16
16
|
import { AuthManager } from './auth/index.js';
|
|
17
|
-
import { McpMonetizationGate } from '../payments/index.js';
|
|
17
|
+
import { McpMonetizationGate, resolvePaymentNetwork } from '../payments/index.js';
|
|
18
|
+
import { generatePayShProviderYaml } from '../payments/pay-sh-spec.js';
|
|
18
19
|
import { RemoteRateLimiter, buildRemoteRateLimitConfigFromEnv } from './rate-limiter.js';
|
|
19
20
|
const PUBLIC_SERVER_TITLE = 'SAP MCP Server | OOBE Protocol';
|
|
20
21
|
const PUBLIC_SERVER_DESCRIPTION = 'Hosted Solana-native MCP gateway for Synapse Agent Protocol tools, x402/pay.sh monetization, SNS identity, and agent operations.';
|
|
21
22
|
const LOGO_ASSET_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'explorer_logo.png');
|
|
23
|
+
const DOCS_ROOT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'docs');
|
|
24
|
+
const USER_DOCS_ROOT_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'USER_DOCS');
|
|
25
|
+
const PAYMENT_STATS_CACHE_MS = 15_000;
|
|
26
|
+
const RELEASE_DOWNLOAD_BASE_URL = `https://github.com/OOBE-PROTOCOL/sap-mcp/releases/download/${MCP_SERVER_VERSION}`;
|
|
27
|
+
const WIZARD_NPM_COMMAND = 'npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config wizard';
|
|
28
|
+
const X402_PAID_CALL_NPX_COMMAND = 'npx --yes --package @oobe-protocol-labs/sap-mcp-server sap-mcp-x402-paid-call --tool sap_list_all_agents --arguments \'{"limit":5}\' --max-usd 0.02 --confirm';
|
|
29
|
+
const X402_PAID_CALL_ADDON_PATH = '~/.config/mcp-sap/addons/x402-paid-call';
|
|
30
|
+
const SOLANA_DEVNET_CAIP2 = 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1';
|
|
31
|
+
const USDC_MAINNET_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
|
|
32
|
+
const USDC_DEVNET_MINT = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
|
|
22
33
|
let logoAssetCache;
|
|
34
|
+
let paymentStatsCache;
|
|
23
35
|
/**
|
|
24
36
|
* @name parseApiKeys
|
|
25
37
|
* @description Parses comma-separated `apiKey=userId` entries from environment configuration.
|
|
@@ -51,8 +63,13 @@ export function defaultRemoteConfig(appConfig) {
|
|
|
51
63
|
port,
|
|
52
64
|
host,
|
|
53
65
|
corsOrigins: appConfig.httpCorsOrigins,
|
|
54
|
-
stateless: parseRemoteBoolean(process.env.SAP_MCP_HTTP_STATELESS,
|
|
66
|
+
stateless: parseRemoteBoolean(process.env.SAP_MCP_HTTP_STATELESS, false),
|
|
55
67
|
rateLimit: buildRemoteRateLimitConfigFromEnv(appConfig.rateLimitPerMinute),
|
|
68
|
+
paymentDiscovery: buildPublicPaymentDiscovery(appConfig),
|
|
69
|
+
paymentPriceRange: {
|
|
70
|
+
minUsd: appConfig.monetization.prices.minUsd,
|
|
71
|
+
maxUsd: appConfig.monetization.prices.maxUsd,
|
|
72
|
+
},
|
|
56
73
|
};
|
|
57
74
|
if (authType === 'none') {
|
|
58
75
|
return {
|
|
@@ -84,6 +101,56 @@ export function defaultRemoteConfig(appConfig) {
|
|
|
84
101
|
},
|
|
85
102
|
};
|
|
86
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* @name buildPublicPaymentDiscovery
|
|
106
|
+
* @description Builds public x402 seller/facilitator coordinates without exposing facilitator URL or auth tokens.
|
|
107
|
+
*/
|
|
108
|
+
function buildPublicPaymentDiscovery(appConfig) {
|
|
109
|
+
if (!appConfig.monetization.enabled || !appConfig.monetization.payTo) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const network = resolvePaymentNetwork(appConfig);
|
|
113
|
+
const facilitatorPublicKey = (process.env.SAP_MCP_X402_FACILITATOR_PUBLIC_KEY
|
|
114
|
+
?? process.env.SAP_MCP_X402_FACILITATOR_FEE_PAYER
|
|
115
|
+
?? '').trim();
|
|
116
|
+
return {
|
|
117
|
+
provider: appConfig.monetization.provider,
|
|
118
|
+
network,
|
|
119
|
+
asset: resolvePublicPaymentAsset(network),
|
|
120
|
+
assetSymbol: 'USDC',
|
|
121
|
+
payTo: appConfig.monetization.payTo,
|
|
122
|
+
seller: {
|
|
123
|
+
name: 'OOBE Protocol',
|
|
124
|
+
payTo: appConfig.monetization.payTo,
|
|
125
|
+
},
|
|
126
|
+
...(facilitatorPublicKey
|
|
127
|
+
? {
|
|
128
|
+
facilitator: {
|
|
129
|
+
role: 'x402-svm-fee-payer',
|
|
130
|
+
publicKey: facilitatorPublicKey,
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
: {}),
|
|
134
|
+
headers: {
|
|
135
|
+
paymentRequired: 'PAYMENT-REQUIRED',
|
|
136
|
+
paymentSignature: 'PAYMENT-SIGNATURE',
|
|
137
|
+
paymentResponse: 'PAYMENT-RESPONSE',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* @name resolvePublicPaymentAsset
|
|
143
|
+
* @description Resolves the public USDC mint advertised in x402 discovery metadata.
|
|
144
|
+
*/
|
|
145
|
+
function resolvePublicPaymentAsset(network) {
|
|
146
|
+
if (network === SOLANA_DEVNET_CAIP2) {
|
|
147
|
+
return USDC_DEVNET_MINT;
|
|
148
|
+
}
|
|
149
|
+
return USDC_MAINNET_MINT;
|
|
150
|
+
}
|
|
151
|
+
function formatOpenApiUsd(value) {
|
|
152
|
+
return value.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
|
|
153
|
+
}
|
|
87
154
|
function parseRemoteBoolean(raw, fallback) {
|
|
88
155
|
if (raw === undefined) {
|
|
89
156
|
return fallback;
|
|
@@ -94,38 +161,208 @@ function parseRemoteBoolean(raw, fallback) {
|
|
|
94
161
|
* @name writeJson
|
|
95
162
|
* @description Writes a JSON response with optional additional headers.
|
|
96
163
|
*/
|
|
97
|
-
function writeJson(res, status, body, headers = {}) {
|
|
164
|
+
function writeJson(res, status, body, headers = {}, omitBody = false) {
|
|
165
|
+
const serialized = JSON.stringify(body);
|
|
98
166
|
res.writeHead(status, {
|
|
99
167
|
'Content-Type': 'application/json',
|
|
168
|
+
'Content-Length': Buffer.byteLength(serialized),
|
|
169
|
+
...headers,
|
|
170
|
+
});
|
|
171
|
+
res.end(omitBody ? undefined : serialized);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* @name writeText
|
|
175
|
+
* @description Writes a cacheable text response with an explicit content type.
|
|
176
|
+
*/
|
|
177
|
+
function writeText(res, status, body, contentType, headers = {}, omitBody = false) {
|
|
178
|
+
res.writeHead(status, {
|
|
179
|
+
'Content-Type': contentType,
|
|
180
|
+
'Cache-Control': 'public, max-age=300',
|
|
181
|
+
'Content-Length': Buffer.byteLength(body),
|
|
182
|
+
'X-Content-Type-Options': 'nosniff',
|
|
100
183
|
...headers,
|
|
101
184
|
});
|
|
102
|
-
res.end(
|
|
185
|
+
res.end(omitBody ? undefined : body);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* @name writeMcpJsonRpcError
|
|
189
|
+
* @description Writes an MCP-compatible JSON-RPC error response with HTTP status semantics.
|
|
190
|
+
*/
|
|
191
|
+
function writeMcpJsonRpcError(res, status, code, id, message, data) {
|
|
192
|
+
writeJson(res, status, {
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
id,
|
|
195
|
+
error: {
|
|
196
|
+
code,
|
|
197
|
+
message,
|
|
198
|
+
...(data === undefined ? {} : { data }),
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* @name readSingleHeader
|
|
204
|
+
* @description Reads one normalized HTTP header value without accepting ambiguous arrays.
|
|
205
|
+
*/
|
|
206
|
+
function readSingleHeader(value) {
|
|
207
|
+
if (Array.isArray(value)) {
|
|
208
|
+
return value[0]?.trim() || undefined;
|
|
209
|
+
}
|
|
210
|
+
return value?.trim() || undefined;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* @name extractJsonRpcId
|
|
214
|
+
* @description Extracts a JSON-RPC id for error correlation.
|
|
215
|
+
*/
|
|
216
|
+
function extractJsonRpcId(body) {
|
|
217
|
+
if (Array.isArray(body) && body.length > 0) {
|
|
218
|
+
return extractJsonRpcId(body[0]);
|
|
219
|
+
}
|
|
220
|
+
if (!body || typeof body !== 'object') {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const record = body;
|
|
224
|
+
return typeof record.id === 'string' || typeof record.id === 'number' ? record.id : null;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* @name isInitializeJsonRpcRequest
|
|
228
|
+
* @description Detects MCP initialize requests that are allowed to create a new stateful session.
|
|
229
|
+
*/
|
|
230
|
+
function isInitializeJsonRpcRequest(body) {
|
|
231
|
+
if (Array.isArray(body)) {
|
|
232
|
+
return body.some(isInitializeJsonRpcRequest);
|
|
233
|
+
}
|
|
234
|
+
if (!body || typeof body !== 'object') {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
return body.method === 'initialize';
|
|
103
238
|
}
|
|
104
239
|
/**
|
|
105
240
|
* @name writeHtml
|
|
106
241
|
* @description Writes a cacheable HTML response for public endpoint previews and social sharing.
|
|
107
242
|
*/
|
|
108
|
-
function writeHtml(res, status, html) {
|
|
243
|
+
function writeHtml(res, status, html, omitBody = false) {
|
|
109
244
|
res.writeHead(status, {
|
|
110
245
|
'Content-Type': 'text/html; charset=utf-8',
|
|
111
246
|
'Cache-Control': 'public, max-age=300',
|
|
247
|
+
'Content-Length': Buffer.byteLength(html),
|
|
248
|
+
'X-Content-Type-Options': 'nosniff',
|
|
249
|
+
});
|
|
250
|
+
res.end(omitBody ? undefined : html);
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* @name isPublicReadMethod
|
|
254
|
+
* @description Returns true for cacheable public metadata reads, including crawler-friendly HEAD checks.
|
|
255
|
+
*/
|
|
256
|
+
function isPublicReadMethod(method) {
|
|
257
|
+
return method === 'GET' || method === 'HEAD';
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* @name isHeadMethod
|
|
261
|
+
* @description Returns true when the request must emit headers without a response body.
|
|
262
|
+
*/
|
|
263
|
+
function isHeadMethod(method) {
|
|
264
|
+
return method === 'HEAD';
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* @name writeMarkdownAsset
|
|
268
|
+
* @description Writes a resolved public markdown documentation asset.
|
|
269
|
+
*/
|
|
270
|
+
function writeMarkdownAsset(res, asset) {
|
|
271
|
+
res.writeHead(200, {
|
|
272
|
+
'Content-Type': asset.contentType,
|
|
273
|
+
'Cache-Control': 'public, max-age=120',
|
|
274
|
+
'Content-Length': asset.contentLength,
|
|
112
275
|
'X-Content-Type-Options': 'nosniff',
|
|
113
276
|
});
|
|
114
|
-
res.end(
|
|
277
|
+
res.end(asset.body);
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* @name resolvePublicDocsMarkdown
|
|
281
|
+
* @description Resolves safe hosted documentation markdown requests under /docs and /docs/user.
|
|
282
|
+
*/
|
|
283
|
+
export function resolvePublicDocsMarkdown(method, pathname) {
|
|
284
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
if (!pathname.startsWith('/docs/') || !pathname.endsWith('.md')) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
let decodedPath;
|
|
291
|
+
try {
|
|
292
|
+
decodedPath = decodeURIComponent(pathname.slice('/docs/'.length));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
const normalizedPath = posix.normalize(decodedPath.replace(/\\/g, '/')).replace(/^[/\\]+/, '');
|
|
298
|
+
if (isUnsafeDocsPath(normalizedPath)) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
const root = normalizedPath.startsWith('user/')
|
|
302
|
+
? USER_DOCS_ROOT_PATH
|
|
303
|
+
: DOCS_ROOT_PATH;
|
|
304
|
+
const relativePath = normalizedPath.startsWith('user/')
|
|
305
|
+
? normalizedPath.slice('user/'.length)
|
|
306
|
+
: normalizedPath;
|
|
307
|
+
if (isUnsafeDocsPath(relativePath)) {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
const absolutePath = join(root, relativePath);
|
|
311
|
+
if (!existsSync(absolutePath)) {
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const markdown = readFileSync(absolutePath, 'utf-8');
|
|
315
|
+
return {
|
|
316
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
317
|
+
contentLength: Buffer.byteLength(markdown),
|
|
318
|
+
body: method === 'HEAD' ? undefined : markdown,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function isUnsafeDocsPath(pathname) {
|
|
322
|
+
return pathname === ''
|
|
323
|
+
|| pathname.includes('\0')
|
|
324
|
+
|| pathname === '..'
|
|
325
|
+
|| pathname.startsWith('../')
|
|
326
|
+
|| pathname.includes('/../');
|
|
115
327
|
}
|
|
116
328
|
/**
|
|
117
|
-
* @name
|
|
118
|
-
* @description
|
|
329
|
+
* @name resolvePublicLogoAsset
|
|
330
|
+
* @description Resolves public icon/social image routes, including HEAD-safe favicon responses for crawlers.
|
|
119
331
|
*/
|
|
120
|
-
function
|
|
332
|
+
export function resolvePublicLogoAsset(method, pathname) {
|
|
333
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
121
336
|
const image = readLogoAsset();
|
|
337
|
+
const body = method === 'HEAD' ? undefined : image;
|
|
338
|
+
if (['/favicon.png', '/apple-touch-icon.png', '/og.png'].includes(pathname)) {
|
|
339
|
+
return {
|
|
340
|
+
contentType: 'image/png',
|
|
341
|
+
contentLength: image.byteLength,
|
|
342
|
+
body,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
if (pathname === '/favicon.ico') {
|
|
346
|
+
return {
|
|
347
|
+
contentType: 'image/x-icon',
|
|
348
|
+
contentLength: image.byteLength,
|
|
349
|
+
body,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* @name writeLogoAsset
|
|
356
|
+
* @description Writes a resolved public logo asset response.
|
|
357
|
+
*/
|
|
358
|
+
function writeLogoAsset(res, asset) {
|
|
122
359
|
res.writeHead(200, {
|
|
123
|
-
'Content-Type':
|
|
360
|
+
'Content-Type': asset.contentType,
|
|
124
361
|
'Cache-Control': 'public, max-age=86400',
|
|
125
|
-
'Content-Length':
|
|
362
|
+
'Content-Length': asset.contentLength,
|
|
126
363
|
'X-Content-Type-Options': 'nosniff',
|
|
127
364
|
});
|
|
128
|
-
res.end(
|
|
365
|
+
res.end(asset.body);
|
|
129
366
|
}
|
|
130
367
|
/**
|
|
131
368
|
* @name readLogoAsset
|
|
@@ -135,6 +372,87 @@ function readLogoAsset() {
|
|
|
135
372
|
logoAssetCache ??= readFileSync(LOGO_ASSET_PATH);
|
|
136
373
|
return logoAssetCache;
|
|
137
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* @name readPublicPaymentStats
|
|
377
|
+
* @description Reads cached public-safe payment totals from the append-only usage ledger.
|
|
378
|
+
*/
|
|
379
|
+
export function readPublicPaymentStats(now = Date.now()) {
|
|
380
|
+
if (paymentStatsCache && paymentStatsCache.expiresAt > now) {
|
|
381
|
+
return paymentStatsCache.stats;
|
|
382
|
+
}
|
|
383
|
+
const stats = readPaymentStatsFromLedger();
|
|
384
|
+
paymentStatsCache = {
|
|
385
|
+
expiresAt: now + PAYMENT_STATS_CACHE_MS,
|
|
386
|
+
stats,
|
|
387
|
+
};
|
|
388
|
+
return stats;
|
|
389
|
+
}
|
|
390
|
+
function readPaymentStatsFromLedger() {
|
|
391
|
+
const stats = {
|
|
392
|
+
totalVolumeUsd: 0,
|
|
393
|
+
totalSettlements: 0,
|
|
394
|
+
totalPaymentRequests: 0,
|
|
395
|
+
totalVerifiedPayments: 0,
|
|
396
|
+
totalFailedSettlements: 0,
|
|
397
|
+
ledgerAvailable: false,
|
|
398
|
+
};
|
|
399
|
+
const ledgerPath = join(getDataDir(), 'payments', 'usage-ledger.jsonl');
|
|
400
|
+
if (!existsSync(ledgerPath)) {
|
|
401
|
+
return stats;
|
|
402
|
+
}
|
|
403
|
+
stats.ledgerAvailable = true;
|
|
404
|
+
const lines = readFileSync(ledgerPath, 'utf-8').split('\n');
|
|
405
|
+
for (const line of lines) {
|
|
406
|
+
const event = parsePaymentLedgerEvent(line);
|
|
407
|
+
if (!event) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (event.event === 'payment_required') {
|
|
411
|
+
stats.totalPaymentRequests += 1;
|
|
412
|
+
}
|
|
413
|
+
if (event.event === 'payment_verified') {
|
|
414
|
+
stats.totalVerifiedPayments += 1;
|
|
415
|
+
}
|
|
416
|
+
if (event.event === 'payment_settled') {
|
|
417
|
+
stats.totalSettlements += 1;
|
|
418
|
+
stats.totalVolumeUsd += event.priceUsd ?? 0;
|
|
419
|
+
stats.lastSettlementAt = event.timestamp;
|
|
420
|
+
}
|
|
421
|
+
if (event.event === 'payment_failed') {
|
|
422
|
+
stats.totalFailedSettlements += 1;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return stats;
|
|
426
|
+
}
|
|
427
|
+
function parsePaymentLedgerEvent(line) {
|
|
428
|
+
const trimmed = line.trim();
|
|
429
|
+
if (!trimmed) {
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
const parsed = JSON.parse(trimmed);
|
|
434
|
+
if (!isPaymentLedgerEvent(parsed)) {
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
return parsed;
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function isPaymentLedgerEvent(value) {
|
|
444
|
+
if (!value || typeof value !== 'object') {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
const record = value;
|
|
448
|
+
return typeof record.event === 'string'
|
|
449
|
+
&& typeof record.timestamp === 'string'
|
|
450
|
+
&& typeof record.requestHash === 'string'
|
|
451
|
+
&& typeof record.method === 'string'
|
|
452
|
+
&& typeof record.path === 'string'
|
|
453
|
+
&& Array.isArray(record.toolNames)
|
|
454
|
+
&& typeof record.paymentHeaderPresent === 'boolean';
|
|
455
|
+
}
|
|
138
456
|
/**
|
|
139
457
|
* @name getRateLimitKey
|
|
140
458
|
* @description Resolves the best available caller identity for hosted MCP rate limiting behind a reverse proxy.
|
|
@@ -182,13 +500,27 @@ export function buildPublicServerInfo(req, config) {
|
|
|
182
500
|
},
|
|
183
501
|
endpoints: {
|
|
184
502
|
landing: `${baseUrl}/`,
|
|
503
|
+
docs: `${baseUrl}/docs`,
|
|
185
504
|
mcp: `${baseUrl}/mcp`,
|
|
186
505
|
health: `${baseUrl}/health`,
|
|
187
506
|
serverInfo: `${baseUrl}/server.json`,
|
|
507
|
+
openApi: `${baseUrl}/openapi.json`,
|
|
508
|
+
x402Discovery: `${baseUrl}/.well-known/x402`,
|
|
509
|
+
payShProvider: `${baseUrl}/pay/provider.yml`,
|
|
188
510
|
agentCard: `${baseUrl}/.well-known/agent-card.json`,
|
|
189
511
|
wizardDescriptor: `${baseUrl}/.well-known/sap-mcp-wizard.json`,
|
|
512
|
+
wizardDownloads: `${baseUrl}/wizard/downloads.json`,
|
|
190
513
|
wizardInstallScript: `${baseUrl}/wizard/install.sh`,
|
|
191
514
|
favicon: `${baseUrl}/favicon.png`,
|
|
515
|
+
faviconIco: `${baseUrl}/favicon.ico`,
|
|
516
|
+
},
|
|
517
|
+
downloads: {
|
|
518
|
+
release: `https://github.com/OOBE-PROTOCOL/sap-mcp/releases/tag/${MCP_SERVER_VERSION}`,
|
|
519
|
+
desktopWizard: {
|
|
520
|
+
macosArm64Dmg: `${RELEASE_DOWNLOAD_BASE_URL}/SAP-MCP-Wizard-${MCP_SERVER_VERSION}-arm64.dmg`,
|
|
521
|
+
windowsX64Setup: `${RELEASE_DOWNLOAD_BASE_URL}/SAP-MCP-Wizard-Setup-${MCP_SERVER_VERSION}-x64.exe`,
|
|
522
|
+
linuxX64TarGz: `${RELEASE_DOWNLOAD_BASE_URL}/sap-mcp-wizard-${MCP_SERVER_VERSION}-x64.tar.gz`,
|
|
523
|
+
},
|
|
192
524
|
},
|
|
193
525
|
capabilities: {
|
|
194
526
|
tools: true,
|
|
@@ -202,6 +534,7 @@ export function buildPublicServerInfo(req, config) {
|
|
|
202
534
|
schemes: authRequired ? ['Bearer'] : ['none', 'x402'],
|
|
203
535
|
bearerRequired: authRequired,
|
|
204
536
|
},
|
|
537
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
205
538
|
security: {
|
|
206
539
|
keypairBytesExposed: false,
|
|
207
540
|
storesUserKeypairs: false,
|
|
@@ -215,17 +548,252 @@ export function buildPublicServerInfo(req, config) {
|
|
|
215
548
|
},
|
|
216
549
|
};
|
|
217
550
|
}
|
|
551
|
+
/**
|
|
552
|
+
* @name buildPublicPayShProviderYaml
|
|
553
|
+
* @description Builds a public pay.sh provider catalog for the hosted SAP MCP endpoint without RPC secrets or signer paths.
|
|
554
|
+
*/
|
|
555
|
+
export function buildPublicPayShProviderYaml(req, config, appConfig) {
|
|
556
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
557
|
+
return generatePayShProviderYaml(appConfig, {
|
|
558
|
+
name: 'oobe-sap-mcp',
|
|
559
|
+
subdomain: 'oobe-sap-mcp',
|
|
560
|
+
title: 'OOBE SAP MCP Server',
|
|
561
|
+
description: PUBLIC_SERVER_DESCRIPTION,
|
|
562
|
+
version: MCP_SERVER_VERSION,
|
|
563
|
+
upstreamUrl: baseUrl,
|
|
564
|
+
recipient: config.paymentDiscovery?.payTo ?? appConfig.monetization.payTo,
|
|
565
|
+
rpcUrl: undefined,
|
|
566
|
+
signerPath: undefined,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* @name buildOpenApiSpec
|
|
571
|
+
* @description OpenAPI 3.1 spec for x402scan discovery. Describes the /mcp endpoint
|
|
572
|
+
* with x-payment-info so x402scan can discover and register the API.
|
|
573
|
+
*/
|
|
574
|
+
export function buildOpenApiSpec(req, config) {
|
|
575
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
576
|
+
const priceRange = config.paymentPriceRange ?? { minUsd: 0.001, maxUsd: 100 };
|
|
577
|
+
return {
|
|
578
|
+
openapi: '3.1.0',
|
|
579
|
+
info: {
|
|
580
|
+
title: 'SAP MCP Server',
|
|
581
|
+
version: MCP_SERVER_VERSION,
|
|
582
|
+
description: 'MCP gateway for Solana DeFi, SAP agent registry, and x402 micropayments. 248 tools across Jupiter, Drift, Metaplex, Pump.fun, Raydium, Orca, and more.',
|
|
583
|
+
'x-guidance': 'Connect to POST /mcp with Accept: application/json, text/event-stream. Initialize an MCP session first, then call tools. Paid tools return a payment_required JSON-RPC error (HTTP 200 for MCP SDK compatibility) or HTTP 402 for non-MCP clients. Use x402_paid_call or npx sap-mcp-x402-paid-call to self-pay and retry.',
|
|
584
|
+
contact: {
|
|
585
|
+
name: 'OOBE Protocol Labs',
|
|
586
|
+
url: 'https://github.com/OOBE-PROTOCOL/sap-mcp',
|
|
587
|
+
email: 'oobe@oobeprotocol.ai',
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
'x-discovery': {
|
|
591
|
+
resources: [`${baseUrl}/mcp`],
|
|
592
|
+
openApi: `${baseUrl}/openapi.json`,
|
|
593
|
+
x402Discovery: `${baseUrl}/.well-known/x402`,
|
|
594
|
+
payShProvider: `${baseUrl}/pay/provider.yml`,
|
|
595
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
596
|
+
},
|
|
597
|
+
'x-pay-sh': {
|
|
598
|
+
providerYaml: `${baseUrl}/pay/provider.yml`,
|
|
599
|
+
routing: {
|
|
600
|
+
type: 'proxy',
|
|
601
|
+
upstream: `${baseUrl}/`,
|
|
602
|
+
},
|
|
603
|
+
monetization: {
|
|
604
|
+
mode: 'metered',
|
|
605
|
+
unit: 'requests',
|
|
606
|
+
sourceOfTruth: 'SAP MCP per-tool x402 pricing remains authoritative after proxy forwarding.',
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
servers: [
|
|
610
|
+
{ url: baseUrl, description: 'SAP MCP Hosted Server' },
|
|
611
|
+
],
|
|
612
|
+
paths: {
|
|
613
|
+
'/mcp': {
|
|
614
|
+
post: {
|
|
615
|
+
operationId: 'mcpCall',
|
|
616
|
+
summary: 'Fetch SAP MCP JSON-RPC response',
|
|
617
|
+
tags: ['MCP'],
|
|
618
|
+
'x-payment-info': {
|
|
619
|
+
price: {
|
|
620
|
+
mode: 'dynamic',
|
|
621
|
+
currency: 'USD',
|
|
622
|
+
min: formatOpenApiUsd(priceRange.minUsd),
|
|
623
|
+
max: formatOpenApiUsd(priceRange.maxUsd),
|
|
624
|
+
},
|
|
625
|
+
protocols: ['x402'],
|
|
626
|
+
...(config.paymentDiscovery
|
|
627
|
+
? {
|
|
628
|
+
network: config.paymentDiscovery.network,
|
|
629
|
+
asset: config.paymentDiscovery.asset,
|
|
630
|
+
assetSymbol: config.paymentDiscovery.assetSymbol,
|
|
631
|
+
payTo: config.paymentDiscovery.payTo,
|
|
632
|
+
seller: config.paymentDiscovery.seller,
|
|
633
|
+
facilitator: config.paymentDiscovery.facilitator,
|
|
634
|
+
}
|
|
635
|
+
: {}),
|
|
636
|
+
},
|
|
637
|
+
requestBody: {
|
|
638
|
+
required: true,
|
|
639
|
+
content: {
|
|
640
|
+
'application/json': {
|
|
641
|
+
schema: {
|
|
642
|
+
oneOf: [
|
|
643
|
+
{
|
|
644
|
+
type: 'object',
|
|
645
|
+
description: 'General MCP JSON-RPC request such as initialize, tools/list, prompts/list, resources/list, or ping.',
|
|
646
|
+
properties: {
|
|
647
|
+
jsonrpc: { type: 'string', const: '2.0' },
|
|
648
|
+
id: { oneOf: [{ type: 'integer' }, { type: 'string' }], description: 'JSON-RPC request ID' },
|
|
649
|
+
method: {
|
|
650
|
+
type: 'string',
|
|
651
|
+
description: 'MCP method.',
|
|
652
|
+
examples: ['initialize', 'tools/list', 'prompts/list', 'resources/list', 'ping'],
|
|
653
|
+
},
|
|
654
|
+
params: { type: 'object', additionalProperties: true },
|
|
655
|
+
},
|
|
656
|
+
required: ['jsonrpc', 'id', 'method'],
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
type: 'object',
|
|
660
|
+
description: 'MCP tools/call request. Paid tool calls may require x402 payment before execution.',
|
|
661
|
+
properties: {
|
|
662
|
+
jsonrpc: { type: 'string', const: '2.0' },
|
|
663
|
+
id: { oneOf: [{ type: 'integer' }, { type: 'string' }], description: 'JSON-RPC request ID' },
|
|
664
|
+
method: { type: 'string', const: 'tools/call' },
|
|
665
|
+
params: {
|
|
666
|
+
type: 'object',
|
|
667
|
+
description: 'MCP tools/call parameters containing the target tool name and optional tool arguments.',
|
|
668
|
+
properties: {
|
|
669
|
+
name: { type: 'string', description: 'Tool name (e.g. jupiter_getPrice)' },
|
|
670
|
+
arguments: { type: 'object', description: 'Tool arguments', additionalProperties: true },
|
|
671
|
+
},
|
|
672
|
+
required: ['name'],
|
|
673
|
+
additionalProperties: true,
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
required: ['jsonrpc', 'id', 'method', 'params'],
|
|
677
|
+
},
|
|
678
|
+
],
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
responses: {
|
|
684
|
+
'200': {
|
|
685
|
+
description: 'MCP tool result or payment_required JSON-RPC error (for MCP SDK clients)',
|
|
686
|
+
content: {
|
|
687
|
+
'application/json': {
|
|
688
|
+
schema: {
|
|
689
|
+
type: 'object',
|
|
690
|
+
properties: {
|
|
691
|
+
jsonrpc: { type: 'string' },
|
|
692
|
+
id: { type: 'integer' },
|
|
693
|
+
result: { type: 'object', description: 'Tool result (if free or paid)' },
|
|
694
|
+
error: {
|
|
695
|
+
type: 'object',
|
|
696
|
+
properties: {
|
|
697
|
+
code: { type: 'integer' },
|
|
698
|
+
message: { type: 'string' },
|
|
699
|
+
data: {
|
|
700
|
+
type: 'object',
|
|
701
|
+
properties: {
|
|
702
|
+
protocol: { type: 'string' },
|
|
703
|
+
paymentRequirements: { type: 'array' },
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
'text/event-stream': {
|
|
712
|
+
schema: {
|
|
713
|
+
type: 'string',
|
|
714
|
+
description: 'Streamable HTTP MCP event stream containing JSON-RPC response events.',
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
'402': {
|
|
720
|
+
description: 'Payment Required (x402 challenge for non-MCP clients)',
|
|
721
|
+
content: {
|
|
722
|
+
'application/json': {
|
|
723
|
+
schema: {
|
|
724
|
+
type: 'object',
|
|
725
|
+
properties: {
|
|
726
|
+
x402Version: { type: 'integer' },
|
|
727
|
+
error: { type: 'string' },
|
|
728
|
+
accepts: {
|
|
729
|
+
type: 'array',
|
|
730
|
+
items: {
|
|
731
|
+
type: 'object',
|
|
732
|
+
properties: {
|
|
733
|
+
scheme: { type: 'string' },
|
|
734
|
+
network: { type: 'string' },
|
|
735
|
+
amount: { type: 'string', description: 'USDC atomic units (6 decimals)' },
|
|
736
|
+
asset: { type: 'string' },
|
|
737
|
+
payTo: { type: 'string' },
|
|
738
|
+
maxTimeoutSeconds: { type: 'integer' },
|
|
739
|
+
extra: {
|
|
740
|
+
type: 'object',
|
|
741
|
+
properties: {
|
|
742
|
+
feePayer: { type: 'string' },
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
resource: {
|
|
749
|
+
type: 'object',
|
|
750
|
+
properties: {
|
|
751
|
+
url: { type: 'string' },
|
|
752
|
+
description: { type: 'string' },
|
|
753
|
+
mimeType: { type: 'string' },
|
|
754
|
+
serviceName: { type: 'string' },
|
|
755
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* @name buildX402DiscoveryDocument
|
|
771
|
+
* @description Builds the compatibility discovery document consumed by x402scan.
|
|
772
|
+
*/
|
|
773
|
+
export function buildX402DiscoveryDocument(req, config) {
|
|
774
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
775
|
+
return {
|
|
776
|
+
version: 1,
|
|
777
|
+
resources: [`${baseUrl}/mcp`],
|
|
778
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
779
|
+
instructions: 'SAP MCP exposes paid hosted MCP tool calls at /mcp. Probe POST /mcp without a payment header to receive a parseable x402 challenge for paid tools.',
|
|
780
|
+
};
|
|
781
|
+
}
|
|
218
782
|
/**
|
|
219
783
|
* @name buildLandingHtml
|
|
220
784
|
* @description Builds professional public HTML metadata for root and `/mcp` URL previews.
|
|
221
785
|
*/
|
|
222
786
|
export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
223
787
|
const info = buildPublicServerInfo(req, config);
|
|
788
|
+
const paymentStats = readPublicPaymentStats();
|
|
224
789
|
const pageUrl = endpoint === 'mcp' ? info.endpoints.mcp : info.endpoints.landing;
|
|
225
790
|
const title = endpoint === 'mcp'
|
|
226
791
|
? 'SAP MCP Streamable HTTP Endpoint | OOBE Protocol'
|
|
227
792
|
: info.title;
|
|
228
793
|
const endpointLabel = endpoint === 'mcp' ? 'Streamable HTTP MCP endpoint' : 'Hosted MCP gateway';
|
|
794
|
+
const wizardCommand = WIZARD_NPM_COMMAND;
|
|
795
|
+
const installScriptCommand = 'curl -fsSL https://mcp.sap.oobeprotocol.ai/wizard/install.sh | sh';
|
|
796
|
+
const x402PaidCallCommand = X402_PAID_CALL_NPX_COMMAND;
|
|
229
797
|
const publicInfo = JSON.stringify(info, null, 2).replace(/</g, '\\u003c');
|
|
230
798
|
return `<!doctype html>
|
|
231
799
|
<html lang="en">
|
|
@@ -236,8 +804,9 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
236
804
|
<meta name="description" content="${escapeHtml(info.description)}">
|
|
237
805
|
<meta name="robots" content="index,follow">
|
|
238
806
|
<link rel="canonical" href="${escapeHtml(pageUrl)}">
|
|
807
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
239
808
|
<link rel="icon" type="image/png" href="/favicon.png">
|
|
240
|
-
<link rel="shortcut icon"
|
|
809
|
+
<link rel="shortcut icon" href="/favicon.ico">
|
|
241
810
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
242
811
|
<meta property="og:type" content="website">
|
|
243
812
|
<meta property="og:title" content="${escapeHtml(title)}">
|
|
@@ -250,72 +819,343 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
250
819
|
<meta name="twitter:image" content="${escapeHtml(info.endpoints.favicon)}">
|
|
251
820
|
<script type="application/ld+json">${publicInfo}</script>
|
|
252
821
|
<style>
|
|
253
|
-
:root {
|
|
822
|
+
:root {
|
|
823
|
+
color-scheme: dark;
|
|
824
|
+
--aqua: #28d8e8;
|
|
825
|
+
--aqua-soft: rgba(40,216,232,.13);
|
|
826
|
+
--ink: #f4fbff;
|
|
827
|
+
--muted: #a8bdc6;
|
|
828
|
+
--subtle: #76909a;
|
|
829
|
+
--panel: rgba(255,255,255,.062);
|
|
830
|
+
--panel-strong: rgba(255,255,255,.095);
|
|
831
|
+
--line: rgba(255,255,255,.14);
|
|
832
|
+
--line-strong: rgba(40,216,232,.38);
|
|
833
|
+
--surface: #080d12;
|
|
834
|
+
--success: #8ef3c5;
|
|
835
|
+
--warning: #ffd38a;
|
|
836
|
+
}
|
|
254
837
|
* { box-sizing: border-box; }
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
838
|
+
html { scroll-behavior: smooth; }
|
|
839
|
+
body {
|
|
840
|
+
margin: 0;
|
|
841
|
+
min-height: 100vh;
|
|
842
|
+
font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
843
|
+
color: var(--ink);
|
|
844
|
+
background:
|
|
845
|
+
linear-gradient(115deg, rgba(40,216,232,.13), transparent 34%),
|
|
846
|
+
linear-gradient(180deg, #0a1218 0%, var(--surface) 46%, #05080b 100%);
|
|
847
|
+
}
|
|
848
|
+
body::before {
|
|
849
|
+
content: "";
|
|
850
|
+
position: fixed;
|
|
851
|
+
inset: 0;
|
|
852
|
+
pointer-events: none;
|
|
853
|
+
background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px);
|
|
854
|
+
background-size: 64px 64px;
|
|
855
|
+
mask-image: linear-gradient(to bottom, rgba(0,0,0,.75), transparent 72%);
|
|
856
|
+
}
|
|
857
|
+
main { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 40px; position: relative; }
|
|
858
|
+
header { display: grid; grid-template-columns: 92px 1fr; gap: 24px; align-items: center; margin-bottom: 30px; }
|
|
859
|
+
img.logo { width: 92px; height: 92px; border-radius: 24px; box-shadow: 0 18px 70px rgba(40,216,232,.22); }
|
|
860
|
+
h1 { margin: 0; max-width: 920px; font-size: clamp(2.2rem, 5vw, 5rem); line-height: .95; letter-spacing: 0; }
|
|
260
861
|
p { margin: 10px 0 0; color: var(--muted); max-width: 760px; }
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
862
|
+
a { color: var(--aqua); text-underline-offset: 4px; }
|
|
863
|
+
a:focus-visible { outline: 2px solid var(--aqua); outline-offset: 3px; border-radius: 6px; }
|
|
864
|
+
code {
|
|
865
|
+
color: #e9fcff;
|
|
866
|
+
overflow-wrap: anywhere;
|
|
867
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
868
|
+
font-size: .92em;
|
|
869
|
+
}
|
|
870
|
+
.badge { display: inline-flex; align-items: center; gap: 8px; color: var(--aqua); font-weight: 750; text-transform: uppercase; font-size: .78rem; letter-spacing: 0; }
|
|
871
|
+
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 22px; }
|
|
872
|
+
.button {
|
|
873
|
+
display: inline-flex;
|
|
874
|
+
min-height: 42px;
|
|
875
|
+
align-items: center;
|
|
876
|
+
justify-content: center;
|
|
877
|
+
border: 1px solid var(--line-strong);
|
|
878
|
+
border-radius: 8px;
|
|
879
|
+
padding: 9px 14px;
|
|
880
|
+
background: var(--aqua-soft);
|
|
881
|
+
color: var(--ink);
|
|
882
|
+
font-weight: 700;
|
|
883
|
+
text-decoration: none;
|
|
884
|
+
}
|
|
885
|
+
.button.secondary { background: transparent; border-color: var(--line); color: var(--aqua); }
|
|
886
|
+
.bento { display: grid; grid-template-columns: repeat(12, 1fr); gap: 14px; margin-top: 28px; }
|
|
887
|
+
.card {
|
|
888
|
+
min-height: 148px;
|
|
889
|
+
border: 1px solid var(--line);
|
|
890
|
+
border-radius: 8px;
|
|
891
|
+
background: var(--panel);
|
|
892
|
+
padding: 18px;
|
|
893
|
+
color: inherit;
|
|
894
|
+
text-decoration: none;
|
|
895
|
+
}
|
|
896
|
+
.card.strong { background: linear-gradient(180deg, var(--panel-strong), var(--panel)); border-color: var(--line-strong); }
|
|
897
|
+
.span-3 { grid-column: span 3; }
|
|
898
|
+
.span-4 { grid-column: span 4; }
|
|
899
|
+
.span-5 { grid-column: span 5; }
|
|
900
|
+
.span-6 { grid-column: span 6; }
|
|
901
|
+
.span-7 { grid-column: span 7; }
|
|
902
|
+
.span-8 { grid-column: span 8; }
|
|
903
|
+
.span-12 { grid-column: span 12; }
|
|
904
|
+
h2 { margin: 0 0 12px; font-size: 1rem; color: var(--aqua); letter-spacing: 0; }
|
|
905
|
+
h3 { margin: 0 0 6px; font-size: .86rem; color: var(--muted); font-weight: 650; text-transform: uppercase; letter-spacing: 0; }
|
|
906
|
+
.metric { margin-top: 4px; font-size: clamp(2rem, 4vw, 3.35rem); line-height: 1; font-weight: 800; letter-spacing: 0; }
|
|
907
|
+
.metric.small { font-size: clamp(1.55rem, 3vw, 2.3rem); }
|
|
908
|
+
.caption { margin-top: 10px; color: var(--subtle); font-size: .94rem; }
|
|
909
|
+
.status { color: var(--success); }
|
|
910
|
+
.warning { color: var(--warning); }
|
|
911
|
+
.list { padding-left: 18px; margin: 0; color: var(--muted); }
|
|
912
|
+
.list li + li { margin-top: 8px; }
|
|
913
|
+
.command { margin-top: 12px; border: 1px solid rgba(255,255,255,.10); border-radius: 8px; background: rgba(0,0,0,.28); padding: 13px; }
|
|
914
|
+
.download-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
|
915
|
+
.download-link {
|
|
916
|
+
display: grid;
|
|
917
|
+
min-height: 74px;
|
|
918
|
+
align-content: center;
|
|
919
|
+
gap: 4px;
|
|
920
|
+
border: 1px solid var(--line);
|
|
921
|
+
border-radius: 8px;
|
|
922
|
+
background: rgba(0,0,0,.18);
|
|
923
|
+
padding: 12px;
|
|
924
|
+
color: var(--ink);
|
|
925
|
+
text-decoration: none;
|
|
926
|
+
}
|
|
927
|
+
.download-link strong { color: var(--aqua); }
|
|
928
|
+
.download-link span { color: var(--muted); font-size: .88rem; }
|
|
929
|
+
.endpoint-list { display: grid; gap: 10px; margin-top: 10px; }
|
|
930
|
+
.endpoint-row { display: grid; grid-template-columns: 72px 1fr; gap: 12px; align-items: baseline; }
|
|
931
|
+
.method { color: var(--success); font: 700 .78rem/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
269
932
|
.footer { margin-top: 26px; color: var(--muted); font-size: .94rem; }
|
|
933
|
+
@media (max-width: 860px) {
|
|
934
|
+
header { grid-template-columns: 1fr; }
|
|
935
|
+
img.logo { width: 76px; height: 76px; border-radius: 20px; }
|
|
936
|
+
.span-3, .span-4, .span-5, .span-6, .span-7, .span-8 { grid-column: span 12; }
|
|
937
|
+
.download-grid { grid-template-columns: 1fr; }
|
|
938
|
+
}
|
|
939
|
+
@media (min-width: 861px) and (max-width: 1120px) {
|
|
940
|
+
.span-3, .span-4 { grid-column: span 6; }
|
|
941
|
+
.span-5, .span-6, .span-7, .span-8 { grid-column: span 12; }
|
|
942
|
+
}
|
|
943
|
+
@media (prefers-reduced-motion: reduce) {
|
|
944
|
+
html { scroll-behavior: auto; }
|
|
945
|
+
}
|
|
270
946
|
</style>
|
|
271
947
|
</head>
|
|
272
948
|
<body>
|
|
273
949
|
<main>
|
|
274
950
|
<header>
|
|
275
|
-
<img src="/favicon.png" alt="SAP MCP logo">
|
|
951
|
+
<img class="logo" src="/favicon.png" alt="SAP MCP logo" width="92" height="92">
|
|
276
952
|
<div>
|
|
277
953
|
<span class="badge">OOBE Protocol • ${escapeHtml(endpointLabel)}</span>
|
|
278
954
|
<h1>${escapeHtml(title)}</h1>
|
|
279
955
|
<p>${escapeHtml(info.description)}</p>
|
|
956
|
+
<div class="hero-actions">
|
|
957
|
+
<a class="button" href="/wizard/install.sh">Install wizard</a>
|
|
958
|
+
<a class="button secondary" href="/wizard/downloads.json">Downloads</a>
|
|
959
|
+
<a class="button secondary" href="/docs">Docs</a>
|
|
960
|
+
<a class="button secondary" href="/server.json">Server JSON</a>
|
|
961
|
+
<a class="button secondary" href="/openapi.json">OpenAPI</a>
|
|
962
|
+
<a class="button secondary" href="/pay/provider.yml">pay.sh YAML</a>
|
|
963
|
+
<a class="button secondary" href="/.well-known/agent-card.json">Agent card</a>
|
|
964
|
+
</div>
|
|
280
965
|
</div>
|
|
281
966
|
</header>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
<
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
967
|
+
|
|
968
|
+
<div class="bento" aria-label="SAP MCP hosted server overview">
|
|
969
|
+
<section class="card strong span-4">
|
|
970
|
+
<h3>Facilitator Volume</h3>
|
|
971
|
+
<div class="metric">${escapeHtml(formatUsd(paymentStats.totalVolumeUsd))}</div>
|
|
972
|
+
<p class="caption">Estimated settled x402/pay.sh tool volume recorded by the hosted payment ledger.</p>
|
|
973
|
+
</section>
|
|
974
|
+
|
|
975
|
+
<section class="card strong span-4">
|
|
976
|
+
<h3>Total Settlements</h3>
|
|
977
|
+
<div class="metric">${escapeHtml(formatInteger(paymentStats.totalSettlements))}</div>
|
|
978
|
+
<p class="caption">${escapeHtml(formatLastSettlement(paymentStats))}</p>
|
|
979
|
+
</section>
|
|
980
|
+
|
|
981
|
+
<section class="card span-4">
|
|
982
|
+
<h3>Payment Requests</h3>
|
|
983
|
+
<div class="metric">${escapeHtml(formatInteger(paymentStats.totalPaymentRequests))}</div>
|
|
984
|
+
<p class="caption"><span class="status">${escapeHtml(formatInteger(paymentStats.totalVerifiedPayments))}</span> verified, <span class="warning">${escapeHtml(formatInteger(paymentStats.totalFailedSettlements))}</span> failed settlements.</p>
|
|
985
|
+
</section>
|
|
986
|
+
|
|
987
|
+
<section class="card span-7">
|
|
988
|
+
<h2>Install Wizard</h2>
|
|
989
|
+
<p>Create a local SAP MCP profile, signer, policy limits, and optional client injection. Hosted users still sign x402/pay.sh and value-moving operations from their own machine or external signer.</p>
|
|
990
|
+
<div class="command"><code>${escapeHtml(installScriptCommand)}</code></div>
|
|
991
|
+
<div class="command"><code>${escapeHtml(wizardCommand)}</code></div>
|
|
992
|
+
</section>
|
|
993
|
+
|
|
994
|
+
<section class="card span-5">
|
|
995
|
+
<h2>Native Downloads</h2>
|
|
996
|
+
<p>Use the desktop wizard when you want a guided setup without touching config files by hand.</p>
|
|
997
|
+
<div class="download-grid">
|
|
998
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.windowsX64Setup)}"><strong>Windows</strong><span>x64 setup .exe</span></a>
|
|
999
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.macosArm64Dmg)}"><strong>macOS</strong><span>Apple Silicon .dmg</span></a>
|
|
1000
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.linuxX64TarGz)}"><strong>Linux</strong><span>x64 tar.gz</span></a>
|
|
1001
|
+
</div>
|
|
1002
|
+
<p class="caption">Machine-readable links: <a href="${escapeHtml(info.endpoints.wizardDownloads)}">/wizard/downloads.json</a>.</p>
|
|
291
1003
|
</section>
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
<li>x402
|
|
297
|
-
<li>
|
|
298
|
-
<li>
|
|
1004
|
+
|
|
1005
|
+
<section class="card span-5">
|
|
1006
|
+
<h2>Payments</h2>
|
|
1007
|
+
<ul class="list">
|
|
1008
|
+
<li><strong>x402:</strong> paid MCP tool calls return HTTP 402 with payment requirements, then settle through the OOBE facilitator.</li>
|
|
1009
|
+
<li><strong>pay.sh:</strong> public provider YAML is available at <a href="${escapeHtml(info.endpoints.payShProvider)}">/pay/provider.yml</a> for catalog and proxy workflows.</li>
|
|
1010
|
+
<li><strong>Local stdio:</strong> free by default; monetization applies to hosted remote usage.</li>
|
|
299
1011
|
</ul>
|
|
300
1012
|
</section>
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
<
|
|
1013
|
+
|
|
1014
|
+
<section class="card strong span-12">
|
|
1015
|
+
<h2>x402 Paid-Call Plugin For Agents</h2>
|
|
1016
|
+
<p>Agents that can reach hosted MCP but cannot replay x402 challenges natively can install the wizard addon or invoke the local paid-call helper directly. The helper signs the payment payload locally and retries the hosted tool call without sending keypair bytes to OOBE.</p>
|
|
1017
|
+
<div class="command"><code>Addon path: ${escapeHtml(X402_PAID_CALL_ADDON_PATH)}</code></div>
|
|
1018
|
+
<div class="command"><code>${escapeHtml(x402PaidCallCommand)}</code></div>
|
|
1019
|
+
</section>
|
|
1020
|
+
|
|
1021
|
+
<section class="card span-8">
|
|
1022
|
+
<h2>Endpoints</h2>
|
|
1023
|
+
<div class="endpoint-list">
|
|
1024
|
+
<div class="endpoint-row"><span class="method">POST</span><code>${escapeHtml(info.endpoints.mcp)}</code></div>
|
|
1025
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.docs)}</code></div>
|
|
1026
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.health)}</code></div>
|
|
1027
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.serverInfo)}</code></div>
|
|
1028
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.openApi)}</code></div>
|
|
1029
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.x402Discovery)}</code></div>
|
|
1030
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.payShProvider)}</code></div>
|
|
1031
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.agentCard)}</code></div>
|
|
1032
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.wizardDescriptor)}</code></div>
|
|
1033
|
+
</div>
|
|
1034
|
+
</section>
|
|
1035
|
+
|
|
1036
|
+
<section class="card span-4">
|
|
1037
|
+
<h2>Security Boundary</h2>
|
|
1038
|
+
<ul class="list">
|
|
304
1039
|
<li>Keypair bytes are never exposed by public endpoints.</li>
|
|
305
|
-
<li>
|
|
306
|
-
<li>Paid tools
|
|
1040
|
+
<li>RPC secrets are redacted from public metadata and UI.</li>
|
|
1041
|
+
<li>Paid tools require payment before execution.</li>
|
|
307
1042
|
</ul>
|
|
308
1043
|
</section>
|
|
309
|
-
<section>
|
|
310
|
-
<h2>Install Wizard</h2>
|
|
311
|
-
<p><code>npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config wizard</code></p>
|
|
312
|
-
</section>
|
|
313
1044
|
</div>
|
|
314
1045
|
<p class="footer">Use <a href="/server.json">/server.json</a> for machine-readable public metadata. MCP clients should connect to <a href="/mcp">/mcp</a> with <code>Accept: application/json, text/event-stream</code>.</p>
|
|
315
1046
|
</main>
|
|
316
1047
|
</body>
|
|
317
1048
|
</html>`;
|
|
318
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* @name buildDocsHtml
|
|
1052
|
+
* @description Builds the hosted SAP MCP documentation shell powered by Docsify.
|
|
1053
|
+
*/
|
|
1054
|
+
export function buildDocsHtml(req, config) {
|
|
1055
|
+
const info = buildPublicServerInfo(req, config);
|
|
1056
|
+
const docsUrl = info.endpoints.docs;
|
|
1057
|
+
const title = 'SAP MCP Documentation | OOBE Protocol';
|
|
1058
|
+
const description = 'Install, configure, and operate SAP MCP with a focus on hosted remote access, local signer setup, x402/pay.sh payments, and MCP client configuration.';
|
|
1059
|
+
const docsifyConfig = JSON.stringify({
|
|
1060
|
+
name: 'SAP MCP',
|
|
1061
|
+
repo: 'https://github.com/OOBE-PROTOCOL/sap-mcp',
|
|
1062
|
+
homepage: 'README.md',
|
|
1063
|
+
loadSidebar: true,
|
|
1064
|
+
alias: {
|
|
1065
|
+
'/.*/_sidebar.md': '/docs/_sidebar.md',
|
|
1066
|
+
},
|
|
1067
|
+
subMaxLevel: 2,
|
|
1068
|
+
auto2top: true,
|
|
1069
|
+
themeColor: '#28d8e8',
|
|
1070
|
+
search: {
|
|
1071
|
+
paths: 'auto',
|
|
1072
|
+
placeholder: 'Search SAP MCP docs',
|
|
1073
|
+
noData: 'No results.',
|
|
1074
|
+
depth: 6,
|
|
1075
|
+
},
|
|
1076
|
+
}, null, 2).replace(/</g, '\\u003c');
|
|
1077
|
+
return `<!doctype html>
|
|
1078
|
+
<html lang="en">
|
|
1079
|
+
<head>
|
|
1080
|
+
<meta charset="utf-8">
|
|
1081
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1082
|
+
<title>${escapeHtml(title)}</title>
|
|
1083
|
+
<meta name="description" content="${escapeHtml(description)}">
|
|
1084
|
+
<meta name="robots" content="index,follow">
|
|
1085
|
+
<link rel="canonical" href="${escapeHtml(docsUrl)}">
|
|
1086
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
1087
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
1088
|
+
<link rel="shortcut icon" href="/favicon.ico">
|
|
1089
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
1090
|
+
<meta property="og:type" content="website">
|
|
1091
|
+
<meta property="og:title" content="${escapeHtml(title)}">
|
|
1092
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
1093
|
+
<meta property="og:url" content="${escapeHtml(docsUrl)}">
|
|
1094
|
+
<meta property="og:image" content="${escapeHtml(info.endpoints.favicon)}">
|
|
1095
|
+
<meta name="twitter:card" content="summary">
|
|
1096
|
+
<meta name="twitter:title" content="${escapeHtml(title)}">
|
|
1097
|
+
<meta name="twitter:description" content="${escapeHtml(description)}">
|
|
1098
|
+
<meta name="twitter:image" content="${escapeHtml(info.endpoints.favicon)}">
|
|
1099
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/themes/vue.css">
|
|
1100
|
+
<style>
|
|
1101
|
+
:root {
|
|
1102
|
+
--theme-color: #28d8e8;
|
|
1103
|
+
--sidebar-width: 310px;
|
|
1104
|
+
}
|
|
1105
|
+
body {
|
|
1106
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1107
|
+
color: #17242b;
|
|
1108
|
+
}
|
|
1109
|
+
.app-name-link {
|
|
1110
|
+
color: #062730;
|
|
1111
|
+
font-weight: 800;
|
|
1112
|
+
}
|
|
1113
|
+
.sidebar {
|
|
1114
|
+
border-right: 1px solid rgba(6,39,48,.10);
|
|
1115
|
+
}
|
|
1116
|
+
.sidebar-nav strong,
|
|
1117
|
+
.markdown-section h1,
|
|
1118
|
+
.markdown-section h2,
|
|
1119
|
+
.markdown-section h3 {
|
|
1120
|
+
color: #062730;
|
|
1121
|
+
letter-spacing: 0;
|
|
1122
|
+
}
|
|
1123
|
+
.markdown-section a {
|
|
1124
|
+
color: #087f91;
|
|
1125
|
+
text-underline-offset: 3px;
|
|
1126
|
+
}
|
|
1127
|
+
.markdown-section code {
|
|
1128
|
+
color: #062730;
|
|
1129
|
+
border-radius: 6px;
|
|
1130
|
+
}
|
|
1131
|
+
.markdown-section pre {
|
|
1132
|
+
border-radius: 8px;
|
|
1133
|
+
border: 1px solid rgba(6,39,48,.10);
|
|
1134
|
+
}
|
|
1135
|
+
.markdown-section table {
|
|
1136
|
+
display: table;
|
|
1137
|
+
width: 100%;
|
|
1138
|
+
}
|
|
1139
|
+
.markdown-section blockquote {
|
|
1140
|
+
border-left-color: #28d8e8;
|
|
1141
|
+
color: #49656f;
|
|
1142
|
+
}
|
|
1143
|
+
.cover-main,
|
|
1144
|
+
section.cover {
|
|
1145
|
+
background: linear-gradient(115deg, rgba(40,216,232,.13), transparent 34%), #f8fdff;
|
|
1146
|
+
}
|
|
1147
|
+
</style>
|
|
1148
|
+
</head>
|
|
1149
|
+
<body>
|
|
1150
|
+
<div id="app">Loading SAP MCP documentation...</div>
|
|
1151
|
+
<script>
|
|
1152
|
+
window.$docsify = ${docsifyConfig};
|
|
1153
|
+
</script>
|
|
1154
|
+
<script src="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/docsify.min.js" defer></script>
|
|
1155
|
+
<script src="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/plugins/search.min.js" defer></script>
|
|
1156
|
+
</body>
|
|
1157
|
+
</html>`;
|
|
1158
|
+
}
|
|
319
1159
|
/**
|
|
320
1160
|
* @name acceptsHtmlPreview
|
|
321
1161
|
* @description Returns true for browser-style GET previews without interfering with MCP SSE clients.
|
|
@@ -324,6 +1164,30 @@ function acceptsHtmlPreview(req) {
|
|
|
324
1164
|
const accept = String(req.headers.accept ?? '');
|
|
325
1165
|
return accept.includes('text/html') && !accept.includes('text/event-stream') && !accept.includes('application/json');
|
|
326
1166
|
}
|
|
1167
|
+
function formatUsd(value) {
|
|
1168
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1169
|
+
return '$0.00';
|
|
1170
|
+
}
|
|
1171
|
+
return `$${value.toLocaleString('en-US', {
|
|
1172
|
+
minimumFractionDigits: value < 1 ? 3 : 2,
|
|
1173
|
+
maximumFractionDigits: value < 1 ? 3 : 2,
|
|
1174
|
+
})}`;
|
|
1175
|
+
}
|
|
1176
|
+
function formatInteger(value) {
|
|
1177
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1178
|
+
return '0';
|
|
1179
|
+
}
|
|
1180
|
+
return Math.trunc(value).toLocaleString('en-US');
|
|
1181
|
+
}
|
|
1182
|
+
function formatLastSettlement(stats) {
|
|
1183
|
+
if (!stats.ledgerAvailable) {
|
|
1184
|
+
return 'Ledger not initialized yet. Metrics appear after the first paid hosted call.';
|
|
1185
|
+
}
|
|
1186
|
+
if (!stats.lastSettlementAt) {
|
|
1187
|
+
return 'No settlements yet. Paid requests will update this card after successful settlement.';
|
|
1188
|
+
}
|
|
1189
|
+
return `Last settlement: ${stats.lastSettlementAt}`;
|
|
1190
|
+
}
|
|
327
1191
|
/**
|
|
328
1192
|
* @name escapeHtml
|
|
329
1193
|
* @description Escapes dynamic strings before embedding them in public HTML metadata.
|
|
@@ -432,13 +1296,16 @@ export function buildWizardInstallDescriptor(req, config) {
|
|
|
432
1296
|
],
|
|
433
1297
|
endpoints: {
|
|
434
1298
|
descriptor: `${baseUrl}/.well-known/sap-mcp-wizard.json`,
|
|
1299
|
+
downloads: `${baseUrl}/wizard/downloads.json`,
|
|
435
1300
|
installScript: `${baseUrl}/wizard/install.sh`,
|
|
436
1301
|
mcp: `${baseUrl}/mcp`,
|
|
437
1302
|
},
|
|
438
1303
|
commands: {
|
|
439
|
-
runWizard:
|
|
1304
|
+
runWizard: WIZARD_NPM_COMMAND,
|
|
440
1305
|
showConfig: 'npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config show',
|
|
441
1306
|
showProfile: 'npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config profile-info',
|
|
1307
|
+
installX402PaidCallAddon: WIZARD_NPM_COMMAND,
|
|
1308
|
+
runX402PaidCall: X402_PAID_CALL_NPX_COMMAND,
|
|
442
1309
|
},
|
|
443
1310
|
security: {
|
|
444
1311
|
keypairBytesExposed: false,
|
|
@@ -470,6 +1337,44 @@ export function buildWizardInstallScript(req, config) {
|
|
|
470
1337
|
'',
|
|
471
1338
|
].join('\n');
|
|
472
1339
|
}
|
|
1340
|
+
/**
|
|
1341
|
+
* @name extractToolNamesFromJsonRpc
|
|
1342
|
+
* @description Extracts tool names from a parsed JSON-RPC 2.0 request body for request logging.
|
|
1343
|
+
*/
|
|
1344
|
+
function extractToolNamesFromJsonRpc(body) {
|
|
1345
|
+
if (!body || typeof body !== 'object')
|
|
1346
|
+
return undefined;
|
|
1347
|
+
const record = body;
|
|
1348
|
+
// tools/call request: { method: "tools/call", params: { name: "tool_name", ... } }
|
|
1349
|
+
if (record.method === 'tools/call' && record.params && typeof record.params === 'object') {
|
|
1350
|
+
const params = record.params;
|
|
1351
|
+
if (typeof params.name === 'string')
|
|
1352
|
+
return [params.name];
|
|
1353
|
+
}
|
|
1354
|
+
// Batch request: array of requests
|
|
1355
|
+
if (Array.isArray(body)) {
|
|
1356
|
+
const names = [];
|
|
1357
|
+
for (const item of body) {
|
|
1358
|
+
const extracted = extractToolNamesFromJsonRpc(item);
|
|
1359
|
+
if (extracted)
|
|
1360
|
+
names.push(...extracted);
|
|
1361
|
+
}
|
|
1362
|
+
return names.length > 0 ? names : undefined;
|
|
1363
|
+
}
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* @name extractJsonRpcMethod
|
|
1368
|
+
* @description Extracts the JSON-RPC method name from a parsed request body for request logging.
|
|
1369
|
+
*/
|
|
1370
|
+
function extractJsonRpcMethod(body) {
|
|
1371
|
+
if (!body || typeof body !== 'object')
|
|
1372
|
+
return undefined;
|
|
1373
|
+
const record = body;
|
|
1374
|
+
if (typeof record.method === 'string')
|
|
1375
|
+
return record.method;
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
473
1378
|
/**
|
|
474
1379
|
* @name RemoteMCPServer
|
|
475
1380
|
* @description Remote MCP server backed by the official Streamable HTTP transport.
|
|
@@ -479,7 +1384,7 @@ export class RemoteMCPServer {
|
|
|
479
1384
|
appConfig;
|
|
480
1385
|
authManager;
|
|
481
1386
|
rateLimiter;
|
|
482
|
-
|
|
1387
|
+
statefulSessions = new Map();
|
|
483
1388
|
httpServer;
|
|
484
1389
|
constructor(config) {
|
|
485
1390
|
this.appConfig = loadConfig();
|
|
@@ -499,58 +1404,89 @@ export class RemoteMCPServer {
|
|
|
499
1404
|
async start() {
|
|
500
1405
|
this.validateAuthConfig();
|
|
501
1406
|
await this.rateLimiter.initialize();
|
|
502
|
-
const server = await createSapMcpServer(this.appConfig);
|
|
503
|
-
this.transport = new StreamableHTTPServerTransport({
|
|
504
|
-
sessionIdGenerator: this.config.stateless ? undefined : () => randomUUID(),
|
|
505
|
-
});
|
|
506
|
-
await server.connect(this.transport);
|
|
507
1407
|
const monetizationGate = await McpMonetizationGate.create(this.appConfig);
|
|
508
1408
|
this.httpServer = http.createServer(async (req, res) => {
|
|
509
1409
|
if (this.applyCors(req, res)) {
|
|
510
1410
|
return;
|
|
511
1411
|
}
|
|
512
1412
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
513
|
-
if (req.method
|
|
514
|
-
writeHtml(res, 200, buildLandingHtml(req, this.config));
|
|
1413
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/') {
|
|
1414
|
+
writeHtml(res, 200, buildLandingHtml(req, this.config), isHeadMethod(req.method));
|
|
1415
|
+
return;
|
|
1416
|
+
}
|
|
1417
|
+
if (isPublicReadMethod(req.method) && ['/docs', '/docs/', '/docs/index.html'].includes(url.pathname)) {
|
|
1418
|
+
writeHtml(res, 200, buildDocsHtml(req, this.config), isHeadMethod(req.method));
|
|
515
1419
|
return;
|
|
516
1420
|
}
|
|
517
|
-
if (req.method
|
|
1421
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/server.json') {
|
|
518
1422
|
writeJson(res, 200, buildPublicServerInfo(req, this.config), {
|
|
519
1423
|
'Cache-Control': 'public, max-age=300',
|
|
520
|
-
});
|
|
1424
|
+
}, isHeadMethod(req.method));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
const publicLogoAsset = resolvePublicLogoAsset(req.method, url.pathname);
|
|
1428
|
+
if (publicLogoAsset) {
|
|
1429
|
+
writeLogoAsset(res, publicLogoAsset);
|
|
521
1430
|
return;
|
|
522
1431
|
}
|
|
523
|
-
|
|
524
|
-
|
|
1432
|
+
const publicDocsMarkdown = resolvePublicDocsMarkdown(req.method, url.pathname);
|
|
1433
|
+
if (publicDocsMarkdown) {
|
|
1434
|
+
writeMarkdownAsset(res, publicDocsMarkdown);
|
|
525
1435
|
return;
|
|
526
1436
|
}
|
|
527
|
-
if (req.method
|
|
1437
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/health') {
|
|
528
1438
|
writeJson(res, 200, {
|
|
529
1439
|
status: 'ok',
|
|
530
1440
|
service: 'sap-mcp-remote',
|
|
531
1441
|
transport: 'streamable-http',
|
|
532
1442
|
timestamp: new Date().toISOString(),
|
|
533
|
-
});
|
|
1443
|
+
}, {}, isHeadMethod(req.method));
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
if (isPublicReadMethod(req.method) && ['/.well-known/agent-card.json', '/a2a/agent-card'].includes(url.pathname)) {
|
|
1447
|
+
writeJson(res, 200, buildA2AAgentCard(req, this.config), {}, isHeadMethod(req.method));
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
if (isPublicReadMethod(req.method) && ['/.well-known/sap-mcp-wizard.json', '/wizard', '/wizard.json'].includes(url.pathname)) {
|
|
1451
|
+
writeJson(res, 200, buildWizardInstallDescriptor(req, this.config), {}, isHeadMethod(req.method));
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/wizard/downloads.json') {
|
|
1455
|
+
const info = buildPublicServerInfo(req, this.config);
|
|
1456
|
+
writeJson(res, 200, info.downloads, {
|
|
1457
|
+
'Cache-Control': 'public, max-age=300',
|
|
1458
|
+
}, isHeadMethod(req.method));
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/openapi.json') {
|
|
1462
|
+
writeJson(res, 200, buildOpenApiSpec(req, this.config), {
|
|
1463
|
+
'Cache-Control': 'public, max-age=300',
|
|
1464
|
+
}, isHeadMethod(req.method));
|
|
534
1465
|
return;
|
|
535
1466
|
}
|
|
536
|
-
if (req.method
|
|
537
|
-
|
|
1467
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/pay/provider.yml') {
|
|
1468
|
+
const providerYaml = buildPublicPayShProviderYaml(req, this.config, this.appConfig);
|
|
1469
|
+
writeText(res, 200, providerYaml, 'application/yaml; charset=utf-8', {}, isHeadMethod(req.method));
|
|
538
1470
|
return;
|
|
539
1471
|
}
|
|
540
|
-
if (req.method
|
|
541
|
-
writeJson(res, 200,
|
|
1472
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/.well-known/x402') {
|
|
1473
|
+
writeJson(res, 200, buildX402DiscoveryDocument(req, this.config), {
|
|
1474
|
+
'Cache-Control': 'public, max-age=300',
|
|
1475
|
+
}, isHeadMethod(req.method));
|
|
542
1476
|
return;
|
|
543
1477
|
}
|
|
544
|
-
if (req.method
|
|
1478
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/wizard/install.sh') {
|
|
1479
|
+
const script = buildWizardInstallScript(req, this.config);
|
|
545
1480
|
res.writeHead(200, {
|
|
546
1481
|
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
|
547
1482
|
'Cache-Control': 'public, max-age=300',
|
|
1483
|
+
'Content-Length': Buffer.byteLength(script),
|
|
548
1484
|
});
|
|
549
|
-
res.end(
|
|
1485
|
+
res.end(isHeadMethod(req.method) ? undefined : script);
|
|
550
1486
|
return;
|
|
551
1487
|
}
|
|
552
|
-
if (req.method
|
|
553
|
-
writeHtml(res, 200, buildLandingHtml(req, this.config, 'mcp'));
|
|
1488
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/mcp' && (isHeadMethod(req.method) || acceptsHtmlPreview(req))) {
|
|
1489
|
+
writeHtml(res, 200, buildLandingHtml(req, this.config, 'mcp'), isHeadMethod(req.method));
|
|
554
1490
|
return;
|
|
555
1491
|
}
|
|
556
1492
|
if (url.pathname !== '/mcp') {
|
|
@@ -577,14 +1513,64 @@ export class RemoteMCPServer {
|
|
|
577
1513
|
});
|
|
578
1514
|
return;
|
|
579
1515
|
}
|
|
1516
|
+
// ── Request logging ─────────────────────────────────────────────
|
|
1517
|
+
const reqStart = Date.now();
|
|
1518
|
+
const callerIp = getRateLimitKey(req);
|
|
1519
|
+
const originalEnd = res.end.bind(res);
|
|
1520
|
+
let loggedMethod;
|
|
1521
|
+
let loggedToolNames;
|
|
1522
|
+
res.end = ((...args) => {
|
|
1523
|
+
const latencyMs = Date.now() - reqStart;
|
|
1524
|
+
const statusCode = res.statusCode;
|
|
1525
|
+
logger.info('MCP request', {
|
|
1526
|
+
method: req.method,
|
|
1527
|
+
path: url.pathname,
|
|
1528
|
+
status: statusCode,
|
|
1529
|
+
latencyMs,
|
|
1530
|
+
callerIp,
|
|
1531
|
+
toolNames: loggedToolNames,
|
|
1532
|
+
rpcMethod: loggedMethod,
|
|
1533
|
+
});
|
|
1534
|
+
return originalEnd(...args);
|
|
1535
|
+
});
|
|
580
1536
|
try {
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
1537
|
+
if (this.config.stateless) {
|
|
1538
|
+
// Stateless mode: create a fresh transport + server per request.
|
|
1539
|
+
// The MCP SDK prohibits reusing a stateless transport across requests.
|
|
1540
|
+
const perRequestServer = await createSapMcpServer(this.appConfig);
|
|
1541
|
+
const perRequestTransport = new StreamableHTTPServerTransport({
|
|
1542
|
+
sessionIdGenerator: undefined,
|
|
584
1543
|
});
|
|
1544
|
+
await perRequestServer.connect(perRequestTransport);
|
|
1545
|
+
if (monetizationGate) {
|
|
1546
|
+
await monetizationGate.handle(req, res, async (mcpReq, mcpRes, parsedBody) => {
|
|
1547
|
+
// Extract tool names from parsed JSON-RPC body for logging
|
|
1548
|
+
loggedToolNames = extractToolNamesFromJsonRpc(parsedBody);
|
|
1549
|
+
loggedMethod = extractJsonRpcMethod(parsedBody);
|
|
1550
|
+
await perRequestTransport.handleRequest(mcpReq, mcpRes, parsedBody);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
else {
|
|
1554
|
+
// Parse body for logging before passing to transport
|
|
1555
|
+
// The transport will re-read it from the stream
|
|
1556
|
+
loggedToolNames = undefined; // Will be logged without tool names in free mode
|
|
1557
|
+
await perRequestTransport.handleRequest(req, res);
|
|
1558
|
+
}
|
|
585
1559
|
}
|
|
586
1560
|
else {
|
|
587
|
-
|
|
1561
|
+
// Stateful mode: route each request to the transport created by its initialize response.
|
|
1562
|
+
if (monetizationGate) {
|
|
1563
|
+
await monetizationGate.handle(req, res, async (mcpReq, mcpRes, parsedBody) => {
|
|
1564
|
+
loggedToolNames = extractToolNamesFromJsonRpc(parsedBody);
|
|
1565
|
+
loggedMethod = extractJsonRpcMethod(parsedBody);
|
|
1566
|
+
await this.handleStatefulMcpRequest(mcpReq, mcpRes, parsedBody);
|
|
1567
|
+
}, {
|
|
1568
|
+
validatePaidRequest: context => this.validateStatefulPaidRequest(context.request, context.parsedBody),
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
else {
|
|
1572
|
+
await this.handleStatefulMcpRequest(req, res);
|
|
1573
|
+
}
|
|
588
1574
|
}
|
|
589
1575
|
}
|
|
590
1576
|
catch (error) {
|
|
@@ -622,7 +1608,9 @@ export class RemoteMCPServer {
|
|
|
622
1608
|
* @description Gracefully closes the MCP transport and HTTP listener.
|
|
623
1609
|
*/
|
|
624
1610
|
async stop() {
|
|
625
|
-
|
|
1611
|
+
const sessions = Array.from(this.statefulSessions.values());
|
|
1612
|
+
this.statefulSessions.clear();
|
|
1613
|
+
await Promise.allSettled(sessions.map(session => session.transport.close()));
|
|
626
1614
|
if (!this.httpServer) {
|
|
627
1615
|
return;
|
|
628
1616
|
}
|
|
@@ -650,6 +1638,110 @@ export class RemoteMCPServer {
|
|
|
650
1638
|
throw new Error('Remote MCP JWT auth requires SAP_MCP_AUTH_SECRET');
|
|
651
1639
|
}
|
|
652
1640
|
}
|
|
1641
|
+
/**
|
|
1642
|
+
* @name handleStatefulMcpRequest
|
|
1643
|
+
* @description Routes a stateful MCP request to the session transport created by initialize.
|
|
1644
|
+
*/
|
|
1645
|
+
async handleStatefulMcpRequest(req, res, parsedBody) {
|
|
1646
|
+
const transport = await this.resolveStatefulTransport(req, parsedBody);
|
|
1647
|
+
if (!transport) {
|
|
1648
|
+
const providedSessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1649
|
+
writeMcpJsonRpcError(res, providedSessionId ? 404 : 400, providedSessionId ? -32011 : -32010, extractJsonRpcId(parsedBody), providedSessionId ? 'mcp_session_not_found' : 'mcp_session_required', {
|
|
1650
|
+
requiredHeader: 'mcp-session-id',
|
|
1651
|
+
...(providedSessionId ? { providedSessionId } : {}),
|
|
1652
|
+
hint: providedSessionId
|
|
1653
|
+
? 'The mcp-session-id must come from this server initialize response. Client-generated UUIDs are not valid MCP sessions.'
|
|
1654
|
+
: 'Call initialize first and reuse the returned mcp-session-id header. Do not generate a client-side UUID.',
|
|
1655
|
+
});
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* @name validateStatefulPaidRequest
|
|
1662
|
+
* @description Fails paid tool calls before x402 verification when the MCP session is missing or unknown.
|
|
1663
|
+
*/
|
|
1664
|
+
validateStatefulPaidRequest(req, parsedBody) {
|
|
1665
|
+
if (isInitializeJsonRpcRequest(parsedBody)) {
|
|
1666
|
+
return undefined;
|
|
1667
|
+
}
|
|
1668
|
+
const sessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1669
|
+
if (!sessionId) {
|
|
1670
|
+
return {
|
|
1671
|
+
code: -32010,
|
|
1672
|
+
message: 'mcp_session_required',
|
|
1673
|
+
data: {
|
|
1674
|
+
requiredHeader: 'mcp-session-id',
|
|
1675
|
+
hint: 'Initialize the MCP Streamable HTTP session first, then reuse the returned mcp-session-id for the unpaid payment challenge and the paid retry.',
|
|
1676
|
+
},
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
if (!this.statefulSessions.has(sessionId)) {
|
|
1680
|
+
return {
|
|
1681
|
+
code: -32011,
|
|
1682
|
+
message: 'mcp_session_not_found',
|
|
1683
|
+
data: {
|
|
1684
|
+
requiredHeader: 'mcp-session-id',
|
|
1685
|
+
providedSessionId: sessionId,
|
|
1686
|
+
hint: 'The mcp-session-id must come from this server initialize response. Client-generated UUIDs are not valid MCP sessions.',
|
|
1687
|
+
},
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
return undefined;
|
|
1691
|
+
}
|
|
1692
|
+
/**
|
|
1693
|
+
* @name resolveStatefulTransport
|
|
1694
|
+
* @description Returns the transport for an existing session or creates one for an initialize request.
|
|
1695
|
+
*/
|
|
1696
|
+
async resolveStatefulTransport(req, parsedBody) {
|
|
1697
|
+
const sessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1698
|
+
if (sessionId) {
|
|
1699
|
+
const session = this.statefulSessions.get(sessionId);
|
|
1700
|
+
if (!session) {
|
|
1701
|
+
return undefined;
|
|
1702
|
+
}
|
|
1703
|
+
session.lastSeenAt = Date.now();
|
|
1704
|
+
return session.transport;
|
|
1705
|
+
}
|
|
1706
|
+
if (req.method === 'POST' && (parsedBody === undefined || isInitializeJsonRpcRequest(parsedBody))) {
|
|
1707
|
+
return await this.createStatefulSessionTransport();
|
|
1708
|
+
}
|
|
1709
|
+
return undefined;
|
|
1710
|
+
}
|
|
1711
|
+
/**
|
|
1712
|
+
* @name createStatefulSessionTransport
|
|
1713
|
+
* @description Creates one MCP server and Streamable HTTP transport for a single initialized session.
|
|
1714
|
+
*/
|
|
1715
|
+
async createStatefulSessionTransport() {
|
|
1716
|
+
const server = await createSapMcpServer(this.appConfig);
|
|
1717
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1718
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1719
|
+
onsessioninitialized: sessionId => {
|
|
1720
|
+
this.statefulSessions.set(sessionId, {
|
|
1721
|
+
server,
|
|
1722
|
+
transport,
|
|
1723
|
+
createdAt: Date.now(),
|
|
1724
|
+
lastSeenAt: Date.now(),
|
|
1725
|
+
});
|
|
1726
|
+
logger.info('Stateful MCP session initialized', {
|
|
1727
|
+
sessionId,
|
|
1728
|
+
activeSessions: this.statefulSessions.size,
|
|
1729
|
+
});
|
|
1730
|
+
},
|
|
1731
|
+
});
|
|
1732
|
+
transport.onclose = () => {
|
|
1733
|
+
const sessionId = transport.sessionId;
|
|
1734
|
+
if (sessionId) {
|
|
1735
|
+
this.statefulSessions.delete(sessionId);
|
|
1736
|
+
logger.info('Stateful MCP session closed', {
|
|
1737
|
+
sessionId,
|
|
1738
|
+
activeSessions: this.statefulSessions.size,
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
await server.connect(transport);
|
|
1743
|
+
return transport;
|
|
1744
|
+
}
|
|
653
1745
|
/**
|
|
654
1746
|
* @name applyCors
|
|
655
1747
|
* @description Applies configured CORS headers and handles preflight requests.
|