Skip to main content
Glama
qmatteoq

expense-mcp-app

by qmatteoq

Expense MCP App — M365 Copilot demo

A minimal, anonymous MCP app that renders an interactive expense-submission widget inside Microsoft 365 Copilot via a declarative agent, and inside Copilot Cowork via a plugin package. One tool, one widget, mock in-memory data. Built for Matteo Pagani's BeConnected talk "MCP in the M365 Copilot ecosystem."

  • Transport: Remote MCP server over Streamable HTTP (SSE is deprecated).

  • SDK: Official MCP TypeScript SDK (@modelcontextprotocol/sdk) on Node.js + Express.

  • Tools: openExpenseForm opens the widget with no required arguments, so the agent never interviews you for details first; submitExpense records what you enter and is widget-only; listExpenses is read-only. See Form-first design.

  • Widget: single-file HTML/CSS/JS served as text/html;profile=mcp-app, Fluent-2 styled.

  • Auth: Anonymous (development only).

Two ways to consume it — the same running server, two different packaging paths:

Host

Packaging

Section

Microsoft 365 Copilot

Declarative agent + MCP action, via Agents Toolkit

§4

Copilot Cowork

Plugin .zip: connector + Agent Skill

§7


1. Prerequisites

  • Node.js 18+ (tested on 22).

  • VS Code.

  • Microsoft 365 Agents Toolkit 6.6.1+ (VS Code extension) — for the declarative-agent path.

  • @microsoft/m365agentstoolkit-cli (atk) — for sideloading the Cowork plugin.

  • A Microsoft 365 tenant with a Microsoft 365 Copilot license and, optionally, Copilot Credits assigned to the user if you want to ues Cowork

  • A way to expose localhost over HTTPS — a dev tunnel (built into VS Code / devtunnel CLI)


Related MCP server: Microsoft Copilot Studio ❤️ MCP

2. Build & run locally

npm install
npm run build
npm start

You should see:

expense-mcp-app (anonymous) listening on http://localhost:3000/mcp
health check:                      http://localhost:3000/health
  • MCP endpoint: http://localhost:3000/mcp (POST, Streamable HTTP)

  • Health check: http://localhost:3000/health

  • Change the port with the PORT env var, e.g. setx PORT 3030.

Quick sanity check: Invoke-RestMethod http://localhost:3000/health{ status: "ok" }.

To preview the widget with no host at all, open dist/widget.html in a browser — it detects that no host is present and simulates the server locally.

3. Expose the local server (dev tunnel)

Both hosts must reach your server over public HTTPS. Either:

Option A — devtunnel CLI (persistent, recommended)

A named tunnel keeps the same URL across restarts, so you don't have to re-edit the manifest every time:

devtunnel create expense-mcp -a --description "Expense MCP app"
devtunnel port create expense-mcp -p 3000 --protocol http
devtunnel host expense-mcp

Reuse it later with just devtunnel host expense-mcp. The public URL follows the pattern https://<id>-3000.<region>.devtunnels.ms; your MCP endpoint is that plus /mcp.

Option B — one-shot tunnel

devtunnel user login
devtunnel host -p 3000 --allow-anonymous

Copy the public HTTPS URL it prints and append /mcp.

Option C — Agents Toolkit tunnel The Toolkit can start/manage a tunnel for you during provisioning; use the URL it surfaces.

Use --protocol http, not https. The flag describes the local service the tunnel forwards to, not the public URL. The relay terminates TLS and serves HTTPS either way. Setting https against a plain-HTTP server makes every tunneled request return 502.

Dev tunnels expire after 30 days. Keep npm start running while you do the steps below.


4. Use it with a declarative agent (Microsoft 365 Copilot)

