Skip to content
Open
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
36 changes: 18 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ The server exposes **40 tools**, plus an MCP **Prompt** (`provision_spe_app`) an
| Tool | Description |
|------|-------------|
| `status_get` | Signed-in identity (Azure CLI) + provisioning readiness |
| `project_app_create` | Create the owning Entra app (via az bootstrap token) |
| `project_app_create` | Create the owning Entra app (via Azure CLI control-plane token) |
| `project_provision` | One-call orchestrator: app → container type → (billing) → register → container |
| `container_type_create` / `container_type_register` / `container_create` | Individual provisioning steps |
| `container_type_list` / `container_list` / `container_get` / `container_type_get` | Read operations |
| `container_type_update` / `container_type_delete` | Update or delete a container type |
| `container_type_grant_owner` / `container_type_revoke_owner` / `container_type_owners_list` | Manage container-type owners (beta; enables PCA container creation) |
| `container_type_owner_grant` / `container_type_owner_delete` / `container_type_owners_list` | Manage container-type owners (beta; enables PCA container creation) |
| `container_type_app_grant_add` / `container_type_app_grant_remove` / `container_type_app_grants_list` | Manage application permission grants on a container type registration (authorize consuming apps; v1.0) |

**Billing**
Expand Down Expand Up @@ -96,7 +96,7 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or
}
```

> Bootstrap mode needs no app-specific environment variables; sign in once with
> Azure CLI token mode needs no app-specific environment variables; sign in once with
> `az login --allow-no-subscriptions`.

### Updating / removing
Expand All @@ -112,13 +112,13 @@ client config entry.

### Running modes

**Bootstrap mode (default, recommended for the standalone POC)** — no Microsoft
**Azure CLI token mode (default, recommended for the standalone POC)** — no Microsoft
app registration required. The server uses your **Azure CLI** session for the
control plane and provisions the owning app on demand.

- Install the [Azure CLI](https://aka.ms/install-azure-cli)
- Sign in once: `az login --allow-no-subscriptions` (the flag is required for M365-only tenants with no Azure subscription)
- Start the server with **no** `--client-id`
- Start the server with **no** `--owning-app-client-id`

> **Conditional Access / step-up authentication (standard billing).** Standard-billing
> provisioning performs Azure Resource Manager (ARM) writes — registering the
Expand Down Expand Up @@ -156,12 +156,12 @@ npm install
# 2. Build
npm run build

# 3a. Bootstrap mode — just sign into Azure CLI (no app needed)
# 3a. Azure CLI token mode — just sign into Azure CLI (no app needed)
az login --allow-no-subscriptions
npx @modelcontextprotocol/inspector node dist/cli.js start

# 3b. OR pre-provisioned-app mode — authenticate as an existing app (once)
node dist/cli.js auth --client-id YOUR_CLIENT_ID --tenant-id YOUR_TENANT_ID
node dist/cli.js auth --owning-app-client-id YOUR_CLIENT_ID --tenant-id YOUR_TENANT_ID

# 4. Test with MCP Inspector
npx @modelcontextprotocol/inspector node dist/cli.js start
Expand All @@ -177,7 +177,7 @@ The server accepts configuration via CLI flags or environment variables:

| CLI Flag | Env Var | Description |
|----------|---------|-------------|
| `--client-id` | `SPE_CLIENT_ID` | Entra ID Application (Client) ID |
| `--owning-app-client-id` | `SPE_CLIENT_ID` | Entra ID Application (Client) ID |
| `--tenant-id` | `SPE_TENANT_ID` | Entra ID Tenant ID |
| `--read-only` | `SPE_READ_ONLY` | Advertise/allow only read/list/get/search tools; reject mutating calls |
| `--tools` | `SPE_TOOLS` | Restrict exposed tools to a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated tool list |
Expand Down Expand Up @@ -235,7 +235,7 @@ To point an MCP client at a local source build instead:
```

> **`npx -y`** suppresses the install prompt so VS Code can launch the server
> non-interactively. Bootstrap mode needs no app, so you can drop the `env` block
> non-interactively. Azure CLI token mode needs no app, so you can drop the `env` block
> and just `az login --allow-no-subscriptions`.

## Usage with Claude Desktop
Expand All @@ -261,10 +261,10 @@ Add to `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/App

```bash
# Start the MCP server (stdio transport)
spe-mcp start [--client-id ID] [--tenant-id ID] [--read-only] [--tools <profileOrCsv>]
spe-mcp start [--owning-app-client-id ID] [--tenant-id ID] [--read-only] [--tools <profileOrCsv>]

# Authenticate interactively (cache tokens for headless use)
spe-mcp auth --client-id ID --tenant-id ID [--reset]
spe-mcp auth --owning-app-client-id ID --tenant-id ID [--reset]

