Merge branch 'feature/mcp-ui' into bot_manager
This commit is contained in:
commit
4b50a75a3d
@ -311,6 +311,22 @@ async def init_agent(config: AgentConfig):
|
|||||||
sandbox, sandbox_type, workspace_root = await sandbox_task
|
sandbox, sandbox_type, workspace_root = await sandbox_task
|
||||||
logger.info(f"init_agent sandbox ready, elapsed: {time.time() - create_start:.3f}s")
|
logger.info(f"init_agent sandbox ready, elapsed: {time.time() - create_start:.3f}s")
|
||||||
|
|
||||||
|
# Inject shell_env into Daytona sandbox via BASH_ENV file
|
||||||
|
if sandbox is not None and sandbox_type == "daytona":
|
||||||
|
_shell_env = {
|
||||||
|
"ASSISTANT_ID": config.bot_id,
|
||||||
|
"USER_IDENTIFIER": config.user_identifier,
|
||||||
|
"TRACE_ID": config.trace_id,
|
||||||
|
"ENABLE_SELF_KNOWLEDGE": str(config.enable_self_knowledge).lower(),
|
||||||
|
**(config.shell_env or {}),
|
||||||
|
}
|
||||||
|
env_lines = "\n".join(f'export {k}="{v}"' for k, v in _shell_env.items() if v is not None)
|
||||||
|
if env_lines:
|
||||||
|
from utils.daytona_sync import REMOTE_BASH_ENV_PATH, REMOTE_WORKSPACE_ROOT
|
||||||
|
bash_env_content = f"cd {REMOTE_WORKSPACE_ROOT}\n{env_lines}"
|
||||||
|
sandbox.execute(f"cat > {REMOTE_BASH_ENV_PATH} << 'ENVEOF'\n{bash_env_content}\nENVEOF")
|
||||||
|
logger.info(f"Injected {len(_shell_env)} env vars into Daytona BASH_ENV")
|
||||||
|
|
||||||
# Load sub-agents from skill directories
|
# Load sub-agents from skill directories
|
||||||
subagents = await load_subagents(
|
subagents = await load_subagents(
|
||||||
bot_id=config.bot_id,
|
bot_id=config.bot_id,
|
||||||
|
|||||||
1063
docs/mcp-app-training.md
Normal file
1063
docs/mcp-app-training.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "ecommerce-storefront",
|
||||||
|
"description": "Renders interactive product browsing, selection, and order confirmation UI for e-commerce scenarios.",
|
||||||
|
"hooks": {
|
||||||
|
"PrePrompt": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "python hooks/pre_prompt.py"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcpServers": {
|
||||||
|
"ecommerce_storefront": {
|
||||||
|
"transport": "stdio",
|
||||||
|
"command": "python",
|
||||||
|
"args": ["./ecommerce_server.py", "{bot_id}"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
233
skills/developing/ecommerce-storefront/apps/order-confirm.html
Normal file
233
skills/developing/ecommerce-storefront/apps/order-confirm.html
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Order Confirmation</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; padding: 20px; }
|
||||||
|
|
||||||
|
.order-card { background: #fff; border-radius: 16px; border: 1px solid #e5e5ea; overflow: hidden; max-width: 480px; margin: 0 auto; }
|
||||||
|
.order-header { padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.order-title { font-size: 20px; font-weight: 700; color: #1d1d1f; }
|
||||||
|
.order-id { font-size: 13px; color: #86868b; margin-top: 4px; }
|
||||||
|
|
||||||
|
.order-items { padding: 16px 24px; }
|
||||||
|
.order-item { display: flex; align-items: center; padding: 12px 0; border-bottom: 1px solid #f5f5f7; }
|
||||||
|
.order-item:last-child { border-bottom: none; }
|
||||||
|
.item-img { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: #f0f0f0; margin-right: 12px; flex-shrink: 0; }
|
||||||
|
.item-img-placeholder { width: 48px; height: 48px; border-radius: 10px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 18px; font-weight: 600; margin-right: 12px; flex-shrink: 0; }
|
||||||
|
.item-info { flex: 1; min-width: 0; }
|
||||||
|
.item-name { font-size: 15px; font-weight: 500; color: #1d1d1f; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.item-qty { font-size: 13px; color: #86868b; margin-top: 2px; }
|
||||||
|
.item-price { font-size: 15px; font-weight: 600; color: #1d1d1f; flex-shrink: 0; margin-left: 12px; }
|
||||||
|
|
||||||
|
.order-summary { padding: 16px 24px; border-top: 1px solid #e5e5ea; background: #fafafa; }
|
||||||
|
.summary-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 14px; color: #6e6e73; }
|
||||||
|
.summary-row.discount { color: #34c759; }
|
||||||
|
.summary-row.total { padding-top: 12px; margin-top: 8px; border-top: 1px solid #e5e5ea;
|
||||||
|
font-size: 18px; font-weight: 700; color: #1d1d1f; }
|
||||||
|
|
||||||
|
.payment-section { padding: 20px 24px; }
|
||||||
|
.payment-qr { display: flex; flex-direction: column; align-items: center; padding: 16px 0; }
|
||||||
|
.payment-qr img { width: 160px; height: 160px; border-radius: 8px; border: 1px solid #e5e5ea; }
|
||||||
|
.payment-qr-hint { font-size: 13px; color: #86868b; margin-top: 8px; }
|
||||||
|
|
||||||
|
.btn-row { display: flex; gap: 10px; }
|
||||||
|
.btn { flex: 1; padding: 14px; border: none; border-radius: 12px; font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.15s; }
|
||||||
|
.btn-primary { background: #0071e3; color: #fff; }
|
||||||
|
.btn-primary:hover { background: #0077ed; }
|
||||||
|
.btn-primary:active { background: #005bb5; }
|
||||||
|
.btn-secondary { background: #f5f5f7; color: #1d1d1f; border: 1px solid #d1d1d6; }
|
||||||
|
.btn-secondary:hover { background: #e8e8ed; }
|
||||||
|
.btn-link { background: #34c759; color: #fff; text-decoration: none; text-align: center; display: block; }
|
||||||
|
.btn-link:hover { background: #2db84d; }
|
||||||
|
|
||||||
|
.success-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(255,255,255,0.95); border-radius: 16px;
|
||||||
|
flex-direction: column; align-items: center; justify-content: center; }
|
||||||
|
.success-overlay.show { display: flex; }
|
||||||
|
.success-icon { font-size: 48px; margin-bottom: 12px; }
|
||||||
|
.success-text { font-size: 18px; font-weight: 600; color: #1d1d1f; }
|
||||||
|
.success-sub { font-size: 14px; color: #86868b; margin-top: 4px; }
|
||||||
|
|
||||||
|
.order-card-wrapper { position: relative; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="order-card-wrapper">
|
||||||
|
<div class="order-card" id="order-card"></div>
|
||||||
|
<div class="success-overlay" id="success-overlay">
|
||||||
|
<div class="success-icon" id="success-icon"></div>
|
||||||
|
<div class="success-text" id="success-text"></div>
|
||||||
|
<div class="success-sub" id="success-sub"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||||
|
|
||||||
|
function render(payload) {
|
||||||
|
var card = document.getElementById('order-card');
|
||||||
|
card.innerHTML = '';
|
||||||
|
var order = payload.order || {};
|
||||||
|
var payment = payload.payment || {};
|
||||||
|
var currency = order.currency || '$';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'order-header';
|
||||||
|
header.innerHTML = '<div class="order-title">' + esc(payload.title || 'Order Confirmation') + '</div>'
|
||||||
|
+ (order.order_id ? '<div class="order-id">Order #' + esc(order.order_id) + '</div>' : '');
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
// Items
|
||||||
|
var itemsDiv = document.createElement('div');
|
||||||
|
itemsDiv.className = 'order-items';
|
||||||
|
(order.items || []).forEach(function (item) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'order-item';
|
||||||
|
|
||||||
|
if (item.image) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'item-img';
|
||||||
|
img.src = item.image;
|
||||||
|
img.onerror = function () {
|
||||||
|
var ph = document.createElement('div');
|
||||||
|
ph.className = 'item-img-placeholder';
|
||||||
|
ph.textContent = (item.name || 'I').charAt(0).toUpperCase();
|
||||||
|
row.replaceChild(ph, img);
|
||||||
|
};
|
||||||
|
row.appendChild(img);
|
||||||
|
} else {
|
||||||
|
var ph = document.createElement('div');
|
||||||
|
ph.className = 'item-img-placeholder';
|
||||||
|
ph.textContent = (item.name || 'I').charAt(0).toUpperCase();
|
||||||
|
row.appendChild(ph);
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = document.createElement('div');
|
||||||
|
info.className = 'item-info';
|
||||||
|
info.innerHTML = '<div class="item-name">' + esc(item.name || '') + '</div>'
|
||||||
|
+ '<div class="item-qty">x' + (item.quantity || 1) + '</div>';
|
||||||
|
row.appendChild(info);
|
||||||
|
|
||||||
|
var price = document.createElement('div');
|
||||||
|
price.className = 'item-price';
|
||||||
|
price.textContent = currency + ((item.price || 0) * (item.quantity || 1)).toFixed(2);
|
||||||
|
row.appendChild(price);
|
||||||
|
|
||||||
|
itemsDiv.appendChild(row);
|
||||||
|
});
|
||||||
|
card.appendChild(itemsDiv);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
var summary = document.createElement('div');
|
||||||
|
summary.className = 'order-summary';
|
||||||
|
if (order.subtotal !== undefined) {
|
||||||
|
summary.innerHTML += '<div class="summary-row"><span>Subtotal</span><span>' + currency + (order.subtotal || 0).toFixed(2) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (order.tax) {
|
||||||
|
summary.innerHTML += '<div class="summary-row"><span>Tax</span><span>' + currency + order.tax.toFixed(2) + '</span></div>';
|
||||||
|
}
|
||||||
|
if (order.discount) {
|
||||||
|
summary.innerHTML += '<div class="summary-row discount"><span>Discount</span><span>-' + currency + order.discount.toFixed(2) + '</span></div>';
|
||||||
|
}
|
||||||
|
summary.innerHTML += '<div class="summary-row total"><span>Total</span><span>' + currency + (order.total || 0).toFixed(2) + '</span></div>';
|
||||||
|
card.appendChild(summary);
|
||||||
|
|
||||||
|
// Payment
|
||||||
|
var payDiv = document.createElement('div');
|
||||||
|
payDiv.className = 'payment-section';
|
||||||
|
|
||||||
|
if (payment.method === 'qrcode' && payment.qrcode_url) {
|
||||||
|
var qrDiv = document.createElement('div');
|
||||||
|
qrDiv.className = 'payment-qr';
|
||||||
|
qrDiv.innerHTML = '<img src="' + esc(payment.qrcode_url) + '" alt="Payment QR Code">'
|
||||||
|
+ '<div class="payment-qr-hint">Scan to pay</div>';
|
||||||
|
payDiv.appendChild(qrDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
var btnRow = document.createElement('div');
|
||||||
|
btnRow.className = 'btn-row';
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.className = 'btn btn-secondary';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
showOverlay('cancelled');
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'mcp-app-response',
|
||||||
|
payload: { action: 'cancel', order_id: order.order_id || '' }
|
||||||
|
}, '*');
|
||||||
|
});
|
||||||
|
btnRow.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
if (payment.method === 'link' && payment.payment_url) {
|
||||||
|
var linkBtn = document.createElement('a');
|
||||||
|
linkBtn.className = 'btn btn-link';
|
||||||
|
linkBtn.href = payment.payment_url;
|
||||||
|
linkBtn.target = '_blank';
|
||||||
|
linkBtn.rel = 'noopener';
|
||||||
|
linkBtn.textContent = payment.button_text || 'Pay Now';
|
||||||
|
linkBtn.addEventListener('click', function () {
|
||||||
|
setTimeout(function () {
|
||||||
|
showOverlay('confirmed');
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'mcp-app-response',
|
||||||
|
payload: { action: 'confirm', order_id: order.order_id || '' }
|
||||||
|
}, '*');
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
btnRow.appendChild(linkBtn);
|
||||||
|
} else {
|
||||||
|
var confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.className = 'btn btn-primary';
|
||||||
|
confirmBtn.textContent = payment.button_text || 'Confirm Payment';
|
||||||
|
confirmBtn.addEventListener('click', function () {
|
||||||
|
showOverlay('confirmed');
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'mcp-app-response',
|
||||||
|
payload: { action: 'confirm', order_id: order.order_id || '' }
|
||||||
|
}, '*');
|
||||||
|
});
|
||||||
|
btnRow.appendChild(confirmBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
payDiv.appendChild(btnRow);
|
||||||
|
card.appendChild(payDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlay(type) {
|
||||||
|
var overlay = document.getElementById('success-overlay');
|
||||||
|
var icon = document.getElementById('success-icon');
|
||||||
|
var text = document.getElementById('success-text');
|
||||||
|
var sub = document.getElementById('success-sub');
|
||||||
|
if (type === 'confirmed') {
|
||||||
|
icon.textContent = '\u2705';
|
||||||
|
text.textContent = 'Payment Confirmed';
|
||||||
|
sub.textContent = 'Your order has been placed successfully!';
|
||||||
|
} else {
|
||||||
|
icon.textContent = '\u274C';
|
||||||
|
text.textContent = 'Order Cancelled';
|
||||||
|
sub.textContent = 'Your order has been cancelled.';
|
||||||
|
}
|
||||||
|
overlay.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
var msg = event.data;
|
||||||
|
if (msg && msg.type === 'mcp-app-data') {
|
||||||
|
document.getElementById('success-overlay').classList.remove('show');
|
||||||
|
render(msg.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
288
skills/developing/ecommerce-storefront/apps/product-list.html
Normal file
288
skills/developing/ecommerce-storefront/apps/product-list.html
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Product List</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f7; padding: 20px; }
|
||||||
|
h1 { font-size: 22px; font-weight: 700; color: #1d1d1f; margin-bottom: 20px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
.card { background: #fff; border-radius: 16px; overflow: hidden; border: 1px solid #e5e5ea;
|
||||||
|
transition: box-shadow 0.2s, transform 0.2s; display: flex; flex-direction: column; }
|
||||||
|
.card:hover { box-shadow: 0 8px 24px rgba(0,0,0,0.08); transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.card-img { width: 100%; height: 180px; object-fit: cover; background: #f0f0f0; }
|
||||||
|
.card-img-placeholder { width: 100%; height: 180px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex; align-items: center; justify-content: center; color: #fff; font-size: 40px; }
|
||||||
|
|
||||||
|
.card-body { padding: 16px; flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.card-tags { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||||
|
.tag { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||||
|
.tag-hot { background: #fee2e2; color: #dc2626; }
|
||||||
|
.tag-new { background: #dbeafe; color: #2563eb; }
|
||||||
|
.tag-sale { background: #fef3c7; color: #d97706; }
|
||||||
|
.tag-default { background: #f1f5f9; color: #475569; }
|
||||||
|
|
||||||
|
.card-name { font-size: 17px; font-weight: 600; color: #1d1d1f; margin-bottom: 4px; }
|
||||||
|
.card-desc { font-size: 13px; color: #86868b; margin-bottom: 12px; line-height: 1.4; }
|
||||||
|
.card-price { font-size: 22px; font-weight: 700; color: #0071e3; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.spec-group { margin-bottom: 10px; }
|
||||||
|
.spec-label { font-size: 12px; font-weight: 600; color: #6e6e73; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.spec-options { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.spec-btn { padding: 6px 12px; border: 1.5px solid #d1d1d6; border-radius: 8px; background: #fff;
|
||||||
|
cursor: pointer; font-size: 13px; color: #1d1d1f; transition: all 0.15s; white-space: nowrap; }
|
||||||
|
.spec-btn:hover { border-color: #0071e3; color: #0071e3; }
|
||||||
|
.spec-btn.selected { border-color: #0071e3; background: #0071e3; color: #fff; }
|
||||||
|
.spec-btn .delta { font-size: 11px; color: #86868b; margin-left: 4px; }
|
||||||
|
.spec-btn.selected .delta { color: rgba(255,255,255,0.8); }
|
||||||
|
|
||||||
|
.quantity-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
|
||||||
|
.quantity-label { font-size: 12px; font-weight: 600; color: #6e6e73; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.quantity-ctrl { display: flex; align-items: center; gap: 0; }
|
||||||
|
.qty-btn { width: 32px; height: 32px; border: 1.5px solid #d1d1d6; background: #fff; cursor: pointer;
|
||||||
|
font-size: 16px; font-weight: 600; color: #1d1d1f; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.qty-btn:first-child { border-radius: 8px 0 0 8px; }
|
||||||
|
.qty-btn:last-child { border-radius: 0 8px 8px 0; }
|
||||||
|
.qty-btn:hover { background: #f5f5f7; }
|
||||||
|
.qty-val { width: 40px; height: 32px; border-top: 1.5px solid #d1d1d6; border-bottom: 1.5px solid #d1d1d6;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.add-btn { width: 100%; padding: 12px; border: none; border-radius: 12px; background: #0071e3;
|
||||||
|
color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.15s; }
|
||||||
|
.add-btn:hover { background: #0077ed; }
|
||||||
|
.add-btn:active { background: #005bb5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 id="title"></h1>
|
||||||
|
<div class="grid" id="grid"></div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function esc(t) { var d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
|
||||||
|
|
||||||
|
function getTagClass(tag) {
|
||||||
|
var t = (tag || '').toLowerCase();
|
||||||
|
if (t === 'hot' || t === 'popular') return 'tag-hot';
|
||||||
|
if (t === 'new') return 'tag-new';
|
||||||
|
if (t === 'sale' || t === 'discount') return 'tag-sale';
|
||||||
|
return 'tag-default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(payload) {
|
||||||
|
document.getElementById('title').textContent = payload.title || 'Products';
|
||||||
|
var grid = document.getElementById('grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
(payload.products || []).forEach(function (p) {
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'card';
|
||||||
|
|
||||||
|
// State
|
||||||
|
var state = { specs: {}, quantity: 1 };
|
||||||
|
var currency = p.currency || '$';
|
||||||
|
|
||||||
|
// Initialize default spec selections (first option of each group)
|
||||||
|
(p.specs || []).forEach(function (sg) {
|
||||||
|
if (sg.options && sg.options.length > 0) {
|
||||||
|
state.specs[sg.label] = sg.options[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function calcPrice() {
|
||||||
|
var total = p.price || 0;
|
||||||
|
Object.keys(state.specs).forEach(function (k) {
|
||||||
|
total += (state.specs[k].price_delta || 0);
|
||||||
|
});
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
|
if (p.image) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'card-img';
|
||||||
|
img.src = p.image;
|
||||||
|
img.alt = p.name || '';
|
||||||
|
img.onerror = function () {
|
||||||
|
var ph = document.createElement('div');
|
||||||
|
ph.className = 'card-img-placeholder';
|
||||||
|
ph.textContent = (p.name || 'P').charAt(0).toUpperCase();
|
||||||
|
card.replaceChild(ph, img);
|
||||||
|
};
|
||||||
|
card.appendChild(img);
|
||||||
|
} else {
|
||||||
|
var ph = document.createElement('div');
|
||||||
|
ph.className = 'card-img-placeholder';
|
||||||
|
ph.textContent = (p.name || 'P').charAt(0).toUpperCase();
|
||||||
|
card.appendChild(ph);
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = document.createElement('div');
|
||||||
|
body.className = 'card-body';
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (p.tags && p.tags.length > 0) {
|
||||||
|
var tagsDiv = document.createElement('div');
|
||||||
|
tagsDiv.className = 'card-tags';
|
||||||
|
p.tags.forEach(function (t) {
|
||||||
|
var span = document.createElement('span');
|
||||||
|
span.className = 'tag ' + getTagClass(t);
|
||||||
|
span.textContent = t;
|
||||||
|
tagsDiv.appendChild(span);
|
||||||
|
});
|
||||||
|
body.appendChild(tagsDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name
|
||||||
|
var name = document.createElement('div');
|
||||||
|
name.className = 'card-name';
|
||||||
|
name.textContent = p.name || '';
|
||||||
|
body.appendChild(name);
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if (p.description) {
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.className = 'card-desc';
|
||||||
|
desc.textContent = p.description;
|
||||||
|
body.appendChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price
|
||||||
|
var priceEl = document.createElement('div');
|
||||||
|
priceEl.className = 'card-price';
|
||||||
|
priceEl.textContent = currency + calcPrice().toFixed(2);
|
||||||
|
body.appendChild(priceEl);
|
||||||
|
|
||||||
|
// Specs
|
||||||
|
(p.specs || []).forEach(function (sg) {
|
||||||
|
var group = document.createElement('div');
|
||||||
|
group.className = 'spec-group';
|
||||||
|
|
||||||
|
var label = document.createElement('div');
|
||||||
|
label.className = 'spec-label';
|
||||||
|
label.textContent = sg.label;
|
||||||
|
group.appendChild(label);
|
||||||
|
|
||||||
|
var optionsDiv = document.createElement('div');
|
||||||
|
optionsDiv.className = 'spec-options';
|
||||||
|
|
||||||
|
(sg.options || []).forEach(function (opt, idx) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'spec-btn' + (idx === 0 ? ' selected' : '');
|
||||||
|
var deltaText = '';
|
||||||
|
if (opt.price_delta && opt.price_delta !== 0) {
|
||||||
|
deltaText = (opt.price_delta > 0 ? '+' : '') + currency + Math.abs(opt.price_delta).toFixed(2);
|
||||||
|
}
|
||||||
|
btn.innerHTML = esc(opt.name) + (deltaText ? ' <span class="delta">' + deltaText + '</span>' : '');
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
state.specs[sg.label] = opt;
|
||||||
|
optionsDiv.querySelectorAll('.spec-btn').forEach(function (b) { b.classList.remove('selected'); });
|
||||||
|
btn.classList.add('selected');
|
||||||
|
priceEl.textContent = currency + calcPrice().toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
optionsDiv.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
group.appendChild(optionsDiv);
|
||||||
|
body.appendChild(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quantity
|
||||||
|
var qtyRow = document.createElement('div');
|
||||||
|
qtyRow.className = 'quantity-row';
|
||||||
|
var qtyLabel = document.createElement('div');
|
||||||
|
qtyLabel.className = 'quantity-label';
|
||||||
|
qtyLabel.textContent = 'Quantity';
|
||||||
|
qtyRow.appendChild(qtyLabel);
|
||||||
|
|
||||||
|
var qtyCtrl = document.createElement('div');
|
||||||
|
qtyCtrl.className = 'quantity-ctrl';
|
||||||
|
|
||||||
|
var minusBtn = document.createElement('button');
|
||||||
|
minusBtn.className = 'qty-btn';
|
||||||
|
minusBtn.textContent = '-';
|
||||||
|
|
||||||
|
var qtyVal = document.createElement('div');
|
||||||
|
qtyVal.className = 'qty-val';
|
||||||
|
qtyVal.textContent = '1';
|
||||||
|
|
||||||
|
var plusBtn = document.createElement('button');
|
||||||
|
plusBtn.className = 'qty-btn';
|
||||||
|
plusBtn.textContent = '+';
|
||||||
|
|
||||||
|
minusBtn.addEventListener('click', function () {
|
||||||
|
if (state.quantity > 1) {
|
||||||
|
state.quantity--;
|
||||||
|
qtyVal.textContent = state.quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
plusBtn.addEventListener('click', function () {
|
||||||
|
if (state.quantity < 99) {
|
||||||
|
state.quantity++;
|
||||||
|
qtyVal.textContent = state.quantity;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
qtyCtrl.appendChild(minusBtn);
|
||||||
|
qtyCtrl.appendChild(qtyVal);
|
||||||
|
qtyCtrl.appendChild(plusBtn);
|
||||||
|
qtyRow.appendChild(qtyCtrl);
|
||||||
|
body.appendChild(qtyRow);
|
||||||
|
|
||||||
|
// Spacer
|
||||||
|
var spacer = document.createElement('div');
|
||||||
|
spacer.className = 'spacer';
|
||||||
|
body.appendChild(spacer);
|
||||||
|
|
||||||
|
// Add to Cart button
|
||||||
|
var addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'add-btn';
|
||||||
|
addBtn.textContent = 'Add to Cart';
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
var selectedSpecs = {};
|
||||||
|
Object.keys(state.specs).forEach(function (k) {
|
||||||
|
selectedSpecs[k] = state.specs[k].name;
|
||||||
|
});
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'mcp-app-response',
|
||||||
|
payload: {
|
||||||
|
product_id: p.id,
|
||||||
|
product_name: p.name,
|
||||||
|
selected_specs: selectedSpecs,
|
||||||
|
final_price: calcPrice(),
|
||||||
|
quantity: state.quantity,
|
||||||
|
currency: currency
|
||||||
|
}
|
||||||
|
}, '*');
|
||||||
|
addBtn.textContent = 'Added!';
|
||||||
|
addBtn.style.background = '#34c759';
|
||||||
|
setTimeout(function () {
|
||||||
|
addBtn.textContent = 'Add to Cart';
|
||||||
|
addBtn.style.background = '#0071e3';
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
body.appendChild(addBtn);
|
||||||
|
|
||||||
|
card.appendChild(body);
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', function (event) {
|
||||||
|
var msg = event.data;
|
||||||
|
if (msg && msg.type === 'mcp-app-data') {
|
||||||
|
render(msg.payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.parent.postMessage({ type: 'mcp-app-ready' }, '*');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
213
skills/developing/ecommerce-storefront/ecommerce_server.py
Normal file
213
skills/developing/ecommerce-storefront/ecommerce_server.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
E-Commerce Storefront MCP Server - standard MCP Apps protocol (SEP-1865).
|
||||||
|
|
||||||
|
- tools/call returns structured data only (no HTML)
|
||||||
|
- resources/read returns static HTML App files
|
||||||
|
- Host renders HTML App in iframe, passes tool data via postMessage
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from mcp_common import (
|
||||||
|
create_error_response,
|
||||||
|
create_ping_response,
|
||||||
|
create_tools_list_response,
|
||||||
|
load_tools_from_json,
|
||||||
|
handle_mcp_streaming,
|
||||||
|
)
|
||||||
|
|
||||||
|
RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"
|
||||||
|
APPS_DIR = os.path.join(os.path.dirname(__file__), "apps")
|
||||||
|
|
||||||
|
# Resource URI -> static HTML App file mapping
|
||||||
|
RESOURCE_MAP = {
|
||||||
|
"ui://ecommerce-storefront/product-list": "product-list.html",
|
||||||
|
"ui://ecommerce-storefront/order-confirm": "order-confirm.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
RESOURCE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
"uri": "ui://ecommerce-storefront/product-list",
|
||||||
|
"name": "product-list",
|
||||||
|
"title": "Product List",
|
||||||
|
"description": "Interactive product cards with spec selection",
|
||||||
|
"mimeType": RESOURCE_MIME_TYPE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uri": "ui://ecommerce-storefront/order-confirm",
|
||||||
|
"name": "order-confirm",
|
||||||
|
"title": "Order Confirmation",
|
||||||
|
"description": "Order summary with payment options",
|
||||||
|
"mimeType": RESOURCE_MIME_TYPE,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_app_html(uri: str) -> str:
|
||||||
|
"""Load static HTML App file for the given resource URI."""
|
||||||
|
filename = RESOURCE_MAP.get(uri)
|
||||||
|
if not filename:
|
||||||
|
raise ValueError(f"Unknown resource URI: {uri}")
|
||||||
|
filepath = os.path.join(APPS_DIR, filename)
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_app_response(resource_uri: str, data: Dict[str, Any],
|
||||||
|
width: str = "100%", height: str = "auto") -> Dict[str, Any]:
|
||||||
|
"""Create a tool result for MCP Apps protocol."""
|
||||||
|
app_json = json.dumps({
|
||||||
|
"type": "app",
|
||||||
|
"resourceUri": resource_uri,
|
||||||
|
"data": data,
|
||||||
|
"_meta": {
|
||||||
|
"mcpui.dev/ui-preferred-frame-size": [width, height],
|
||||||
|
},
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
return {"content": [{"type": "text", "text": app_json}]}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tool handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_render_product_list(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
title = arguments.get("title", "Products")
|
||||||
|
products = arguments.get("products", [])
|
||||||
|
if not products:
|
||||||
|
raise ValueError("Missing required parameter: products")
|
||||||
|
return _create_app_response(
|
||||||
|
"ui://ecommerce-storefront/product-list",
|
||||||
|
{"title": title, "products": products},
|
||||||
|
"100%", "auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_render_order_confirm(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
title = arguments.get("title", "Order Confirmation")
|
||||||
|
order = arguments.get("order")
|
||||||
|
payment = arguments.get("payment")
|
||||||
|
if not order:
|
||||||
|
raise ValueError("Missing required parameter: order")
|
||||||
|
if not payment:
|
||||||
|
raise ValueError("Missing required parameter: payment")
|
||||||
|
if not order.get("items"):
|
||||||
|
raise ValueError("Missing required parameter: order.items")
|
||||||
|
return _create_app_response(
|
||||||
|
"ui://ecommerce-storefront/order-confirm",
|
||||||
|
{"title": title, "order": order, "payment": payment},
|
||||||
|
"100%", "auto",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_HANDLERS = {
|
||||||
|
"render_product_list": _handle_render_product_list,
|
||||||
|
"render_order_confirm": _handle_render_order_confirm,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Handle an MCP request."""
|
||||||
|
try:
|
||||||
|
method = request.get("method")
|
||||||
|
params = request.get("params", {})
|
||||||
|
request_id = request.get("id")
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {},
|
||||||
|
"resources": {},
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "ecommerce-storefront",
|
||||||
|
"version": "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "ping":
|
||||||
|
return create_ping_response(request_id)
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
|
tools = load_tools_from_json("ecommerce_tools.json")
|
||||||
|
if not tools:
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"description": f"Render {name.replace('render_', '')}",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}, "required": []},
|
||||||
|
"_meta": {"ui": {"resourceUri": uri}},
|
||||||
|
}
|
||||||
|
for name, uri in [
|
||||||
|
("render_product_list", "ui://ecommerce-storefront/product-list"),
|
||||||
|
("render_order_confirm", "ui://ecommerce-storefront/order-confirm"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
return create_tools_list_response(request_id, tools)
|
||||||
|
|
||||||
|
elif method == "resources/list":
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {"resources": RESOURCE_DEFINITIONS},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "resources/read":
|
||||||
|
uri = params.get("uri", "")
|
||||||
|
try:
|
||||||
|
html = _load_app_html(uri)
|
||||||
|
except (ValueError, FileNotFoundError) as e:
|
||||||
|
return create_error_response(request_id, -32602, str(e))
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"contents": [
|
||||||
|
{
|
||||||
|
"uri": uri,
|
||||||
|
"mimeType": RESOURCE_MIME_TYPE,
|
||||||
|
"text": html,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
handler = TOOL_HANDLERS.get(tool_name)
|
||||||
|
if not handler:
|
||||||
|
return create_error_response(request_id, -32601, f"Unknown tool: {tool_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = handler(arguments)
|
||||||
|
except ValueError as e:
|
||||||
|
return create_error_response(request_id, -32602, str(e))
|
||||||
|
|
||||||
|
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
||||||
|
|
||||||
|
else:
|
||||||
|
return create_error_response(request_id, -32601, f"Unknown method: {method}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return create_error_response(
|
||||||
|
request.get("id"), -32603, f"Internal error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await handle_mcp_streaming(handle_request)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
125
skills/developing/ecommerce-storefront/ecommerce_tools.json
Normal file
125
skills/developing/ecommerce-storefront/ecommerce_tools.json
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "render_product_list",
|
||||||
|
"description": "Render an interactive product card list. Users can browse products, select specifications (size/flavor/etc.), and add items to cart. Returns the user's selection via mcp-app-response.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Title displayed at the top of the product list"
|
||||||
|
},
|
||||||
|
"products": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Array of product objects to display as cards",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "description": "Unique product identifier" },
|
||||||
|
"name": { "type": "string", "description": "Product name" },
|
||||||
|
"description": { "type": "string", "description": "Short product description" },
|
||||||
|
"image": { "type": "string", "description": "Product image URL" },
|
||||||
|
"price": { "type": "number", "description": "Base price" },
|
||||||
|
"currency": { "type": "string", "description": "Currency symbol, default: '$'", "default": "$" },
|
||||||
|
"specs": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Available specification groups (e.g. size, flavor)",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": { "type": "string", "description": "Spec group label, e.g. 'Size'" },
|
||||||
|
"options": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "description": "Option name, e.g. 'Large'" },
|
||||||
|
"price_delta": { "type": "number", "description": "Price adjustment, default 0", "default": 0 }
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["label", "options"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "Optional tags like 'Hot', 'New', 'Sale'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["id", "name", "price"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "products"]
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"ui": {
|
||||||
|
"resourceUri": "ui://ecommerce-storefront/product-list"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "render_order_confirm",
|
||||||
|
"description": "Render an order confirmation page with order details, total price, and a payment action button or QR code. Returns 'confirmed' or 'cancelled' via mcp-app-response.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Page title, e.g. 'Order Confirmation'"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Order details",
|
||||||
|
"properties": {
|
||||||
|
"order_id": { "type": "string", "description": "Order ID" },
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Ordered items",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string", "description": "Product name with specs" },
|
||||||
|
"quantity": { "type": "integer", "description": "Quantity", "default": 1 },
|
||||||
|
"price": { "type": "number", "description": "Unit price" },
|
||||||
|
"image": { "type": "string", "description": "Product image URL" }
|
||||||
|
},
|
||||||
|
"required": ["name", "price"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subtotal": { "type": "number", "description": "Subtotal before tax/fees" },
|
||||||
|
"tax": { "type": "number", "description": "Tax amount", "default": 0 },
|
||||||
|
"discount": { "type": "number", "description": "Discount amount", "default": 0 },
|
||||||
|
"total": { "type": "number", "description": "Final total" },
|
||||||
|
"currency": { "type": "string", "description": "Currency symbol", "default": "$" }
|
||||||
|
},
|
||||||
|
"required": ["order_id", "items", "total"]
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Payment configuration",
|
||||||
|
"properties": {
|
||||||
|
"method": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["button", "qrcode", "link"],
|
||||||
|
"description": "Payment UI type: button (confirm button), qrcode (QR code image), link (external payment URL)"
|
||||||
|
},
|
||||||
|
"qrcode_url": { "type": "string", "description": "QR code image URL (when method=qrcode)" },
|
||||||
|
"payment_url": { "type": "string", "description": "External payment URL (when method=link)" },
|
||||||
|
"button_text": { "type": "string", "description": "Payment button text, default: 'Confirm Payment'", "default": "Confirm Payment" }
|
||||||
|
},
|
||||||
|
"required": ["method"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["title", "order", "payment"]
|
||||||
|
},
|
||||||
|
"_meta": {
|
||||||
|
"ui": {
|
||||||
|
"resourceUri": "ui://ecommerce-storefront/order-confirm"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
102
skills/developing/ecommerce-storefront/hooks/ecommerce_guide.md
Normal file
102
skills/developing/ecommerce-storefront/hooks/ecommerce_guide.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
## E-Commerce Storefront Tools Usage Guide
|
||||||
|
|
||||||
|
Two tools are available for e-commerce product browsing and ordering:
|
||||||
|
- `render_product_list` — Display interactive product cards with spec selection
|
||||||
|
- `render_order_confirm` — Display order confirmation with payment options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. render_product_list
|
||||||
|
|
||||||
|
When to use: User wants to browse, search, or buy products. Show product cards with images, prices, and selectable specs (size, flavor, color, etc.).
|
||||||
|
|
||||||
|
The user can select specs and click "Add to Cart" on any product. The selection is returned as an mcp-app-response containing: `{ product_id, product_name, selected_specs: {...}, final_price, quantity }`.
|
||||||
|
|
||||||
|
```
|
||||||
|
render_product_list(
|
||||||
|
title="Coffee Menu",
|
||||||
|
products=[
|
||||||
|
{
|
||||||
|
"id": "latte-001",
|
||||||
|
"name": "Caffe Latte",
|
||||||
|
"description": "Rich espresso with steamed milk",
|
||||||
|
"image": "https://images.unsplash.com/photo-1572442388796-11668a67e53d?w=300&h=200&fit=crop",
|
||||||
|
"price": 4.50,
|
||||||
|
"currency": "$",
|
||||||
|
"specs": [
|
||||||
|
{
|
||||||
|
"label": "Size",
|
||||||
|
"options": [
|
||||||
|
{"name": "Small", "price_delta": 0},
|
||||||
|
{"name": "Medium", "price_delta": 0.5},
|
||||||
|
{"name": "Large", "price_delta": 1.0}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Milk",
|
||||||
|
"options": [
|
||||||
|
{"name": "Whole Milk", "price_delta": 0},
|
||||||
|
{"name": "Oat Milk", "price_delta": 0.6},
|
||||||
|
{"name": "Almond Milk", "price_delta": 0.6}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": ["Hot", "Popular"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
After receiving the user's selection, proceed to generate an order and call `render_order_confirm`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. render_order_confirm
|
||||||
|
|
||||||
|
When to use: After the user selects a product and you have generated an order.
|
||||||
|
|
||||||
|
Payment method options:
|
||||||
|
- `"button"` — Simple confirm/cancel buttons (default)
|
||||||
|
- `"qrcode"` — Show a QR code image for mobile payment
|
||||||
|
- `"link"` — Provide an external payment URL
|
||||||
|
|
||||||
|
```
|
||||||
|
render_order_confirm(
|
||||||
|
title="Order Confirmation",
|
||||||
|
order={
|
||||||
|
"order_id": "ORD-20260522-001",
|
||||||
|
"items": [
|
||||||
|
{"name": "Caffe Latte (Large, Oat Milk)", "quantity": 1, "price": 6.10}
|
||||||
|
],
|
||||||
|
"subtotal": 6.10,
|
||||||
|
"tax": 0.49,
|
||||||
|
"total": 6.59,
|
||||||
|
"currency": "$"
|
||||||
|
},
|
||||||
|
payment={
|
||||||
|
"method": "button",
|
||||||
|
"button_text": "Confirm Payment"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The user response will be: `{ action: "confirm", order_id }` or `{ action: "cancel", order_id }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
|
||||||
|
The typical e-commerce flow is:
|
||||||
|
1. User asks to buy something → call `render_product_list`
|
||||||
|
2. User selects a product → receive mcp-app-response with selection details
|
||||||
|
3. Generate order from selection → call `render_order_confirm`
|
||||||
|
4. User confirms or cancels → receive mcp-app-response with action
|
||||||
|
5. If confirmed → process payment / show success message
|
||||||
|
6. If cancelled → ask user what they'd like to do next
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
- Always include product images when available for better visual experience
|
||||||
|
- Use tags like "Hot", "New", "Sale" to highlight special products
|
||||||
|
- Keep product descriptions short (under 50 characters)
|
||||||
|
- Generate a unique order_id for each order
|
||||||
|
- Include tax/discount breakdowns when applicable for transparency
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PrePrompt hook - inject ecommerce tool guide into system prompt."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
guide_path = os.path.join(os.path.dirname(__file__), "ecommerce_guide.md")
|
||||||
|
if os.path.exists(guide_path):
|
||||||
|
with open(guide_path, "r", encoding="utf-8") as f:
|
||||||
|
print(f.read())
|
||||||
252
skills/developing/ecommerce-storefront/mcp_common.py
Normal file
252
skills/developing/ecommerce-storefront/mcp_common.py
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shared utility functions for the MCP server.
|
||||||
|
Provides common functionality for path handling, file validation, and request processing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
import re
|
||||||
|
|
||||||
|
def get_allowed_directory():
|
||||||
|
"""Get the directory that is allowed to be accessed."""
|
||||||
|
# Prefer dataset_dir passed through command-line arguments.
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
dataset_dir = sys.argv[1]
|
||||||
|
return os.path.abspath(dataset_dir)
|
||||||
|
|
||||||
|
# Read the project data directory from the environment variable.
|
||||||
|
project_dir = os.getenv("PROJECT_DATA_DIR", "./projects/data")
|
||||||
|
return os.path.abspath(project_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_path(file_path: str, default_subfolder: str = "default") -> str:
|
||||||
|
"""
|
||||||
|
Resolve a file path, supporting both folder/document.txt and document.txt formats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Input file path.
|
||||||
|
default_subfolder: Default subfolder name to use when only a filename is provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved full file path.
|
||||||
|
"""
|
||||||
|
# If the path contains a folder separator, use it directly.
|
||||||
|
if '/' in file_path or '\\' in file_path:
|
||||||
|
clean_path = file_path.replace('\\', '/')
|
||||||
|
|
||||||
|
# Remove the projects/ prefix if it exists.
|
||||||
|
if clean_path.startswith('projects/'):
|
||||||
|
clean_path = clean_path[9:] # Remove the 'projects/' prefix.
|
||||||
|
elif clean_path.startswith('./projects/'):
|
||||||
|
clean_path = clean_path[11:] # Remove the './projects/' prefix.
|
||||||
|
else:
|
||||||
|
# If only a filename is provided, add the default subfolder.
|
||||||
|
clean_path = f"{default_subfolder}/{file_path}"
|
||||||
|
|
||||||
|
# Get the allowed directory.
|
||||||
|
project_data_dir = get_allowed_directory()
|
||||||
|
|
||||||
|
# Try to locate the file directly under the project directory.
|
||||||
|
full_path = os.path.join(project_data_dir, clean_path.lstrip('./'))
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
# If the direct path does not exist, try a recursive search.
|
||||||
|
found = find_file_in_project(clean_path, project_data_dir)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
|
||||||
|
# If this is a bare filename and it was not found under the default subfolder,
|
||||||
|
# try looking in the project root.
|
||||||
|
if '/' not in file_path and '\\' not in file_path:
|
||||||
|
root_path = os.path.join(project_data_dir, file_path)
|
||||||
|
if os.path.exists(root_path):
|
||||||
|
return root_path
|
||||||
|
|
||||||
|
raise FileNotFoundError(f"File not found: {file_path} (searched in {project_data_dir})")
|
||||||
|
|
||||||
|
|
||||||
|
def find_file_in_project(filename: str, project_dir: str) -> Optional[str]:
|
||||||
|
"""Recursively search for a file inside the project directory."""
|
||||||
|
# If filename includes a path, only search within the specified path.
|
||||||
|
if '/' in filename:
|
||||||
|
parts = filename.split('/')
|
||||||
|
target_file = parts[-1]
|
||||||
|
search_dir = os.path.join(project_dir, *parts[:-1])
|
||||||
|
|
||||||
|
if os.path.exists(search_dir):
|
||||||
|
target_path = os.path.join(search_dir, target_file)
|
||||||
|
if os.path.exists(target_path):
|
||||||
|
return target_path
|
||||||
|
else:
|
||||||
|
# For a bare filename, recursively search the whole project directory.
|
||||||
|
for root, dirs, files in os.walk(project_dir):
|
||||||
|
if filename in files:
|
||||||
|
return os.path.join(root, filename)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_tools_from_json(tools_file_name: str) -> List[Dict[str, Any]]:
|
||||||
|
"""Load tool definitions from a JSON file."""
|
||||||
|
try:
|
||||||
|
tools_file = os.path.join(os.path.dirname(__file__), tools_file_name)
|
||||||
|
if os.path.exists(tools_file):
|
||||||
|
with open(tools_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
else:
|
||||||
|
# If the JSON file does not exist, use the default definitions.
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Unable to load tool definition JSON file: {str(e)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def create_error_response(request_id: Any, code: int, message: str) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized error response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_success_response(request_id: Any, result: Any) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized success response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_initialize_response(request_id: Any, server_name: str, server_version: str = "1.0.0") -> Dict[str, Any]:
|
||||||
|
"""Create a standardized initialize response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {
|
||||||
|
"tools": {}
|
||||||
|
},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": server_name,
|
||||||
|
"version": server_version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_ping_response(request_id: Any) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized ping response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"pong": True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_tools_list_response(request_id: Any, tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Create a standardized tools/list response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": request_id,
|
||||||
|
"result": {
|
||||||
|
"tools": tools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_regex_pattern(pattern: str) -> bool:
|
||||||
|
"""Check whether a string should be treated as a regular expression pattern."""
|
||||||
|
# Check the /pattern/ format.
|
||||||
|
if pattern.startswith('/') and pattern.endswith('/') and len(pattern) > 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check the r"pattern" or r'pattern' format.
|
||||||
|
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")) and len(pattern) > 3:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check whether it contains regex metacharacters.
|
||||||
|
regex_chars = {'*', '+', '?', '|', '(', ')', '[', ']', '{', '}', '^', '$', '\\', '.'}
|
||||||
|
return any(char in pattern for char in regex_chars)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_pattern(pattern: str) -> Union[re.Pattern, str, None]:
|
||||||
|
"""Compile a regex pattern, or return the original string if it is not regex."""
|
||||||
|
if not is_regex_pattern(pattern):
|
||||||
|
return pattern
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Handle the /pattern/ format.
|
||||||
|
if pattern.startswith('/') and pattern.endswith('/'):
|
||||||
|
regex_body = pattern[1:-1]
|
||||||
|
return re.compile(regex_body)
|
||||||
|
|
||||||
|
# Handle the r"pattern" or r'pattern' format.
|
||||||
|
if pattern.startswith(('r"', "r'")) and pattern.endswith(('"', "'")):
|
||||||
|
regex_body = pattern[2:-1]
|
||||||
|
return re.compile(regex_body)
|
||||||
|
|
||||||
|
# Directly compile strings that contain regex metacharacters.
|
||||||
|
return re.compile(pattern)
|
||||||
|
except re.error as e:
|
||||||
|
# If compilation fails, return None to indicate an invalid regex.
|
||||||
|
print(f"Warning: Regular expression '{pattern}' compilation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_mcp_streaming(request_handler):
|
||||||
|
"""Handle the standard main loop for MCP requests."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
# Read from stdin
|
||||||
|
line = await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = json.loads(line)
|
||||||
|
response = await request_handler(request)
|
||||||
|
|
||||||
|
# Write to stdout
|
||||||
|
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32700,
|
||||||
|
"message": "Parse error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_response = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"error": {
|
||||||
|
"code": -32603,
|
||||||
|
"message": f"Internal error: {str(e)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sys.stdout.write(json.dumps(error_response, ensure_ascii=False) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
Loading…
Reference in New Issue
Block a user