In VS Code with the Microsoft 365 Agents Toolkit:

  1. Create New AgentDeclarative Agent.

  2. Add ActionStart with an MCP Server.

  3. Enter the MCP server URL: your public tunnel URL ending in /mcp (e.g. https://<id>-3000.<region>.devtunnels.ms/mcp).

  4. Run ATK: Fetch action from MCP — the Toolkit introspects the server.

  5. Select the openExpenseForm tool — this is the entry point that renders the widget. Add listExpenses too if you want the read-only path. You do not need to select submitExpense: it is widget-only by design (see Form-first design), and the widget calls it through the host bridge rather than through the agent.

  6. Choose authentication: Anonymous.

  7. Provision the agent (uploads the declarative agent + action package to your tenant).

  8. Test at https://m365.cloud.microsoft/chat — open your agent and say "I need to submit an expense". The form renders immediately, with no questions asked first.

Iterating: changes to the tool logic or the widget need only npm run build + a server restart — the host reads both live. Re-provision only when the action itself changes (new tools, renamed tools, changed input schemas), since that is what the uploaded package describes.

If ATK reports that the server exposes no tools, see §6.

5. What to show on stage

  1. Open the agent in https://m365.cloud.microsoft/chat and say "I need to submit an expense".

  2. The inline widget renders immediately — no interview. A Fluent-2 expense form (merchant, amount, currency, category, date) plus a Recent expenses list seeded with two rows. Mention a detail up front ("…a 42 EUR taxi") and the form arrives prefilled.

  3. Fill the form and click Submit expense — the widget calls the server tool through the host bridge and flips to a success state showing the returned expense id and summary.

  4. The new expense appears at the top of Recent expenses (served from structuredContent).

  5. Point out: the widget feature-detects the host API and falls back gracefully, so the very same file also previews standalone in a browser.

  6. Then show the same server running inside Cowork as a plugin (§7) — one MCP server, two hosts, no code change.

6. Troubleshooting: ATK shows no tools

If ATK: Fetch action from MCP reports that the server exposes no tools (so you cannot turn them into a declarative-agent action), the server is almost certainly using a stateless, per-request Streamable HTTP transport.

Why it breaks: ATK introspects the server with separate JSON-RPC calls — first an initialize, then a tools/list. The MCP lifecycle requires that the initialize response issue an Mcp-Session-Id header, and that every later request reuse it. A stateless server that creates a fresh, un-initialized McpServer per POST (and never issues a session id) rejects the follow-up tools/list, so enumeration returns nothing.

The fix: use the canonical stateful session-management pattern from the MCP TypeScript SDK (already implemented in src/server.ts):

  • initialize creates a StreamableHTTPServerTransport with a sessionIdGenerator, which emits an Mcp-Session-Id.

  • The transport is cached by session id; subsequent requests (tools/list, tools/call, the SSE stream on GET, termination on DELETE) look it up via the mcp-session-id header.

Verify the fix:

  1. Press Start on the server in .vscode/mcp.json (or npm start).

  2. Run ATK: Fetch action from MCP — the openExpenseForm, submitExpense and listExpenses tools should now appear.

If it still fails, enable Copilot debug logs with -developer on to inspect the raw initialize / tools/list exchange and confirm a session id is issued.

7. Use it with Copilot Cowork

Cowork is extended with M365 app packages, the same distribution mechanism as Teams apps and Copilot agents. A package can carry two kinds of things:

  • Connectors — remote MCP servers that give Cowork new tools (this project's server).

  • SkillsSKILL.md files that teach Cowork when and how to use those tools.

This repo builds both into dist/cowork-plugin.zip. See Build plugins for Copilot Cowork.

Package layout

cowork-plugin/                     # sources, zipped with contents at the ROOT
  manifest.json                    # M365 Unified App Manifest v1.28
  color.png                        # 192x192, generated by scripts/build-icons.mjs
  outline.png                      # 32x32, generated by scripts/build-icons.mjs
  tools/
    contoso-expense-tools.json     # mcpToolDescription target (REQUIRED for connectors)
  skills/
    expense-submission/
      SKILL.md                     # workflow: intent -> openExpenseForm (never interview)
      references/
        expense-categories.md      # loaded on demand, keeps SKILL.md lean
dist/cowork-plugin.zip             # the uploadable package

The skill is what makes the plugin feel native rather than just a bag of tools: it tells Cowork how to map a receipt onto the five categories, how to normalise amounts and dates, when to split a hotel folio into separate line items, and never to invent a value it cannot find. The connector supplies the tools the skill calls.

Step 1 — Point the manifest at your tunnel

The committed manifest ships a placeholder host, so the repo carries nobody's personal tunnel. Supply the real URL one of three ways — the packager checks all of them, in this precedence order, and never rewrites the file on disk unless you edit it yourself.

Option A — let the VS Code launch do it (recommended). Run the Run with Dev Tunnel profile. After the tunnel reports ready, the resolve-tunnel-url task queries devtunnel show, writes the live URL to .tunnel-url (git-ignored), and rebuilds dist/cowork-plugin.zip against that exact URL. So the zip on disk always matches the tunnel that is actually running — press F5, then upload.

To do the same without a debug session:

npm run tunnel:package   # resolve the URL and repackage
npm run tunnel:url       # resolve the URL only

package:plugin and check:connector also read .tunnel-url on their own, printing (from .tunnel-url).

Option B — pass it at build time, which overrides .tunnel-url:

$env:MCP_SERVER_URL = "https://<id>-3000.<region>.devtunnels.ms/mcp"
npm run package:plugin
npm run check:connector

Option C — edit the manifest. In cowork-plugin/manifest.json, set mcpServerUrl to your tunnel URL plus /mcp, and put the same host in validDomains:

"validDomains": ["<id>-3000.<region>.devtunnels.ms"],
...
"remoteMcpServer": {
  "mcpServerUrl": "https://<id>-3000.<region>.devtunnels.ms/mcp",
  "mcpToolDescription": { "file": "tools/contoso-expense-tools.json" },
  "authorization": { "type": "None" }
}

Options A and B rewrite mcpServerUrl and validDomains in the manifest copy written into the zip. The tracked file keeps its placeholder, so git status stays clean.

Both hosts must agree. Cowork blocks requests to hosts the manifest does not declare, so a validDomains mismatch produces a connector that installs cleanly and then never responds. The packager checks this for you.

Do not write ./tools/.... The package service compares the declared path against zip entry names literally, and those names carry no ./ prefix — so a leading ./ fails the upload with 400 InvalidAgentConnector: ... not found in the app package, even though the official docs write it that way. Use the bare form (tools/contoso-expense-tools.json), which matches either way. npm run package:plugin rejects the ./ spelling for you.

Step 2 — Build the package

npm run package:plugin

This generates the icons, validates the package, and writes dist/cowork-plugin.zip. Validation fails the build on the mistakes the platform rejects at upload time — you find out in a second rather than after a round trip:

  • a leading ./ on any package path

  • a missing mcpToolDescription, or one pointing at a file not in the zip (HTTP 400)

  • a SKILL.md name that does not match its folder (ASKILL-P006)

  • a name that is not kebab-case (ASKILL-P007)

  • a non-HTTPS mcpServerUrl, an unreplaced placeholder, or a host missing from validDomains

  • referenceId misuse for the declared authorization type

  • companion-file count and size limits

  • tools declared without MCP annotations

Step 3 — Verify the connector is reachable

npm run check:connector

This runs initializenotifications/initializedtools/list against the exact mcpServerUrl in the manifest (or MCP_SERVER_URL, if set), asserts every tool declared in tools/contoso-expense-tools.json is really served with matching annotations, and verifies the MCP apps widget contract (see §8).

Run it before every upload — a dead tunnel is the most common reason a plugin installs successfully and then does nothing.

Step 4 — Install it

Sideload for yourself:

npm install -g @microsoft/m365agentstoolkit-cli
atk auth login
atk install --file-path ".\dist\cowork-plugin.zip" --scope Personal

A successful install prints a TitleId and AppId — keep them for later updates or uninstall.

Or roll out to the whole tenant: M365 admin centerManage appsUpload custom app, then Add agent.

Either way, the plugin then appears under CoworkSources & SkillsPlugins.

Step 5 — Try it

"I need to submit an expense"

The skill triggers, Cowork calls openExpenseForm, and the widget mounts inline — immediately, with no questions asked first. Fill it in and click Submit expense; that call goes from the widget straight back to your server.

Also worth demoing:

  • "Expense my 42 EUR taxi from yesterday" → the same form, arriving prefilled with amount, currency, category and date, leaving only the merchant to type.

  • "What expenses have I submitted?"listExpenses, runs with no confirmation prompt because it is annotated readOnlyHint: true.

  • "Expense this receipt" with a receipt attached → the skill reads it and prefills the form.

Opening the form is also prompt-free, since openExpenseForm is readOnlyHint: true — it records nothing. The confirmation, if any, belongs on the submit.

Iterating

Server-side changes (tool logic, widget HTML) need no re-upload — Cowork reads them live from your server:

npm run build
npm start

Re-package and re-install only when the package changes: the manifest, the skill, or the tool-description JSON. A new tunnel URL counts as a manifest change.

Moving to production

  • Replace the placeholder icons. scripts/build-icons.mjs draws a generic receipt glyph.

  • Replace the None authorization. Anonymous access is fine for a dev tunnel, not for a store submission. Switch to OAuthPluginVault with a referenceId from the OAuth registration in the Teams Developer Portal When registering the OAuth client, set usage to Any Microsoft 365 Organization so the plugin works across tenants.

  • Update the developer URLs and the id GUID in manifest.json. Keep the GUID stable across versions.

  • The widget DOES render in Cowork. Cowork implements the MCP Apps extension (SEP-1865) and mounts the ui:// widget in a sandboxed iframe. See MCP apps plugin author guide for Cowork and the conformance notes in section 8 below.

8. MCP apps (widget) conformance in Cowork

Cowork renders the widget inline via the MCP Apps extension (SEP-1865). npm run check:connector verifies this contract against the live server, since these rules live in the server's runtime responses rather than in the .zip.

Guideline

Status

Tool declares _meta.ui.resourceUri with a ui:// URI (≤ 1024 chars)

ui://widget/expense-form.html

Tool handler returns data, not HTML

Returns content + structuredContent

Resource serves HTML as text with text/html;profile=mcp-app

Yes — text, not a base64 blob

Inline tool result under 64 KiB

~590 bytes; recent is capped at 50 rows

visibility includes "app" for widget-initiated calls

["model", "app"], or ["app"] for the widget-only submitExpense

Self-contained HTML (no external assets)

Widget + SDK inlined by build-widget.mjs

Graceful degradation when no widget renders

Every result carries a text summary

Avoids unsupported ui/open-link, ui/update-model-context, pip

None are used


Form-first design

By default an MCP widget arrives too late to collect anything. It renders as a tool result, so the agent must already have called the tool — and it cannot call a tool without filling in that tool's required arguments. A single submitExpense(merchant, amount, category, date) therefore produces exactly the interview you were trying to avoid:

You: I need to submit an expense Agent: Sure — what was the merchant? … And the amount? … And the date? (only now does the form appear, asking for what you just typed)

This server splits that in two:

Tool

Required args

Visibility

Role

openExpenseForm

none

model, app

The agent's entry point. Callable the instant you express intent, so the form is the first thing you see.

submitExpense

merchant, amount, category, date

app only

Records the expense. The widget's Submit button calls it.

listExpenses

none

model, app

Read-only lookup.

Two details make it hold:

  • openExpenseForm requires nothing. The agent can call it immediately. Anything you did mention ("my 42 EUR taxi") is passed as optional prefill and lands in the form fields, so volunteering details still speeds things up — it just never becomes a prerequisite. It is readOnlyHint: true, so it records nothing and hosts do not gate it behind a confirmation prompt.

  • submitExpense is hidden from the model via visibility: ["app"]. This is what makes the behavior reliable rather than merely likely: a model-visible write tool is an ever-present temptation for the agent to gather arguments in chat, no matter what the prompt says. Hiding it leaves openExpenseForm as the only door in. The widget can still call it, because widget-initiated calls are authorized by "app".

The SKILL.md reinforces this in prose ("do not ask for details, open the form"), but the schema is what enforces it.

To allow one-shot submissions again — the agent recording an expense directly when you give it every detail — give submitExpense back visibility: ["model", "app"] in src/server.ts (use UI_META instead of UI_META_APP_ONLY). Expect the agent to sometimes prefer interviewing you over opening the form.


Scripts

Script

What it does

npm run build

Bundles the widget into dist/widget.html, then compiles TypeScript

npm start

Runs the MCP server from dist/

npm run build:widget

Rebuilds only the widget bundle

npm run build:icons

Regenerates the Cowork package icons

npm run package:plugin

Validates and zips the Cowork plugin → dist/cowork-plugin.zip

npm run check:connector

Live conformance check against the manifest's mcpServerUrl

npm run tunnel:url

Resolves the expense-mcp tunnel URL into .tunnel-url

npm run tunnel:package

Same, then rebuilds dist/cowork-plugin.zip against that URL

package:plugin and check:connector resolve the MCP server URL in this order: MCP_SERVER_URL → the git-ignored .tunnel-url file → whatever the manifest holds. The first two override mcpServerUrl and validDomains without touching the tracked file. See §7 Step 1.


Project layout

expense-mcp-app/
  package.json        # scripts: build, package:plugin, check:connector, start
  tsconfig.json       # ES2022, NodeNext, strict, outDir dist
  src/
    server.ts         # MCP server: Streamable HTTP, openExpenseForm + submitExpense + listExpenses, widget resource
    widget.ts         # widget logic against the MCP Apps SDK
    widget.html       # single-file inline widget (HTML + CSS + JS), served as the UI resource
  scripts/
    build-widget.mjs      # bundles the widget into dist/
    build-icons.mjs       # generates the package icons (placeholders)
    package-plugin.mjs    # validates + zips the Cowork plugin package
    check-connector.mjs   # live tools/list smoke test against the manifest URL
  cowork-plugin/      # Cowork plugin sources (manifest, icons, tools, skills)
  dist/               # build output: server, widget, cowork-plugin.zip

Maintenance

ActivityMaintained
ResponsivenessSyncing

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    F
    maintenance
    A minimal server/client application implementation utilizing the Model Context Protocol (MCP) and Azure OpenAI.
    34
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    A Model Context Protocol (MCP) server that provides joke-fetching capabilities, demonstrating how to deploy an MCP Server and integrate it with Microsoft Copilot Studio.
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    A minimal Model Context Protocol (MCP) server that uses streamable HTTP transport to provide demo tools for calculations, notes, and time. It serves as a standalone example for testing MCP connectivity and gateway registration through a standard HTTP endpoint.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/qmatteoq/expense-mcp-app'

If you have feedback or need assistance with the MCP directory API, please join our Discord server