Stromy Workflows MCP
Officialby stromy-org
README.md
# Stromy Workflows MCP
Hosted workflow discovery, validation, execution, and lifecycle facade for Stromy
Built with [FastMCP 3.0](https://gofastmcp.com) and managed with [uv](https://docs.astral.sh/uv/).
The facade is deliberately thin and client-agnostic: verified Entra app roles
scope every run; Stromy owns the Postgres schema and execution worker; this
service performs DML only, validates tiered contracts, and starts ACA Job
executions with server-controlled templates.
## Tools
- `list_workflows` (chooser's summary) / `describe_workflow` (tiered contract) /
`validate_config` (dry run: normalized config + the resolved run owner)
- `start_run` / `run_status` / `list_runs`
- `resume_run` / `retry_run` / `cancel_run` / `get_results`
- `fs_list` / `fs_read` for the hosted `wf-*` skills
Tier 1 keys are interview questions, tier 2 keys are caller-overridable
defaults, and tier 3 keys are provider-locked. Client tokens cannot see or set
tier 3. Run ownership comes only from verified `client.<slug>` roles; a
chat-supplied slug can narrow scope but never grant it. Ownership is never
inferred from a default — an operator may name any client but must name one,
because the runner derives the deliverable's brand from the owner.
Authorization has four layers, each answering a different question:
| Layer | Question | Where |
|---|---|---|
| Authentication | who are you? | Entra app roles, `scoping.resolve_scope` (default-deny) |
| Workflow entitlement | which workflows are yours? | `components/resources/entitlements.json` |
| Run tenancy | whose runs are these? | `service._require_run_scope` |
| Config tiering | which fields may you set? | `x-tier` in the contract |
Entitlement is default-deny: a workflow absent from the registry, or listed with
`"clients": []`, is operator-only. Granting one is a reviewed PR to this repo
followed by a redeploy — `components/` is baked into the image, so it is not a
live flag flip. Grant/revoke runbook: [`references/entitlements.md`](references/entitlements.md).
## Setup
```bash
uv sync
cp .env.example .env
```
## Run
```bash
# stdio (default)
uv run python -m stromy_workflows_mcp.server
# Or via the FastMCP CLI (reads fastmcp.json):
uv run fastmcp run
uv run fastmcp dev # with the Inspector UI
```
HTTP transport is enabled by default — the server listens on `http://127.0.0.1:8000/mcp/`.
## Project layout
```
src/stromy_workflows_mcp/server.py FastMCP server entrypoint (instance: `mcp`)
src/stromy_workflows_mcp/config.py Settings via pydantic-settings (reads .env)
components/
├── tools/ @tool functions, auto-discovered
├── resources/ @resource functions, auto-discovered
└── prompts/ @prompt functions, auto-discovered
skills/
└── server-guide/ Skill served via the fs_read/fs_list tools
components/resources/contracts/ Generated from Stromy's authored contracts
tests/ pytest + in-memory FastMCP Client
```
Components are loaded by `FileSystemProvider`. Drop a new `.py` file into any
subdirectory of `components/` with a standalone `@tool` / `@resource` /
`@prompt` decorator — no registration required. Set `MCP_DEV_MODE=true`
in `.env` to enable hot-reload during development.
### Skills
The `skills/` directory is served through the generic `fs_read` / `fs_list`
**tools** rather than MCP resources, so skills reach every client (the Claude
app, ChatGPT, etc.) — not only clients that surface resources. Discover skills
with `fs_list("skills")` and load one with `fs_read("skills/<name>/SKILL.md")`.
The tools are jailed to the configured `fs_roots` (default `["skills"]`); paths
that escape via `..`, absolute paths, or symlinks are rejected.
Drop a new folder into `skills/` with a `SKILL.md` — no registration needed.
## Tests
```bash
uv run pytest
uv run ruff check
uv run pyright
python3 scripts/sync_contracts.py --source-root ../../Stromy --check
```
`GET /health` is a readiness check, not a cosmetic liveness response. It returns
503 when Postgres is unreachable or `schema_meta.version` is outside the
facade's supported range. Deploy order is therefore Stromy migration first,
facade second.
## Use with Claude Code
The included `.mcp.json` registers this server as `stromy-workflows-mcp` for any
Claude Code session opened in this directory.
## OAuth (Microsoft Entra ID)
This server supports optional OAuth authentication via Microsoft Entra ID. When enabled, HTTP/SSE clients must authenticate via browser-based Azure login before accessing MCP tools. Stdio transport is unaffected.
### Azure App Registration
1. Go to **Azure Portal → App registrations → New registration**
2. Name: `stromy-workflows-mcp-oauth`
3. Supported account types: **Single tenant** (Accounts in this organizational directory only)
4. Redirect URI: **Web** → `http://localhost:8000/auth/callback` (update for production)
5. After creation, go to **Authentication** and ensure **Access tokens** and **ID tokens** are checked under "Implicit grant and hybrid flows"
#### Token version
Go to **Manifest** and set `"accessTokenAcceptedVersion": 2` (required by FastMCP's AzureProvider).
#### Expose an API
1. Go to **Expose an API** → Set Application ID URI (accept default `api://<client-id>`)
2. **Add a scope**: `mcp.access` — "Access MCP server" — Admins and users
#### Client secret
1. Go to **Certificates & secrets** → **New client secret**
2. Copy the **Value** (not the Secret ID) — this is `OAUTH_CLIENT_SECRET`
### Configuration
Fill in `.env`:
```bash
OAUTH_ENABLE=true
OAUTH_CLIENT_ID=<Application (client) ID from Overview>
OAUTH_CLIENT_SECRET=<Client secret Value>
OAUTH_TENANT_ID=<Directory (tenant) ID from Overview>
OAUTH_BASE_URL=http://localhost:8000
OAUTH_REQUIRED_SCOPES=mcp.access
```
### Production deployment on Azure Container Apps
Store secrets separately from plain env vars:
```bash
APP=stromy-workflows-mcp
RG=rg-stromy-workflows-mcp
az containerapp secret set --name $APP --resource-group $RG \
--secrets oauth-client-secret="<your-secret>"
az containerapp update --name $APP --resource-group $RG \
--set-env-vars \
OAUTH_ENABLE=true \
OAUTH_CLIENT_ID=<client-id> \
OAUTH_TENANT_ID=<tenant-id> \
OAUTH_BASE_URL=https://<your-app-fqdn> \
OAUTH_REQUIRED_SCOPES=mcp.access \
OAUTH_CLIENT_SECRET=secretref:oauth-client-secret
```
### Durable sessions
FastMCP keeps OAuth state — the JWT signing key, dynamically-registered clients, and
upstream tokens. The keys it needs (signing key, storage encryption key, storage path)
are **derived deterministically from the stable OAuth client secret**, so they already
survive restarts with no configuration. The **only** ephemeral piece is the store
*directory contents* on the container's local disk, which Azure Container Apps wipes on
every cold start / scale-to-zero — invalidating the tokens clients hold and forcing them
to re-authenticate.
The fix is **infrastructure, not app code**: a persistent **Azure Files** share mounted at
FastMCP's home path (`/home/appuser/.local/share/fastmcp`) makes the store dir survive
restarts. No Redis, no extra dependencies, no extra env vars, no extra secrets
(ORG-PLAN-073).
**On Azure this is provisioned automatically** when the server is registered with
`enable_oauth = true` — the generated terraform fragment carries `"oauth_sessions": "files"`,
which mounts the share. **BENIGN** under the cost policy (a per-use file share). Nothing to
configure by hand.
**Locally**, the default on-disk / in-memory store needs no configuration, so
`uv run pytest` and `fastmcp run` work out of the box.
## Deploy to Azure Container Apps
Production runs on [Azure Container Apps](https://learn.microsoft.com/en-us/azure/container-apps/) with scale-to-zero. CI/CD is handled by `.github/workflows/deploy-aca.yml` on every push to `main`.
See [`azure_aca/README.md`](azure_aca/README.md) for the full setup guide (automated script or manual commands).
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessUnresponsive