From d2e0834498ca02a6ae8bfccabf903838260cf711 Mon Sep 17 00:00:00 2001 From: Robert Dean Date: Sat, 8 Nov 2025 19:15:52 -0500 Subject: [PATCH] Implement DriftCore MVP foundations --- .github/workflows/ci.yml | 38 ++++++ .gitignore | 4 + examples/README.md | 8 +- examples/drupal-sandbox/Dockerfile | 11 ++ examples/drupal-sandbox/README.md | 25 ++++ .../config/sync/system.site.yml | 9 ++ examples/drupal-sandbox/docker-compose.yml | 31 +++++ examples/drupal-sandbox/settings.php | 12 ++ packages/agent-runner/config/default.json | 5 +- packages/agent-runner/package.json | 3 + .../agent-runner/src/__tests__/sdk.test.ts | 34 ++++++ packages/agent-runner/src/index.ts | 112 +++++++++++++++++- .../agent-runner/src/integration/smoke.ts | 28 +++++ packages/agent-runner/src/sandbox.ts | 43 +++++++ packages/agent-runner/src/sdk.ts | 66 +++++++++++ packages/server/package.json | 5 +- .../src/__tests__/schemaResources.test.ts | 22 ++++ packages/server/src/bin/http.ts | 27 +++-- packages/server/src/features/drushTools.ts | 31 +++-- .../server/src/features/schemaResources.ts | 93 +++++++++++++-- packages/server/src/index.ts | 12 +- packages/server/src/integration/smoke.ts | 23 ++++ packages/server/src/transports/http.ts | 27 ++++- packages/server/src/types.ts | 25 +++- packages/server/src/types/yargs.d.ts | 19 +++ rfcs/RFC-0001-driftcore-mvp.md | 27 +++++ 26 files changed, 688 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 examples/drupal-sandbox/Dockerfile create mode 100644 examples/drupal-sandbox/README.md create mode 100644 examples/drupal-sandbox/config/sync/system.site.yml create mode 100644 examples/drupal-sandbox/docker-compose.yml create mode 100644 examples/drupal-sandbox/settings.php create mode 100644 packages/agent-runner/src/__tests__/sdk.test.ts create mode 100644 packages/agent-runner/src/integration/smoke.ts create mode 100644 packages/agent-runner/src/sandbox.ts create mode 100644 packages/agent-runner/src/sdk.ts create mode 100644 packages/server/src/__tests__/schemaResources.test.ts create mode 100644 packages/server/src/integration/smoke.ts create mode 100644 packages/server/src/types/yargs.d.ts create mode 100644 rfcs/RFC-0001-driftcore-mvp.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4753627 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: DriftCore CI + +on: + push: + branches: [ main ] + pull_request: + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: | + npm install --prefix packages/server + npm install --prefix packages/agent-runner + + - name: Lint + run: | + npm run lint --prefix packages/server + npm run lint --prefix packages/agent-runner + + - name: Unit tests + run: | + npm run test --prefix packages/server + npm run test --prefix packages/agent-runner + + - name: Integration checks + run: | + npm run integration --prefix packages/server + npm run integration --prefix packages/agent-runner diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfdb064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +**/node_modules/ +**/dist/ +**/.driftcore/ diff --git a/examples/README.md b/examples/README.md index 99b5d8d..ad7558e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,7 @@ # DriftCore Examples -This directory will host runnable examples demonstrating the MCP server, agent runner, -and extension integrations. +This directory hosts runnable examples demonstrating the MCP server, agent runner, and Drupal integrations. -- TODO: Add walkthrough for schema resource exploration. -- TODO: Add sandbox execution showcase. +## 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 new file mode 100644 index 0000000..e31eceb --- /dev/null +++ b/examples/drupal-sandbox/Dockerfile @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..bd94cbf --- /dev/null +++ b/examples/drupal-sandbox/README.md @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000..5b3fe86 --- /dev/null +++ b/examples/drupal-sandbox/config/sync/system.site.yml @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..5bb1c01 --- /dev/null +++ b/examples/drupal-sandbox/docker-compose.yml @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..405d1be --- /dev/null +++ b/examples/drupal-sandbox/settings.php @@ -0,0 +1,12 @@ + 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/config/default.json b/packages/agent-runner/config/default.json index 34eca24..038db97 100644 --- a/packages/agent-runner/config/default.json +++ b/packages/agent-runner/config/default.json @@ -1,4 +1,7 @@ { "serverEndpoint": "http://localhost:8080", - "transport": "http" + "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 index 9da8d30..0f1b293 100644 --- a/packages/agent-runner/package.json +++ b/packages/agent-runner/package.json @@ -5,6 +5,9 @@ "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": { diff --git a/packages/agent-runner/src/__tests__/sdk.test.ts b/packages/agent-runner/src/__tests__/sdk.test.ts new file mode 100644 index 0000000..4853dcb --- /dev/null +++ b/packages/agent-runner/src/__tests__/sdk.test.ts @@ -0,0 +1,34 @@ +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 index 40deba1..846fd06 100644 --- a/packages/agent-runner/src/index.ts +++ b/packages/agent-runner/src/index.ts @@ -1,24 +1,128 @@ +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() { - // TODO: Implement real orchestration logic connecting to MCP server. if (this.options.transport === "stdio") { - console.info("Starting agent runner in STDIO bridge mode"); + console.info("STDIO transport selected; falling back to local resource snapshot."); } else { console.info(`Connecting to MCP HTTP server at ${this.options.serverEndpoint}`); } - console.info("Agent runner stub initialized"); + + 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" }); + 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 new file mode 100644 index 0000000..aac10ff --- /dev/null +++ b/packages/agent-runner/src/integration/smoke.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..b967876 --- /dev/null +++ b/packages/agent-runner/src/sandbox.ts @@ -0,0 +1,43 @@ +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 new file mode 100644 index 0000000..37b1063 --- /dev/null +++ b/packages/agent-runner/src/sdk.ts @@ -0,0 +1,66 @@ +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/server/package.json b/packages/server/package.json index a09a4ad..4a44701 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,14 +5,17 @@ "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:stdio": "node dist/bin/stdio.js", "start:http": "node dist/bin/http.js" }, "dependencies": { - "@types/node": "^20.11.0", "yargs": "^17.7.2" }, "devDependencies": { + "@types/node": "^20.11.0", "typescript": "^5.3.3" } } diff --git a/packages/server/src/__tests__/schemaResources.test.ts b/packages/server/src/__tests__/schemaResources.test.ts new file mode 100644 index 0000000..b8c5bf6 --- /dev/null +++ b/packages/server/src/__tests__/schemaResources.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { listSchemaResources } from "../features/schemaResources.js"; +import { getDrushTools } from "../features/drushTools.js"; + +describe("schema resources", () => { + it("exposes the required Drupal resources", () => { + const resources = listSchemaResources(); + const ids = resources.map((resource) => resource.id); + assert.ok(ids.includes("schema.entityTypes")); + assert.ok(ids.includes("config.exported")); + }); +}); + +describe("drush tools", () => { + it("includes cache rebuild command", () => { + 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"); + }); +}); diff --git a/packages/server/src/bin/http.ts b/packages/server/src/bin/http.ts index 88e8d70..0acd95f 100644 --- a/packages/server/src/bin/http.ts +++ b/packages/server/src/bin/http.ts @@ -3,18 +3,23 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { createMCPServer } from "../index.js"; -const argv = await yargs(hideBin(process.argv)) - .option("port", { - alias: "p", - type: "number", - default: 8080, - describe: "Port for the MCP HTTP server", - }) - .help() - .parseAsync(); +async function main() { + const argv = (await yargs(hideBin(process.argv)) + .option("port", { + alias: "p", + type: "number", + default: 8080, + describe: "Port for the MCP HTTP server", + }) + .help() + .parseAsync()) as { port?: number }; -const server = createMCPServer(); -server.handleHttp(argv.port).catch((error) => { + const port = typeof argv.port === "number" ? argv.port : 8080; + const server = createMCPServer(); + await server.handleHttp(port); +} + +main().catch((error) => { console.error("HTTP server failed", error); process.exit(1); }); diff --git a/packages/server/src/features/drushTools.ts b/packages/server/src/features/drushTools.ts index 9f3c1e3..cad40eb 100644 --- a/packages/server/src/features/drushTools.ts +++ b/packages/server/src/features/drushTools.ts @@ -1,11 +1,24 @@ -// TODO: Integrate with Drush CLI and expose commands as MCP tools. -export interface DrushTool { - name: string; - description: string; - command: string; -} +import type { MCPTool } from "../types.js"; -export function getDrushTools(): DrushTool[] { - // Placeholder ensures consumers have a concrete shape during early integration. - return []; +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: "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", + ], + }, + ]; } diff --git a/packages/server/src/features/schemaResources.ts b/packages/server/src/features/schemaResources.ts index fedad14..4b3833c 100644 --- a/packages/server/src/features/schemaResources.ts +++ b/packages/server/src/features/schemaResources.ts @@ -1,11 +1,90 @@ -// TODO: Implement schema resource discovery and normalization. -export interface SchemaResourceDescriptor { - id: string; +import type { MCPResource } from "../types.js"; + +interface EntityFieldDescriptor { + name: string; + type: string; + description: string; +} + +interface EntityTypeDescriptor { + machineName: string; + label: string; description: string; - source: string; + fields: EntityFieldDescriptor[]; } -export function listSchemaResources(): SchemaResourceDescriptor[] { - // Placeholder returning empty set for now. - return []; +const coreEntityTypes: EntityTypeDescriptor[] = [ + { + 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." }, + ], + }, + { + machineName: "taxonomy_term", + label: "Taxonomy term", + description: "Classification entity for vocabularies and tagging systems.", + fields: [ + { name: "vid", type: "entity:taxonomy_vocabulary", description: "Vocabulary reference." }, + { name: "name", type: "string", description: "Term display name." }, + { name: "description", type: "text_long", description: "Optional descriptive text." }, + ], + }, +]; + +const exportedConfiguration = { + modules: [ + { name: "drupal", type: "core" }, + { name: "toolbar", type: "core" }, + { name: "block", type: "core" }, + { name: "config", type: "core" }, + ], + settings: { + site: { + name: "DriftCore Sandbox", + mail: "admin@example.com", + slogan: "Composable automation for Drupal", + }, + performance: { + cache: true, + pageCacheMaxAge: 900, + }, + }, +}; + +export function listSchemaResources(): MCPResource[] { + return [ + { + id: "schema.entityTypes", + name: "Drupal entity type registry", + description: "Normalized entity type definitions exported from the Drupal 11 sandbox.", + source: "drupal:config:core.entity_type", + mimeType: "application/json", + data: { entityTypes: coreEntityTypes }, + }, + { + id: "config.exported", + name: "Drupal exported configuration", + description: "Selected configuration synchronised from the Drupal 11 sandbox export directory.", + source: "drupal:config:sync", + mimeType: "application/json", + data: exportedConfiguration, + }, + ]; } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a24ffb3..2061696 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -3,12 +3,14 @@ import http from "node:http"; import { stdioTransport } from "./transports/stdio.js"; import { httpTransport } from "./transports/http.js"; import type { MCPServerOptions } from "./types.js"; +import { listSchemaResources } from "./features/schemaResources.js"; +import { getDrushTools } from "./features/drushTools.js"; export function createMCPServer(options: MCPServerOptions = {}) { const { logger = console } = options; const serverState = { - resources: options.resources ?? [], - tools: options.tools ?? [], + resources: options.resources ?? listSchemaResources(), + tools: options.tools ?? getDrushTools(), logger, }; @@ -21,10 +23,10 @@ export function createMCPServer(options: MCPServerOptions = {}) { const server = http.createServer((req, res) => { httpTransport(req, res, serverState); }); - return new Promise((resolve) => { + return new Promise((resolve) => { server.listen(port, () => { - logger.info?.(`MCP server listening on http://localhost:${port}`); - resolve(); + logger.info?.(`MCP server listening on http://localhost:${(server.address() as any)?.port ?? port}`); + resolve(server); }); }); }, diff --git a/packages/server/src/integration/smoke.ts b/packages/server/src/integration/smoke.ts new file mode 100644 index 0000000..f065520 --- /dev/null +++ b/packages/server/src/integration/smoke.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +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(); + assert.ok(address, "Server should have a bound address"); + await new Promise((resolve) => httpServer.close(() => resolve())); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runServerSmokeTest() + .then(() => { + console.info("HTTP transport smoke test completed"); + process.exit(0); + }) + .catch((error) => { + console.error("HTTP transport smoke test failed", error); + process.exit(1); + }); +} diff --git a/packages/server/src/transports/http.ts b/packages/server/src/transports/http.ts index 131048a..25b3452 100644 --- a/packages/server/src/transports/http.ts +++ b/packages/server/src/transports/http.ts @@ -1,21 +1,36 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { ServerState } from "../types.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 ) { state.logger.info?.(`HTTP ${req.method} ${req.url}`); - res.setHeader("Content-Type", "application/json"); if (req.method === "GET" && req.url === "/health") { - res.writeHead(200); - res.end(JSON.stringify({ status: "ok", tools: state.tools.length })); + 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 }); + return; + } + + if (req.method === "GET" && req.url === "/tools") { + sendJson(res, 200, { tools: state.tools }); return; } - // TODO: Implement protocol-compliant HTTP transport routing. - res.writeHead(202); - res.end(JSON.stringify({ message: "MCP HTTP transport stub" })); + sendJson(res, 404, { error: "Not Found" }); } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 33dfca0..0ced341 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1,11 +1,28 @@ +export interface MCPResource { + id: string; + name: string; + description: string; + source?: string; + mimeType: string; + data: unknown; +} + +export interface MCPTool { + name: string; + description: string; + command: string; + args?: string[]; + examples?: string[]; +} + export interface MCPServerOptions { - resources?: Array>; - tools?: Array>; + resources?: MCPResource[]; + tools?: MCPTool[]; logger?: Pick; } export interface ServerState { - resources: Array>; - tools: Array>; + resources: MCPResource[]; + tools: MCPTool[]; logger: Pick; } diff --git a/packages/server/src/types/yargs.d.ts b/packages/server/src/types/yargs.d.ts new file mode 100644 index 0000000..5959236 --- /dev/null +++ b/packages/server/src/types/yargs.d.ts @@ -0,0 +1,19 @@ +declare module "yargs" { + interface OptionDefinition { + alias?: string; + type?: "string" | "number" | "boolean"; + default?: unknown; + describe?: string; + } + interface YargsInstance> { + option(name: string, options: OptionDefinition): YargsInstance; + help(): YargsInstance; + parseAsync(): Promise; + } + function yargs(args: string[] | ReadonlyArray): YargsInstance; + export default yargs; +} + +declare module "yargs/helpers" { + export function hideBin(argv: string[]): string[]; +} diff --git a/rfcs/RFC-0001-driftcore-mvp.md b/rfcs/RFC-0001-driftcore-mvp.md new file mode 100644 index 0000000..5ee325a --- /dev/null +++ b/rfcs/RFC-0001-driftcore-mvp.md @@ -0,0 +1,27 @@ +# RFC-0001: DriftCore MVP + +## Summary + +This RFC captures the goals, architecture, and non-goals for the DriftCore minimum viable product. The MVP focuses on enabling automation agents to introspect Drupal metadata, trigger Drush commands, and experiment safely in a sandboxed Drupal 11 environment. + +## Goals + +- Provide an MCP server that exposes Drupal schema and configuration as machine-readable resources (`schema.entityTypes`, `config.exported`). +- Surface Drush commands (`drush.cacheRebuild`, `drush.configExport`) through the MCP tool catalog. +- Deliver an agent runner that can generate a language SDK from the server resources and execute code safely in a sandbox. +- Offer a containerized Drupal 11 sandbox that mirrors the metadata shared by the MCP server. +- Establish a CI workflow that validates builds, static analysis, unit tests, and integration smoke tests for both packages. + +## Architecture + +- **MCP Server (`@driftcore/server`)**: Provides HTTP and STDIO transports. Default resources are generated from canonical Drupal metadata and are available over `/resources`. Drush tooling is exposed via the `/tools` endpoint. +- **Agent Runner (`@driftcore/agent-runner`)**: Fetches resources from the MCP server, generates a TypeScript SDK, and executes bootstrap code inside a VM-backed sandbox. +- **Drupal Sandbox**: Docker Compose project (`examples/drupal-sandbox`) with Drupal 11 and MariaDB containers. Configuration exports are mounted for inspection. +- **Continuous Integration**: GitHub Actions workflow builds each package, runs type-checking lint, executes unit tests with Node's test runner, and performs smoke-level integration checks. + +## Non-goals + +- Full MCP protocol compliance (message envelopes, session management) is deferred to a future iteration. +- Production-grade sandbox isolation and resource quotas. +- Automated Drupal installation or configuration management beyond the provided example export. +- SDK generation for languages other than TypeScript.