# Clear cached tokens
spe-mcp logout
Expand All @@ -275,7 +275,7 @@ Every command has built-in help — run `spe-mcp <command> --help` (e.g.

| Flag | Description |
|------|-------------|
| `--client-id <id>` | Owning Entra app Client ID. Omit to run in bootstrap mode (Azure CLI control plane). |
| `--owning-app-client-id <id>` | Owning Entra app Client ID. Omit to run in Azure CLI token mode (Azure CLI control plane). |
| `--tenant-id <id>` | Entra ID Tenant ID. Discovered from the Azure CLI when omitted. |
| `--read-only` | Read-only mode: only read/list/get/search tools are exposed and callable. |
| `--tools <profileOrCsv>` | Tool allowlist: a profile (`readOnly`, `docsOnly`, `provisioning`, `content`, `admin`) or a comma-separated list of tool names. |
Expand All @@ -290,7 +290,7 @@ The server uses [MSAL](https://learn.microsoft.com/en-us/entra/identity-platform

For most developers nothing extra is needed: create the owning app with the `project_app_create` tool, then the first SPE call prompts a browser consent automatically.

**Automation / headless:** in CI (`CI=true`) or a Linux host with no display, interactive sign-in is disabled by default, and SPE operations return an actionable error. Pre-cache a token by running `spe-mcp auth --client-id <appId> --tenant-id <tenantId>` once in a terminal. Override the defaults with `SPE_INTERACTIVE=1` (force browser sign-in) or `SPE_NON_INTERACTIVE=1` (force off).
**Automation / headless:** in CI (`CI=true`) or a Linux host with no display, interactive sign-in is disabled by default, and SPE operations return an actionable error. Pre-cache a token by running `spe-mcp auth --owning-app-client-id <appId> --tenant-id <tenantId>` once in a terminal. Override the defaults with `SPE_INTERACTIVE=1` (force browser sign-in) or `SPE_NON_INTERACTIVE=1` (force off).

### Headless & orchestrator / sub-agent sign-in

Expand All @@ -305,7 +305,7 @@ Interactive sign-in is **enabled by default for local use** (the server can open

**Orchestrator / sub-agent / agent-team scenarios.** When the MCP server runs over stdio and is driven by a *calling* agent (an orchestrator spawning sub-agents), the sub-agent's terminal is usually **not visible** to the caller. The device-code prompt is printed to **stderr**, which the calling agent typically cannot see — so a device-code wait would block invisibly. To avoid that, the server only offers device code when its stderr prompt is on a real **TTY**; otherwise it **fails fast** with actionable guidance rather than hanging. Recommended pattern for headless/agent setups:

1. **Pre-authenticate before starting the server.** For the bootstrap / control-plane token, run `az login` (`--allow-no-subscriptions` for M365-only tenants). For the owning-app token, sign in once interactively in a **visible** terminal: `spe-mcp auth --client-id <appId> --tenant-id <tenantId>`.
1. **Pre-authenticate before starting the server.** For the Azure CLI token / control-plane token, run `az login` (`--allow-no-subscriptions` for M365-only tenants). For the owning-app token, sign in once interactively in a **visible** terminal: `spe-mcp auth --owning-app-client-id <appId> --tenant-id <tenantId>`.
2. **Restart the server after signing in** so it re-primes auth from the freshly cached token (startup auth is stamped for the session), then let the agent drive tool calls.

This keeps sub-agents non-blocking: they either use a pre-cached token silently or return a clear "sign in first" error instead of stalling on an invisible prompt.
Expand Down Expand Up @@ -349,7 +349,7 @@ az logout
Remove-Item "$HOME/.spe-mcp/state.json" -Force -ErrorAction SilentlyContinue
```

`spe-mcp logout` clears MSAL token cache files, while `state.json` stores persisted provisioning metadata used to prime bootstrap auth on startup.
`spe-mcp logout` clears MSAL token cache files, while `state.json` stores persisted provisioning metadata used to prime Azure CLI token auth on startup.

> **TODO:** Add OS keychain support via [keytar](https://github.com/nicktrav/keytar) as the primary cache, falling back to file cache. Keytar provides OS-managed encryption (Windows Credential Manager / macOS Keychain / Linux Secret Service) but hit data size limits with MSAL's multi-scope cache during initial testing.

Expand All @@ -360,7 +360,7 @@ src/
├── index.ts — MCP server: TOOLS registry, dispatch, transport, prompts/resources wiring
├── cli.ts — CLI entry point (start, auth, logout)
├── auth.ts — MSAL auth (silent → browser → device code)
├── bootstrap.ts — Azure CLI bootstrap (signed-in identity, az token)
├── azureCliToken.ts — Azure CLI Azure CLI token (signed-in identity, az token)
├── azure-cli.ts — az invocations (subscriptions, resource groups, RP registration)
├── graph-client.ts — Microsoft Graph client with retry + auth
├── docs-client.ts — Microsoft Learn MCP proxy (docs_search / docs_fetch)
Expand Down Expand Up @@ -486,8 +486,8 @@ npm run build

Set breakpoints in `src/` (e.g. a tool handler, `dispatch` in `index.ts`, or the
`catch` in `startServer`), then press **F5**. The "Debug SPE MCP server" config
starts a bootstrap-mode session (sign in first with
`az login --allow-no-subscriptions`); pass `--client-id`/`--tenant-id` in `args`
starts a Azure CLI token-mode session (sign in first with
`az login --allow-no-subscriptions`); pass `--owning-app-client-id`/`--tenant-id` in `args`
for pre-provisioned-app mode.

**3. Attach with `--inspect` (CLI, Chrome DevTools, or when an MCP client spawns
Expand Down
4 changes: 2 additions & 2 deletions docs/e2e-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ Use these natural-language prompts for MCP UX regression across clients.
- `container_list`: "List containers for this SPE container type."
- `container_get`: "Show details for this SPE container."
- `container_type_update`: "Rename this SPE container type."
- `container_type_grant_owner`: "Grant another app owner access to this container type."
- `container_type_owner_grant`: "Grant another app owner access to this container type."
- `container_type_owners_list`: "List owner grants on this container type."
- `container_type_revoke_owner`: "Revoke an owner grant from this container type."
- `container_type_owner_delete`: "Revoke an owner grant from this container type."
- `container_type_app_grant_add`: "Authorize a consuming app for this registered container type."
- `container_type_app_grants_list`: "List consuming app grants for this container type registration."
- `container_type_app_grant_remove`: "Remove a consuming app grant from this container type registration."
Expand Down
2 changes: 1 addition & 1 deletion src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ describe("owning-app precondition guidance (UX)", () => {
// 2. that it takes effect with no restart,
expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/no restart/i);
// 3. the alternative for an already-provisioned app — the CLI flags.
expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/--client-id/);
expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/--owning-app-client-id/);
expect(OWNING_APP_REQUIRED_MESSAGE).toMatch(/--tenant-id/);
});

Expand Down
10 changes: 5 additions & 5 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ function resetInMemoryAuthState(): void {
* Actionable message shown when a control-plane SPE operation is attempted
* before an owning Entra app is configured. SPE container-type / container /
* billing operations need a delegated token from an owning app that holds the
* SPE Graph permissions — the Azure CLI bootstrap token cannot carry those
* SPE Graph permissions — the Azure CLI control-plane token cannot carry those
* scopes. This message tells the agent/user exactly how to proceed.
*
* Once an owning app IS configured, the server acquires a delegated token AS
Expand All @@ -231,7 +231,7 @@ export const OWNING_APP_REQUIRED_MESSAGE =
"operations need an owning Entra app with the SPE Graph permissions. Run the " +
"`project_app_create` tool to create (or reuse) one — the server then signs in " +
"as that app automatically, no restart needed. Alternatively, start the server " +
"with `--client-id <appId> --tenant-id <tenantId>` for an existing owning app.";
"with `--owning-app-client-id <appId> --tenant-id <tenantId>` for an existing owning app.";

/**
* Whether a previously-provisioned or explicitly-configured owning SPE app
Expand Down Expand Up @@ -690,7 +690,7 @@ export function renderAuthErrorHtml(): string {
"<li>App registration or redirect URI misconfigured — confirm the app allows the local redirect used for interactive sign-in.</li>",
"</ul>",
"<p>If browser sign-in keeps failing, run " +
"<code>spe-mcp auth --client-id &lt;appId&gt; --tenant-id &lt;tenantId&gt;</code> in a terminal.</p>",
"<code>spe-mcp auth --owning-app-client-id &lt;appId&gt; --tenant-id &lt;tenantId&gt;</code> in a terminal.</p>",
"</body></html>",
].join("");
}
Expand Down Expand Up @@ -769,7 +769,7 @@ async function acquireTokenInteractiveWithFallbacks(): Promise<AuthenticationRes
{
safeMessage:
"No cached credentials and interactive sign-in is disabled (SPE_NON_INTERACTIVE). " +
"Run `spe-mcp auth --client-id <appId> --tenant-id <tenantId>` in a terminal to " +
"Run `spe-mcp auth --owning-app-client-id <appId> --tenant-id <tenantId>` in a terminal to " +
"pre-cache a token, then retry.",
suggestion: "Pre-cache a token with `spe-mcp auth`, or unset SPE_NON_INTERACTIVE.",
},
Expand All @@ -780,7 +780,7 @@ async function acquireTokenInteractiveWithFallbacks(): Promise<AuthenticationRes
safeMessage:
"Sign-in did not complete. A browser should have opened for you to consent to the SPE " +
"app — complete it and retry. If no browser opened (headless/remote), run " +
"`spe-mcp auth --client-id <appId> --tenant-id <tenantId>` in a terminal, then retry.",
"`spe-mcp auth --owning-app-client-id <appId> --tenant-id <tenantId>` in a terminal, then retry.",
suggestion: "Complete the browser consent and retry.",
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/az-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Shared Azure CLI error classification for Conditional Access (CA) /
* claims-challenge / interaction-required failures.
*
* Used by both the bootstrap token path (bootstrap.ts) and the ARM control-plane
* Used by both the Azure CLI token path (azure-cli-token.ts) and the ARM control-plane
* operations (azure-cli.ts: Syntex provider registration + Microsoft.Syntex
* billing-account create) so that a CA step-up failure surfaces a single,
* actionable remediation instead of a generic "command failed" or the plain
Expand Down
18 changes: 9 additions & 9 deletions src/bootstrap.test.ts → src/azure-cli-token.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Licensed under the MIT license.

/**
* Unit tests for the Azure CLI bootstrap module.
* Unit tests for the Azure CLI token module.
* `node:child_process.execFile` is mocked so these run offline.
*/

Expand All @@ -11,7 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("node:child_process", () => ({ execFile: vi.fn() }));

import { execFile } from "node:child_process";
import { assertAzCli, getSignedInIdentity, getBootstrapToken } from "./bootstrap.js";
import { assertAzCli, getSignedInIdentity, getAzureCliToken } from "./azure-cli-token.js";

type ExecCb = (err: Error | null, stdout: string, stderr: string) => void;

Expand Down Expand Up @@ -63,7 +63,7 @@ describe("getSignedInIdentity", () => {
});
});

describe("getBootstrapToken", () => {
describe("getAzureCliToken", () => {
it("returns an access token for Graph", async () => {
mockExec({
stdout: JSON.stringify({
Expand All @@ -72,15 +72,15 @@ describe("getBootstrapToken", () => {
tenantId: "tenant-123",
}),
});
const token = await getBootstrapToken();
const token = await getAzureCliToken();
expect(token.accessToken).toBe("tok-abc");
expect(token.tenantId).toBe("tenant-123");
expect(token.expiresOn).toBeInstanceOf(Date);
});

it("throws a friendly not-signed-in error", async () => {
mockExec({ error: new Error("Please run 'az login' to setup account.") });
await expect(getBootstrapToken()).rejects.toThrow(/not signed in/i);
await expect(getAzureCliToken()).rejects.toThrow(/not signed in/i);
});

it("throws an actionable Conditional Access step-up error (not the plain not-signed-in path)", async () => {
Expand All @@ -91,7 +91,7 @@ describe("getBootstrapToken", () => {
"multi-factor authentication to access the resource. Trace ID: ...",
),
});
const err = await getBootstrapToken().catch((e: unknown) => e as Error);
const err = await getAzureCliToken().catch((e: unknown) => e as Error);
expect(err.message).toMatch(/Conditional Access requires step-up authentication/i);
expect(err.message).toContain("az login --scope https://management.core.windows.net//.default --tenant");
// tenant cannot be resolved under the simulated CA failure, so a placeholder is used.
Expand All @@ -101,18 +101,18 @@ describe("getBootstrapToken", () => {

it("throws not-installed error on ENOENT", async () => {
mockExec({ error: new Error("spawn az ENOENT") });
await expect(getBootstrapToken()).rejects.toThrow(/not installed/i);
await expect(getAzureCliToken()).rejects.toThrow(/not installed/i);
});

it("throws when az returns no token", async () => {
mockExec({ stdout: JSON.stringify({ expiresOn: "x" }) });
await expect(getBootstrapToken()).rejects.toThrow(/no access token/i);
await expect(getAzureCliToken()).rejects.toThrow(/no access token/i);
});
});

describe("cross-platform az invocation", () => {
// `az` is a native binary on macOS/Linux but a `.cmd` shim on Windows that
// must be resolved through a shell. bootstrap.ts sets `shell: true` only on
// must be resolved through a shell. azure-cli-token.ts sets `shell: true` only on
// win32; this asserts the invocation adapts to the current platform so the
// command works on both Windows and Linux.
it("passes shell:true on Windows and falsy elsewhere", async () => {
Expand Down
Loading
Loading