@oobe-protocol-labs/sap-mcp-server 0.3.0 → 0.7.1
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 +222 -2
- package/README.md +63 -0
- package/USER_DOCS/00_START_HERE.md +23 -1
- package/USER_DOCS/01_HOSTED_REMOTE_MCP.md +4 -1
- package/USER_DOCS/03_PAYMENTS_X402_PAYSH.md +63 -7
- package/USER_DOCS/04_CLIENT_CONFIGS.md +134 -6
- 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 +64 -2
- package/dist/config/mcp-client-injection.d.ts.map +1 -1
- package/dist/config/mcp-client-injection.js +668 -11
- package/dist/config/mcp-client-injection.js.map +1 -1
- package/dist/config/wizard.d.ts.map +1 -1
- package/dist/config/wizard.js +89 -5
- 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/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 +6 -0
- package/dist/payments/oobe-facilitator-server.d.ts.map +1 -1
- package/dist/payments/oobe-facilitator-server.js +79 -4
- package/dist/payments/oobe-facilitator-server.js.map +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 +9 -3
- 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 +132 -1
- package/dist/remote/server.d.ts.map +1 -1
- package/dist/remote/server.js +930 -49
- 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 +11 -1
- 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 +5 -5
- 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/signer/signer-resolver.js +1 -1
- 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 +20 -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/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 +28 -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/sap-defi/SKILL.md +8 -0
- package/skills/sap-mcp/SKILL.md +30 -0
- package/skills/sap-payments-x402/SKILL.md +36 -4
package/dist/remote/server.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { randomUUID } from 'crypto';
|
|
7
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
12
|
import { getDataDir, loadConfig } from '../config/env.js';
|
|
@@ -14,12 +14,23 @@ 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');
|
|
22
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';
|
|
33
|
+
const MCP_REGISTRY_AUTH_PATH = '/.well-known/mcp-registry-auth';
|
|
23
34
|
let logoAssetCache;
|
|
24
35
|
let paymentStatsCache;
|
|
25
36
|
/**
|
|
@@ -53,8 +64,13 @@ export function defaultRemoteConfig(appConfig) {
|
|
|
53
64
|
port,
|
|
54
65
|
host,
|
|
55
66
|
corsOrigins: appConfig.httpCorsOrigins,
|
|
56
|
-
stateless: parseRemoteBoolean(process.env.SAP_MCP_HTTP_STATELESS,
|
|
67
|
+
stateless: parseRemoteBoolean(process.env.SAP_MCP_HTTP_STATELESS, false),
|
|
57
68
|
rateLimit: buildRemoteRateLimitConfigFromEnv(appConfig.rateLimitPerMinute),
|
|
69
|
+
paymentDiscovery: buildPublicPaymentDiscovery(appConfig),
|
|
70
|
+
paymentPriceRange: {
|
|
71
|
+
minUsd: appConfig.monetization.prices.minUsd,
|
|
72
|
+
maxUsd: appConfig.monetization.prices.maxUsd,
|
|
73
|
+
},
|
|
58
74
|
};
|
|
59
75
|
if (authType === 'none') {
|
|
60
76
|
return {
|
|
@@ -86,6 +102,56 @@ export function defaultRemoteConfig(appConfig) {
|
|
|
86
102
|
},
|
|
87
103
|
};
|
|
88
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* @name buildPublicPaymentDiscovery
|
|
107
|
+
* @description Builds public x402 seller/facilitator coordinates without exposing facilitator URL or auth tokens.
|
|
108
|
+
*/
|
|
109
|
+
function buildPublicPaymentDiscovery(appConfig) {
|
|
110
|
+
if (!appConfig.monetization.enabled || !appConfig.monetization.payTo) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const network = resolvePaymentNetwork(appConfig);
|
|
114
|
+
const facilitatorPublicKey = (process.env.SAP_MCP_X402_FACILITATOR_PUBLIC_KEY
|
|
115
|
+
?? process.env.SAP_MCP_X402_FACILITATOR_FEE_PAYER
|
|
116
|
+
?? '').trim();
|
|
117
|
+
return {
|
|
118
|
+
provider: appConfig.monetization.provider,
|
|
119
|
+
network,
|
|
120
|
+
asset: resolvePublicPaymentAsset(network),
|
|
121
|
+
assetSymbol: 'USDC',
|
|
122
|
+
payTo: appConfig.monetization.payTo,
|
|
123
|
+
seller: {
|
|
124
|
+
name: 'OOBE Protocol',
|
|
125
|
+
payTo: appConfig.monetization.payTo,
|
|
126
|
+
},
|
|
127
|
+
...(facilitatorPublicKey
|
|
128
|
+
? {
|
|
129
|
+
facilitator: {
|
|
130
|
+
role: 'x402-svm-fee-payer',
|
|
131
|
+
publicKey: facilitatorPublicKey,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: {}),
|
|
135
|
+
headers: {
|
|
136
|
+
paymentRequired: 'PAYMENT-REQUIRED',
|
|
137
|
+
paymentSignature: 'PAYMENT-SIGNATURE',
|
|
138
|
+
paymentResponse: 'PAYMENT-RESPONSE',
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* @name resolvePublicPaymentAsset
|
|
144
|
+
* @description Resolves the public USDC mint advertised in x402 discovery metadata.
|
|
145
|
+
*/
|
|
146
|
+
function resolvePublicPaymentAsset(network) {
|
|
147
|
+
if (network === SOLANA_DEVNET_CAIP2) {
|
|
148
|
+
return USDC_DEVNET_MINT;
|
|
149
|
+
}
|
|
150
|
+
return USDC_MAINNET_MINT;
|
|
151
|
+
}
|
|
152
|
+
function formatOpenApiUsd(value) {
|
|
153
|
+
return value.toFixed(6).replace(/0+$/, '').replace(/\.$/, '');
|
|
154
|
+
}
|
|
89
155
|
function parseRemoteBoolean(raw, fallback) {
|
|
90
156
|
if (raw === undefined) {
|
|
91
157
|
return fallback;
|
|
@@ -96,38 +162,248 @@ function parseRemoteBoolean(raw, fallback) {
|
|
|
96
162
|
* @name writeJson
|
|
97
163
|
* @description Writes a JSON response with optional additional headers.
|
|
98
164
|
*/
|
|
99
|
-
function writeJson(res, status, body, headers = {}) {
|
|
165
|
+
function writeJson(res, status, body, headers = {}, omitBody = false) {
|
|
166
|
+
const serialized = JSON.stringify(body);
|
|
100
167
|
res.writeHead(status, {
|
|
101
168
|
'Content-Type': 'application/json',
|
|
169
|
+
'Content-Length': Buffer.byteLength(serialized),
|
|
102
170
|
...headers,
|
|
103
171
|
});
|
|
104
|
-
res.end(
|
|
172
|
+
res.end(omitBody ? undefined : serialized);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* @name writeText
|
|
176
|
+
* @description Writes a cacheable text response with an explicit content type.
|
|
177
|
+
*/
|
|
178
|
+
function writeText(res, status, body, contentType, headers = {}, omitBody = false) {
|
|
179
|
+
res.writeHead(status, {
|
|
180
|
+
'Content-Type': contentType,
|
|
181
|
+
'Cache-Control': 'public, max-age=300',
|
|
182
|
+
'Content-Length': Buffer.byteLength(body),
|
|
183
|
+
'X-Content-Type-Options': 'nosniff',
|
|
184
|
+
...headers,
|
|
185
|
+
});
|
|
186
|
+
res.end(omitBody ? undefined : body);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* @name writeMcpJsonRpcError
|
|
190
|
+
* @description Writes an MCP-compatible JSON-RPC error response with HTTP status semantics.
|
|
191
|
+
*/
|
|
192
|
+
function writeMcpJsonRpcError(res, status, code, id, message, data) {
|
|
193
|
+
writeJson(res, status, {
|
|
194
|
+
jsonrpc: '2.0',
|
|
195
|
+
id,
|
|
196
|
+
error: {
|
|
197
|
+
code,
|
|
198
|
+
message,
|
|
199
|
+
...(data === undefined ? {} : { data }),
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* @name readSingleHeader
|
|
205
|
+
* @description Reads one normalized HTTP header value without accepting ambiguous arrays.
|
|
206
|
+
*/
|
|
207
|
+
function readSingleHeader(value) {
|
|
208
|
+
if (Array.isArray(value)) {
|
|
209
|
+
return value[0]?.trim() || undefined;
|
|
210
|
+
}
|
|
211
|
+
return value?.trim() || undefined;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* @name extractJsonRpcId
|
|
215
|
+
* @description Extracts a JSON-RPC id for error correlation.
|
|
216
|
+
*/
|
|
217
|
+
function extractJsonRpcId(body) {
|
|
218
|
+
if (Array.isArray(body) && body.length > 0) {
|
|
219
|
+
return extractJsonRpcId(body[0]);
|
|
220
|
+
}
|
|
221
|
+
if (!body || typeof body !== 'object') {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const record = body;
|
|
225
|
+
return typeof record.id === 'string' || typeof record.id === 'number' ? record.id : null;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* @name isInitializeJsonRpcRequest
|
|
229
|
+
* @description Detects MCP initialize requests that are allowed to create a new stateful session.
|
|
230
|
+
*/
|
|
231
|
+
function isInitializeJsonRpcRequest(body) {
|
|
232
|
+
if (Array.isArray(body)) {
|
|
233
|
+
return body.some(isInitializeJsonRpcRequest);
|
|
234
|
+
}
|
|
235
|
+
if (!body || typeof body !== 'object') {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return body.method === 'initialize';
|
|
105
239
|
}
|
|
106
240
|
/**
|
|
107
241
|
* @name writeHtml
|
|
108
242
|
* @description Writes a cacheable HTML response for public endpoint previews and social sharing.
|
|
109
243
|
*/
|
|
110
|
-
function writeHtml(res, status, html) {
|
|
244
|
+
function writeHtml(res, status, html, omitBody = false) {
|
|
111
245
|
res.writeHead(status, {
|
|
112
246
|
'Content-Type': 'text/html; charset=utf-8',
|
|
113
247
|
'Cache-Control': 'public, max-age=300',
|
|
248
|
+
'Content-Length': Buffer.byteLength(html),
|
|
249
|
+
'X-Content-Type-Options': 'nosniff',
|
|
250
|
+
});
|
|
251
|
+
res.end(omitBody ? undefined : html);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* @name isPublicReadMethod
|
|
255
|
+
* @description Returns true for cacheable public metadata reads, including crawler-friendly HEAD checks.
|
|
256
|
+
*/
|
|
257
|
+
function isPublicReadMethod(method) {
|
|
258
|
+
return method === 'GET' || method === 'HEAD';
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* @name isHeadMethod
|
|
262
|
+
* @description Returns true when the request must emit headers without a response body.
|
|
263
|
+
*/
|
|
264
|
+
function isHeadMethod(method) {
|
|
265
|
+
return method === 'HEAD';
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* @name resolveMcpRegistryAuthRecord
|
|
269
|
+
* @description Resolves the MCP Registry HTTP ownership proof from a public env value or a private file path.
|
|
270
|
+
*/
|
|
271
|
+
function resolveMcpRegistryAuthRecord() {
|
|
272
|
+
const inlineRecord = process.env.SAP_MCP_REGISTRY_AUTH_RECORD?.trim();
|
|
273
|
+
if (inlineRecord) {
|
|
274
|
+
return inlineRecord;
|
|
275
|
+
}
|
|
276
|
+
const recordPath = process.env.SAP_MCP_REGISTRY_AUTH_FILE?.trim();
|
|
277
|
+
if (!recordPath) {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
if (!existsSync(recordPath)) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
const fileRecord = readFileSync(recordPath, 'utf8').trim();
|
|
285
|
+
return fileRecord || undefined;
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
logger.warn('Failed to read MCP Registry auth record', {
|
|
289
|
+
path: recordPath,
|
|
290
|
+
error: error instanceof Error ? error.message : String(error),
|
|
291
|
+
});
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* @name writeMcpRegistryAuthRecord
|
|
297
|
+
* @description Writes the MCP Registry HTTP ownership proof without exposing private signing material.
|
|
298
|
+
*/
|
|
299
|
+
function writeMcpRegistryAuthRecord(res, omitBody) {
|
|
300
|
+
const record = resolveMcpRegistryAuthRecord();
|
|
301
|
+
if (!record) {
|
|
302
|
+
writeText(res, 404, 'MCP Registry auth record is not configured.\n', 'text/plain; charset=utf-8', { 'Cache-Control': 'no-store' }, omitBody);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
writeText(res, 200, `${record}\n`, 'text/plain; charset=utf-8', { 'Cache-Control': 'public, max-age=60' }, omitBody);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* @name writeMarkdownAsset
|
|
309
|
+
* @description Writes a resolved public markdown documentation asset.
|
|
310
|
+
*/
|
|
311
|
+
function writeMarkdownAsset(res, asset) {
|
|
312
|
+
res.writeHead(200, {
|
|
313
|
+
'Content-Type': asset.contentType,
|
|
314
|
+
'Cache-Control': 'public, max-age=120',
|
|
315
|
+
'Content-Length': asset.contentLength,
|
|
114
316
|
'X-Content-Type-Options': 'nosniff',
|
|
115
317
|
});
|
|
116
|
-
res.end(
|
|
318
|
+
res.end(asset.body);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* @name resolvePublicDocsMarkdown
|
|
322
|
+
* @description Resolves safe hosted documentation markdown requests under /docs and /docs/user.
|
|
323
|
+
*/
|
|
324
|
+
export function resolvePublicDocsMarkdown(method, pathname) {
|
|
325
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
if (!pathname.startsWith('/docs/') || !pathname.endsWith('.md')) {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
let decodedPath;
|
|
332
|
+
try {
|
|
333
|
+
decodedPath = decodeURIComponent(pathname.slice('/docs/'.length));
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
const normalizedPath = posix.normalize(decodedPath.replace(/\\/g, '/')).replace(/^[/\\]+/, '');
|
|
339
|
+
if (isUnsafeDocsPath(normalizedPath)) {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
const root = normalizedPath.startsWith('user/')
|
|
343
|
+
? USER_DOCS_ROOT_PATH
|
|
344
|
+
: DOCS_ROOT_PATH;
|
|
345
|
+
const relativePath = normalizedPath.startsWith('user/')
|
|
346
|
+
? normalizedPath.slice('user/'.length)
|
|
347
|
+
: normalizedPath;
|
|
348
|
+
if (isUnsafeDocsPath(relativePath)) {
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
const absolutePath = join(root, relativePath);
|
|
352
|
+
if (!existsSync(absolutePath)) {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
const markdown = readFileSync(absolutePath, 'utf-8');
|
|
356
|
+
return {
|
|
357
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
358
|
+
contentLength: Buffer.byteLength(markdown),
|
|
359
|
+
body: method === 'HEAD' ? undefined : markdown,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function isUnsafeDocsPath(pathname) {
|
|
363
|
+
return pathname === ''
|
|
364
|
+
|| pathname.includes('\0')
|
|
365
|
+
|| pathname === '..'
|
|
366
|
+
|| pathname.startsWith('../')
|
|
367
|
+
|| pathname.includes('/../');
|
|
117
368
|
}
|
|
118
369
|
/**
|
|
119
|
-
* @name
|
|
120
|
-
* @description
|
|
370
|
+
* @name resolvePublicLogoAsset
|
|
371
|
+
* @description Resolves public icon/social image routes, including HEAD-safe favicon responses for crawlers.
|
|
121
372
|
*/
|
|
122
|
-
function
|
|
373
|
+
export function resolvePublicLogoAsset(method, pathname) {
|
|
374
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
375
|
+
return undefined;
|
|
376
|
+
}
|
|
123
377
|
const image = readLogoAsset();
|
|
378
|
+
const body = method === 'HEAD' ? undefined : image;
|
|
379
|
+
if (['/favicon.png', '/apple-touch-icon.png', '/og.png'].includes(pathname)) {
|
|
380
|
+
return {
|
|
381
|
+
contentType: 'image/png',
|
|
382
|
+
contentLength: image.byteLength,
|
|
383
|
+
body,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (pathname === '/favicon.ico') {
|
|
387
|
+
return {
|
|
388
|
+
contentType: 'image/x-icon',
|
|
389
|
+
contentLength: image.byteLength,
|
|
390
|
+
body,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* @name writeLogoAsset
|
|
397
|
+
* @description Writes a resolved public logo asset response.
|
|
398
|
+
*/
|
|
399
|
+
function writeLogoAsset(res, asset) {
|
|
124
400
|
res.writeHead(200, {
|
|
125
|
-
'Content-Type':
|
|
401
|
+
'Content-Type': asset.contentType,
|
|
126
402
|
'Cache-Control': 'public, max-age=86400',
|
|
127
|
-
'Content-Length':
|
|
403
|
+
'Content-Length': asset.contentLength,
|
|
128
404
|
'X-Content-Type-Options': 'nosniff',
|
|
129
405
|
});
|
|
130
|
-
res.end(
|
|
406
|
+
res.end(asset.body);
|
|
131
407
|
}
|
|
132
408
|
/**
|
|
133
409
|
* @name readLogoAsset
|
|
@@ -265,13 +541,27 @@ export function buildPublicServerInfo(req, config) {
|
|
|
265
541
|
},
|
|
266
542
|
endpoints: {
|
|
267
543
|
landing: `${baseUrl}/`,
|
|
544
|
+
docs: `${baseUrl}/docs`,
|
|
268
545
|
mcp: `${baseUrl}/mcp`,
|
|
269
546
|
health: `${baseUrl}/health`,
|
|
270
547
|
serverInfo: `${baseUrl}/server.json`,
|
|
548
|
+
openApi: `${baseUrl}/openapi.json`,
|
|
549
|
+
x402Discovery: `${baseUrl}/.well-known/x402`,
|
|
550
|
+
payShProvider: `${baseUrl}/pay/provider.yml`,
|
|
271
551
|
agentCard: `${baseUrl}/.well-known/agent-card.json`,
|
|
272
552
|
wizardDescriptor: `${baseUrl}/.well-known/sap-mcp-wizard.json`,
|
|
553
|
+
wizardDownloads: `${baseUrl}/wizard/downloads.json`,
|
|
273
554
|
wizardInstallScript: `${baseUrl}/wizard/install.sh`,
|
|
274
555
|
favicon: `${baseUrl}/favicon.png`,
|
|
556
|
+
faviconIco: `${baseUrl}/favicon.ico`,
|
|
557
|
+
},
|
|
558
|
+
downloads: {
|
|
559
|
+
release: `https://github.com/OOBE-PROTOCOL/sap-mcp/releases/tag/${MCP_SERVER_VERSION}`,
|
|
560
|
+
desktopWizard: {
|
|
561
|
+
macosArm64Dmg: `${RELEASE_DOWNLOAD_BASE_URL}/SAP-MCP-Wizard-${MCP_SERVER_VERSION}-arm64.dmg`,
|
|
562
|
+
windowsX64Setup: `${RELEASE_DOWNLOAD_BASE_URL}/SAP-MCP-Wizard-Setup-${MCP_SERVER_VERSION}-x64.exe`,
|
|
563
|
+
linuxX64TarGz: `${RELEASE_DOWNLOAD_BASE_URL}/sap-mcp-wizard-${MCP_SERVER_VERSION}-x64.tar.gz`,
|
|
564
|
+
},
|
|
275
565
|
},
|
|
276
566
|
capabilities: {
|
|
277
567
|
tools: true,
|
|
@@ -285,6 +575,7 @@ export function buildPublicServerInfo(req, config) {
|
|
|
285
575
|
schemes: authRequired ? ['Bearer'] : ['none', 'x402'],
|
|
286
576
|
bearerRequired: authRequired,
|
|
287
577
|
},
|
|
578
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
288
579
|
security: {
|
|
289
580
|
keypairBytesExposed: false,
|
|
290
581
|
storesUserKeypairs: false,
|
|
@@ -298,6 +589,237 @@ export function buildPublicServerInfo(req, config) {
|
|
|
298
589
|
},
|
|
299
590
|
};
|
|
300
591
|
}
|
|
592
|
+
/**
|
|
593
|
+
* @name buildPublicPayShProviderYaml
|
|
594
|
+
* @description Builds a public pay.sh provider catalog for the hosted SAP MCP endpoint without RPC secrets or signer paths.
|
|
595
|
+
*/
|
|
596
|
+
export function buildPublicPayShProviderYaml(req, config, appConfig) {
|
|
597
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
598
|
+
return generatePayShProviderYaml(appConfig, {
|
|
599
|
+
name: 'oobe-sap-mcp',
|
|
600
|
+
subdomain: 'oobe-sap-mcp',
|
|
601
|
+
title: 'OOBE SAP MCP Server',
|
|
602
|
+
description: PUBLIC_SERVER_DESCRIPTION,
|
|
603
|
+
version: MCP_SERVER_VERSION,
|
|
604
|
+
upstreamUrl: baseUrl,
|
|
605
|
+
recipient: config.paymentDiscovery?.payTo ?? appConfig.monetization.payTo,
|
|
606
|
+
rpcUrl: undefined,
|
|
607
|
+
signerPath: undefined,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* @name buildOpenApiSpec
|
|
612
|
+
* @description OpenAPI 3.1 spec for x402scan discovery. Describes the /mcp endpoint
|
|
613
|
+
* with x-payment-info so x402scan can discover and register the API.
|
|
614
|
+
*/
|
|
615
|
+
export function buildOpenApiSpec(req, config) {
|
|
616
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
617
|
+
const priceRange = config.paymentPriceRange ?? { minUsd: 0.001, maxUsd: 100 };
|
|
618
|
+
return {
|
|
619
|
+
openapi: '3.1.0',
|
|
620
|
+
info: {
|
|
621
|
+
title: 'SAP MCP Server',
|
|
622
|
+
version: MCP_SERVER_VERSION,
|
|
623
|
+
description: 'MCP gateway for Solana DeFi, SAP agent registry, and x402 micropayments. 248 tools across Jupiter, Drift, Metaplex, Pump.fun, Raydium, Orca, and more.',
|
|
624
|
+
'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.',
|
|
625
|
+
contact: {
|
|
626
|
+
name: 'OOBE Protocol Labs',
|
|
627
|
+
url: 'https://github.com/OOBE-PROTOCOL/sap-mcp',
|
|
628
|
+
email: 'oobe@oobeprotocol.ai',
|
|
629
|
+
},
|
|
630
|
+
},
|
|
631
|
+
'x-discovery': {
|
|
632
|
+
resources: [`${baseUrl}/mcp`],
|
|
633
|
+
openApi: `${baseUrl}/openapi.json`,
|
|
634
|
+
x402Discovery: `${baseUrl}/.well-known/x402`,
|
|
635
|
+
payShProvider: `${baseUrl}/pay/provider.yml`,
|
|
636
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
637
|
+
},
|
|
638
|
+
'x-pay-sh': {
|
|
639
|
+
providerYaml: `${baseUrl}/pay/provider.yml`,
|
|
640
|
+
routing: {
|
|
641
|
+
type: 'proxy',
|
|
642
|
+
upstream: `${baseUrl}/`,
|
|
643
|
+
},
|
|
644
|
+
monetization: {
|
|
645
|
+
mode: 'metered',
|
|
646
|
+
unit: 'requests',
|
|
647
|
+
sourceOfTruth: 'SAP MCP per-tool x402 pricing remains authoritative after proxy forwarding.',
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
servers: [
|
|
651
|
+
{ url: baseUrl, description: 'SAP MCP Hosted Server' },
|
|
652
|
+
],
|
|
653
|
+
paths: {
|
|
654
|
+
'/mcp': {
|
|
655
|
+
post: {
|
|
656
|
+
operationId: 'mcpCall',
|
|
657
|
+
summary: 'Fetch SAP MCP JSON-RPC response',
|
|
658
|
+
tags: ['MCP'],
|
|
659
|
+
'x-payment-info': {
|
|
660
|
+
price: {
|
|
661
|
+
mode: 'dynamic',
|
|
662
|
+
currency: 'USD',
|
|
663
|
+
min: formatOpenApiUsd(priceRange.minUsd),
|
|
664
|
+
max: formatOpenApiUsd(priceRange.maxUsd),
|
|
665
|
+
},
|
|
666
|
+
protocols: ['x402'],
|
|
667
|
+
...(config.paymentDiscovery
|
|
668
|
+
? {
|
|
669
|
+
network: config.paymentDiscovery.network,
|
|
670
|
+
asset: config.paymentDiscovery.asset,
|
|
671
|
+
assetSymbol: config.paymentDiscovery.assetSymbol,
|
|
672
|
+
payTo: config.paymentDiscovery.payTo,
|
|
673
|
+
seller: config.paymentDiscovery.seller,
|
|
674
|
+
facilitator: config.paymentDiscovery.facilitator,
|
|
675
|
+
}
|
|
676
|
+
: {}),
|
|
677
|
+
},
|
|
678
|
+
requestBody: {
|
|
679
|
+
required: true,
|
|
680
|
+
content: {
|
|
681
|
+
'application/json': {
|
|
682
|
+
schema: {
|
|
683
|
+
oneOf: [
|
|
684
|
+
{
|
|
685
|
+
type: 'object',
|
|
686
|
+
description: 'General MCP JSON-RPC request such as initialize, tools/list, prompts/list, resources/list, or ping.',
|
|
687
|
+
properties: {
|
|
688
|
+
jsonrpc: { type: 'string', const: '2.0' },
|
|
689
|
+
id: { oneOf: [{ type: 'integer' }, { type: 'string' }], description: 'JSON-RPC request ID' },
|
|
690
|
+
method: {
|
|
691
|
+
type: 'string',
|
|
692
|
+
description: 'MCP method.',
|
|
693
|
+
examples: ['initialize', 'tools/list', 'prompts/list', 'resources/list', 'ping'],
|
|
694
|
+
},
|
|
695
|
+
params: { type: 'object', additionalProperties: true },
|
|
696
|
+
},
|
|
697
|
+
required: ['jsonrpc', 'id', 'method'],
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
type: 'object',
|
|
701
|
+
description: 'MCP tools/call request. Paid tool calls may require x402 payment before execution.',
|
|
702
|
+
properties: {
|
|
703
|
+
jsonrpc: { type: 'string', const: '2.0' },
|
|
704
|
+
id: { oneOf: [{ type: 'integer' }, { type: 'string' }], description: 'JSON-RPC request ID' },
|
|
705
|
+
method: { type: 'string', const: 'tools/call' },
|
|
706
|
+
params: {
|
|
707
|
+
type: 'object',
|
|
708
|
+
description: 'MCP tools/call parameters containing the target tool name and optional tool arguments.',
|
|
709
|
+
properties: {
|
|
710
|
+
name: { type: 'string', description: 'Tool name (e.g. jupiter_getPrice)' },
|
|
711
|
+
arguments: { type: 'object', description: 'Tool arguments', additionalProperties: true },
|
|
712
|
+
},
|
|
713
|
+
required: ['name'],
|
|
714
|
+
additionalProperties: true,
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
required: ['jsonrpc', 'id', 'method', 'params'],
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
responses: {
|
|
725
|
+
'200': {
|
|
726
|
+
description: 'MCP tool result or payment_required JSON-RPC error (for MCP SDK clients)',
|
|
727
|
+
content: {
|
|
728
|
+
'application/json': {
|
|
729
|
+
schema: {
|
|
730
|
+
type: 'object',
|
|
731
|
+
properties: {
|
|
732
|
+
jsonrpc: { type: 'string' },
|
|
733
|
+
id: { type: 'integer' },
|
|
734
|
+
result: { type: 'object', description: 'Tool result (if free or paid)' },
|
|
735
|
+
error: {
|
|
736
|
+
type: 'object',
|
|
737
|
+
properties: {
|
|
738
|
+
code: { type: 'integer' },
|
|
739
|
+
message: { type: 'string' },
|
|
740
|
+
data: {
|
|
741
|
+
type: 'object',
|
|
742
|
+
properties: {
|
|
743
|
+
protocol: { type: 'string' },
|
|
744
|
+
paymentRequirements: { type: 'array' },
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
'text/event-stream': {
|
|
753
|
+
schema: {
|
|
754
|
+
type: 'string',
|
|
755
|
+
description: 'Streamable HTTP MCP event stream containing JSON-RPC response events.',
|
|
756
|
+
},
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
'402': {
|
|
761
|
+
description: 'Payment Required (x402 challenge for non-MCP clients)',
|
|
762
|
+
content: {
|
|
763
|
+
'application/json': {
|
|
764
|
+
schema: {
|
|
765
|
+
type: 'object',
|
|
766
|
+
properties: {
|
|
767
|
+
x402Version: { type: 'integer' },
|
|
768
|
+
error: { type: 'string' },
|
|
769
|
+
accepts: {
|
|
770
|
+
type: 'array',
|
|
771
|
+
items: {
|
|
772
|
+
type: 'object',
|
|
773
|
+
properties: {
|
|
774
|
+
scheme: { type: 'string' },
|
|
775
|
+
network: { type: 'string' },
|
|
776
|
+
amount: { type: 'string', description: 'USDC atomic units (6 decimals)' },
|
|
777
|
+
asset: { type: 'string' },
|
|
778
|
+
payTo: { type: 'string' },
|
|
779
|
+
maxTimeoutSeconds: { type: 'integer' },
|
|
780
|
+
extra: {
|
|
781
|
+
type: 'object',
|
|
782
|
+
properties: {
|
|
783
|
+
feePayer: { type: 'string' },
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
},
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
resource: {
|
|
790
|
+
type: 'object',
|
|
791
|
+
properties: {
|
|
792
|
+
url: { type: 'string' },
|
|
793
|
+
description: { type: 'string' },
|
|
794
|
+
mimeType: { type: 'string' },
|
|
795
|
+
serviceName: { type: 'string' },
|
|
796
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
797
|
+
},
|
|
798
|
+
},
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* @name buildX402DiscoveryDocument
|
|
812
|
+
* @description Builds the compatibility discovery document consumed by x402scan.
|
|
813
|
+
*/
|
|
814
|
+
export function buildX402DiscoveryDocument(req, config) {
|
|
815
|
+
const baseUrl = buildPublicBaseUrl(req, config);
|
|
816
|
+
return {
|
|
817
|
+
version: 1,
|
|
818
|
+
resources: [`${baseUrl}/mcp`],
|
|
819
|
+
...(config.paymentDiscovery ? { payments: config.paymentDiscovery } : {}),
|
|
820
|
+
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.',
|
|
821
|
+
};
|
|
822
|
+
}
|
|
301
823
|
/**
|
|
302
824
|
* @name buildLandingHtml
|
|
303
825
|
* @description Builds professional public HTML metadata for root and `/mcp` URL previews.
|
|
@@ -310,8 +832,9 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
310
832
|
? 'SAP MCP Streamable HTTP Endpoint | OOBE Protocol'
|
|
311
833
|
: info.title;
|
|
312
834
|
const endpointLabel = endpoint === 'mcp' ? 'Streamable HTTP MCP endpoint' : 'Hosted MCP gateway';
|
|
313
|
-
const wizardCommand =
|
|
835
|
+
const wizardCommand = WIZARD_NPM_COMMAND;
|
|
314
836
|
const installScriptCommand = 'curl -fsSL https://mcp.sap.oobeprotocol.ai/wizard/install.sh | sh';
|
|
837
|
+
const x402PaidCallCommand = X402_PAID_CALL_NPX_COMMAND;
|
|
315
838
|
const publicInfo = JSON.stringify(info, null, 2).replace(/</g, '\\u003c');
|
|
316
839
|
return `<!doctype html>
|
|
317
840
|
<html lang="en">
|
|
@@ -322,8 +845,9 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
322
845
|
<meta name="description" content="${escapeHtml(info.description)}">
|
|
323
846
|
<meta name="robots" content="index,follow">
|
|
324
847
|
<link rel="canonical" href="${escapeHtml(pageUrl)}">
|
|
848
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
325
849
|
<link rel="icon" type="image/png" href="/favicon.png">
|
|
326
|
-
<link rel="shortcut icon"
|
|
850
|
+
<link rel="shortcut icon" href="/favicon.ico">
|
|
327
851
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
328
852
|
<meta property="og:type" content="website">
|
|
329
853
|
<meta property="og:title" content="${escapeHtml(title)}">
|
|
@@ -428,6 +952,21 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
428
952
|
.list { padding-left: 18px; margin: 0; color: var(--muted); }
|
|
429
953
|
.list li + li { margin-top: 8px; }
|
|
430
954
|
.command { margin-top: 12px; border: 1px solid rgba(255,255,255,.10); border-radius: 8px; background: rgba(0,0,0,.28); padding: 13px; }
|
|
955
|
+
.download-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 14px; }
|
|
956
|
+
.download-link {
|
|
957
|
+
display: grid;
|
|
958
|
+
min-height: 74px;
|
|
959
|
+
align-content: center;
|
|
960
|
+
gap: 4px;
|
|
961
|
+
border: 1px solid var(--line);
|
|
962
|
+
border-radius: 8px;
|
|
963
|
+
background: rgba(0,0,0,.18);
|
|
964
|
+
padding: 12px;
|
|
965
|
+
color: var(--ink);
|
|
966
|
+
text-decoration: none;
|
|
967
|
+
}
|
|
968
|
+
.download-link strong { color: var(--aqua); }
|
|
969
|
+
.download-link span { color: var(--muted); font-size: .88rem; }
|
|
431
970
|
.endpoint-list { display: grid; gap: 10px; margin-top: 10px; }
|
|
432
971
|
.endpoint-row { display: grid; grid-template-columns: 72px 1fr; gap: 12px; align-items: baseline; }
|
|
433
972
|
.method { color: var(--success); font: 700 .78rem/1 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
|
|
@@ -436,6 +975,7 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
436
975
|
header { grid-template-columns: 1fr; }
|
|
437
976
|
img.logo { width: 76px; height: 76px; border-radius: 20px; }
|
|
438
977
|
.span-3, .span-4, .span-5, .span-6, .span-7, .span-8 { grid-column: span 12; }
|
|
978
|
+
.download-grid { grid-template-columns: 1fr; }
|
|
439
979
|
}
|
|
440
980
|
@media (min-width: 861px) and (max-width: 1120px) {
|
|
441
981
|
.span-3, .span-4 { grid-column: span 6; }
|
|
@@ -456,7 +996,11 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
456
996
|
<p>${escapeHtml(info.description)}</p>
|
|
457
997
|
<div class="hero-actions">
|
|
458
998
|
<a class="button" href="/wizard/install.sh">Install wizard</a>
|
|
999
|
+
<a class="button secondary" href="/wizard/downloads.json">Downloads</a>
|
|
1000
|
+
<a class="button secondary" href="/docs">Docs</a>
|
|
459
1001
|
<a class="button secondary" href="/server.json">Server JSON</a>
|
|
1002
|
+
<a class="button secondary" href="/openapi.json">OpenAPI</a>
|
|
1003
|
+
<a class="button secondary" href="/pay/provider.yml">pay.sh YAML</a>
|
|
460
1004
|
<a class="button secondary" href="/.well-known/agent-card.json">Agent card</a>
|
|
461
1005
|
</div>
|
|
462
1006
|
</div>
|
|
@@ -488,21 +1032,43 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
488
1032
|
<div class="command"><code>${escapeHtml(wizardCommand)}</code></div>
|
|
489
1033
|
</section>
|
|
490
1034
|
|
|
1035
|
+
<section class="card span-5">
|
|
1036
|
+
<h2>Native Downloads</h2>
|
|
1037
|
+
<p>Use the desktop wizard when you want a guided setup without touching config files by hand.</p>
|
|
1038
|
+
<div class="download-grid">
|
|
1039
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.windowsX64Setup)}"><strong>Windows</strong><span>x64 setup .exe</span></a>
|
|
1040
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.macosArm64Dmg)}"><strong>macOS</strong><span>Apple Silicon .dmg</span></a>
|
|
1041
|
+
<a class="download-link" href="${escapeHtml(info.downloads.desktopWizard.linuxX64TarGz)}"><strong>Linux</strong><span>x64 tar.gz</span></a>
|
|
1042
|
+
</div>
|
|
1043
|
+
<p class="caption">Machine-readable links: <a href="${escapeHtml(info.endpoints.wizardDownloads)}">/wizard/downloads.json</a>.</p>
|
|
1044
|
+
</section>
|
|
1045
|
+
|
|
491
1046
|
<section class="card span-5">
|
|
492
1047
|
<h2>Payments</h2>
|
|
493
1048
|
<ul class="list">
|
|
494
1049
|
<li><strong>x402:</strong> paid MCP tool calls return HTTP 402 with payment requirements, then settle through the OOBE facilitator.</li>
|
|
495
|
-
<li><strong>pay.sh:</strong>
|
|
1050
|
+
<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>
|
|
496
1051
|
<li><strong>Local stdio:</strong> free by default; monetization applies to hosted remote usage.</li>
|
|
497
1052
|
</ul>
|
|
498
1053
|
</section>
|
|
499
1054
|
|
|
1055
|
+
<section class="card strong span-12">
|
|
1056
|
+
<h2>x402 Paid-Call Plugin For Agents</h2>
|
|
1057
|
+
<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>
|
|
1058
|
+
<div class="command"><code>Addon path: ${escapeHtml(X402_PAID_CALL_ADDON_PATH)}</code></div>
|
|
1059
|
+
<div class="command"><code>${escapeHtml(x402PaidCallCommand)}</code></div>
|
|
1060
|
+
</section>
|
|
1061
|
+
|
|
500
1062
|
<section class="card span-8">
|
|
501
1063
|
<h2>Endpoints</h2>
|
|
502
1064
|
<div class="endpoint-list">
|
|
503
1065
|
<div class="endpoint-row"><span class="method">POST</span><code>${escapeHtml(info.endpoints.mcp)}</code></div>
|
|
1066
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.docs)}</code></div>
|
|
504
1067
|
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.health)}</code></div>
|
|
505
1068
|
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.serverInfo)}</code></div>
|
|
1069
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.openApi)}</code></div>
|
|
1070
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.x402Discovery)}</code></div>
|
|
1071
|
+
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.payShProvider)}</code></div>
|
|
506
1072
|
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.agentCard)}</code></div>
|
|
507
1073
|
<div class="endpoint-row"><span class="method">GET</span><code>${escapeHtml(info.endpoints.wizardDescriptor)}</code></div>
|
|
508
1074
|
</div>
|
|
@@ -522,6 +1088,115 @@ export function buildLandingHtml(req, config, endpoint = 'root') {
|
|
|
522
1088
|
</body>
|
|
523
1089
|
</html>`;
|
|
524
1090
|
}
|
|
1091
|
+
/**
|
|
1092
|
+
* @name buildDocsHtml
|
|
1093
|
+
* @description Builds the hosted SAP MCP documentation shell powered by Docsify.
|
|
1094
|
+
*/
|
|
1095
|
+
export function buildDocsHtml(req, config) {
|
|
1096
|
+
const info = buildPublicServerInfo(req, config);
|
|
1097
|
+
const docsUrl = info.endpoints.docs;
|
|
1098
|
+
const title = 'SAP MCP Documentation | OOBE Protocol';
|
|
1099
|
+
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.';
|
|
1100
|
+
const docsifyConfig = JSON.stringify({
|
|
1101
|
+
name: 'SAP MCP',
|
|
1102
|
+
repo: 'https://github.com/OOBE-PROTOCOL/sap-mcp',
|
|
1103
|
+
homepage: 'README.md',
|
|
1104
|
+
loadSidebar: true,
|
|
1105
|
+
alias: {
|
|
1106
|
+
'/.*/_sidebar.md': '/docs/_sidebar.md',
|
|
1107
|
+
},
|
|
1108
|
+
subMaxLevel: 2,
|
|
1109
|
+
auto2top: true,
|
|
1110
|
+
themeColor: '#28d8e8',
|
|
1111
|
+
search: {
|
|
1112
|
+
paths: 'auto',
|
|
1113
|
+
placeholder: 'Search SAP MCP docs',
|
|
1114
|
+
noData: 'No results.',
|
|
1115
|
+
depth: 6,
|
|
1116
|
+
},
|
|
1117
|
+
}, null, 2).replace(/</g, '\\u003c');
|
|
1118
|
+
return `<!doctype html>
|
|
1119
|
+
<html lang="en">
|
|
1120
|
+
<head>
|
|
1121
|
+
<meta charset="utf-8">
|
|
1122
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1123
|
+
<title>${escapeHtml(title)}</title>
|
|
1124
|
+
<meta name="description" content="${escapeHtml(description)}">
|
|
1125
|
+
<meta name="robots" content="index,follow">
|
|
1126
|
+
<link rel="canonical" href="${escapeHtml(docsUrl)}">
|
|
1127
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
1128
|
+
<link rel="icon" type="image/png" href="/favicon.png">
|
|
1129
|
+
<link rel="shortcut icon" href="/favicon.ico">
|
|
1130
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
1131
|
+
<meta property="og:type" content="website">
|
|
1132
|
+
<meta property="og:title" content="${escapeHtml(title)}">
|
|
1133
|
+
<meta property="og:description" content="${escapeHtml(description)}">
|
|
1134
|
+
<meta property="og:url" content="${escapeHtml(docsUrl)}">
|
|
1135
|
+
<meta property="og:image" content="${escapeHtml(info.endpoints.favicon)}">
|
|
1136
|
+
<meta name="twitter:card" content="summary">
|
|
1137
|
+
<meta name="twitter:title" content="${escapeHtml(title)}">
|
|
1138
|
+
<meta name="twitter:description" content="${escapeHtml(description)}">
|
|
1139
|
+
<meta name="twitter:image" content="${escapeHtml(info.endpoints.favicon)}">
|
|
1140
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/themes/vue.css">
|
|
1141
|
+
<style>
|
|
1142
|
+
:root {
|
|
1143
|
+
--theme-color: #28d8e8;
|
|
1144
|
+
--sidebar-width: 310px;
|
|
1145
|
+
}
|
|
1146
|
+
body {
|
|
1147
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1148
|
+
color: #17242b;
|
|
1149
|
+
}
|
|
1150
|
+
.app-name-link {
|
|
1151
|
+
color: #062730;
|
|
1152
|
+
font-weight: 800;
|
|
1153
|
+
}
|
|
1154
|
+
.sidebar {
|
|
1155
|
+
border-right: 1px solid rgba(6,39,48,.10);
|
|
1156
|
+
}
|
|
1157
|
+
.sidebar-nav strong,
|
|
1158
|
+
.markdown-section h1,
|
|
1159
|
+
.markdown-section h2,
|
|
1160
|
+
.markdown-section h3 {
|
|
1161
|
+
color: #062730;
|
|
1162
|
+
letter-spacing: 0;
|
|
1163
|
+
}
|
|
1164
|
+
.markdown-section a {
|
|
1165
|
+
color: #087f91;
|
|
1166
|
+
text-underline-offset: 3px;
|
|
1167
|
+
}
|
|
1168
|
+
.markdown-section code {
|
|
1169
|
+
color: #062730;
|
|
1170
|
+
border-radius: 6px;
|
|
1171
|
+
}
|
|
1172
|
+
.markdown-section pre {
|
|
1173
|
+
border-radius: 8px;
|
|
1174
|
+
border: 1px solid rgba(6,39,48,.10);
|
|
1175
|
+
}
|
|
1176
|
+
.markdown-section table {
|
|
1177
|
+
display: table;
|
|
1178
|
+
width: 100%;
|
|
1179
|
+
}
|
|
1180
|
+
.markdown-section blockquote {
|
|
1181
|
+
border-left-color: #28d8e8;
|
|
1182
|
+
color: #49656f;
|
|
1183
|
+
}
|
|
1184
|
+
.cover-main,
|
|
1185
|
+
section.cover {
|
|
1186
|
+
background: linear-gradient(115deg, rgba(40,216,232,.13), transparent 34%), #f8fdff;
|
|
1187
|
+
}
|
|
1188
|
+
</style>
|
|
1189
|
+
</head>
|
|
1190
|
+
<body>
|
|
1191
|
+
<div id="app">Loading SAP MCP documentation...</div>
|
|
1192
|
+
<script>
|
|
1193
|
+
window.$docsify = ${docsifyConfig};
|
|
1194
|
+
</script>
|
|
1195
|
+
<script src="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/docsify.min.js" defer></script>
|
|
1196
|
+
<script src="https://cdn.jsdelivr.net/npm/docsify@4.13.1/lib/plugins/search.min.js" defer></script>
|
|
1197
|
+
</body>
|
|
1198
|
+
</html>`;
|
|
1199
|
+
}
|
|
525
1200
|
/**
|
|
526
1201
|
* @name acceptsHtmlPreview
|
|
527
1202
|
* @description Returns true for browser-style GET previews without interfering with MCP SSE clients.
|
|
@@ -662,13 +1337,16 @@ export function buildWizardInstallDescriptor(req, config) {
|
|
|
662
1337
|
],
|
|
663
1338
|
endpoints: {
|
|
664
1339
|
descriptor: `${baseUrl}/.well-known/sap-mcp-wizard.json`,
|
|
1340
|
+
downloads: `${baseUrl}/wizard/downloads.json`,
|
|
665
1341
|
installScript: `${baseUrl}/wizard/install.sh`,
|
|
666
1342
|
mcp: `${baseUrl}/mcp`,
|
|
667
1343
|
},
|
|
668
1344
|
commands: {
|
|
669
|
-
runWizard:
|
|
1345
|
+
runWizard: WIZARD_NPM_COMMAND,
|
|
670
1346
|
showConfig: 'npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config show',
|
|
671
1347
|
showProfile: 'npm exec --yes --package @oobe-protocol-labs/sap-mcp-server -- sap-mcp-config profile-info',
|
|
1348
|
+
installX402PaidCallAddon: WIZARD_NPM_COMMAND,
|
|
1349
|
+
runX402PaidCall: X402_PAID_CALL_NPX_COMMAND,
|
|
672
1350
|
},
|
|
673
1351
|
security: {
|
|
674
1352
|
keypairBytesExposed: false,
|
|
@@ -700,6 +1378,44 @@ export function buildWizardInstallScript(req, config) {
|
|
|
700
1378
|
'',
|
|
701
1379
|
].join('\n');
|
|
702
1380
|
}
|
|
1381
|
+
/**
|
|
1382
|
+
* @name extractToolNamesFromJsonRpc
|
|
1383
|
+
* @description Extracts tool names from a parsed JSON-RPC 2.0 request body for request logging.
|
|
1384
|
+
*/
|
|
1385
|
+
function extractToolNamesFromJsonRpc(body) {
|
|
1386
|
+
if (!body || typeof body !== 'object')
|
|
1387
|
+
return undefined;
|
|
1388
|
+
const record = body;
|
|
1389
|
+
// tools/call request: { method: "tools/call", params: { name: "tool_name", ... } }
|
|
1390
|
+
if (record.method === 'tools/call' && record.params && typeof record.params === 'object') {
|
|
1391
|
+
const params = record.params;
|
|
1392
|
+
if (typeof params.name === 'string')
|
|
1393
|
+
return [params.name];
|
|
1394
|
+
}
|
|
1395
|
+
// Batch request: array of requests
|
|
1396
|
+
if (Array.isArray(body)) {
|
|
1397
|
+
const names = [];
|
|
1398
|
+
for (const item of body) {
|
|
1399
|
+
const extracted = extractToolNamesFromJsonRpc(item);
|
|
1400
|
+
if (extracted)
|
|
1401
|
+
names.push(...extracted);
|
|
1402
|
+
}
|
|
1403
|
+
return names.length > 0 ? names : undefined;
|
|
1404
|
+
}
|
|
1405
|
+
return undefined;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* @name extractJsonRpcMethod
|
|
1409
|
+
* @description Extracts the JSON-RPC method name from a parsed request body for request logging.
|
|
1410
|
+
*/
|
|
1411
|
+
function extractJsonRpcMethod(body) {
|
|
1412
|
+
if (!body || typeof body !== 'object')
|
|
1413
|
+
return undefined;
|
|
1414
|
+
const record = body;
|
|
1415
|
+
if (typeof record.method === 'string')
|
|
1416
|
+
return record.method;
|
|
1417
|
+
return undefined;
|
|
1418
|
+
}
|
|
703
1419
|
/**
|
|
704
1420
|
* @name RemoteMCPServer
|
|
705
1421
|
* @description Remote MCP server backed by the official Streamable HTTP transport.
|
|
@@ -709,7 +1425,7 @@ export class RemoteMCPServer {
|
|
|
709
1425
|
appConfig;
|
|
710
1426
|
authManager;
|
|
711
1427
|
rateLimiter;
|
|
712
|
-
|
|
1428
|
+
statefulSessions = new Map();
|
|
713
1429
|
httpServer;
|
|
714
1430
|
constructor(config) {
|
|
715
1431
|
this.appConfig = loadConfig();
|
|
@@ -730,63 +1446,92 @@ export class RemoteMCPServer {
|
|
|
730
1446
|
this.validateAuthConfig();
|
|
731
1447
|
await this.rateLimiter.initialize();
|
|
732
1448
|
const monetizationGate = await McpMonetizationGate.create(this.appConfig);
|
|
733
|
-
// In stateful mode, create one long-lived transport and connect the server once.
|
|
734
|
-
// In stateless mode, a fresh transport + server must be created per request —
|
|
735
|
-
// the MCP SDK throws "Stateless transport cannot be reused across requests"
|
|
736
|
-
// if a stateless transport handles more than one request.
|
|
737
|
-
if (!this.config.stateless) {
|
|
738
|
-
const server = await createSapMcpServer(this.appConfig);
|
|
739
|
-
this.transport = new StreamableHTTPServerTransport({
|
|
740
|
-
sessionIdGenerator: () => randomUUID(),
|
|
741
|
-
});
|
|
742
|
-
await server.connect(this.transport);
|
|
743
|
-
}
|
|
744
1449
|
this.httpServer = http.createServer(async (req, res) => {
|
|
745
1450
|
if (this.applyCors(req, res)) {
|
|
746
1451
|
return;
|
|
747
1452
|
}
|
|
748
1453
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
749
|
-
if (req.method
|
|
750
|
-
writeHtml(res, 200, buildLandingHtml(req, this.config));
|
|
1454
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/') {
|
|
1455
|
+
writeHtml(res, 200, buildLandingHtml(req, this.config), isHeadMethod(req.method));
|
|
751
1456
|
return;
|
|
752
1457
|
}
|
|
753
|
-
if (req.method
|
|
1458
|
+
if (isPublicReadMethod(req.method) && ['/docs', '/docs/', '/docs/index.html'].includes(url.pathname)) {
|
|
1459
|
+
writeHtml(res, 200, buildDocsHtml(req, this.config), isHeadMethod(req.method));
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/server.json') {
|
|
754
1463
|
writeJson(res, 200, buildPublicServerInfo(req, this.config), {
|
|
755
1464
|
'Cache-Control': 'public, max-age=300',
|
|
756
|
-
});
|
|
1465
|
+
}, isHeadMethod(req.method));
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
const publicLogoAsset = resolvePublicLogoAsset(req.method, url.pathname);
|
|
1469
|
+
if (publicLogoAsset) {
|
|
1470
|
+
writeLogoAsset(res, publicLogoAsset);
|
|
757
1471
|
return;
|
|
758
1472
|
}
|
|
759
|
-
|
|
760
|
-
|
|
1473
|
+
const publicDocsMarkdown = resolvePublicDocsMarkdown(req.method, url.pathname);
|
|
1474
|
+
if (publicDocsMarkdown) {
|
|
1475
|
+
writeMarkdownAsset(res, publicDocsMarkdown);
|
|
761
1476
|
return;
|
|
762
1477
|
}
|
|
763
|
-
if (req.method
|
|
1478
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/health') {
|
|
764
1479
|
writeJson(res, 200, {
|
|
765
1480
|
status: 'ok',
|
|
766
1481
|
service: 'sap-mcp-remote',
|
|
767
1482
|
transport: 'streamable-http',
|
|
768
1483
|
timestamp: new Date().toISOString(),
|
|
769
|
-
});
|
|
1484
|
+
}, {}, isHeadMethod(req.method));
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (isPublicReadMethod(req.method) && url.pathname === MCP_REGISTRY_AUTH_PATH) {
|
|
1488
|
+
writeMcpRegistryAuthRecord(res, isHeadMethod(req.method));
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (isPublicReadMethod(req.method) && ['/.well-known/agent-card.json', '/a2a/agent-card'].includes(url.pathname)) {
|
|
1492
|
+
writeJson(res, 200, buildA2AAgentCard(req, this.config), {}, isHeadMethod(req.method));
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (isPublicReadMethod(req.method) && ['/.well-known/sap-mcp-wizard.json', '/wizard', '/wizard.json'].includes(url.pathname)) {
|
|
1496
|
+
writeJson(res, 200, buildWizardInstallDescriptor(req, this.config), {}, isHeadMethod(req.method));
|
|
770
1497
|
return;
|
|
771
1498
|
}
|
|
772
|
-
if (req.method
|
|
773
|
-
|
|
1499
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/wizard/downloads.json') {
|
|
1500
|
+
const info = buildPublicServerInfo(req, this.config);
|
|
1501
|
+
writeJson(res, 200, info.downloads, {
|
|
1502
|
+
'Cache-Control': 'public, max-age=300',
|
|
1503
|
+
}, isHeadMethod(req.method));
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/openapi.json') {
|
|
1507
|
+
writeJson(res, 200, buildOpenApiSpec(req, this.config), {
|
|
1508
|
+
'Cache-Control': 'public, max-age=300',
|
|
1509
|
+
}, isHeadMethod(req.method));
|
|
774
1510
|
return;
|
|
775
1511
|
}
|
|
776
|
-
if (req.method
|
|
777
|
-
|
|
1512
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/pay/provider.yml') {
|
|
1513
|
+
const providerYaml = buildPublicPayShProviderYaml(req, this.config, this.appConfig);
|
|
1514
|
+
writeText(res, 200, providerYaml, 'application/yaml; charset=utf-8', {}, isHeadMethod(req.method));
|
|
778
1515
|
return;
|
|
779
1516
|
}
|
|
780
|
-
if (req.method
|
|
1517
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/.well-known/x402') {
|
|
1518
|
+
writeJson(res, 200, buildX402DiscoveryDocument(req, this.config), {
|
|
1519
|
+
'Cache-Control': 'public, max-age=300',
|
|
1520
|
+
}, isHeadMethod(req.method));
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/wizard/install.sh') {
|
|
1524
|
+
const script = buildWizardInstallScript(req, this.config);
|
|
781
1525
|
res.writeHead(200, {
|
|
782
1526
|
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
|
783
1527
|
'Cache-Control': 'public, max-age=300',
|
|
1528
|
+
'Content-Length': Buffer.byteLength(script),
|
|
784
1529
|
});
|
|
785
|
-
res.end(
|
|
1530
|
+
res.end(isHeadMethod(req.method) ? undefined : script);
|
|
786
1531
|
return;
|
|
787
1532
|
}
|
|
788
|
-
if (req.method
|
|
789
|
-
writeHtml(res, 200, buildLandingHtml(req, this.config, 'mcp'));
|
|
1533
|
+
if (isPublicReadMethod(req.method) && url.pathname === '/mcp' && (isHeadMethod(req.method) || acceptsHtmlPreview(req))) {
|
|
1534
|
+
writeHtml(res, 200, buildLandingHtml(req, this.config, 'mcp'), isHeadMethod(req.method));
|
|
790
1535
|
return;
|
|
791
1536
|
}
|
|
792
1537
|
if (url.pathname !== '/mcp') {
|
|
@@ -813,6 +1558,26 @@ export class RemoteMCPServer {
|
|
|
813
1558
|
});
|
|
814
1559
|
return;
|
|
815
1560
|
}
|
|
1561
|
+
// ── Request logging ─────────────────────────────────────────────
|
|
1562
|
+
const reqStart = Date.now();
|
|
1563
|
+
const callerIp = getRateLimitKey(req);
|
|
1564
|
+
const originalEnd = res.end.bind(res);
|
|
1565
|
+
let loggedMethod;
|
|
1566
|
+
let loggedToolNames;
|
|
1567
|
+
res.end = ((...args) => {
|
|
1568
|
+
const latencyMs = Date.now() - reqStart;
|
|
1569
|
+
const statusCode = res.statusCode;
|
|
1570
|
+
logger.info('MCP request', {
|
|
1571
|
+
method: req.method,
|
|
1572
|
+
path: url.pathname,
|
|
1573
|
+
status: statusCode,
|
|
1574
|
+
latencyMs,
|
|
1575
|
+
callerIp,
|
|
1576
|
+
toolNames: loggedToolNames,
|
|
1577
|
+
rpcMethod: loggedMethod,
|
|
1578
|
+
});
|
|
1579
|
+
return originalEnd(...args);
|
|
1580
|
+
});
|
|
816
1581
|
try {
|
|
817
1582
|
if (this.config.stateless) {
|
|
818
1583
|
// Stateless mode: create a fresh transport + server per request.
|
|
@@ -824,22 +1589,32 @@ export class RemoteMCPServer {
|
|
|
824
1589
|
await perRequestServer.connect(perRequestTransport);
|
|
825
1590
|
if (monetizationGate) {
|
|
826
1591
|
await monetizationGate.handle(req, res, async (mcpReq, mcpRes, parsedBody) => {
|
|
1592
|
+
// Extract tool names from parsed JSON-RPC body for logging
|
|
1593
|
+
loggedToolNames = extractToolNamesFromJsonRpc(parsedBody);
|
|
1594
|
+
loggedMethod = extractJsonRpcMethod(parsedBody);
|
|
827
1595
|
await perRequestTransport.handleRequest(mcpReq, mcpRes, parsedBody);
|
|
828
1596
|
});
|
|
829
1597
|
}
|
|
830
1598
|
else {
|
|
1599
|
+
// Parse body for logging before passing to transport
|
|
1600
|
+
// The transport will re-read it from the stream
|
|
1601
|
+
loggedToolNames = undefined; // Will be logged without tool names in free mode
|
|
831
1602
|
await perRequestTransport.handleRequest(req, res);
|
|
832
1603
|
}
|
|
833
1604
|
}
|
|
834
1605
|
else {
|
|
835
|
-
// Stateful mode:
|
|
1606
|
+
// Stateful mode: route each request to the transport created by its initialize response.
|
|
836
1607
|
if (monetizationGate) {
|
|
837
1608
|
await monetizationGate.handle(req, res, async (mcpReq, mcpRes, parsedBody) => {
|
|
838
|
-
|
|
1609
|
+
loggedToolNames = extractToolNamesFromJsonRpc(parsedBody);
|
|
1610
|
+
loggedMethod = extractJsonRpcMethod(parsedBody);
|
|
1611
|
+
await this.handleStatefulMcpRequest(mcpReq, mcpRes, parsedBody);
|
|
1612
|
+
}, {
|
|
1613
|
+
validatePaidRequest: context => this.validateStatefulPaidRequest(context.request, context.parsedBody),
|
|
839
1614
|
});
|
|
840
1615
|
}
|
|
841
1616
|
else {
|
|
842
|
-
await this.
|
|
1617
|
+
await this.handleStatefulMcpRequest(req, res);
|
|
843
1618
|
}
|
|
844
1619
|
}
|
|
845
1620
|
}
|
|
@@ -878,7 +1653,9 @@ export class RemoteMCPServer {
|
|
|
878
1653
|
* @description Gracefully closes the MCP transport and HTTP listener.
|
|
879
1654
|
*/
|
|
880
1655
|
async stop() {
|
|
881
|
-
|
|
1656
|
+
const sessions = Array.from(this.statefulSessions.values());
|
|
1657
|
+
this.statefulSessions.clear();
|
|
1658
|
+
await Promise.allSettled(sessions.map(session => session.transport.close()));
|
|
882
1659
|
if (!this.httpServer) {
|
|
883
1660
|
return;
|
|
884
1661
|
}
|
|
@@ -906,6 +1683,110 @@ export class RemoteMCPServer {
|
|
|
906
1683
|
throw new Error('Remote MCP JWT auth requires SAP_MCP_AUTH_SECRET');
|
|
907
1684
|
}
|
|
908
1685
|
}
|
|
1686
|
+
/**
|
|
1687
|
+
* @name handleStatefulMcpRequest
|
|
1688
|
+
* @description Routes a stateful MCP request to the session transport created by initialize.
|
|
1689
|
+
*/
|
|
1690
|
+
async handleStatefulMcpRequest(req, res, parsedBody) {
|
|
1691
|
+
const transport = await this.resolveStatefulTransport(req, parsedBody);
|
|
1692
|
+
if (!transport) {
|
|
1693
|
+
const providedSessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1694
|
+
writeMcpJsonRpcError(res, providedSessionId ? 404 : 400, providedSessionId ? -32011 : -32010, extractJsonRpcId(parsedBody), providedSessionId ? 'mcp_session_not_found' : 'mcp_session_required', {
|
|
1695
|
+
requiredHeader: 'mcp-session-id',
|
|
1696
|
+
...(providedSessionId ? { providedSessionId } : {}),
|
|
1697
|
+
hint: providedSessionId
|
|
1698
|
+
? 'The mcp-session-id must come from this server initialize response. Client-generated UUIDs are not valid MCP sessions.'
|
|
1699
|
+
: 'Call initialize first and reuse the returned mcp-session-id header. Do not generate a client-side UUID.',
|
|
1700
|
+
});
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
await transport.handleRequest(req, res, parsedBody);
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* @name validateStatefulPaidRequest
|
|
1707
|
+
* @description Fails paid tool calls before x402 verification when the MCP session is missing or unknown.
|
|
1708
|
+
*/
|
|
1709
|
+
validateStatefulPaidRequest(req, parsedBody) {
|
|
1710
|
+
if (isInitializeJsonRpcRequest(parsedBody)) {
|
|
1711
|
+
return undefined;
|
|
1712
|
+
}
|
|
1713
|
+
const sessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1714
|
+
if (!sessionId) {
|
|
1715
|
+
return {
|
|
1716
|
+
code: -32010,
|
|
1717
|
+
message: 'mcp_session_required',
|
|
1718
|
+
data: {
|
|
1719
|
+
requiredHeader: 'mcp-session-id',
|
|
1720
|
+
hint: 'Initialize the MCP Streamable HTTP session first, then reuse the returned mcp-session-id for the unpaid payment challenge and the paid retry.',
|
|
1721
|
+
},
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
if (!this.statefulSessions.has(sessionId)) {
|
|
1725
|
+
return {
|
|
1726
|
+
code: -32011,
|
|
1727
|
+
message: 'mcp_session_not_found',
|
|
1728
|
+
data: {
|
|
1729
|
+
requiredHeader: 'mcp-session-id',
|
|
1730
|
+
providedSessionId: sessionId,
|
|
1731
|
+
hint: 'The mcp-session-id must come from this server initialize response. Client-generated UUIDs are not valid MCP sessions.',
|
|
1732
|
+
},
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
return undefined;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* @name resolveStatefulTransport
|
|
1739
|
+
* @description Returns the transport for an existing session or creates one for an initialize request.
|
|
1740
|
+
*/
|
|
1741
|
+
async resolveStatefulTransport(req, parsedBody) {
|
|
1742
|
+
const sessionId = readSingleHeader(req.headers['mcp-session-id']);
|
|
1743
|
+
if (sessionId) {
|
|
1744
|
+
const session = this.statefulSessions.get(sessionId);
|
|
1745
|
+
if (!session) {
|
|
1746
|
+
return undefined;
|
|
1747
|
+
}
|
|
1748
|
+
session.lastSeenAt = Date.now();
|
|
1749
|
+
return session.transport;
|
|
1750
|
+
}
|
|
1751
|
+
if (req.method === 'POST' && (parsedBody === undefined || isInitializeJsonRpcRequest(parsedBody))) {
|
|
1752
|
+
return await this.createStatefulSessionTransport();
|
|
1753
|
+
}
|
|
1754
|
+
return undefined;
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* @name createStatefulSessionTransport
|
|
1758
|
+
* @description Creates one MCP server and Streamable HTTP transport for a single initialized session.
|
|
1759
|
+
*/
|
|
1760
|
+
async createStatefulSessionTransport() {
|
|
1761
|
+
const server = await createSapMcpServer(this.appConfig);
|
|
1762
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1763
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1764
|
+
onsessioninitialized: sessionId => {
|
|
1765
|
+
this.statefulSessions.set(sessionId, {
|
|
1766
|
+
server,
|
|
1767
|
+
transport,
|
|
1768
|
+
createdAt: Date.now(),
|
|
1769
|
+
lastSeenAt: Date.now(),
|
|
1770
|
+
});
|
|
1771
|
+
logger.info('Stateful MCP session initialized', {
|
|
1772
|
+
sessionId,
|
|
1773
|
+
activeSessions: this.statefulSessions.size,
|
|
1774
|
+
});
|
|
1775
|
+
},
|
|
1776
|
+
});
|
|
1777
|
+
transport.onclose = () => {
|
|
1778
|
+
const sessionId = transport.sessionId;
|
|
1779
|
+
if (sessionId) {
|
|
1780
|
+
this.statefulSessions.delete(sessionId);
|
|
1781
|
+
logger.info('Stateful MCP session closed', {
|
|
1782
|
+
sessionId,
|
|
1783
|
+
activeSessions: this.statefulSessions.size,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
await server.connect(transport);
|
|
1788
|
+
return transport;
|
|
1789
|
+
}
|
|
909
1790
|
/**
|
|
910
1791
|
* @name applyCors
|
|
911
1792
|
* @description Applies configured CORS headers and handles preflight requests.
|