Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ yarn-error.log*

# Cursor
.specstory
.claude/settings.local.json
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ $: npm run test
- [`@examples/lights`](./examples/lights/README.md)
- [`@examples/reactions`](./examples/reactions/README.md)
- [`@examples/tab`](./examples/tab/README.md)
- [`@examples/mcp`](./examples/mcp/README.md)
- [`@examples/mcp-server`](./examples/mcp-server/README.md)
- [`@examples/meetings`](./examples/meetings/README.md)

## Links
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @examples/mcp
# @examples/mcp-server

## 0.0.6

Expand Down
100 changes: 100 additions & 0 deletions examples/mcp-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Agent: MCP (Model Context Protocol) Server

A Teams bot that doubles as an MCP server, exposing human-in-the-loop tools
that let an MCP client (an agent, an IDE, etc.) reach a real user through
Teams and wait for them to reply or approve.

## Tools

| Tool | Purpose |
| ----------------- | ------------------------------------------------------------- |
| `notify` | Send a one-way message to a user. No response expected. |
| `ask` | Ask a user a question. Returns a `requestId`. |
| `getReply` | Poll for the reply to an earlier `ask`. |
| `requestApproval` | Send an Approve/Reject card to a user. Returns `approvalId`. |
| `getApproval` | Poll for the decision on an earlier `requestApproval`. |

## Layout

- `src/state.ts` — in-memory maps for conversations, pending asks, approvals.
- `src/app.ts` — the `App` instance and the Teams activity handlers
(`message`, `card.action`) that capture replies and approvals.
- `src/mcpTools.ts` — `McpServer` from `@modelcontextprotocol/sdk` plus the
five tool registrations that send to users and read/write shared state.
- `src/index.ts` — initializes the app, mounts a `StreamableHTTPServerTransport`
at `/mcp` on the Express adapter, and starts the server.

## Configure

Create a `.env` file:

```
CLIENT_ID=<your-azure-bot-app-id>
CLIENT_SECRET=<your-azure-bot-app-secret>
TENANT_ID=<your-tenant-id>
```

`TENANT_ID` is required because the MCP tools
open 1:1 conversations *proactively* via `app.api.conversations.create({
tenantId })`. There's no inbound activity to read the tenant from.

The `userId` argument passed to `notify`, `ask`, and `requestApproval` is the
Teams AAD user id of someone in the same tenant. For the simplest setup,
message the bot once with a real user, then read the user id off the first
`message` activity in the server log and use that.

## Run

```bash
npm run dev
```

## Run with the MCP Inspector

The inspector connects to the server over HTTP, so run both in separate
terminals. Terminal 1:

```bash
npm run dev
```

Terminal 2:

```bash
npm run inspect
```

In the inspector UI (opens in your browser), pick **Streamable HTTP** as the
transport and enter `http://localhost:3978/mcp` as the URL, then click
**Connect**.

## Example agent flow

1. Agent calls `requestApproval(userId, title, description)` → gets
`approvalId`.
2. The user sees an Approve/Reject card in Teams and clicks a button.
3. The `card.action` handler records the decision.
4. Agent polls `getApproval(approvalId)` until the status flips to
`approved` or `rejected`.

## Limitations

All state is in-memory. A server restart clears everything — pending asks
and approvals in flight will be lost.

**Only one outstanding `ask` per user.** The next message that user sends to
the bot is treated as the answer to their open ask. Calling `ask` for the
same user while a previous ask is still pending will overwrite the
correlation, and the user's reply will resolve whichever ask is current.

## Security

The `/mcp` endpoint is mounted **without authentication**. Anyone who can
reach the port can call the tools — which means they can DM arbitrary users
and mutate approval state on your behalf. This is fine for local dev (the
MCP Inspector connects from the same machine), but **do not expose `/mcp`
on the network as-is.**

