Embed an authenticated SaaS assistant
Connect sign-in, page context, and order tools with server-side ownership checks.
Embed an order assistant in a SaaS workspace: alice can read her orders, bob can read his, and returns require confirmation. This runnable example connects host sign-in, trusted token issuance, page context, and MCP authorization. Its orders are in-memory fixtures, not a connection to a real order system.
Prepare the application and agent
You need Bun, an available Develop service agent, an organization API Key, and a test domain that proxies HTTPS requests to local port 4242. Praxis must be able to reach /mcp on that domain. Follow Web integration to prepare Kit, the environment, and origin policy. Shared fields are documented in Embed tokens and the component reference.
Basic authentication below is only for two test accounts sharing your chosen test password. An existing SaaS should replace authenticate with server-side session verification and retain origin and CSRF protection. Never select external_user_id from the request body. The server binds to loopback and is exposed only through your HTTPS proxy.
Provide five server environment variables: PRAXIS_API_KEY for the organization Key, PRAXIS_AGENT for the actual agent_… ID, PRAXIS_MCP_SECRET for your order-service credential, DEMO_PASSWORD for test sign-in, and APP_ORIGIN for the HTTPS test origin without a path. Keep them out of page source and version control.
Implement order tools and authorization
In a new example directory, run bun add @modelcontextprotocol/server@2.0.0. Save this complete module as orders.ts. list_orders has no user argument: identity comes from a request header only after the service credential is verified. request_return checks ownership before changing state. It does not rely on the model to supply an authorized order.
import { McpServer, WebStandardStreamableHTTPServerTransport, fromJsonSchema } from "@modelcontextprotocol/server";
const orders = [
{ id: "order_a", user: "alice", item: "Headphones", status: "delivered" },
{ id: "order_b", user: "bob", item: "Keyboard", status: "delivered" },
];
const returns = new Set<string>(); // Demo memory only; production needs durable storage.
const knownUser = (user: string | null): user is string => user === "alice" || user === "bob";
export function listOrders(user: string) {
return orders.filter(order => order.user === user).map(order => ({
id: order.id, item: order.item, status: returns.has(order.id) ? "return_requested" : order.status,
}));
}
export function requestReturn(user: string, orderId: string, reason: string) {
const order = orders.find(order => order.id === orderId && order.user === user);
if (!order) return { error: "Order missing or not owned by this user" };
if (!reason.trim() || reason.length > 500) return { error: "Provide a reason of 1–500 characters" };
if (returns.has(order.id)) return { error: "Return already requested" };
returns.add(order.id);
return { order_id: order.id, status: "return_requested" };
}
const result = (value: unknown, isError = false) => ({
content: [{ type: "text" as const, text: JSON.stringify(value) }], ...(isError ? { isError: true } : {}),
});
export async function handleMcp(request: Request, secret: string): Promise<Response> {
if (!secret || request.headers.get("Authorization") !== `Bearer ${secret}`) {
return new Response("Unauthorized", { status: 401 });
}
const user = request.headers.get("X-Praxis-External-User-Id");
if (!knownUser(user)) return new Response("Unknown end user", { status: 403 });
// A fresh closure per request prevents concurrent users from sharing identity.
const server = new McpServer({ name: "orders", version: "1.0.0" });
server.registerTool("list_orders", {
description: "List orders owned by the current user.",
inputSchema: fromJsonSchema<Record<string, never>>({ type: "object", properties: {}, additionalProperties: false }),
annotations: { readOnlyHint: true },
}, async () => result(listOrders(user)));
server.registerTool("request_return", {
description: "Request a return after the user confirms the order and reason.",
inputSchema: fromJsonSchema<{ order_id: string; reason: string }>({
type: "object", properties: {
order_id: { type: "string", minLength: 1, maxLength: 100 },
reason: { type: "string", minLength: 1, maxLength: 500 },
}, required: ["order_id", "reason"], additionalProperties: false,
}),
annotations: { readOnlyHint: false, destructiveHint: true },
}, async ({ order_id, reason }) => {
const value = requestReturn(user, order_id, reason);
return result(value, "error" in value);
});
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, enableJsonResponse: true,
});
await server.connect(transport);
try { return await transport.handleRequest(request); }
finally { await server.close(); }
}Expected: Queries return only the current user's orders. bob cannot return order_a; alice can. Repeating the return produces an error. Replace the in-memory collection with durable transactions and business deduplication before production. Restarting this example resets every return.
Connect sign-in and token issuance
Save this as server.ts. Page and token routes share the same sign-in check. The MCP route uses a separate service credential. Retrieve trusted customer information from your backend; this example uses the verified test username. Keep secrets out of system_message.
import { timingSafeEqual } from "node:crypto";
import { handleMcp } from "./orders";
const { PRAXIS_API_KEY, PRAXIS_AGENT, PRAXIS_MCP_SECRET, DEMO_PASSWORD, APP_ORIGIN } = process.env;
if (!PRAXIS_API_KEY || !PRAXIS_AGENT || !PRAXIS_MCP_SECRET || !DEMO_PASSWORD || !APP_ORIGIN) {
throw new Error("Set all five server variables");
}
const origin = new URL(APP_ORIGIN).origin;
const noStore = { "Cache-Control": "no-store" };
function authenticate(request: Request) {
const actual = Buffer.from(request.headers.get("Authorization") ?? "");
for (const id of ["alice", "bob"]) {
const expected = Buffer.from("Basic " + Buffer.from(`${id}:${DEMO_PASSWORD}`).toString("base64"));
if (actual.length === expected.length && timingSafeEqual(actual, expected)) return id;
}
}
Bun.serve({
hostname: "127.0.0.1", port: 4242,
async fetch(request) {
const path = new URL(request.url).pathname;
if (path === "/mcp") return handleMcp(request, PRAXIS_MCP_SECRET);
const user = authenticate(request);
if (!user) return new Response("Sign in", {
status: 401, headers: { ...noStore, "WWW-Authenticate": 'Basic realm="Order demo"' },
});
if (request.method === "GET" && path === "/") {
return new Response(Bun.file("index.html"), { headers: noStore });
}
if (request.method === "POST" && path === "/api/praxis-token") {
if (request.headers.get("Origin") !== origin) return new Response("Origin rejected", { status: 403 });
try {
const response = await fetch("https://api-develop.deepraxis.com/agent/v1/embed_tokens", {
method: "POST",
headers: { Authorization: `Bearer ${PRAXIS_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
agent: PRAXIS_AGENT, external_user_id: user,
system_message: [{ type: "text", text: `Signed-in customer: ${user}. Use order tools for order facts.` }],
}),
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) return new Response("Token issuance failed", { status: response.status, headers: noStore });
const data = await response.json();
if (typeof data.token !== "string" || typeof data.expires_at !== "string") throw new Error("Invalid response");
return Response.json({ token: data.token, expires_at: data.expires_at }, { headers: noStore });
} catch { return new Response("Assistant unavailable", { status: 502, headers: noStore }); }
}
return new Response("Not found", { status: 404 });
},
});After saving index.html from the next section, run bun server.ts with all five variables injected into its server process. Proxy the test domain to 127.0.0.1:4242, preserving Authorization, Origin, and MCP request headers. Do not log credentials.
In Service agents → your agent → Integration and configuration → Configuration:
- Set instructions to use order tools for queries and returns, never invent orders, and confirm the order and reason before returning. Select an available
deepseek-flashmodel. - Under MCP and credentials, add an HTTP server named
ordersat your test domain plus/mcp. Store the credential matchingPRAXIS_MCP_SECRETin a vault, restrict its allowed host to the MCP domain, and save the vault selection. - Under Tools and authorization, set
list_ordersto automatic execution andrequest_returnto ask every time. Save a new configuration version and ensure embedding uses it. - Under Security, select an available environment and the same HTTPS origin as
APP_ORIGIN, then save. Embed policy and vault selection currently can only be configured in the dashboard.
Expected: Unsigned host token requests return 401. Only authenticated, same-origin requests issue a Praxis token. The host returns token and expires_at without caching. Praxis issuance succeeds with 201; this host projects success as 200.
Pass the current order page
Save this as index.html. The input tells the assistant which order is being discussed. It cannot change identity or grant access. setReadable values accompany later user messages, while the order service always checks ownership independently.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Order workspace</title>
<h1>Order workspace</h1>
<label>Order to discuss <input id="order" placeholder="order_a or order_b"></label>
<p id="status" role="status"></p>
<script type="module" src="https://kit-develop.praxisusercontent.com/v0.2.8/praxis-kit.js" integrity="sha384-vey+VZZcFIWJq9Fmv/nmLxspKtXRiL8XiDOfg2g2QFvwZi5MA+tkiQvPBtoMS8eU" crossorigin="anonymous"></script>
<script type="module">
await customElements.whenDefined("praxis-agent");
const agent = document.createElement("praxis-agent");
agent.setOptions({
api: {
url: "https://api-develop.deepraxis.com",
getToken: async () => {
const response = await fetch("/api/praxis-token", {
method: "POST", credentials: "same-origin", cache: "no-store",
});
if (!response.ok) throw new Error(`Token request failed: ${response.status}`);
const data = await response.json();
if (typeof data.token !== "string") throw new Error("Missing token");
return data.token;
},
},
agent: { name: "Order assistant" },
layout: "popup", launcher: { type: "bubble", position: "bottom-right", label: "Ask about orders" },
header: { title: "Order assistant" },
});
agent.setReadable("page", "Order workspace");
document.querySelector("#order").addEventListener("input", event => {
agent.setReadable("selected_order", event.target.value);
});
agent.addEventListener("praxis.error", () => {
document.querySelector("#status").textContent = "Assistant unavailable. Check your sign-in or try later.";
});
document.body.appendChild(agent);
window.addEventListener("pagehide", () => agent.remove(), { once: true });
window.addEventListener("pageshow", event => { if (event.persisted) location.reload(); });
</script>
</html>Expected: Sign-in shows the order page and a bubble at the bottom right. Asking for your orders calls list_orders. Entering order_a and requesting a return prompts for confirmation before executing the write tool. Denying confirmation must leave the order unchanged.
Use separate browser profiles or private windows for the test accounts to avoid cached Basic credentials. In production, sign-out must end the host session and destroy the component. Mount a fresh component and issue fresh credentials for the next user. See the full token lifecycle.
Automated checks and two-user acceptance
Save this as orders.test.ts and run nice -n 10 bun test orders.test.ts. It uses a local fake credential, makes no Praxis calls, and changes no real orders.
import { test, expect } from "bun:test";
import { handleMcp, listOrders, requestReturn } from "./orders";
test("orders remain scoped to the authenticated user", () => {
expect(listOrders("alice").map(order => order.id)).toEqual(["order_a"]);
expect(listOrders("bob").map(order => order.id)).toEqual(["order_b"]);
expect(requestReturn("bob", "order_a", "Wrong item")).toHaveProperty("error");
expect(listOrders("alice")[0].status).toBe("delivered");
expect(requestReturn("alice", "order_a", "Wrong item")).toEqual({ order_id: "order_a", status: "return_requested" });
expect(requestReturn("alice", "order_a", "Try again")).toHaveProperty("error");
});
test("an identity header without the service credential grants no access", async () => {
const forged = new Request("https://example.test/mcp", {
method: "POST", headers: { "X-Praxis-External-User-Id": "alice" },
});
expect((await handleMcp(forged, "local-test-only")).status).toBe(401);
const missingUser = new Request("https://example.test/mcp", {
method: "POST", headers: { Authorization: "Bearer local-test-only" },
});
expect((await handleMcp(missingUser, "local-test-only")).status).toBe(403);
});Expected: Both tests pass. Cross-user returns fail, and a forged identity header without the service credential returns 401. Then verify the real integration in separate browsers:
| Action | Expected result and verification |
|---|---|
| alice and bob each list orders | Only order_a and order_b, respectively; replay shows the matching end user |
bob enters order_a and requests a return | The tool denies access without changing alice's order; neither page context nor model arguments replace identity |
| alice requests a return but denies confirmation | No return executes; a new query still reports delivered |
| alice requests again and confirms | The return succeeds; refreshing and querying shows its state within this server process |
| bob opens conversation history | No alice conversations; also test session access using the lifecycle checks |
Confirmation expresses user intent. Trust X-Praxis-External-User-Id for authorization only after verifying the MCP caller's credential. Trusting an identically named header from an arbitrary internet request lets an attacker impersonate users.
Failures, cleanup, and extensions
For MCP 401, compare the vault credential with the order-service configuration and check the allowed host. For 403, confirm that the issued identity is alice or bob. For origin refusal, compare the HTTPS origins. If tools are missing, check the saved version and MCP configuration. Record statuses and request identifiers, never Keys or tokens.
After testing, revoke test-user tokens, disable test credentials, stop the server, and remove the test proxy. Return state disappears with the process. Before connecting real business data, replace storage, authentication, and business rules, then complete the go-live checklist.
The four files on this page form the complete example. Use Pass context for page changes and Client tools for browser actions. For public material, use the documentation Q&A recipe.