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
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. The format
is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.0-alpha.1]

### Added

- **Per-instance data directory.** New `--data-dir <path>` flag and `SPE_DATA_DIR`
environment variable select where the provisioning `state.json` and MSAL token
cache are stored (precedence: flag > env > default `~/.spe-mcp`). Point each
server instance at a unique directory to run multiple instances (e.g. two
tenants, or a published build alongside a local build) without clobbering
shared state. Applies uniformly to `start`, `auth`, and `logout`. The default
path is unchanged and byte-identical to prior releases.

### Security

- **Fail-closed credential/state file handling.** The data directory and token
cache files are now validated fail-closed: a symlinked, foreign-owned, or
group/other-accessible directory is refused (POSIX `0o700`); an off-`%USERPROFILE%`
Windows override is given an owner-only DACL or refused. Reads and writes use
`O_NOFOLLOW` + `fstat` fd verification and `fchmod` the descriptor (never the
path) to defeat symlink/TOCTOU swaps. A caller-supplied `--data-dir` must be an
absolute (or `~/`-relative) path; CWD-relative paths are rejected so credentials
can never be written into a working directory. On an insecure/unverifiable
target, refresh-token persistence is skipped (forcing a fresh interactive
sign-in) rather than writing a token to an unsafe location.

## [0.1.0]

Initial release.
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ The server accepts configuration via CLI flags or environment variables:
| `--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 |
| `--data-dir` | `SPE_DATA_DIR` | Directory for the token cache + provisioning state (default `~/.spe-mcp`). Point each instance at a unique **absolute** path (or `~/...`; CWD-relative paths are rejected) to run multiple servers without clobbering state |

> The CLI flag wins when both a flag and its env var are set. Run
> `spe-mcp start --help` to see the authoritative option list and descriptions.
Expand Down Expand Up @@ -293,7 +294,31 @@ For most developers nothing extra is needed: create the owning app with the `pro

### Token Storage

Tokens are cached under `~/.spe-mcp/` in per-identity files named `token-cache.<tenantId>.<clientId>.json` (a legacy `token-cache.json` may also exist). Each file contains MSAL's serialized token cache (refresh tokens, account info). On macOS/Linux the cache directory is created `0700` and the cache files `0600` (owner read/write only); on Windows the files are protected by the per-user profile ACL.
Tokens are cached under the **data directory** (default `~/.spe-mcp/`, or a `--data-dir` / `SPE_DATA_DIR` override) in per-identity files named `token-cache.<tenantId>.<clientId>.json` (a legacy `token-cache.json` may also exist). Each file contains MSAL's serialized token cache (refresh tokens, account info). On macOS/Linux the cache directory is created `0700` and the cache files `0600` (owner read/write only), and the server fails closed if the directory is a symlink, owned by another user, or group/other-accessible; on Windows the files are protected by the per-user profile ACL (an off-profile `--data-dir` override is given an owner-only DACL, or refused).

### Running multiple instances (isolating state)

The data directory holds a single provisioning `state.json` plus the token cache, so two servers pointed at the **same** directory can clobber each other's state. To run more than one instance (e.g. two tenants, or a published build alongside a local build), give each its own `--data-dir` / `SPE_DATA_DIR`:

```jsonc
// .vscode/mcp.json — two isolated instances
{
"servers": {
"spe-tenantA": {
"command": "npx",
"args": ["-y", "@microsoft/spe-mcp-server", "start"],
"env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantA", "SPE_TENANT_ID": "<tenant-A>" }
},
"spe-tenantB": {
"command": "npx",
"args": ["-y", "@microsoft/spe-mcp-server", "start"],
"env": { "SPE_DATA_DIR": "~/.spe-mcp-tenantB", "SPE_TENANT_ID": "<tenant-B>" }
}
}
}
```

The path must be **absolute** (a leading `~/` is expanded against your home directory); CWD-relative paths are rejected so credentials can never be written into a working directory. The same value must be used for `start`, `auth`, and `logout` of a given instance — set it once via `SPE_DATA_DIR` (as above) and all three commands agree.

