microsoft / microsoft/teams.py

Support hosting the Teams endpoint inside a caller-owned ASGI app (sync route registration, host owns lifecycle)

Open
#447 1 comment 1 reaction 1 assignee View on GitHub

@heyitsaamir is already working on this.

Since Jun 3, 2026.

Dominant language
Python
Stars
65
Forks
30
Avg merge
1d 4h
Merged PRs (30d)
23

Description

## Summary

Please provide a **first-class, supported way to mount the Teams messaging endpoint onto an externally-owned ASGI app**, where the **host owns the server lifecycle** and route registration is available **synchronously** (decoupled from async plugin initialization).

Today the SDK is designed around the App owning (or at least driving) the HTTP server through an `HttpServerAdapter`. There is a "bring your own Starlette" pattern in `examples/http-adapters/src/starlette_adapter.py`, but it only works by calling the **async** `App.initialize()` to register routes, and the example adapter is not shipped in the package. That makes clean integration into a host that already owns the ASGI app, the port, and the startup/shutdown lifecycle surprisingly hard.

## Motivation / use case

We are building a multi-channel agent host (the Microsoft Agent Framework) where a single ASGI/Starlette application is owned by the host and serves many channels behind one public URL (Bot Service, Telegram, Discord, A2A, MCP, ...). Each channel *contributes* routes + startup/shutdown hooks; the host mounts them all and owns binding/serving.

To embed Teams there we want to:

1. Reuse the SDK's JWT validation, activity parsing, dispatch, and streaming (we don't want to reimplement Bot Framework auth).
2. **Not** let the SDK own the server — the host binds the port and runs uvicorn.
3. Obtain the registered routes **synchronously** at "contribution" time (before the event loop starts serving), because the host needs the route table up front, while async plugin `on_init` work must run later at startup.

## What we had to do today (and why it's painful)

Because route registration currently happens inside `HttpServer.initialize(...)` which is only invoked from the async `App.initialize()`, and because the only Starlette adapter is an example (not importable), we ended up:

- Writing a custom capturing `HttpServerAdapter` whose `register_route` just records `(method, path) -> handler`, and whose `serve_static` / `start` / `stop` are no-ops (host owns those).
- Calling the **private** `app.server.initialize(...)` ourselves to force synchronous route registration.
- Manually wiring `app.server.on_request = app._process_activity_event` (private) because that wiring normally happens inside `App.initialize()`, which we deliberately skip at route-collection time.
- Re-implementing the Starlette↔`HttpRequest`/`HttpResponse` translation, then calling the async `App.initialize()` later (at host startup) purely to run plugin `on_init` hooks — guarding against the SDK's private `_initialized` re-running the sync step.

This works but reaches into private internals (`server._adapter`, `server.initialize`, `_process_activity_event`, `_initialized`) and is fragile against minor SDK changes.

## Proposed API (illustrative)

A public, lifecycle-free integration seam. For example, any of:

- `app.register_routes() -> list[tuple[HttpMethod, str, HttpRouteHandler]]` — synchronously perform auth setup + route registration and return the route table, **without** owning a server and **without** running async plugin init.
- Split `App.initialize()` so that the synchronous "register routes / wire dispatcher" step is a separate, public, sync-callable method from the async plugin `on_init` step (e.g. `app.register_routes()` (sync) + `await app.start_plugins()` (async)).
- A **shipped** (not example-only) ASGI/Starlette adapter that supports "bring your own app, host owns lifecycle", with route registration callable without `await app.start()` and clearly separated from plugin init.

Concretely, the host wants something like:

```python
app = App(client_id=..., client_secret=..., messaging_endpoint="/teams/messages")
routes = app.register_routes() # sync: auth + messaging endpoint, NO server, NO async plugin init
host.mount(routes) # host owns the ASGI app + port
# ... at host startup:
await app.start_plugins() # async: plugin on_init only; never binds a port
```

## Acceptance criteria

- A documented, **non-private** way to obtain/register the Teams messaging route(s) onto a caller-owned ASGI app.
- Route registration callable **synchronously** (or at least separable from the async plugin-init step), so a host can build its route table before serving.
- The SDK never tries to bind a port / own start-stop in this mode.
- A shipped Starlette (and/or FastAPI) "bring your own app" adapter, so consumers don't copy the `examples/` adapter.

Happy to contribute a PR if the team is open to one of these shapes.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.