diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8bb022d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules/ +**/node_modules/ +dist/ +**/dist/ +build/ +**/build/ +npm-debug.log* +yarn-error.log* +pnpm-debug.log* +.git/ +.gitignore +.env +.env.* +coverage/ +Dockerfile +Dockerfile.* + diff --git a/.gitignore b/.gitignore index cfdb064..15b7c47 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ **/node_modules/ **/dist/ **/.driftcore/ +.codex/ +.specify/ +.docs/ +specs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..95cb093 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,200 @@ +# Agents and DriftCore + +This document describes how external agents should use DriftCore through MCP. + +DriftCore is not an agent runner. It does not handle planning, long term memory, or conversation orchestration. It provides: + +- Project aware context for a single Drupal codebase +- A small set of curated tools (Drush, Composer, etc.) +- Stable MCP resources and tools that agents can call + +Your agent runner (Claude, ChatGPT MCP, a custom framework, etc.) is responsible for how and when to call these tools. + +--- + +## 1. How agents should think about DriftCore + +You can think of DriftCore as a "project console" for a specific Drupal repo. Before an agent suggests code or commands, it should: + +1. Discover the project context +2. Inspect the current state with Drush and Composer tools +3. Use that information to ground any recommendations + +Agents should never assume: + +- Drupal core version +- Enabled modules or themes +- Folder layout or config paths + +They should always verify these through DriftCore first. + +--- + +## 2. Available resources (v0.1) + +### 2.1 `project_manifest` + +Use this to understand what project you are in. + +Typical fields (exact schema may evolve): + +- `drupal_root` +- `drupal_core_version` +- `project_type` (for example "drupal-recommended-project") +- `composer_json` (subset of composer.json relevant for agents) +- `custom_modules[]` with `name` and `path` +- `custom_themes[]` with `name` and `path` + +**Agent guidance:** + +- Call `project_manifest` at the start of any session dealing with a new project. +- Use the returned Drupal core version when suggesting APIs. +- Use module and theme paths when talking about files or code locations. + +--- + +## 3. Available tools (v0.1) + +All tools in v0.1 are read only. They should not modify the project. + +### 3.1 `drift.drush_status` + +Runs a fixed `drush status` and returns a structured summary. + +Use it to: + +- Confirm Drupal core version reported by Drush +- See PHP version, database driver, and site path +- Cross check environment assumptions + +### 3.2 `drift.drush_pml` + +Runs a fixed `drush pml` and returns enabled modules and themes in a structured form. + +Use it to: + +- See which core, contrib, and custom modules are enabled +- Check which themes are active +- Avoid recommending modules that are already present or enabled + +### 3.3 `drift.composer_info` + +Returns: + +- The project name from composer.json +- The full set of `require` dependencies +- Optional summary of the lock file + +Use it to: + +- Understand which Drupal packages are actually installed +- Check compatibility constraints before suggesting new packages + +### 3.4 `drift.composer_outdated` + +Runs `composer outdated` in a safe way and returns a parsed list of outdated packages. + +Use it to: + +- Identify modules and libraries that may need upgrades +- Prioritize which packages to discuss in an upgrade plan + +--- + +## 4. Agent patterns + +This section describes common patterns agents should follow. + +### 4.1 Project discovery pattern + +Goal: build a mental model of the project before suggesting changes. + +Recommended sequence: + +1. Call `project_manifest` +2. Call `drift.drush_status` +3. Call `drift.drush_pml` +4. Optionally call `drift.composer_info` + +Then: + +- Summarize: + - Drupal core version + - Notable modules and themes + - Any obvious characteristics from composer.json +- Only after this summary should you propose any plan or code. + +### 4.2 Upgrade assessment pattern + +Goal: help the user understand upgrade options, without running commands. + +Recommended sequence: + +1. `project_manifest` +2. `drift.composer_outdated` + +Then: + +- Group outdated packages into: + - Drupal core and related packages + - Key contrib modules + - Everything else +- Describe potential risks at a high level based on the stack +- Suggest a staged upgrade approach +- Do **not** suggest running composer commands directly in v0.1 + (writing commands as text for the user to run is fine, executing them is not) + +### 4.3 Feature planning pattern + +Goal: plan a new feature or custom module that fits project conventions. + +Recommended sequence: + +1. `project_manifest` +2. `drift.drush_pml` + +Then: + +- Check if a similar module or feature already exists +- If not, propose: + - Module name and purpose + - Directory path that matches existing custom modules + - High level list of components (services, plugins, config) + +Do **not** assume file structure that conflicts with the actual paths returned by `project_manifest`. + +--- + +## 5. Safety and constraints + +Rules for any agent using DriftCore v0.1: + +1. **Read only tools** + - All tools are inspection only in v0.1. + - Do not expect DriftCore to write files or run destructive operations. + - If you need to propose changes, describe them as text or patches for the human to apply. + +2. **Always ground recommendations in tool output** + - If you are about to say "This project runs Drupal X", first verify with `project_manifest` and `drift.drush_status`. + - If your earlier assumptions conflict with tool output, explicitly update your understanding and correct yourself. + +3. **Handle errors transparently** + - If a tool returns an error (missing Drush, Composer, invalid root), surface that to the user. + - Do not invent alternate commands or flags that DriftCore does not expose. + +4. **Do not guess paths** + - Use the module and theme paths from `project_manifest`. + - If a module is not listed, assume it does not exist until the user confirms otherwise. + +--- + +## 6. Example agent persona + +Here is an example of how you might configure an ag + +## Active Technologies +- TypeScript 5.x targeting Node.js 20 LTS + Node.js standard library (`http`, `readline`), `yargs` for CLI argument parsing, TypeScript toolchain (001-driftcore-1-single) +- N/A (no persistent storage; configuration via JSON file only) (001-driftcore-1-single) + +## Recent Changes +- 001-driftcore-1-single: Added TypeScript 5.x targeting Node.js 20 LTS + Node.js standard library (`http`, `readline`), `yargs` for CLI argument parsing, TypeScript toolchain diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json deleted file mode 100644 index f160a88..0000000 --- a/apps/vscode-extension/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "driftcore-vscode-extension", - "displayName": "DriftCore Tools", - "version": "0.0.1", - "engines": { - "vscode": "^1.85.0" - }, - "publisher": "driftcore", - "activationEvents": ["onCommand:driftcore.hello"], - "main": "./out/extension.js", - "contributes": { - "commands": [ - { - "command": "driftcore.hello", - "title": "DriftCore: Hello" - } - ] - } -} diff --git a/apps/vscode-extension/src/extension.ts b/apps/vscode-extension/src/extension.ts deleted file mode 100644 index 6047a1e..0000000 --- a/apps/vscode-extension/src/extension.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as vscode from "vscode"; - -export function activate(context: vscode.ExtensionContext) { - const disposable = vscode.commands.registerCommand("driftcore.hello", () => { - vscode.window.showInformationMessage("DriftCore extension placeholder"); - }); - - context.subscriptions.push(disposable); -} - -export function deactivate() { - // TODO: Clean up resources and MCP connections when the extension unloads. -} diff --git a/apps/vscode-extension/tsconfig.json b/apps/vscode-extension/tsconfig.json deleted file mode 100644 index d613bdf..0000000 --- a/apps/vscode-extension/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "module": "commonjs", - "target": "ES2020", - "outDir": "out", - "lib": ["ES2020"], - "rootDir": "src", - "strict": true, - "esModuleInterop": true - }, - "include": ["src/**/*"] -} diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index ad7558e..0000000 --- a/examples/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# DriftCore Examples - -This directory hosts runnable examples demonstrating the MCP server, agent runner, and Drupal integrations. - -## Available Examples - -- [Drupal 11 Sandbox](./drupal-sandbox/README.md) – Docker Compose environment that provisions Drupal, MariaDB, and pre-seeded configuration matching the MCP resources exposed by `@driftcore/server`. diff --git a/examples/drupal-sandbox/Dockerfile b/examples/drupal-sandbox/Dockerfile deleted file mode 100644 index e31eceb..0000000 --- a/examples/drupal-sandbox/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -FROM drupal:11-apache - -RUN set -eux; \ - apt-get update; \ - apt-get install -y git unzip mariadb-client; \ - rm -rf /var/lib/apt/lists/*; - -COPY ./settings.php /var/www/html/sites/default/settings.local.php -COPY ./config /var/www/html/config - -RUN chown -R www-data:www-data /var/www/html/config diff --git a/examples/drupal-sandbox/README.md b/examples/drupal-sandbox/README.md deleted file mode 100644 index bd94cbf..0000000 --- a/examples/drupal-sandbox/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# Drupal 11 Sandbox - -This Docker Compose environment provisions a minimal Drupal 11 site backed by MariaDB. It mirrors the resources exposed by the DriftCore MCP server and is intended for local experimentation. - -## Prerequisites - -- Docker -- Docker Compose v2 - -## Usage - -```bash -cd examples/drupal-sandbox -docker compose up --build -``` - -Once the containers are running, visit [http://localhost:8081](http://localhost:8081) to complete Drupal's installation wizard. - -The configuration export directory is mounted at `examples/drupal-sandbox/config/sync` and seeded with the same configuration that is exposed through the MCP server's `config.exported` resource. - -## Maintenance - -- To rebuild after editing configuration run `docker compose build drupal`. -- Run Drush commands inside the container with `docker compose exec drupal drush status`. -- Use `docker compose down --volumes` to reset the database and configuration. diff --git a/examples/drupal-sandbox/config/sync/system.site.yml b/examples/drupal-sandbox/config/sync/system.site.yml deleted file mode 100644 index 5b3fe86..0000000 --- a/examples/drupal-sandbox/config/sync/system.site.yml +++ /dev/null @@ -1,9 +0,0 @@ -uuid: 00000000-0000-0000-0000-000000000000 -name: 'DriftCore Sandbox' -mail: admin@example.com -slogan: 'Composable automation for Drupal' -page: - 403: '' - 404: '' - front: /node -langcode: en diff --git a/examples/drupal-sandbox/docker-compose.yml b/examples/drupal-sandbox/docker-compose.yml deleted file mode 100644 index 5bb1c01..0000000 --- a/examples/drupal-sandbox/docker-compose.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: "3.9" - -services: - drupal: - build: . - container_name: driftcore-sandbox - ports: - - "8081:80" - environment: - DRUPAL_DB_HOST: database - DRUPAL_DB_NAME: drupal - DRUPAL_DB_USER: drupal - DRUPAL_DB_PASSWORD: drupal - volumes: - - drupal-config:/var/www/html/config - depends_on: - - database - - database: - image: mariadb:11 - environment: - MARIADB_DATABASE: drupal - MARIADB_USER: drupal - MARIADB_PASSWORD: drupal - MARIADB_ROOT_PASSWORD: root - volumes: - - db-data:/var/lib/mysql - -volumes: - drupal-config: - db-data: diff --git a/examples/drupal-sandbox/settings.php b/examples/drupal-sandbox/settings.php deleted file mode 100644 index 405d1be..0000000 --- a/examples/drupal-sandbox/settings.php +++ /dev/null @@ -1,12 +0,0 @@ - getenv('DRUPAL_DB_NAME') ?: 'drupal', - 'username' => getenv('DRUPAL_DB_USER') ?: 'drupal', - 'password' => getenv('DRUPAL_DB_PASSWORD') ?: 'drupal', - 'host' => getenv('DRUPAL_DB_HOST') ?: 'database', - 'driver' => 'mysql', - 'prefix' => '', -]; - -$settings['config_sync_directory'] = '/var/www/html/config/sync'; -$settings['trusted_host_patterns'] = ['^localhost$', '^driftcore-sandbox$']; diff --git a/packages/agent-runner/Dockerfile b/packages/agent-runner/Dockerfile deleted file mode 100644 index 39f2106..0000000 --- a/packages/agent-runner/Dockerfile +++ /dev/null @@ -1,6 +0,0 @@ -FROM node:20-slim -WORKDIR /app -COPY package.json tsconfig.json ./ -COPY src ./src -RUN npm install && npm run build -CMD ["node", "dist/index.js"] diff --git a/packages/agent-runner/config/default.json b/packages/agent-runner/config/default.json deleted file mode 100644 index 038db97..0000000 --- a/packages/agent-runner/config/default.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "serverEndpoint": "http://localhost:8080", - "transport": "http", - "sdkOutputDir": ".driftcore/sdk", - "sandboxRuntime": "node", - "sandboxBootstrapCode": "console.log('Drupal sandbox ready for automation');" -} diff --git a/packages/agent-runner/package.json b/packages/agent-runner/package.json deleted file mode 100644 index 0f1b293..0000000 --- a/packages/agent-runner/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "@driftcore/agent-runner", - "version": "0.1.0", - "description": "Reference agent runner for DriftCore", - "type": "module", - "scripts": { - "build": "tsc -p tsconfig.json", - "lint": "tsc -p tsconfig.json --pretty --noEmit", - "test": "npm run build && node --test dist/__tests__", - "integration": "npm run build && node dist/integration/smoke.js", - "start": "node dist/index.js" - }, - "dependencies": { - "@types/node": "^20.11.0" - }, - "devDependencies": { - "typescript": "^5.3.3" - } -} diff --git a/packages/agent-runner/src/__tests__/sdk.test.ts b/packages/agent-runner/src/__tests__/sdk.test.ts deleted file mode 100644 index 4853dcb..0000000 --- a/packages/agent-runner/src/__tests__/sdk.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { generateTypeScriptSDK } from "../sdk.js"; - -const resources = [ - { - id: "schema.entityTypes", - name: "Drupal entity type registry", - description: "", - mimeType: "application/json", - data: { entityTypes: [{ machineName: "node", label: "Content", description: "", fields: [] }] }, - }, - { - id: "config.exported", - name: "Drupal exported configuration", - description: "", - mimeType: "application/json", - data: { settings: { site: { name: "Example" } } }, - }, -]; - -describe("generateTypeScriptSDK", () => { - it("writes the SDK file to disk", async () => { - const outDir = await mkdtemp(path.join(tmpdir(), "driftcore-sdk-")); - await generateTypeScriptSDK({ outputDir: outDir, resources, logger: console }); - const contents = await readFile(path.join(outDir, "driftcore-sdk.ts"), "utf8"); - assert.match(contents, /entityTypes/); - assert.match(contents, /exportedConfiguration/); - }); -}); diff --git a/packages/agent-runner/src/index.ts b/packages/agent-runner/src/index.ts deleted file mode 100644 index 846fd06..0000000 --- a/packages/agent-runner/src/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { generateTypeScriptSDK, type ResourceDescriptor } from "./sdk.js"; -import { executeSandbox } from "./sandbox.js"; - -const fallbackResources: ResourceDescriptor[] = [ - { - id: "schema.entityTypes", - name: "Drupal entity type registry", - description: "Local fallback entity registry used during STDIO development.", - mimeType: "application/json", - data: { - entityTypes: [ - { - machineName: "node", - label: "Content", - description: "Drupal content entity representing published site content.", - fields: [ - { name: "title", type: "string", description: "Human readable title." }, - { name: "body", type: "text_long", description: "Rich text body field." }, - { name: "uid", type: "entity:user", description: "Reference to the authoring user." }, - { name: "status", type: "boolean", description: "Published flag." }, - ], - }, - { - machineName: "user", - label: "User", - description: "Account entity storing authentication and profile data.", - fields: [ - { name: "name", type: "string", description: "Login username." }, - { name: "mail", type: "email", description: "Primary e-mail address." }, - { name: "roles", type: "string[]", description: "Assigned Drupal roles." }, - { name: "status", type: "boolean", description: "Active state." }, - ], - }, - ], - }, - }, - { - id: "config.exported", - name: "Drupal exported configuration", - description: "Local fallback configuration snapshot used during STDIO development.", - mimeType: "application/json", - data: { - modules: [ - { name: "drupal", type: "core" }, - { name: "toolbar", type: "core" }, - { name: "block", type: "core" }, - ], - settings: { - site: { - name: "DriftCore Sandbox", - mail: "admin@example.com", - slogan: "Composable automation for Drupal", - }, - }, - }, - }, -]; - -export interface AgentRunnerOptions { - serverEndpoint: string; - transport: "stdio" | "http"; - sdkOutputDir: string; - sandboxRuntime: "node" | "deno" | "php"; - sandboxBootstrapCode: string; -} - -async function fetchJson(url: string): Promise { - const response = await fetch(url, { headers: { "Accept": "application/json" } }); - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`); - } - return (await response.json()) as T; -} - -export class AgentRunner { - constructor(private readonly options: AgentRunnerOptions) {} - - async start() { - if (this.options.transport === "stdio") { - console.info("STDIO transport selected; falling back to local resource snapshot."); - } else { - console.info(`Connecting to MCP HTTP server at ${this.options.serverEndpoint}`); - } - - const resources = await this.loadResources(); - await generateTypeScriptSDK({ - outputDir: this.options.sdkOutputDir, - resources, - logger: console, - }); - - const sandboxResult = await executeSandbox({ - code: this.options.sandboxBootstrapCode, - runtime: this.options.sandboxRuntime, - context: { resources }, - }); - - if (sandboxResult.warnings.length > 0) { - sandboxResult.warnings.forEach((warning) => console.warn(warning)); - } - sandboxResult.logs.forEach((line) => console.info(`[sandbox] ${line}`)); - console.info("Agent runner initialization complete"); - - return { resources, sandboxResult }; - } - - private async loadResources(): Promise { - if (this.options.transport === "http") { - const url = new URL("/resources", this.options.serverEndpoint).toString(); - const payload = await fetchJson<{ resources: ResourceDescriptor[] }>(url); - return payload.resources; - } - - return fallbackResources; - } -} - -export async function createDefaultRunner() { - const runner = new AgentRunner({ - serverEndpoint: "http://localhost:8080", - transport: "http", - sdkOutputDir: ".driftcore/sdk", - sandboxRuntime: "node", - sandboxBootstrapCode: "console.log('Drupal sandbox ready for automation');", - }); - await runner.start(); - return runner; -} diff --git a/packages/agent-runner/src/integration/smoke.ts b/packages/agent-runner/src/integration/smoke.ts deleted file mode 100644 index aac10ff..0000000 --- a/packages/agent-runner/src/integration/smoke.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { AgentRunner } from "../index.js"; - -export async function runAgentIntegration() { - const outputDir = await mkdtemp(path.join(tmpdir(), "driftcore-sdk-")); - const runner = new AgentRunner({ - serverEndpoint: "http://localhost:65535", // intentionally unused in HTTP fallback - transport: "stdio", - sdkOutputDir: outputDir, - sandboxRuntime: "node", - sandboxBootstrapCode: "console.log('Sandbox ready')", - }); - await runner.start(); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - runAgentIntegration() - .then(() => { - console.info("Agent runner integration smoke test completed"); - process.exit(0); - }) - .catch((error) => { - console.error("Agent runner integration smoke test failed", error); - process.exit(1); - }); -} diff --git a/packages/agent-runner/src/sandbox.ts b/packages/agent-runner/src/sandbox.ts deleted file mode 100644 index b967876..0000000 --- a/packages/agent-runner/src/sandbox.ts +++ /dev/null @@ -1,43 +0,0 @@ -import vm from "node:vm"; - -export interface SandboxExecutionOptions { - code: string; - runtime: "node" | "deno" | "php"; - timeoutMs?: number; - context?: Record; -} - -export interface SandboxResult { - logs: string[]; - result?: unknown; - warnings: string[]; -} - -export async function executeSandbox({ - code, - runtime, - timeoutMs = 500, - context = {}, -}: SandboxExecutionOptions): Promise { - if (runtime !== "node") { - return { - logs: [], - result: undefined, - warnings: [ - `${runtime} execution is not yet implemented; rerun with runtime=\"node\" to execute code.`, - ], - }; - } - - const logs: string[] = []; - const sandboxConsole: Pick = { - log: (...args: unknown[]) => logs.push(args.map(String).join(" ")), - error: (...args: unknown[]) => logs.push(args.map(String).join(" ")), - warn: (...args: unknown[]) => logs.push(args.map(String).join(" ")), - }; - - const script = new vm.Script(code, { filename: "sandbox.mjs" }); - const result = script.runInNewContext({ console: sandboxConsole, ...context }, { timeout: timeoutMs }); - - return { logs, result, warnings: [] }; -} diff --git a/packages/agent-runner/src/sdk.ts b/packages/agent-runner/src/sdk.ts deleted file mode 100644 index 37b1063..0000000 --- a/packages/agent-runner/src/sdk.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -export interface ResourceDescriptor { - id: string; - name: string; - description: string; - mimeType: string; - data: unknown; -} - -export interface GenerateSDKOptions { - outputDir: string; - resources: ResourceDescriptor[]; - logger?: Pick; -} - -function stringify(value: unknown) { - return JSON.stringify(value, null, 2); -} - -export async function generateTypeScriptSDK({ - outputDir, - resources, - logger = console, -}: GenerateSDKOptions) { - await mkdir(outputDir, { recursive: true }); - - const entityResource = resources.find((resource) => resource.id === "schema.entityTypes"); - const configResource = resources.find((resource) => resource.id === "config.exported"); - - const lines: string[] = [ - "// This file is auto-generated by the DriftCore agent runner.", - "// Do not edit directly; regenerate via the agent orchestration pipeline.", - "", "export interface EntityField {", - " name: string;", - " type: string;", - " description: string;", - "}", - "", - "export interface EntityType {", - " machineName: string;", - " label: string;", - " description: string;", - " fields: EntityField[];", - "}", - ]; - - lines.push( - "", - "export const entityTypes: EntityType[] = "+ - (entityResource ? stringify((entityResource.data as { entityTypes?: unknown })?.entityTypes ?? []) : "[]") + - " as const;" - ); - - lines.push( - "", - "export const exportedConfiguration = "+ - (configResource ? stringify(configResource.data) : "{}") + - " as const;" - ); - - const outputPath = path.join(outputDir, "driftcore-sdk.ts"); - await writeFile(outputPath, `${lines.join("\n")}\n`, "utf8"); - logger.info?.(`Generated DriftCore SDK at ${outputPath}`); -} diff --git a/packages/agent-runner/tsconfig.json b/packages/agent-runner/tsconfig.json deleted file mode 100644 index a64106c..0000000 --- a/packages/agent-runner/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "ES2020", - "moduleResolution": "node", - "esModuleInterop": true, - "outDir": "dist", - "rootDir": "src", - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*"] -} diff --git a/packages/server/.npmignore b/packages/server/.npmignore new file mode 100644 index 0000000..71748ef --- /dev/null +++ b/packages/server/.npmignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +build/ +npm-debug.log* +yarn-error.log* +pnpm-debug.log* +coverage/ +src/ +tsconfig.json +Dockerfile +Dockerfile.* + diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..50bb45b --- /dev/null +++ b/packages/server/README.md @@ -0,0 +1,104 @@ +# @driftcore/server + +Baseline MCP server that connects to a single Drupal project and exposes read-only resources and tools (Drush and Composer) for external agents. + +## Requirements + +- Node.js 20+ +- npm 9+ +- A Drupal codebase checked out locally (for example `/Users/rob/Dev/drupal`) +- Drush and Composer installed (the project’s `vendor/bin` copies work fine) + +## Installation + +```bash +cd packages/server +npm install +``` + +## Configuration + +Create a JSON configuration file and point `DRIFTCORE_CONFIG` at it. Example: + +`/Users/rob/Dev/DriftCore/driftcore.config.json` + +```jsonc +{ + "drupalRoot": "/Users/rob/Dev/drupal/web", + "drushPath": "/Users/rob/Dev/drupal/vendor/bin/drush", + "composerPath": "/usr/local/bin/composer", + "customModuleDirs": ["web/modules/custom"], + "customThemeDirs": ["web/themes/custom"], + "maxParallelCli": 1, + "timeouts": { + "drushStatusMs": 10000, + "drushPmlMs": 15000, + "composerInfoMs": 8000, + "composerOutdatedMs": 30000 + }, + "cacheTtlMs": { + "projectManifest": 5000, + "pml": 5000 + } +} +``` + +`drupalRoot` must exist and point to the Drupal installation root (`web/`). If omitted or invalid, every resource/tool reports `status="not_configured"` with `E_CONFIG_INVALID_ROOT`. + +## Running the server + +### STDIO transport + +```bash +cd packages/server +DRIFTCORE_CONFIG=/path/to/driftcore.config.json npm run start:stdio +``` + +The stdio transport accepts JSON commands such as: + +```json +{"id":1,"action":"project_manifest"} +``` + +### HTTP transport + +```bash +cd packages/server +DRIFTCORE_CONFIG=/path/to/driftcore.config.json npm run start:http -- --port 8080 +``` + +Endpoints (all `GET`): + +- `/health` +- `/resources` +- `/tools` +- `/project-manifest` +- `/drush/status` +- `/drush/pml` +- `/composer/info` +- `/composer/outdated` + +Each returns the shared response envelope (`status`, optional `data`, optional `error` with `code` and `message`). + +## Available tools/resources + +- `project_manifest` resource summarizing Drupal root, core version, Composer dependencies, custom modules/themes. +- Tools: + - `drift.drush_status` + - `drift.drush_pml` + - `drift.composer_info` + - `drift.composer_outdated` + +All tools run from the configured `drupalRoot` with fixed arguments, obey per-tool timeouts, enforce `maxParallelCli`, and never mutate project files. + +## Testing + +```bash +cd packages/server +npm test # Builds and runs node --test suites. +npm run build # Type-checks and emits dist/ +npm run integration # Runs the HTTP transport smoke test +``` + +The test suite covers schema resources, project manifest discovery, Drush/Composer tool parsing, and non-write guarantees. The integration smoke test exercises the HTTP transport endpoints. + diff --git a/packages/server/package-lock.json b/packages/server/package-lock.json new file mode 100644 index 0000000..37dc36e --- /dev/null +++ b/packages/server/package-lock.json @@ -0,0 +1,227 @@ +{ + "name": "@driftcore/server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@driftcore/server", + "version": "0.1.0", + "dependencies": { + "yargs": "^17.7.2" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.3" + } + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/packages/server/src/__tests__/cliTools.nonwrite.test.ts b/packages/server/src/__tests__/cliTools.nonwrite.test.ts new file mode 100644 index 0000000..5a50153 --- /dev/null +++ b/packages/server/src/__tests__/cliTools.nonwrite.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { runDrushStatus, runDrushPml } from "../features/drushTools.js"; +import { runComposerInfo, runComposerOutdated } from "../features/composerTools.js"; +import type { ServerConfig, ServerState } from "../types.js"; +import type { + CliExecutionOptions, + CliExecutionResult, +} from "../features/sandboxExecution.js"; + +type RunnerStub = (options: CliExecutionOptions) => Promise; + +function createProject(): { + config: ServerConfig; + cleanup: () => void; + sentinelPath: string; +} { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "driftcore-nonwrite-")); + const projectRoot = tmpDir; + const drupalRoot = path.join(projectRoot, "web"); + fs.mkdirSync(drupalRoot, { recursive: true }); + + fs.writeFileSync( + path.join(projectRoot, "composer.json"), + JSON.stringify({ name: "acme/site", require: { "drupal/token": "^1.0" } }, null, 2), + ); + fs.writeFileSync( + path.join(projectRoot, "composer.lock"), + JSON.stringify({ packages: [{ name: "drupal/token", version: "1.0.0", type: "drupal-module" }] }, null, 2), + ); + + const sentinelPath = path.join(projectRoot, "SENTINEL.txt"); + fs.writeFileSync(sentinelPath, "original"); + + const config: ServerConfig = { + drupalRoot, + customModuleDirs: ["web/modules/custom"], + customThemeDirs: ["web/themes/custom"], + cacheTtlMs: { projectManifest: 0, pml: 0 }, + }; + + return { + config, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + sentinelPath, + }; +} + +function createState(config: ServerConfig): ServerState { + return { + resources: [], + tools: [], + logger: console, + config, + runOperation: async (_meta, executor) => executor(), + }; +} + +const runnerStub: RunnerStub = async () => ({ + stdout: JSON.stringify({}), + stderr: "", + exitCode: 0, + timedOut: false, + durationMs: 1, +}); + +describe("CLI tools non-write behavior", () => { + it("does not modify project files", async () => { + const { config, cleanup, sentinelPath } = createProject(); + try { + const initialContent = fs.readFileSync(sentinelPath, "utf8"); + + await runDrushStatus(createState(config), runnerStub); + await runDrushPml(createState(config), runnerStub); + await runComposerInfo(createState(config)); + await runComposerOutdated(createState(config), runnerStub); + + const finalContent = fs.readFileSync(sentinelPath, "utf8"); + assert.equal(finalContent, initialContent); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/server/src/__tests__/cliTools.test.ts b/packages/server/src/__tests__/cliTools.test.ts new file mode 100644 index 0000000..fe68b03 --- /dev/null +++ b/packages/server/src/__tests__/cliTools.test.ts @@ -0,0 +1,174 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ServerConfig, ServerState } from "../types.js"; +import { + runDrushStatus, + runDrushPml, +} from "../features/drushTools.js"; +import { + runComposerInfo, + runComposerOutdated, +} from "../features/composerTools.js"; +import type { + CliExecutionOptions, + CliExecutionResult, +} from "../features/sandboxExecution.js"; + +type RunnerStub = (options: CliExecutionOptions) => Promise; + +function createTempProject(): { config: ServerConfig; cleanup: () => void; projectRoot: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "driftcore-cli-")); + const projectRoot = tmpDir; + const drupalRoot = path.join(projectRoot, "web"); + fs.mkdirSync(drupalRoot, { recursive: true }); + + const composerJson = { + name: "acme/site", + require: { + "drupal/core-recommended": "^11.1", + "drupal/token": "^1.11", + }, + }; + fs.writeFileSync( + path.join(projectRoot, "composer.json"), + JSON.stringify(composerJson, null, 2), + ); + + const composerLock = { + packages: [ + { name: "drupal/core-recommended", version: "11.1.4", type: "drupal-core" }, + { name: "drupal/token", version: "1.11.0", type: "drupal-module" }, + ], + }; + fs.writeFileSync( + path.join(projectRoot, "composer.lock"), + JSON.stringify(composerLock, null, 2), + ); + + const config: ServerConfig = { + drupalRoot, + customModuleDirs: ["web/modules/custom"], + customThemeDirs: ["web/themes/custom"], + cacheTtlMs: { projectManifest: 0, pml: 0 }, + }; + + return { + config, + cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }), + projectRoot, + }; +} + +function createState(config: ServerConfig): ServerState { + return { + resources: [], + tools: [], + logger: console, + config, + runOperation: async (_meta, executor) => executor(), + }; +} + +function makeRunnerStub(stdout: string): RunnerStub { + return async () => ({ + stdout, + stderr: "", + exitCode: 0, + timedOut: false, + durationMs: 5, + }); +} + +describe("drift.drush tools", () => { + it("normalizes drush status output", async () => { + const { config, cleanup } = createTempProject(); + try { + const runner = makeRunnerStub( + JSON.stringify({ + "drupal-version": "11.1.5", + "php-version": "8.4.14", + "db-driver": "sqlite", + site: "sites/default", + }), + ); + const response = await runDrushStatus(createState(config), runner); + assert.equal(response.status, "ok"); + assert.equal(response.data?.drupal_version, "11.1.5"); + assert.equal(response.data?.php_version, "8.4.14"); + assert.equal(response.data?.database_driver, "sqlite"); + assert.equal(response.data?.site_path, "sites/default"); + assert.ok(response.data?.details["drupal-version"]); + } finally { + cleanup(); + } + }); + + it("parses pm:list output into module/theme descriptors", async () => { + const { config, cleanup } = createTempProject(); + try { + const runner = makeRunnerStub( + JSON.stringify({ + modules: { + node: { status: "Enabled", package: "Core", path: "core/modules/node" }, + token: { + status: "Enabled", + package: "Contributed modules", + path: "web/modules/contrib/token", + }, + }, + themes: { + claro: { status: "Enabled", package: "Core", path: "core/themes/claro" }, + }, + }), + ); + const response = await runDrushPml(createState(config), runner); + assert.equal(response.status, "ok"); + const modules = response.data?.modules ?? []; + const node = modules.find((m) => m.name === "node"); + assert.equal(node?.type, "core"); + const tokenModule = modules.find((m) => m.name === "token"); + assert.equal(tokenModule?.type, "contrib"); + const themes = response.data?.themes ?? []; + assert.equal(themes[0]?.name, "claro"); + } finally { + cleanup(); + } + }); +}); + +describe("composer tools", () => { + it("reads composer manifest and lock summary", async () => { + const { config, cleanup } = createTempProject(); + try { + const response = await runComposerInfo(createState(config)); + assert.equal(response.status, "ok"); + assert.equal(response.data?.manifest.name, "acme/site"); + assert.ok(response.data?.manifest.require?.["drupal/token"]); + assert.ok(response.data?.lock_summary?.packages?.length); + } finally { + cleanup(); + } + }); + + it("parses composer outdated output", async () => { + const { config, cleanup } = createTempProject(); + try { + const runner: RunnerStub = makeRunnerStub( + `Cannot create cache directory +{"installed":[{"name":"drupal/token","version":"1.11.0","latest":"1.12.0","latest-status":"semver-safe-update"}]}`, + ); + const response = await runComposerOutdated(createState(config), runner); + assert.equal(response.status, "ok"); + const pkg = response.data?.packages[0]; + assert.equal(pkg?.name, "drupal/token"); + assert.equal(pkg?.constraint, "^1.11"); + assert.equal(pkg?.package_type, "drupal-module"); + assert.equal(pkg?.latest_status, "semver-safe-update"); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/server/src/__tests__/projectManifest.test.ts b/packages/server/src/__tests__/projectManifest.test.ts new file mode 100644 index 0000000..f95757c --- /dev/null +++ b/packages/server/src/__tests__/projectManifest.test.ts @@ -0,0 +1,145 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ServerConfig, ServerState } from "../types.js"; +import { getProjectManifest } from "../features/projectManifest.js"; + +function createTempProject(): { config: ServerConfig; cleanup: () => void } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "driftcore-manifest-")); + const projectRoot = tmpDir; + const drupalRoot = path.join(projectRoot, "web"); + + fs.mkdirSync(drupalRoot, { recursive: true }); + + const composerJson = { + name: "acme/site", + type: "project", + require: { + "drupal/core-recommended": "^11.1", + "drupal/token": "^1.11", + }, + }; + fs.writeFileSync( + path.join(projectRoot, "composer.json"), + JSON.stringify(composerJson, null, 2), + "utf8", + ); + + const composerLock = { + packages: [ + { + name: "drupal/core-recommended", + version: "11.1.4", + }, + ], + }; + fs.writeFileSync( + path.join(projectRoot, "composer.lock"), + JSON.stringify(composerLock, null, 2), + "utf8", + ); + + const customModuleDir = path.join(projectRoot, "web", "modules", "custom", "acme_blog"); + fs.mkdirSync(customModuleDir, { recursive: true }); + + const config: ServerConfig = { + drupalRoot, + customModuleDirs: ["web/modules/custom"], + customThemeDirs: ["web/themes/custom"], + cacheTtlMs: { projectManifest: 5000, pml: 5000 }, + }; + + const cleanup = () => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }; + + return { config, cleanup }; +} + +describe("project manifest", () => { + it("builds a manifest matching the configured project", async () => { + const { config, cleanup } = createTempProject(); + try { + const state: ServerState = { + resources: [], + tools: [], + logger: console, + config, + runOperation: async (_meta, executor) => executor(), + }; + + const response = await getProjectManifest(state); + assert.equal(response.status, "ok"); + assert.ok(response.data, "expected data in ok response"); + + const data = response.data!; + assert.equal(data.schema_version, "0.1.0"); + assert.equal(data.drupal_root, config.drupalRoot); + assert.equal(data.drupal_core_version, "11.1.4"); + assert.equal(data.project_type, "drupal-recommended-project"); + + assert.equal(data.composer.status, "ok"); + assert.equal(data.composer.name, "acme/site"); + assert.ok(data.composer.require); + assert.equal( + data.composer.require && data.composer.require["drupal/core-recommended"], + "^11.1", + ); + + assert.ok(Array.isArray(data.custom_modules)); + const moduleNames = data.custom_modules.map((m) => m.name); + assert.ok(moduleNames.includes("acme_blog")); + + const modulePaths = data.custom_modules.map((m) => m.path); + assert.ok(modulePaths.some((p) => p.endsWith("web/modules/custom/acme_blog"))); + + assert.ok(Array.isArray(data.custom_themes)); + } finally { + cleanup(); + } + }); + + it("returns not_configured when no server config is present", async () => { + const state: ServerState = { + resources: [], + tools: [], + logger: console, + config: null, + configError: { + code: "E_CONFIG_INVALID_ROOT", + message: "invalid", + }, + runOperation: async (_meta, executor) => executor(), + }; + + const response = await getProjectManifest(state); + assert.equal(response.status, "not_configured"); + assert.ok(response.error); + assert.equal(response.error?.code, "E_CONFIG_INVALID_ROOT"); + }); + + it("degrades when composer metadata is incomplete", async () => { + const { config, cleanup } = createTempProject(); + try { + fs.unlinkSync(path.join(path.dirname(config.drupalRoot), "composer.lock")); + + const state: ServerState = { + resources: [], + tools: [], + logger: console, + config, + runOperation: async (_meta, executor) => executor(), + }; + + const response = await getProjectManifest(state); + assert.equal(response.status, "degraded"); + assert.ok(response.data); + assert.ok(response.error); + assert.equal(response.error?.code, "E_MANIFEST_INCOMPLETE"); + } finally { + cleanup(); + } + }); +}); diff --git a/packages/server/src/__tests__/schemaResources.test.ts b/packages/server/src/__tests__/schemaResources.test.ts index b8c5bf6..e1b82e1 100644 --- a/packages/server/src/__tests__/schemaResources.test.ts +++ b/packages/server/src/__tests__/schemaResources.test.ts @@ -9,14 +9,15 @@ describe("schema resources", () => { const ids = resources.map((resource) => resource.id); assert.ok(ids.includes("schema.entityTypes")); assert.ok(ids.includes("config.exported")); + assert.ok(ids.includes("project_manifest")); }); }); describe("drush tools", () => { - it("includes cache rebuild command", () => { + it("includes drift.drush_status tool definition", () => { const tools = getDrushTools(); - const cacheTool = tools.find((tool) => tool.name === "drush.cacheRebuild"); - assert.ok(cacheTool, "drush.cacheRebuild tool should be defined"); - assert.equal(cacheTool?.command, "drush cache:rebuild"); + const statusTool = tools.find((tool) => tool.name === "drift.drush_status"); + assert.ok(statusTool, "drift.drush_status tool should be defined"); + assert.ok(statusTool?.args?.includes("--format=json")); }); }); diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts new file mode 100644 index 0000000..a80d968 --- /dev/null +++ b/packages/server/src/config.ts @@ -0,0 +1,188 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ErrorDetail, ServerConfig, TimeoutsConfig, CacheTtlConfig } from "./types.js"; + +export interface LoadConfigOptions { + configPath?: string; + logger?: Pick; +} + +export interface LoadedConfig { + config: ServerConfig | null; + configPath: string | null; + error?: ErrorDetail; +} + +const DEFAULT_CUSTOM_MODULE_DIRS = ["web/modules/custom", "modules/custom"]; +const DEFAULT_CUSTOM_THEME_DIRS = ["web/themes/custom", "themes/custom"]; + +const DEFAULT_TIMEOUTS: Required = { + drushStatusMs: 10000, + drushPmlMs: 15000, + composerInfoMs: 8000, + composerOutdatedMs: 30000, +}; + +const DEFAULT_CACHE_TTL_MS: Required = { + projectManifest: 5000, + pml: 5000, +}; + +function applyDefaults(raw: ServerConfig): ServerConfig { + const customModuleDirs = + Array.isArray(raw.customModuleDirs) && raw.customModuleDirs.length > 0 + ? raw.customModuleDirs + : DEFAULT_CUSTOM_MODULE_DIRS; + + const customThemeDirs = + Array.isArray(raw.customThemeDirs) && raw.customThemeDirs.length > 0 + ? raw.customThemeDirs + : DEFAULT_CUSTOM_THEME_DIRS; + + const timeouts: TimeoutsConfig = { + drushStatusMs: raw.timeouts?.drushStatusMs ?? DEFAULT_TIMEOUTS.drushStatusMs, + drushPmlMs: raw.timeouts?.drushPmlMs ?? DEFAULT_TIMEOUTS.drushPmlMs, + composerInfoMs: raw.timeouts?.composerInfoMs ?? DEFAULT_TIMEOUTS.composerInfoMs, + composerOutdatedMs: + raw.timeouts?.composerOutdatedMs ?? DEFAULT_TIMEOUTS.composerOutdatedMs, + }; + + const cacheTtlMs: CacheTtlConfig = { + projectManifest: + raw.cacheTtlMs?.projectManifest ?? DEFAULT_CACHE_TTL_MS.projectManifest, + pml: raw.cacheTtlMs?.pml ?? DEFAULT_CACHE_TTL_MS.pml, + }; + + return { + drupalRoot: raw.drupalRoot, + drushPath: raw.drushPath, + composerPath: raw.composerPath, + customModuleDirs, + customThemeDirs, + timeouts, + maxParallelCli: raw.maxParallelCli ?? 1, + cacheTtlMs, + }; +} + +function resolveConfigPath(explicitPath?: string): string | null { + if (explicitPath) { + return explicitPath; + } + if (process.env.DRIFTCORE_CONFIG) { + return process.env.DRIFTCORE_CONFIG; + } + return path.resolve(process.cwd(), "driftcore.config.json"); +} + +export function loadServerConfig(options: LoadConfigOptions = {}): LoadedConfig { + const logger = options.logger ?? console; + const configPath = resolveConfigPath(options.configPath); + + if (!configPath) { + return { config: null, configPath: null }; + } + + if (!fs.existsSync(configPath)) { + logger.warn?.(`DriftCore config not found at ${configPath}`); + return { + config: null, + configPath, + error: { + code: "E_CONFIG_NOT_FOUND", + message: `Configuration file not found at ${configPath}`, + diagnostics: { configPath }, + }, + }; + } + + let parsed: unknown; + try { + const raw = fs.readFileSync(configPath, "utf8"); + parsed = JSON.parse(raw); + } catch (error) { + const err = error as Error; + logger.error?.(`Failed to read DriftCore config from ${configPath}: ${err.message}`); + return { + config: null, + configPath, + error: { + code: "E_JSON_PARSE", + message: `Failed to parse configuration JSON at ${configPath}`, + diagnostics: { configPath, error: err.message }, + }, + }; + } + + const rawConfig = parsed as Partial & { drupalRoot?: unknown }; + + if (typeof rawConfig.drupalRoot !== "string" || rawConfig.drupalRoot.length === 0) { + const message = "Configuration must specify an absolute drupalRoot path"; + logger.error?.(message); + return { + config: null, + configPath, + error: { + code: "E_CONFIG_INVALID_ROOT", + message, + diagnostics: { configPath, drupalRoot: rawConfig.drupalRoot }, + }, + }; + } + + const resolvedRoot = path.resolve(rawConfig.drupalRoot); + + let stat: fs.Stats; + try { + stat = fs.statSync(resolvedRoot); + } catch { + const message = `Configured drupalRoot does not exist: ${resolvedRoot}`; + logger.error?.(message); + return { + config: null, + configPath, + error: { + code: "E_CONFIG_INVALID_ROOT", + message, + diagnostics: { configPath, drupalRoot: resolvedRoot }, + }, + }; + } + + if (!stat.isDirectory()) { + const message = `Configured drupalRoot is not a directory: ${resolvedRoot}`; + logger.error?.(message); + return { + config: null, + configPath, + error: { + code: "E_CONFIG_INVALID_ROOT", + message, + diagnostics: { configPath, drupalRoot: resolvedRoot }, + }, + }; + } + + const baseConfig: ServerConfig = { + drupalRoot: resolvedRoot, + drushPath: rawConfig.drushPath, + composerPath: rawConfig.composerPath, + customModuleDirs: rawConfig.customModuleDirs, + customThemeDirs: rawConfig.customThemeDirs, + timeouts: rawConfig.timeouts, + maxParallelCli: rawConfig.maxParallelCli, + cacheTtlMs: rawConfig.cacheTtlMs, + }; + + const configWithDefaults = applyDefaults(baseConfig); + + logger.info?.( + `Loaded DriftCore config for Drupal root ${configWithDefaults.drupalRoot}`, + ); + + return { + config: configWithDefaults, + configPath, + }; +} + diff --git a/packages/server/src/features/cache.ts b/packages/server/src/features/cache.ts new file mode 100644 index 0000000..46fc53b --- /dev/null +++ b/packages/server/src/features/cache.ts @@ -0,0 +1,28 @@ +export interface TimedCache { + get(ttlMs: number, loader: () => Promise | T): Promise; + clear(): void; +} + +export function createTimedCache(): TimedCache { + let value: T | undefined; + let expiresAt = 0; + + return { + async get(ttlMs: number, loader: () => Promise | T): Promise { + const now = Date.now(); + if (value !== undefined && now < expiresAt) { + return value; + } + + const result = await loader(); + value = result; + expiresAt = now + ttlMs; + return result; + }, + clear() { + value = undefined; + expiresAt = 0; + }, + }; +} + diff --git a/packages/server/src/features/composerTools.ts b/packages/server/src/features/composerTools.ts new file mode 100644 index 0000000..5ce77c0 --- /dev/null +++ b/packages/server/src/features/composerTools.ts @@ -0,0 +1,266 @@ +import path from "node:path"; +import fs from "node:fs"; +import type { + MCPTool, + ResourceOrToolResponse, + ServerConfig, + ServerState, +} from "../types.js"; +import { + runCliCommand, + type CliExecutionOptions, + type CliExecutionResult, +} from "./sandboxExecution.js"; +import { resolveProjectRoot, readJsonFile } from "./projectPaths.js"; +import { mapCliResultToError, truncateStderr } from "./errorMapping.js"; + +type CliRunner = (options: CliExecutionOptions) => Promise; + +export interface ComposerInfoData { + manifest: { + name?: string; + require?: Record; + }; + lock_summary?: { + packages?: Array<{ name: string; version: string }>; + }; +} + +export interface ComposerOutdatedPackage { + name: string; + current_version: string; + constraint?: string; + latest_version: string | null; + latest_status: "semver-safe-update" | "update-possible" | "unknown"; + package_type?: "drupal-core" | "drupal-module" | "drupal-theme" | "library"; +} + +export interface ComposerOutdatedData { + packages: ComposerOutdatedPackage[]; +} + +export function getComposerTools(): MCPTool[] { + return [ + { + name: "drift.composer_info", + description: "Reads composer.json/lock and returns manifest + lock summaries.", + command: "composer info", + args: [], + examples: [], + }, + { + name: "drift.composer_outdated", + description: + "Runs `composer outdated --format=json` and normalizes package update metadata.", + command: "composer outdated", + args: ["--format=json"], + examples: ["composer outdated --format=json"], + }, + ]; +} + +function ensureConfig( + state: ServerState, +): { config: ServerConfig } | ResourceOrToolResponse { + if (!state.config) { + return { + status: "not_configured", + error: + state.configError ?? + { + code: "E_CONFIG_INVALID_ROOT", + message: "DriftCore configuration is missing or invalid", + }, + }; + } + return { config: state.config }; +} + +export async function runComposerInfo( + state: ServerState, +): Promise> { + const ensured = ensureConfig(state); + if (!("config" in ensured)) { + return ensured; + } + const { config } = ensured; + const projectRoot = resolveProjectRoot(config); + const composerPath = path.join(projectRoot, "composer.json"); + const lockPath = path.join(projectRoot, "composer.lock"); + + const composerJson = readJsonFile>(composerPath); + if (!composerJson) { + return { + status: "error", + error: { + code: "E_COMPOSER_NOT_FOUND", + message: `composer.json not found at ${composerPath}`, + }, + }; + } + + const manifest: ComposerInfoData["manifest"] = {}; + if (typeof composerJson.name === "string") { + manifest.name = composerJson.name; + } + if (composerJson.require && typeof composerJson.require === "object") { + manifest.require = composerJson.require as Record; + } + + const lockJson = readJsonFile>(lockPath); + const packages: Array<{ name: string; version: string }> | undefined = lockJson + ? extractLockPackages(lockJson) + : undefined; + + const data: ComposerInfoData = { + manifest, + }; + + if (packages && packages.length > 0) { + data.lock_summary = { + packages, + }; + } + + return { + status: "ok", + data, + }; +} + +function extractLockPackages(lockJson: Record) { + const sections = [ + ...(Array.isArray(lockJson.packages) ? lockJson.packages : []), + ...(Array.isArray(lockJson["packages-dev"]) ? lockJson["packages-dev"] : []), + ]; + return sections.map((pkg) => ({ + name: pkg.name as string, + version: pkg.version as string, + })); +} + +export async function runComposerOutdated( + state: ServerState, + runner: CliRunner = runCliCommand, +): Promise> { + const ensured = ensureConfig(state); + if (!("config" in ensured)) { + return ensured; + } + const { config } = ensured; + const projectRoot = resolveProjectRoot(config); + const manifest = readJsonFile>(path.join(projectRoot, "composer.json")); + const lockJson = readJsonFile>(path.join(projectRoot, "composer.lock")); + + const command = resolveComposerCommand(config, projectRoot); + const args = ["outdated", "--format=json"]; + const cliResult = await runner({ + command, + args, + cwd: projectRoot, + timeoutMs: config.timeouts?.composerOutdatedMs ?? 30000, + env: { + COMPOSER_DISABLE_XDEBUG_WARN: "1", + COMPOSER_MEMORY_LIMIT: process.env.COMPOSER_MEMORY_LIMIT ?? "1G", + }, + maxParallel: config.maxParallelCli ?? 1, + }); + + if (cliResult.timedOut || cliResult.exitCode !== 0) { + return mapCliResultToError(cliResult, { + command, + args, + cwd: projectRoot, + }, { + missingBinaryCode: "E_COMPOSER_NOT_FOUND", + missingBinaryMessage: + "Composer executable was not found. Install Composer or update the configuration.", + }); + } + + let parsed: any; + try { + parsed = parseComposerJson(cliResult.stdout); + } catch (error) { + return { + status: "error", + error: { + code: "E_JSON_PARSE", + message: "Failed to parse composer outdated output", + details: { error: (error as Error).message }, + stderr: truncateStderr(cliResult.stderr), + }, + }; + } + + const requireMap: Record = + (manifest?.require as Record) ?? {}; + const packageTypes = buildPackageTypeMap(lockJson); + + const packages: ComposerOutdatedPackage[] = Array.isArray(parsed?.installed) + ? parsed.installed.map((pkg: any) => ({ + name: pkg.name, + current_version: pkg.version, + constraint: requireMap[pkg.name], + latest_version: pkg.latest ?? null, + latest_status: (pkg["latest-status"] as ComposerOutdatedPackage["latest_status"]) ?? "unknown", + package_type: packageTypes[pkg.name], + })) + : []; + + return { + status: "ok", + data: { + packages, + }, + }; +} + +function resolveComposerCommand(config: ServerConfig, projectRoot: string): string { + if (config.composerPath) { + return config.composerPath; + } + const vendorComposer = path.join(projectRoot, "vendor", "bin", "composer"); + if (fs.existsSync(vendorComposer)) { + return vendorComposer; + } + const composerPhar = path.join(projectRoot, "composer.phar"); + if (fs.existsSync(composerPhar)) { + return composerPhar; + } + return "composer"; +} + +function parseComposerJson(raw: string): any { + const firstBrace = raw.indexOf("{"); + if (firstBrace === -1) { + throw new Error("Composer output did not contain JSON"); + } + const lastBrace = raw.lastIndexOf("}"); + const jsonString = raw.slice(firstBrace, lastBrace + 1); + return JSON.parse(jsonString); +} + +function buildPackageTypeMap( + lockJson: Record | null, +): Record { + if (!lockJson) { + return {}; + } + const packages = [ + ...(Array.isArray(lockJson.packages) ? lockJson.packages : []), + ...(Array.isArray(lockJson["packages-dev"]) ? lockJson["packages-dev"] : []), + ]; + const map: Record = {}; + for (const pkg of packages) { + const type = typeof pkg.type === "string" ? pkg.type : ""; + if (type === "drupal-core") { + map[pkg.name as string] = "drupal-core"; + } else if (type === "drupal-module") { + map[pkg.name as string] = "drupal-module"; + } else if (type === "drupal-theme") { + map[pkg.name as string] = "drupal-theme"; + } + } + return map; +} diff --git a/packages/server/src/features/drushTools.ts b/packages/server/src/features/drushTools.ts index cad40eb..0d267bd 100644 --- a/packages/server/src/features/drushTools.ts +++ b/packages/server/src/features/drushTools.ts @@ -1,24 +1,362 @@ -import type { MCPTool } from "../types.js"; +import fs from "node:fs"; +import path from "node:path"; +import type { + MCPTool, + ResourceOrToolResponse, + ServerConfig, + ServerState, +} from "../types.js"; +import { + runCliCommand, + type CliExecutionOptions, + type CliExecutionResult, +} from "./sandboxExecution.js"; +import { resolveProjectRoot, toProjectRelativePath } from "./projectPaths.js"; +import { createTimedCache } from "./cache.js"; +import { mapCliResultToError, truncateStderr } from "./errorMapping.js"; + +type CliRunner = (options: CliExecutionOptions) => Promise; + +export interface DrushStatusData { + drupal_version: string | null; + php_version: string | null; + database_driver: string | null; + site_path: string | null; + details: Record; +} + +export interface ModuleDescriptor { + name: string; + type: "core" | "contrib" | "custom" | "unknown"; + status: "enabled" | "disabled"; +} + +export type ThemeDescriptor = ModuleDescriptor; + +export interface DrushPmlData { + modules: ModuleDescriptor[]; + themes: ThemeDescriptor[]; +} + +const pmlCache = createTimedCache>(); export function getDrushTools(): MCPTool[] { return [ { - name: "drush.cacheRebuild", - description: "Clears all Drupal caches using the Drush cache:rebuild command.", - command: "drush cache:rebuild", - args: [], - examples: [ - "drush cache:rebuild", - ], + name: "drift.drush_status", + description: + "Runs `drush status --format=json` from the configured Drupal root and returns normalized status metadata.", + command: "drush status", + args: ["--format=json"], + examples: ["drush status --format=json"], }, { - name: "drush.configExport", - description: "Exports the active Drupal configuration to the sync directory.", - command: "drush config:export", - args: ["--destination=/var/www/html/config/sync"], - examples: [ - "drush config:export --destination=/var/www/html/config/sync", - ], + name: "drift.drush_pml", + description: + "Runs `drush pm:list --format=json` to list all modules and themes with status/type metadata.", + command: "drush pm:list", + args: ["--format=json"], + examples: ["drush pm:list --format=json"], }, ]; } + +function ensureConfig( + state: ServerState, +): { config: ServerConfig } | ResourceOrToolResponse { + if (!state.config) { + return { + status: "not_configured", + error: + state.configError ?? + { + code: "E_CONFIG_INVALID_ROOT", + message: "DriftCore configuration is missing or invalid", + }, + }; + } + + return { config: state.config }; +} + +function resolveDrushCommand(config: ServerConfig): string { + if (config.drushPath) { + return config.drushPath; + } + const projectRoot = resolveProjectRoot(config); + const vendorDrush = path.join(projectRoot, "vendor", "bin", "drush"); + if (pathExists(vendorDrush)) { + return vendorDrush; + } + return "drush"; +} + +function pathExists(target: string): boolean { + return fs.existsSync(target); +} + +function coerceString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === "string") { + return value; + } + return String(value); +} + +function parseJsonOutput(raw: string): T { + const firstBrace = raw.indexOf("{"); + if (firstBrace === -1) { + throw new Error("Drush output did not contain JSON data"); + } + const trimmed = raw.slice(firstBrace).trim(); + return JSON.parse(trimmed) as T; +} + +export async function runDrushStatus( + state: ServerState, + runner: CliRunner = runCliCommand, +): Promise> { + const ensured = ensureConfig(state); + if (!("config" in ensured)) { + return ensured; + } + const { config } = ensured; + const command = resolveDrushCommand(config); + const args = ["status", "--format=json"]; + const cliResult = await runner({ + command, + args, + cwd: config.drupalRoot, + timeoutMs: config.timeouts?.drushStatusMs ?? 10000, + maxParallel: config.maxParallelCli ?? 1, + }); + + if (cliResult.timedOut || cliResult.exitCode !== 0) { + return mapCliResultToError(cliResult, { + command, + args, + cwd: config.drupalRoot, + }, { + missingBinaryCode: "E_DRUSH_NOT_FOUND", + missingBinaryMessage: + "Drush executable was not found. Install Drush or update the configuration.", + }); + } + + let parsed: Record; + try { + parsed = parseJsonOutput>(cliResult.stdout); + } catch (error) { + return { + status: "error", + error: { + code: "E_JSON_PARSE", + message: "Failed to parse Drush status output as JSON", + diagnostics: { + command, + args, + }, + details: { error: (error as Error).message }, + stderr: truncateStderr(cliResult.stderr), + }, + }; + } + + const details: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (value === null || value === undefined) { + details[key] = ""; + } else if (typeof value === "object") { + details[key] = JSON.stringify(value); + } else { + details[key] = String(value); + } + } + + return { + status: "ok", + data: { + drupal_version: coerceString(parsed["drupal-version"]), + php_version: coerceString(parsed["php-version"]), + database_driver: coerceString(parsed["db-driver"]), + site_path: coerceString(parsed["site"]), + details, + }, + }; +} + +export async function runDrushPml( + state: ServerState, + runner: CliRunner = runCliCommand, +): Promise> { + const ensured = ensureConfig(state); + if (!("config" in ensured)) { + return ensured; + } + const { config } = ensured; + const ttlMs = config.cacheTtlMs?.pml ?? 5000; + + return pmlCache.get(ttlMs, async () => { + const command = resolveDrushCommand(config); + const args = ["pm:list", "--format=json"]; + const cliResult = await runner({ + command, + args, + cwd: config.drupalRoot, + timeoutMs: config.timeouts?.drushPmlMs ?? 15000, + maxParallel: config.maxParallelCli ?? 1, + }); + + if (cliResult.timedOut || cliResult.exitCode !== 0) { + return mapCliResultToError(cliResult, { + command, + args, + cwd: config.drupalRoot, + }, { + missingBinaryCode: "E_DRUSH_NOT_FOUND", + missingBinaryMessage: + "Drush executable was not found. Install Drush or update the configuration.", + }); + } + + let parsed: unknown; + try { + parsed = parseJsonOutput(cliResult.stdout); + } catch (error) { + return { + status: "error", + error: { + code: "E_JSON_PARSE", + message: "Failed to parse Drush pm:list output as JSON", + diagnostics: { + command, + args, + }, + details: { error: (error as Error).message }, + stderr: truncateStderr(cliResult.stderr), + }, + }; + } + + const data = normalisePmlOutput(parsed, config); + return { + status: "ok", + data, + }; + }); +} + +function normalisePmlOutput(raw: unknown, config: ServerConfig): DrushPmlData { + const modules = normaliseExtensions(raw, "modules", config, "module"); + const themes = normaliseExtensions(raw, "themes", config, "theme") as ThemeDescriptor[]; + return { modules, themes }; +} + +type ExtensionKind = "module" | "theme"; + +function normaliseExtensions( + raw: unknown, + sectionKey: string, + config: ServerConfig, + kind: ExtensionKind, +): ModuleDescriptor[] { + if (!raw || typeof raw !== "object") { + return []; + } + + const container = + (raw as Record)[sectionKey] ?? raw; + + const items: Array<{ name: string; info: any }> = []; + + if (Array.isArray(container)) { + for (const entry of container) { + if (entry && typeof entry === "object") { + const name = + typeof (entry as any).name === "string" + ? (entry as any).name + : typeof (entry as any).machine_name === "string" + ? (entry as any).machine_name + : ""; + items.push({ name, info: entry }); + } + } + } else if (container && typeof container === "object") { + for (const [name, value] of Object.entries(container)) { + items.push({ name, info: value }); + } + } + + return items.map(({ name, info }) => { + const rawStatus = + typeof info === "object" && info + ? (info as any).status ?? (info as any).state ?? "" + : ""; + const normalizedStatus = + typeof rawStatus === "string" && rawStatus.toLowerCase().includes("enable") + ? "enabled" + : "disabled"; + + const resolvedInfo = info as Record; + const fallbackName = + typeof resolvedInfo?.name === "string" + ? (resolvedInfo.name as string) + : typeof resolvedInfo?.machine_name === "string" + ? (resolvedInfo.machine_name as string) + : "unknown"; + + const descriptor: ModuleDescriptor = { + name: name || fallbackName, + type: classifyExtension(resolvedInfo, config, kind), + status: normalizedStatus as "enabled" | "disabled", + }; + return descriptor; + }); +} + +function classifyExtension( + info: unknown, + config: ServerConfig, + kind: ExtensionKind, +): "core" | "contrib" | "custom" | "unknown" { + if (!info || typeof info !== "object") { + return "unknown"; + } + + const packageName = String((info as any).package ?? "").toLowerCase(); + if (packageName.includes("core")) { + return "core"; + } + if (packageName.includes("contrib")) { + return "contrib"; + } + if (packageName.includes("custom")) { + return "custom"; + } + + const extensionPath = (info as any).path ?? (info as any).extensionPath; + if (typeof extensionPath === "string") { + const projectRoot = resolveProjectRoot(config); + const relativePath = toProjectRelativePath(projectRoot, extensionPath).replace( + /\\/g, + "/", + ); + const dirs = + kind === "module" + ? config.customModuleDirs ?? [] + : config.customThemeDirs ?? []; + if (dirs.some((dir) => relativePath.startsWith(dir.replace(/\\/g, "/")))) { + return "custom"; + } + if (relativePath.startsWith("core/")) { + return "core"; + } + if (relativePath.includes("/contrib/")) { + return "contrib"; + } + } + + return "unknown"; +} diff --git a/packages/server/src/features/errorMapping.ts b/packages/server/src/features/errorMapping.ts new file mode 100644 index 0000000..97b045c --- /dev/null +++ b/packages/server/src/features/errorMapping.ts @@ -0,0 +1,75 @@ +import type { CliExecutionResult } from "./sandboxExecution.js"; +import type { ResourceOrToolResponse } from "../types.js"; + +const MAX_STDERR_LENGTH = 2000; + +export interface CliErrorContext { + command: string; + args: string[]; + cwd: string; +} + +export interface CliErrorOptions { + missingBinaryCode: string; + missingBinaryMessage: string; +} + +export function mapCliResultToError( + result: CliExecutionResult, + context: CliErrorContext, + options: CliErrorOptions, +): ResourceOrToolResponse { + const diagnostics = { + command: context.command, + args: context.args, + cwd: context.cwd, + exitCode: result.exitCode, + durationMs: result.durationMs, + }; + + if (result.timedOut) { + return { + status: "timeout", + error: { + code: "E_TIMEOUT", + message: `${context.command} ${context.args.join(" ")} exceeded timeout`, + diagnostics, + stderr: truncateStderr(result.stderr), + }, + }; + } + + if (result.exitCode === null) { + return { + status: "error", + error: { + code: options.missingBinaryCode, + message: options.missingBinaryMessage, + diagnostics, + stderr: truncateStderr(result.stderr), + }, + }; + } + + return { + status: "error", + error: { + code: "E_CLI_NONZERO_EXIT", + message: `${context.command} exited with code ${result.exitCode}`, + diagnostics, + stderr: truncateStderr(result.stderr), + }, + }; +} + +export function truncateStderr(stderr: string | undefined): string | undefined { + if (!stderr) { + return undefined; + } + const trimmed = stderr.trim(); + if (trimmed.length <= MAX_STDERR_LENGTH) { + return trimmed; + } + return `${trimmed.slice(0, MAX_STDERR_LENGTH)}…`; +} + diff --git a/packages/server/src/features/projectManifest.ts b/packages/server/src/features/projectManifest.ts new file mode 100644 index 0000000..b629cc8 --- /dev/null +++ b/packages/server/src/features/projectManifest.ts @@ -0,0 +1,222 @@ +import type { + ErrorDetail, + ResourceOrToolResponse, + ServerConfig, + ServerState, +} from "../types.js"; +import { resolveProjectRoot, readJsonFile, toProjectRelativePath } from "./projectPaths.js"; +import path from "node:path"; +import fs from "node:fs"; + +interface ComposerError { + code: string; + message: string; +} + +interface ComposerSummary { + status: "ok" | "partial" | "missing"; + name?: string; + require?: Record; + errors?: ComposerError[]; +} + +interface CustomModule { + name: string; + path: string; +} + +interface CustomTheme { + name: string; + path: string; +} + +export interface ProjectManifestData { + schema_version: "0.1.0"; + drupal_root: string; + drupal_core_version: string | null; + project_type: string | null; + composer: ComposerSummary; + custom_modules: CustomModule[]; + custom_themes: CustomTheme[]; +} + +export type ProjectManifestResponse = ResourceOrToolResponse; + +function extractDrupalCoreVersion(lockJson: any): string | null { + if (!lockJson) { + return null; + } + + const packages: any[] = Array.isArray(lockJson.packages) ? lockJson.packages : []; + const packagesDev: any[] = Array.isArray(lockJson["packages-dev"]) + ? lockJson["packages-dev"] + : []; + const allPackages = [...packages, ...packagesDev]; + + const corePackage = + allPackages.find((pkg) => pkg.name === "drupal/core-recommended") ?? + allPackages.find((pkg) => pkg.name === "drupal/core"); + + return typeof corePackage?.version === "string" ? corePackage.version : null; +} + +function deriveProjectType(composerJson: any | null): string | null { + if (!composerJson || typeof composerJson !== "object") { + return null; + } + + const require = composerJson.require ?? {}; + if (require && typeof require === "object" && "drupal/core-recommended" in require) { + return "drupal-recommended-project"; + } + + if (typeof composerJson.type === "string" && composerJson.type.length > 0) { + return composerJson.type; + } + + return null; +} + +function summariseComposer( + projectRoot: string, +): { summary: ComposerSummary; coreVersion: string | null } { + const composerPath = path.join(projectRoot, "composer.json"); + const lockPath = path.join(projectRoot, "composer.lock"); + const errors: ComposerError[] = []; + + const composerJson = readJsonFile(composerPath); + if (!composerJson) { + return { + summary: { + status: "missing", + errors: [ + { + code: "E_MANIFEST_INCOMPLETE", + message: `composer.json not found or unreadable at ${composerPath}`, + }, + ], + }, + coreVersion: null, + }; + } + + const summary: ComposerSummary = { + status: "ok", + }; + + if (typeof composerJson.name === "string") { + summary.name = composerJson.name; + } + if (composerJson.require && typeof composerJson.require === "object") { + summary.require = composerJson.require as Record; + } + + const lockJson = readJsonFile(lockPath); + const coreVersion = extractDrupalCoreVersion(lockJson); + + if (!lockJson) { + errors.push({ + code: "E_MANIFEST_INCOMPLETE", + message: `composer.lock not found or unreadable at ${lockPath}`, + }); + } + + if (errors.length > 0) { + summary.status = summary.name || summary.require ? "partial" : "missing"; + summary.errors = errors; + } + + return { summary, coreVersion }; +} + +function discoverCustomItems( + projectRoot: string, + relativeDirs: string[] | undefined, +): CustomModule[] { + if (!relativeDirs || relativeDirs.length === 0) { + return []; + } + + const results: CustomModule[] = []; + + for (const rel of relativeDirs) { + const baseDir = path.resolve(projectRoot, rel); + if (!fs.existsSync(baseDir) || !fs.statSync(baseDir).isDirectory()) { + continue; + } + const entries = fs.readdirSync(baseDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + const fullPath = path.join(baseDir, entry.name); + const projectRelative = toProjectRelativePath(projectRoot, fullPath); + results.push({ + name: entry.name, + path: projectRelative, + }); + } + } + + return results; +} + +async function buildManifest(config: ServerConfig): Promise { + const projectRoot = resolveProjectRoot(config); + const { summary: composerSummary, coreVersion } = summariseComposer(projectRoot); + + const customModules = discoverCustomItems(projectRoot, config.customModuleDirs); + const customThemes = discoverCustomItems(projectRoot, config.customThemeDirs); + + const manifest: ProjectManifestData = { + schema_version: "0.1.0", + drupal_root: config.drupalRoot, + drupal_core_version: coreVersion, + project_type: deriveProjectType( + readJsonFile(path.join(projectRoot, "composer.json")), + ), + composer: composerSummary, + custom_modules: customModules, + custom_themes: customThemes, + }; + + if (composerSummary.status === "ok") { + return { + status: "ok", + data: manifest, + }; + } + + const error: ErrorDetail = { + code: "E_MANIFEST_INCOMPLETE", + message: "Composer metadata could not be fully read for this project", + diagnostics: { + composerStatus: composerSummary.status, + composerErrors: composerSummary.errors, + }, + }; + + return { + status: "degraded", + data: manifest, + error, + }; +} + +export async function getProjectManifest( + state: ServerState, +): Promise { + if (!state.config) { + return { + status: "not_configured", + error: + state.configError ?? + ({ + code: "E_CONFIG_INVALID_ROOT", + message: "DriftCore configuration is missing or invalid", + } as ErrorDetail), + }; + } + + return buildManifest(state.config as ServerConfig); +} diff --git a/packages/server/src/features/projectPaths.ts b/packages/server/src/features/projectPaths.ts new file mode 100644 index 0000000..e857526 --- /dev/null +++ b/packages/server/src/features/projectPaths.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { ServerConfig } from "../types.js"; + +export function resolveProjectRoot(config: ServerConfig): string { + const drupalRoot = path.resolve(config.drupalRoot); + const candidateComposer = path.join(drupalRoot, "composer.json"); + if (fs.existsSync(candidateComposer)) { + return drupalRoot; + } + + const parent = path.dirname(drupalRoot); + const parentComposer = path.join(parent, "composer.json"); + if (fs.existsSync(parentComposer)) { + return parent; + } + + return drupalRoot; +} + +export function readJsonFile(filePath: string): T | null { + try { + const raw = fs.readFileSync(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +export function toProjectRelativePath(projectRoot: string, targetPath: string): string { + const absoluteTarget = path.isAbsolute(targetPath) + ? targetPath + : path.join(projectRoot, targetPath); + return path.relative(projectRoot, absoluteTarget); +} + diff --git a/packages/server/src/features/sandboxExecution.ts b/packages/server/src/features/sandboxExecution.ts index 444c740..f922626 100644 --- a/packages/server/src/features/sandboxExecution.ts +++ b/packages/server/src/features/sandboxExecution.ts @@ -1,3 +1,6 @@ +import { spawn } from "node:child_process"; +import process from "node:process"; + // TODO: Provide isolated sandbox execution for user-supplied scripts. export interface SandboxExecutionOptions { code: string; @@ -5,8 +8,133 @@ export interface SandboxExecutionOptions { } export async function executeInSandbox( - _options: SandboxExecutionOptions + _options: SandboxExecutionOptions, ): Promise<{ output: string }> { // Placeholder for sandbox execution - returns canned response. return { output: "Sandbox execution not yet implemented" }; } + +export interface CliExecutionOptions { + command: string; + args: string[]; + cwd: string; + env?: NodeJS.ProcessEnv; + timeoutMs?: number; + maxParallel?: number; +} + +export interface CliExecutionResult { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + durationMs: number; +} + +type ResolveFn = () => void; +const pendingQueue: ResolveFn[] = []; +let activeProcesses = 0; + +function scheduleExecution(maxParallel: number, executor: () => void) { + if (activeProcesses < maxParallel) { + activeProcesses += 1; + executor(); + return; + } + pendingQueue.push(() => { + activeProcesses += 1; + executor(); + }); +} + +function finalizeExecution() { + activeProcesses = Math.max(0, activeProcesses - 1); + const next = pendingQueue.shift(); + if (next) { + next(); + } +} + +export async function runCliCommand( + options: CliExecutionOptions, +): Promise { + const { command, args, cwd, env, timeoutMs, maxParallel = 1 } = options; + const start = Date.now(); + + return new Promise((resolve) => { + scheduleExecution(maxParallel, () => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + detached: process.platform !== "win32", + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + + const cleanup = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + finalizeExecution(); + }; + + const killProcessTree = () => { + if (child.pid) { + try { + if (process.platform !== "win32") { + process.kill(-child.pid, "SIGKILL"); + } + } catch { + // ignore + } + } + child.kill("SIGKILL"); + }; + + if (timeoutMs && timeoutMs > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + killProcessTree(); + }, timeoutMs); + } + + child.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + child.on("error", (error) => { + cleanup(); + const durationMs = Date.now() - start; + const message = (error as Error).message; + resolve({ + stdout, + stderr: stderr || message, + exitCode: null, + timedOut, + durationMs, + }); + }); + + child.on("close", (code) => { + cleanup(); + const durationMs = Date.now() - start; + resolve({ + stdout, + stderr, + exitCode: code, + timedOut, + durationMs, + }); + }); + }); + }); +} diff --git a/packages/server/src/features/schemaResources.ts b/packages/server/src/features/schemaResources.ts index 4b3833c..5a772a0 100644 --- a/packages/server/src/features/schemaResources.ts +++ b/packages/server/src/features/schemaResources.ts @@ -68,6 +68,18 @@ const exportedConfiguration = { }, }; +const projectManifestTemplate = { + schema_version: "0.1.0", + drupal_root: "", + drupal_core_version: null, + project_type: null, + composer: { + status: "missing", + }, + custom_modules: [], + custom_themes: [], +}; + export function listSchemaResources(): MCPResource[] { return [ { @@ -86,5 +98,14 @@ export function listSchemaResources(): MCPResource[] { mimeType: "application/json", data: exportedConfiguration, }, + { + id: "project_manifest", + name: "Drupal project manifest", + description: + "Summarised Drupal project context including core version, Composer dependencies, and custom modules/themes.", + source: "drupal:project_manifest", + mimeType: "application/json", + data: projectManifestTemplate, + }, ]; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 2061696..21e279d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,16 +2,60 @@ import { createInterface } from "node:readline"; import http from "node:http"; import { stdioTransport } from "./transports/stdio.js"; import { httpTransport } from "./transports/http.js"; -import type { MCPServerOptions } from "./types.js"; +import type { MCPServerOptions, OperationMeta } from "./types.js"; +import { loadServerConfig } from "./config.js"; import { listSchemaResources } from "./features/schemaResources.js"; import { getDrushTools } from "./features/drushTools.js"; +import { getComposerTools } from "./features/composerTools.js"; export function createMCPServer(options: MCPServerOptions = {}) { const { logger = console } = options; + const loadedConfig = loadServerConfig({ + logger, + configPath: options.configPath, + }); + + if (!loadedConfig.config) { + logger.warn?.( + `DriftCore server is running without a valid configuration${ + loadedConfig.error ? ` (${loadedConfig.error.message})` : "" + }`, + ); + } + + async function withOperationLogging( + meta: OperationMeta, + executor: () => Promise | T, + ): Promise { + const start = Date.now(); + try { + const result = await executor(); + const status = + result && typeof result === "object" && "status" in (result as Record) + ? ((result as Record).status as string) + : "ok"; + logger.info?.( + `[mcp] kind=${meta.kind} name=${meta.name} status=${status} durationMs=${Date.now() - start}`, + ); + return result; + } catch (error) { + logger.error?.( + `[mcp] kind=${meta.kind} name=${meta.name} status=exception durationMs=${ + Date.now() - start + }`, + error, + ); + throw error; + } + } + const serverState = { resources: options.resources ?? listSchemaResources(), - tools: options.tools ?? getDrushTools(), + tools: options.tools ?? [...getDrushTools(), ...getComposerTools()], logger, + config: loadedConfig.config, + configError: loadedConfig.error, + runOperation: withOperationLogging, }; return { diff --git a/packages/server/src/integration/smoke.ts b/packages/server/src/integration/smoke.ts index f065520..c431671 100644 --- a/packages/server/src/integration/smoke.ts +++ b/packages/server/src/integration/smoke.ts @@ -1,12 +1,32 @@ import assert from "node:assert/strict"; +import type { AddressInfo } from "node:net"; import { createMCPServer } from "../index.js"; export async function runServerSmokeTest() { const server = createMCPServer({ logger: console }); assert.ok(server, "Server factory should return handlers"); const httpServer = await server.handleHttp(0); - const address = httpServer.address(); + const address = httpServer.address() as AddressInfo; assert.ok(address, "Server should have a bound address"); + + const baseUrl = `http://localhost:${address.port}`; + + const health = await fetch(`${baseUrl}/health`).then((res) => res.json()); + assert.equal(health.status, "ok"); + assert.equal(typeof health.tools, "number"); + + const resources = await fetch(`${baseUrl}/resources`).then((res) => res.json()); + assert.ok(Array.isArray(resources.resources)); + + const manifest = await fetch(`${baseUrl}/project-manifest`).then((res) => res.json()); + assert.ok(manifest.status); + + const drushStatus = await fetch(`${baseUrl}/drush/status`).then((res) => res.json()); + assert.ok(drushStatus.status); + + const composerInfo = await fetch(`${baseUrl}/composer/info`).then((res) => res.json()); + assert.ok(composerInfo.status); + await new Promise((resolve) => httpServer.close(() => resolve())); } diff --git a/packages/server/src/transports/http.ts b/packages/server/src/transports/http.ts index 25b3452..54ada8e 100644 --- a/packages/server/src/transports/http.ts +++ b/packages/server/src/transports/http.ts @@ -1,36 +1,116 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ServerState } from "../types.js"; +import { getProjectManifest } from "../features/projectManifest.js"; +import { runDrushStatus, runDrushPml } from "../features/drushTools.js"; +import { runComposerInfo, runComposerOutdated } from "../features/composerTools.js"; function sendJson(res: ServerResponse, status: number, payload: unknown) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(payload, null, 2)); } -export function httpTransport( - req: IncomingMessage, - res: ServerResponse, - state: ServerState -) { +async function handleRoute(req: IncomingMessage, res: ServerResponse, state: ServerState) { state.logger.info?.(`HTTP ${req.method} ${req.url}`); - - if (req.method === "GET" && req.url === "/health") { - sendJson(res, 200, { - status: "ok", - tools: state.tools.length, - resources: state.resources.length, - }); - return; - } - - if (req.method === "GET" && req.url === "/resources") { - sendJson(res, 200, { resources: state.resources }); + if (req.method !== "GET" || !req.url) { + sendJson(res, 405, { error: "Method Not Allowed" }); return; } - if (req.method === "GET" && req.url === "/tools") { - sendJson(res, 200, { tools: state.tools }); - return; + switch (req.url) { + case "/health": + sendJson( + res, + 200, + await state.runOperation( + { name: "health", kind: "transport" }, + () => ({ + status: "ok", + configured: Boolean(state.config), + tools: state.tools.length, + resources: state.resources.length, + }), + ), + ); + return; + case "/resources": + sendJson( + res, + 200, + await state.runOperation( + { name: "list_resources", kind: "transport" }, + () => ({ resources: state.resources }), + ), + ); + return; + case "/tools": + sendJson( + res, + 200, + await state.runOperation( + { name: "list_tools", kind: "transport" }, + () => ({ tools: state.tools }), + ), + ); + return; + case "/project-manifest": { + const response = await state.runOperation( + { name: "project_manifest", kind: "resource" }, + () => getProjectManifest(state), + ); + sendJson(res, 200, response); + return; + } + case "/drush/status": { + const response = await state.runOperation( + { name: "drush_status", kind: "tool" }, + () => runDrushStatus(state), + ); + sendJson(res, 200, response); + return; + } + case "/drush/pml": { + const response = await state.runOperation( + { name: "drush_pml", kind: "tool" }, + () => runDrushPml(state), + ); + sendJson(res, 200, response); + return; + } + case "/composer/info": { + const response = await state.runOperation( + { name: "composer_info", kind: "tool" }, + () => runComposerInfo(state), + ); + sendJson(res, 200, response); + return; + } + case "/composer/outdated": { + const response = await state.runOperation( + { name: "composer_outdated", kind: "tool" }, + () => runComposerOutdated(state), + ); + sendJson(res, 200, response); + return; + } + default: + sendJson(res, 404, { error: "Not Found" }); } +} - sendJson(res, 404, { error: "Not Found" }); +export function httpTransport( + req: IncomingMessage, + res: ServerResponse, + state: ServerState, +) { + handleRoute(req, res, state).catch((error) => { + state.logger.error?.("HTTP transport error", error); + sendJson(res, 500, { + status: "error", + error: { + code: "E_TRANSPORT_FAILURE", + message: "HTTP transport encountered an unexpected error", + details: { message: (error as Error).message }, + }, + }); + }); } diff --git a/packages/server/src/transports/stdio.ts b/packages/server/src/transports/stdio.ts index 0025828..bbd3f4f 100644 --- a/packages/server/src/transports/stdio.ts +++ b/packages/server/src/transports/stdio.ts @@ -1,13 +1,100 @@ import type { Interface } from "node:readline"; import type { ServerState } from "../types.js"; +import { getProjectManifest } from "../features/projectManifest.js"; +import { runDrushStatus, runDrushPml } from "../features/drushTools.js"; +import { runComposerInfo, runComposerOutdated } from "../features/composerTools.js"; + +interface StdioRequest { + id?: string | number; + action: string; +} export async function stdioTransport(rl: Interface, state: ServerState) { - state.logger.info?.("Starting MCP STDIO transport (placeholder implementation)"); + state.logger.info?.("Starting MCP STDIO transport"); + + const handleRequest = async (request: StdioRequest) => { + switch (request.action) { + case "resources": + return state.runOperation( + { name: "stdio_resources", kind: "transport" }, + () => ({ resources: state.resources }), + ); + case "tools": + return state.runOperation( + { name: "stdio_tools", kind: "transport" }, + () => ({ tools: state.tools }), + ); + case "project_manifest": + return state.runOperation( + { name: "project_manifest", kind: "resource" }, + () => getProjectManifest(state), + ); + case "drush_status": + return state.runOperation( + { name: "drush_status", kind: "tool" }, + () => runDrushStatus(state), + ); + case "drush_pml": + return state.runOperation( + { name: "drush_pml", kind: "tool" }, + () => runDrushPml(state), + ); + case "composer_info": + return state.runOperation( + { name: "composer_info", kind: "tool" }, + () => runComposerInfo(state), + ); + case "composer_outdated": + return state.runOperation( + { name: "composer_outdated", kind: "tool" }, + () => runComposerOutdated(state), + ); + default: + return { + status: "error", + error: { + code: "E_UNKNOWN_ACTION", + message: `Unknown stdio action: ${request.action}`, + }, + }; + } + }; rl.on("line", (line) => { - state.logger.info?.(`Received STDIO input: ${line}`); - // TODO: Replace echo logic with protocol-compliant message handling. - rl.write(`echo: ${line}\n`); + (async () => { + let parsed: StdioRequest; + try { + parsed = JSON.parse(line) as StdioRequest; + } catch { + rl.write( + JSON.stringify({ + status: "error", + error: { code: "E_PARSE", message: "STDIO input must be JSON" }, + }) + "\n", + ); + return; + } + + const response = await handleRequest(parsed); + rl.write( + JSON.stringify({ + id: parsed.id, + action: parsed.action, + response, + }) + "\n", + ); + })().catch((error) => { + rl.write( + JSON.stringify({ + status: "error", + error: { + code: "E_TRANSPORT_FAILURE", + message: "STDIO transport encountered an unexpected error", + details: { message: (error as Error).message }, + }, + }) + "\n", + ); + }); }); return new Promise((resolve) => { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 0ced341..2c17987 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -19,10 +19,92 @@ export interface MCPServerOptions { resources?: MCPResource[]; tools?: MCPTool[]; logger?: Pick; + /** + * Optional explicit configuration file path. If omitted, the loader + * will use DRIFTCORE_CONFIG or driftcore.config.json in CWD. + */ + configPath?: string; } +export interface OperationMeta { + name: string; + kind: "resource" | "tool" | "transport"; +} + +export type OperationLogger = ( + meta: OperationMeta, + executor: () => Promise | T, +) => Promise; + export interface ServerState { resources: MCPResource[]; tools: MCPTool[]; logger: Pick; + config: ServerConfig | null; + configError?: ErrorDetail; + runOperation: OperationLogger; +} + +export type ResponseStatus = + | "ok" + | "degraded" + | "error" + | "timeout" + | "not_configured"; + +export interface ErrorDetail { + code: string; + message: string; + diagnostics?: Record; + details?: Record; + exitCode?: number; + stderr?: string; +} + +export interface ResourceOrToolResponse { + status: ResponseStatus; + data?: TData; + error?: ErrorDetail; +} + +export interface TimeoutsConfig { + drushStatusMs?: number; + drushPmlMs?: number; + composerInfoMs?: number; + composerOutdatedMs?: number; +} + +export interface CacheTtlConfig { + projectManifest?: number; + pml?: number; +} + +export interface ServerConfig { + drupalRoot: string; + drushPath?: string; + composerPath?: string; + customModuleDirs?: string[]; + customThemeDirs?: string[]; + timeouts?: TimeoutsConfig; + maxParallelCli?: number; + cacheTtlMs?: CacheTtlConfig; +} + +export function makeOkResponse(data: TData): ResourceOrToolResponse { + return { status: "ok", data }; +} + +export function makeErrorResponse( + code: string, + message: string, + extras?: Partial, +): ResourceOrToolResponse { + return { + status: "error", + error: { + code, + message, + ...extras, + }, + }; } diff --git a/specs/001-driftcore-single-project/spec.md b/specs/001-driftcore-single-project/spec.md new file mode 100644 index 0000000..3b6c314 --- /dev/null +++ b/specs/001-driftcore-single-project/spec.md @@ -0,0 +1,382 @@ +# Feature Specification: DriftCore v0.1 Single-Project MCP Server + +**Feature Branch**: `[001-driftcore-single-project]` +**Created**: 2025-11-18 +**Status**: Draft +**Input**: User description: "DriftCore v0.1 will be a single-project MCP server that attaches to a Drupal codebase and exposes read only, structured insight into that project for external agents. It must load a configurable Drupal root, detect Drupal core version, custom modules, and themes, and expose this via a `project_manifest` resource. It must provide a small, fixed set of tools that run safe, whitelisted Drush and Composer commands (`drush status`, module and theme listings, composer manifest inspection, and outdated package checks) and return predictable JSON style structures rather than raw text. No tools in v0.1 are allowed to modify code, config, or the database, and all errors must be surfaced as structured, human understandable responses without crashing the server. The system must be simple to configure, stable even when Drush or Composer fail, and documented clearly enough that a Drupal developer can point it at a project, connect an MCP compatible client, and use these resources and tools without needing to understand the internal implementation." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Discover real project context (Priority: P1) + +As a Drupal developer, I can point DriftCore at a single Drupal project and retrieve a `project_manifest` resource that accurately reports the Drupal root, core version, Composer dependencies, and custom modules/themes, so that agents can reason about the project without guessing. + +**Why this priority**: All other tools and behaviors depend on having correct, project-aware context. Without reliable `project_manifest` data, agents cannot safely propose changes or interpret Drush/Composer output. + +**Independent Test**: Start DriftCore against a known Drupal project, call `project_manifest` via an MCP client, and compare the returned structure to the actual filesystem and `composer.json` values (core version, custom module/theme paths, and dependencies). + +**Acceptance Scenarios**: + +1. **Given** a valid Drupal project root with Drupal core and `composer.json`, **When** I start DriftCore and call `project_manifest`, **Then** I see `drupal_root`, `drupal_core_version`, `project_type`, `composer` (with `name` and `require`), `custom_modules`, and `custom_themes` that match the project on disk. +2. **Given** a Drupal project with no custom modules or themes, **When** I call `project_manifest`, **Then** `custom_modules` and `custom_themes` are present and empty arrays (not omitted). +3. **Given** a project where Composer metadata cannot be fully read, **When** I call `project_manifest`, **Then** I receive a valid response with a `composer` field that clearly indicates which data is unavailable instead of crashing or returning malformed JSON. + +--- + +### User Story 2 - Inspect project state via Drush and Composer (Priority: P2) + +As a Drupal developer, I can use DriftCore MCP tools to run safe, whitelisted Drush and Composer commands (status, module/theme lists, manifest, outdated) and receive predictable structured results so that I can understand project health and dependencies without running shell commands manually. + +**Why this priority**: Once project context is available, inspection tools are the main way agents validate assumptions (modules enabled, versions, outdated packages) and plan future work. They must be safe, read only, and consistent. + +**Independent Test**: With DriftCore pointed at a Drupal project, call each tool (`drift.drush_status`, `drift.drush_pml`, `drift.composer_info`, `drift.composer_outdated`) from an MCP client and verify that the returned JSON-style structures match the output of the underlying CLI commands and never modify the project. + +**Acceptance Scenarios**: + +1. **Given** a valid project and working Drush, **When** I call `drift.drush_status`, **Then** I receive a structured object including at least `drupal_version`, `php_version`, `database_driver`, `site_path`, and a details map, and **And** no project files are modified. +2. **Given** a valid project, **When** I call `drift.drush_pml`, **Then** I receive `modules` and `themes` arrays with `name`, `type` (core/contrib/custom/unknown), and `status` (enabled/disabled), and the tool does not accept arbitrary Drush flags from the client. +3. **Given** a valid project, **When** I call `drift.composer_info`, **Then** I receive a structured `manifest` (with `name` and `require`) and optional `lock_summary`, and Composer files on disk remain unchanged. +4. **Given** a valid project, **When** I call `drift.composer_outdated`, **Then** I receive an array of packages with `name`, `current_version`, `latest_version`, and status, and the command is executed from the configured project root without changing any dependencies. + +--- + +### User Story 3 - Robust error handling and simple MCP integration (Priority: P3) + +As a Drupal developer using an MCP-compatible client, I can configure DriftCore against a project and see clear, structured error responses whenever Drush or Composer fail so that I can diagnose configuration problems without the server crashing or hanging. + +**Why this priority**: In real projects, Drush or Composer may be missing, misconfigured, or slow. Agents and humans must be able to handle these failures gracefully through structured responses instead of brittle text parsing or opaque crashes. + +**Independent Test**: Misconfigure Drush or Composer (or point DriftCore at an invalid project root), call each tool and `project_manifest`, and verify that the MCP client receives structured `status` and `error` fields describing what went wrong while the server remains responsive. + +**Acceptance Scenarios**: + +1. **Given** an invalid Drupal root in configuration, **When** I start DriftCore or call `project_manifest`, **Then** I receive a clear, human-readable error description and a machine-readable error object, and the MCP server either refuses to start cleanly or enters a degraded but stable mode. +2. **Given** Drush is not installed or not runnable, **When** I call `drift.drush_status` or `drift.drush_pml`, **Then** I receive a structured error that explicitly states Drush is unavailable or misconfigured, and the MCP server remains up. +3. **Given** Composer is not installed or `composer outdated` times out, **When** I call `drift.composer_outdated`, **Then** I receive a structured `status` (for example, `timeout` or `error`) and an `error` object with a human-understandable message and diagnostics, and no dependency changes are attempted. + +--- + +### Edge Cases + +- What happens when the configured project root points to a directory that is not a Drupal installation? +- How does the system behave when `composer.json` is missing but a Drupal core directory exists? +- What happens when the Drush or Composer commands are present but return non-zero exit codes (for example, due to PHP errors or missing extensions)? +- How does DriftCore handle large module/theme lists or long-running Composer commands without blocking the MCP server indefinitely? +- What happens when the underlying project changes on disk (new custom modules/themes added) while DriftCore is running? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The MCP server MUST represent exactly one Drupal project per running instance and MUST execute all underlying Drush and Composer commands from the configured project root directory. +- **FR-002**: The system MUST expose a `project_manifest` resource that returns `schema_version`, `drupal_root`, `drupal_core_version`, `project_type`, `composer` (with `name` and `require`), and arrays of `custom_modules` and `custom_themes` (present even when empty). +- **FR-003**: The system MUST provide read-only tools: `drift.drush_status`, `drift.drush_pml`, `drift.composer_info`, and `drift.composer_outdated`, each implemented as a fixed, whitelisted command that does not accept arbitrary flags or shell input from the client. +- **FR-004**: No v0.1 tool or resource MAY perform persistent write operations to the project’s code, configuration, or database; all operations are inspection-only. +- **FR-005**: All tool and resource responses MUST include a machine-readable `status` field and, on failure, a structured `error` object with at least a code and human-understandable message. +- **FR-006**: When Drush or Composer are unavailable, misconfigured, or return non-zero exit codes, the corresponding tools MUST return structured errors without crashing or terminating the MCP server. +- **FR-007**: The system MUST accept configuration that specifies the Drupal project root and optional overrides for Drush/Composer invocation, and it MUST either fail fast with clear configuration errors or apply documented defaults. +- **FR-008**: Each resource and tool MUST be documented (in repo docs) with purpose, inputs, outputs (schema), and usage examples that emphasize read-only, project-aware behavior. + +### Key Entities *(include if feature involves data)* + +- **Project Manifest**: Represents the current Drupal project context as seen from the configured root, including core version, Composer dependencies, and discovered custom modules/themes. Used by agents as the primary grounding resource. +- **Tool Result**: A structured response envelope for all tools and resources that includes `status`, optional `error`, and tool-specific payload fields (for example, Drush status values, module lists, Composer package details). + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A Drupal developer can configure DriftCore against an existing Drupal project, connect an MCP-compatible client, and successfully call `project_manifest`, `drift.drush_status`, `drift.drush_pml`, and `drift.composer_outdated` using only the provided documentation. +- **SC-002**: Automated tests verify that none of the v0.1 tools or resources perform persistent writes to the project’s code, configuration, or database when invoked under normal or error conditions. +- **SC-003**: In controlled failure scenarios (invalid project root, missing Drush, missing Composer, command timeouts), the MCP server remains responsive and returns structured `status` and `error` fields for 100% of tool calls. +- **SC-004**: For a sample Drupal project, the values returned by `project_manifest` and the Drush/Composer tools match the actual project state (core version, custom modules/themes, dependencies, outdated packages) within acceptable tolerances (for example, differences only where underlying tools disagree). + +--- + +## API surface and schemas (normative) + +This section formalizes the MCP-facing API for v0.1. All resources and tools MUST wrap their payloads in a common response envelope and MUST NOT accept arbitrary CLI flags or shell input. + +### Response envelope + +All resource and tool responses MUST conform to this envelope: + +```jsonc +{ + "status": "ok" | "error" | "timeout" | "degraded" | "not_configured", + "error"?: { + "code": string, // e.g., "E_DRUSH_NOT_FOUND", "E_TIMEOUT" + "message": string, // human-readable summary + "details"?: object, // tool-specific diagnostics (safe to surface) + "exitCode"?: number, // when a subprocess fails + "stderr"?: string, // redacted/truncated as needed + "diagnostics"?: object // timing, command name, cwd, paths used + }, + "data"?: object // tool/resource-specific payload +} +``` + +Notes: + +- On success, `status=ok` and `data` is present. +- On any failure, `status` is not `ok` and `error` is present; `data` MAY be omitted or partial (if partial, set `status=degraded`). + +### Resource: project_manifest + +Request: no parameters. + +Response `data` schema: + +```jsonc +{ + "schema_version": "0.1.0", + "drupal_root": "", // absolute path + "drupal_core_version": "" | null, // null if unknown + "project_type": "" | null, // e.g., "drupal-recommended-project" + "composer": { + "status": "ok" | "partial" | "missing", + "name"?: "", + "require"?: { "": "" }, + "errors"?: [ { "code": "", "message": "" } ] + }, + "custom_modules": [ { "name": "", "path": "" } ], + "custom_themes": [ { "name": "", "path": "" } ] +} +``` + +Behavioral notes: + +- `custom_modules` and `custom_themes` MUST be present even when empty. +- If Composer metadata cannot be fully read, set `composer.status` to `partial` or `missing` instead of failing the entire resource. + +### Tool: drift.drush_status + +Request: no parameters. + +Response `data` schema: + +```jsonc +{ + "drupal_version": "" | null, + "php_version": "" | null, + "database_driver": "" | null, + "site_path": "" | null, + "details": { "": "" } +} +``` + +### Tool: drift.drush_pml + +Request: no parameters (no passthrough flags allowed). + +Response `data` schema: + +```jsonc +{ + "modules": [ + { + "name": "", + "type": "core" | "contrib" | "custom" | "unknown", + "status": "enabled" | "disabled" + } + ], + "themes": [ + { + "name": "", + "type": "core" | "contrib" | "custom" | "unknown", + "status": "enabled" | "disabled" + } + ] +} +``` + +### Tool: drift.composer_info + +Request: no parameters. + +Response `data` schema: + +```jsonc +{ + "manifest": { + "name"?: "", + "require"?: { "": "" } + }, + "lock_summary"?: { + "packages"?: [ + { "name": "", "version": "" } + ] + } +} +``` + +### Tool: drift.composer_outdated + +Request: no parameters. + +Response `data` schema: + +```jsonc +{ + "packages": [ + { + "name": "", + "current_version": "", + "constraint"?: "", // from composer.json + "latest_version": "" | null, + "latest_status": "semver-safe-update" | "update-possible" | "unknown", + "package_type"?: "drupal-core" | "drupal-module" | "drupal-theme" | "library" + } + ] +} +``` + +--- + +## Configuration (normative) + +The server MUST accept configuration via JSON (exact file path documented in repo README). Keys and defaults: + +```jsonc +{ + "drupalRoot": string, // required; absolute path + "drushPath"?: string, // optional; absolute path to drush binary + "composerPath"?: string, // optional; absolute path to composer binary + "customModuleDirs"?: string[], // defaults: ["web/modules/custom", "modules/custom"] + "customThemeDirs"?: string[], // defaults: ["web/themes/custom", "themes/custom"] + "timeouts"?: { + "drushStatusMs"?: number, // default 10000 + "drushPmlMs"?: number, // default 15000 + "composerInfoMs"?: number, // default 8000 + "composerOutdatedMs"?: number // default 30000 + }, + "maxParallelCli"?: 1, // default 1 (serialize to avoid contention) + "cacheTtlMs"?: { + "projectManifest"?: number, // default 5000 + "pml"?: number // default 5000 + } +} +``` + +Validation: +- `drupalRoot` MUST exist and be a directory; otherwise the server fails fast with `status=not_configured` for all calls and an `E_CONFIG_INVALID_ROOT` error. +- If `drushPath`/`composerPath` are unset, PATH discovery is attempted; failures yield tool-specific errors without terminating the server. + +--- + +## Execution model and safety (normative) + +- All CLI invocations MUST use spawned processes without a shell (no `sh -c`), passing fixed arguments only. +- CWD MUST be the configured `drupalRoot`. +- No tool accepts user-provided flags or arbitrary args in v0.1. +- Environment variables MUST be sanitized; only required values are inherited. +- The server MUST never perform persistent writes to code, config, or the database. +- On non-zero exit codes, return `status=error` and include `exitCode` and a redacted/truncated `stderr` when useful. + +Concurrency: +- `maxParallelCli` defaults to 1. When >1, tools SHOULD queue to respect the limit; v0.1 MAY keep it at 1. + +--- + +## Timeouts and long-running commands + +Each tool MUST enforce a timeout (see configuration). On timeout: +- Kill the subprocess tree. +- Return `status=timeout` with error code `E_TIMEOUT` and elapsed time diagnostics. + +--- + +## Caching and change detection + +- `project_manifest` and `drift.drush_pml` MAY cache results for `cacheTtlMs` to reduce CLI load. +- Implement basic invalidation by tracking directory mtimes for custom module/theme roots when feasible. Manual refresh is achieved by simply waiting TTL; no cache-busting API is required in v0.1. + +--- + +## Error response contract and codes + +Common error codes (non-exhaustive): +- `E_CONFIG_INVALID_ROOT` +- `E_DRUSH_NOT_FOUND` +- `E_COMPOSER_NOT_FOUND` +- `E_CLI_NONZERO_EXIT` +- `E_TIMEOUT` +- `E_JSON_PARSE` +- `E_MANIFEST_INCOMPLETE` + +All failures MUST return the response envelope with a populated `error` object. Resources MAY degrade (partial data) with `status=degraded` rather than hard-failing when safe and useful. + +--- + +## Transports + +v0.1 supports at least one MCP transport. The implementation in this repo provides: +- stdio (default) +- http on localhost (optional) + +The chosen transport MUST be documented in the server package README with setup steps. + +--- + +## Examples + +project_manifest (success): + +```json +{ + "status": "ok", + "data": { + "schema_version": "0.1.0", + "drupal_root": "/path/to/project/web", + "drupal_core_version": "10.3.5", + "project_type": "drupal-recommended-project", + "composer": { + "status": "ok", + "name": "acme/site", + "require": { "drupal/core": "^10.3", "drupal/token": "^1.11" } + }, + "custom_modules": [ { "name": "acme_blog", "path": "web/modules/custom/acme_blog" } ], + "custom_themes": [] + } +} +``` + +drift.composer_outdated (timeout): + +```json +{ + "status": "timeout", + "error": { + "code": "E_TIMEOUT", + "message": "composer outdated exceeded 30000ms", + "diagnostics": { "command": "composer outdated --format=json", "elapsedMs": 30012 } + } +} +``` + +--- + +## Test plan additions + +Augment the existing user scenarios with automated tests: + +1. Contract tests for each resource/tool validating the envelope and schema (using JSON schema validation). +2. Snapshot tests comparing parsed outputs to golden files from known Drupal sandboxes (avoid exact version pinning when flaky). +3. Failure matrix: + - Invalid `drupalRoot` -> `not_configured` + `E_CONFIG_INVALID_ROOT`. + - Missing Drush -> `E_DRUSH_NOT_FOUND` for Drush tools only. + - Missing Composer -> `E_COMPOSER_NOT_FOUND` for Composer tools only. + - Non-zero exits (simulate PHP error) -> `E_CLI_NONZERO_EXIT` with `exitCode`. + - Timeouts -> `E_TIMEOUT` with elapsedMs. +4. Non-write verification: run tools and assert file mtimes under project root are unchanged. +5. Concurrency: when `maxParallelCli=1`, concurrent invocations queue and complete without overlap (assert ordering by timestamps in diagnostics). + +--- + +## Non-functional requirements + +- Logging: structured logs with level, tool, duration, status; exclude secrets; cap stderr length. +- Security: no shell invocation; fixed args only; sanitize env; validate paths; guard against path traversal when scanning custom dirs. +- Performance: default timeouts as configured; typical responses under 1s for `drush status` and `composer info` on a warm system. + +