### Full Local Auth Reset

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@microsoft/spe-mcp-server",
"version": "0.1.0-alpha.1",
"version": "0.2.0-alpha.1",
"description": "SharePoint Embedded MCP Server — manage container types, containers, and content via any MCP client",
"keywords": [
"mcp",
Expand Down
36 changes: 15 additions & 21 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@
* prior tenant from interfering on a tenant switch.
*/

import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join } from "node:path";
import { ensureSecureDir, writeSecureFile } from "./secure-fs.js";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import { getCacheDir, getCacheFile, getLegacyCacheFile } from "./paths.js";
import { ensureSecureDir, readSecureFile, writeSecureFile } from "./secure-fs.js";
import {
type AccountInfo,
type AuthenticationResult,
Expand Down Expand Up @@ -271,15 +271,9 @@ function logError(message: string, data?: unknown): void {
// different tenants never co-mingle, which is the root cause of the
// tenant-switch silent-auth failures and wrong-tenant-token hazard.

const CACHE_DIR = join(homedir(), ".spe-mcp");
// Legacy single-file cache used before per-tenant partitioning. Kept only so
// that clearCachedToken() can clean it up; it is never read on the hot path.
const LEGACY_CACHE_FILE = join(CACHE_DIR, "token-cache.json");

/** Make a value safe to embed in a filename (GUIDs are already safe; be defensive). */
function sanitizeForFilename(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
}
// The token-cache directory + partitioned file paths come from the resolve-once
// seam in paths.ts, so they honor a --data-dir / SPE_DATA_DIR override. Legacy
// single-file cache (pre-partitioning) is only cleaned up by clearCachedToken().

/**
* Derive the token-cache file path for a given auth config. Partitioned by
Expand All @@ -291,11 +285,9 @@ function sanitizeForFilename(value: string): string {
*/
export function getCacheFilePath(config: AuthConfig | null = authConfig): string {
if (config?.tenantId) {
const tenant = sanitizeForFilename(config.tenantId);
const client = config.clientId ? sanitizeForFilename(config.clientId) : "default";
return join(CACHE_DIR, `token-cache.${tenant}.${client}.json`);
return getCacheFile(config.tenantId, config.clientId ?? "default");
}
return LEGACY_CACHE_FILE;
return getLegacyCacheFile();
}

/**
Expand All @@ -316,8 +308,10 @@ const fileCachePlugin: ICachePlugin = {
beforeCacheAccess: async (cacheContext) => {
const cacheFile = getCacheFilePath();
try {
if (existsSync(cacheFile)) {
const cached = readFileSync(cacheFile, "utf-8");
// O_NOFOLLOW + owner check: a planted symlink is refused (throws) rather
// than followed; a missing cache returns null and forces a fresh sign-in.
const cached = readSecureFile(cacheFile);
if (cached !== null) {
cacheContext.tokenCache.deserialize(cached);
log(`Token cache loaded from file (${basename(cacheFile)})`);
}
Expand All @@ -329,7 +323,7 @@ const fileCachePlugin: ICachePlugin = {
if (cacheContext.cacheHasChanged) {
const cacheFile = getCacheFilePath();
try {
ensureSecureDir(CACHE_DIR);
ensureSecureDir(getCacheDir());
const serialized = cacheContext.tokenCache.serialize();
// SEC-003: token cache holds refresh tokens — owner-only (0o600).
writeSecureFile(cacheFile, serialized);
Expand Down Expand Up @@ -896,7 +890,7 @@ export async function clearCachedToken(): Promise<void> {
try {
log("Clearing cached tokens...");
const { unlinkSync } = await import("node:fs");
const filesToRemove = new Set([getCacheFilePath(), LEGACY_CACHE_FILE]);
const filesToRemove = new Set([getCacheFilePath(), getLegacyCacheFile()]);
for (const file of filesToRemove) {
try {
if (existsSync(file)) {
Expand Down
39 changes: 36 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ function isTruthyEnv(value: string | undefined): boolean {
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}

/** Shared `--data-dir` option description (start / auth / logout). */
const DATA_DIR_OPTION =
"Directory for the token cache + provisioning state (default ~/.spe-mcp). " +
"Point each instance at a unique path to run multiple servers without clobbering state. " +
"Must be absolute (or ~/...). Can also be set via SPE_DATA_DIR.";

/**
* Resolve the data directory from `--data-dir` (falling back to SPE_DATA_DIR),
* record it as the process-wide override BEFORE any state/auth module reads the
* location, propagate it via SPE_DATA_DIR for defense-in-depth, and log the
* resolved path to stderr only (path-only; stdout is the MCP JSON-RPC channel).
* Throws AppError on an invalid (e.g. CWD-relative) path — caught by each
* command's try/catch so the failure is loud, not silent.
*/
async function applyDataDir(dataDir?: string): Promise<void> {
const { setDataDirOverride } = await import("./paths.js");
const resolved = setDataDirOverride(dataDir || process.env.SPE_DATA_DIR);
process.env.SPE_DATA_DIR = resolved;
console.error(`[SPE MCP Server] Data directory: ${resolved}`);
}

const program = new Command();

program
Expand All @@ -54,8 +75,12 @@ program
"--tools <profileOrCsv>",
"Restrict exposed tools: a built-in profile (readOnly, docsOnly, provisioning, content, admin) or a comma-separated list of tool names. Can also be set via SPE_TOOLS.",
)
.action(async (options: { clientId?: string; tenantId?: string; readOnly?: boolean; tools?: string }) => {
.option("--data-dir <path>", DATA_DIR_OPTION)
.action(async (options: { clientId?: string; tenantId?: string; readOnly?: boolean; tools?: string; dataDir?: string }) => {
try {
// Resolve + record the data dir FIRST, before importing ./index.js (which
// pulls in state.ts/auth.ts) so every entry point resolves the same dir.
await applyDataDir(options.dataDir);
const clientId = options.clientId || process.env.SPE_CLIENT_ID;
const tenantId = options.tenantId || process.env.SPE_TENANT_ID;
// Read-only: CLI flag wins; otherwise a truthy SPE_READ_ONLY env value.
Expand Down Expand Up @@ -85,8 +110,12 @@ program
.option("--client-id <id>", "Entra ID Application (Client) ID. Can also be set via SPE_CLIENT_ID env var.")
.option("--tenant-id <id>", "Entra ID Tenant ID. Can also be set via SPE_TENANT_ID env var.")
.option("--reset", "Clear any cached tokens for this tenant before authenticating (useful when switching tenants).")
.action(async (options: { clientId?: string; tenantId?: string; reset?: boolean }) => {
.option("--data-dir <path>", DATA_DIR_OPTION)
.action(async (options: { clientId?: string; tenantId?: string; reset?: boolean; dataDir?: string }) => {
try {
// Resolve + record the data dir FIRST so auth caches tokens to the SAME
// directory `start` will later read from (else silent "not authenticated").
await applyDataDir(options.dataDir);
const clientId = options.clientId || process.env.SPE_CLIENT_ID;
const tenantId = options.tenantId || process.env.SPE_TENANT_ID;

Expand Down Expand Up @@ -120,8 +149,12 @@ program
program
.command("logout")
.description("Clear cached authentication tokens")
.action(async () => {
.option("--data-dir <path>", DATA_DIR_OPTION)
.action(async (options: { dataDir?: string }) => {
try {
// Resolve + record the data dir FIRST so logout clears tokens from the
// SAME directory the matching `auth`/`start` used.
await applyDataDir(options.dataDir);
const { clearCachedToken } = await import("./auth.js");
await clearCachedToken();
console.log("Logged out. Cached tokens have been cleared.");
Expand Down
134 changes: 134 additions & 0 deletions src/paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, normalize } from "node:path";
import {
resolveDataDir,
setDataDirOverride,
getDataDir,
getStateFile,
getCacheDir,
getCacheFile,
getLegacyCacheFile,
sanitizeForFilename,
__testing,
} from "./paths.js";

const DEFAULT = normalize(join(homedir(), ".spe-mcp"));

describe("paths — data directory resolution", () => {
const savedEnv = process.env.SPE_DATA_DIR;

beforeEach(() => {
// Each test starts from a clean slate: no memoized dir, no env override.
__testing.reset();
delete process.env.SPE_DATA_DIR;
});

afterEach(() => {
__testing.reset();
if (savedEnv === undefined) delete process.env.SPE_DATA_DIR;
else process.env.SPE_DATA_DIR = savedEnv;
});

it("defaults to ~/.spe-mcp (byte-identical to the legacy hardcoded path)", () => {
expect(getDataDir()).toBe(DEFAULT);
expect(getStateFile()).toBe(join(DEFAULT, "state.json"));
expect(getCacheDir()).toBe(DEFAULT);
});

it("resolveDataDir(undefined/empty/whitespace) returns the default", () => {
expect(resolveDataDir()).toBe(DEFAULT);
expect(resolveDataDir("")).toBe(DEFAULT);
expect(resolveDataDir(" ")).toBe(DEFAULT);
});

it("honors an explicit override (flag) via setDataDirOverride", () => {
const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-A-"));
try {
const resolved = setDataDirOverride(dir);
expect(resolved).toBe(normalize(dir));
expect(getDataDir()).toBe(normalize(dir));
expect(getStateFile()).toBe(join(normalize(dir), "state.json"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("honors SPE_DATA_DIR env when no explicit override is set", () => {
const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-E-"));
try {
process.env.SPE_DATA_DIR = dir;
expect(getDataDir()).toBe(normalize(dir));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("gives the explicit override precedence over the env var (flag > env)", () => {
const envDir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-env-"));
const flagDir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-flag-"));
try {
process.env.SPE_DATA_DIR = envDir;
// Mirror the CLI's `options.dataDir || process.env.SPE_DATA_DIR`.
setDataDirOverride(flagDir || process.env.SPE_DATA_DIR);
expect(getDataDir()).toBe(normalize(flagDir));
} finally {
rmSync(envDir, { recursive: true, force: true });
rmSync(flagDir, { recursive: true, force: true });
}
});

it("expands a leading ~ against the home directory", () => {
expect(resolveDataDir("~")).toBe(normalize(homedir()));
expect(resolveDataDir("~/spe-alt")).toBe(normalize(join(homedir(), "spe-alt")));
});

it("rejects a CWD-relative path so secrets never land in the working directory", () => {
expect(() => resolveDataDir("relative/dir")).toThrow(/absolute/i);
expect(() => resolveDataDir("./foo")).toThrow(/absolute/i);
expect(() => resolveDataDir("../foo")).toThrow(/absolute/i);
});

it("re-resolves lazily after a later override (no import-time freeze)", () => {
const a = mkdtempSync(join(tmpdir(), "spe-mcp-paths-lazy-a-"));
const b = mkdtempSync(join(tmpdir(), "spe-mcp-paths-lazy-b-"));
try {
setDataDirOverride(a);
expect(getDataDir()).toBe(normalize(a));
// A subsequent override wins and getters re-resolve to it.
setDataDirOverride(b);
expect(getDataDir()).toBe(normalize(b));
} finally {
rmSync(a, { recursive: true, force: true });
rmSync(b, { recursive: true, force: true });
}
});

it("partitions the token cache by tenant and client within the data dir", () => {
const dir = mkdtempSync(join(tmpdir(), "spe-mcp-paths-cache-"));
try {
setDataDirOverride(dir);
const a = getCacheFile("tenant-A", "client-1");
const b = getCacheFile("tenant-B", "client-1");
const c = getCacheFile("tenant-A", "client-2");
expect(a).not.toBe(b);
expect(a).not.toBe(c);
expect(a).toContain("tenant-A");
expect(a.startsWith(normalize(dir))).toBe(true);
expect(getLegacyCacheFile()).toBe(join(normalize(dir), "token-cache.json"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it("sanitizeForFilename replaces path-unsafe characters (keeps [A-Za-z0-9._-])", () => {
expect(sanitizeForFilename("abc-123_DEF.xyz")).toBe("abc-123_DEF.xyz");
expect(sanitizeForFilename("a/b\\c:d*e")).toBe("a_b_c_d_e");
// A traversal-looking value cannot introduce separators into the filename.
expect(sanitizeForFilename("../../etc/passwd")).toBe(".._.._etc_passwd");
});
});
Loading
Loading