Compare commits

...

3 Commits

Author SHA1 Message Date
朱潮
4b50a75a3d Merge branch 'feature/mcp-ui' into bot_manager 2026-05-23 13:53:24 +08:00
朱潮
9d001c86fc add ecommerce-storefront 2026-05-23 13:53:10 +08:00
朱潮
776acc2373 DaytonaSandbox support shell env 2026-05-21 19:46:22 +08:00
10 changed files with 2319 additions and 0 deletions

View File

@ -311,6 +311,22 @@ async def init_agent(config: AgentConfig):
sandbox, sandbox_type, workspace_root = await sandbox_task
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
subagents = await load_subagents(
bot_id=config.bot_id,

1063
docs/mcp-app-training.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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}"]
}
}
}

View 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>

View 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>

View 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())

View 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"
}
}
}
]

View 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

View File

@ -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())

View 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