diff --git a/README.md b/README.md index 590b1cf6..e5d96dd6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Commonality is a system for decentralized crowdfunding of public goods: people c - [High-level project status](./workflow/project-status.md) AKA what milestone are we currently heading for: never deployed to mainnet yet, just did first testnet deployment, see also [MVP](./specs/product/mvp.md) - Product boundaries between the eight sites: [product UI domains](./specs/product/ui-domains.md). - Technical domain composition and live route ownership: [technical UI domains](./specs/tech/ui-domains.md) and the actual domain manifests under [`ui/src/domains/`](./ui/src/domains/). - - **CauseStarter** (SPA in [`ui/src/causestarter/`](./ui/src/causestarter/), `VITE_DOMAIN=causestarter`; glue/backlog [`causestarter/`](./causestarter/), [`causestarter/TODO.md`](./causestarter/TODO.md)): founder-first core domain (eventual primary entry; Tally / LazyGiving / etc. de-emphasized as tools). Included in `./scripts/services.sh --start` and `./scripts/deploy-causestarter.sh`. Local start currently publishes **only** the CauseStarter IPFS bundle (`LOCAL_UI_DOMAINS`; restore all UIs with `LOCAL_UI_DOMAINS=all` — see [local development](./workflow/local-development.md)). LLM helpers: [`cause-assist/`](./cause-assist/). + - **CauseStarter** (SPA in [`ui/src/causestarter/`](./ui/src/causestarter/), `VITE_DOMAIN=causestarter`; glue/backlog [`causestarter/`](./causestarter/), [`causestarter/TODO.md`](./causestarter/TODO.md)): founder-first core domain (eventual primary entry; Tally / LazyGiving / etc. de-emphasized as tools). Included in `./scripts/services.sh --start` and `./scripts/deploy-causestarter.sh`. Local start currently publishes **only** the CauseStarter IPFS bundle (`LOCAL_UI_DOMAINS`; restore all UIs with `LOCAL_UI_DOMAINS=all` — see [local development](./workflow/local-development.md)). LLM helpers: [`cause-assist/`](./cause-assist/). Protocol MCP: [`mcp/`](./mcp/). Agent job map: [`docs/end-user/causestarter/for-llms.md`](./docs/end-user/causestarter/for-llms.md). - [Glossary](./specs/glossary.md) — the project's ubiquitous language. Read before naming anything; it also lists the known terminology drift. - [Architecture Decision Records](./specs/decisions/README.md) — immutable log of *why* consequential decisions were made (and what was rejected). Grep before reversing something that looks wrong. - [Role-based guidance](./workflow/roles/README.md) on what docs to read: diff --git a/causestarter/README.md b/causestarter/README.md index 24eb1a21..d4e23632 100644 --- a/causestarter/README.md +++ b/causestarter/README.md @@ -219,7 +219,9 @@ See [`cause-assist/README.md`](../cause-assist/README.md). Bridge-cluster wordin catalog. Do not restore Start → Grow → Deliver or “build a Movement.” - **In-app docs** (`/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/` via `endUserDocsPlugin`. Keep markdown links relative so - they resolve in that viewer. + they resolve in that viewer. Agent/protocol index: `/docs/for-llms`. Generated + SDK/contract docs: `/api-docs/sdk/` and `/api-docs/contracts/` (Vite serves + them in dev; `npm run build:docs` regenerates). Stdio MCP: `mcp/`. - **CauseStarter is a lens, not a directory** ([ADR 0008](../specs/decisions/0008-operated-surfaces-are-lenses.md)). It authors no discovery: no search, browse, ranking, featuring, or leaderboards. A cause is reached at `/cause/:causeId` through a link its organizer circulates. @@ -292,6 +294,8 @@ startup_timeout_sec = 90 Register once with: `grok mcp add playwright -- npx -y @playwright/mcp@latest` Then **restart Grok** so MCP tools load. +Protocol reads (SDK / IPFS / attesters), not the browser: [`mcp/README.md`](../mcp/README.md) (`npm run mcp`). + 3. Chromium for Playwright tests (repo root): `npx playwright install chromium` diff --git a/docs/end-user/causestarter/for-llms.md b/docs/end-user/causestarter/for-llms.md new file mode 100644 index 00000000..24d8bca1 --- /dev/null +++ b/docs/end-user/causestarter/for-llms.md @@ -0,0 +1,74 @@ +# CauseStarter API map (for LLMs) + +Task-oriented index for agents. Signatures live in generated TypeDoc; this page only maps **jobs → imports / HTTP**. Do not treat CauseStarter screens as an API — they will change. + +Human briefing: [CauseStarter](./index.md). Concept orientation: [tldr-for-llms.md](../tldr-for-llms.md). + +## Generated references (regenerate, don’t hand-write) + +On a deployed site these are static files (not React routes): + +- **SDK TypeDoc:** [/api-docs/sdk/](/api-docs/sdk/) +- **Solidity contract docs:** [/api-docs/contracts/index.md](/api-docs/contracts/index.md) + +Regenerate with `npm run build:docs` at the repo root (`typedoc` on `sdk/`, solc docs on `hardhat/`). Local Vite (`causestarter:dev` on :5174) serves the same trees at those paths when the generated folders exist. + +There is **no CauseStarter REST API**. The UI is a lens over the SDK plus a few helper HTTP services. + +## What is already in the SDK (including IPFS) + +Import subpaths of `@commonality/sdk` (no flat barrel). Construct machinery with `createSDKMachinery` from `@commonality/sdk/machinery`. Node env helpers: `@commonality/sdk/node` (`createIPFSConfigInNodeJSFromTheUsualEnvVars`). + +| Job | Import | Functions | +| --- | --- | --- | +| Fetch / upload IPFS | `@commonality/sdk/utils` | `fetchFromIPFS`, `uploadToIPFS`, `uploadBlobToIPFS`. Upload needs `ipfsConfig.apiUrl` (Kubo `/api/v0/add`). Fetch needs `gatewayUrl`. | +| Publish statement bytes on-chain | `@commonality/sdk/published-data` | `publishData`, resolvers. Content-addressed documents also go through displayable-documents. | +| Create a signable statement and sign it | `@commonality/sdk/conceptspace` | `createAndSignStatement` (upload + PublishedData + `believeStatement`). Also `believeStatement` / `disbelieveStatement` / `clearOpinion`. | +| Read a statement | `@commonality/sdk/conceptspace` | `getStatement`, `getStatementWithContent`, `getUserBelief`, believer-set helpers. | +| Implication graph | `@commonality/sdk/conceptspace` | `getImplicationsFrom`, `getImplicationsTo`, `getImplication`. On-chain attest is `attestImplication` (usually an attester service, not a donor). | +| Cause board as a named ref | `@commonality/sdk/mutable-refs` | `getUserRef`, `getUserRefs`. Organizers publish a roster document and point a named ref at it. | +| Projects / pledges | `@commonality/sdk/lazy-giving`, `@commonality/sdk/fundingportals`, `@commonality/sdk/delegation` | `getProject`; alignment/success vouches; notes and standing pledges. | +| Indexer | `@commonality/sdk/utils` event-cache client; `@commonality/sdk/indexer-sync` | Raw events only. Fold in the SDK. `GET {EVENT_CACHE_URL}/status`. | + +Local defaults after a stack start: `IPFS_API` → localhost:5001, `IPFS_GATEWAY` → localhost:8080/ipfs, `EVENT_CACHE_URL` → localhost:42069, plus the usual `*_CONTRACT_ADDRESS` env vars. + +## HTTP services that are *not* the SDK + +These are optional operators. Anyone can run their own. Users trust *identities* (attester / nudger addresses), not “the platform.” + +| Service | Typical local URL | What an agent calls | Notes | +| --- | --- | --- | --- | +| **Cause assist** | localhost:3002 | `POST /atomize`, `/sharpen-plank`, `/draft-anchor`, `/check-implications`, `/safety-check`, `/check-coherence`, bridge-cluster draft verbs | Wording help only. **No chain writes.** See `cause-assist/README.md`. | +| **Implication attester** | localhost:3006/implication-attester (compose path) | `POST /evaluate-implication`, `POST /evaluate-implications-batch`, `GET /health`, `/quote`, `/attester-status` | x402 payment on evaluate. Positive evaluations publish on-chain. | +| **Content attester** | content-attester package | `POST /evaluate-content` (batch too) | Same attester-core HTTP shape; different prompt/domain. | +| **Finders** | implication-finder, content-finder | Usually **no public “please find” API** | Poll event cache, POST candidates to an attester. | +| **Nudgers** | implication-graph-nudger, explorer-curator, bridge-creator | `GET /.well-known/nudger.json`, `POST /suggest` (curator), `POST /propose-bridge` (bridge-creator) | Suggestions are signed publications; users opt in. | +| **Platform API** | localhost:3001 | onramp, sponsored gas, policy content | Ops for the operated UIs, not the protocol. | +| **Coherence badge worker** | worker package | operator-only chain write | CauseStarter site operator mints badges; cause-assist HTTP must not hold that key. | + +Attester-core shared routes: `GET /health`, `GET /quote`, plus a per-service status path. + +## MCP in this repo + +Package `@commonality/mcp` (`mcp/`). Stdio server wrapping the SDK reads (and optional writes) plus the HTTP helpers above. + +```bash +npm run mcp --workspace=@commonality/mcp +``` + +Grok: + +```toml +[mcp_servers.commonality] +command = "npm" +args = ["run", "mcp", "--workspace=@commonality/mcp"] +``` + +Writes (`upload_ipfs`, `believe_statement`) stay off unless `COMMONALITY_MCP_WRITES=1` and a key is set. Implication evaluate may still return HTTP 402 (x402). + +## Rules of thumb + +- Prefer SDK queries over scraping CauseStarter. +- Prefer `getStatementWithContent` over raw gateway fetches when you want displayable JSON. +- Do not invent a cause directory; you need an owner address + slug/ref name, or a statement CID. +- Do not treat an attester HTTP 200 as “the protocol agrees.” It is one identity’s attestation, filtered by the viewer’s trust graph. diff --git a/docs/end-user/causestarter/index.md b/docs/end-user/causestarter/index.md index 72ec1d7f..105b8551 100644 --- a/docs/end-user/causestarter/index.md +++ b/docs/end-user/causestarter/index.md @@ -52,6 +52,10 @@ Most products with that pitch ask you to join something, trust a black box, or b You do not need the longer [vision and strategy](../commonality/vision-and-strategy/README.md) notes to use a cause board. They are there if you want the civilizational argument. +## For assistants and developers + +The UI is a lens, not the protocol. **[CauseStarter API map (for LLMs)](./for-llms.md)** maps jobs (sign, publish, pledge, vouch, IPFS, attesters) onto SDK imports and helper HTTP services. Generated **[SDK API docs](/api-docs/sdk/)** and **[contract docs](/api-docs/contracts/index.md)** are the signature reference (`npm run build:docs`). A stdio MCP server lives in the `mcp/` package. + ## See it as a story - [A neighborhood throws a block party](../shared/use-case-walkthroughs/block-party.md) diff --git a/docs/end-user/shared/for-crypto-natives.md b/docs/end-user/shared/for-crypto-natives.md index 8827d3cd..608d3cad 100644 --- a/docs/end-user/shared/for-crypto-natives.md +++ b/docs/end-user/shared/for-crypto-natives.md @@ -39,8 +39,9 @@ The SDK also fetches IPFS content directly from a gateway, so statement content The SDK and smart contract documentation is auto-generated: -- **SDK API docs** ([sdk/docs/api/](https://github.com/AdamSpitz/commonality/tree/master/sdk/docs/api)) — TypeScript SDK reference, auto-generated via typedoc from the SDK source. -- **Contract docs** ([hardhat/docs/](https://github.com/AdamSpitz/commonality/tree/master/hardhat/docs)) — Solidity contract reference, auto-generated via solc doc. +- **SDK API docs** ([sdk/docs/api/](https://github.com/AdamSpitz/commonality/tree/master/sdk/docs/api) or [/api-docs/sdk/](/api-docs/sdk/) on a deployed UI) — TypeScript SDK reference, auto-generated via typedoc from the SDK source. IPFS fetch/upload is in `@commonality/sdk/utils` (`fetchFromIPFS`, `uploadToIPFS`). +- **Contract docs** ([hardhat/docs/](https://github.com/AdamSpitz/commonality/tree/master/hardhat/docs) or [/api-docs/contracts/index.md](/api-docs/contracts/index.md)) — Solidity contract reference, auto-generated via solc doc. +- **CauseStarter job map for agents** ([causestarter/for-llms.md](../causestarter/for-llms.md)) — which SDK subpath or HTTP service (attesters, cause-assist, nudgers) to use; not a second TypeDoc. Run `npm run build:docs` to regenerate these. diff --git a/docs/end-user/tldr-for-llms.md b/docs/end-user/tldr-for-llms.md index 8065feec..a9300fa9 100644 --- a/docs/end-user/tldr-for-llms.md +++ b/docs/end-user/tldr-for-llms.md @@ -7,6 +7,14 @@ Each entry follows the same shape: - **When a user encounters it** — UI surfaces or moments in the user's flow where this concept becomes load-bearing. - **What they might want help with** — typical assistant tasks around this concept. +## CauseStarter API map (jobs → SDK / HTTP) + +[Full page](/docs/end-user/causestarter/for-llms.md) + +- **What it is:** Task index for agents: which `@commonality/sdk` subpath (including IPFS upload/fetch) and which HTTP services (cause-assist, implication attester, nudgers) implement each job. Points at generated TypeDoc under `/api-docs/sdk/` rather than restating signatures. Documents the repo MCP (`mcp/`). +- **When a user encounters it:** `/docs/for-llms`, or when an assistant is about to call the protocol instead of driving the UI. +- **What they might want help with:** Uploading a statement to IPFS; signing; reading a cause ref; talking to an attester without treating it as platform truth. + ## CauseStarter (you landed on a cause page) [Full page](/docs/end-user/causestarter/index.md) diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 00000000..2b1a77c2 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,47 @@ +# Commonality MCP + +Stdio [MCP](https://modelcontextprotocol.io) server for agents that should talk to the protocol instead of scraping CauseStarter. + +Reads go through `@commonality/sdk` (event cache + IPFS + folds). Optional HTTP helpers reach **cause-assist** and the **implication attester**. Chain/IPFS writes stay off until `COMMONALITY_MCP_WRITES=1`. + +Job map: [`docs/end-user/causestarter/for-llms.md`](../docs/end-user/causestarter/for-llms.md). Generated SDK docs: `sdk/docs/api/` (`npm run build:docs`). + +## Run + +From the repo root, with the same env as local stack (`.env` after `./scripts/setup-env.sh localhost`): + +```bash +npm run mcp --workspace=@commonality/mcp +``` + +Grok (`~/.grok/config.toml` or `grok mcp add`): + +```toml +[mcp_servers.commonality] +command = "npm" +args = ["run", "mcp", "--workspace=@commonality/mcp"] +``` + +Run the process with cwd = this repository so workspace resolution works. + +## Tools + +| Tool | Side effect | +| --- | --- | +| `get_statement`, `fetch_ipfs`, `get_implications_*`, `get_user_belief`, `get_user_ref`, `get_project`, `indexer_status` | Read | +| `cause_assist` | HTTP to cause-assist (wording only) | +| `implication_attester_status`, `evaluate_implication` | HTTP; evaluate may 402 (x402) and, if paid, the *attester* publishes on-chain | +| `upload_ipfs`, `believe_statement` | Writes; require `COMMONALITY_MCP_WRITES=1` | + +## Env + +Same contract/IPFS/indexer names as integration tests (`EVENT_CACHE_URL`, `IPFS_API`, `IPFS_GATEWAY`, `BELIEFS_CONTRACT_ADDRESS`, …). Extra: + +| Var | Default | +| --- | --- | +| `CAUSE_ASSIST_URL` | `http://127.0.0.1:3002` | +| `IMPLICATION_ATTESTER_URL` | `http://localhost:3006/implication-attester` | +| `COMMONALITY_MCP_WRITES` | unset (reads only) | +| `MCP_PRIVATE_KEY` or `ETHEREUM_PRIVATE_KEY` | required for `believe_statement` | + +Do not log secrets. Stdio is the MCP transport — keep `console.log` off `stdout`. diff --git a/mcp/eslint.config.js b/mcp/eslint.config.js new file mode 100644 index 00000000..378deec3 --- /dev/null +++ b/mcp/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import codeMetrics from '../eslint.metrics.mjs' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + ...codeMetrics, + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + ], + languageOptions: { + ecmaVersion: 2020, + globals: { + node: true, + mocha: true, + }, + parserOptions: { + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]) diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 00000000..c37c6ec9 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,35 @@ +{ + "name": "@commonality/mcp", + "version": "0.1.0", + "description": "Stdio MCP server for Commonality SDK reads, IPFS, and helper HTTP services", + "type": "module", + "bin": { + "commonality-mcp": "./dist/index.js" + }, + "scripts": { + "prebuild": "npm run build --workspace=@commonality/sdk", + "build": "tsc", + "clean": "rm -rf dist", + "pretypecheck": "npm run build --workspace=@commonality/sdk", + "typecheck": "tsc --noEmit", + "mcp": "tsx src/index.ts", + "start": "node dist/index.js", + "test": "npm run build && mocha \"dist/**/*.test.js\"", + "lint": "eslint ." + }, + "dependencies": { + "@commonality/sdk": "1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "viem": "2.54.3", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "^20.10.0", + "eslint": "^9.39.1", + "mocha": "^10.8.2", + "tsx": "^4.21.0", + "typescript": "^5.3.2", + "typescript-eslint": "^8.46.4" + } +} diff --git a/mcp/src/config.ts b/mcp/src/config.ts new file mode 100644 index 00000000..e66cd990 --- /dev/null +++ b/mcp/src/config.ts @@ -0,0 +1,65 @@ +import type { Address } from 'viem' +import type { ContractAddresses } from '@commonality/sdk/machinery' + +function env(name: string, fallback?: string): string | undefined { + const value = process.env[name] + if (value && value.length > 0) return value + return fallback +} + +function address(name: string): Address | undefined { + const value = env(name) + return value ? (value as Address) : undefined +} + +export const CAUSE_ASSIST_PATHS = [ + '/atomize', + '/sharpen-plank', + '/draft-anchor', + '/suggest-statements', + '/suggest-mediator-scaffold', + '/draft-modified-plank', + '/draft-stand-in-sliver', + '/draft-bridge-plank', + '/critique-triple', + '/check-implications', + '/safety-check', + '/check-coherence', + '/health', +] as const + +export type CauseAssistPath = (typeof CAUSE_ASSIST_PATHS)[number] + +export function isCauseAssistPath(path: string): path is CauseAssistPath { + return (CAUSE_ASSIST_PATHS as readonly string[]).includes(path) +} + +const ZERO: Address = '0x0000000000000000000000000000000000000000' + +function loadContractAddresses(): ContractAddresses { + return { + beliefs: address('BELIEFS_CONTRACT_ADDRESS') ?? ZERO, + implications: address('IMPLICATIONS_CONTRACT_ADDRESS') ?? ZERO, + assuranceContractFactory: address('ASSURANCE_CONTRACT_FACTORY_ADDRESS') ?? ZERO, + erc1155Factory: address('ERC1155_FACTORY_ADDRESS') ?? ZERO, + delegatableNotes: address('DELEGATABLE_NOTES_CONTRACT_ADDRESS') ?? address('DELEGATABLE_NOTES_ADDRESS') ?? ZERO, + noteIntent: address('NOTE_INTENT_ADDRESS') ?? ZERO, + alignmentAttestations: address('PROJECT_ALIGNMENT_CONTRACT_ADDRESS') ?? address('ALIGNMENT_ATTESTATIONS_CONTRACT_ADDRESS') ?? ZERO, + mutableRefUpdater: address('MUTABLE_REF_UPDATER_CONTRACT_ADDRESS') ?? address('MUTABLE_REF_UPDATER_ADDRESS') ?? ZERO, + trustRegistry: address('TRUST_REGISTRY_ADDRESS') ?? ZERO, + nudgePublications: address('NUDGE_PUBLICATIONS_CONTRACT_ADDRESS'), + publishedData: address('PUBLISHED_DATA_CONTRACT_ADDRESS'), + } +} + +export function loadMcpConfig() { + return { + writesEnabled: process.env.COMMONALITY_MCP_WRITES === '1', + privateKey: (env('MCP_PRIVATE_KEY') ?? env('ETHEREUM_PRIVATE_KEY') ?? env('PRIVATE_KEY')) as `0x${string}` | undefined, + rpcUrl: env('ETH_RPC_URL') ?? env('RPC_URL') ?? 'http://127.0.0.1:8545', + eventCacheUrl: env('EVENT_CACHE_URL') ?? 'http://localhost:42069', + causeAssistUrl: (env('CAUSE_ASSIST_URL') ?? 'http://127.0.0.1:3002').replace(/\/$/, ''), + implicationAttesterUrl: (env('IMPLICATION_ATTESTER_URL') ?? 'http://localhost:3006/implication-attester').replace(/\/$/, ''), + contractAddresses: loadContractAddresses(), + } +} diff --git a/mcp/src/http.ts b/mcp/src/http.ts new file mode 100644 index 00000000..fab4b608 --- /dev/null +++ b/mcp/src/http.ts @@ -0,0 +1,13 @@ +export async function fetchJson(url: string, init?: RequestInit): Promise<{ status: number; body: unknown }> { + const response = await fetch(url, init) + const text = await response.text() + let body: unknown = text + if (text.length > 0) { + try { + body = JSON.parse(text) + } catch { + body = text + } + } + return { status: response.status, body } +} diff --git a/mcp/src/index.ts b/mcp/src/index.ts new file mode 100644 index 00000000..8fd21df3 --- /dev/null +++ b/mcp/src/index.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { registerCommonalityTools } from './tools.js' + +const server = new McpServer({ + name: 'commonality', + version: '0.1.0', +}) + +registerCommonalityTools(server) + +const transport = new StdioServerTransport() +await server.connect(transport) diff --git a/mcp/src/json.test.ts b/mcp/src/json.test.ts new file mode 100644 index 00000000..0db05446 --- /dev/null +++ b/mcp/src/json.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict' +import { jsonText } from './json.js' +import { isCauseAssistPath, loadMcpConfig } from './config.js' +import { TOOL_NAMES } from './tools.js' + +describe('mcp helpers', () => { + it('serializes bigint as a decimal string', () => { + assert.equal(jsonText({ n: 1n }), '{\n "n": "1"\n}') + }) + + it('allow-lists cause-assist paths and rejects others', () => { + assert.equal(isCauseAssistPath('/atomize'), true) + assert.equal(isCauseAssistPath('/evaluate-implication'), false) + }) + + it('keeps writes off unless COMMONALITY_MCP_WRITES=1', () => { + const previous = process.env.COMMONALITY_MCP_WRITES + delete process.env.COMMONALITY_MCP_WRITES + assert.equal(loadMcpConfig().writesEnabled, false) + process.env.COMMONALITY_MCP_WRITES = '1' + assert.equal(loadMcpConfig().writesEnabled, true) + if (previous === undefined) delete process.env.COMMONALITY_MCP_WRITES + else process.env.COMMONALITY_MCP_WRITES = previous + }) + + it('exports the expected tool names', () => { + assert.ok(TOOL_NAMES.includes('fetch_ipfs')) + assert.ok(TOOL_NAMES.includes('upload_ipfs')) + assert.ok(TOOL_NAMES.includes('cause_assist')) + assert.ok(TOOL_NAMES.includes('evaluate_implication')) + }) +}) diff --git a/mcp/src/json.ts b/mcp/src/json.ts new file mode 100644 index 00000000..b4a15241 --- /dev/null +++ b/mcp/src/json.ts @@ -0,0 +1,11 @@ +export function jsonText(value: unknown): string { + return JSON.stringify(value, (_key, item) => (typeof item === 'bigint' ? item.toString() : item), 2) +} + +export function textResult(value: unknown): { content: Array<{ type: 'text'; text: string }> } { + return { content: [{ type: 'text', text: jsonText(value) }] } +} + +export function errorResult(message: string): { content: Array<{ type: 'text'; text: string }>; isError: true } { + return { content: [{ type: 'text', text: message }], isError: true } +} diff --git a/mcp/src/machinery.ts b/mcp/src/machinery.ts new file mode 100644 index 00000000..55c42507 --- /dev/null +++ b/mcp/src/machinery.ts @@ -0,0 +1,27 @@ +import { createPublicClient, http } from 'viem' +import { hardhat } from 'viem/chains' +import { createSDKMachinery, type SDKMachinery } from '@commonality/sdk/machinery' +import { createIPFSConfigInNodeJSFromTheUsualEnvVars } from '@commonality/sdk/node' +import { loadMcpConfig } from './config.js' + +let cached: SDKMachinery | undefined + +export function getMachinery(): SDKMachinery { + if (cached) return cached + const config = loadMcpConfig() + const publicClient = createPublicClient({ + chain: hardhat, + transport: http(config.rpcUrl), + }) + cached = createSDKMachinery({ + ipfsConfig: createIPFSConfigInNodeJSFromTheUsualEnvVars(), + publicClient, + eventCacheUrl: config.eventCacheUrl, + contractAddresses: config.contractAddresses, + }) + return cached +} + +export function resetMachineryForTests(): void { + cached = undefined +} diff --git a/mcp/src/tools.ts b/mcp/src/tools.ts new file mode 100644 index 00000000..2f967ed7 --- /dev/null +++ b/mcp/src/tools.ts @@ -0,0 +1,223 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' +import type { IpfsCidV1 } from '@commonality/sdk/utils' +import { fetchFromIPFS, uploadToIPFS, createWriteClients } from '@commonality/sdk/utils' +import { getStatementWithContent, getImplicationsFrom, getImplicationsTo, getUserBelief, believeStatement } from '@commonality/sdk/conceptspace' +import { getUserRef } from '@commonality/sdk/mutable-refs' +import { getProject } from '@commonality/sdk/lazy-giving' +import { BeliefsAbi } from '@commonality/sdk/abis' +import { errorResult, textResult } from './json.js' +import { isCauseAssistPath, loadMcpConfig } from './config.js' +import { fetchJson } from './http.js' +import { getMachinery } from './machinery.js' + +function asCid(cid: string): IpfsCidV1 { + return cid as IpfsCidV1 +} + +function requireWrites() { + const config = loadMcpConfig() + if (!config.writesEnabled) { + throw new Error('Writes are disabled. Set COMMONALITY_MCP_WRITES=1 to enable upload_ipfs and believe_statement.') + } + return config +} + +export const TOOL_NAMES = [ + 'get_statement', + 'fetch_ipfs', + 'get_implications_from', + 'get_implications_to', + 'get_user_belief', + 'get_user_ref', + 'get_project', + 'indexer_status', + 'cause_assist', + 'implication_attester_status', + 'evaluate_implication', + 'upload_ipfs', + 'believe_statement', +] as const + +export function registerCommonalityTools(server: McpServer): void { + registerReadTools(server) + registerHttpTools(server) + registerWriteTools(server) +} + +function registerReadTools(server: McpServer): void { + server.tool( + 'get_statement', + 'Read a statement CID: on-chain belief counts plus displayable IPFS/PublishedData content.', + { cid: z.string().describe('IPFS CID of the statement') }, + async ({ cid }) => { + const statement = await getStatementWithContent(getMachinery(), asCid(cid)) + return textResult(statement) + }, + ) + + server.tool( + 'fetch_ipfs', + 'Fetch JSON from the configured IPFS gateway (SDK fetchFromIPFS).', + { cid: z.string() }, + async ({ cid }) => { + const content = await fetchFromIPFS(getMachinery().ipfsConfig, cid) + return textResult(content) + }, + ) + + server.tool( + 'get_implications_from', + 'On-chain implication attestations originating from this statement CID.', + { + cid: z.string(), + trustedAttesters: z.array(z.string()).optional(), + }, + async ({ cid, trustedAttesters }) => { + const rows = await getImplicationsFrom(getMachinery(), asCid(cid), trustedAttesters) + return textResult(rows) + }, + ) + + server.tool( + 'get_implications_to', + 'On-chain implication attestations pointing at this statement CID.', + { + cid: z.string(), + trustedAttesters: z.array(z.string()).optional(), + }, + async ({ cid, trustedAttesters }) => { + const rows = await getImplicationsTo(getMachinery(), asCid(cid), trustedAttesters) + return textResult(rows) + }, + ) + + server.tool( + 'get_user_belief', + 'One address’s belief state for one statement CID.', + { address: z.string(), cid: z.string() }, + async ({ address, cid }) => { + const belief = await getUserBelief(getMachinery(), address, asCid(cid)) + return textResult(belief) + }, + ) + + server.tool( + 'get_user_ref', + 'Named mutable ref (cause boards are organizer-owned refs pointing at a roster CID).', + { owner: z.string().describe('Organizer address'), name: z.string().describe('Ref name / slug') }, + async ({ owner, name }) => { + const ref = await getUserRef(getMachinery(), owner, name) + return textResult(ref) + }, + ) + + server.tool( + 'get_project', + 'Fold a LazyGiving assurance-contract address into project state.', + { address: z.string().describe('Assurance contract address') }, + async ({ address }) => { + const project = await getProject(getMachinery(), address) + return textResult(project) + }, + ) + +} + +function registerHttpTools(server: McpServer): void { + server.tool( + 'indexer_status', + 'GET the event-cache /status endpoint.', + async () => { + const { eventCacheUrl } = loadMcpConfig() + return textResult(await fetchJson(`${eventCacheUrl}/status`)) + }, + ) + + server.tool( + 'cause_assist', + 'Call Cause Assist wording helpers (no chain writes). path must be an allow-listed endpoint.', + { + path: z.string().describe('e.g. /atomize, /sharpen-plank, /health'), + body: z.record(z.unknown()).optional(), + }, + async ({ path, body }) => { + if (!isCauseAssistPath(path)) { + return errorResult(`Unsupported cause-assist path: ${path}`) + } + const { causeAssistUrl } = loadMcpConfig() + const method = path === '/health' ? 'GET' : 'POST' + return textResult(await fetchJson(`${causeAssistUrl}${path}`, { + method, + headers: method === 'POST' ? { 'content-type': 'application/json' } : undefined, + body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined, + })) + }, + ) + + server.tool( + 'implication_attester_status', + 'Implication attester GET /health (and /quote if present).', + async () => { + const { implicationAttesterUrl } = loadMcpConfig() + const health = await fetchJson(`${implicationAttesterUrl}/health`) + const quote = await fetchJson(`${implicationAttesterUrl}/quote`) + return textResult({ health, quote }) + }, + ) + + server.tool( + 'evaluate_implication', + 'POST /evaluate-implication on the implication attester. May return HTTP 402 (x402 payment). Positive results publish on-chain as that attester identity, not as the user.', + { fromStatementCid: z.string(), toStatementCid: z.string() }, + async ({ fromStatementCid, toStatementCid }) => { + const { implicationAttesterUrl } = loadMcpConfig() + return textResult(await fetchJson(`${implicationAttesterUrl}/evaluate-implication`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fromStatementCid, toStatementCid }), + })) + }, + ) + +} + +function registerWriteTools(server: McpServer): void { + server.tool( + 'upload_ipfs', + 'Upload a JSON object to IPFS via the SDK (requires IPFS_API and COMMONALITY_MCP_WRITES=1).', + { content: z.record(z.unknown()) }, + async ({ content }) => { + try { + requireWrites() + const cid = await uploadToIPFS(getMachinery().ipfsConfig, content) + return textResult({ cid }) + } catch (error) { + return errorResult(error instanceof Error ? error.message : String(error)) + } + }, + ) + + server.tool( + 'believe_statement', + 'On-chain setBelief(BELIEVES) for a statement CID. Requires COMMONALITY_MCP_WRITES=1 and MCP_PRIVATE_KEY (or ETHEREUM_PRIVATE_KEY).', + { cid: z.string() }, + async ({ cid }) => { + try { + const config = requireWrites() + if (!config.privateKey) { + throw new Error('Set MCP_PRIVATE_KEY or ETHEREUM_PRIVATE_KEY for writes.') + } + const clients = createWriteClients(config.privateKey, config.rpcUrl) + const hash = await believeStatement( + clients, + { address: config.contractAddresses.beliefs, abi: BeliefsAbi }, + asCid(cid), + ) + return textResult({ transactionHash: hash }) + } catch (error) { + return errorResult(error instanceof Error ? error.message : String(error)) + } + }, + ) +} diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 00000000..1bbc55a4 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/package-lock.json b/package-lock.json index cc7ba700..f6ef651c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "alignment-trust-bootstrap", "fake-data-generation", "cause-assist", + "mcp", "ui", "causestarter" ], @@ -470,6 +471,28 @@ "url": "https://dotenvx.com" } }, + "mcp": { + "name": "@commonality/mcp", + "version": "0.1.0", + "dependencies": { + "@commonality/sdk": "1.0.0", + "@modelcontextprotocol/sdk": "^1.25.1", + "viem": "2.54.3", + "zod": "^3.25.76" + }, + "bin": { + "commonality-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "^20.10.0", + "eslint": "^9.39.1", + "mocha": "^10.8.2", + "tsx": "^4.21.0", + "typescript": "^5.3.2", + "typescript-eslint": "^8.46.4" + } + }, "node_modules/@aave/account": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@aave/account/-/account-0.2.0.tgz", @@ -1272,6 +1295,10 @@ "resolved": "services/implication-graph-nudger", "link": true }, + "node_modules/@commonality/mcp": { + "resolved": "mcp", + "link": true + }, "node_modules/@commonality/nudger-core": { "resolved": "services/nudger-core", "link": true @@ -4521,6 +4548,275 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@motionone/animation": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.18.0.tgz", @@ -12631,6 +12927,59 @@ "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -13873,6 +14222,23 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -15856,6 +16222,27 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -15952,6 +16339,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -16305,6 +16711,27 @@ "node": ">=0.10.0" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-replace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz", @@ -18129,7 +18556,6 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -18520,6 +18946,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -19845,6 +20277,19 @@ "dev": true, "license": "MIT" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -20927,6 +21372,35 @@ "node": ">= 4.4.x" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -22231,6 +22705,15 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", @@ -23786,6 +24269,37 @@ "node": ">= 0.6" } }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -24405,6 +24919,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -27350,6 +27890,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typechain": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/typechain/-/typechain-8.3.2.tgz", @@ -31123,6 +31719,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", diff --git a/package.json b/package.json index e94ce4b9..9a683d96 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "alignment-trust-bootstrap", "fake-data-generation", "cause-assist", + "mcp", "ui", "causestarter" ], @@ -45,6 +46,9 @@ "cause-assist:start": "npm run start --workspace=@commonality/cause-assist", "cause-assist:test": "npm run test --workspace=@commonality/cause-assist", "cause-assist:typecheck": "npm run typecheck --workspace=@commonality/cause-assist", + "mcp": "npm run mcp --workspace=@commonality/mcp", + "mcp:test": "npm run test --workspace=@commonality/mcp", + "mcp:typecheck": "npm run typecheck --workspace=@commonality/mcp", "coherence-badge-worker:test": "npm run test --workspace=@commonality/coherence-badge-worker", "alignment-trust-bootstrap:test": "npm run test --workspace=@commonality/alignment-trust-bootstrap", "causestarter:typecheck": "npm run typecheck --workspace=ui", diff --git a/scripts/check-docs-inventory.mjs b/scripts/check-docs-inventory.mjs index aaa2b7a0..a433c5f1 100644 --- a/scripts/check-docs-inventory.mjs +++ b/scripts/check-docs-inventory.mjs @@ -123,6 +123,7 @@ function assertDocumentedPackagePathsExist() { 'ui/README.md', 'indexer/README.md', 'integration-tests/README.md', + 'mcp/README.md', ] for (const file of durablePackageDocs) { diff --git a/specs/user-docs.md b/specs/user-docs.md index e5dd94c0..b074b188 100644 --- a/specs/user-docs.md +++ b/specs/user-docs.md @@ -29,6 +29,6 @@ Write the docs for humans (narrative, plain language). Then add a block to the d **User-facing docs live in:** - Role-based how-tos live on the site where the role is actually performed (e.g. `lazyGiving/get-your-project-funded.md`, `alignment/become-a-delegate.md`, `tally/express-what-you-care-about.md`). The cross-ecosystem index is in [docs/end-user/commonality/index.md](/docs/end-user/commonality/index.md) under "What can I do across the ecosystem?". Each role doc ends with an "On other sites" footer pointing at the cross-site connections. -- CauseStarter’s in-app docs (`ui/src/causestarter` `/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/`. The spoken briefing is [causestarter/index.md](/docs/end-user/causestarter/index.md) (bulletin board + two twists; the rest is objections). The jobs catalog is [the-jobs.md](/docs/end-user/causestarter/the-jobs.md). +- CauseStarter’s in-app docs (`ui/src/causestarter` `/docs/*`) bundle `docs/end-user/causestarter/`, `shared/`, and `commonality/`. The spoken briefing is [causestarter/index.md](/docs/end-user/causestarter/index.md) (bulletin board + two twists; the rest is objections). The jobs catalog is [the-jobs.md](/docs/end-user/causestarter/the-jobs.md). The agent/protocol index is [for-llms.md](/docs/end-user/causestarter/for-llms.md) (SDK + IPFS + attester HTTP + MCP); generated TypeDoc/contract docs are `/api-docs/sdk/` and `/api-docs/contracts/`. - [docs/end-user/shared/use-case-walkthroughs/](/docs/end-user/shared/use-case-walkthroughs/README.md) — concrete scenarios - [docs/end-user/shared/key-ideas/](/docs/end-user/shared/key-ideas/README.md) — concept reference pages diff --git a/tsconfig.json b/tsconfig.json index 632ccd6a..d605a3c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "integration-tests/src/**/*.ts", "ui/src/**/*.ts", "ui/src/**/*.tsx", - "cause-assist/src/**/*.ts" + "cause-assist/src/**/*.ts", + "mcp/src/**/*.ts" ], "exclude": ["node_modules", "dist", ".turbo"] } diff --git a/ui/src/causestarter/pages/DocsPage.test.tsx b/ui/src/causestarter/pages/DocsPage.test.tsx index ba1c97ed..3babace3 100644 --- a/ui/src/causestarter/pages/DocsPage.test.tsx +++ b/ui/src/causestarter/pages/DocsPage.test.tsx @@ -32,6 +32,17 @@ describe('DocsPage', () => { 'href', '/docs/faq', ) + expect(screen.getByRole('link', { name: /CauseStarter API map \(for LLMs\)/i })).toHaveAttribute( + 'href', + '/docs/for-llms', + ) + expect(screen.getByRole('link', { name: /SDK API docs/i })).toHaveAttribute('href', '/api-docs/sdk/') + }) + + it('renders the LLM API map', () => { + renderDocs('/docs/for-llms') + expect(screen.getByRole('heading', { name: /CauseStarter API map/i })).toBeInTheDocument() + expect(screen.getByText(/fetchFromIPFS/i)).toBeInTheDocument() }) it('renders the FAQ', () => { diff --git a/ui/src/causestarter/pages/DocsPage.tsx b/ui/src/causestarter/pages/DocsPage.tsx index 9287269f..8ddaeb74 100644 --- a/ui/src/causestarter/pages/DocsPage.tsx +++ b/ui/src/causestarter/pages/DocsPage.tsx @@ -167,9 +167,9 @@ export function DocsPage() { ), a: ({ href, children }) => { const resolved = href ? resolveHref(href, pathForRelativeLinks) : '#' - if (/^https?:\/\//.test(resolved)) { + if (/^https?:\/\//.test(resolved) || resolved.startsWith('/api-docs')) { return ( - + {children} ) diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f00ef8ef..95ae70d3 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,4 +1,5 @@ -import { mkdirSync, writeFileSync } from 'node:fs' +import { cpSync, createReadStream, existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import type { IncomingMessage, ServerResponse } from 'node:http' import path from 'node:path' import { defineConfig, loadEnv, type Plugin } from 'vite' import react from '@vitejs/plugin-react' @@ -18,7 +19,7 @@ export default defineConfig(({ mode }) => { build: { outDir: `dist/${domain}`, }, - plugins: [react(), htmlTitlePlugin(domain), runtimeConfigPlugin(domain, env), endUserDocsPlugin({ domain })], + plugins: [react(), htmlTitlePlugin(domain), runtimeConfigPlugin(domain, env), endUserDocsPlugin({ domain }), apiDocsStaticPlugin(domain)], worker: { format: 'es', }, @@ -115,6 +116,64 @@ const DOMAIN_TITLES: Record = { causestarter: 'CauseStarter', } +function sendStaticFile(root: string, urlPath: string, res: ServerResponse, next: () => void) { + const rel = decodeURIComponent((urlPath.split('?')[0] ?? '/')).replace(/\\/g, '/') + const candidate = path.resolve(root, rel === '/' ? 'index.html' : rel.replace(/^\//, '')) + const relative = path.relative(root, candidate) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + next() + return + } + let file = candidate + if (existsSync(file) && statSync(file).isDirectory()) { + file = path.join(file, 'index.html') + } + if (!existsSync(file) || !statSync(file).isFile()) { + next() + return + } + const ext = path.extname(file) + const types: Record = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/markdown; charset=utf-8', + '.svg': 'image/svg+xml', + } + res.setHeader('Content-Type', types[ext] ?? 'application/octet-stream') + createReadStream(file).pipe(res) +} + +function apiDocsStaticPlugin(buildDomain: string): Plugin { + const sdkDocs = path.resolve(process.cwd(), '../sdk/docs/api') + const hardhatDocs = path.resolve(process.cwd(), '../hardhat/docs') + const mount = (root: string) => + (req: IncomingMessage, res: ServerResponse, next: () => void) => { + if (!existsSync(root)) { + next() + return + } + sendStaticFile(root, req.url ?? '/', res, next) + } + return { + name: 'commonality-api-docs-static', + configureServer(server) { + server.middlewares.use('/api-docs/sdk', mount(sdkDocs)) + server.middlewares.use('/api-docs/contracts', mount(hardhatDocs)) + }, + closeBundle() { + const outDir = path.resolve(process.cwd(), 'dist', buildDomain, 'api-docs') + if (existsSync(sdkDocs)) { + cpSync(sdkDocs, path.join(outDir, 'sdk'), { recursive: true }) + } + if (existsSync(hardhatDocs)) { + cpSync(hardhatDocs, path.join(outDir, 'contracts'), { recursive: true }) + } + }, + } +} + function htmlTitlePlugin(buildDomain: string): Plugin { const title = DOMAIN_TITLES[buildDomain] ?? 'Commonality' return {