Before deploying or making the port reachable from anywhere but localhost,
add an authentication check on `/mcp` — e.g. a bearer token / shared
secret in a header, or proper OAuth.
File renamed without changes.
14 changes: 8 additions & 6 deletions examples/mcp/package.json → examples/mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@examples/mcp",
"version": "0.0.6",
"name": "@examples/mcp-server",
"version": "0.0.0",
"private": true,
"license": "MIT",
"main": "dist/index",
Expand All @@ -16,22 +16,24 @@
"build": "npx tsc",
"start": "node -r dotenv/config .",
"dev": "tsx watch -r dotenv/config src/index.ts",
"inspect": "npx cross-env SERVER_PORT=9000 npx @modelcontextprotocol/inspector -e NODE_NO_WARNINGS=1 -e PORT=3978 node -r dotenv/config .",
"inspect": "npx cross-env SERVER_PORT=9000 npx @modelcontextprotocol/inspector",
"dev:teamsfx": "NODE_OPTIONS='--inspect=9239' npx env-cmd -f .env npm run dev",
"dev:teamsfx:testtool": "NODE_OPTIONS='--inspect=9239' npx env-cmd -f .env npm run dev",
"dev:teamsfx:launch-testtool": "npx env-cmd --silent -f env/.env.testtool teamsapptester start"
},
"dependencies": {
"@microsoft/teams.api": "*",
"@microsoft/teams.apps": "*",
"@microsoft/teams.cards": "*",
"@microsoft/teams.common": "*",
"@microsoft/teams.dev": "*",
"@microsoft/teams.mcp": "*",
"@modelcontextprotocol/sdk": "^1.25.2",
"express": "^5.0.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@microsoft/teams.config": "*",
"@modelcontextprotocol/inspector": "^0.16.5",
"@modelcontextprotocol/inspector": "^0.21.2",
"@types/express": "^5.0.0",
"@types/node": "^22.5.4",
"cross-env": "^7.0.3",
"dotenv": "^16.4.5",
Expand Down
64 changes: 64 additions & 0 deletions examples/mcp-server/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import express from 'express';

import { AdaptiveCardActionMessageResponse } from '@microsoft/teams.api';
import { App, ExpressAdapter } from '@microsoft/teams.apps';
import { ConsoleLogger } from '@microsoft/teams.common/logging';

import { state } from './state';

// Own the Express app so we can mount /mcp alongside /api/messages
// and manage the http.Server lifecycle in index.ts.
export const expressApp = express();

export const app = new App({
logger: new ConsoleLogger('@examples/mcp-server', { level: 'debug' }),
httpServerAdapter: new ExpressAdapter(expressApp),
});

app.on('message', async ({ activity, send }) => {
const userId = activity.from.id;
const conversationId = activity.conversation.id;

if (activity.conversation.conversationType === 'personal') {
// cache the personal conversation_id so MCP tools can DM this user later.
state.conversations.set(userId, conversationId);
}

// If this user has a pending ask, treat their next message as the answer.
// Only one outstanding ask per user is supported (see README Limitations).
const requestId = state.userPendingAsk.get(userId);
if (requestId && state.pendingAsks.has(requestId)) {
const entry = state.pendingAsks.get(requestId)!;
entry.reply = activity.text ?? '';
entry.status = 'answered';
state.userPendingAsk.delete(userId);
await send('Got it, thank you!');
return;
}

app.log.info(
`Received message from user ${userId} in conversation ${conversationId}, but no pending ask found.`
);
await send('Hi! Will let you know if I need anything.');
});

app.on('card.action.approval_response', async ({ activity }) => {
const { approval_id: approvalId, decision } = activity.value.action.data as {
approval_id?: string;
decision?: string;
};

if (
approvalId &&
state.approvals.has(approvalId) &&
(decision === 'approved' || decision === 'rejected')
) {
state.approvals.set(approvalId, decision);
}

return {
statusCode: 200,
type: 'application/vnd.microsoft.activity.message',
value: 'Response recorded',
} satisfies AdaptiveCardActionMessageResponse;
});
40 changes: 40 additions & 0 deletions examples/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { randomUUID } from 'crypto';
import http from 'http';

import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';


import { app, expressApp } from './app';
import { mcpServer } from './mcpTools';

const MCP_PATH = '/mcp';
const PORT = parseInt(process.env.PORT || '3978', 10);

async function main() {
// Initialize first so the teams.ts plugins register /api/messages on expressApp.
await app.initialize();

const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
await mcpServer.connect(transport);

const handle: express.RequestHandler = async (req, res) => {
await transport.handleRequest(req, res, req.body);
};
expressApp.post(MCP_PATH, express.json(), handle);
expressApp.get(MCP_PATH, handle);
expressApp.delete(MCP_PATH, handle);

// We manage the http server ourselves
const server = http.createServer(expressApp);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(PORT, () => resolve());
});

app.log.info(`listening on http://localhost:${PORT} (MCP at ${MCP_PATH})`);
}

main().catch(console.error);
Loading
Loading