diff --git a/content/develop/ai/context-engine/context-retriever/_index.md b/content/develop/ai/context-engine/context-retriever/_index.md index b5e83bac30..2ca5e75ae0 100644 --- a/content/develop/ai/context-engine/context-retriever/_index.md +++ b/content/develop/ai/context-engine/context-retriever/_index.md @@ -70,6 +70,8 @@ Use the `ctxctl` CLI, the Python client, or the Redis Cloud UI to model your ent See the [Redis Cloud setup guide](/content/operate/iris/context-retriever/create-service.md) to create your first Context Retriever service. +To see how a data model becomes tools before you create a service, try the [interactive demo](/content/develop/ai/context-engine/context-retriever/interactive-demo.md). + Redis Context Retriever helps teams expose operational context to AI agents through schema-first retrieval. It models the entities, fields, keys, and relationships that matter to an agent workflow, then presents that context through a governed tool surface the agent can call at runtime. Context Retriever helps an AI Agent understand what business objects exist, how they connect, and which paths are safe to use. ## Overview diff --git a/content/develop/ai/context-engine/context-retriever/interactive-demo.md b/content/develop/ai/context-engine/context-retriever/interactive-demo.md new file mode 100644 index 0000000000..f07a1b956e --- /dev/null +++ b/content/develop/ai/context-engine/context-retriever/interactive-demo.md @@ -0,0 +1,31 @@ +--- +Title: Context Retriever interactive demo +alwaysopen: false +categories: +- docs +- develop +- ai +description: See how Context Retriever turns the keys in a Redis database into MCP tools that an agent calls to answer questions. +linkTitle: Interactive demo +weight: 20 +hideListLinks: true +--- + +Context Retriever reads a data model that you define once, and generates the tools an agent uses to query your data. The agent calls those tools over MCP (Model Context Protocol) and never connects to Redis itself. + +This demo runs in your browser with sample data from a food delivery app: customers, restaurants, and orders. It doesn't connect to a Context Retriever service, but the tool names, arguments, and responses follow the same shape a real service returns. + +{{< context-retriever-demo >}} + +## What the demo shows + +1. **Keys in Redis.** Each business object is a JSON document, and its key name starts with the object type, such as `restaurant:r201`. +2. **Data model to tools.** A key template such as `restaurant:{id}` tells Context Retriever which keys belong to an entity. The fields you index decide which tools it generates and which arguments those tools accept. +3. **Ask over MCP.** The agent lists the tools, picks one or more for each question, and combines the results into an answer. + +Try removing the index from a field in step 2, then ask a question that needs it. The server rejects the call, because the tool no longer accepts that field. + +## Next steps + +- [Create a Context Retriever service](/content/operate/iris/context-retriever/create-service.md) on Redis Cloud. +- Read the [Context Retriever overview](/content/develop/ai/context-engine/context-retriever/_index.md). diff --git a/layouts/shortcodes/context-retriever-demo.html b/layouts/shortcodes/context-retriever-demo.html new file mode 100644 index 0000000000..21ab600f05 --- /dev/null +++ b/layouts/shortcodes/context-retriever-demo.html @@ -0,0 +1,20 @@ +{{- /* + context-retriever-demo + ---------------------- + Scripted, dependency-free demo of Redis Context Retriever: sample customer + keys, a data model that generates MCP tools, and a scripted agent calling + those tools. Runs entirely in the browser; no service is called. + + Usage (no arguments): + {{< context-retriever-demo >}} +*/ -}} + +{{- if not (.Page.Store.Get "rcrAssetsEmitted") -}} + + +{{- .Page.Store.Set "rcrAssetsEmitted" true -}} +{{- end -}} + +
+ +
diff --git a/static/css/context-retriever-demo.css b/static/css/context-retriever-demo.css new file mode 100644 index 0000000000..7a1bdc1302 --- /dev/null +++ b/static/css/context-retriever-demo.css @@ -0,0 +1,193 @@ +/* ========================================================================= + Redis Context Retriever scripted demo + Scoped under `.rcr`. Palette uses the Redis design tokens, matching + vector-playground.css. + ========================================================================= */ + +.rcr { + --rcr-ink: #091a23; + --rcr-muted: #5c707a; + --rcr-faint: #8a99a0; + --rcr-line: #e8ebec; + --rcr-line-strong: #d9d9d9; + --rcr-surface: #ffffff; + --rcr-surface-2: #f9f9f9; + --rcr-code-bg: #f6f7f9; + --rcr-accent: #ff4438; + --rcr-accent-2: #5961ff; + --rcr-ok: #1a7f4b; + --rcr-bad: #c62828; + --rcr-focus: rgba(89, 97, 255, .28); + --rcr-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + container-type: inline-size; + color: var(--rcr-ink); + font-size: 15px; + line-height: 1.5; + margin: 1.5rem 0 2rem; + border: 1px solid var(--rcr-line); + border-radius: 12px; + background: var(--rcr-surface); + padding: 16px; +} +.rcr *, .rcr *::before, .rcr *::after { box-sizing: border-box; } +.rcr p { margin: 0; } +.rcr code, .rcr .rcr-mono { font-family: var(--rcr-mono); font-size: 12.5px; } +.rcr code { background: var(--rcr-code-bg); border-radius: 4px; padding: 1px 5px; } +/* Zero-specificity reset, so component classes such as .rcr-btn-primary win. */ +:where(.rcr) :where(button, select, input) { font: inherit; color: inherit; } +.rcr button:focus-visible, .rcr select:focus-visible, .rcr input:focus-visible, .rcr summary:focus-visible { + outline: none; box-shadow: 0 0 0 3px var(--rcr-focus); border-radius: 6px; +} +.rcr [hidden] { display: none !important; } + +/* ---- tabs ---- */ +.rcr-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } +.rcr-tab { + display: inline-flex; align-items: center; gap: 8px; cursor: pointer; + background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); + border-radius: 999px; padding: 6px 14px; font-size: 13.5px; color: var(--rcr-muted); +} +.rcr-tab[aria-selected="true"] { background: var(--rcr-ink); border-color: var(--rcr-ink); color: #fff; } +.rcr-n { + width: 19px; height: 19px; border-radius: 50%; display: grid; place-items: center; + font-size: 11px; font-weight: 700; background: #fff; color: var(--rcr-accent-2); border: 1px solid var(--rcr-line-strong); +} +.rcr-tab[aria-selected="true"] .rcr-n { border-color: #fff; } + +.rcr-lede { color: var(--rcr-muted); margin-bottom: 14px !important; max-width: 70ch; } +.rcr-note { color: var(--rcr-muted); font-size: 13px; margin-top: 10px !important; } +.rcr-faint { color: var(--rcr-faint); font-size: 12px; } +.rcr-lbl { color: var(--rcr-faint); font-size: 11px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; } + +/* ---- layout ---- */ +.rcr-split { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; } +@container (min-width: 560px) { + .rcr-split { grid-template-columns: minmax(200px, 2fr) 3fr; } + .rcr .rcr-toolsbody { grid-template-columns: minmax(190px, 2fr) 3fr; } +} +.rcr-card { border: 1px solid var(--rcr-line); border-radius: 10px; background: var(--rcr-surface); padding: 12px; min-width: 0; } +.rcr-cardhead { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; font-size: 13.5px; margin-bottom: 8px; } +.rcr-count { background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); border-radius: 999px; padding: 0 8px; font-size: 11.5px; color: var(--rcr-muted); font-weight: 600; } +.rcr-next { display: flex; justify-content: flex-end; margin-top: 14px; } + +.rcr-btn { cursor: pointer; border-radius: 8px; padding: 7px 14px; border: 1px solid var(--rcr-line-strong); background: #fff; font-size: 13.5px; } +.rcr-btn-primary { background: var(--rcr-accent); border-color: var(--rcr-accent); color: #fff; font-weight: 600; } +.rcr-btn-primary:hover { filter: brightness(.95); } + +/* ---- code ---- */ +.rcr pre.rcr-code { + margin: 0; background: var(--rcr-code-bg) !important; border: 1px solid var(--rcr-line); border-radius: 8px; + padding: 10px 12px; font-family: var(--rcr-mono); font-size: 12.5px; line-height: 1.45; + overflow: auto; max-height: 360px; white-space: pre; color: var(--rcr-ink); +} +.rcr pre.rcr-code-sm { font-size: 11.5px; max-height: 260px; white-space: pre-wrap; overflow-wrap: anywhere; } +.rcr-jk { color: #3b44c9; } +.rcr-js { color: #1a7f4b; } +.rcr-jn { color: #b35900; } +.rcr-jl { color: #c62828; } + +/* ---- step 1: keys ---- */ +.rcr-keylist { max-height: 420px; overflow: auto; padding: 6px; } +.rcr-group summary { + display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 6px 8px; border-radius: 6px; list-style: none; +} +.rcr-group summary::-webkit-details-marker { display: none; } +.rcr-group summary::before { content: "\25B8"; color: var(--rcr-faint); font-size: 11px; } +.rcr-group[open] summary::before { content: "\25BE"; } +.rcr-group summary:hover { background: var(--rcr-surface-2); } +.rcr-type { margin-left: auto; font-size: 10.5px; font-weight: 700; letter-spacing: .3px; color: var(--rcr-muted); background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); border-radius: 4px; padding: 0 5px; } +.rcr-type-json { color: var(--rcr-accent-2); } +.rcr-key { + display: block; width: 100%; text-align: left; cursor: pointer; background: none; border: 0; + padding: 3px 8px 3px 28px; border-radius: 6px; color: var(--rcr-muted); +} +.rcr-key:hover { background: var(--rcr-surface-2); color: var(--rcr-ink); } +.rcr-key.is-on { background: rgba(89, 97, 255, .09); color: var(--rcr-accent-2); } + +/* ---- step 2: model ---- */ +.rcr-subtabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--rcr-line); margin-bottom: 12px; } +.rcr-subtab { cursor: pointer; background: none; border: 0; border-bottom: 2px solid transparent; padding: 6px 10px; font-weight: 600; font-size: 13.5px; color: var(--rcr-muted); margin-bottom: -1px; } +.rcr-subtab[aria-selected="true"] { color: var(--rcr-ink); border-bottom-color: var(--rcr-accent); } +.rcr-entities { display: grid; gap: 12px; min-width: 0; } +.rcr-enthead { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; } +.rcr-entname { font-weight: 700; font-size: 15px; } +.rcr-tpl { display: inline-flex; align-items: center; gap: 6px; } +.rcr-input { border: 1px solid var(--rcr-line-strong); border-radius: 6px; padding: 3px 8px; width: 150px; background: #fff; } +.rcr-match { font-size: 12px; font-weight: 600; border-radius: 999px; padding: 1px 9px; } +.rcr-match.is-ok { color: var(--rcr-ok); background: rgba(26, 127, 75, .09); } +.rcr-match.is-bad { color: var(--rcr-bad); background: rgba(198, 40, 40, .09); } +.rcr .rcr-reset { padding: 2px 10px; font-size: 12.5px; border-radius: 6px; } +.rcr-fields { width: 100%; border-collapse: collapse; font-size: 13px; margin: 0; } +.rcr-fields th { text-align: left; color: var(--rcr-faint); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--rcr-line); } +.rcr-fields td { padding: 3px 6px; border-bottom: 1px solid var(--rcr-line); vertical-align: middle; } +.rcr-fields tr:last-child td { border-bottom: 0; } +.rcr .rcr-fieldsnote { font-size: 12px; color: var(--rcr-faint); margin: 0 0 6px; } +.rcr .rcr-nofields td { color: var(--rcr-muted); font-size: 12.5px; padding: 10px 6px; } +.rcr-select { + -webkit-appearance: none; appearance: none; border: 1px solid var(--rcr-line-strong); border-radius: 6px; + padding: 1px 22px 1px 7px; font-size: 12.5px; cursor: pointer; + background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%235c707a' stroke-width='1.5'/%3E%3C/svg%3E") no-repeat right 7px center; +} +.rcr-pk { font-size: 10.5px; font-weight: 700; color: #fff; background: var(--rcr-accent-2); border-radius: 4px; padding: 0 5px; } +.rcr-rel { font-size: 12px; color: var(--rcr-accent-2); font-weight: 600; } +.rcr-rel::before { content: "\2192\00a0"; } + +.rcr-tools { margin-top: 12px; } +.rcr-toolsbody { display: grid; grid-template-columns: 1fr; gap: 12px; align-items: start; } +.rcr-toollist { display: grid; gap: 2px; max-height: 330px; overflow: auto; align-content: start; } +.rcr-tool.is-mine .rcr-mono { font-weight: 700; } +.rcr-tooldetail { min-width: 0; } +.rcr-toolgroup { font-size: 11px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--rcr-faint); margin: 6px 0 2px; } +.rcr-tool { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; text-align: left; cursor: pointer; background: none; border: 0; border-radius: 6px; padding: 3px 8px; } +.rcr-tool:hover { background: var(--rcr-surface-2); } +.rcr-tool.is-on { background: rgba(89, 97, 255, .09); color: var(--rcr-accent-2); } +.rcr-tooldesc { font-size: 12.5px; color: var(--rcr-muted); margin-bottom: 8px !important; } +.rcr-flash { animation: rcr-flash .8s ease-out; } +@keyframes rcr-flash { from { box-shadow: 0 0 0 3px rgba(89, 97, 255, .35); } to { box-shadow: 0 0 0 0 rgba(89, 97, 255, 0); } } +.rcr-behind { margin-top: 12px; } +.rcr-behind summary { cursor: pointer; font-weight: 600; font-size: 13.5px; } +.rcr-behind .rcr-code { margin-top: 10px; } + +/* ---- step 3: ask ---- */ +.rcr-conn { margin-bottom: 12px; background: var(--rcr-surface-2); } +.rcr-connrow { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: center; } +.rcr-connrow code { background: #fff; overflow-wrap: anywhere; } +.rcr-connlog { margin-top: 10px; font-size: 13px; color: var(--rcr-muted); line-height: 1.9; } +.rcr-connlog code { margin-right: 2px; } +.rcr-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; margin-right: 3px; } +.rcr-dot.is-ok { background: var(--rcr-ok); } +.rcr-dot.is-bad { background: var(--rcr-bad); } +.rcr-dot.is-wait { background: var(--rcr-faint); animation: rcr-pulse 1s infinite; } +@keyframes rcr-pulse { 50% { opacity: .3; } } + +.rcr-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; } +.rcr-chip { cursor: pointer; border: 1px solid var(--rcr-line-strong); background: #fff; border-radius: 999px; padding: 5px 12px; font-size: 13px; } +.rcr-chip:hover:not(:disabled) { border-color: var(--rcr-accent-2); color: var(--rcr-accent-2); } +.rcr-chip:disabled { opacity: .5; cursor: default; } + +.rcr-chat { display: grid; gap: 8px; } +.rcr-msg { max-width: 88%; border-radius: 12px; padding: 9px 13px; font-size: 14px; } +.rcr-msg p + p { margin-top: 6px; } +.rcr-msg ul { margin: 4px 0 0; padding-left: 18px; } +.rcr-msg li { margin: 2px 0; } +.rcr-user { justify-self: end; background: var(--rcr-ink); color: #fff; } +.rcr-agent { justify-self: start; background: var(--rcr-surface-2); border: 1px solid var(--rcr-line); } +.rcr-thinking { display: inline-flex; gap: 4px; padding: 12px 14px; } +.rcr-thinking span { width: 6px; height: 6px; border-radius: 50%; background: var(--rcr-faint); animation: rcr-pulse 1s infinite; } +.rcr-thinking span:nth-child(2) { animation-delay: .2s; } +.rcr-thinking span:nth-child(3) { animation-delay: .4s; } + +.rcr-call { border: 1px solid var(--rcr-line); border-left: 3px solid var(--rcr-accent-2); border-radius: 8px; background: #fff; max-width: 100%; min-width: 0; } +.rcr-call.is-err { border-left-color: var(--rcr-bad); } +.rcr-call > summary { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 7px 10px; list-style: none; min-width: 0; } +.rcr-call > summary::-webkit-details-marker { display: none; } +.rcr-callname { font-weight: 700; color: var(--rcr-accent-2); white-space: nowrap; } +.rcr-callargs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; } +.rcr-status { margin-left: auto; font-size: 12px; color: var(--rcr-muted); white-space: nowrap; } +.rcr-callbody { padding: 0 10px 10px; display: grid; gap: 6px; } +.rcr-curl summary { cursor: pointer; font-size: 12.5px; color: var(--rcr-accent-2); margin: 4px 0; } + +@media (prefers-reduced-motion: reduce) { + .rcr-flash, .rcr-dot.is-wait, .rcr-thinking span { animation: none; } +} diff --git a/static/js/context-retriever-demo.js b/static/js/context-retriever-demo.js new file mode 100644 index 0000000000..f1e0c749c2 --- /dev/null +++ b/static/js/context-retriever-demo.js @@ -0,0 +1,914 @@ +/* ========================================================================= + Redis Context Retriever scripted demo + Dependency-free. Everything runs in the browser against an in-memory + sample dataset; no Context Retriever service is called. + + Tool names, argument shapes, descriptions, and response envelopes mirror + what a live Context Retriever MCP server returned (Sep 2026). Two parts are + modelled rather than observed: relationship names, and text_query / + numeric_conditions on the per-entity filter tool (observed on the set + tools only). + ========================================================================= */ +(function () { + "use strict"; + + /* ------------------------------------------------------------------ data */ + + /* A fictional food delivery app: customers, restaurants, and orders. */ + function ts(y, m, d, h) { return Math.floor(Date.UTC(y, m - 1, d, h == null ? 12 : h) / 1000); } + + var CUSTOMERS = [ + ["u101", "Maya Chen", "austin", "vegetarian", "thai", 1240, ts(2024, 5, 3)], + ["u102", "Liam Ortiz", "austin", "none", "bbq", 860, ts(2025, 1, 19)], + ["u103", "Priya Nair", "seattle", "vegan", "indian", 2310, ts(2023, 9, 12)], + ["u104", "Noah Williams", "denver", "none", "pizza", 410, ts(2025, 11, 2)], + ["u105", "Sofia Rossi", "seattle", "vegetarian", "italian", 1780, ts(2024, 2, 27)], + ["u106", "Ethan Brooks", "denver", "gluten_free", "sushi", 950, ts(2024, 8, 14)], + ["u107", "Aisha Khan", "austin", "halal", "mediterranean", 1530, ts(2023, 12, 6)], + ["u108", "Lucas Meyer", "denver", "none", "burgers", 120, ts(2026, 7, 21)] + ].map(function (r) { + return { id: r[0], name: r[1], email: r[1].toLowerCase().replace(/[^a-z]+/g, ".") + "@example.com", + city: r[2], dietary: r[3], favorite_cuisine: r[4], loyalty_points: r[5], joined_ts: r[6] }; + }); + + var RESTAURANTS = [ + ["r201", "Bangkok Street Kitchen", "thai", "austin", 4.7, 2, 30], + ["r202", "Smokehouse 512", "bbq", "austin", 4.4, 3, 40], + ["r203", "Green Bowl", "vegan", "seattle", 4.8, 2, 25], + ["r204", "Sakura Sushi Bar", "sushi", "denver", 4.6, 3, 35], + ["r205", "Nonna's Trattoria", "italian", "seattle", 4.5, 3, 45], + ["r206", "Slice Society", "pizza", "denver", 4.2, 1, 30], + ["r207", "Olive & Za'atar", "mediterranean", "austin", 4.6, 2, 30], + ["r208", "Spice Route", "indian", "seattle", 4.3, 2, 40], + ["r209", "Lotus Thai", "thai", "austin", 4.1, 1, 25] + ].map(function (r) { + return { id: r[0], name: r[1], cuisine: r[2], city: r[3], rating: r[4], price_level: r[5], avg_delivery_min: r[6] }; + }); + + var ORDERS = [ + ["o3001", "u101", "r201", "delivered", 34.5, ts(2026, 9, 2, 19), "Pad thai with tofu, spring rolls"], + ["o3002", "u101", "r207", "delivered", 28, ts(2026, 9, 10, 13), "Falafel wrap, hummus"], + ["o3003", "u101", "r209", "on_the_way", 22.75, ts(2026, 9, 24, 18), "Green curry without fish sauce, jasmine rice"], + ["o3004", "u102", "r202", "delivered", 61.2, ts(2026, 9, 5, 20), "Brisket platter, mac and cheese"], + ["o3005", "u102", "r201", "cancelled", 19.9, ts(2026, 8, 28, 12), "Pad see ew"], + ["o3006", "u103", "r203", "delivered", 24, ts(2026, 9, 12, 12), "Buddha bowl, oat latte"], + ["o3007", "u103", "r208", "delivered", 38.4, ts(2026, 9, 18, 19), "Chana masala, garlic naan"], + ["o3008", "u104", "r206", "delivered", 42, ts(2026, 9, 6, 20), "Two large pizzas"], + ["o3009", "u104", "r204", "delivered", 67.8, ts(2026, 9, 20, 19), "Omakase set for two"], + ["o3010", "u105", "r205", "delivered", 54.3, ts(2026, 9, 8, 20), "Mushroom risotto, tiramisu"], + ["o3011", "u105", "r203", "preparing", 21.5, ts(2026, 9, 24, 18), "Vegan lasagna"], + ["o3012", "u106", "r204", "delivered", 48.9, ts(2026, 9, 14, 19), "Salmon nigiri, miso soup"], + ["o3013", "u107", "r207", "delivered", 36.2, ts(2026, 9, 11, 13), "Chicken shawarma plate"], + ["o3014", "u107", "r201", "delivered", 26, ts(2026, 8, 30, 19), "Pad thai with chicken"], + ["o3015", "u108", "r206", "delivered", 18.5, ts(2026, 9, 15, 12), "Pepperoni slice combo"], + ["o3016", "u108", "r204", "on_the_way", 29.4, ts(2026, 9, 24, 18), "Spicy tuna roll, edamame"], + ["o3017", "u106", "r206", "delivered", 26, ts(2026, 9, 21, 20), "Gluten-free margherita"], + ["o3018", "u101", "r201", "delivered", 31, ts(2026, 9, 19, 19), "Pad thai with tofu, mango sticky rice"], + ["o3019", "u105", "r205", "delivered", 47.6, ts(2026, 9, 22, 20), "Eggplant parmigiana, focaccia"], + ["o3020", "u102", "r209", "delivered", 25.3, ts(2026, 9, 16, 13), "Drunken noodles, Thai iced tea"] + ].map(function (r) { + return { id: r[0], customer_id: r[1], restaurant_id: r[2], status: r[3], total: r[4], placed_ts: r[5], items: r[6] }; + }); + + var RECORDS = { Customer: CUSTOMERS, Restaurant: RESTAURANTS, Order: ORDERS }; + + /* Keys that exist in the sample database, including some that are not entities. */ + var KEYS = []; + CUSTOMERS.forEach(function (c) { KEYS.push({ key: "customer:" + c.id, type: "JSON", entity: "Customer", doc: c }); }); + RESTAURANTS.forEach(function (r) { KEYS.push({ key: "restaurant:" + r.id, type: "JSON", entity: "Restaurant", doc: r }); }); + ORDERS.forEach(function (o) { KEYS.push({ key: "order:" + o.id, type: "JSON", entity: "Order", doc: o }); }); + KEYS.push({ key: "cart:u104", type: "hash", doc: null }); + KEYS.push({ key: "stats:orders:hourly", type: "TSDB-TYPE", doc: null }); + + function defaultModel() { + return [ + { name: "Customer", template: "customer:{id}", desc: "A person who orders food through the app.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Customer ID." }, + { name: "name", type: "string", index: "text", desc: "Full name." }, + { name: "email", type: "string", index: "none", desc: "Contact email." }, + { name: "city", type: "string", index: "tag", desc: "Delivery city." }, + { name: "dietary", type: "string", index: "tag", desc: "Dietary preference: none, vegetarian, vegan, gluten_free, or halal." }, + { name: "favorite_cuisine", type: "string", index: "tag", desc: "Most-ordered cuisine." }, + { name: "loyalty_points", type: "number", index: "numeric", desc: "Loyalty points balance." }, + { name: "joined_ts", type: "number", index: "numeric", desc: "Signup time, Unix seconds." } + ] }, + { name: "Restaurant", template: "restaurant:{id}", desc: "A restaurant that delivers through the app.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Restaurant ID." }, + { name: "name", type: "string", index: "text", desc: "Restaurant name." }, + { name: "cuisine", type: "string", index: "tag", desc: "Cuisine type." }, + { name: "city", type: "string", index: "tag", desc: "City the restaurant delivers in." }, + { name: "rating", type: "number", index: "numeric", desc: "Average rating, 1 to 5." }, + { name: "price_level", type: "number", index: "numeric", desc: "Price level, 1 to 4." }, + { name: "avg_delivery_min", type: "number", index: "numeric", desc: "Average delivery time in minutes." } + ] }, + { name: "Order", template: "order:{id}", desc: "A delivery order placed by a customer at a restaurant.", fields: [ + { name: "id", type: "string", pk: true, index: "tag", desc: "Order ID." }, + { name: "customer_id", type: "string", index: "tag", rel: "Customer", desc: "Customer who placed the order." }, + { name: "restaurant_id", type: "string", index: "tag", rel: "Restaurant", desc: "Restaurant that made the order." }, + { name: "status", type: "string", index: "tag", desc: "preparing, on_the_way, delivered, or cancelled." }, + { name: "total", type: "number", index: "numeric", desc: "Order total in USD." }, + { name: "placed_ts", type: "number", index: "numeric", desc: "Order time, Unix seconds." }, + { name: "items", type: "string", index: "text", desc: "What was ordered." } + ] } + ]; + } + + /* --------------------------------------------------------------- helpers */ + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; + }); + } + function el(tag, cls, html) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; + } + function lower(s) { return s.charAt(0).toLowerCase() + s.slice(1); } + function snake(s) { return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); } + function fmtDate(t) { return new Date(t * 1000).toISOString().slice(0, 10); } + function hash8(s) { + var h = 2166136261; + for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } + return ("00000000" + (h >>> 0).toString(16)).slice(-8); + } + + /* Pretty JSON with light syntax colouring. */ + function jsonHtml(v) { + var s = JSON.stringify(v, null, 2); + return esc(s).replace(/("(?:[^&]|&(?!quot;))*?")(\s*:)?|\b(true|false|null)\b|(-?\d+(?:\.\d+)?)/g, + function (m, str, colon, lit, num) { + if (str) return colon ? '' + str + "" + colon : '' + str + ""; + if (lit) return '' + lit + ""; + return '' + num + ""; + }); + } + + function templateRegex(t) { + if (t.indexOf("{id}") === -1) return null; + var parts = t.split("{id}").map(function (p) { return p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }); + return new RegExp("^" + parts.join("([^:]+)") + "$"); + } + + /* ---------------------------------------------------- model -> tool list */ + + function fieldsBy(ent, idx) { return ent.fields.filter(function (f) { return !f.pk && f.index === idx; }).map(function (f) { return f.name; }); } + + function relationships(model) { + var rels = []; + model.forEach(function (e) { + e.fields.forEach(function (f) { + if (!f.rel) return; + var target = model.filter(function (t) { return t.name === f.rel; })[0]; + if (!target) return; + rels.push({ name: e.name + "." + lower(f.rel), from: e.name, to: f.rel, via: f.name, reverse: false }); + rels.push({ name: f.rel + "." + snake(e.name) + "s", from: f.rel, to: e.name, via: f.name, reverse: true }); + }); + }); + return rels; + } + + function limitProps(max) { + return { + limit: { description: "Maximum number of results to return (default: 10, max: " + max + "). A larger request returns " + max + " results; use offset to page through the rest.", type: "number" }, + offset: { description: "Number of results to skip (default: 0)", type: "number" } + }; + } + + function conditionSchemas(tags, nums, texts) { + var p = {}; + if (tags.length) p.tag_conditions = { + description: "Exact-match tag conditions, ANDed with every other condition. Omit the parameter (or pass []) when there are none.", + type: "array", maxItems: 16, + items: { type: "object", additionalProperties: false, required: ["field", "value"], properties: { + field: { description: "The tag field this condition applies to.", type: "string", enum: tags }, + value: { description: "Exact TAG value to match.", type: "string" }, + exclude: { description: "When true, match records where this field is present and does not contain the exact value.", type: "boolean" } } } + }; + if (nums.length) p.numeric_conditions = { + description: "Inclusive numeric-range conditions, ANDed together.", + type: "array", maxItems: 16, + items: { type: "object", additionalProperties: false, required: ["field"], properties: { + field: { description: "Numeric field on the entity.", type: "string", enum: nums }, + min_value: { description: "Inclusive lower bound; omit for unbounded.", type: "number" }, + max_value: { description: "Inclusive upper bound; omit for unbounded.", type: "number" } } } + }; + if (texts.length) { + p.text_query = { description: "Lexical text to match on the entity's text fields (" + texts.join(", ") + ").", type: "string" }; + p.text_match_mode = { description: "How text_query terms combine: any (default), all, or phrase.", type: "string", enum: ["any", "all", "phrase"] }; + } + return p; + } + + function buildTools(model) { + var tools = []; + var rels = relationships(model); + var names = model.map(function (e) { return e.name; }); + model.forEach(function (e) { + var pk = e.fields.filter(function (f) { return f.pk; })[0]; + var tags = fieldsBy(e, "tag"), nums = fieldsBy(e, "numeric"), texts = fieldsBy(e, "text"); + var sn = snake(e.name); + var sample = (RECORDS[e.name] && RECORDS[e.name][0] || {}).id || "3"; + if (pk) tools.push({ name: "get_" + sn + "_by_id", entity: e.name, kind: "get", + description: "Get " + e.name + " by ID using a key lookup. " + e.desc + " ID field " + pk.name + ": " + pk.desc, + inputSchema: { type: "object", required: ["id"], properties: { id: { + description: "The ID of the " + e.name + " to retrieve. Pass only the key component, for example id=\"" + sample + "\"; do not pass the full Redis key " + e.template.replace("{id}", sample) + ".", + type: "string" } } } }); + if (tags.length || nums.length || texts.length) { + var parts = []; + if (tags.length) parts.push("tag_conditions"); + if (nums.length) parts.push("numeric_conditions"); + if (texts.length) parts.push("text_query"); + var lines = []; + if (tags.length) lines.push("Tag fields (tag_conditions, exact match): " + tags.join(", ") + "."); + if (nums.length) lines.push("Numeric fields (numeric_conditions, inclusive range): " + nums.join(", ") + "."); + if (texts.length) lines.push("Text fields (text_query, full-text): " + texts.join(", ") + "."); + var props = conditionSchemas(tags, nums, texts); + var lp = limitProps(100); props.limit = lp.limit; props.offset = lp.offset; + tools.push({ name: "filter_" + sn, entity: e.name, kind: "filter", + description: "Filter " + e.name + " by one or MORE field conditions in a single call; a record is returned only if it satisfies ALL conditions (AND) across " + parts.join(", ") + ". " + e.desc + " " + lines.join(" "), + inputSchema: { type: "object", properties: props } }); + } + if (tags.length || nums.length) { + var agg = tags.concat(nums); + var cp = { function: { description: "Aggregation function to execute. count takes no field; count_distinct requires one eligible field.", type: "string", enum: ["count", "count_distinct"] }, + field: { description: "Scalar TAG or NUMERIC field whose distinct values should be counted.", type: "string", enum: agg } }; + if (tags.length) cp.group_by = { description: "Optional scalar TAG field to group counts by.", type: "string", enum: tags }; + cp.limit = { description: "Maximum number of groups to return (default: 10)", type: "integer", minimum: 1, maximum: 100, default: 10 }; + tools.push({ name: "count_" + sn, entity: e.name, kind: "count", + description: "Count " + e.name + " records without retrieving individual records. " + e.desc, + inputSchema: { type: "object", additionalProperties: false, required: ["function"], properties: cp } }); + } + }); + var relText = rels.length ? rels.map(function (r) { return r.name; }).join(", ") : "this data model declares none"; + var setItem = { type: "object", additionalProperties: false, required: ["entity"], properties: { + entity: { description: "Which entity this set selects.", type: "string", enum: names }, + tag_conditions: { description: "Exact-match tag conditions, ANDed together.", type: "array" }, + numeric_conditions: { description: "Inclusive numeric-range conditions, ANDed together.", type: "array" }, + text_query: { description: "Lexical text to match on the entity's text fields.", type: "string" }, + expand_to: { description: "Optional relationship to follow after retrieving this set, bringing it to the related entity. Declared: " + relText + ".", type: "string" } } }; + var common = "Each set states which records it selects: the entity (" + names.join(", ") + ") plus tag_conditions, numeric_conditions or text_query. The whole matching set is used, not a page of it."; + function setTool(name, what) { + var p = { sets: { description: "Two or more sets to combine (at most 8), all resolving to the same entity.", type: "array", minItems: 2, maxItems: 8, items: setItem } }; + var lp = limitProps(100); p.limit = lp.limit; p.offset = lp.offset; + return { name: name, kind: "set", description: what + " All sets must resolve to the same entity; use expand_to on one of them to bring it to the same entity first. " + common, + inputSchema: { type: "object", additionalProperties: false, required: ["sets"], properties: p } }; + } + tools.push(setTool("intersect_results", "Return the records present in EVERY given set (AND) -- the conjunctive filter the per-entity tools cannot express.")); + tools.push(setTool("union_results", "Return the records present in ANY given set (OR).")); + tools.push(setTool("except_results", "Return the records in the FIRST set that are not in any of the others (set difference).")); + var ep = { set: setItem, relationship: { description: "Relationship to follow. Declared hops: " + relText + ".", type: "string" } }; + var elp = limitProps(100); ep.limit = elp.limit; ep.offset = elp.offset; + tools.push({ name: "expand_results", kind: "expand", + description: "Follow a declared relationship from one set to the related entity and return that entity's records. This is the only tool here that changes entity; use it when a question joins two entities. Relationships you can follow: " + relText + ".", + inputSchema: { type: "object", additionalProperties: false, required: ["set", "relationship"], properties: ep } }); + return tools; + } + + /* Field options as the live service creates them: TAG fields get + INDEXMISSING (plus INDEXEMPTY when used for access control, which this + demo doesn't model), NUMERIC fields are SORTABLE, TEXT fields are plain. */ + var INDEX_OPTIONS = { tag: "TAG INDEXMISSING", numeric: "NUMERIC SORTABLE", text: "TEXT" }; + + function indexCommands(model, surface) { + return model.filter(function (e) { return e.fields.some(function (f) { return f.index !== "none"; }); }).map(function (e) { + var prefix = e.template.split("{id}")[0]; + var schema = e.fields.filter(function (f) { return f.index !== "none"; }).map(function (f) { + return "'$." + f.name + "' AS " + f.name + " " + INDEX_OPTIONS[f.index]; + }); + return "FT.CREATE idx:" + surface + ":" + hash8(e.name + e.template) + ":" + lower(e.name) + + " ON JSON PREFIX 1 " + prefix + " SCHEMA \\\n " + schema.join(" \\\n "); + }); + } + + /* ------------------------------------------------------- query execution */ + + function entityOf(model, name) { return model.filter(function (e) { return e.name === name; })[0]; } + + /* Fields detected from the JSON documents a template matches, the way + Auto-detect fields works in the console: the field set of each document + type found, merged by name. Returns the document types too, so an entity + can tell when its template starts pointing at different documents. */ + function detectFields(template) { + var re = templateRegex(template), kinds = [], names = {}, fields = []; + if (re) KEYS.forEach(function (k) { if (k.type === "JSON" && re.test(k.key) && kinds.indexOf(k.entity) === -1) kinds.push(k.entity); }); + kinds.forEach(function (kind) { + defaultModel().filter(function (e) { return e.name === kind; })[0].fields.forEach(function (f) { + if (!names[f.name]) { names[f.name] = true; fields.push(f); } + }); + }); + return { kind: kinds.join("+"), fields: fields }; + } + + /* Records an entity can see: JSON documents whose key matches its key template. */ + function rowsFor(ent) { + var re = templateRegex(ent.template); + if (!re) return []; + return KEYS.filter(function (k) { return k.type === "JSON" && re.test(k.key); }).map(function (k) { return k.doc; }); + } + + /* Protocol-level rejection (bad arguments): a JSON-RPC error. */ + function McpError(msg) { this.message = msg; } + /* Tool-level failure (the call ran but failed): a result with isError: true, + the way the live server reports a failed lookup. */ + function ToolError(msg) { this.message = msg; } + + function checkEnum(path, val, en) { + if (!en || en.indexOf(val) === -1) + throw new McpError("invalid params: validating \"arguments\": validating " + path + ": enum: " + val + " does not equal any of: [" + (en || []).join(" ") + "]"); + } + + function selectSet(model, set, pathRoot) { + var e = entityOf(model, set.entity); + if (!e) throw new McpError("invalid params: unknown entity " + set.entity); + var tags = fieldsBy(e, "tag"); + var nums = fieldsBy(e, "numeric"), texts = fieldsBy(e, "text"); + var rows = rowsFor(e).slice(); + (set.tag_conditions || []).forEach(function (c) { + checkEnum(pathRoot + "/tag_conditions/items/properties/field", c.field, tags); + rows = rows.filter(function (r) { return c.exclude ? r[c.field] !== c.value : r[c.field] === c.value; }); + }); + (set.numeric_conditions || []).forEach(function (c) { + checkEnum(pathRoot + "/numeric_conditions/items/properties/field", c.field, nums); + rows = rows.filter(function (r) { + return (c.min_value == null || r[c.field] >= c.min_value) && (c.max_value == null || r[c.field] <= c.max_value); + }); + }); + if (set.text_query) { + if (!texts.length) throw new McpError("invalid params: validating \"arguments\": validating " + pathRoot + ": unexpected additional properties [\"text_query\"]"); + var q = set.text_query.toLowerCase(), mode = set.text_match_mode || "any"; + if (/^".*"$/.test(q)) { mode = "phrase"; q = q.slice(1, -1); } + var terms = q.split(/\s+/).filter(Boolean); + rows = rows.filter(function (r) { + var hay = texts.map(function (f) { return String(r[f] || ""); }).join(" ").toLowerCase(); + var words = hay.split(/[^a-z0-9]+/); + if (mode === "phrase") return (" " + words.join(" ") + " ").indexOf(" " + terms.join(" ") + " ") !== -1; + var hit = function (t) { return words.indexOf(t) !== -1; }; + return mode === "all" ? terms.every(hit) : terms.some(hit); + }); + } + return { entity: e, rows: rows }; + } + + function follow(model, sel, relName, rels) { + var rel = rels.filter(function (r) { return r.name === relName; })[0]; + if (!rel || rel.from !== sel.entity.name) + throw new McpError("invalid params: relationship " + relName + " is not declared from " + sel.entity.name + ". Declared hops: " + (rels.map(function (r) { return r.name; }).join(", ") || "none")); + var target = entityOf(model, rel.to), rows; + if (!rel.reverse) { + var ids = sel.rows.map(function (r) { return r[rel.via]; }); + rows = rowsFor(target).filter(function (t) { return ids.indexOf(t.id) !== -1; }); + } else { + var src = sel.rows.map(function (r) { return r.id; }); + rows = rowsFor(target).filter(function (t) { return src.indexOf(t[rel.via]) !== -1; }); + } + return { entity: target, rows: rows }; + } + + function stamp(rec, surface) { + var out = { _retrieval: { source: surface, retrieved_at: new Date().toISOString(), source_version: 1 } }; + Object.keys(rec).sort().forEach(function (k) { out[k] = rec[k]; }); + return out; + } + + function page(rows, args) { + var limit = Math.min(args.limit || 10, 100), offset = args.offset || 0; + return { rows: rows.slice(offset, offset + limit), limit: limit, offset: offset, has_more: rows.length > offset + limit }; + } + + function execute(model, tools, surface, name, args) { + var tool = tools.filter(function (t) { return t.name === name; })[0]; + if (!tool) throw new McpError("unknown tool \"" + name + "\""); + var rels = relationships(model); + if (tool.kind === "get") { + var ent = entityOf(model, tool.entity); + var rec = rowsFor(ent).filter(function (r) { return r.id === args.id; })[0]; + if (!rec) throw new ToolError("get by ID failed: failed to get document: key " + ent.template.replace("{id}", args.id) + " not found"); + return stamp(rec, surface); + } + if (tool.kind === "filter") { + var props = tool.inputSchema.properties; + Object.keys(args).forEach(function (k) { + if (!props[k]) throw new McpError("invalid params: validating \"arguments\": validating root: unexpected additional properties [\"" + k + "\"]"); + }); + var sel = selectSet(model, { entity: tool.entity, tag_conditions: args.tag_conditions, numeric_conditions: args.numeric_conditions, text_query: args.text_query, text_match_mode: args.text_match_mode }, "/properties"); + var pg = page(sel.rows, args); + return { count: pg.rows.length, has_more: pg.has_more, limit: pg.limit, offset: pg.offset, + results: pg.rows.map(function (r) { return stamp(r, surface); }), total_count: sel.rows.length }; + } + if (tool.kind === "count") { + var ce = entityOf(model, tool.entity), rows = rowsFor(ce); + if (args.group_by) { + checkEnum("/properties/group_by", args.group_by, tool.inputSchema.properties.group_by && tool.inputSchema.properties.group_by.enum); + var g = {}; + rows.forEach(function (r) { g[r[args.group_by]] = (g[r[args.group_by]] || 0) + 1; }); + return { function: "count", group_by: args.group_by, + groups: Object.keys(g).sort().map(function (k) { return { value: g[k], group_value: k }; }), has_more: false }; + } + return { function: "count", value: rows.length }; + } + if (tool.kind === "set") { + var resolved = args.sets.map(function (s, i) { + var sl = selectSet(model, s, "/properties/sets/items/" + i); + return s.expand_to ? follow(model, sl, s.expand_to, rels) : sl; + }); + var ename = resolved[0].entity.name; + resolved.forEach(function (r) { + if (r.entity.name !== ename) throw new McpError("invalid params: all sets must resolve to the same entity (got " + ename + " and " + r.entity.name + ")"); + }); + var idLists = resolved.map(function (r) { return r.rows.map(function (x) { return x.id; }); }); + var ids; + if (name === "intersect_results") ids = idLists[0].filter(function (id) { return idLists.every(function (l) { return l.indexOf(id) !== -1; }); }); + else if (name === "union_results") { ids = []; idLists.forEach(function (l) { l.forEach(function (id) { if (ids.indexOf(id) === -1) ids.push(id); }); }); } + else ids = idLists[0].filter(function (id) { return idLists.slice(1).every(function (l) { return l.indexOf(id) === -1; }); }); + var pr = page(ids, args), tpl = resolved[0].entity.template; + return { complete: true, count: ids.length, entity: ename, has_more: pr.has_more, + ids: pr.rows.map(function (id) { return tpl.replace("{id}", id); }), limit: pr.limit, offset: pr.offset }; + } + if (tool.kind === "expand") { + var from = selectSet(model, args.set, "/properties/set"); + var to = follow(model, from, args.relationship, rels); + var pe = page(to.rows, args); + return { complete: true, count: to.rows.length, entity: to.entity.name, has_more: pe.has_more, + ids: pe.rows.map(function (r) { return to.entity.template.replace("{id}", r.id); }), limit: pe.limit, offset: pe.offset }; + } + throw new McpError("unsupported tool"); + } + + /* ------------------------------------------------------ scripted agent */ + + function idFromKey(k) { return k.slice(k.lastIndexOf(":") + 1); } + function usd2(n) { return "$" + Number(n).toFixed(2); } + var SEPT_START = ts(2026, 9, 1, 0), SEPT_END = ts(2026, 10, 1, 0) - 1; + function label(s) { return String(s).replace(/_/g, " "); } + function city(s) { return String(s).charAt(0).toUpperCase() + String(s).slice(1); } + + /* Look up each distinct restaurant once, the way an agent would. */ + function restaurantNames(call, orders) { + var names = {}; + orders.forEach(function (o) { + if (!names[o.restaurant_id]) names[o.restaurant_id] = call("get_restaurant_by_id", { id: o.restaurant_id }).name; + }); + return names; + } + + /* Each question drives tool calls through `call(name, args)` and returns the answer. */ + var QUESTIONS = [ + { q: "How many customers follow each diet?", + run: function (call) { + var r = call("count_customer", { function: "count", group_by: "dietary" }); + if (!r.groups.length) return "I found no customers. Check that the Customer key template matches your keys."; + return "Here's the split by dietary preference: " + r.groups.slice().sort(function (a, b) { return b.value - a.value; }) + .map(function (g) { return "**" + g.value + "** " + (g.group_value === "none" ? "with no restriction" : label(g.group_value)); }).join(", ") + "."; + } }, + { q: "Which Thai restaurants in Austin are rated 4.5 or higher?", + run: function (call) { + var r = call("filter_restaurant", { tag_conditions: [{ field: "cuisine", value: "thai" }, { field: "city", value: "austin" }], + numeric_conditions: [{ field: "rating", min_value: 4.5 }] }); + if (!r.total_count) return "No Thai restaurants in Austin are rated 4.5 or higher."; + return r.results.map(function (x) { + return "**" + x.name + "** is rated " + x.rating + ", with an average delivery time of " + x.avg_delivery_min + " minutes."; + }).join("\n"); + } }, + { q: "What has Maya Chen ordered this month?", + run: function (call) { + var c = call("filter_customer", { text_query: "Maya Chen", text_match_mode: "all" }); + if (!c.total_count) return "I couldn't find a customer called Maya Chen."; + var cust = c.results[0]; + var o = call("filter_order", { tag_conditions: [{ field: "customer_id", value: cust.id }], + numeric_conditions: [{ field: "placed_ts", min_value: SEPT_START, max_value: SEPT_END }] }); + if (!o.total_count) return cust.name + " hasn't ordered anything this month."; + var names = restaurantNames(call, o.results); + /* Sort a copy: o.results is also what the call card shows as the tool's response. */ + var byDate = o.results.slice().sort(function (a, b) { return a.placed_ts - b.placed_ts; }); + return cust.name + " has placed **" + o.total_count + "** orders this month:\n" + byDate.map(function (x) { + return "- " + fmtDate(x.placed_ts) + ": " + x.items + " from " + names[x.restaurant_id] + ", " + usd2(x.total) + " (" + label(x.status) + ")"; + }).join("\n") + "\n\nHer profile says she's " + label(cust.dietary) + " and her favorite cuisine is " + label(cust.favorite_cuisine) + "."; + } }, + { q: "Which vegetarian customers have ordered from Green Bowl?", + run: function (call) { + var r = call("filter_restaurant", { text_query: "Green Bowl", text_match_mode: "phrase" }); + if (!r.total_count) return "I couldn't find a restaurant called Green Bowl."; + var rest = r.results[0]; + var s = call("intersect_results", { sets: [ + { entity: "Order", tag_conditions: [{ field: "restaurant_id", value: rest.id }], expand_to: "Order.customer" }, + { entity: "Customer", tag_conditions: [{ field: "dietary", value: "vegetarian" }] }] }); + if (!s.count) return "No vegetarian customers have ordered from " + rest.name + "."; + var people = s.ids.slice(0, 3).map(function (k) { var c = call("get_customer_by_id", { id: idFromKey(k) }); return "- " + c.name + " (" + city(c.city) + ", " + c.loyalty_points + " loyalty points)"; }); + return "**" + s.count + "** vegetarian customer" + (s.count === 1 ? " has" : "s have") + " ordered from " + rest.name + ":\n" + people.join("\n"); + } }, + { q: "Find orders that include pad thai.", + run: function (call) { + var r = call("filter_order", { text_query: "pad thai", text_match_mode: "phrase" }); + if (!r.total_count) return "No orders include pad thai."; + return "**" + r.total_count + "** orders include pad thai:\n" + r.results.map(function (x) { + return "- " + x.id + ": " + x.items + ", " + usd2(x.total) + " (" + fmtDate(x.placed_ts) + ")"; + }).join("\n"); + } }, + { q: "Which orders are being prepared or on the way right now?", + run: function (call) { + var s = call("union_results", { sets: [ + { entity: "Order", tag_conditions: [{ field: "status", value: "preparing" }] }, + { entity: "Order", tag_conditions: [{ field: "status", value: "on_the_way" }] }] }); + if (!s.count) return "No orders are in progress."; + var orders = s.ids.slice(0, 5).map(function (k) { return call("get_order_by_id", { id: idFromKey(k) }); }); + var names = restaurantNames(call, orders); + return "**" + s.count + "** orders are in progress:\n" + orders.map(function (x) { + return "- " + x.id + ": " + x.items + " from " + names[x.restaurant_id] + " (" + label(x.status) + ")"; + }).join("\n"); + } } + ]; + + function mdLite(s) { + return s.split("\n").map(function (line) { + var h = esc(line).replace(/\*\*(.+?)\*\*/g, "$1"); + return line.indexOf("- ") === 0 ? "
  • " + h.slice(2) + "
  • " : (h ? "

    " + h + "

    " : ""); + }).join("").replace(/(
  • .*?<\/li>)+/g, function (m) { return ""; }); + } + + /* ------------------------------------------------------------------- UI */ + + function init(root) { + var state = { model: defaultModel(), tools: [], surface: "3f8c2a1e-demo", selectedKey: null, selectedTool: null, busy: false, connected: false }; + state.tools = buildTools(state.model); + var ENDPOINT = "https://.context-surfaces.redis.io/mcp"; + + root.innerHTML = ""; + var tabs = el("div", "rcr-tabs"); + tabs.setAttribute("role", "tablist"); + var TABS = [["keys", "1", "Keys in Redis"], ["model", "2", "Data model to tools"], ["ask", "3", "Ask over MCP"]]; + var panels = {}; + TABS.forEach(function (t, i) { + var b = el("button", "rcr-tab", '' + t[1] + "" + esc(t[2])); + b.type = "button"; b.setAttribute("role", "tab"); b.dataset.tab = t[0]; + b.setAttribute("aria-selected", i === 0 ? "true" : "false"); + b.addEventListener("click", function () { show(t[0]); }); + tabs.appendChild(b); + panels[t[0]] = el("div", "rcr-panel"); + panels[t[0]].setAttribute("role", "tabpanel"); + if (i) panels[t[0]].hidden = true; + }); + root.appendChild(tabs); + Object.keys(panels).forEach(function (k) { root.appendChild(panels[k]); }); + + function show(name) { + Array.prototype.forEach.call(tabs.children, function (b) { b.setAttribute("aria-selected", b.dataset.tab === name ? "true" : "false"); }); + Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== name; }); + if (name === "keys") renderKeys(); + if (name === "model") renderModel(); + if (name === "ask") renderAsk(); + } + + function next(label, target) { + var b = el("button", "rcr-btn rcr-btn-primary", esc(label)); + b.type = "button"; + b.addEventListener("click", function () { show(target); }); + var w = el("div", "rcr-next"); w.appendChild(b); return w; + } + + /* ---- step 1: keys ---- */ + function renderKeys() { + var p = panels.keys; p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "This sample database belongs to a food delivery app. It stores customers, restaurants, and orders as JSON documents. Each business object is one key, and the key name starts with the object type. Select a key to see its document.")); + var grid = el("div", "rcr-split"); + var list = el("div", "rcr-card rcr-keylist"); + var groups = {}; + KEYS.forEach(function (k) { var pre = k.key.split(":")[0]; (groups[pre] = groups[pre] || []).push(k); }); + Object.keys(groups).forEach(function (pre) { + var g = groups[pre]; + var d = el("details", "rcr-group"); + if (!state.selectedKey && pre === "customer") d.open = true; + if (state.selectedKey && state.selectedKey.key.indexOf(pre + ":") === 0) d.open = true; + d.appendChild(el("summary", "", '' + esc(pre) + ":*" + g.length + "" + esc(g[0].type) + "")); + g.forEach(function (k) { + var b = el("button", "rcr-key" + (state.selectedKey === k ? " is-on" : ""), '' + esc(k.key) + ""); + b.type = "button"; + b.addEventListener("click", function () { state.selectedKey = k; renderKeys(); }); + d.appendChild(b); + }); + list.appendChild(d); + }); + var view = el("div", "rcr-card rcr-docview"); + var k = state.selectedKey || KEYS[0]; + view.appendChild(el("div", "rcr-cardhead", '' + esc(k.key) + '' + esc(k.type) + "")); + if (k.doc && k.type === "JSON") { + view.appendChild(el("pre", "rcr-code", jsonHtml(k.doc))); + view.appendChild(el("p", "rcr-note", "Entity " + esc(k.entity) + ". Key template " + esc(k.entity.toLowerCase()) + ":{id} matches this key with {id} = " + esc(k.doc.id) + ".")); + } else { + view.appendChild(el("p", "rcr-note", "This key is a " + esc(k.type) + ", not a JSON document. Context Retriever entities are JSON documents, so it isn't part of the data model.")); + } + grid.appendChild(list); grid.appendChild(view); p.appendChild(grid); + p.appendChild(next("Next: define the data model", "model")); + } + + /* ---- step 2: model -> tools ---- */ + function renderModel() { + var p = panels.model; p.innerHTML = ""; + p.appendChild(el("p", "rcr-lede", "You describe each entity once: a key template that finds its keys, and the fields an agent can use. Context Retriever turns that description into MCP tools. Change a key template to point the entity at other documents, or change an index type, and watch the tools change. Reset puts an edited template back.")); + if (!state.entityTab) state.entityTab = state.model[0].name; + var sub = el("div", "rcr-subtabs"); + sub.setAttribute("role", "tablist"); + state.model.forEach(function (ent) { + var b = el("button", "rcr-subtab", esc(ent.name) + ' ' + esc(ent.template) + ""); + b.type = "button"; b.setAttribute("role", "tab"); + b.setAttribute("aria-selected", ent.name === state.entityTab ? "true" : "false"); + b.addEventListener("click", function () { + if (ent.name === state.entityTab) return; + state.entityTab = ent.name; + renderModel(); + }); + sub.appendChild(b); + }); + p.appendChild(sub); + var ent = entityOf(state.model, state.entityTab); + p.appendChild(entityCard(ent)); + var right = el("div", "rcr-card rcr-tools"); + p.appendChild(right); + renderTools(right); + var idx = el("details", "rcr-card rcr-behind"); + idx.appendChild(el("summary", "", "Behind the scenes: the search indexes the service creates")); + idx.appendChild(el("pre", "rcr-code", esc(indexCommands(state.model, state.surface).join("\n\n") || "No indexed fields, so no index is created."))); + idx.appendChild(el("p", "rcr-note", "Tag fields use the INDEXMISSING option, so the database must run Redis 7.4 or later. " + + "Tag fields used for access control, which this demo doesn't include, also get INDEXEMPTY. The index names are illustrative.")); + p.appendChild(idx); + p.appendChild(next("Next: ask questions over MCP", "ask")); + } + + /* Point an entity at the documents a template matches. When they're + different documents, re-detect the fields, keeping the edits made for + the old ones in case the template comes back. True if the fields changed. */ + function setTemplate(ent, value) { + ent.template = value; + var d = detectFields(value), was = ent.kind == null ? ent.name : ent.kind; + if (d.kind === was) return false; + ent.saved = ent.saved || {}; + ent.saved[was] = ent.fields; + ent.fields = ent.saved[d.kind] || d.fields; + ent.kind = d.kind; + return true; + } + /* The Reset button puts an entity's template back to the default. Index + choices made on its own documents stay; anything set while the template + pointed elsewhere is dropped. True if something changed. */ + function resetTemplate(ent) { + var def = defaultModel().filter(function (e) { return e.name === ent.name; })[0]; + if (!def || ent.template === def.template) return false; + setTemplate(ent, def.template); + ent.saved = {}; + return true; + } + + function refreshTools() { + state.tools = buildTools(state.model); + state.connected = false; + var right = panels.model.querySelector(".rcr-tools"); + if (right) { renderTools(right); right.classList.remove("rcr-flash"); void right.offsetWidth; right.classList.add("rcr-flash"); } + var beh = panels.model.querySelector(".rcr-behind pre"); + if (beh) beh.textContent = indexCommands(state.model, state.surface).join("\n\n") || "No indexed fields, so no index is created."; + } + + function entityCard(ent) { + var card = el("div", "rcr-card rcr-entity"); + var head = el("div", "rcr-enthead"); + head.appendChild(el("span", "rcr-entname", esc(ent.name))); + var tplWrap = el("label", "rcr-tpl", 'Key template'); + var inp = el("input", "rcr-input rcr-mono"); + inp.value = ent.template; inp.spellcheck = false; inp.setAttribute("aria-label", ent.name + " key template"); + var match = el("span", "rcr-match"); + /* Count only JSON documents, the keys queries and the ON JSON index use. + A template that only hits other key types says so instead of going green. */ + function updateMatch() { + var re = templateRegex(inp.value); + var hits = re ? KEYS.filter(function (k) { return re.test(k.key); }) : []; + var json = hits.filter(function (k) { return k.type === "JSON"; }).length, other = hits.length - json; + var keys = function (n) { return n + (n === 1 ? " key" : " keys"); }; + match.className = "rcr-match " + (json ? "is-ok" : "is-bad"); + match.textContent = !re ? "Missing {id}" : json ? "Matches " + keys(json) : + other ? "Matches " + keys(other) + ", but not JSON" : "Matches no keys"; + match.title = !json && other ? "Context Retriever only reads JSON documents." : ""; + } + var def = defaultModel().filter(function (e) { return e.name === ent.name; })[0].template; + var reset = el("button", "rcr-btn rcr-reset", "Reset"); + reset.type = "button"; + reset.title = "Put the key template back to " + def; + reset.setAttribute("aria-label", "Reset the " + ent.name + " key template to " + def); + function templateChanged() { + updateMatch(); refreshTools(); + reset.hidden = ent.template === def; + var on = panels.model.querySelector('.rcr-subtab[aria-selected="true"] .rcr-mono'); + if (on) on.textContent = ent.template; + } + inp.addEventListener("input", function () { + if (setTemplate(ent, inp.value)) renderFields(); + templateChanged(); + }); + reset.addEventListener("click", function () { + if (resetTemplate(ent)) { inp.value = ent.template; renderFields(); } + templateChanged(); + inp.focus(); + }); + updateMatch(); + reset.hidden = ent.template === def; + tplWrap.appendChild(inp); head.appendChild(tplWrap); head.appendChild(match); head.appendChild(reset); + card.appendChild(head); + card.appendChild(el("p", "rcr-fieldsnote", "Fields are detected from the JSON documents this template matches, as with Auto-detect fields in the console.")); + var tbl = el("table", "rcr-fields"); + tbl.innerHTML = "FieldPKTypeIndexRelated"; + var tb = el("tbody"); + tbl.appendChild(tb); card.appendChild(tbl); + renderFields(); + return card; + + function renderFields() { + tb.innerHTML = ""; + if (!ent.fields.length) { + tb.appendChild(el("tr", "rcr-nofields", 'No fields. The template matches no JSON documents, so there is nothing to detect.')); + } + ent.fields.forEach(function (f) { + var tr = el("tr"); + tr.appendChild(el("td", "rcr-mono", esc(f.name))); + tr.appendChild(el("td", "", f.pk ? 'PK' : "")); + tr.appendChild(el("td", "", esc(f.type))); + var td = el("td"); + if (f.pk) td.appendChild(el("span", "rcr-faint", "key lookup")); + else { + var sel = el("select", "rcr-select"); + sel.setAttribute("aria-label", ent.name + " " + f.name + " index type"); + (f.type === "number" ? ["none", "numeric"] : ["none", "tag", "text"]).forEach(function (o) { + var op = el("option", "", o); op.value = o; if (o === f.index) op.selected = true; sel.appendChild(op); + }); + sel.addEventListener("change", function () { f.index = sel.value; refreshTools(); }); + td.appendChild(sel); + } + tr.appendChild(td); + tr.appendChild(el("td", "", f.rel ? '' + esc(f.rel) + "" : "")); + tb.appendChild(tr); + }); + } + } + + function renderTools(box) { + box.innerHTML = ""; + box.appendChild(el("div", "rcr-cardhead", "Generated MCP tools" + state.tools.length + "")); + var list = el("div", "rcr-toollist"); + if (!state.selectedTool || !state.tools.some(function (t) { return t.name === state.selectedTool; })) state.selectedTool = state.tools[0].name; + var groups = [["Per entity", state.tools.filter(function (t) { return t.entity; })], ["Across entities", state.tools.filter(function (t) { return !t.entity; })]]; + groups.forEach(function (g) { + list.appendChild(el("div", "rcr-toolgroup", esc(g[0]))); + g[1].forEach(function (t) { + var b = el("button", "rcr-tool" + (t.name === state.selectedTool ? " is-on" : "") + (t.entity && t.entity === state.entityTab ? " is-mine" : ""), '' + esc(t.name) + "" + (t.entity ? '' + esc(t.entity) + "" : "")); + b.type = "button"; + b.addEventListener("click", function () { state.selectedTool = t.name; renderTools(box); }); + list.appendChild(b); + }); + }); + var body = el("div", "rcr-toolsbody"); + body.appendChild(list); + box.appendChild(body); + var t = state.tools.filter(function (x) { return x.name === state.selectedTool; })[0]; + var det = el("div", "rcr-tooldetail"); + det.appendChild(el("p", "rcr-tooldesc", esc(t.description))); + det.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(t.inputSchema))); + body.appendChild(det); + } + + /* ---- step 3: ask ---- */ + /* Built once, so a replay keeps playing and the conversation survives tab + switches. On return, reconnect only if the data model changed the tools. */ + function renderAsk() { + var p = panels.ask; + if (p.dataset.built) { + if (!state.connected) handshake(p.querySelector(".rcr-connlog")); + return; + } + p.dataset.built = "1"; + p.appendChild(el("p", "rcr-lede", "An agent connects to the service's MCP endpoint with an agent key, lists the tools, and picks the right ones for each question. The agent never connects to Redis. Pick a question to watch the calls.")); + var conn = el("div", "rcr-card rcr-conn"); + conn.appendChild(el("div", "rcr-connrow", 'MCP endpoint' + esc(ENDPOINT) + 'HeaderX-API-Key: <your-agent-key>')); + var log = el("div", "rcr-connlog"); + conn.appendChild(log); + p.appendChild(conn); + var chips = el("div", "rcr-chips"); + QUESTIONS.forEach(function (q) { + var b = el("button", "rcr-chip", esc(q.q)); b.type = "button"; + b.addEventListener("click", function () { ask(q); }); + chips.appendChild(b); + }); + p.appendChild(chips); + var chat = el("div", "rcr-chat"); chat.setAttribute("aria-live", "polite"); + p.appendChild(chat); + handshake(log); + } + + function wait(ms) { return new Promise(function (r) { setTimeout(r, reduced ? 0 : ms); }); } + var reduced = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + function handshake(log) { + if (state.connected) { log.innerHTML = connectedLine(); return; } + log.innerHTML = ' initialize ...'; + wait(450).then(function () { + log.innerHTML = ' initialize 200 OK, protocol 2025-06-18   tools/list ...'; + return wait(450); + }).then(function () { state.connected = true; log.innerHTML = connectedLine(); }); + } + function connectedLine() { + return ' Connected. tools/list returned ' + state.tools.length + " tools: " + + state.tools.map(function (t) { return '' + esc(t.name) + ""; }).join(" "); + } + + function curlFor(name, args, id) { + var body = JSON.stringify({ jsonrpc: "2.0", id: id, method: "tools/call", params: { name: name, arguments: args } }); + return "curl -X POST " + ENDPOINT + " \\\n -H \"X-API-Key: \" \\\n -H \"Content-Type: application/json\" \\\n -H \"Accept: application/json, text/event-stream\" \\\n -d '" + body + "'"; + } + + function ask(q) { + if (state.busy) return; + state.busy = true; + var chips = panels.ask.querySelectorAll(".rcr-chip"); + Array.prototype.forEach.call(chips, function (c) { c.disabled = true; }); + var chat = panels.ask.querySelector(".rcr-chat"); + chat.innerHTML = ""; + chat.appendChild(el("div", "rcr-msg rcr-user", esc(q.q))); + var thinking = el("div", "rcr-msg rcr-agent rcr-thinking", ""); + chat.appendChild(thinking); + + /* Run the script synchronously to get the calls, then replay with timing. */ + var calls = [], answer, rpcId = 3; + try { + answer = q.run(function (name, args) { + var entry = { name: name, args: args, id: rpcId++ }; + calls.push(entry); + try { entry.result = execute(state.model, state.tools, state.surface, name, args); } + catch (e) { + if (e instanceof McpError) entry.error = e.message; + else if (e instanceof ToolError) entry.toolError = e.message; + throw e; + } + return entry.result; + }); + } catch (e) { + if (!(e instanceof McpError) && !(e instanceof ToolError)) throw e; + var last = calls[calls.length - 1]; + if (e instanceof ToolError) { + var t = state.tools.filter(function (x) { return x.name === last.name; })[0]; + answer = "I couldn't answer that. My call to `" + last.name + "` failed because the record wasn't found. Check the " + + (t && t.entity ? t.entity : "entity") + " key template in step 2: it has to match your keys."; + } else if (/^unknown tool/.test(e.message)) { + var owner = state.model.filter(function (x) { return last.name.replace(/^(get_|filter_|count_)/, "").replace(/_by_id$/, "") === snake(x.name); })[0]; + answer = "I couldn't answer that. The service has no `" + last.name + "` tool, because the " + (owner ? owner.name : "entity") + + " entity has no fields. Check its key template in step 2: it has to match JSON documents."; + } else { + answer = "I couldn't answer that. The server rejected my call to `" + last.name + "`: the data model doesn't index the field I needed. Go back to step 2, index it, and ask again."; + } + } + + var chain = wait(700); + calls.forEach(function (c) { + chain = chain.then(function () { + if (!thinking.parentNode) chat.appendChild(thinking); + chat.insertBefore(callCard(c), thinking); + return wait(650); + }); + }); + chain.then(function () { + thinking.remove(); + chat.appendChild(el("div", "rcr-msg rcr-agent", mdLite(answer).replace(/`([^`]+)`/g, "$1"))); + state.busy = false; + Array.prototype.forEach.call(chips, function (c) { c.disabled = false; }); + }); + } + + function callCard(c) { + var failed = c.error || c.toolError; + var d = el("details", "rcr-call" + (failed ? " is-err" : "")); + var status = failed ? ' error' : ' ok'; + d.appendChild(el("summary", "", 'tools/call ' + esc(c.name) + ' ' + esc(JSON.stringify(c.args)) + "" + status + "")); + var body = el("div", "rcr-callbody"); + var req = { jsonrpc: "2.0", id: c.id, method: "tools/call", params: { name: c.name, arguments: c.args } }; + var res = c.error ? { jsonrpc: "2.0", id: c.id, error: { code: -32602, message: c.error } } + : c.toolError ? { jsonrpc: "2.0", id: c.id, result: { content: [{ type: "text", text: "Error executing tool: " + c.toolError }], isError: true } } + : { jsonrpc: "2.0", id: c.id, result: { content: [{ type: "text", text: JSON.stringify(c.result) }] } }; + body.appendChild(el("div", "rcr-lbl", "Request")); + body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(req))); + body.appendChild(el("div", "rcr-lbl", failed ? "Response" : "Response (the tool result, parsed from content[0].text)")); + body.appendChild(el("pre", "rcr-code rcr-code-sm", jsonHtml(failed ? res : c.result))); + var cd = el("details", "rcr-curl"); + cd.appendChild(el("summary", "", "Run this call yourself with curl")); + cd.appendChild(el("pre", "rcr-code rcr-code-sm", esc(curlFor(c.name, c.args, c.id)))); + body.appendChild(cd); + d.appendChild(body); + return d; + } + + renderKeys(); + } + + function boot() { Array.prototype.forEach.call(document.querySelectorAll(".rcr[data-rcr]"), init); } + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot(); + + /* Exposed for tests. */ + window.ContextRetrieverDemo = { buildTools: buildTools, execute: execute, defaultModel: defaultModel, QUESTIONS: QUESTIONS, McpError: McpError, ToolError: ToolError, + indexCommands: indexCommands, detectFields: detectFields }; +})();