Skip to main content
Glama
README.md
# 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](https://workplace-mcp-apps.vercel.app/) · [MCP endpoint](https://workplace-mcp-apps.vercel.app/mcp) · 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](#make-it-yours-real-data-tools-and-sign-in)**.

## Try it in OpenWork

1. Add `https://workplace-mcp-apps.vercel.app/mcp` as a remote Streamable HTTP connector. Choose **no authentication** for this public demo.
2. Test tools. You should see eight launch tools. The ninth tool, `get_workplace_snapshot`, is an app-only refresh helper—not another dashboard card.
3. Create a dashboard and add each App in the order below. Paste this **Launch input (JSON)** for each:

   ```json
   { "config": {} }
   ```

4. 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.
5. 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 `dev` alone 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 / `show_agenda` | Tall, about 700px | Select a schedule block |
| 2 | Your daily brief / `show_brief` | Short, about 230px | Changing metrics and activity trend |
| 3 | Needs your attention / `show_attention` | Medium, about 500px | Filter and expand items |
| 4 | My goals / `show_goals` | Short, about 230px | Expand goal checkpoints |
| 5 | Time off / `show_leave` | Short, about 190px | Preview a fictional leave request |
| 6 | Keep learning / `show_learning` | Short, about 250px | Try a sample lesson |
| 7 | Around the company / `show_updates` | Medium, about 330px | Choose a department and read a story |
| 8 | Quick actions / `show_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.

## 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:

```json
{
  "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 |
|---|---|
| `company`, `viewer` | Fictional labels, max 48 / 32 characters |
| `team` | `Product`, `Engineering`, `Operations`, `Design` |
| `accent` | `blue`, `violet`, `teal` |
| `density` | `comfortable`, `compact` |
| `scenario` | `balanced`, `busy`, `focus` |
| `live` | `true` or `false` |
| `refreshSeconds` | `15`, `30`, `60` |
| `seed` | Integer `0`–`9999`; repeatable demo variations |

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: false` removes 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`).

```sh
corepack enable
pnpm install --frozen-lockfile
pnpm dev
```

Open `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 build`**
- No environment variables, database, or paid integration required

Or, from your clone with Vercel CLI already signed in:

```sh
vercel link
vercel --prod
```

Your 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 |
|---|---|
| `src/shared/config.ts` | Widget catalog, tool names, recommended order, launch defaults and validation |
| `src/shared/data.ts` | Typed snapshot contract and stateless synthetic-data adapter |
| `src/ui/Widgets.tsx`, `widgets.css` | The actual card components shared by website and MCP views |
| `src/ui/embedded.ts` | Standard MCP Apps lifecycle, tool refresh, cancellation and height reporting |
| `src/server/mcp.ts` | Eight tool/resource bindings and the app-only helper |
| `src/server/http.ts` | Origin, request-size, method, and cache boundaries |
| `src/ui/showcase.tsx` | Public demo and developer configurator |
| `scripts/build.ts` | Inline the App resource, check its size, build the public site |

### Add your own widget

1. Add its ID, title and tool name to `config.ts`.
2. Extend `snapshotSchema` and the data adapter with its typed result.
3. Add a React card and register it in `widgetComponents` in `Widgets.tsx`.
4. Add it to `recommendedOrder` if it belongs in the default board. The server loops over the catalog to register launch tools and resources.
5. 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.

```text
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 the `ui://` resource prefix).
- **Look and feel:** `src/ui/widgets.css` and the accent options in `configSchema`. Add your palette as another `accent` value.
- **Server identity:** `name` / `version` in `package.json` become the MCP `serverInfo`.
- **Demo wording:** once the data is real, update `instructions`, `demoPolicy`, the tool descriptions, and the "Synthetic shared demo" text fallback in `src/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:

```ts
// 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()`:

```ts
}, 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_snapshot` already refreshes the widgets. Once it calls the same adapter, **Refresh** and the live timer return live data without any UI changes. Choose a `refreshSeconds` that fits your upstream API's rate limits.
- Keep validating with `snapshotSchema` (the SDK checks `outputSchema`) and return only the fields the card displays.
- `config` should stay presentation-only. Never read identity from `config.viewer` or `config.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) | `show_tickets` | `["model", "app"]` + `_meta.ui.resourceUri` | read-only |
| **App-only data tool** (the widget calls it) | `get_ticket_details` | `["app"]` | read-only |
| **Action tool** (it changes something) | `submit_leave_request` | `["model", "app"]` or `["app"]` | `readOnlyHint: false`, `destructiveHint` as appropriate |

Register a data or action tool:

```ts
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:

```ts
const result = await app.callServerTool(
  { name: "submit_leave_request", arguments: { start, end } },
  { timeout: 12000 },
);
```

For a new card, follow [Add your own widget](#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):

1. The host calls `/mcp` without a token and gets back `401` with `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource"`.
2. 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.
3. 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`:

```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`:

```ts
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 `/mcp` needs 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.ts` already sets `no-store` on responses. Keep that so personal data is never cached.
- Update the tests: `tests/server.test.ts` should 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

```sh
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 build
```

Tests 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.