Architecture
Explanation
One engine, two front ends
Section titled “One engine, two front ends”src/auth/sso-login.ts Playwright state machine for the identity-provider handshakesrc/auth/session-manager.ts cached → silent → credentials → interactive; single-flight, profile locksrc/auth/browser.ts persistent Chromium profile, first-run browser installsrc/auth/login-flow.ts step-wise wrapper (start / submitOtp / wait) for the MCP login toolssrc/auth/session-store.ts session.json (0600) and Cookie header buildingsrc/sap/client.ts cookie-authenticated HTTP client: CSRF token, OData errors, expiry detectionsrc/sap/odata.ts OData v2 helpers ($filter, dates, $batch)src/timesheet/standard.ts Standard timesheet domain clientsrc/timesheet/multiproject.ts Multiproject domain client (+ lock.ts)src/cli/main.ts Commander CLI; runCli() is testable in-processsrc/cli/timesheet-commands.ts std and mp commandssrc/mcp/server.ts MCP tools (createMcpServer()); run.ts is the stdio entrysrc/config.ts environment and flag resolutionThe CLI and the MCP server are thin: both resolve the configuration, obtain a session from the SessionManager, build a SapClient, and call the same domain classes. A CLI command and its MCP tool therefore return the same structure (--json on the CLI shows it). The one deliberate difference is who may open a sign-in window: the CLI’s sso command and the MCP sso_login tool, nothing else.
The SAP client
Section titled “The SAP client”SapClient sends the stored cookies, appends sap-language and sap-client when configured, fetches a CSRF token before mutations and retries once when SAP answers 403 with x-csrf-token: Required. It decides that a session is gone by outcome, not by cookie expiry: a redirect to the identity provider, a 401, or an HTML body that looks like a login page all raise SessionExpiredError, which the session manager turns into a silent renewal. Reads are retried once after that; writes are not, to avoid double-applying a half-finished batch.
The MCP server
Section titled “The MCP server”createMcpServer() registers the tools with zod input schemas (the same definitions the MCP tools reference describes) and a short instruction text that tells assistants the intended workflow. Stdout is the JSON-RPC channel, so everything else, including Playwright’s browser-download progress bar, is routed to stderr. A test drives the bundled entry point over raw stdio and asserts that stdout contains only well-formed JSON-RPC.
The MCPB bundle
Section titled “The MCPB bundle”scripts/build-mcpb.mjs produces the Claude Desktop extension:
- esbuild bundles
src/mcp/run.tsinto one ESM file,server/index.js, with everything inlined except Playwright. - A
package.jsonpinning the exact Playwright version is written next to it andnpm install --omit=devruns there, so the bundle carries a real, flatnode_modulesfor Playwright (it spawns its own CLI and cannot be inlined).PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1keeps the 150 MB browser out of the archive. manifest.json,icon.png,LICENSEand.mcpbignoreare copied.mcpb validate, thenmcpb pack.
The manifest declares the settings form (launchpad URL, optional email and password, SAP client, language) and maps each field to an environment variable. Claude Desktop expands every field, so a blank one arrives as an empty string; the configuration code treats blank and unset alike. manifest.json, package.json and src/version.ts must carry the same version, and the tool list in the manifest must equal what the server registers; a test enforces both.
The bundle is platform-independent (plain JavaScript plus the pure-JavaScript Playwright package), so CI builds it once on Linux.
CI and releases
Section titled “CI and releases”.github/workflows/ci.yml runs type-checking and the unit tests on Ubuntu, macOS and Windows (the sign-in tests drive a real headless Chromium), builds the bundle, and on a push to main that changes package.json’s version creates a GitHub release with the .mcpb attached and publishes the package to npm with provenance. Shipping a version is therefore a version bump in the three files, a commit and a push.
Development is test-driven; every feature starts with a failing test. Two fixtures make that possible without any real credentials:
test/fixtures/fake-idp.ts: a small HTTP server that reproduces the Entra ID pages and element ids (loginfmt,passwd,otc,idSIButton9,idRichContext_DisplaySign, …) and a fake launchpad that sets cookies once the handshake completes.test/fixtures/fake-xflow.ts: an in-memory imitation of the three SAP services with realistic shapes, used by the domain, CLI and MCP tests.
pnpm test # everythingpnpm test:unit # without the end-to-end tests that need a real systempnpm typechecktest/e2e/real-xflow.test.ts runs against the real portal when a session is available and is excluded from CI.