Workplace MCP Apps
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@Workplace MCP AppsSet up my daily dashboard with agenda, brief, and goals."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Workplace MCP Apps
Eight thoughtful workplace widgets, one standard MCP server. A small, open-source example of how to build a company's own MCP Apps without coupling its UI or data adapter to a particular host.
Live demo · MCP endpoint · MIT licensed
All people, meetings, incidents, balances, and progress are synthetic. No account, API key, database, or paid data provider is needed. The public site previews the same React components used by the MCP Apps; inside an MCP host, snapshots come from actual MCP tool calls.
The mock data is only a stand-in. Every card renders whatever its MCP tool returns, so you can point the same widgets at your own calendar, ticketing, HR, or learning systems and let each person sign in with OAuth. See Make it yours: real data, tools, and sign-in.
Try it in OpenWork
Add
https://workplace-mcp-apps.vercel.app/mcpas a remote Streamable HTTP connector. Choose no authentication for this public demo.Test tools. You should see eight launch tools. The ninth tool,
get_workplace_snapshot, is an app-only refresh helper—not another dashboard card.Create a dashboard and add each App in the order below. Paste this Launch input (JSON) for each:
{ "config": {} }Enable Run automatically for these read-only demo widgets, or run each once as the host permits. Share the dashboard using your normal organization controls.
Open Dashboard. In builds with the compact masonry layout, the tall agenda sits beside shorter stacked cards. Change the saved order with Den's up/down arrows. A merge into
devalone does not mean an installed release includes that layout.
The hosted /mcp URL is an API, not a webpage to embed as an iframe. A normal browser GET returns a helpful 405. Compatible hosts discover tools and ui:// resources over MCP.
Recommended three-column order
Order | App / tool | Shape | Demo interaction |
1 | Today at a glance / | Tall, about 700px | Select a schedule block |
2 | Your daily brief / | Short, about 230px | Changing metrics and activity trend |
3 | Needs your attention / | Medium, about 500px | Filter and expand items |
4 | My goals / | Short, about 230px | Expand goal checkpoints |
5 | Time off / | Short, about 190px | Preview a fictional leave request |
6 | Keep learning / | Short, about 250px | Try a sample lesson |
7 | Around the company / | Medium, about 330px | Choose a department and read a story |
8 | Quick actions / | Short, about 270px | Local previews; no real submissions |
These are approximate collapsed heights at typical dashboard widths, not forced heights. Cards use one column each—no cross-column spans. Expanded content renegotiates height. A host may clamp height (OpenWork currently caps inline frames at 800px), so particularly long expanded states may scroll within that limit. Fewer available columns naturally change packing.
Related MCP server: Boardstate MCP Server
Configure without editing code
Use Configure on the live demo to choose a persona, accent, density, scenario, and refresh pace, then Copy JSON. Paste the complete object into each tile's launch input:
{
"config": {
"company": "Example Company",
"viewer": "Alex",
"team": "Product",
"accent": "blue",
"density": "comfortable",
"scenario": "balanced",
"live": true,
"refreshSeconds": 30,
"seed": 7
}
}Only config itself is required; every setting inside it is optional. Use the same configuration across tiles for a cohesive demo. Different input can give the same tool another persona on hosts that permit duplicate tools with distinct launch arguments.
Setting | Accepted values |
| Fictional labels, max 48 / 32 characters |
|
|
|
|
|
|
|
|
|
|
|
|
| Integer |
The website does not save or send its configuration. Copying settings into an MCP host sends those launch arguments to this public server, so do not use sensitive labels or credentials. Company/viewer/team are not an authentication or authorization mechanism.
How the changing data works
createSnapshot() combines a time bucket, seed, and optional preview step into six deterministic demo moments. Counts, incident states, goals, and learning progress change; the sample schedule remains a readable fictional workday rather than pretending to be your actual calendar.
In an MCP host, the App uses
callServerTool("get_workplace_snapshot")through its existing host connection. There is no direct browser fetch to a third party.Pause, Refresh, and Next demo moment control the sample. No concurrent refresh requests are sent.
Hidden views pause. Teardown cancels requests and removes timers/observers. A failed refresh keeps the last valid data and pauses automatic updates until retry.
Read-only hosts can show the initial result but do not advertise or receive refresh calls.
live: falseremoves wall-clock changes. Next demo moment still works as an explicit read; reopening starts from the configured seed. Nothing persists server-side.
Run locally
Requires Node 24.x and pnpm 10.28.0 (declared in package.json).
corepack enable
pnpm install --frozen-lockfile
pnpm devOpen http://127.0.0.1:3000/. The MCP endpoint is http://127.0.0.1:3000/mcp. Set PORT=3001 if needed. pnpm dev builds then starts the server; restart it after edits. Some remote hosts require a public HTTPS URL instead of a loopback address.
Deploy your own copy to Vercel
Fork or clone this repository into your own GitHub account, then import it into Vercel.
Framework: Hono
Root directory: repository root
Node: 24.x
Build command:
pnpm buildNo environment variables, database, or paid integration required
Or, from your clone with Vercel CLI already signed in:
vercel link
vercel --prodYour new endpoint is https://<your-project>.vercel.app/mcp. The Copy MCP URL button derives this from the page's own origin, so it automatically works on your deployment.
vercel.json includes generated/widget.html and the generated public/ assets in the function. Keep that setting: the static showcase alone is not an MCP App deployment. Hono serves an explicit fallback for the page and content-hashed assets, with CDN cache headers, because Vercel may inventory static files before the build creates them. public/ and generated/ are dedicated generated outputs; the build replaces them. Do not put hand-maintained files there.
For a public demo, ensure Vercel deployment protection is not blocking the production endpoint. Preview deployments can remain protected. Choose your own plan/budgets; public anonymous requests still consume hosting resources. No firewall bypass token belongs in this repository or a connector URL.
Where to make changes
File | Responsibility |
| Widget catalog, tool names, recommended order, launch defaults and validation |
| Typed snapshot contract and stateless synthetic-data adapter |
| The actual card components shared by website and MCP views |
| Standard MCP Apps lifecycle, tool refresh, cancellation and height reporting |
| Eight tool/resource bindings and the app-only helper |
| Origin, request-size, method, and cache boundaries |
| Public demo and developer configurator |
| Inline the App resource, check its size, build the public site |
Add your own widget
Add its ID, title and tool name to
config.ts.Extend
snapshotSchemaand the data adapter with its typed result.Add a React card and register it in
widgetComponentsinWidgets.tsx.Add it to
recommendedOrderif it belongs in the default board. The server loops over the catalog to register launch tools and resources.Extend the protocol and browser tests, then deploy. Test at 320px width and after expanding/collapsing content.
Each launch tool advertises _meta.ui.resourceUri, such as ui://workplace/agenda.html. resources/read returns exactly one self-contained HTML document with MIME text/html;profile=mcp-app. All eight resource URIs share one bundled React renderer; the validated tool result selects the widget. Text fallback and explicit structured output remain available to clients without an App renderer.
Height is measured from content with ResizeObserver and reported via the official SDK's sendSizeChanged({ height }). No viewport-sized cards, 100vh, or fixed dashboard row heights; the host owns the width and placement. CSS and JavaScript are bundled inline, with no CDN/font/image dependencies or sandbox network permissions. The build enforces a resource smaller than 768 KiB.
Make it yours: real data, tools, and sign-in
The public demo uses mock data, but the same design works with live data. The widgets never hard-code anything. They render the typed result of an MCP tool call. If you change what the tool returns, the dashboard changes with it.
MCP host (e.g. OpenWork) Your MCP server (this repo)
───────────────────────── ───────────────────────────
1. calls show_agenda({ config }) ─────────────▶ tool handler
└─ data adapter ──▶ mock today: createSnapshot()
yours: calendar / HRIS / ITSM APIs
2. renders ui://workplace/agenda.html ◀────────── structuredContent (validated by snapshotSchema)
3. widget calls get_workplace_snapshot ────────▶ same adapter, fresh data on each refresh
via app.callServerTool(...)Customizing it takes four steps. Do them in order and you can stop after any of them.
1. Rebrand it
Names, catalog, and order:
src/shared/config.ts(widgets,recommendedOrder, tool names, and theui://resource prefix).Look and feel:
src/ui/widgets.cssand the accent options inconfigSchema. Add your palette as anotheraccentvalue.Server identity:
name/versioninpackage.jsonbecome the MCPserverInfo.Demo wording: once the data is real, update
instructions,demoPolicy, the tool descriptions, and the "Synthetic shared demo" text fallback insrc/server/mcp.ts. They currently tell the model and host that the data is fake.
2. Replace the mock adapter with live data
createSnapshot() in src/shared/data.ts is the only mock. Keep the snapshot schema and card layout, and swap in an adapter that fetches real data for the signed-in person:
// src/server/adapter.ts: sketch, not shipped code
import type { WidgetId } from "../shared/config.js";
import type { Snapshot } from "../shared/data.js";
export type RequestContext = { userId: string; accessToken: string; tenantId?: string };
export async function loadSnapshot(widget: WidgetId, ctx: RequestContext): Promise<Snapshot> {
switch (widget) {
case "agenda": {
const events = await fetch("https://calendar.example.com/v1/me/events?range=today", {
headers: { Authorization: `Bearer ${ctx.accessToken}` },
}).then(r => r.json());
return toSnapshot(widget, { agenda: mapEvents(events) });
}
// brief, attention, goals, leave, learning, updates, actions …
}
}Then make the tool handlers in src/server/mcp.ts async and call your adapter instead of createSnapshot():
}, async ({ config }, extra) => snapshotResult(await loadSnapshot(widget.id, contextFrom(extra))));Tips:
Fetch on the server, not from the widget. The App resource declares an empty CSP (
connectDomains: []), so all data goes through the host's MCP connection. You don't need to open any extra network paths from the iframe.get_workplace_snapshotalready refreshes the widgets. Once it calls the same adapter, Refresh and the live timer return live data without any UI changes. Choose arefreshSecondsthat fits your upstream API's rate limits.Keep validating with
snapshotSchema(the SDK checksoutputSchema) and return only the fields the card displays.configshould stay presentation-only. Never read identity fromconfig.viewerorconfig.company. It comes from the verified token (step 4).
3. Add your own tools and tool calls
There are three kinds of tools. Each is a server.registerTool(...) call in src/server/mcp.ts:
Kind | Example | Visibility | Annotations |
Launch tool (a card on the dashboard) |
|
| read-only |
App-only data tool (the widget calls it) |
|
| read-only |
Action tool (it changes something) |
|
|
|
Register a data or action tool:
server.registerTool("submit_leave_request", {
title: "Submit a leave request",
description: "Creates a leave request for the signed-in employee.",
inputSchema: z.object({ start: z.string().date(), end: z.string().date(), note: z.string().max(280).optional() }).strict(),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
_meta: { ui: { visibility: ["app"] } },
}, async (args, extra) => {
const ctx = contextFrom(extra); // verified user (step 4)
const result = await hris.createLeave(ctx, args); // your API; use an idempotency key
return { content: [{ type: "text", text: `Request ${result.id} submitted.` }], structuredContent: result };
});Call it from a widget the same way src/ui/embedded.ts calls the refresh helper:
const result = await app.callServerTool(
{ name: "submit_leave_request", arguments: { start, end } },
{ timeout: 12000 },
);For a new card, follow Add your own widget as well. Rules for write tools: run them only on an explicit user click, never from the refresh timer; show a confirmation step in the card; make them idempotent and audited on the server; and check that the host supports server tools (app.getHostCapabilities()?.serverTools) before showing the button.
4. Add OAuth so each person signs in
The public demo runs without authentication. For personal data, turn the server into an OAuth-protected MCP resource. Compatible hosts, including OpenWork remote connectors set to OAuth, then walk each user through sign-in and send their bearer token with every tool call.
How the flow works (MCP authorization spec, RFC 9728 protected resource metadata):
The host calls
/mcpwithout a token and gets back401withWWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource".The host reads that metadata, finds your authorization server (Okta, Entra ID, Auth0, Clerk, WorkOS, Keycloak, …), and runs the OAuth + PKCE sign-in in the user's browser.
The host retries with
Authorization: Bearer <token>. Your server verifies it and passes the user to your tool handlers.
mcp-handler (already a dependency) provides the pieces. A sketch for src/server/routes.ts:
import { withMcpAuth, protectedResourceHandler, metadataCorsOptionsRequestHandler } from "mcp-handler";
import { createRemoteJWKSet, jwtVerify } from "jose"; // pnpm add jose
const ISSUER = process.env.OAUTH_ISSUER!; // e.g. https://login.example.com
const AUDIENCE = process.env.OAUTH_AUDIENCE!; // e.g. https://your-project.vercel.app/mcp
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
const authed = withMcpAuth(handler, async (_req, token) => {
if (!token) return undefined;
const { payload } = await jwtVerify(token, jwks, { issuer: ISSUER, audience: AUDIENCE });
return {
token,
clientId: String(payload.azp ?? payload.client_id ?? ""),
scopes: String(payload.scope ?? "").split(" ").filter(Boolean),
expiresAt: payload.exp,
extra: { userId: payload.sub, tenantId: payload.tid },
};
}, { required: true, requiredScopes: ["workplace.read"] });
app.all("/mcp", c => handleHttp(c.req.raw, authed, { methods: ["POST"], /* … */ }));
app.get("/.well-known/oauth-protected-resource", c => protectedResourceHandler({ authServerUrls: [ISSUER] })(c.req.raw));
app.options("/.well-known/oauth-protected-resource", c => metadataCorsOptionsRequestHandler()(c.req.raw));In the tool handlers, the verified user arrives as extra.authInfo:
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
function contextFrom(extra: { authInfo?: AuthInfo }): RequestContext {
const auth = extra.authInfo;
if (!auth?.extra?.userId) throw new Error("Sign in required.");
return { userId: String(auth.extra.userId), tenantId: auth.extra.tenantId as string | undefined, accessToken: auth.token };
}Reaching the downstream systems (calendar, HRIS, ticketing) works one of two ways:
Same identity provider: exchange the user's MCP token for a downstream token (OAuth token exchange / on-behalf-of) server-side. Don't forward the MCP token to other APIs unless its audience allows it.
Separate provider (for example Google or Microsoft 365): run a second OAuth consent on your server, store each user's refresh token encrypted server-side (keyed by
userId), and exchange it for access tokens as needed.
Deployment notes:
Put issuer, audience, and client secrets in Vercel environment variables, never in this repo, launch
config, or a connector URL.Once
/mcpneeds a token, add authorization per tenant and per member in the adapter. A valid token proves who the user is, not what they may see.http.tsalready setsno-storeon responses. Keep that so personal data is never cached.Update the tests:
tests/server.test.tsshould cover 401-without-token and a stubbed verified user.
This repository deliberately ships without steps 2–4 so it stays a zero-setup public demo. The snippets above are starting points, not a production employee portal or a finished OAuth implementation.
Checks
pnpm typecheck
pnpm test
pnpm build
pnpm test:runtime # plain Node import of emitted server, without a TypeScript loader
pnpm exec playwright install chromium # first time, if no Chrome/cached Chromium
pnpm test:browser
pnpm audit --prod --audit-level high
pnpm smoke https://your-project.vercel.app # after deployment; compares resource bytes to your local buildTests cover validation, deterministic demo moments, real HTTP MCP negotiation/tool calls/resources, three-/one-column layout, light/dark rendering, config export, local-only interactions, and a separate-origin MCP Apps SDK host with real tool refresh and height notifications. They are not a claim of full OpenWork or every third-party host certification.
The local SDK fixture is test-only, never deployed. /healthz checks that the resource is present and valid; /catalog.json lists Apps, defaults, and readiness. The MCP endpoint accepts bounded POST requests and OPTIONS, not long-lived GET streams. Browser access is same-origin; server-side MCP clients may omit Origin. No wildcard CORS, cookies, analytics, external providers, or business writes.
License
Original project code is MIT, copyright 2026 Jalil. Bundled dependencies keep their own licenses; see THIRD_PARTY_NOTICES.md. This is an independent example using the MCP and MCP Apps standards, not an official service of those projects.
This server cannot be deployed
Maintenance
Related MCP Connectors
Let AI agents query data and act across all your business apps via MCP.
Harmny career frameworks, competencies, goals, org metrics and tasks as live context in MCP clients.
An AI concierge that turns static forms into adaptive AI conversations. From any MCP client.
Your OpenWork org's skills, plugins, workflows, and connections through one OAuth MCP URL.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceAn extensible MCP server and generative UI engine for hosting interactive B2B enterprise workflows with dynamic styling and stateful simulators.-
- AlicenseNot gradedqualityBmaintenanceEnables AI agents to compose and edit dashboards through MCP tools, allowing them to manage tabs, widgets, layout, and data bindings via a unified control plane.9MIT
- FlicenseAqualityBmaintenanceMCP server that lets agents create, display, and export rich UI widgets (cards, dashboards, charts, forms) inline in conversations, with interactive iframe support in MCP Apps hosts and PNG image fallback for other clients.43-
- FlicenseNot gradedqualityCmaintenanceEnables local employee onboarding, leave tracking, meeting coordination, and workplace requests through any MCP-compatible client, with optional SMTP notifications.2-